diff --git a/client/public/brands/airtrail.svg b/client/public/brands/airtrail.svg new file mode 100644 index 000000000..6349a0370 --- /dev/null +++ b/client/public/brands/airtrail.svg @@ -0,0 +1,11 @@ + + + + + + + + + \ No newline at end of file diff --git a/client/public/brands/dawarich.svg b/client/public/brands/dawarich.svg new file mode 100644 index 000000000..cfe2843ee --- /dev/null +++ b/client/public/brands/dawarich.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/client/src/api/dawarich.ts b/client/src/api/dawarich.ts new file mode 100644 index 000000000..d2c3ff295 --- /dev/null +++ b/client/src/api/dawarich.ts @@ -0,0 +1,83 @@ +import apiClient from './client' +import type { + DawarichAccept, + DawarichAcceptResult, + DawarichAtlasSuggestions, + DawarichBucketScan, + DawarichConnection, + DawarichSettings, + DawarichStatus, + DawarichSuggestion, + DawarichSuggestionList, + DawarichTrack, +} from '@trek/shared' + +/** + * The Dawarich surface, in its own file rather than in the `client.ts` god + * module. Types come from the shared contracts, so a server change that the + * client has not caught up with is a typecheck failure rather than a runtime + * surprise. + */ +export const dawarichApi = { + // ── Connection ───────────────────────────────────────────────────────────── + getSettings: (): Promise => + apiClient.get('/integrations/dawarich/settings').then(r => r.data), + saveSettings: (data: DawarichSettings): Promise<{ + success: boolean + /** The server's own sentence, English — the fallback when the code below is unknown here. */ + warning?: string + /** What the warning is about, so the UI can say it in the reader's language. */ + warningCode?: string + /** The address it resolved to, for the private-IP warning. */ + warningIp?: string + }> => + apiClient.put('/integrations/dawarich/settings', data).then(r => r.data), + disconnect: (): Promise<{ success: boolean }> => + apiClient.delete('/integrations/dawarich/settings').then(r => r.data), + test: (data: Partial): Promise => + apiClient.post('/integrations/dawarich/test', data).then(r => r.data), + syncNow: (): Promise<{ state: string; created: number; updated: number; missing: number; alreadyRunning?: boolean }> => + apiClient.post('/integrations/dawarich/sync').then(r => r.data), + + // ── Suggestions ──────────────────────────────────────────────────────────── + listSuggestions: (params?: { tripId?: number; state?: string }): Promise => + apiClient.get('/integrations/dawarich/suggestions', { params }).then(r => r.data), + accept: (id: number, body: DawarichAccept): Promise => + apiClient.post(`/integrations/dawarich/suggestions/${id}/accept`, body).then(r => r.data), + setState: (id: number, state: 'new' | 'dismissed'): Promise => + apiClient.put(`/integrations/dawarich/suggestions/${id}/state`, { state }).then(r => r.data), + + // ── Bucket list ──────────────────────────────────────────────────────────── + scanBucketList: (): Promise => + apiClient.post('/integrations/dawarich/bucket-list/scan').then(r => r.data), + confirmBucketVisits: (itemIds: number[], visitedAt?: string): Promise<{ updated: number }> => + apiClient.post('/integrations/dawarich/bucket-list/confirm', { itemIds, visitedAt }).then(r => r.data), + clearBucketVisit: (itemId: number): Promise<{ success: boolean }> => + apiClient.delete(`/integrations/dawarich/bucket-list/${itemId}/visit`).then(r => r.data), + + // ── Atlas ────────────────────────────────────────────────────────────────── + atlasSuggestions: (from: string, to: string): Promise => + apiClient.get('/integrations/dawarich/atlas/suggestions', { params: { from, to } }).then(r => r.data), + acceptAtlasCountries: (countryCodes: string[]): Promise<{ marked: number }> => + apiClient.post('/integrations/dawarich/atlas/accept', { countryCodes }).then(r => r.data), + + // ── Track overlay ────────────────────────────────────────────────────────── + // + // Recorded points are bare instants, so the server cannot know which day they + // belong to without being told whose day is meant. The browser is the only + // party that knows, so it says so — otherwise an evening east of UTC lands on + // the wrong day of the trip. + tripTrack: (tripId: number, params?: { from?: string; to?: string }, signal?: AbortSignal): Promise => + apiClient + .get(`/integrations/dawarich/trips/${tripId}/track`, { params: { ...params, offset: localOffsetMinutes() }, signal }) + .then(r => r.data), + windowTrack: (from: string, to: string, signal?: AbortSignal): Promise => + apiClient + .get('/integrations/dawarich/track', { params: { from, to, offset: localOffsetMinutes() }, signal }) + .then(r => r.data), +} + +/** The reader's own UTC offset in minutes — +120 for Berlin in summer. */ +function localOffsetMinutes(): number { + return -new Date().getTimezoneOffset() +} diff --git a/client/src/components/Admin/AddonManager.tsx b/client/src/components/Admin/AddonManager.tsx index c9b041a88..2deda0596 100644 --- a/client/src/components/Admin/AddonManager.tsx +++ b/client/src/components/Admin/AddonManager.tsx @@ -7,6 +7,8 @@ import { useToast } from '../shared/Toast' import { Puzzle, ListChecks, Wallet, FileText, CalendarDays, Globe, Briefcase, Image, Terminal, Link2, Compass, BookOpen, MessageCircle, StickyNote, BarChart3, Sparkles, Luggage, Plane, Server, Cloud, Bookmark, Users, Loader2 } from 'lucide-react' import CustomSelect from '../shared/CustomSelect' import EmptyState from '../shared/EmptyState' +import DawarichIcon from '../shared/DawarichIcon' +import AirTrailIcon from '../shared/AirTrailIcon' import AddonTile from './AddonTile' import AddonSubRow from './AddonSubRow' @@ -15,6 +17,9 @@ import AddonSubRow from './AddonSubRow' // used to land on the fallback. const ICON_MAP = { ListChecks, Wallet, FileText, CalendarDays, Puzzle, Globe, Briefcase, Image, Terminal, Link2, Compass, BookOpen, Plane, Bookmark, Users, Sparkles, + // Dawarich ships a brand mark rather than a lucide name, the same way the photo + // providers below do. Keyed by the string in the addons table. + Dawarich: DawarichIcon, } function ImmichIcon({ size = 14 }: { size?: number }) { @@ -59,9 +64,20 @@ interface ProviderOption { interface AddonIconProps { name: string size?: number + /** A switched-off addon greys its icon out, brand marks included. */ + enabled?: boolean } -function AddonIcon({ name, size = 20 }: AddonIconProps) { +function AddonIcon({ name, size = 20, enabled = true }: AddonIconProps) { + // The brand marks are badges, not glyphs: they fill the framed slot instead of + // sitting in the middle of it at glyph size, and they grey out when the addon + // is off — the lucide glyphs get that from the slot's text colour, an + // has to be told. + if (name === 'Dawarich') return + // 'Plane' is the icon string airtrail was seeded with, and INSERT OR IGNORE + // means every existing install still carries it — so the brand is keyed on + // that rather than on a new name no row would ever have. + if (name === 'Plane') return const Icon = ICON_MAP[name] || Puzzle return } @@ -212,7 +228,7 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking, return ( } + icon={} name={label.name} description={label.description} enabled={addon.enabled} diff --git a/client/src/components/Admin/AddonTile.tsx b/client/src/components/Admin/AddonTile.tsx index 99e68f1c6..10760615a 100644 --- a/client/src/components/Admin/AddonTile.tsx +++ b/client/src/components/Admin/AddonTile.tsx @@ -44,7 +44,9 @@ export default function AddonTile({ >
diff --git a/client/src/components/Atlas/useGlassGlare.ts b/client/src/components/Atlas/useGlassGlare.ts new file mode 100644 index 000000000..540401de7 --- /dev/null +++ b/client/src/components/Atlas/useGlassGlare.ts @@ -0,0 +1,48 @@ +import { useRef, type RefObject } from 'react' + +export interface GlassGlare { + /** Goes on the panel itself — the glare is measured against its box. */ + panelRef: RefObject + /** The soft light under the cursor. */ + glareRef: RefObject + /** The lit stretch of border around it. */ + borderGlareRef: RefObject + onMouseMove: (e: { clientX: number; clientY: number }) => void + onMouseLeave: () => void +} + +/** + * The Atlas panels' liquid-glass hover: a pool of light under the cursor and the + * piece of border nearest to it lighting up. + * + * Extracted so a second panel beside the first one behaves identically. Two + * hand-copied versions of a gradient are two versions that drift, and the whole + * point of the effect is that the panels look like one material. + * + * Written straight to the DOM rather than through state: this runs on every + * mousemove, and a re-render per frame would cost more than the effect is worth. + */ +export function useGlassGlare(dark: boolean): GlassGlare { + const panelRef = useRef(null) + const glareRef = useRef(null) + const borderGlareRef = useRef(null) + + const onMouseMove = (e: { clientX: number; clientY: number }): void => { + if (!panelRef.current || !glareRef.current || !borderGlareRef.current) return + const rect = panelRef.current.getBoundingClientRect() + const x = e.clientX - rect.left + const y = e.clientY - rect.top + glareRef.current.style.background = `radial-gradient(circle 300px at ${x}px ${y}px, ${dark ? 'rgba(255,255,255,0.025)' : 'rgba(255,255,255,0.25)'} 0%, transparent 70%)` + glareRef.current.style.opacity = '1' + borderGlareRef.current.style.opacity = '1' + borderGlareRef.current.style.maskImage = `radial-gradient(circle 150px at ${x}px ${y}px, black 0%, transparent 100%)` + borderGlareRef.current.style.webkitMaskImage = `radial-gradient(circle 150px at ${x}px ${y}px, black 0%, transparent 100%)` + } + + const onMouseLeave = (): void => { + if (glareRef.current) glareRef.current.style.opacity = '0' + if (borderGlareRef.current) borderGlareRef.current.style.opacity = '0' + } + + return { panelRef, glareRef, borderGlareRef, onMouseMove, onMouseLeave } +} diff --git a/client/src/components/Dawarich/DawarichAcceptDialog.tsx b/client/src/components/Dawarich/DawarichAcceptDialog.tsx new file mode 100644 index 000000000..9faeca089 --- /dev/null +++ b/client/src/components/Dawarich/DawarichAcceptDialog.tsx @@ -0,0 +1,387 @@ +import React, { useMemo, useState } from 'react' +import { Gauge, Hourglass, CircleDashed, Star } from 'lucide-react' +import type { DawarichAccept, DawarichSuggestion, DawarichSuggestionTarget } from '@trek/shared' +import { DAWARICH_BUCKET_MATCH_MIN_MINUTES, DAWARICH_BUCKET_MATCH_RADIUS_M } from '@trek/shared' +import { useTranslation } from '../../i18n' +import Modal from '../shared/Modal' +import MSheet from '../../mobile/components/MSheet' +import { FormSheetFooter, FormSheetHeader } from '../../mobile/screens/trip/sheets/PlSheetChrome' +import { useIsPhone } from '../../mobile/useIsPhone' +import DawarichIcon from '../shared/DawarichIcon' +import DawarichBadge from './DawarichBadge' +import { CustomDatePicker } from '../shared/CustomDateTimePicker' +import CustomTimePicker from '../shared/CustomTimePicker' +import CustomSelect from '../shared/CustomSelect' + +export interface AcceptTargetOption { + id: number + label: string + /** Secondary half of the label — a day's date beside its number. */ + badge?: string +} + +export interface DawarichAcceptDialogProps { + suggestion: DawarichSuggestion | null + target: DawarichSuggestionTarget + /** Trips the user may write to, for the `place` target. */ + trips?: AcceptTargetOption[] + /** Days of the chosen trip, resolved by the caller. */ + daysForTrip?: (tripId: number) => AcceptTargetOption[] + /** Journeys the user may edit, for the `journal` target. */ + journals?: AcceptTargetOption[] + busy?: boolean + onCancel: () => void + onConfirm: (body: DawarichAccept) => void +} + +/** + * The review step: see what was recorded, correct it, then decide where it goes. + * + * The whole reason the integration is not a one-click import. A visit detector + * is right most of the time and confidently wrong the rest, and the difference + * between the two is a judgement only the traveller can make — so the values + * are prefilled from the recording and every one of them is editable before + * anything is written. + * + * What it does NOT do is offer to edit the recording. Dawarich owns that, and + * a correction made here would be lost the next time the detector ran. + * + * Three targets, because `DawarichSuggestionTarget` has three. The trip and + * journal rails open the first two; `bucket_list` is reached through MCP, where + * an assistant can accept a stay against a wish. The branch is kept for that and + * for the day an Atlas-side entry point wants it — the server has always been + * able to do it, and a dialog that could only handle two of three targets would + * be the thing that had to be fixed then. + */ +export default function DawarichAcceptDialog({ + suggestion, + target, + trips = [], + daysForTrip, + journals = [], + busy = false, + onCancel, + onConfirm, +}: DawarichAcceptDialogProps): React.ReactElement | null { + const { t, locale } = useTranslation() + const phone = useIsPhone() + + const [name, setName] = useState('') + const [notes, setNotes] = useState('') + const [date, setDate] = useState('') + const [time, setTime] = useState('') + const [endTime, setEndTime] = useState('') + const [tripId, setTripId] = useState('') + const [dayId, setDayId] = useState('') + const [journalId, setJournalId] = useState('') + // Re-seeded whenever a different suggestion opens the dialog, so the fields + // always describe the row the user just pressed rather than the previous one. + const [seededFor, setSeededFor] = useState(null) + + if (suggestion && seededFor !== suggestion.id) { + setSeededFor(suggestion.id) + setName(suggestion.name) + setNotes('') + setDate(suggestion.localDate) + setTime(clockOf(suggestion.startedAt)) + setEndTime(clockOf(suggestion.endedAt)) + setTripId(suggestion.tripId ?? (trips[0]?.id ?? '')) + setDayId('') + setJournalId(journals[0]?.id ?? '') + } + + const days = useMemo( + () => (typeof tripId === 'number' && daysForTrip ? daysForTrip(tripId) : []), + [tripId, daysForTrip], + ) + + if (!suggestion) return null + + const missingTarget = + (target === 'place' && typeof tripId !== 'number') || + (target === 'journal' && typeof journalId !== 'number') || + (target === 'bucket_list' && suggestion.matchedBucketListItemId === null) + + const confirm = () => { + const body: DawarichAccept = { target } + if (target === 'place') { + if (typeof tripId === 'number') body.tripId = tripId + if (typeof dayId === 'number') body.dayId = dayId + } + if (target === 'journal' && typeof journalId === 'number') body.journalId = journalId + if (target === 'bucket_list' && suggestion.matchedBucketListItemId !== null) { + body.bucketListItemId = suggestion.matchedBucketListItemId + } + if (name.trim() && name.trim() !== suggestion.name) body.name = name.trim() + if (notes.trim()) body.notes = notes.trim() + if (date && date !== suggestion.localDate) body.date = date + if (time) body.time = time + if (endTime) body.endTime = endTime + onConfirm(body) + } + + // The same fields in both shells — only the frame around them differs. + const fields = ( +
+ {/* What was recorded, before anything the user changed. Kept visible so a + corrected field can be compared against the source rather than + replacing it invisibly. */} + {/* What Dawarich recorded, as facts rather than as a sentence with + separators in it: a line for when, and a row of the same badges the + review list uses so the dialog and the list read as one thing. */} +
+
+ + + +

+ {t('dawarich.accept.recorded', { + from: new Date(suggestion.startedAt).toLocaleString(locale), + to: new Date(suggestion.endedAt).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit' }), + })} +

+
+
+ + {t('dawarich.accept.duration', { minutes: suggestion.durationMinutes })} + + {/* The source's own verdict, shown here rather than on every row of + the list: since Dawarich 1.12.0 "unconfirmed" is the normal state + of a visit, so it says nothing in a list and something here, where + somebody is deciding whether to trust this one. */} + {suggestion.sourceStatus === 'suggested' && ( + {t('dawarich.sourceStatus.suggested')} + )} + {KNOWN_BANDS.includes(suggestion.confidenceBand ?? '') && ( + + {t('dawarich.confidence.' + suggestion.confidenceBand)} + + )} +
+
+ + {/* Why this stay is being offered against a wish at all. The row that + led here shows the wish's name and nothing else, which leaves the + reader to guess what a star beside a café has to do with a museum — + it is the rule, and the rule is short enough to state. */} + {target === 'bucket_list' && suggestion.matchedBucketListName && ( +

+ + + {t('dawarich.suggestions.matchesWish', { name: suggestion.matchedBucketListName })} + {' — '} + {t('dawarich.bucket.rule', { + meters: DAWARICH_BUCKET_MATCH_RADIUS_M, + minutes: DAWARICH_BUCKET_MATCH_MIN_MINUTES, + })} + +

+ )} + +
+ + setName(e.target.value)} + className="w-full px-3 py-2.5 border rounded-lg text-body focus:outline-none focus:ring-2 ring-accent border-edge bg-surface-input text-content" + /> +
+ + {/* TREK's own pickers, not the browser's. A native `type="date"` paints + itself from the OS locale and a native `type="time"` ignores the 12h/24h + setting outright (#2067) — and neither follows the colour scheme. The + date column is the wide one because a localized date is long and a + clock never is. */} + {target !== 'bucket_list' && ( +
+
+ + {t('dawarich.accept.date')} + + +
+
+ + {t('dawarich.accept.from')} + + +
+
+ + {t('dawarich.accept.to')} + + +
+
+ )} + + {/* Stacked and full width, in the order the choice is actually made: the + day is what somebody is deciding, the trip is usually already right. + Side by side, both pickers were half as wide as their longest option + and the menus opened narrower than the text in them. */} + {target === 'place' && ( +
+
+ + {t('dawarich.accept.day')} + + {/* Unplanned is a real answer, not a missing one: half the places on + a trip sit on no day, and forcing a choice here would invent one. */} + setDayId(next === NO_DAY || typeof next !== 'number' ? '' : next)} + options={[ + { value: NO_DAY, label: t('dawarich.accept.noDay') }, + // The date rides as the option's badge rather than inside its + // label: the day number is what people scan for, and the date + // is the confirmation they read second. + ...days.map(day => ({ value: day.id, label: day.label, badge: day.badge })), + ]} + style={{ width: '100%' }} + /> +
+
+ + {t('dawarich.accept.trip')} + + { setTripId(typeof next === 'number' ? next : ''); setDayId('') }} + options={trips.map(trip => ({ value: trip.id, label: trip.label }))} + placeholder={t('dawarich.accept.pickTrip')} + style={{ width: '100%' }} + /> +
+
+ )} + + {target === 'journal' && ( +
+ + {t('dawarich.accept.journal')} + + setJournalId(typeof next === 'number' ? next : '')} + options={journals.map(journal => ({ value: journal.id, label: journal.label }))} + placeholder={t('dawarich.accept.pickJournal')} + style={{ width: '100%' }} + /> +
+ )} + + {target !== 'bucket_list' && ( +
+ +