Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ dist
__pycache__
package-lock.json
.parcel-cache
.pnpm-store/
pnpm-lock.yaml
.pytest_cache/
*.old

data/*.db*
Expand Down
1 change: 1 addition & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ ENV DATA_PATH=/data
ENV JETLOG_PORT=3000
ENV TOKEN_DURATION=7
ENV ENABLE_EXTERNAL_APIS=true
ENV DEFAULT_LANGUAGE=en
ENV USE_IPV6=false

RUN mkdir -p ${APP_PATH}
Expand Down
9 changes: 5 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,10 +40,11 @@ services:
image: pbogre/jetlog:latest
volumes:
- /your/data/path:/data
environment:
JETLOG_PORT: 3000 # optional, default is 3000
SECRET_KEY: yourLongAndRandomStringOfCharacters123!
restart: unless-stopped
environment:
JETLOG_PORT: 3000 # optional, default is 3000
SECRET_KEY: yourLongAndRandomStringOfCharacters123!
DEFAULT_LANGUAGE: en # optional default UI language: en, zh-CN, zh-TW, ja, ko, fr, de, es
restart: unless-stopped
ports:
- 3000:3000
```
Expand Down
7 changes: 6 additions & 1 deletion client/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,15 @@ import TokenStorage from './storage/tokenStorage';
import { showToast } from './components/Toast';

const config = await fetch('./config').then((response) => response.json())
.catch(() => ({ BASE_URL: '/', ENABLE_EXTERNAL_APIS: true }));
.catch(() => ({
BASE_URL: '/',
ENABLE_EXTERNAL_APIS: true,
DEFAULT_LANGUAGE: undefined,
}));

export const BASE_URL = config.BASE_URL == '/' ? '' : config.BASE_URL;
export const ENABLE_EXTERNAL_APIS = config.ENABLE_EXTERNAL_APIS;
export const DEFAULT_LANGUAGE: string | undefined = config.DEFAULT_LANGUAGE ?? undefined;

// TODO improve this because there's a lot of repetition (get, post, delete are pretty much exactly the same)
// perhaps one method for each endpoint? i.e. API.getFlights(), ...
Expand Down
5 changes: 4 additions & 1 deletion client/components/Toast.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import React, { createContext, useContext, useState, useCallback, useEffect } from 'react';
import { useTranslation } from 'react-i18next';

export type ToastType = 'success' | 'error' | 'info';

Expand Down Expand Up @@ -38,6 +39,8 @@ interface ToastItemProps {
}

function ToastItem({ toast, onRemove }: ToastItemProps) {
const { t } = useTranslation();

useEffect(() => {
const timer = setTimeout(() => {
onRemove(toast.id);
Expand Down Expand Up @@ -67,7 +70,7 @@ function ToastItem({ toast, onRemove }: ToastItemProps) {
<button
onClick={() => onRemove(toast.id)}
className="text-white hover:text-gray-200 font-bold text-lg leading-none ml-2"
aria-label="Close"
aria-label={t('common.close')}
>
&times;
</button>
Expand Down
109 changes: 60 additions & 49 deletions client/components/flights/FlightDetail.tsx

Large diffs are not rendered by default.

30 changes: 17 additions & 13 deletions client/components/flights/FlightFilters.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { useState } from 'react'
import { Filter, X } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { Sheet } from '@/components/ui/Sheet'
import { Button } from '@/components/ui/Button'
import { Input } from '@/components/ui/Input'
Expand All @@ -19,6 +20,7 @@ export function FlightFiltersBar({ filters, onChange }: FlightFiltersProps) {
const [open, setOpen] = useState(false)
const { data: usernames } = useUsernames()
const [draft, setDraft] = useState<FlightsFilters>(filters)
const { t } = useTranslation()

const activeCount = ACTIVE_KEYS.filter((k) => filters[k] !== undefined && filters[k] !== '').length

Expand All @@ -44,24 +46,24 @@ export function FlightFiltersBar({ filters, onChange }: FlightFiltersProps) {
<div className="flex items-center gap-2 flex-wrap">
{filters.start && (
<FilterChip
label={`From ${filters.start}`}
label={t('allFlights.from', { date: filters.start })}
onClear={() => onChange({ ...filters, start: undefined })}
/>
)}
{filters.end && (
<FilterChip
label={`To ${filters.end}`}
label={t('allFlights.to', { date: filters.end })}
onClear={() => onChange({ ...filters, end: undefined })}
/>
)}
{filters.username && (
<FilterChip
label={`User ${filters.username}`}
label={t('allFlights.userFilter', { username: filters.username })}
onClear={() => onChange({ ...filters, username: undefined })}
/>
)}
{activeCount === 0 && (
<span className="board-label text-ink-muted">All flights</span>
<span className="board-label text-ink-muted">{t('allFlights.title')}</span>
)}
</div>
<Button
Expand All @@ -73,7 +75,7 @@ export function FlightFiltersBar({ filters, onChange }: FlightFiltersProps) {
}}
>
<Filter size={13} />
Filters
{t('allFlights.filters')}
{activeCount > 0 && (
<span className="ml-1 bg-accent text-ink px-1.5 text-[10px] font-bold">
{activeCount}
Expand All @@ -82,33 +84,33 @@ export function FlightFiltersBar({ filters, onChange }: FlightFiltersProps) {
</Button>
</div>

<Sheet open={open} onOpenChange={setOpen} title="Filters">
<Sheet open={open} onOpenChange={setOpen} title={t('allFlights.filters')}>
<div className="space-y-4">
<div>
<Label>Start date</Label>
<Label>{t('allFlights.startDate')}</Label>
<Input
type="date"
value={draft.start ?? ''}
onChange={(e) => setDraft({ ...draft, start: e.target.value })}
/>
</div>
<div>
<Label>End date</Label>
<Label>{t('allFlights.endDate')}</Label>
<Input
type="date"
value={draft.end ?? ''}
onChange={(e) => setDraft({ ...draft, end: e.target.value })}
/>
</div>
<div>
<Label>User</Label>
<Label>{t('allFlights.user')}</Label>
<Select
value={draft.username ?? ''}
onChange={(e) =>
setDraft({ ...draft, username: e.target.value || undefined })
}
>
<option value="">Any user</option>
<option value="">{t('allFlights.anyUser')}</option>
{usernames?.map((u) => (
<option key={u} value={u}>
{u}
Expand All @@ -119,10 +121,10 @@ export function FlightFiltersBar({ filters, onChange }: FlightFiltersProps) {

<div className="flex gap-2 pt-4 border-t border-rule">
<Button variant="outline" onClick={clear} className="flex-1">
Clear all
{t('allFlights.clearAll')}
</Button>
<Button variant="accent" onClick={apply} className="flex-1">
Apply
{t('allFlights.apply')}
</Button>
</div>
</div>
Expand All @@ -132,12 +134,14 @@ export function FlightFiltersBar({ filters, onChange }: FlightFiltersProps) {
}

function FilterChip({ label, onClear }: { label: string; onClear: () => void }) {
const { t } = useTranslation()

return (
<Badge variant="accent" className="gap-1.5 pl-2 pr-1 py-1">
{label}
<button
onClick={onClear}
aria-label="Remove filter"
aria-label={t('allFlights.removeFilter')}
className="hover:text-danger"
>
<X size={11} />
Expand Down
6 changes: 4 additions & 2 deletions client/components/flights/FlightsTable.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { useMemo } from 'react'
import { useNavigate } from 'react-router-dom'
import { ArrowRight, ArrowUpDown, ArrowUp, ArrowDown } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import type { SortingState } from '@tanstack/react-table'

import type { Flight } from '@/models'
Expand Down Expand Up @@ -33,6 +34,7 @@ export function FlightsTable({
columnPrefs,
}: FlightsTableProps) {
const navigate = useNavigate()
const { t } = useTranslation()

const visibleColumns = useMemo<FlightColumnDef[]>(() => {
// Use prefs order, fall back to registry order for unknowns.
Expand Down Expand Up @@ -62,7 +64,7 @@ export function FlightsTable({
if (!flights || flights.length === 0) {
return (
<div className="text-center text-ink-muted text-sm font-mono py-16">
No flights found.
{t('allFlights.noFlightsFound')}
</div>
)
}
Expand Down Expand Up @@ -98,7 +100,7 @@ export function FlightsTable({
col.numeric && 'justify-end w-full',
)}
>
{col.label}
{col.label(t)}
{sortable && sortedDesc === undefined && (
<ArrowUpDown size={11} className="opacity-40" />
)}
Expand Down
22 changes: 13 additions & 9 deletions client/components/settings/ColumnsManager.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import {
DndContext,
closestCenter,
Expand Down Expand Up @@ -35,6 +36,7 @@ function emitChanged() {

export function ColumnsManager() {
const [prefs, setPrefs] = useState<ColumnPref[]>(() => loadColumnPrefs())
const { t } = useTranslation()

const update = (next: ColumnPref[]) => {
setPrefs(next)
Expand Down Expand Up @@ -85,15 +87,14 @@ export function ColumnsManager() {
<div className="flex items-center justify-between gap-3 flex-wrap">
<div>
<p className="board-label text-ink-muted">
{visibleCount} of {prefs.length} columns visible
{t('columns.visibleOf', { visible: visibleCount, total: prefs.length })}
</p>
<p className="text-xs text-ink-muted font-mono mt-0.5">
Drag rows to reorder · toggle to show/hide · origin and
destination are required.
{t('columns.hint')}
</p>
</div>
<Button variant="outline" size="sm" onClick={resetAll}>
<RotateCcw size={13} /> Reset
<RotateCcw size={13} /> {t('common.reset')}
</Button>
</div>

Expand Down Expand Up @@ -127,6 +128,7 @@ interface ColumnRowProps {
}

function ColumnRow({ pref, onToggle }: ColumnRowProps) {
const { t } = useTranslation()
const def = COLUMN_INDEX[pref.id]
const {
attributes,
Expand Down Expand Up @@ -155,25 +157,25 @@ function ColumnRow({ pref, onToggle }: ColumnRowProps) {
type="button"
{...attributes}
{...listeners}
aria-label="Drag to reorder"
aria-label={t('columns.dragToReorder')}
className="cursor-grab active:cursor-grabbing text-ink-faint hover:text-ink touch-none"
>
<GripVertical size={16} />
</button>
<div className="flex-1 min-w-0">
<div className="font-mono text-sm text-ink truncate">
{def?.label ?? pref.id}
{def?.label ? def.label(t) : pref.id}
</div>
<div className="text-[10px] font-mono uppercase tracking-board text-ink-faint">
{pref.id}
</div>
</div>
{required ? (
<span
title="Required column"
title={t('columns.requiredColumn')}
className="flex items-center gap-1 board-label text-ink-muted"
>
<Lock size={12} /> Required
<Lock size={12} /> {t('columns.required')}
</span>
) : pref.visible ? (
<Eye size={14} className="text-ink-muted" />
Expand All @@ -184,7 +186,9 @@ function ColumnRow({ pref, onToggle }: ColumnRowProps) {
checked={pref.visible}
onCheckedChange={onToggle}
disabled={required}
aria-label={`Show ${def?.label ?? pref.id}`}
aria-label={t('columns.showColumn', {
label: def?.label ? def.label(t) : pref.id,
})}
/>
</div>
)
Expand Down
38 changes: 21 additions & 17 deletions client/components/shell/AppShell.tsx
Original file line number Diff line number Diff line change
@@ -1,28 +1,31 @@
import { useState } from 'react'
import { Link, Outlet, useLocation } from 'react-router-dom'
import { Menu } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { NavSheet } from './NavSheet'
import { BoardClock } from './BoardClock'

const TITLES: Record<string, string> = {
'/': 'Dashboard',
'/flights': 'Flights',
'/new': 'New Flight',
'/statistics': 'Statistics',
'/settings': 'Settings',
}

function pageTitle(pathname: string) {
if (TITLES[pathname]) return TITLES[pathname]
const match = Object.keys(TITLES)
.filter((p) => p !== '/')
.find((p) => pathname.startsWith(p))
return match ? TITLES[match] : 'Jetlog'
}
import { LanguageSwitcher } from './LanguageSwitcher'

export function AppShell() {
const [menuOpen, setMenuOpen] = useState(false)
const location = useLocation()
const { t } = useTranslation()

const titles: Record<string, string> = {
'/': t('nav.dashboard'),
'/flights': t('nav.flights'),
'/new': t('nav.newFlight'),
'/statistics': t('nav.statistics'),
'/settings': t('nav.settings'),
}

const pageTitle = (pathname: string) => {
if (titles[pathname]) return titles[pathname]
const match = Object.keys(titles)
.filter((p) => p !== '/')
.find((p) => pathname.startsWith(p))
return match ? titles[match] : 'Jetlog'
}

return (
<div className="min-h-full flex flex-col">
Expand All @@ -42,10 +45,11 @@ export function AppShell() {
</div>

<div className="flex items-center gap-4">
<LanguageSwitcher />
<BoardClock />
<button
onClick={() => setMenuOpen(true)}
aria-label="Open menu"
aria-label={t('common.openMenu')}
className="p-1.5 -mr-1.5 text-ink hover:text-accent-deep"
>
<Menu size={22} strokeWidth={1.75} />
Expand Down
4 changes: 3 additions & 1 deletion client/components/shell/BoardClock.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'

export function BoardClock() {
const [now, setNow] = useState(() => new Date())
const { t } = useTranslation()

useEffect(() => {
const t = window.setInterval(() => setNow(new Date()), 1000)
Expand All @@ -14,7 +16,7 @@ export function BoardClock() {

return (
<div className="hidden md:flex items-baseline gap-2">
<span className="board-label">UTC</span>
<span className="board-label">{t('common.utc')}</span>
<span className="board-value text-sm tracking-board">
{hh}:{mm}
<span className="text-ink-faint">:{ss}</span>
Expand Down
Loading