Compare commits

...

20 Commits

Author SHA1 Message Date
Maurice cd2f50bc89 chore: trigger CI 2026-04-17 23:36:31 +02:00
Maurice 530550455d feat(ui): unified toolbar design + redesigned budget widgets + polish
Trip planner now has a consistent rounded toolbar across bookings, lists,
budget and files. Each panel shows title, inline filter pills (with
counts where useful) and an accent action button on the right. Moved
per-tab controls into the toolbar — lists import, todo add, budget
currency/add-category, files trash/filters — and dropped the redundant
in-panel headers.

Budget sidebar redesigned: total-budget card with indigo-ringed avatars
and coloured split bar; settlement flows as paired avatar cards;
by-category donut rebuilt in SVG with per-category gradients. Both cards
now follow dark/light mode via a widgetTheme helper.

Todo: add-new-task is a portalled modal on desktop, the add-task input
bar is gone; new SORT BY section in the sidebar; inline category
creation in the task editor.

Reservations: pending / confirmed sections remember their collapsed
state per trip (localStorage).

Misc: per-trip connections toggle moved into the day-plan sidebar,
booking endpoints fixed to show on map for trains/cruises/cars as well,
label localStorage persistence, RESMODAL test updated to the new
airport-select flow.

i18n: the new booking / map / todo / budget strings are translated into
all 15 supported languages.
2026-04-17 23:25:38 +02:00
Maurice 5e9c8d2c43 fix(bookings): client test failures after map overlay refactor
- Make useEndpointPane tolerant when map mock lacks getPane/createPane
- Add useMapEvents to react-leaflet mock in MapView.test
- Rewrite RESMODAL-042 to use the new AirportSelect flow (airline and
  flight number only; airport codes are now saved as endpoints, not
  metadata)
2026-04-17 19:03:21 +02:00
Maurice 8defc90e95 feat(bookings): show transport routes on map (#384, #587)
Adds from/to endpoints to flight/train/cruise/car reservations with
live map rendering. Flights use geodesic arcs and a curved duration +
distance badge; train/car/cruise render as straight or geodesic lines
with endpoint markers. Airports come from an embedded OurAirports
database (~3200 airports, offline-capable); train/cruise/car locations
via Nominatim. Per-trip connection toggle sits in the day plan
sidebar, persisted in localStorage. Clicking a map endpoint opens the
existing transport detail popup. New display setting toggles endpoint
labels on the map. Migration 105 adds the reservation_endpoints table
plus needs_review flag; existing flights are backfilled from their
IATA metadata on server startup.
2026-04-17 14:04:40 +02:00
Maurice 21511c2f68 Merge pull request #700 from mauriceboe/feat/v3-thankyou-notice
feat: v3 thank-you notice, mobile map+timeline, modal UX improvements
2026-04-16 23:51:13 +02:00
Maurice 0e5c819f7c fix: adapt tests for last-page-only dismiss and fix editor z-index
- SystemNoticeModal tests: navigate to last page before testing
  X button, ESC, and CTA dismiss (matches new last-page-only behavior)
- EntryEditor: use z-[9999] instead of portal (fixes iOS stacking
  without breaking test DOM queries)
- Pros/cons inputs: remove colored backgrounds in dark mode
2026-04-16 23:46:07 +02:00
Maurice 0f44d7d264 feat(journey): combined map+timeline view on mobile (Polarsteps-style)
Merge the separate Timeline and Map tabs into a single fullscreen
combined view on mobile (<1024px). A Leaflet map fills the background
while a horizontal snap-scroll carousel of entry cards sits at the
bottom. Scrolling the carousel auto-focuses the corresponding map
marker; tapping a marker scrolls to the card. Tapping a card opens
a new fullscreen entry view with edit/delete actions.

- New: MobileMapTimeline, MobileEntryCard, MobileEntryView components
- New: useIsMobile hook (matchMedia < 1024px)
- JourneyMap: fullScreen + paddingBottom props, focusMarker guard
- Desktop layout completely unchanged
- Public share page gets the same combined view (read-only)
- Fix: entry editor now portaled to body (iOS stacking context)
- Fix: pros/cons dark mode input backgrounds
- Fix: mood button borders in dark mode
- Fix: location icon color (neutral instead of green/indigo)
2026-04-16 23:37:09 +02:00
Maurice df075630fb feat(system-notices): add personal thank-you notice for v3.0.0
Personal note from the creator shown as the first page in the 3.0
upgrade modal. Includes community links (Discord, Ko-fi) and a
special shout-out to jubnl. Modal UX improved: users must click
through all pages before dismissing, wider layout, enhanced
markdown rendering with styled links, signature, and HR separator.
i18n coverage across all 15 languages.
2026-04-16 22:25:03 +02:00
Julien G. bffb55d8c0 Merge pull request #699 from mauriceboe/fix/journey-gallery-lightbox-grouping
fix(journey): gallery lightbox navigates all photos, not just same-day entry
2026-04-16 21:43:07 +02:00
jubnl 5c24213b0e fix(journey): gallery lightbox navigates all photos, not just same-day entry 2026-04-16 21:35:52 +02:00
Julien G. 12a457801a Merge pull request #697 from mauriceboe/fix/journey-photo-thumbnail-cache
fix(journey): serve local file when uploading photos with Immich syncenabled
2026-04-16 21:29:59 +02:00
jubnl ae4d317dc3 fix(journey): serve local file when uploading photos with Immich sync enabled
After upload, trek_photos.provider is immediately flipped to 'immich' even
though Immich's thumbnail generation is async. streamPhoto then routed to
Immich, which returned an error for the not-yet-processed asset. Because
Cache-Control was set before the proxy attempt, the error response was cached
by the browser for 24h — breaking thumbnails until a hard refresh bypassed
the cache and Immich had finished processing.

- streamPhoto now prefers the local file_path when it exists on disk,
  regardless of provider; Immich/Synology are only used when no local
  file is available (fixes the immediate broken-thumbnail symptom)
- pipeAsset sets Cache-Control: no-store on upstream errors and uses the
  caller-supplied default only on success (prevents cache poisoning)
- streamImmichAsset no longer pre-sets Cache-Control before the proxy
- streamSynologyAsset passes the same defaultCacheControl through pipeAsset

Closes #691
2026-04-16 21:20:38 +02:00
Julien G. f7c6854059 Merge pull request #693 from mauriceboe/fix/synology-shared-albums-pagination
Fix/synology shared albums pagination
2026-04-16 21:06:28 +02:00
jubnl bdb6b01765 fix(synology): paginate all three album sources past 100 albums and tighten targetUserId type
- Extract _fetchAllSynologyAlbums helper that loops until the source is
  exhausted; listSynologyAlbums now uses it for personal, shared-out,
  and shared-with-me instead of a hard-capped single request of 100
- Make getSynologyAssetInfo targetUserId required (number, not number|undefined)
  to match every call site and eliminate an implicit any at the _requestSynologyApi
  boundary
2026-04-16 20:54:35 +02:00
jubnl 129dfabaa3 feat(synology): persist and use passphrase for shared album photo streaming (#689-4)
- syncSynologyAlbumLink now uses getAlbumLinkForSync to read the stored
  passphrase and passes it in the SYNO.Foto.Browse.Item call when present,
  falling back to album_id for links without a passphrase.
- Selection type gains optional passphrase field; addTripPhotos and
  _addTripPhoto thread it through to getOrCreateTrekPhoto.
- getOrCreateTrekPhoto accepts an optional passphrase (4th param) and
  encrypts it when inserting a new trek_photos row; backfills existing
  rows that lack a passphrase.
- streamPhoto and getPhotoInfo decrypt the stored passphrase from
  trek_photos and forward it to streamSynologyAsset / getSynologyAssetInfo
  so shared-album photos resolve correctly at access time.
- Add SYNO-054 integration test covering the passphrase sync-and-persist
  path end-to-end.
2026-04-16 20:05:18 +02:00
jubnl 8a6d1b2aaf feat(synology): merge personal, shared-out, and shared-with-me albums in listSynologyAlbums
Fire all three Synology album sources in parallel via Promise.allSettled so a
permissions failure on one source (e.g. SYNO.Foto.Sharing.Misc) never blocks
personal album display. Deduplicate by album id (last-write-wins), propagate
passphrase from shared/shared-with-me entries, and return the merged list sorted
by albumName. Extends AlbumsList type to carry optional passphrase.

Adds SYNO-027/028/029 integration tests; updates SYNO-060/061/081 to match
the new multi-source call pattern.
2026-04-16 19:56:10 +02:00
jubnl 465b78411a fix(synology): resolve pagination offset using correct size before computing page offset
The `size` → `limit` assignment was evaluated after `page * limit`, causing
the offset to be computed using the hardcoded default (100) instead of the
caller-supplied page size. Swapping the two `if` blocks ensures `limit` is
resolved from `size` first so the offset is always `(page-1) * size`.

Adds SYNO-025 and SYNO-026 integration tests that capture the raw Synology
API body and assert `offset` and `limit` are forwarded correctly.
2026-04-16 19:49:08 +02:00
Julien G. 272b32b410 Merge pull request #685 from mauriceboe/fix/hide-mobile-scrollbars
fix(ui): hide scrollbars on mobile, keep styled bars on desktop
2026-04-16 16:50:20 +02:00
jubnl 7945e752d6 fix(ui): restore scrollbar-width: thin on .scroll-container 2026-04-16 16:44:27 +02:00
jubnl 6eb3ab38fb fix(ui): hide scrollbars on mobile, keep styled bars on desktop
Scrollbars on mobile caused layout shift (content pushed left).
Hidden via media query on mobile; desktop retains thin styled scrollbars.
Also removes inline scrollbarWidth override in DayPlanSidebar that bypassed the CSS rule.
2026-04-16 16:42:36 +02:00
65 changed files with 3689 additions and 572 deletions
+2 -1
View File
@@ -16,7 +16,8 @@ client/public/icons/*.png
*.sqlite-wal
# User data
server/data/
server/data/*
!server/data/airports.json
server/uploads/
# Environment
+5
View File
@@ -365,6 +365,11 @@ export const mapsApi = {
resolveUrl: (url: string) => apiClient.post('/maps/resolve-url', { url }).then(r => r.data),
}
export const airportsApi = {
search: (q: string, signal?: AbortSignal) => apiClient.get('/airports/search', { params: { q }, signal }).then(r => r.data),
byIata: (iata: string) => apiClient.get(`/airports/${encodeURIComponent(iata)}`).then(r => r.data),
}
export const budgetApi = {
list: (tripId: number | string) => apiClient.get(`/trips/${tripId}/budget`).then(r => r.data),
create: (tripId: number | string, data: Record<string, unknown>) => apiClient.post(`/trips/${tripId}/budget`, data).then(r => r.data),
+380 -134
View File
@@ -4,7 +4,69 @@ import DOM from 'react-dom'
import { useTripStore } from '../../store/tripStore'
import { useCanDo } from '../../store/permissionsStore'
import { useTranslation } from '../../i18n'
import { Plus, Trash2, Calculator, Wallet, Pencil, Users, Check, Info, ChevronDown, ChevronRight, Download, GripVertical } from 'lucide-react'
import { Plus, Trash2, Calculator, Wallet, Pencil, Users, Check, Info, ChevronDown, ChevronRight, Download, GripVertical, TrendingUp, TrendingDown, PieChart as PieChartIcon } from 'lucide-react'
function useIsDark(): boolean {
const [dark, setDark] = useState<boolean>(() => typeof document !== 'undefined' && document.documentElement.classList.contains('dark'))
useEffect(() => {
if (typeof document === 'undefined') return
const mo = new MutationObserver(() => setDark(document.documentElement.classList.contains('dark')))
mo.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] })
return () => mo.disconnect()
}, [])
return dark
}
function widgetTheme(dark: boolean) {
if (dark) return {
bg: 'linear-gradient(180deg, #17171d 0%, #0d0d12 100%)',
border: 'rgba(255,255,255,0.07)',
text: '#ffffff',
sub: 'rgba(255,255,255,0.6)',
faint: 'rgba(255,255,255,0.4)',
track: 'rgba(255,255,255,0.04)',
divider: 'rgba(255,255,255,0.07)',
iconBg: 'rgba(255,255,255,0.08)',
iconBorder: 'rgba(255,255,255,0.12)',
iconColor: 'rgba(255,255,255,0.9)',
centerBg: '#17171d',
flowBg: 'rgba(255,255,255,0.05)',
flowBorder: 'rgba(255,255,255,0.07)',
flowHoverBg: 'rgba(255,255,255,0.08)',
flowHoverBorder: 'rgba(255,255,255,0.12)',
rowHover: 'rgba(255,255,255,0.03)',
shadow: '0 20px 50px rgba(0,0,0,0.35), inset 0 1px 0 rgba(255,255,255,0.04)',
donutShadow: 'drop-shadow(0 0 20px rgba(0,0,0,0.3))',
}
return {
bg: 'linear-gradient(180deg, #ffffff 0%, #f9fafb 100%)',
border: 'rgba(15,23,42,0.08)',
text: '#111827',
sub: 'rgba(17,24,39,0.6)',
faint: 'rgba(17,24,39,0.4)',
track: 'rgba(15,23,42,0.05)',
divider: 'rgba(15,23,42,0.08)',
iconBg: 'rgba(15,23,42,0.05)',
iconBorder: 'rgba(15,23,42,0.1)',
iconColor: 'rgba(17,24,39,0.75)',
centerBg: '#ffffff',
flowBg: 'rgba(15,23,42,0.03)',
flowBorder: 'rgba(15,23,42,0.08)',
flowHoverBg: 'rgba(15,23,42,0.06)',
flowHoverBorder: 'rgba(15,23,42,0.14)',
rowHover: 'rgba(15,23,42,0.04)',
shadow: '0 12px 32px rgba(15,23,42,0.08), 0 2px 6px rgba(0,0,0,0.04)',
donutShadow: 'drop-shadow(0 4px 18px rgba(15,23,42,0.12))',
}
}
function hexLighten(hex: string, amount: number): string {
const m = hex.replace('#', '').match(/.{2}/g)
if (!m || m.length !== 3) return hex
const mix = (c: number) => Math.min(255, Math.round(c + (255 - c) * amount))
const [r, g, b] = m.map(x => parseInt(x, 16))
return `#${[mix(r), mix(g), mix(b)].map(v => v.toString(16).padStart(2, '0')).join('')}`
}
import CustomSelect from '../shared/CustomSelect'
import { budgetApi } from '../../api/client'
import { CustomDatePicker } from '../shared/CustomDateTimePicker'
@@ -361,9 +423,47 @@ interface PerPersonInlineProps {
locale: string
}
function PerPersonInline({ tripId, budgetItems, currency, locale }: PerPersonInlineProps) {
const [data, setData] = useState(null)
const fmt = (v) => fmtNum(v, locale, currency)
const SPLIT_COLORS = [
{ solid: '#6366f1', gradient: 'linear-gradient(135deg, #6366f1, #8b5cf6)' },
{ solid: '#ec4899', gradient: 'linear-gradient(135deg, #ec4899, #f43f5e)' },
{ solid: '#10b981', gradient: 'linear-gradient(135deg, #10b981, #22c55e)' },
{ solid: '#f59e0b', gradient: 'linear-gradient(135deg, #f59e0b, #f97316)' },
{ solid: '#06b6d4', gradient: 'linear-gradient(135deg, #06b6d4, #3b82f6)' },
{ solid: '#a855f7', gradient: 'linear-gradient(135deg, #a855f7, #d946ef)' },
]
export function splitColorFor(userId: number, order: number) {
return SPLIT_COLORS[order % SPLIT_COLORS.length]
}
function colorForUserId(userId: number) {
return SPLIT_COLORS[((userId | 0) - 1 + SPLIT_COLORS.length * 1000) % SPLIT_COLORS.length]
}
function RingAvatar({ userId, username, avatarUrl, size = 34, innerBg = '#17171d', textColor = '#fff' }: { userId: number; username?: string; avatarUrl?: string | null; size?: number; innerBg?: string; textColor?: string }) {
const color = colorForUserId(userId)
return (
<div style={{
width: size, height: size, borderRadius: '50%', flexShrink: 0,
padding: 2, background: color.gradient,
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
<div style={{
width: '100%', height: '100%', borderRadius: '50%',
background: innerBg,
display: 'flex', alignItems: 'center', justifyContent: 'center',
overflow: 'hidden',
fontSize: size < 28 ? 10 : 12, fontWeight: 600, color: textColor,
}}>
{avatarUrl ? <img src={avatarUrl} style={{ width: '100%', height: '100%', objectFit: 'cover' }} /> : username?.[0]?.toUpperCase()}
</div>
</div>
)
}
function PerPersonInline({ tripId, budgetItems, currency, locale, grandTotal, theme }: PerPersonInlineProps & { grandTotal: number; theme: ReturnType<typeof widgetTheme> }) {
const [data, setData] = useState<any[] | null>(null)
const fmt = (v: number) => fmtNum(v, locale, currency)
useEffect(() => {
budgetApi.perPersonSummary(tripId).then(d => setData(d.summary)).catch(() => {})
@@ -371,25 +471,38 @@ function PerPersonInline({ tripId, budgetItems, currency, locale }: PerPersonInl
if (!data || data.length === 0) return null
const people = data.map((p: any) => ({ ...p, color: colorForUserId(p.user_id) }))
return (
<div style={{ marginTop: 16, borderTop: '1px solid rgba(255,255,255,0.1)', paddingTop: 14, display: 'flex', flexDirection: 'column', gap: 8 }}>
{data.map(person => (
<div key={person.user_id} style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<div style={{
width: 22, height: 22, borderRadius: '50%', background: 'rgba(255,255,255,0.1)',
display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 9, fontWeight: 700,
color: 'rgba(255,255,255,0.7)', overflow: 'hidden', flexShrink: 0,
}}>
{person.avatar_url
? <img src={person.avatar_url} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
: person.username?.[0]?.toUpperCase()
}
</div>
<span style={{ flex: 1, fontSize: 12, fontWeight: 500, color: 'rgba(255,255,255,0.7)' }}>{person.username}</span>
<span style={{ fontSize: 12, fontWeight: 600, color: '#fff' }}>{fmt(person.total_assigned)}</span>
<>
{grandTotal > 0 && (
<div style={{ display: 'flex', height: 6, borderRadius: 999, overflow: 'hidden', marginTop: 8, marginBottom: 4, gap: 3 }}>
{people.map(p => (
<div key={p.user_id} style={{
height: '100%', borderRadius: 999,
flex: Math.max(p.total_assigned || 0, 0.01),
background: p.color.gradient,
}} />
))}
</div>
))}
</div>
)}
<div style={{ marginTop: 14, paddingTop: 14, borderTop: `1px solid ${theme.divider}`, display: 'flex', flexDirection: 'column', gap: 2 }}>
{people.map(p => {
const percent = grandTotal > 0 ? Math.round((p.total_assigned / grandTotal) * 100) : 0
return (
<div key={p.user_id} style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '6px 0' }}>
<RingAvatar userId={p.user_id} username={p.username} avatarUrl={p.avatar_url} size={34} innerBg={theme.centerBg} textColor={theme.text} />
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 13.5, fontWeight: 500, letterSpacing: '-0.01em', color: theme.text }}>{p.username}</div>
<div style={{ fontSize: 11, color: theme.faint, marginTop: 1 }}>{percent}%</div>
</div>
<div style={{ fontSize: 13.5, fontWeight: 600, color: theme.text, letterSpacing: '-0.01em' }}>{fmt(p.total_assigned)}</div>
</div>
)
})}
</div>
</>
)
}
@@ -446,6 +559,8 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
const { trip, budgetItems, addBudgetItem, updateBudgetItem, deleteBudgetItem, loadBudgetItems, updateTrip, setBudgetItemMembers, toggleBudgetMemberPaid, reorderBudgetItems, reorderBudgetCategories } = useTripStore()
const can = useCanDo()
const { t, locale } = useTranslation()
const isDark = useIsDark()
const theme = useMemo(() => widgetTheme(isDark), [isDark])
const [newCategoryName, setNewCategoryName] = useState('')
const [editingCat, setEditingCat] = useState(null) // { name, value }
const [settlement, setSettlement] = useState<{ balances: any[]; flows: any[] } | null>(null)
@@ -589,20 +704,69 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
}
// ── Main Layout ──────────────────────────────────────────────────────────
const totalBudget = budgetItems.reduce((s, x) => s + (x.total_price || 0), 0)
return (
<div style={{ fontFamily: "'Poppins', -apple-system, BlinkMacSystemFont, system-ui, sans-serif" }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '16px 16px 12px', flexWrap: 'wrap', gap: 8 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<Calculator size={20} color="var(--text-primary)" />
<h2 style={{ fontSize: 18, fontWeight: 700, color: 'var(--text-primary)', margin: 0 }}>{t('budget.title')}</h2>
<div>
<div style={{ padding: '24px 28px 0' }} className="max-md:!px-4 max-md:!pt-4">
<div style={{
background: 'var(--bg-tertiary)', borderRadius: 18,
padding: '14px 16px 14px 22px',
display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 16, flexWrap: 'wrap',
}}>
<h2 style={{ margin: 0, fontSize: 18, fontWeight: 600, color: 'var(--text-primary)', letterSpacing: '-0.01em', flexShrink: 0 }}>
{t('budget.title')}
</h2>
<div className="hidden md:flex" style={{ alignItems: 'center', gap: 8, marginLeft: 'auto', flexShrink: 0 }}>
<div style={{ width: 150 }}>
<CustomSelect
value={currency}
onChange={setCurrency}
disabled={!canEdit}
options={CURRENCIES.map(c => ({ value: c, label: `${c} (${SYMBOLS[c] || c})` }))}
searchable
/>
</div>
{canEdit && (
<div style={{ display: 'flex', gap: 6, width: 260 }}>
<input
value={newCategoryName}
onChange={e => setNewCategoryName(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') handleAddCategory() }}
placeholder={t('budget.categoryName')}
style={{ flex: 1, minWidth: 0, border: '1px solid var(--border-primary)', borderRadius: 10, padding: '9px 14px', fontSize: 13, outline: 'none', fontFamily: 'inherit', background: 'var(--bg-card)', color: 'var(--text-primary)' }}
/>
<button onClick={handleAddCategory} disabled={!newCategoryName.trim()}
title={t('budget.addCategory')}
style={{
appearance: 'none', border: 'none', cursor: newCategoryName.trim() ? 'pointer' : 'default', fontFamily: 'inherit',
display: 'inline-flex', alignItems: 'center', gap: 6,
padding: '9px 14px', borderRadius: 10, fontSize: 13, fontWeight: 500,
background: 'var(--accent)', color: 'var(--accent-text)', flexShrink: 0,
opacity: newCategoryName.trim() ? 1 : 0.4,
transition: 'opacity 0.15s ease',
}}>
<Plus size={14} strokeWidth={2.5} />
</button>
</div>
)}
<button onClick={handleExportCsv} title={t('budget.exportCsv')}
style={{
appearance: 'none', border: 'none', cursor: 'pointer', fontFamily: 'inherit',
display: 'inline-flex', alignItems: 'center', gap: 6,
padding: '9px 14px', borderRadius: 10, fontSize: 13, fontWeight: 500,
background: 'var(--accent)', color: 'var(--accent-text)', flexShrink: 0,
transition: 'opacity 0.15s ease',
}}
onMouseEnter={e => e.currentTarget.style.opacity = '0.88'}
onMouseLeave={e => e.currentTarget.style.opacity = '1'}
>
<Download size={14} strokeWidth={2.5} /> CSV
</button>
</div>
</div>
<button onClick={handleExportCsv} title={t('budget.exportCsv')}
style={{ display: 'flex', alignItems: 'center', gap: 6, padding: '6px 12px', borderRadius: 8, border: '1px solid var(--border-primary)', background: 'none', color: 'var(--text-muted)', fontSize: 12, fontWeight: 500, cursor: 'pointer', fontFamily: 'inherit' }}>
<Download size={13} /> CSV
</button>
</div>
<div style={{ display: 'flex', gap: 20, padding: '0 16px 40px', alignItems: 'flex-start', flexWrap: 'wrap' }}>
<div style={{ display: 'flex', gap: 20, padding: '24px 28px 40px', alignItems: 'flex-start', flexWrap: 'wrap' }} className="max-md:!px-4">
<div style={{ flex: 1, minWidth: 0 }}>
{categoryNames.map((cat, ci) => {
const items = grouped.get(cat) || []
@@ -811,61 +975,57 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
})}
</div>
<div className="w-full md:w-[240px]" style={{ flexShrink: 0, position: 'sticky', top: 16, alignSelf: 'flex-start' }}>
<div style={{ marginBottom: 12 }}>
<CustomSelect
value={currency}
onChange={setCurrency}
disabled={!canEdit}
options={CURRENCIES.map(c => ({ value: c, label: `${c} (${SYMBOLS[c] || c})` }))}
searchable
/>
</div>
{canEdit && (
<div style={{ display: 'flex', gap: 6, marginBottom: 12 }}>
<input
value={newCategoryName}
onChange={e => setNewCategoryName(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') handleAddCategory() }}
placeholder={t('budget.categoryName')}
style={{ flex: 1, border: '1px solid var(--border-primary)', borderRadius: 10, padding: '9px 14px', fontSize: 13, outline: 'none', fontFamily: 'inherit', background: 'var(--bg-input)', color: 'var(--text-primary)' }}
/>
<button onClick={handleAddCategory} disabled={!newCategoryName.trim()}
style={{ background: 'var(--accent)', color: 'var(--accent-text)', border: 'none', borderRadius: 10, padding: '9px 12px', cursor: 'pointer', display: 'flex', alignItems: 'center', opacity: newCategoryName.trim() ? 1 : 0.4, flexShrink: 0 }}>
<Plus size={16} />
</button>
</div>
)}
<div className="w-full md:w-[320px]" style={{ flexShrink: 0, position: 'sticky', top: 16, alignSelf: 'flex-start' }}>
<div style={{
background: 'linear-gradient(135deg, #000000 0%, #18181b 100%)',
borderRadius: 16, padding: '24px 20px', color: '#fff', marginBottom: 16,
boxShadow: '0 8px 32px rgba(15,23,42,0.18)',
background: theme.bg,
borderRadius: 20, padding: 20, color: theme.text, marginBottom: 16,
border: `1px solid ${theme.border}`,
boxShadow: theme.shadow,
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 16 }}>
<div style={{ width: 36, height: 36, borderRadius: 10, background: 'rgba(255,255,255,0.1)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<Wallet size={18} color="rgba(255,255,255,0.8)" />
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 18 }}>
<div style={{
width: 40, height: 40, borderRadius: 12,
background: theme.iconBg,
border: `1px solid ${theme.iconBorder}`,
display: 'flex', alignItems: 'center', justifyContent: 'center',
color: theme.iconColor, flexShrink: 0,
}}>
<Wallet size={20} strokeWidth={2} />
</div>
<div>
<div style={{ fontSize: 11, color: 'rgba(255,255,255,0.5)', fontWeight: 500, letterSpacing: 0.5 }}>{t('budget.totalBudget')}</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 11, color: theme.faint, fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.09em' }}>{t('budget.totalBudget')}</div>
</div>
</div>
<div style={{ fontSize: 22, fontWeight: 700, lineHeight: 1, marginBottom: 4 }}>
{Number(grandTotal).toLocaleString(locale, { minimumFractionDigits: currencyDecimals(currency), maximumFractionDigits: currencyDecimals(currency) })}
{(() => {
const decimals = currencyDecimals(currency)
const full = Number(grandTotal).toLocaleString(locale, { minimumFractionDigits: decimals, maximumFractionDigits: decimals })
const sep = (0.1).toLocaleString(locale).replace(/\d/g, '')
const [integerPart, decimalPart] = decimals > 0 ? full.split(sep) : [full, '']
return (
<div style={{ display: 'flex', alignItems: 'baseline', gap: 4, letterSpacing: '-0.03em', lineHeight: 1 }}>
<span style={{ fontSize: 38, fontWeight: 700 }}>{integerPart}</span>
{decimalPart && <span style={{ fontSize: 22, fontWeight: 500, color: theme.sub }}>{sep}{decimalPart}</span>}
<span style={{ fontSize: 22, fontWeight: 500, color: theme.sub, marginLeft: 2 }}>{SYMBOLS[currency] || currency}</span>
</div>
)
})()}
<div style={{ color: theme.faint, fontSize: 12, marginTop: 8, fontWeight: 500, letterSpacing: '0.04em', display: 'flex', alignItems: 'center', gap: 6 }}>
<span>{currency}</span>
</div>
<div style={{ fontSize: 14, color: 'rgba(255,255,255,0.5)', fontWeight: 500 }}>{SYMBOLS[currency] || currency} {currency}</div>
{hasMultipleMembers && (budgetItems || []).some(i => i.members?.length > 0) && (
<PerPersonInline tripId={tripId} budgetItems={budgetItems} currency={currency} locale={locale} />
<PerPersonInline tripId={tripId} budgetItems={budgetItems} currency={currency} locale={locale} grandTotal={grandTotal} theme={theme} />
)}
{/* Settlement dropdown inside the total card */}
{hasMultipleMembers && settlement && settlement.flows.length > 0 && (
<div style={{ marginTop: 16, borderTop: '1px solid rgba(255,255,255,0.1)', paddingTop: 12 }}>
<div style={{ marginTop: 16, borderTop: `1px solid ${theme.divider}`, paddingTop: 12 }}>
<button onClick={() => setSettlementOpen(v => !v)} style={{
display: 'flex', alignItems: 'center', gap: 6, width: '100%',
background: 'none', border: 'none', cursor: 'pointer', padding: 0, fontFamily: 'inherit',
color: 'rgba(255,255,255,0.6)', fontSize: 11, fontWeight: 600, letterSpacing: 0.5,
color: theme.sub, fontSize: 11, fontWeight: 600, letterSpacing: 0.5,
}}>
{settlementOpen ? <ChevronDown size={13} /> : <ChevronRight size={13} />}
{t('budget.settlement')}
@@ -890,53 +1050,60 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
</button>
{settlementOpen && (
<div style={{ marginTop: 10, display: 'flex', flexDirection: 'column', gap: 8 }}>
<div style={{ marginTop: 12, display: 'flex', flexDirection: 'column', gap: 8 }}>
{settlement.flows.map((flow, i) => (
<div key={i} style={{
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 10,
padding: '8px 10px', borderRadius: 10,
background: 'rgba(255,255,255,0.06)',
}}>
<ChipWithTooltip label={flow.from.username} avatarUrl={flow.from.avatar_url} size={28} />
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<span style={{ fontSize: 11, color: 'rgba(255,255,255,0.3)' }}></span>
<span style={{ fontSize: 12, fontWeight: 700, color: '#f87171', whiteSpace: 'nowrap' }}>
display: 'flex', alignItems: 'center', gap: 14,
padding: '12px 14px', borderRadius: 14,
background: theme.flowBg,
border: `1px solid ${theme.flowBorder}`,
transition: 'all 0.2s',
}}
onMouseEnter={e => { e.currentTarget.style.background = theme.flowHoverBg; e.currentTarget.style.borderColor = theme.flowHoverBorder }}
onMouseLeave={e => { e.currentTarget.style.background = theme.flowBg; e.currentTarget.style.borderColor = theme.flowBorder }}
>
<RingAvatar userId={flow.from.user_id} username={flow.from.username} avatarUrl={flow.from.avatar_url} size={32} innerBg={theme.centerBg} textColor={theme.text} />
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 5 }}>
<span style={{ fontSize: 13, fontWeight: 700, color: '#ef4444', letterSpacing: '-0.01em' }}>
{fmt(flow.amount, currency)}
</span>
<span style={{ fontSize: 11, color: 'rgba(255,255,255,0.3)' }}></span>
<div style={{ width: '100%', height: 2, borderRadius: 2, background: 'linear-gradient(90deg, rgba(239,68,68,0.1), rgba(239,68,68,0.55), rgba(239,68,68,0.3))', position: 'relative' }}>
<div style={{ position: 'absolute', right: -1, top: '50%', transform: 'translateY(-50%)', width: 0, height: 0, borderLeft: '6px solid rgba(239,68,68,0.55)', borderTop: '4px solid transparent', borderBottom: '4px solid transparent' }} />
</div>
</div>
<ChipWithTooltip label={flow.to.username} avatarUrl={flow.to.avatar_url} size={28} />
<RingAvatar userId={flow.to.user_id} username={flow.to.username} avatarUrl={flow.to.avatar_url} size={32} innerBg={theme.centerBg} textColor={theme.text} />
</div>
))}
{settlement.balances.filter(b => Math.abs(b.balance) > 0.01).length > 0 && (
<div style={{ marginTop: 4, borderTop: '1px solid rgba(255,255,255,0.08)', paddingTop: 8 }}>
<div style={{ fontSize: 9, fontWeight: 600, color: 'rgba(255,255,255,0.35)', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: 6 }}>
<div style={{ marginTop: 8, borderTop: `1px solid ${theme.divider}`, paddingTop: 12 }}>
<div style={{ fontSize: 10, fontWeight: 700, color: theme.faint, textTransform: 'uppercase', letterSpacing: '0.11em', marginBottom: 10 }}>
{t('budget.netBalances')}
</div>
{settlement.balances.filter(b => Math.abs(b.balance) > 0.01).map(b => (
<div key={b.user_id} style={{ display: 'flex', alignItems: 'center', gap: 6, padding: '2px 0' }}>
<div style={{
width: 20, height: 20, borderRadius: '50%', flexShrink: 0,
background: 'rgba(255,255,255,0.1)', display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 8, fontWeight: 700, color: 'rgba(255,255,255,0.6)', overflow: 'hidden',
}}>
{b.avatar_url
? <img src={b.avatar_url} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
: b.username?.[0]?.toUpperCase()
}
</div>
<span style={{ flex: 1, fontSize: 11, color: 'rgba(255,255,255,0.6)', fontWeight: 500, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{b.username}
</span>
<span style={{
fontSize: 11, fontWeight: 600, flexShrink: 0,
color: b.balance > 0 ? '#4ade80' : '#f87171',
}}>
{b.balance > 0 ? '+' : ''}{fmt(b.balance, currency)}
</span>
</div>
))}
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
{settlement.balances.filter(b => Math.abs(b.balance) > 0.01).map(b => {
const positive = b.balance > 0
const Trend = positive ? TrendingUp : TrendingDown
return (
<div key={b.user_id} style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '5px 0' }}>
<RingAvatar userId={b.user_id} username={b.username} avatarUrl={b.avatar_url} size={26} innerBg={theme.centerBg} textColor={theme.text} />
<span style={{ flex: 1, fontSize: 13, color: theme.text, fontWeight: 500, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{b.username}
</span>
<span style={{
display: 'inline-flex', alignItems: 'center', gap: 4,
padding: '4px 10px', borderRadius: 8,
fontSize: 12, fontWeight: 700, letterSpacing: '-0.01em',
background: positive ? 'rgba(16,185,129,0.13)' : 'rgba(239,68,68,0.13)',
color: positive ? '#10b981' : '#ef4444',
}}>
<Trend size={11} strokeWidth={3} />
{positive ? '+' : ''}{fmt(b.balance, currency)}
</span>
</div>
)
})}
</div>
</div>
)}
</div>
@@ -945,36 +1112,115 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
)}
</div>
{pieSegments.length > 0 && (
<div style={{
background: 'var(--bg-card)', borderRadius: 16, padding: '20px 16px',
border: '1px solid var(--border-primary)',
boxShadow: '0 2px 12px rgba(0,0,0,0.04)',
marginBottom: 16,
}}>
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)', marginBottom: 16, textAlign: 'center' }}>{t('budget.byCategory')}</div>
{pieSegments.length > 0 && (() => {
const decimals = currencyDecimals(currency)
const total = pieSegments.reduce((s, x) => s + x.value, 0)
const totalFmt = Number(total).toLocaleString(locale, { minimumFractionDigits: decimals, maximumFractionDigits: decimals })
const decimalSep = (0.1).toLocaleString(locale).replace(/\d/g, '')
const [totalInt, totalDec] = decimals > 0 ? totalFmt.split(decimalSep) : [totalFmt, '']
const R = 80
const CIRC = 2 * Math.PI * R
let dashOffset = 0
return (
<div style={{
background: theme.bg,
borderRadius: 20, padding: 20, color: theme.text, marginBottom: 16,
border: `1px solid ${theme.border}`,
boxShadow: theme.shadow,
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 18 }}>
<div style={{
width: 38, height: 38, borderRadius: 11,
background: theme.iconBg,
border: `1px solid ${theme.iconBorder}`,
display: 'flex', alignItems: 'center', justifyContent: 'center',
color: theme.iconColor, flexShrink: 0,
}}>
<PieChartIcon size={18} strokeWidth={2} />
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 11, color: theme.faint, textTransform: 'uppercase', letterSpacing: '0.09em', fontWeight: 600 }}>{t('budget.byCategory')}</div>
</div>
</div>
<PieChart segments={pieSegments} size={180} totalLabel={t('budget.total')} />
<div style={{ marginTop: 20, display: 'flex', flexDirection: 'column', gap: 0 }}>
{pieSegments.map((seg, i) => {
const pct = grandTotal > 0 ? ((seg.value / grandTotal) * 100).toFixed(1) : '0.0'
return (
<div key={seg.name} style={{ padding: '8px 0', borderTop: i > 0 ? '1px solid var(--border-secondary)' : 'none' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<div style={{ width: 10, height: 10, borderRadius: 3, background: seg.color, flexShrink: 0 }} />
<span style={{ fontSize: 12, color: 'var(--text-primary)', fontWeight: 600 }}>{seg.name}</span>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginTop: 3, paddingLeft: 18 }}>
<span style={{ fontSize: 12, color: 'var(--text-muted)', fontWeight: 500 }}>{fmt(seg.value, currency)}</span>
<span style={{ fontSize: 10, color: 'var(--text-faint)', fontWeight: 600, background: 'var(--bg-secondary)', padding: '1px 6px', borderRadius: 99 }}>{pct}%</span>
</div>
<div style={{ position: 'relative', display: 'flex', justifyContent: 'center', margin: '4px 0 16px' }}>
<svg width={200} height={200} viewBox="0 0 200 200" style={{ transform: 'rotate(-90deg)', filter: theme.donutShadow }}>
<defs>
{pieSegments.map((seg, i) => {
const c2 = hexLighten(seg.color, 0.2)
return (
<linearGradient key={`grad-${i}`} id={`cat-grad-${i}`} x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stopColor={seg.color} />
<stop offset="100%" stopColor={c2} />
</linearGradient>
)
})}
</defs>
<circle cx={100} cy={100} r={R} fill="none" stroke={theme.track} strokeWidth={22} />
{pieSegments.map((seg, i) => {
const segLen = total > 0 ? (seg.value / total) * CIRC : 0
const circle = (
<circle key={i}
cx={100} cy={100} r={R}
fill="none" strokeLinecap="round" strokeWidth={22}
stroke={`url(#cat-grad-${i})`}
strokeDasharray={`${segLen} ${CIRC}`}
strokeDashoffset={-dashOffset}
/>
)
dashOffset += segLen
return circle
})}
</svg>
<div style={{ position: 'absolute', top: '50%', left: '50%', transform: 'translate(-50%, -50%)', textAlign: 'center', display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 2, pointerEvents: 'none' }}>
<div style={{ fontSize: 10.5, color: theme.faint, textTransform: 'uppercase', letterSpacing: '0.12em', fontWeight: 700 }}>{t('budget.total')}</div>
<div style={{ fontSize: 22, fontWeight: 700, letterSpacing: '-0.03em', lineHeight: 1, display: 'flex', alignItems: 'baseline', gap: 2 }}>
<span>{totalInt}</span>
{totalDec && <span style={{ fontSize: 13, fontWeight: 500, color: theme.sub }}>{decimalSep}{totalDec}</span>}
</div>
)
})}
<div style={{ fontSize: 10.5, color: theme.faint, fontWeight: 500, marginTop: 2 }}>{currency}</div>
</div>
</div>
<div style={{ borderTop: `1px solid ${theme.divider}`, paddingTop: 10, display: 'flex', flexDirection: 'column', gap: 2 }}>
{pieSegments.map((seg, i) => {
const pct = total > 0 ? (seg.value / total) * 100 : 0
const pctLabel = pct.toFixed(1).replace('.', decimalSep) + '%'
const c2 = hexLighten(seg.color, 0.2)
const chipColor = isDark ? hexLighten(seg.color, 0.35) : seg.color
return (
<div key={seg.name} style={{
display: 'flex', alignItems: 'center', gap: 12,
padding: '10px 8px', borderRadius: 12,
transition: 'background 0.15s',
}}
onMouseEnter={e => e.currentTarget.style.background = theme.rowHover}
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}
>
<div style={{
width: 10, height: 10, borderRadius: 3, flexShrink: 0,
background: `linear-gradient(135deg, ${seg.color}, ${c2})`,
boxShadow: `0 0 12px ${seg.color}80`,
}} />
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 13.5, fontWeight: 500, letterSpacing: '-0.01em', color: theme.text, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{seg.name}</div>
<div style={{ fontSize: 11.5, color: theme.sub, fontWeight: 500, marginTop: 1 }}>{fmt(seg.value, currency)}</div>
</div>
<span style={{
flexShrink: 0,
padding: '4px 9px', borderRadius: 7,
fontSize: 11, fontWeight: 700, letterSpacing: '-0.01em',
background: `${seg.color}26`,
border: `1px solid ${seg.color}40`,
color: chipColor,
}}>{pctLabel}</span>
</div>
)
})}
</div>
</div>
</div>
)}
)
})()}
</div>
</div>
+77 -21
View File
@@ -779,25 +779,81 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
document.body
)}
{/* Header */}
<div style={{ padding: '20px 24px 16px', borderBottom: '1px solid rgba(0,0,0,0.06)', display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexShrink: 0 }}>
<div>
<h2 style={{ margin: 0, fontSize: 18, fontWeight: 700, color: 'var(--text-primary)' }}>{showTrash ? (t('files.trash') || 'Trash') : t('files.title')}</h2>
<p style={{ margin: '2px 0 0', fontSize: 12.5, color: 'var(--text-faint)' }}>
{showTrash
? `${trashFiles.length} ${trashFiles.length === 1 ? 'file' : 'files'}`
: (files.length === 1 ? t('files.countSingular') : t('files.count', { count: files.length }))}
</p>
</div>
<button onClick={toggleTrash} style={{
padding: '6px 12px', borderRadius: 8, border: '1px solid var(--border-primary)',
background: showTrash ? 'var(--accent)' : 'var(--bg-card)',
color: showTrash ? 'var(--accent-text)' : 'var(--text-muted)',
fontSize: 12, fontWeight: 500, cursor: 'pointer', display: 'flex', alignItems: 'center', gap: 5,
fontFamily: 'inherit',
{/* Toolbar */}
<div style={{ padding: '24px 28px 0', flexShrink: 0 }} className="max-md:!px-4 max-md:!pt-4">
<div style={{
background: 'var(--bg-tertiary)', borderRadius: 18,
padding: '14px 16px 14px 22px',
display: 'flex', alignItems: 'center', gap: 16, flexWrap: 'wrap',
}}>
<Trash2 size={13} /> {t('files.trash') || 'Trash'}
</button>
<h2 style={{ margin: 0, fontSize: 18, fontWeight: 600, color: 'var(--text-primary)', letterSpacing: '-0.01em', flexShrink: 0 }}>
{showTrash ? (t('files.trash') || 'Trash') : t('files.title')}
</h2>
{!showTrash && (
<>
<div className="hidden md:block" style={{ width: 1, height: 22, background: 'var(--border-faint)', flexShrink: 0 }} />
<div className="hidden md:inline-flex" style={{ gap: 4, flexWrap: 'wrap', flex: 1, minWidth: 0 }}>
{[
{ id: 'all', label: t('files.filterAll') },
...(files.some(f => f.starred) ? [{ id: 'starred', icon: Star } as const] : []),
{ id: 'pdf', label: t('files.filterPdf') },
{ id: 'image', label: t('files.filterImages') },
{ id: 'doc', label: t('files.filterDocs') },
...(files.some(f => f.note_id) ? [{ id: 'collab', label: t('files.filterCollab') || 'Collab' }] : []),
].map(tab => {
const active = filterType === tab.id
const TabIcon = 'icon' in tab ? tab.icon : null
const count = tab.id === 'all' ? files.length
: tab.id === 'starred' ? files.filter(f => f.starred).length
: tab.id === 'pdf' ? files.filter(f => (f.mime_type || '').includes('pdf') || /\.pdf$/i.test(f.original_name)).length
: tab.id === 'image' ? files.filter(f => (f.mime_type || '').startsWith('image/')).length
: tab.id === 'doc' ? files.filter(f => /\.(docx?|xlsx?|txt|csv)$/i.test(f.original_name)).length
: tab.id === 'collab' ? files.filter(f => f.note_id).length
: 0
return (
<button key={tab.id} onClick={() => setFilterType(tab.id)}
style={{
appearance: 'none', border: 'none', cursor: 'pointer', fontFamily: 'inherit',
display: 'inline-flex', alignItems: 'center', gap: 6,
padding: '6px 12px', borderRadius: 99, fontSize: 13, whiteSpace: 'nowrap',
background: active ? 'var(--bg-card)' : 'transparent',
color: active ? 'var(--text-primary)' : 'var(--text-muted)',
fontWeight: active ? 500 : 400,
boxShadow: active ? '0 1px 2px rgba(0,0,0,0.06)' : 'none',
transition: 'all 0.15s ease',
}}
>
{TabIcon ? <TabIcon size={13} fill={active ? '#facc15' : 'none'} color={active ? '#facc15' : 'currentColor'} /> : null}
{'label' in tab && tab.label}
<span style={{
fontSize: 10, fontWeight: 600,
background: active ? 'var(--bg-tertiary)' : 'rgba(0,0,0,0.06)',
color: 'var(--text-faint)',
padding: '1px 6px', borderRadius: 99, minWidth: 16, textAlign: 'center',
}}>{count}</span>
</button>
)
})}
</div>
</>
)}
<button onClick={toggleTrash} style={{
appearance: 'none', border: 'none', cursor: 'pointer', fontFamily: 'inherit',
display: 'inline-flex', alignItems: 'center', gap: 6,
padding: '9px 14px', borderRadius: 10, fontSize: 13, fontWeight: 500,
background: 'var(--accent)', color: 'var(--accent-text)',
flexShrink: 0, marginLeft: 'auto',
opacity: showTrash ? 1 : 0.88,
transition: 'opacity 0.15s ease',
}}
onMouseEnter={e => e.currentTarget.style.opacity = '1'}
onMouseLeave={e => e.currentTarget.style.opacity = showTrash ? '1' : '0.88'}
>
<Trash2 size={14} strokeWidth={2.5} /> <span className="hidden sm:inline">{t('files.trash') || 'Trash'}</span>
</button>
</div>
</div>
{showTrash ? (
@@ -835,7 +891,7 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
{can('file_upload', trip) && <div
{...getRootProps()}
style={{
margin: '16px 16px 0', border: '2px dashed', borderRadius: 14, padding: '20px 16px',
margin: '16px 28px 0', border: '2px dashed', borderRadius: 14, padding: '20px 16px',
textAlign: 'center', cursor: 'pointer', transition: 'all 0.15s',
borderColor: isDragActive ? 'var(--text-secondary)' : 'var(--border-primary)',
background: isDragActive ? 'var(--bg-secondary)' : 'var(--bg-card)',
@@ -860,7 +916,7 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
</div>}
{/* Filter tabs */}
<div style={{ display: 'flex', gap: 4, padding: '12px 16px 0', flexShrink: 0, flexWrap: 'wrap' }}>
<div className="md:!hidden" style={{ display: 'flex', gap: 4, padding: '12px 16px 0', flexShrink: 0, flexWrap: 'wrap' }}>
{[
{ id: 'all', label: t('files.filterAll') },
...(files.some(f => f.starred) ? [{ id: 'starred', icon: Star }] : []),
@@ -883,7 +939,7 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
</div>
{/* File list */}
<div style={{ flex: 1, overflowY: 'auto', padding: '12px 16px 16px' }}>
<div style={{ flex: 1, overflowY: 'auto', padding: '12px 28px 16px' }} className="max-md:!px-4">
{filteredFiles.length === 0 ? (
<div style={{ textAlign: 'center', padding: '60px 20px', color: 'var(--text-faint)' }}>
<FileText size={40} style={{ color: 'var(--text-faint)', display: 'block', margin: '0 auto 12px' }} />
+21 -11
View File
@@ -33,6 +33,8 @@ interface Props {
dark?: boolean
activeMarkerId?: string | null
onMarkerClick?: (id: string, type?: string) => void
fullScreen?: boolean
paddingBottom?: number
}
function buildMarkerItems(entries: MapEntry[]): MapMarkerItem[] {
@@ -57,15 +59,20 @@ const MARKER_W = 28
const MARKER_H = 36
function markerSvg(index: number, highlighted: boolean, dark: boolean): string {
// Highlighted: inverted colors for contrast (black on light, white on dark)
const fill = dark
? (highlighted ? '#FAFAFA' : '#FAFAFA')
: (highlighted ? '#18181B' : '#18181B')
? (highlighted ? '#FAFAFA' : '#A1A1AA')
: (highlighted ? '#18181B' : '#52525B')
const textColor = dark
? (highlighted ? '#18181B' : '#18181B')
: (highlighted ? '#fff' : '#fff')
const stroke = dark ? '#3F3F46' : '#fff'
const stroke = highlighted
? (dark ? '#fff' : '#18181B')
: (dark ? '#3F3F46' : '#fff')
const shadow = highlighted
? 'filter:drop-shadow(0 0 8px rgba(0,0,0,0.4)) drop-shadow(0 2px 6px rgba(0,0,0,0.3))'
? (dark
? 'filter:drop-shadow(0 0 10px rgba(255,255,255,0.35)) drop-shadow(0 2px 6px rgba(0,0,0,0.4))'
: 'filter:drop-shadow(0 0 10px rgba(0,0,0,0.3)) drop-shadow(0 2px 6px rgba(0,0,0,0.3))')
: 'filter:drop-shadow(0 2px 4px rgba(0,0,0,0.25))'
const label = String(index + 1)
const scale = highlighted ? 1.2 : 1
@@ -82,7 +89,7 @@ function markerSvg(index: number, highlighted: boolean, dark: boolean): string {
const EMPTY_TRAIL: { lat: number; lng: number }[] = []
const JourneyMap = forwardRef<JourneyMapHandle, Props>(function JourneyMap(
{ entries, trail, height = 220, dark, activeMarkerId, onMarkerClick },
{ entries, trail, height = 220, dark, activeMarkerId, onMarkerClick, fullScreen, paddingBottom },
ref
) {
const stableTrail = trail || EMPTY_TRAIL
@@ -138,7 +145,9 @@ const JourneyMap = forwardRef<JourneyMapHandle, Props>(function JourneyMap(
highlightMarker(id)
const marker = markersRef.current.get(id)
if (marker && mapRef.current) {
mapRef.current.flyTo(marker.getLatLng(), Math.max(mapRef.current.getZoom(), 12), { duration: 0.5 })
try {
mapRef.current.flyTo(marker.getLatLng(), Math.max(mapRef.current.getZoom(), 12), { duration: 0.5 })
} catch { /* map not yet initialized */ }
}
}, [])
@@ -156,7 +165,7 @@ const JourneyMap = forwardRef<JourneyMapHandle, Props>(function JourneyMap(
const map = L.map(containerRef.current, {
zoomControl: false,
attributionControl: true,
scrollWheelZoom: false,
scrollWheelZoom: fullScreen ? true : false,
dragging: true,
touchZoom: true,
})
@@ -185,8 +194,8 @@ const JourneyMap = forwardRef<JourneyMapHandle, Props>(function JourneyMap(
coords.forEach(c => allCoords.push(c))
}
// route polyline — subtle dashed connection
if (items.length > 1) {
// route polyline — only in non-fullscreen (sidebar map) mode
if (!fullScreen && items.length > 1) {
const routeCoords = items.map(i => [i.lat, i.lng] as L.LatLngTuple)
L.polyline(routeCoords, {
color: dark ? '#71717A' : '#A1A1AA',
@@ -229,7 +238,8 @@ const JourneyMap = forwardRef<JourneyMapHandle, Props>(function JourneyMap(
try {
map.invalidateSize()
if (allCoords.length > 0) {
map.fitBounds(L.latLngBounds(allCoords), { padding: [50, 50], maxZoom: 14 })
const pb = paddingBottom || 50
map.fitBounds(L.latLngBounds(allCoords), { paddingTopLeft: [50, 50], paddingBottomRight: [50, pb], maxZoom: 14 })
} else {
map.setView([30, 0], 2)
}
@@ -245,7 +255,7 @@ const JourneyMap = forwardRef<JourneyMapHandle, Props>(function JourneyMap(
mapRef.current = null
markersRef.current.clear()
}
}, [entries, stableTrail, dark, mapTileUrl])
}, [entries, stableTrail, dark, mapTileUrl, fullScreen, paddingBottom])
// react to activeMarkerId prop changes — runs after map is built
useEffect(() => {
@@ -0,0 +1,154 @@
import { MapPin, Camera, Smile, Laugh, Meh, Frown, Sun, CloudSun, Cloud, CloudRain, CloudLightning, Snowflake } from 'lucide-react'
import type { JourneyEntry, JourneyPhoto } from '../../store/journeyStore'
const MOOD_ICONS: Record<string, typeof Smile> = {
amazing: Laugh,
good: Smile,
neutral: Meh,
rough: Frown,
}
const MOOD_COLORS: Record<string, string> = {
amazing: 'text-pink-500',
good: 'text-amber-500',
neutral: 'text-zinc-400',
rough: 'text-violet-500',
}
const WEATHER_ICONS: Record<string, typeof Sun> = {
sunny: Sun,
partly: CloudSun,
cloudy: Cloud,
rainy: CloudRain,
stormy: CloudLightning,
cold: Snowflake,
}
function photoUrl(p: JourneyPhoto): string {
return `/api/photos/${p.photo_id}/thumbnail`
}
function stripMarkdown(text: string): string {
return text
.replace(/[#*_~`>\[\]()!|-]/g, '')
.replace(/\n+/g, ' ')
.trim()
}
interface Props {
entry: JourneyEntry | { id: number; type: string; title?: string | null; location_name?: string | null; location_lat?: number | null; location_lng?: number | null; entry_date: string; entry_time?: string | null; mood?: string | null; weather?: string | null; photos?: { photo_id: number }[]; story?: string | null }
index: number
isActive: boolean
onClick: () => void
publicPhotoUrl?: (photoId: number) => string
}
export default function MobileEntryCard({ entry, index, isActive, onClick, publicPhotoUrl }: Props) {
const hasLocation = !!(entry.location_lat && entry.location_lng)
const hasPhotos = entry.photos && entry.photos.length > 0
const firstPhoto = hasPhotos ? entry.photos![0] : null
const MoodIcon = entry.mood ? MOOD_ICONS[entry.mood] : null
const moodColor = entry.mood ? MOOD_COLORS[entry.mood] : ''
const WeatherIcon = entry.weather ? WEATHER_ICONS[entry.weather] : null
const thumbSrc = firstPhoto
? publicPhotoUrl
? publicPhotoUrl((firstPhoto as any).photo_id ?? (firstPhoto as any).id)
: photoUrl(firstPhoto as JourneyPhoto)
: null
const date = new Date(entry.entry_date + 'T00:00:00')
const dateStr = date.toLocaleDateString(undefined, { month: 'short', day: 'numeric' })
const storyPreview = entry.story ? stripMarkdown(entry.story) : ''
return (
<button
onClick={onClick}
className={`flex-shrink-0 rounded-xl overflow-hidden text-left transition-all duration-100 ${
isActive
? 'w-[320px] sm:w-[340px] bg-white dark:bg-zinc-800 shadow-lg ring-2 ring-zinc-900/70 dark:ring-white/60'
: 'w-[240px] sm:w-[260px] bg-white/90 dark:bg-zinc-800/90 shadow-md'
} backdrop-blur-lg`}
>
<div className={`flex ${isActive ? 'h-[140px]' : 'h-[110px]'} transition-all duration-100`}>
{/* Photo thumbnail */}
{thumbSrc ? (
<div className={`${isActive ? 'w-[110px]' : 'w-[90px]'} flex-shrink-0 relative overflow-hidden transition-all duration-100`}>
<img
src={thumbSrc}
alt=""
className="w-full h-full object-cover"
loading="lazy"
/>
{hasPhotos && entry.photos!.length > 1 && (
<div className="absolute bottom-1 right-1 flex items-center gap-0.5 bg-black/60 text-white rounded px-1 py-0.5 text-[10px] font-medium">
<Camera size={10} />
{entry.photos!.length}
</div>
)}
</div>
) : (
<div className={`${isActive ? 'w-[110px]' : 'w-[90px]'} flex-shrink-0 bg-zinc-100 dark:bg-zinc-700 flex items-center justify-center transition-all duration-100`}>
<MapPin size={20} className="text-zinc-300 dark:text-zinc-500" />
</div>
)}
{/* Content */}
<div className="flex-1 p-3 flex flex-col min-w-0">
{/* Day number + date + mood/weather */}
<div className="flex items-center gap-1.5 mb-1">
<span className="w-5 h-5 rounded bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 text-[10px] font-bold flex items-center justify-center flex-shrink-0">
{index + 1}
</span>
<span className="text-[11px] text-zinc-400 font-medium">{dateStr}</span>
{entry.entry_time && (
<span className="text-[11px] text-zinc-400">· {entry.entry_time.slice(0, 5)}</span>
)}
<div className="flex items-center gap-1.5 ml-auto flex-shrink-0">
{MoodIcon && (
<span className={`inline-flex items-center justify-center w-5 h-5 rounded-full ${
entry.mood === 'amazing' ? 'bg-pink-100 dark:bg-pink-900/30' :
entry.mood === 'good' ? 'bg-amber-100 dark:bg-amber-900/30' :
entry.mood === 'rough' ? 'bg-violet-100 dark:bg-violet-900/30' :
'bg-zinc-100 dark:bg-zinc-700'
}`}>
<MoodIcon size={11} className={moodColor} />
</span>
)}
{WeatherIcon && (
<span className="inline-flex items-center justify-center w-5 h-5 rounded-full bg-zinc-100 dark:bg-zinc-700">
<WeatherIcon size={11} className="text-zinc-500 dark:text-zinc-400" />
</span>
)}
</div>
</div>
{/* Title */}
<h4 className="text-[13px] font-semibold text-zinc-900 dark:text-white leading-tight truncate">
{entry.title || (entry.type === 'checkin' ? 'Check-in' : entry.type === 'skeleton' ? 'Add your story…' : 'Untitled')}
</h4>
{/* Story preview (1-2 lines, only on active card) */}
{isActive && storyPreview && (
<p className="text-[11px] text-zinc-400 dark:text-zinc-500 leading-snug mt-0.5 line-clamp-2">
{storyPreview}
</p>
)}
{/* Location badge */}
<div className="flex items-center gap-1 mt-auto">
{hasLocation ? (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-zinc-100 dark:bg-zinc-700 text-[10px] font-medium text-zinc-600 dark:text-zinc-300 max-w-full overflow-hidden">
<MapPin size={10} className="flex-shrink-0" />
<span className="truncate">{entry.location_name || 'On the map'}</span>
</span>
) : (
<span className="text-[10px] text-zinc-400 italic">No location</span>
)}
</div>
</div>
</div>
</button>
)
}
@@ -0,0 +1,218 @@
import { useState } from 'react'
import {
X, Pencil, Trash2, MapPin, Clock, Camera,
Laugh, Smile, Meh, Frown,
Sun, CloudSun, Cloud, CloudRain, CloudLightning, Snowflake,
ThumbsUp, ThumbsDown, ChevronDown,
} from 'lucide-react'
import JournalBody from './JournalBody'
import type { JourneyEntry, JourneyPhoto } from '../../store/journeyStore'
const MOOD_CONFIG: Record<string, { icon: typeof Smile; label: string; bg: string; text: string }> = {
amazing: { icon: Laugh, label: 'Amazing', bg: 'bg-pink-50 dark:bg-pink-900/20', text: 'text-pink-600 dark:text-pink-400' },
good: { icon: Smile, label: 'Good', bg: 'bg-amber-50 dark:bg-amber-900/20', text: 'text-amber-600 dark:text-amber-400' },
neutral: { icon: Meh, label: 'Neutral', bg: 'bg-zinc-100 dark:bg-zinc-800', text: 'text-zinc-500 dark:text-zinc-400' },
rough: { icon: Frown, label: 'Rough', bg: 'bg-violet-50 dark:bg-violet-900/20', text: 'text-violet-600 dark:text-violet-400' },
}
const WEATHER_CONFIG: Record<string, { icon: typeof Sun; label: string }> = {
sunny: { icon: Sun, label: 'Sunny' },
partly: { icon: CloudSun, label: 'Partly cloudy' },
cloudy: { icon: Cloud, label: 'Cloudy' },
rainy: { icon: CloudRain, label: 'Rainy' },
stormy: { icon: CloudLightning, label: 'Stormy' },
cold: { icon: Snowflake, label: 'Cold' },
}
function photoUrl(p: JourneyPhoto, size: 'thumbnail' | 'original' = 'original'): string {
return `/api/photos/${p.photo_id}/${size}`
}
interface Props {
entry: JourneyEntry
onClose: () => void
onEdit: () => void
onDelete: () => void
onPhotoClick: (photos: JourneyPhoto[], index: number) => void
}
export default function MobileEntryView({ entry, onClose, onEdit, onDelete, onPhotoClick }: Props) {
const photos = entry.photos || []
const mood = entry.mood ? MOOD_CONFIG[entry.mood] : null
const weather = entry.weather ? WEATHER_CONFIG[entry.weather] : null
const prosArr = entry.pros_cons?.pros ?? []
const consArr = entry.pros_cons?.cons ?? []
const hasProscons = prosArr.length > 0 || consArr.length > 0
const date = new Date(entry.entry_date + 'T00:00:00')
const dateStr = date.toLocaleDateString(undefined, { weekday: 'long', day: 'numeric', month: 'long' })
return (
<div className="fixed inset-0 z-50 bg-white dark:bg-zinc-950 flex flex-col overflow-hidden" style={{ height: '100dvh' }}>
{/* Top bar */}
<div className="flex items-center justify-between px-4 py-3 border-b border-zinc-100 dark:border-zinc-800 flex-shrink-0">
<button
onClick={onClose}
className="w-9 h-9 rounded-lg flex items-center justify-center text-zinc-500 hover:bg-zinc-100 dark:hover:bg-zinc-800 transition-colors"
>
<X size={20} />
</button>
<div className="flex items-center gap-1.5">
<button
onClick={() => { onClose(); onEdit(); }}
className="h-8 px-3 rounded-lg bg-zinc-100 dark:bg-zinc-800 text-zinc-700 dark:text-zinc-300 text-[12px] font-medium flex items-center gap-1.5 hover:bg-zinc-200 dark:hover:bg-zinc-700 transition-colors"
>
<Pencil size={13} />
Edit
</button>
<button
onClick={() => { onClose(); onDelete(); }}
className="w-8 h-8 rounded-lg flex items-center justify-center text-zinc-400 hover:bg-red-50 hover:text-red-500 dark:hover:bg-red-900/20 dark:hover:text-red-400 transition-colors"
>
<Trash2 size={15} />
</button>
</div>
</div>
{/* Scrollable content */}
<div className="flex-1 min-h-0 overflow-y-auto overscroll-contain" style={{ WebkitOverflowScrolling: 'touch' }}>
{/* Hero photo(s) */}
{photos.length > 0 && (
<div className="relative">
<img
src={photoUrl(photos[0])}
alt=""
className="w-full max-h-[50vh] object-cover cursor-pointer"
onClick={() => onPhotoClick(photos, 0)}
/>
{photos.length > 1 && (
<div className="absolute bottom-3 right-3 flex items-center gap-1 bg-black/60 backdrop-blur-sm text-white rounded-full px-2.5 py-1 text-[11px] font-medium">
<Camera size={12} />
{photos.length} photos
</div>
)}
{/* Photo strip for multiple photos */}
{photos.length > 1 && (
<div className="flex gap-1 px-4 py-2 overflow-x-auto bg-zinc-50 dark:bg-zinc-900">
{photos.map((p, i) => (
<img
key={p.id || i}
src={photoUrl(p, 'thumbnail')}
alt=""
className="w-16 h-16 rounded-lg object-cover flex-shrink-0 cursor-pointer hover:ring-2 ring-zinc-900/30 dark:ring-white/30 transition-all"
onClick={() => onPhotoClick(photos, i)}
/>
))}
</div>
)}
</div>
)}
{/* Content */}
<div className="px-5 py-5 pb-32">
{/* Date + time + location header */}
<div className="flex flex-wrap items-center gap-2 mb-3">
<span className="text-[12px] font-medium text-zinc-500">{dateStr}</span>
{entry.entry_time && (
<span className="flex items-center gap-1 text-[12px] text-zinc-400">
<Clock size={11} />
{entry.entry_time.slice(0, 5)}
</span>
)}
</div>
{entry.location_name && (
<div className="mb-3">
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full bg-zinc-100 dark:bg-zinc-800 text-[12px] font-medium text-zinc-700 dark:text-zinc-300">
<MapPin size={12} className="text-zinc-500 dark:text-zinc-400 flex-shrink-0" />
{entry.location_name}
</span>
</div>
)}
{/* Title */}
{entry.title && (
<h1 className="text-[22px] font-bold text-zinc-900 dark:text-white tracking-tight leading-tight mb-4">
{entry.title}
</h1>
)}
{/* Mood + Weather chips */}
{(mood || weather) && (
<div className="flex items-center gap-2 mb-4">
{mood && (
<span className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-[11px] font-semibold ${mood.bg} ${mood.text}`}>
<mood.icon size={13} />
{mood.label}
</span>
)}
{weather && (
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-[11px] font-semibold bg-zinc-100 dark:bg-zinc-800 text-zinc-600 dark:text-zinc-400">
<weather.icon size={13} />
{weather.label}
</span>
)}
</div>
)}
{/* Story */}
{entry.story && (
<div className="text-[14px] leading-relaxed text-zinc-700 dark:text-zinc-300 mb-5">
<JournalBody text={entry.story} />
</div>
)}
{/* Tags */}
{entry.tags && entry.tags.length > 0 && (
<div className="flex flex-wrap gap-1.5 mb-5">
{entry.tags.map((tag, i) => (
<span key={i} className="text-[11px] font-medium px-2 py-0.5 rounded-full bg-indigo-50 dark:bg-indigo-900/30 text-indigo-600 dark:text-indigo-400">
{tag}
</span>
))}
</div>
)}
{/* Pros & Cons */}
{hasProscons && (
<div className="border border-zinc-200 dark:border-zinc-700 rounded-xl overflow-hidden mb-5">
{prosArr.length > 0 && (
<div className="px-4 py-3">
<div className="flex items-center gap-1.5 text-[11px] font-semibold text-emerald-600 dark:text-emerald-400 uppercase tracking-wide mb-2">
<ThumbsUp size={12} /> Pros
</div>
<ul className="space-y-1">
{prosArr.map((p, i) => (
<li key={i} className="text-[13px] text-zinc-700 dark:text-zinc-300 flex items-start gap-2">
<span className="text-emerald-500 mt-0.5">+</span> {p}
</li>
))}
</ul>
</div>
)}
{prosArr.length > 0 && consArr.length > 0 && (
<div className="border-t border-zinc-200 dark:border-zinc-700" />
)}
{consArr.length > 0 && (
<div className="px-4 py-3">
<div className="flex items-center gap-1.5 text-[11px] font-semibold text-red-500 dark:text-red-400 uppercase tracking-wide mb-2">
<ThumbsDown size={12} /> Cons
</div>
<ul className="space-y-1">
{consArr.map((c, i) => (
<li key={i} className="text-[13px] text-zinc-700 dark:text-zinc-300 flex items-start gap-2">
<span className="text-red-500 mt-0.5"></span> {c}
</li>
))}
</ul>
</div>
)}
</div>
)}
</div>
</div>
</div>
)
}
@@ -0,0 +1,194 @@
import { useRef, useState, useEffect, useCallback } from 'react'
import { Plus } from 'lucide-react'
import JourneyMap from './JourneyMap'
import MobileEntryCard from './MobileEntryCard'
import type { JourneyMapHandle } from './JourneyMap'
import type { JourneyEntry } from '../../store/journeyStore'
interface MapEntry {
id: string
lat: number
lng: number
title?: string | null
mood?: string | null
entry_date: string
}
interface Props {
entries: JourneyEntry[] | any[]
mapEntries: MapEntry[]
trail?: { lat: number; lng: number }[]
dark?: boolean
readOnly?: boolean
onEntryClick: (entry: any) => void
onAddEntry?: () => void
publicPhotoUrl?: (photoId: number) => string
}
export default function MobileMapTimeline({
entries,
mapEntries,
trail,
dark,
readOnly,
onEntryClick,
onAddEntry,
publicPhotoUrl,
}: Props) {
const mapRef = useRef<JourneyMapHandle>(null)
const carouselRef = useRef<HTMLDivElement>(null)
const [activeIndex, setActiveIndex] = useState(0)
const cardRefs = useRef<Map<number, HTMLDivElement>>(new Map())
// Sync map focus when carousel scrolls (with guard for uninitialized map)
const syncMapToCarousel = useCallback((index: number) => {
const entry = entries[index]
if (!entry) return
const mapEntry = mapEntries.find(m => String(m.id) === String(entry.id))
if (mapEntry) {
try { mapRef.current?.focusMarker(String(mapEntry.id)) } catch {}
} else {
try { mapRef.current?.highlightMarker(null) } catch {}
}
}, [entries, mapEntries])
// IntersectionObserver for instant snap detection
useEffect(() => {
const el = carouselRef.current
if (!el || entries.length === 0) return
const observer = new IntersectionObserver(
(observed) => {
for (const o of observed) {
if (o.isIntersecting) {
const idx = Number(o.target.getAttribute('data-idx'))
if (!isNaN(idx)) {
setActiveIndex(idx)
syncMapToCarousel(idx)
}
}
}
},
{ root: el, threshold: 0.6 },
)
cardRefs.current.forEach(node => observer.observe(node))
return () => observer.disconnect()
}, [entries.length, syncMapToCarousel])
// Scroll carousel to entry when map marker is clicked
const handleMarkerClick = useCallback((id: string) => {
const idx = entries.findIndex((e: any) => String(e.id) === id)
if (idx === -1) return
setActiveIndex(idx)
const el = carouselRef.current
if (!el) return
const cardWidth = 272
el.scrollTo({ left: idx * cardWidth, behavior: 'smooth' })
}, [entries])
// Initial map focus — delay to let Leaflet initialize and fitBounds
useEffect(() => {
if (entries.length > 0) {
const timer = setTimeout(() => syncMapToCarousel(0), 500)
return () => clearTimeout(timer)
}
}, [entries.length])
const activeEntryId = entries[activeIndex]
? String(entries[activeIndex].id)
: null
if (entries.length === 0) {
return (
<div className="fixed inset-0 z-10" style={{ top: 0, bottom: 0 }}>
<JourneyMap
ref={mapRef}
entries={mapEntries}
checkins={[]}
trail={trail}
height={9999}
dark={dark}
onMarkerClick={handleMarkerClick}
fullScreen
/>
{!readOnly && onAddEntry && (
<div className="fixed top-[calc(var(--nav-h,56px)+12px)] right-4 z-30">
<button
onClick={onAddEntry}
className="w-10 h-10 rounded-lg bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 shadow-lg flex items-center justify-center hover:scale-105 active:scale-95 transition-transform"
>
<Plus size={18} />
</button>
</div>
)}
</div>
)
}
return (
<div className="fixed inset-0 z-10" style={{ top: 0, bottom: 0 }}>
{/* Full-screen map */}
<JourneyMap
ref={mapRef}
entries={mapEntries}
checkins={[]}
trail={trail}
height={9999}
dark={dark}
activeMarkerId={activeEntryId}
onMarkerClick={handleMarkerClick}
fullScreen
paddingBottom={200}
/>
{/* Bottom carousel */}
<div
className="fixed bottom-20 left-0 right-0 z-40"
style={{ touchAction: 'pan-x' }}
>
<div
ref={carouselRef}
className="flex gap-3 overflow-x-auto px-4 pb-3 pt-1 scroll-smooth"
style={{
scrollSnapType: 'x mandatory',
WebkitOverflowScrolling: 'touch',
scrollbarWidth: 'none',
msOverflowStyle: 'none',
}}
>
{entries.map((entry: any, i: number) => (
<div
key={entry.id}
data-idx={i}
ref={node => { if (node) cardRefs.current.set(i, node); else cardRefs.current.delete(i); }}
style={{ scrollSnapAlign: 'center' }}
>
<MobileEntryCard
entry={entry}
index={i}
isActive={i === activeIndex}
onClick={() => onEntryClick(entry)}
publicPhotoUrl={publicPhotoUrl}
/>
</div>
))}
</div>
</div>
{/* FAB: add entry — top right */}
{!readOnly && onAddEntry && (
<div className="fixed top-[calc(var(--nav-h,56px)+12px)] right-4 z-30">
<button
onClick={onAddEntry}
className="w-10 h-10 rounded-lg bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 shadow-lg flex items-center justify-center hover:scale-105 active:scale-95 transition-transform"
>
<Plus size={18} />
</button>
</div>
)}
</div>
)
}
@@ -32,6 +32,7 @@ vi.mock('react-leaflet', () => ({
off: vi.fn(),
panBy: vi.fn(),
}),
useMapEvents: () => ({}),
}))
vi.mock('react-leaflet-cluster', () => ({
+19 -1
View File
@@ -8,6 +8,8 @@ import 'leaflet.markercluster/dist/MarkerCluster.css'
import 'leaflet.markercluster/dist/MarkerCluster.Default.css'
import { mapsApi } from '../../api/client'
import { getCategoryIcon, CATEGORY_ICON_MAP } from '../shared/categoryIcons'
import ReservationOverlay from './ReservationOverlay'
import type { Reservation } from '../../types'
function categoryIconSvg(iconName: string | null | undefined, size: number): string {
const IconComponent = (iconName && CATEGORY_ICON_MAP[iconName]) || CATEGORY_ICON_MAP['MapPin']
@@ -384,7 +386,16 @@ export const MapView = memo(function MapView({
rightWidth = 0,
hasInspector = false,
hasDayDetail = false,
}) {
reservations = [] as Reservation[],
showReservationStats = false,
visibleConnectionIds = [] as number[],
onReservationClick,
}: any) {
const visibleReservations = useMemo(() => {
if (!visibleConnectionIds || visibleConnectionIds.length === 0) return []
const set = new Set(visibleConnectionIds)
return reservations.filter((r: Reservation) => set.has(r.id))
}, [reservations, visibleConnectionIds])
// Dynamic padding: account for sidebars + bottom inspector + day detail panel
const paddingOpts = useMemo(() => {
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768
@@ -569,6 +580,13 @@ export const MapView = memo(function MapView({
)
} catch { return null }
})}
<ReservationOverlay
reservations={visibleReservations}
showConnections
showStats={showReservationStats}
onEndpointClick={onReservationClick}
/>
</MapContainer>
)
})
@@ -0,0 +1,447 @@
import { createElement, useEffect, useMemo, useRef, useState } from 'react'
import { renderToStaticMarkup } from 'react-dom/server'
import { Marker, Polyline, Tooltip, useMap, useMapEvents } from 'react-leaflet'
import L from 'leaflet'
import { Plane, Train, Ship, Car } from 'lucide-react'
import { useSettingsStore } from '../../store/settingsStore'
import type { Reservation, ReservationEndpoint } from '../../types'
const ENDPOINT_PANE = 'reservation-endpoints'
const AIRPORT_BADGE_HALF_PX = 16
const BADGE_GAP_PX = 5
type TransportType = 'flight' | 'train' | 'cruise' | 'car'
const TRANSPORT_TYPES: TransportType[] = ['flight', 'train', 'cruise', 'car']
const TRANSPORT_COLOR = '#3b82f6'
const TYPE_META: Record<TransportType, { color: string; icon: typeof Plane; geodesic: boolean }> = {
flight: { color: TRANSPORT_COLOR, icon: Plane, geodesic: true },
train: { color: TRANSPORT_COLOR, icon: Train, geodesic: false },
cruise: { color: TRANSPORT_COLOR, icon: Ship, geodesic: true },
car: { color: TRANSPORT_COLOR, icon: Car, geodesic: false },
}
function useEndpointPane() {
const map = useMap()
useMemo(() => {
if (typeof map?.getPane !== 'function' || typeof map?.createPane !== 'function') return
if (!map.getPane(ENDPOINT_PANE)) {
const pane = map.createPane(ENDPOINT_PANE)
pane.style.zIndex = '650'
pane.style.pointerEvents = 'auto'
}
}, [map])
}
function endpointIcon(type: TransportType, label: string | null): L.DivIcon {
const { icon: IconCmp, color } = TYPE_META[type]
const svg = renderToStaticMarkup(createElement(IconCmp, { size: 13, color: 'white', strokeWidth: 2.5 }))
const labelHtml = label ? `<span>${label}</span>` : ''
const estWidth = label ? Math.max(40, label.length * 6 + 28) : 26
return L.divIcon({
className: 'trek-endpoint-marker',
html: `<div style="
display:inline-flex;align-items:center;justify-content:center;gap:4px;
padding:0 8px;border-radius:999px;
background:${color};box-shadow:0 2px 6px rgba(0,0,0,0.25);
border:1.5px solid #fff;color:#fff;
font-family:-apple-system,system-ui,sans-serif;font-size:11px;font-weight:600;letter-spacing:0.3px;line-height:1;
box-sizing:border-box;height:22px;white-space:nowrap;
"><span style="display:inline-flex;align-items:center;">${svg}</span>${labelHtml ? `<span style="display:inline-flex;align-items:center;line-height:1">${label}</span>` : ''}</div>`,
iconSize: [estWidth, 22],
iconAnchor: [estWidth / 2, 11],
popupAnchor: [0, -11],
})
}
function toRad(d: number) { return d * Math.PI / 180 }
function toDeg(r: number) { return r * 180 / Math.PI }
function greatCircle(a: [number, number], b: [number, number], steps = 256): [number, number][] {
const [lat1, lng1] = [toRad(a[0]), toRad(a[1])]
const [lat2, lng2] = [toRad(b[0]), toRad(b[1])]
const d = 2 * Math.asin(Math.sqrt(Math.sin((lat2 - lat1) / 2) ** 2 + Math.cos(lat1) * Math.cos(lat2) * Math.sin((lng2 - lng1) / 2) ** 2))
if (d === 0) return [a, b]
const pts: [number, number][] = []
for (let i = 0; i <= steps; i++) {
const f = i / steps
const A = Math.sin((1 - f) * d) / Math.sin(d)
const B = Math.sin(f * d) / Math.sin(d)
const x = A * Math.cos(lat1) * Math.cos(lng1) + B * Math.cos(lat2) * Math.cos(lng2)
const y = A * Math.cos(lat1) * Math.sin(lng1) + B * Math.cos(lat2) * Math.sin(lng2)
const z = A * Math.sin(lat1) + B * Math.sin(lat2)
const lat = Math.atan2(z, Math.sqrt(x * x + y * y))
const lng = Math.atan2(y, x)
pts.push([toDeg(lat), toDeg(lng)])
}
return pts
}
function splitAntimeridian(points: [number, number][]): [number, number][][] {
const segments: [number, number][][] = []
let cur: [number, number][] = []
for (let i = 0; i < points.length; i++) {
if (i > 0 && Math.abs(points[i][1] - points[i - 1][1]) > 180) {
if (cur.length > 1) segments.push(cur)
cur = []
}
cur.push(points[i])
}
if (cur.length > 1) segments.push(cur)
return segments
}
function cleanName(name: string): string {
return name.replace(/\s*\([^)]*\)/g, '').trim()
}
function haversineKm(a: [number, number], b: [number, number]): number {
const R = 6371
const dLat = toRad(b[0] - a[0])
const dLng = toRad(b[1] - a[1])
const h = Math.sin(dLat / 2) ** 2 + Math.cos(toRad(a[0])) * Math.cos(toRad(b[0])) * Math.sin(dLng / 2) ** 2
return 2 * R * Math.asin(Math.sqrt(h))
}
function parseInTz(isoLocal: string, tz: string): number {
const [datePart, timePart] = isoLocal.split('T')
const [y, mo, d] = datePart.split('-').map(Number)
const [h, mi] = (timePart || '00:00').split(':').map(Number)
const guess = Date.UTC(y, mo - 1, d, h, mi)
const fmt = new Intl.DateTimeFormat('en-US', {
timeZone: tz, hour12: false,
year: 'numeric', month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit', second: '2-digit',
})
const parts = Object.fromEntries(fmt.formatToParts(new Date(guess)).filter(p => p.type !== 'literal').map(p => [p.type, p.value]))
const asUtc = Date.UTC(Number(parts.year), Number(parts.month) - 1, Number(parts.day), Number(parts.hour) % 24, Number(parts.minute), Number(parts.second))
return guess - (asUtc - guess)
}
function computeDuration(from: ReservationEndpoint, to: ReservationEndpoint, fallbackStart: string | null, fallbackEnd: string | null): string | null {
let start = from.local_date && from.local_time ? `${from.local_date}T${from.local_time}` : fallbackStart
let end = to.local_date && to.local_time ? `${to.local_date}T${to.local_time}` : fallbackEnd
if (!start || !end) return null
if (!start.includes('T') && end.includes('T')) start = `${end.split('T')[0]}T${start}`
if (!end.includes('T') && start.includes('T')) end = `${start.split('T')[0]}T${end}`
if (!start.includes('T') || !end.includes('T')) return null
const fromTz = from.timezone || to.timezone
const toTz = to.timezone || fromTz
let startMs: number, endMs: number
if (fromTz && toTz) {
startMs = parseInTz(start, fromTz)
endMs = parseInTz(end, toTz)
} else {
startMs = new Date(start).getTime()
endMs = new Date(end).getTime()
}
if (!Number.isFinite(startMs) || !Number.isFinite(endMs)) return null
if (endMs <= startMs) endMs += 24 * 60 * 60000
const minutes = Math.round((endMs - startMs) / 60000)
if (minutes <= 0 || minutes > 48 * 60) return null
const h = Math.floor(minutes / 60)
const m = minutes % 60
return h > 0 ? `${h}h ${m}m` : `${m}m`
}
interface TransportItem {
res: Reservation
from: ReservationEndpoint
to: ReservationEndpoint
type: TransportType
arcs: [number, number][][]
primaryArc: [number, number][]
fallback: [number, number]
mainLabel: string | null
subLabel: string | null
}
function buildStatsHtml(color: string, mainLabel: string | null, subLabel: string | null): { html: string; width: number; height: number } {
const estWidth = Math.max(
mainLabel ? mainLabel.length * 6.5 : 0,
subLabel ? subLabel.length * 5.5 : 0,
) + 22
const hasBoth = !!mainLabel && !!subLabel
const height = hasBoth ? 36 : 22
const main = mainLabel ? `<span style="font-size:12px;font-weight:700;line-height:1;display:block">${mainLabel}</span>` : ''
const sub = subLabel ? `<span style="font-size:10px;font-weight:500;line-height:1;opacity:0.85;display:block${hasBoth ? ';margin-top:4px' : ''}">${subLabel}</span>` : ''
const html = `<div class="trek-stats-inner" style="
display:flex;flex-direction:column;align-items:center;justify-content:center;
width:100%;height:100%;
padding:0 11px;border-radius:999px;
background:rgba(17,24,39,0.92);color:#fff;
box-shadow:0 2px 6px rgba(0,0,0,0.25);
border:1px solid ${color}aa;
font-family:-apple-system,system-ui,'SF Pro Text',sans-serif;
white-space:nowrap;box-sizing:border-box;
transform-origin:center;
will-change:transform;
">${main}${sub}</div>`
return { html, width: estWidth, height }
}
function StatsLabel({ item }: { item: TransportItem }) {
const map = useMap()
const markerRef = useRef<L.Marker | null>(null)
const innerRef = useRef<HTMLElement | null>(null)
const arc = item.primaryArc
const color = TYPE_META[item.type].color
const { html, width, height } = useMemo(() => buildStatsHtml(color, item.mainLabel, item.subLabel), [color, item.mainLabel, item.subLabel])
const buffer = AIRPORT_BADGE_HALF_PX + width / 2 + BADGE_GAP_PX
const compute = () => {
if (arc.length < 2) return null
const size = map.getSize()
const pts = arc.map(p => map.latLngToContainerPoint(p as L.LatLngTuple))
const cum: number[] = [0]
let total = 0
for (let i = 1; i < pts.length; i++) {
total += pts[i].distanceTo(pts[i - 1])
cum.push(total)
}
if (total <= 0) return null
const fromPx = map.latLngToContainerPoint([item.from.lat, item.from.lng])
const toPx = map.latLngToContainerPoint([item.to.lat, item.to.lng])
const isIn = (p: L.Point) => {
if (p.x < -40 || p.x > size.x + 40 || p.y < -40 || p.y > size.y + 40) return false
if (p.distanceTo(fromPx) < buffer) return false
if (p.distanceTo(toPx) < buffer) return false
return true
}
let firstIdx = -1
let lastIdx = -1
for (let i = 0; i < pts.length; i++) {
if (isIn(pts[i])) {
if (firstIdx < 0) firstIdx = i
lastIdx = i
}
}
if (firstIdx < 0) {
const target = total / 2
let sIdx = 0
while (sIdx < cum.length - 2 && cum[sIdx + 1] < target) sIdx++
const span = cum[sIdx + 1] - cum[sIdx]
const tm = span > 0 ? (target - cum[sIdx]) / span : 0
const pA = pts[sIdx]
const pB = pts[sIdx + 1]
const mx = pA.x + (pB.x - pA.x) * tm
const my = pA.y + (pB.y - pA.y) * tm
const latlng = map.containerPointToLatLng([mx, my])
let angle = Math.atan2(pB.y - pA.y, pB.x - pA.x) * 180 / Math.PI
if (angle > 90) angle -= 180
if (angle < -90) angle += 180
return { point: [latlng.lat, latlng.lng] as [number, number], angle }
}
const bisectFraction = (a: L.Point, b: L.Point) => {
let lo = 0, hi = 1
for (let k = 0; k < 10; k++) {
const mid = (lo + hi) / 2
const mp = L.point(a.x + (b.x - a.x) * mid, a.y + (b.y - a.y) * mid)
if (isIn(mp)) hi = mid
else lo = mid
}
return (lo + hi) / 2
}
let lowCum = cum[firstIdx]
if (firstIdx > 0) {
const t = bisectFraction(pts[firstIdx - 1], pts[firstIdx])
lowCum = cum[firstIdx - 1] + (cum[firstIdx] - cum[firstIdx - 1]) * t
}
let highCum = cum[lastIdx]
if (lastIdx < pts.length - 1) {
const t = bisectFraction(pts[lastIdx + 1], pts[lastIdx])
highCum = cum[lastIdx] + (cum[lastIdx + 1] - cum[lastIdx]) * (1 - t)
}
const targetLen = (lowCum + highCum) / 2
let segIdx = 0
while (segIdx < cum.length - 2 && cum[segIdx + 1] < targetLen) segIdx++
const segSpan = cum[segIdx + 1] - cum[segIdx]
const t = segSpan > 0 ? (targetLen - cum[segIdx]) / segSpan : 0
const pA = pts[segIdx]
const pB = pts[segIdx + 1]
const px = pA.x + (pB.x - pA.x) * t
const py = pA.y + (pB.y - pA.y) * t
const latlng = map.containerPointToLatLng([px, py])
let angle = Math.atan2(pB.y - pA.y, pB.x - pA.x) * 180 / Math.PI
if (angle > 90) angle -= 180
if (angle < -90) angle += 180
return { point: [latlng.lat, latlng.lng] as [number, number], angle }
}
const apply = () => {
const pose = compute()
const marker = markerRef.current
if (!marker) return
const el = marker.getElement() as HTMLElement | null
if (!pose) {
if (el) el.style.display = 'none'
return
}
if (el) el.style.display = ''
marker.setLatLng(pose.point as L.LatLngTuple)
if (!innerRef.current && el) innerRef.current = el.querySelector('.trek-stats-inner') as HTMLElement | null
if (innerRef.current) innerRef.current.style.transform = `rotate(${pose.angle}deg)`
}
useEffect(() => {
const icon = L.divIcon({
className: 'trek-endpoint-stats',
html,
iconSize: [width, height],
iconAnchor: [width / 2, height / 2],
})
const marker = L.marker([0, 0], { icon, pane: ENDPOINT_PANE, interactive: false, keyboard: false })
marker.addTo(map)
markerRef.current = marker
innerRef.current = null
apply()
return () => {
marker.remove()
markerRef.current = null
innerRef.current = null
}
}, [map, html, width, height])
useMapEvents({
move: apply,
zoom: apply,
viewreset: apply,
resize: apply,
})
return null
}
interface Props {
reservations: Reservation[]
showConnections: boolean
showStats: boolean
onEndpointClick?: (reservationId: number) => void
}
export default function ReservationOverlay({ reservations, showConnections, showStats, onEndpointClick }: Props) {
useEndpointPane()
const map = useMap()
const [zoom, setZoom] = useState(() => map.getZoom())
useMapEvents({
zoomend: () => setZoom(map.getZoom()),
})
const showEndpointLabels = useSettingsStore(s => s.settings.map_booking_labels) !== false
const items = useMemo<TransportItem[]>(() => {
const out: TransportItem[] = []
for (const r of reservations) {
if (!TRANSPORT_TYPES.includes(r.type as TransportType)) continue
const eps = r.endpoints || []
const from = eps.find(e => e.role === 'from')
const to = eps.find(e => e.role === 'to')
if (!from || !to) continue
const type = r.type as TransportType
const isGeo = TYPE_META[type].geodesic
const arcs = isGeo
? splitAntimeridian(greatCircle([from.lat, from.lng], [to.lat, to.lng]))
: [[[from.lat, from.lng], [to.lat, to.lng]] as [number, number][]]
const primaryIdx = arcs.reduce((best, seg, idx, all) => seg.length > all[best].length ? idx : best, 0)
const primaryArc = arcs[primaryIdx] ?? []
const fallback: [number, number] = primaryArc.length > 0
? (primaryArc[Math.floor(primaryArc.length / 2)] ?? [(from.lat + to.lat) / 2, (from.lng + to.lng) / 2])
: [(from.lat + to.lat) / 2, (from.lng + to.lng) / 2]
const duration = computeDuration(from, to, r.reservation_time || null, r.reservation_end_time || null)
const distance = `${Math.round(haversineKm([from.lat, from.lng], [to.lat, to.lng]))} km`
const mainLabel = from.code && to.code ? `${from.code}${to.code}` : null
const subParts = [duration, distance].filter(Boolean) as string[]
const subLabel = subParts.length > 0 ? subParts.join(' · ') : null
out.push({ res: r, from, to, type, arcs, primaryArc, fallback, mainLabel, subLabel })
}
return out
}, [reservations])
const visibleItems = useMemo(() => {
return items.filter(item => {
const fromPx = map.latLngToContainerPoint([item.from.lat, item.from.lng])
const toPx = map.latLngToContainerPoint([item.to.lat, item.to.lng])
const minPx = item.type === 'flight' ? 50 : item.type === 'cruise' ? 150 : item.type === 'car' ? 80 : 200
return fromPx.distanceTo(toPx) >= minPx
})
}, [items, zoom, map])
const labelVisibleIds = useMemo(() => {
const set = new Set<number>()
for (const item of visibleItems) {
const fromPx = map.latLngToContainerPoint([item.from.lat, item.from.lng])
const toPx = map.latLngToContainerPoint([item.to.lat, item.to.lng])
const minPx = item.type === 'flight' ? 50 : item.type === 'cruise' ? 300 : item.type === 'car' ? 150 : 400
if (fromPx.distanceTo(toPx) >= minPx) set.add(item.res.id)
}
return set
}, [visibleItems, zoom, map])
if (!showConnections) return null
return (
<>
{visibleItems.map(item => item.arcs.map((seg, segIdx) => (
<Polyline
key={`line-${item.res.id}-${segIdx}`}
positions={seg}
pathOptions={{
color: TYPE_META[item.type].color,
weight: 2.5,
opacity: item.res.status === 'confirmed' ? 0.75 : 0.55,
dashArray: item.res.status === 'confirmed' ? undefined : '6, 6',
}}
/>
)))}
{visibleItems.flatMap(item => [
<Marker
key={`from-${item.res.id}`}
position={[item.from.lat, item.from.lng]}
icon={endpointIcon(item.type, showEndpointLabels && labelVisibleIds.has(item.res.id) ? (item.from.code || cleanName(item.from.name)) : null)}
pane={ENDPOINT_PANE}
zIndexOffset={1000}
eventHandlers={{ click: () => onEndpointClick?.(item.res.id) }}
>
<Tooltip direction="top" offset={[0, -8]} opacity={1} className="map-tooltip">
<div style={{ fontWeight: 600, fontSize: 12 }}>{item.from.name}</div>
{item.res.title && <div style={{ fontSize: 11, color: 'var(--text-muted)' }}>{item.res.title}</div>}
</Tooltip>
</Marker>,
<Marker
key={`to-${item.res.id}`}
position={[item.to.lat, item.to.lng]}
icon={endpointIcon(item.type, showEndpointLabels && labelVisibleIds.has(item.res.id) ? (item.to.code || cleanName(item.to.name)) : null)}
pane={ENDPOINT_PANE}
zIndexOffset={1000}
eventHandlers={{ click: () => onEndpointClick?.(item.res.id) }}
>
<Tooltip direction="top" offset={[0, -8]} opacity={1} className="map-tooltip">
<div style={{ fontWeight: 600, fontSize: 12 }}>{item.to.name}</div>
{item.res.title && <div style={{ fontSize: 11, color: 'var(--text-muted)' }}>{item.res.title}</div>}
</Tooltip>
</Marker>,
])}
{showStats && visibleItems.map(item => item.type === 'flight' && (item.mainLabel || item.subLabel) && labelVisibleIds.has(item.res.id) && (
<StatsLabel key={`stats-${item.res.id}`} item={item} />
))}
</>
)
}
@@ -85,7 +85,7 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
// Album linking
const [showAlbumPicker, setShowAlbumPicker] = useState(false)
const [albums, setAlbums] = useState<{ id: string; albumName: string; assetCount: number }[]>([])
const [albums, setAlbums] = useState<{ id: string; albumName: string; assetCount: number; passphrase?: string }[]>([])
const [albumsLoading, setAlbumsLoading] = useState(false)
const [albumLinks, setAlbumLinks] = useState<{ id: number; provider: string; album_id: string; album_name: string; user_id: number; username: string; sync_enabled: number; last_synced_at: string | null }[]>([])
const [syncing, setSyncing] = useState<number | null>(null)
@@ -141,7 +141,7 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
await loadAlbums(selectedProvider)
}
const linkAlbum = async (albumId: string, albumName: string) => {
const linkAlbum = async (albumId: string, albumName: string, passphrase?: string) => {
if (!selectedProvider) {
toast.error(t('memories.error.linkAlbum'))
return
@@ -152,6 +152,7 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
album_id: albumId,
album_name: albumName,
provider: selectedProvider,
...(passphrase ? { passphrase } : {}),
})
setShowAlbumPicker(false)
await loadAlbumLinks()
@@ -489,7 +490,7 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
{albums.map(album => {
const isLinked = linkedIds.has(album.id)
return (
<button key={album.id} onClick={() => !isLinked && linkAlbum(album.id, album.albumName)}
<button key={album.id} onClick={() => !isLinked && linkAlbum(album.id, album.albumName, album.passphrase)}
disabled={isLinked}
style={{
display: 'flex', alignItems: 'center', gap: 12, width: '100%', padding: '12px 14px',
@@ -729,9 +729,10 @@ function MenuItem({ icon, label, onClick, danger }: MenuItemProps) {
interface PackingListPanelProps {
tripId: number
items: PackingItem[]
openImportSignal?: number
}
export default function PackingListPanel({ tripId, items }: PackingListPanelProps) {
export default function PackingListPanel({ tripId, items, openImportSignal = 0 }: PackingListPanelProps) {
const [filter, setFilter] = useState('alle') // 'alle' | 'offen' | 'erledigt'
const [addingCategory, setAddingCategory] = useState(false)
const [newCatName, setNewCatName] = useState('')
@@ -896,6 +897,14 @@ export default function PackingListPanel({ tripId, items }: PackingListPanelProp
const [saveTemplateName, setSaveTemplateName] = useState('')
const [showImportModal, setShowImportModal] = useState(false)
const [importText, setImportText] = useState('')
const lastHandledImportSignal = useRef(openImportSignal)
useEffect(() => {
if (openImportSignal !== lastHandledImportSignal.current && openImportSignal > 0) {
setShowImportModal(true)
}
lastHandledImportSignal.current = openImportSignal
}, [openImportSignal])
const csvInputRef = useRef<HTMLInputElement>(null)
const templateDropdownRef = useRef<HTMLDivElement>(null)
@@ -999,14 +1008,13 @@ export default function PackingListPanel({ tripId, items }: PackingListPanelProp
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', ...font }}>
{/* ── Header ── */}
<div style={{ padding: '20px 24px 16px', borderBottom: '1px solid rgba(0,0,0,0.06)', flexShrink: 0 }}>
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', marginBottom: 14 }}>
<div>
<h2 style={{ margin: 0, fontSize: 18, fontWeight: 700, color: 'var(--text-primary)' }}>{t('packing.title')}</h2>
<p style={{ margin: '2px 0 0', fontSize: 12.5, color: 'var(--text-faint)' }}>
{items.length === 0 ? t('packing.empty') : t('packing.progress', { packed: abgehakt, total: items.length, percent: fortschritt })}
<div style={{ padding: '0 0 16px', flexShrink: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 14 }}>
{items.length > 0 ? (
<p style={{ margin: 0, fontSize: 12.5, color: 'var(--text-faint)' }}>
{t('packing.progress', { packed: abgehakt, total: items.length, percent: fortschritt })}
</p>
</div>
) : <span />}
<div style={{ display: 'flex', gap: 6 }}>
{canEdit && abgehakt > 0 && (
<button onClick={handleClearChecked} style={{
@@ -1017,15 +1025,6 @@ export default function PackingListPanel({ tripId, items }: PackingListPanelProp
<span className="sm:hidden">{t('packing.clearCheckedShort', { count: abgehakt })}</span>
</button>
)}
{canEdit && (
<button onClick={() => setShowImportModal(true)} style={{
display: 'flex', alignItems: 'center', gap: 5, padding: '5px 11px', borderRadius: 99,
border: '1px solid var(--border-primary)', fontSize: 12, fontWeight: 500, cursor: 'pointer',
fontFamily: 'inherit', background: 'var(--bg-card)', color: 'var(--text-muted)',
}}>
<Upload size={12} /> <span className="hidden sm:inline">{t('packing.import')}</span>
</button>
)}
{canEdit && availableTemplates.length > 0 && (
<div ref={templateDropdownRef} style={{ position: 'relative' }}>
<button onClick={() => setShowTemplateDropdown(v => !v)} disabled={applyingTemplate} style={{
@@ -1151,7 +1150,7 @@ export default function PackingListPanel({ tripId, items }: PackingListPanelProp
{/* ── Filter-Tabs ── */}
{items.length > 0 && (
<div style={{ display: 'flex', gap: 4, padding: '10px 16px 0', flexShrink: 0 }}>
<div style={{ display: 'flex', gap: 4, padding: '10px 0 0', flexShrink: 0 }}>
{[['alle', t('packing.filterAll')], ['offen', t('packing.filterOpen')], ['erledigt', t('packing.filterDone')]].map(([id, label]) => (
<button key={id} onClick={() => setFilter(id)} style={{
padding: '4px 12px', borderRadius: 99, border: 'none', cursor: 'pointer',
@@ -1165,7 +1164,7 @@ export default function PackingListPanel({ tripId, items }: PackingListPanelProp
{/* ── Liste + Bags Sidebar ── */}
<div style={{ flex: 1, display: 'flex', overflow: 'hidden' }}>
<div style={{ flex: 1, overflowY: 'auto', padding: '10px 12px 16px' }}>
<div style={{ flex: 1, overflowY: 'auto', padding: '10px 0 16px' }}>
{items.length === 0 ? (
<div style={{ textAlign: 'center', padding: '60px 20px' }}>
<Luggage size={40} style={{ color: 'var(--text-faint)', display: 'block', margin: '0 auto 10px' }} />
@@ -0,0 +1,155 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { Plane, X } from 'lucide-react'
import { airportsApi } from '../../api/client'
import { useTranslation } from '../../i18n'
export interface Airport {
iata: string
icao: string | null
name: string
city: string
country: string
lat: number
lng: number
tz: string
}
interface Props {
value: Airport | null
onChange: (airport: Airport | null) => void
placeholder?: string
style?: React.CSSProperties
}
function formatLabel(a: Airport) {
return `${a.city || a.name} (${a.iata})`
}
export default function AirportSelect({ value, onChange, placeholder, style }: Props) {
const { t, locale } = useTranslation()
const countryName = useMemo(() => {
try { return new Intl.DisplayNames([locale || 'en'], { type: 'region' }) } catch { return null }
}, [locale])
const displayCountry = (code: string) => {
if (!code) return ''
try { return countryName?.of(code) || code } catch { return code }
}
const [query, setQuery] = useState(value ? formatLabel(value) : '')
const [open, setOpen] = useState(false)
const [results, setResults] = useState<Airport[]>([])
const [highlight, setHighlight] = useState(-1)
const [loading, setLoading] = useState(false)
const wrapRef = useRef<HTMLDivElement>(null)
const abortRef = useRef<AbortController | null>(null)
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
useEffect(() => {
setQuery(value ? formatLabel(value) : '')
}, [value])
useEffect(() => {
const handler = (e: MouseEvent) => {
if (!wrapRef.current?.contains(e.target as Node)) setOpen(false)
}
if (open) document.addEventListener('mousedown', handler)
return () => document.removeEventListener('mousedown', handler)
}, [open])
useEffect(() => {
if (debounceRef.current) clearTimeout(debounceRef.current)
const trimmed = query.trim()
if (trimmed.length < 2 || (value && trimmed === formatLabel(value))) {
setResults([])
return
}
debounceRef.current = setTimeout(async () => {
abortRef.current?.abort()
const controller = new AbortController()
abortRef.current = controller
setLoading(true)
try {
const data = await airportsApi.search(trimmed, controller.signal)
setResults(Array.isArray(data) ? data : [])
setHighlight(-1)
} catch (err: any) {
if (err?.name !== 'AbortError' && err?.name !== 'CanceledError') {
setResults([])
}
} finally {
setLoading(false)
}
}, 220)
return () => { if (debounceRef.current) clearTimeout(debounceRef.current) }
}, [query, value])
const pick = (a: Airport) => {
onChange(a)
setQuery(formatLabel(a))
setOpen(false)
setResults([])
}
const clear = () => {
onChange(null)
setQuery('')
setResults([])
}
const onKey = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (!open || results.length === 0) return
if (e.key === 'ArrowDown') { e.preventDefault(); setHighlight(h => Math.min(h + 1, results.length - 1)) }
else if (e.key === 'ArrowUp') { e.preventDefault(); setHighlight(h => Math.max(h - 1, 0)) }
else if (e.key === 'Enter' && highlight >= 0) { e.preventDefault(); pick(results[highlight]) }
else if (e.key === 'Escape') setOpen(false)
}
return (
<div ref={wrapRef} style={{ position: 'relative', ...style }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, padding: '8px 10px', background: 'var(--bg-tertiary)', borderRadius: 10, border: '1px solid var(--border-primary)' }}>
<Plane size={14} style={{ color: 'var(--text-faint)', flexShrink: 0 }} />
<input
type="text"
value={query}
placeholder={placeholder ?? t('airport.searchPlaceholder')}
onChange={(e) => { setQuery(e.target.value); setOpen(true); if (value) onChange(null) }}
onFocus={() => setOpen(true)}
onKeyDown={onKey}
style={{ flex: 1, minWidth: 0, background: 'transparent', border: 'none', outline: 'none', color: 'var(--text-primary)', fontSize: 13 }}
/>
{value && (
<button type="button" onClick={clear} style={{ background: 'transparent', border: 'none', padding: 2, cursor: 'pointer', color: 'var(--text-faint)', display: 'flex' }} aria-label="Clear">
<X size={14} />
</button>
)}
</div>
{open && (loading || results.length > 0) && (
<div style={{ position: 'absolute', top: 'calc(100% + 4px)', left: 0, right: 0, background: 'var(--bg-card)', border: '1px solid var(--border-primary)', borderRadius: 10, boxShadow: '0 8px 24px rgba(0,0,0,0.18)', maxHeight: 260, overflowY: 'auto', zIndex: 1000 }}>
{loading && results.length === 0 && (
<div style={{ padding: 10, fontSize: 12, color: 'var(--text-faint)' }}>{t('common.loading')}</div>
)}
{results.map((a, i) => (
<button
key={a.iata}
type="button"
onClick={() => pick(a)}
onMouseEnter={() => setHighlight(i)}
style={{
display: 'flex', alignItems: 'center', gap: 8, width: '100%',
padding: '8px 12px', border: 'none', cursor: 'pointer', textAlign: 'left',
background: i === highlight ? 'var(--bg-hover)' : 'transparent',
color: 'var(--text-primary)', fontFamily: 'inherit',
}}
>
<span style={{ fontFamily: 'ui-monospace, SFMono-Regular, monospace', fontSize: 11, fontWeight: 700, color: 'var(--text-muted)', minWidth: 32 }}>{a.iata}</span>
<span style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 13, fontWeight: 500, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{a.city || a.name}</div>
<div style={{ fontSize: 11, color: 'var(--text-faint)', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{a.name}{a.country ? ` · ${displayCountry(a.country)}` : ''}</div>
</span>
</button>
))}
</div>
)}
</div>
)
}
@@ -4,7 +4,7 @@ declare global { interface Window { __dragData: DragDataPayload | null } }
import React, { useState, useEffect, useRef, useMemo } from 'react'
import ReactDOM from 'react-dom'
import { ChevronDown, ChevronRight, ChevronUp, Navigation, RotateCcw, ExternalLink, Clock, Pencil, GripVertical, Ticket, Plus, FileText, Check, Trash2, Info, MapPin, Star, Heart, Camera, Lightbulb, Flag, Bookmark, Train, Bus, Plane, Car, Ship, Coffee, ShoppingBag, AlertTriangle, FileDown, Lock, Hotel, Utensils, Users, Undo2, X } from 'lucide-react'
import { ChevronDown, ChevronRight, ChevronUp, Navigation, RotateCcw, ExternalLink, Clock, Pencil, GripVertical, Ticket, Plus, FileText, Check, Trash2, Info, MapPin, Star, Heart, Camera, Lightbulb, Flag, Bookmark, Train, Bus, Plane, Car, Ship, Coffee, ShoppingBag, AlertTriangle, FileDown, Lock, Hotel, Utensils, Users, Undo2, X, Route as RouteIcon } from 'lucide-react'
const RES_ICONS = { flight: Plane, hotel: Hotel, restaurant: Utensils, train: Train, car: Car, cruise: Ship, event: Ticket, tour: Users, other: FileText }
import { assignmentsApi, reservationsApi } from '../../api/client'
@@ -170,6 +170,10 @@ interface DayPlanSidebarProps {
onEditPlace: (place: Place) => void
onDeletePlace: (placeId: number) => void
reservations?: Reservation[]
visibleConnectionIds?: number[]
onToggleConnection?: (reservationId: number) => void
externalTransportDetail?: Reservation | null
onExternalTransportDetailHandled?: () => void
onAddReservation: () => void
onNavigateToFiles?: () => void
onAddPlace?: () => void
@@ -189,6 +193,10 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
onReorder, onUpdateDayTitle, onRouteCalculated,
onAssignToDay, onRemoveAssignment, onEditPlace, onDeletePlace,
reservations = [],
visibleConnectionIds = [],
onToggleConnection,
externalTransportDetail,
onExternalTransportDetailHandled,
onAddReservation,
onAddPlace,
onAddPlaceToDay,
@@ -234,6 +242,13 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
const [hoveredId, setHoveredId] = useState(null)
const [transportDetail, setTransportDetail] = useState(null)
const [transportPosVersion, setTransportPosVersion] = useState(0)
useEffect(() => {
if (externalTransportDetail) {
setTransportDetail(externalTransportDetail)
onExternalTransportDetailHandled?.()
}
}, [externalTransportDetail, onExternalTransportDetailHandled])
const [timeConfirm, setTimeConfirm] = useState<{
dayId: number; fromId: number; time: string;
// For drag & drop reorder
@@ -1023,7 +1038,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
</div>
{/* Tagesliste */}
<div className="scroll-container" style={{ flex: 1, overflowY: 'auto', minHeight: 0, scrollbarWidth: 'thin', scrollbarColor: 'var(--scrollbar-thumb) transparent' }}>
<div className="scroll-container" style={{ flex: 1, overflowY: 'auto', minHeight: 0 }}>
{days.map((day, index) => {
const isSelected = selectedDayId === day.id
const isExpanded = expandedDays.has(day.id)
@@ -1570,6 +1585,29 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
</div>
)}
</div>
{onToggleConnection && (res.endpoints || []).length >= 2 && (() => {
const active = visibleConnectionIds.includes(res.id)
return (
<button
type="button"
onClick={e => { e.stopPropagation(); onToggleConnection(res.id) }}
title={t(active ? 'map.hideConnections' : 'map.showConnections')}
style={{
flexShrink: 0, appearance: 'none',
width: 26, height: 26, borderRadius: 6,
display: 'grid', placeItems: 'center', cursor: 'pointer',
border: 'none',
background: active ? color : 'transparent',
color: active ? '#fff' : 'var(--text-faint)',
transition: 'all 0.12s',
}}
onMouseEnter={e => { if (!active) e.currentTarget.style.color = 'var(--text-primary)' }}
onMouseLeave={e => { if (!active) e.currentTarget.style.color = 'var(--text-faint)' }}
>
<RouteIcon size={13} />
</button>
)
})()}
</div>
{showDropLineAfter && <div style={{ height: 2, background: 'var(--text-primary)', borderRadius: 1, margin: '2px 8px' }} />}
</React.Fragment>
@@ -0,0 +1,140 @@
import { useEffect, useRef, useState } from 'react'
import { MapPin, X } from 'lucide-react'
import { mapsApi } from '../../api/client'
import { useTranslation } from '../../i18n'
export interface LocationPoint {
name: string
lat: number
lng: number
address?: string | null
}
interface Props {
value: LocationPoint | null
onChange: (loc: LocationPoint | null) => void
placeholder?: string
style?: React.CSSProperties
}
export default function LocationSelect({ value, onChange, placeholder, style }: Props) {
const { t, locale } = useTranslation()
const [query, setQuery] = useState(value?.name || '')
const [open, setOpen] = useState(false)
const [results, setResults] = useState<any[]>([])
const [highlight, setHighlight] = useState(-1)
const [loading, setLoading] = useState(false)
const wrapRef = useRef<HTMLDivElement>(null)
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
useEffect(() => {
setQuery(value?.name || '')
}, [value])
useEffect(() => {
const handler = (e: MouseEvent) => {
if (!wrapRef.current?.contains(e.target as Node)) setOpen(false)
}
if (open) document.addEventListener('mousedown', handler)
return () => document.removeEventListener('mousedown', handler)
}, [open])
useEffect(() => {
if (debounceRef.current) clearTimeout(debounceRef.current)
const trimmed = query.trim()
if (trimmed.length < 3 || (value && trimmed === value.name)) {
setResults([])
return
}
debounceRef.current = setTimeout(async () => {
setLoading(true)
try {
const data = await mapsApi.search(trimmed, locale)
setResults(data.places || [])
setHighlight(-1)
} catch {
setResults([])
} finally {
setLoading(false)
}
}, 320)
return () => { if (debounceRef.current) clearTimeout(debounceRef.current) }
}, [query, value, locale])
const pick = (r: any) => {
const lat = Number(r.lat)
const lng = Number(r.lng)
if (!Number.isFinite(lat) || !Number.isFinite(lng)) return
const loc: LocationPoint = { name: r.name || r.address || 'Location', lat, lng, address: r.address || null }
onChange(loc)
setQuery(loc.name)
setOpen(false)
setResults([])
}
const clear = () => {
onChange(null)
setQuery('')
setResults([])
}
const onKey = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (!open || results.length === 0) return
if (e.key === 'ArrowDown') { e.preventDefault(); setHighlight(h => Math.min(h + 1, results.length - 1)) }
else if (e.key === 'ArrowUp') { e.preventDefault(); setHighlight(h => Math.max(h - 1, 0)) }
else if (e.key === 'Enter' && highlight >= 0) { e.preventDefault(); pick(results[highlight]) }
else if (e.key === 'Escape') setOpen(false)
}
return (
<div ref={wrapRef} style={{ position: 'relative', ...style }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, padding: '8px 10px', background: 'var(--bg-tertiary)', borderRadius: 10, border: '1px solid var(--border-primary)' }}>
<MapPin size={14} style={{ color: 'var(--text-faint)', flexShrink: 0 }} />
<input
type="text"
value={query}
placeholder={placeholder ?? t('reservations.searchLocation')}
onChange={(e) => { setQuery(e.target.value); setOpen(true); if (value) onChange(null) }}
onFocus={() => setOpen(true)}
onKeyDown={onKey}
style={{ flex: 1, minWidth: 0, background: 'transparent', border: 'none', outline: 'none', color: 'var(--text-primary)', fontSize: 13 }}
/>
{value && (
<button type="button" onClick={clear} style={{ background: 'transparent', border: 'none', padding: 2, cursor: 'pointer', color: 'var(--text-faint)', display: 'flex' }} aria-label="Clear">
<X size={14} />
</button>
)}
</div>
{open && (loading || results.length > 0) && (
<div style={{ position: 'absolute', top: 'calc(100% + 4px)', left: 0, right: 0, background: 'var(--bg-card)', border: '1px solid var(--border-primary)', borderRadius: 10, boxShadow: '0 8px 24px rgba(0,0,0,0.18)', maxHeight: 260, overflowY: 'auto', zIndex: 1000 }}>
{loading && results.length === 0 && (
<div style={{ padding: 10, fontSize: 12, color: 'var(--text-faint)' }}>{t('common.loading')}</div>
)}
{results.map((r, i) => (
<button
key={`${r.osm_id || r.google_place_id || i}`}
type="button"
onClick={() => pick(r)}
onMouseEnter={() => setHighlight(i)}
style={{
display: 'flex', alignItems: 'flex-start', gap: 8, width: '100%',
padding: '8px 12px', border: 'none', cursor: 'pointer', textAlign: 'left',
background: i === highlight ? 'var(--bg-hover)' : 'transparent',
color: 'var(--text-primary)', fontFamily: 'inherit',
}}
>
<MapPin size={12} style={{ color: 'var(--text-faint)', marginTop: 2, flexShrink: 0 }} />
<span style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 13, fontWeight: 500, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{r.name || r.address}</div>
{r.address && r.name !== r.address && (
<div style={{ fontSize: 11, color: 'var(--text-faint)', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{r.address}</div>
)}
</span>
</button>
))}
</div>
)}
</div>
)
}
@@ -575,16 +575,14 @@ describe('ReservationModal', () => {
expect(screen.queryByPlaceholderText('0.00')).not.toBeInTheDocument();
});
it('FE-PLANNER-RESMODAL-042: flight type metadata saved with airline and airports', async () => {
it('FE-PLANNER-RESMODAL-042: flight type metadata saved with airline and flight number', async () => {
const onSave = vi.fn().mockResolvedValue(undefined);
render(<ReservationModal {...defaultProps} onSave={onSave} />);
await userEvent.click(screen.getByRole('button', { name: /Flight/i }));
await userEvent.type(screen.getByPlaceholderText(/e\.g\. Lufthansa/i), 'AF 447');
await userEvent.type(screen.getByPlaceholderText(/e\.g\. Lufthansa/i), 'AF 447 CDG → JFK');
await userEvent.type(screen.getByPlaceholderText('Lufthansa'), 'Air France');
await userEvent.type(screen.getByPlaceholderText('LH 123'), 'AF 447');
await userEvent.type(screen.getByPlaceholderText('FRA'), 'CDG');
await userEvent.type(screen.getByPlaceholderText('NRT'), 'JFK');
await userEvent.click(screen.getByRole('button', { name: /^Add$/i }));
@@ -595,8 +593,6 @@ describe('ReservationModal', () => {
metadata: expect.objectContaining({
airline: 'Air France',
flight_number: 'AF 447',
departure_airport: 'CDG',
arrival_airport: 'JFK',
}),
})
);
@@ -11,7 +11,58 @@ import { useTranslation } from '../../i18n'
import { CustomDatePicker } from '../shared/CustomDateTimePicker'
import CustomTimePicker from '../shared/CustomTimePicker'
import { openFile } from '../../utils/fileDownload'
import type { Day, Place, Reservation, TripFile, AssignmentsMap, Accommodation } from '../../types'
import AirportSelect, { type Airport } from './AirportSelect'
import LocationSelect, { type LocationPoint } from './LocationSelect'
import type { Day, Place, Reservation, TripFile, AssignmentsMap, Accommodation, ReservationEndpoint } from '../../types'
const TRANSPORT_TYPES = ['flight', 'train', 'cruise', 'car'] as const
type TransportType = typeof TRANSPORT_TYPES[number]
const isTransport = (t: string): t is TransportType => (TRANSPORT_TYPES as readonly string[]).includes(t)
interface EndpointPick {
airport?: Airport
location?: LocationPoint
}
function endpointFromAirport(a: Airport, role: 'from' | 'to', sequence: number, date: string | null, time: string | null): Omit<ReservationEndpoint, 'id' | 'reservation_id'> {
return {
role, sequence,
name: a.city ? `${a.city} (${a.iata})` : a.name,
code: a.iata,
lat: a.lat, lng: a.lng,
timezone: a.tz,
local_date: date,
local_time: time,
}
}
function endpointFromLocation(l: LocationPoint, role: 'from' | 'to', sequence: number, date: string | null, time: string | null): Omit<ReservationEndpoint, 'id' | 'reservation_id'> {
return {
role, sequence,
name: l.name,
code: null,
lat: l.lat, lng: l.lng,
timezone: null,
local_date: date,
local_time: time,
}
}
function airportFromEndpoint(e: ReservationEndpoint | undefined): Airport | null {
if (!e || !e.code) return null
return {
iata: e.code, icao: null,
name: e.name, city: e.name.replace(/\s*\([A-Z]{3}\)\s*$/, ''),
country: '',
lat: e.lat, lng: e.lng,
tz: e.timezone || '',
}
}
function locationFromEndpoint(e: ReservationEndpoint | undefined): LocationPoint | null {
if (!e) return null
return { name: e.name, lat: e.lat, lng: e.lng, address: null }
}
const TYPE_OPTIONS = [
{ value: 'flight', labelKey: 'reservations.type.flight', Icon: Plane },
@@ -98,6 +149,8 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
const [showFilePicker, setShowFilePicker] = useState(false)
const [linkedFileIds, setLinkedFileIds] = useState<number[]>([])
const [unlinkedFileIds, setUnlinkedFileIds] = useState<number[]>([])
const [fromPick, setFromPick] = useState<EndpointPick>({})
const [toPick, setToPick] = useState<EndpointPick>({})
const assignmentOptions = useMemo(
() => buildAssignmentOptions(days, assignments, t, locale),
@@ -148,6 +201,20 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
price: meta.price || '',
budget_category: (meta.budget_category && budgetItems.some(i => i.category === meta.budget_category)) ? meta.budget_category : '',
})
const eps = reservation.endpoints || []
const from = eps.find(e => e.role === 'from')
const to = eps.find(e => e.role === 'to')
if (reservation.type === 'flight') {
setFromPick({ airport: airportFromEndpoint(from) || undefined })
setToPick({ airport: airportFromEndpoint(to) || undefined })
} else if (isTransport(reservation.type)) {
setFromPick({ location: locationFromEndpoint(from) || undefined })
setToPick({ location: locationFromEndpoint(to) || undefined })
} else {
setFromPick({})
setToPick({})
}
} else {
setForm({
title: '', type: 'other', status: 'pending',
@@ -160,6 +227,8 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
meta_check_in_time: '', meta_check_in_end_time: '', meta_check_out_time: '',
})
setPendingFiles([])
setFromPick({})
setToPick({})
}
}, [reservation, isOpen, selectedDayId])
@@ -202,10 +271,14 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
if (form.type === 'flight') {
if (form.meta_airline) metadata.airline = form.meta_airline
if (form.meta_flight_number) metadata.flight_number = form.meta_flight_number
if (form.meta_departure_airport) metadata.departure_airport = form.meta_departure_airport
if (form.meta_arrival_airport) metadata.arrival_airport = form.meta_arrival_airport
if (form.meta_departure_timezone) metadata.departure_timezone = form.meta_departure_timezone
if (form.meta_arrival_timezone) metadata.arrival_timezone = form.meta_arrival_timezone
if (fromPick.airport) {
metadata.departure_airport = fromPick.airport.iata
metadata.departure_timezone = fromPick.airport.tz
}
if (toPick.airport) {
metadata.arrival_airport = toPick.airport.iata
metadata.arrival_timezone = toPick.airport.tz
}
} else if (form.type === 'hotel') {
if (form.meta_check_in_time) metadata.check_in_time = form.meta_check_in_time
if (form.meta_check_in_end_time) metadata.check_in_end_time = form.meta_check_in_end_time
@@ -224,6 +297,21 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
if (form.price) metadata.price = form.price
if (form.budget_category) metadata.budget_category = form.budget_category
}
const endpoints: ReturnType<typeof endpointFromAirport>[] = []
if (isTransport(form.type)) {
const startDate = (form.reservation_time || '').split('T')[0] || null
const startTime = (form.reservation_time || '').split('T')[1]?.slice(0, 5) || null
const endDate = form.end_date || null
const endTime = form.reservation_end_time || null
if (form.type === 'flight') {
if (fromPick.airport) endpoints.push(endpointFromAirport(fromPick.airport, 'from', 0, startDate, startTime))
if (toPick.airport) endpoints.push(endpointFromAirport(toPick.airport, 'to', 1, endDate, endTime))
} else {
if (fromPick.location) endpoints.push(endpointFromLocation(fromPick.location, 'from', 0, startDate, startTime))
if (toPick.location) endpoints.push(endpointFromLocation(toPick.location, 'to', 1, endDate, endTime))
}
}
const saveData: Record<string, any> = {
title: form.title, type: form.type, status: form.status,
reservation_time: form.type === 'hotel' ? null : form.reservation_time,
@@ -233,6 +321,8 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
assignment_id: form.assignment_id || null,
accommodation_id: form.type === 'hotel' ? (form.accommodation_id || null) : null,
metadata: Object.keys(metadata).length > 0 ? metadata : null,
endpoints: isTransport(form.type) ? endpoints : [],
needs_review: false,
}
// Auto-create/update budget entry if price is set, or signal removal if cleared
if (isBudgetEnabled) {
@@ -394,11 +484,12 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
}}
/>
</div>
{form.type === 'flight' && (
{form.type === 'flight' && fromPick.airport && (
<div style={{ flex: 1, minWidth: 0 }}>
<label style={labelStyle}>{t('reservations.meta.departureTimezone')}</label>
<input type="text" value={form.meta_departure_timezone} onChange={e => set('meta_departure_timezone', e.target.value)}
placeholder="e.g. CET, UTC+1" style={inputStyle} />
<div style={{ ...inputStyle, padding: '8px 12px', color: 'var(--text-muted)', fontSize: 12, background: 'var(--bg-tertiary)' }}>
{fromPick.airport.tz}
</div>
</div>
)}
</div>
@@ -414,51 +505,75 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
<label style={labelStyle}>{form.type === 'flight' ? t('reservations.arrivalTime') : form.type === 'car' ? t('reservations.returnTime') : t('reservations.endTime')}</label>
<CustomTimePicker value={form.reservation_end_time} onChange={v => set('reservation_end_time', v)} />
</div>
{form.type === 'flight' && (
{form.type === 'flight' && toPick.airport && (
<div style={{ flex: 1, minWidth: 0 }}>
<label style={labelStyle}>{t('reservations.meta.arrivalTimezone')}</label>
<input type="text" value={form.meta_arrival_timezone} onChange={e => set('meta_arrival_timezone', e.target.value)}
placeholder="e.g. JST, UTC+9" style={inputStyle} />
<div style={{ ...inputStyle, padding: '8px 12px', color: 'var(--text-muted)', fontSize: 12, background: 'var(--bg-tertiary)' }}>
{toPick.airport.tz}
</div>
</div>
)}
</div>
{isEndBeforeStart && (
<div style={{ fontSize: 11, color: '#ef4444', marginTop: -6 }}>{t('reservations.validation.endBeforeStart')}</div>
)}
<div style={{ display: 'flex', gap: 8 }}>
<div style={{ flex: 1, minWidth: 0 }}>
<label style={labelStyle}>{t('reservations.status')}</label>
<CustomSelect
value={form.status}
onChange={value => set('status', value)}
options={[
{ value: 'pending', label: t('reservations.pending') },
{ value: 'confirmed', label: t('reservations.confirmed') },
]}
size="sm"
/>
</div>
</div>
</>
)}
{/* Location + Booking Code */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{/* Location (own row for non-transport, non-hotel types) */}
{!isTransport(form.type) && form.type !== 'hotel' && (
<div>
<label style={labelStyle}>{t('reservations.locationAddress')}</label>
<input type="text" value={form.location} onChange={e => set('location', e.target.value)}
placeholder={t('reservations.locationPlaceholder')} style={inputStyle} />
</div>
)}
{/* Booking Code + Status */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div>
<label style={labelStyle}>{t('reservations.confirmationCode')}</label>
<input type="text" value={form.confirmation_number} onChange={e => set('confirmation_number', e.target.value)}
placeholder={t('reservations.confirmationPlaceholder')} style={inputStyle} />
</div>
<div>
<label style={labelStyle}>{t('reservations.status')}</label>
<CustomSelect
value={form.status}
onChange={value => set('status', value)}
options={[
{ value: 'pending', label: t('reservations.pending') },
{ value: 'confirmed', label: t('reservations.confirmed') },
]}
size="sm"
/>
</div>
</div>
{/* Type-specific fields */}
{/* From / To endpoints for transport bookings */}
{isTransport(form.type) && (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div>
<label style={labelStyle}>{t('reservations.meta.from')}</label>
{form.type === 'flight' ? (
<AirportSelect value={fromPick.airport || null} onChange={a => setFromPick({ airport: a || undefined })} />
) : (
<LocationSelect value={fromPick.location || null} onChange={l => setFromPick({ location: l || undefined })} />
)}
</div>
<div>
<label style={labelStyle}>{t('reservations.meta.to')}</label>
{form.type === 'flight' ? (
<AirportSelect value={toPick.airport || null} onChange={a => setToPick({ airport: a || undefined })} />
) : (
<LocationSelect value={toPick.location || null} onChange={l => setToPick({ location: l || undefined })} />
)}
</div>
</div>
)}
{form.type === 'flight' && (
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div>
<label style={labelStyle}>{t('reservations.meta.airline') || 'Airline'}</label>
<input type="text" value={form.meta_airline} onChange={e => set('meta_airline', e.target.value)}
@@ -469,16 +584,6 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
<input type="text" value={form.meta_flight_number} onChange={e => set('meta_flight_number', e.target.value)}
placeholder="LH 123" style={inputStyle} />
</div>
<div>
<label style={labelStyle}>{t('reservations.meta.from') || 'From'}</label>
<input type="text" value={form.meta_departure_airport} onChange={e => set('meta_departure_airport', e.target.value)}
placeholder="FRA" style={inputStyle} />
</div>
<div>
<label style={labelStyle}>{t('reservations.meta.to') || 'To'}</label>
<input type="text" value={form.meta_arrival_airport} onChange={e => set('meta_arrival_airport', e.target.value)}
placeholder="NRT" style={inputStyle} />
</div>
</div>
)}
@@ -528,8 +633,8 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
/>
</div>
</div>
{/* Check-in/out times + Status */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
{/* Check-in / check-in-until / check-out */}
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
<div>
<label style={labelStyle}>{t('reservations.meta.checkIn')}</label>
<CustomTimePicker value={form.meta_check_in_time} onChange={v => set('meta_check_in_time', v)} />
@@ -542,18 +647,6 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
<label style={labelStyle}>{t('reservations.meta.checkOut')}</label>
<CustomTimePicker value={form.meta_check_out_time} onChange={v => set('meta_check_out_time', v)} />
</div>
<div>
<label style={labelStyle}>{t('reservations.status')}</label>
<CustomSelect
value={form.status}
onChange={value => set('status', value)}
options={[
{ value: 'pending', label: t('reservations.pending') },
{ value: 'confirmed', label: t('reservations.confirmed') },
]}
size="sm"
/>
</div>
</div>
</>
)}
@@ -1,4 +1,4 @@
import { useState, useMemo } from 'react'
import { useState, useMemo, useEffect } from 'react'
import ReactDOM from 'react-dom'
import { useTripStore } from '../../store/tripStore'
import { useCanDo } from '../../store/permissionsStore'
@@ -8,7 +8,7 @@ import { useTranslation } from '../../i18n'
import {
Plane, Hotel, Utensils, Train, Car, Ship, Ticket, FileText, MapPin,
Calendar, Hash, CheckCircle2, Circle, Pencil, Trash2, Plus, ChevronDown, ChevronRight, Users,
ExternalLink, BookMarked, Lightbulb, Link2, Clock,
ExternalLink, BookMarked, Lightbulb, Link2, Clock, ArrowRight, AlertCircle,
} from 'lucide-react'
import { openFile } from '../../utils/fileDownload'
import type { Reservation, Day, TripFile, AssignmentsMap } from '../../types'
@@ -142,6 +142,17 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo
<TypeIcon size={12} style={{ color: typeInfo.color }} />
{t(typeInfo.labelKey)}
</span>
{r.needs_review ? (
<span style={{
display: 'inline-flex', alignItems: 'center', gap: 4,
fontSize: 11, fontWeight: 600, color: '#b45309',
padding: '3px 8px', borderRadius: 6,
background: 'rgba(245,158,11,0.12)',
}} title={t('reservations.needsReviewHint')}>
<AlertCircle size={11} />
{t('reservations.needsReview')}
</span>
) : null}
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 2 }}>
<span style={{
@@ -218,15 +229,35 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo
</div>
)}
{(() => {
const eps = r.endpoints || []
const from = eps.find(e => e.role === 'from')
const to = eps.find(e => e.role === 'to')
if (!from || !to) return null
return (
<div style={{
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8,
padding: '8px 12px', borderRadius: 10,
background: 'var(--bg-tertiary)',
fontSize: 12.5, color: 'var(--text-primary)',
}}>
<span style={{ fontWeight: 500, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{from.name}</span>
<TypeIcon size={14} style={{ color: typeInfo.color, flexShrink: 0 }} />
<span style={{ fontWeight: 500, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{to.name}</span>
</div>
)
})()}
{/* Type-specific metadata */}
{(() => {
const meta = typeof r.metadata === 'string' ? JSON.parse(r.metadata || '{}') : (r.metadata || {})
if (!meta || Object.keys(meta).length === 0) return null
const hasEndpoints = (r.endpoints || []).some(e => e.role === 'from') && (r.endpoints || []).some(e => e.role === 'to')
const cells: { label: string; value: string }[] = []
if (meta.airline) cells.push({ label: t('reservations.meta.airline'), value: meta.airline })
if (meta.flight_number) cells.push({ label: t('reservations.meta.flightNumber'), value: meta.flight_number })
if (meta.departure_airport) cells.push({ label: t('reservations.meta.from'), value: meta.departure_airport })
if (meta.arrival_airport) cells.push({ label: t('reservations.meta.to'), value: meta.arrival_airport })
if (!hasEndpoints && meta.departure_airport) cells.push({ label: t('reservations.meta.from'), value: meta.departure_airport })
if (!hasEndpoints && meta.arrival_airport) cells.push({ label: t('reservations.meta.to'), value: meta.arrival_airport })
if (meta.train_number) cells.push({ label: t('reservations.meta.trainNumber'), value: meta.train_number })
if (meta.platform) cells.push({ label: t('reservations.meta.platform'), value: meta.platform })
if (meta.seat) cells.push({ label: t('reservations.meta.seat'), value: meta.seat })
@@ -351,10 +382,20 @@ interface SectionProps {
children: React.ReactNode
defaultOpen?: boolean
accent: 'green' | string
storageKey?: string
}
function Section({ title, count, children, defaultOpen = true, accent }: SectionProps) {
const [open, setOpen] = useState(defaultOpen)
function Section({ title, count, children, defaultOpen = true, accent, storageKey }: SectionProps) {
const [open, setOpen] = useState(() => {
if (!storageKey || typeof window === 'undefined') return defaultOpen
const stored = window.localStorage.getItem(storageKey)
if (stored === null) return defaultOpen
return stored === '1'
})
useEffect(() => {
if (!storageKey || typeof window === 'undefined') return
window.localStorage.setItem(storageKey, open ? '1' : '0')
}, [open, storageKey])
return (
<div style={{ marginBottom: 28 }}>
<button onClick={() => setOpen(o => !o)} style={{
@@ -537,12 +578,12 @@ export default function ReservationsPanel({ tripId, reservations, days, assignme
) : (
<>
{allPending.length > 0 && (
<Section title={t('reservations.pending')} count={allPending.length} accent="gray">
<Section title={t('reservations.pending')} count={allPending.length} accent="gray" storageKey={`trek:bookings-pending-open:${tripId}`}>
{allPending.map(r => <ReservationCard key={r.id} r={r} tripId={tripId} onEdit={onEdit} onDelete={onDelete} files={files} onNavigateToFiles={onNavigateToFiles} assignmentLookup={assignmentLookup} canEdit={canEdit} />)}
</Section>
)}
{allConfirmed.length > 0 && (
<Section title={t('reservations.confirmed')} count={allConfirmed.length} accent="green">
<Section title={t('reservations.confirmed')} count={allConfirmed.length} accent="green" storageKey={`trek:bookings-confirmed-open:${tripId}`}>
{allConfirmed.map(r => <ReservationCard key={r.id} r={r} tripId={tripId} onEdit={onEdit} onDelete={onDelete} files={files} onNavigateToFiles={onNavigateToFiles} assignmentLookup={assignmentLookup} canEdit={canEdit} />)}
</Section>
)}
@@ -172,6 +172,37 @@ export default function DisplaySettingsTab(): React.ReactElement {
</div>
</div>
{/* Booking route labels */}
<div>
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-secondary)' }}>{t('settings.bookingLabels')}</label>
<div className="flex gap-3">
{[
{ value: true, label: t('settings.on') || 'On' },
{ value: false, label: t('settings.off') || 'Off' },
].map(opt => (
<button
key={String(opt.value)}
onClick={async () => {
try { await updateSetting('map_booking_labels', opt.value) }
catch (e: unknown) { toast.error(e instanceof Error ? e.message : t('common.error')) }
}}
style={{
display: 'flex', alignItems: 'center', gap: 8,
padding: '10px 20px', borderRadius: 10, cursor: 'pointer',
fontFamily: 'inherit', fontSize: 14, fontWeight: 500,
border: (settings.map_booking_labels !== false) === opt.value ? '2px solid var(--text-primary)' : '2px solid var(--border-primary)',
background: (settings.map_booking_labels !== false) === opt.value ? 'var(--bg-hover)' : 'var(--bg-card)',
color: 'var(--text-primary)',
transition: 'all 0.15s',
}}
>
{opt.label}
</button>
))}
</div>
<p className="text-xs mt-1" style={{ color: 'var(--text-faint)' }}>{t('settings.bookingLabelsHint')}</p>
</div>
{/* Blur Booking Codes */}
<div>
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-secondary)' }}>{t('settings.blurBookingCodes')}</label>
@@ -112,8 +112,9 @@ describe('ModalRenderer', () => {
});
it('FE-SN-MODAL-005: CTA nav button dismisses all notices (not just current)', async () => {
const noticeA = makeNotice({ id: 'n-a', titleKey: 'Notice A', cta: { kind: 'nav', labelKey: 'Go to trips', href: '/trips' } });
const noticeB = makeNotice({ id: 'n-b', titleKey: 'Notice B' });
// CTA is only shown on the last page; navigate there first
const noticeA = makeNotice({ id: 'n-a', titleKey: 'Notice A' });
const noticeB = makeNotice({ id: 'n-b', titleKey: 'Notice B', cta: { kind: 'nav', labelKey: 'Go to trips', href: '/trips' } });
useSystemNoticeStore.setState({ notices: [noticeA, noticeB], loaded: true });
const dismissSpy = vi.spyOn(useSystemNoticeStore.getState(), 'dismiss');
@@ -121,6 +122,12 @@ describe('ModalRenderer', () => {
await flushGraceDelay();
// Navigate to last page
await act(async () => {
fireEvent.click(screen.getByLabelText('Go to notice 2'));
});
await flushGraceDelay();
const ctaBtn = screen.getByRole('button', { name: 'Go to trips' });
await act(async () => {
fireEvent.click(ctaBtn);
@@ -299,17 +306,22 @@ describe('ModalRenderer', () => {
expect(screen.getByText('Notice A')).toBeTruthy();
expect(screen.getByText('1 / 3')).toBeTruthy();
// Dismiss notice A — store shrinks, parent re-renders with [B, C]
// Navigate to last page where X button is available
await act(async () => {
fireEvent.click(screen.getByLabelText('Dismiss'));
useSystemNoticeStore.setState({ notices: [noticeB, noticeC], loaded: true });
rerender(<ModalRenderer notices={[noticeB, noticeC]} />);
fireEvent.click(screen.getByLabelText('Go to notice 3'));
});
await flushGraceDelay();
// Must show B (idx=0), not C (idx=1 — the old buggy behavior)
expect(screen.getByText('Notice B')).toBeTruthy();
expect(screen.getByText('1 / 2')).toBeTruthy();
// Dismiss all from last page — store shrinks
await act(async () => {
fireEvent.click(screen.getByLabelText('Dismiss'));
useSystemNoticeStore.setState({ notices: [], loaded: true });
rerender(<ModalRenderer notices={[]} />);
});
await flushGraceDelay();
// All dismissed — modal should be gone
expect(screen.queryByRole('dialog')).toBeNull();
});
it('FE-SN-MODAL-017: X button dismisses all notices, not just the current one', async () => {
@@ -321,6 +333,12 @@ describe('ModalRenderer', () => {
render(<ModalRenderer notices={[noticeA, noticeB]} />);
await flushGraceDelay();
// X button only appears on the last page — navigate there
await act(async () => {
fireEvent.click(screen.getByLabelText('Go to notice 2'));
});
await flushGraceDelay();
await act(async () => {
fireEvent.click(screen.getByLabelText('Dismiss'));
});
@@ -330,7 +348,7 @@ describe('ModalRenderer', () => {
expect(dismissSpy).toHaveBeenCalledTimes(2);
});
it('FE-SN-MODAL-018: ESC key dismisses all notices when current is dismissible', async () => {
it('FE-SN-MODAL-018: ESC key dismisses all notices when on last page', async () => {
const noticeA = makeNotice({ id: 'n-a', titleKey: 'Notice A' });
const noticeB = makeNotice({ id: 'n-b', titleKey: 'Notice B' });
useSystemNoticeStore.setState({ notices: [noticeA, noticeB], loaded: true });
@@ -339,6 +357,12 @@ describe('ModalRenderer', () => {
render(<ModalRenderer notices={[noticeA, noticeB]} />);
await flushGraceDelay();
// ESC only works on last page — navigate there first
await act(async () => {
fireEvent.click(screen.getByLabelText('Go to notice 2'));
});
await flushGraceDelay();
await act(async () => {
fireEvent.keyDown(document, { key: 'Escape' });
});
@@ -62,6 +62,7 @@ interface ContentProps {
function NoticeContent({ notice, title, body, ctaLabel, titleId, bodyId, isDark, onDismiss, onDismissAll, onCTA, total, currentPage, canPage, onPrev, onNext, onGoto }: ContentProps) {
const { t } = useTranslation();
const isLastPage = total <= 1 || currentPage === total - 1;
const DefaultIcon = SEVERITY_ICONS[notice.severity] ?? Info;
const LucideIcon: React.ElementType = notice.icon
@@ -70,8 +71,8 @@ function NoticeContent({ notice, title, body, ctaLabel, titleId, bodyId, isDark,
return (
<div className="flex flex-col relative">
{/* Dismiss X button */}
{notice.dismissible && (
{/* Dismiss X button — only on last page so users read all notices */}
{notice.dismissible && isLastPage && (
<button
onClick={onDismissAll}
className="absolute top-4 right-4 p-2 rounded-lg text-slate-400 hover:text-slate-600 dark:hover:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors"
@@ -98,26 +99,44 @@ function NoticeContent({ notice, title, body, ctaLabel, titleId, bodyId, isDark,
</div>
)}
<div className="p-8">
{/* Severity icon (when no hero) */}
{!notice.media && (
{/* Special warm header for Heart icon (thank-you notice) */}
{notice.icon === 'Heart' && !notice.media && (
<div className="relative overflow-hidden bg-gradient-to-br from-rose-500 via-pink-500 to-indigo-500 px-8 py-5 text-center">
<div className="absolute inset-0 opacity-10" style={{ backgroundImage: 'radial-gradient(circle at 20% 50%, white 1px, transparent 1px), radial-gradient(circle at 80% 20%, white 1px, transparent 1px), radial-gradient(circle at 60% 80%, white 1px, transparent 1px)', backgroundSize: '60px 60px, 80px 80px, 40px 40px' }} />
<div className="relative flex items-center justify-center gap-3">
<div className="w-10 h-10 rounded-full bg-white/20 backdrop-blur-sm flex items-center justify-center ring-2 ring-white/10">
<LucideIcon size={20} className="text-white" />
</div>
<div className="text-left">
<h2 id={titleId} className="text-lg font-bold text-white leading-tight">{title}</h2>
<p className="text-xs text-white/60 font-medium">TREK 3.0</p>
</div>
</div>
</div>
)}
<div className={notice.icon === 'Heart' && !notice.media ? 'px-8 py-6' : 'p-8'}>
{/* Severity icon (when no hero and not Heart) */}
{!notice.media && notice.icon !== 'Heart' && (
<div className={`w-16 h-16 rounded-full flex items-center justify-center mx-auto mb-4 ${SEVERITY_ACCENT[notice.severity] ?? ''}`}>
<LucideIcon size={28} />
</div>
)}
{/* Title */}
<h2
id={titleId}
className="text-xl font-semibold text-center text-slate-900 dark:text-slate-100 mb-3"
>
{title}
</h2>
{/* Title (not for Heart — rendered in gradient header) */}
{(notice.icon !== 'Heart' || notice.media) && (
<h2
id={titleId}
className="text-xl font-semibold text-center text-slate-900 dark:text-slate-100 mb-3"
>
{title}
</h2>
)}
{/* Body — markdown */}
{/* Body — markdown (long body text uses left-aligned layout) */}
<div
id={bodyId}
className="text-sm leading-relaxed text-center text-slate-600 dark:text-slate-400 max-w-[340px] mx-auto mb-4"
className="text-sm leading-relaxed text-slate-600 dark:text-slate-400 mx-auto mb-4 text-center"
>
<React.Suspense fallback={<p className="text-sm text-slate-500">{body}</p>}>
<ReactMarkdown
@@ -127,13 +146,29 @@ function NoticeContent({ notice, title, body, ctaLabel, titleId, bodyId, isDark,
a: ({ href, children }) => (
<a
href={href}
className="text-blue-600 dark:text-blue-400 underline hover:no-underline"
className="text-indigo-600 dark:text-indigo-400 underline decoration-indigo-300 dark:decoration-indigo-700 hover:decoration-indigo-500 dark:hover:decoration-indigo-400 underline-offset-2 transition-colors"
target="_blank"
rel="noopener noreferrer"
>
{children}
</a>
),
p: ({ children }) => {
// Signature line styling (e.g. "— Maurice")
const text = typeof children === 'string' ? children : Array.isArray(children) ? children.find(c => typeof c === 'string') : '';
if (typeof text === 'string' && text.trim().startsWith('—') && text.trim().length < 30) {
return <p className="mt-4 mb-3 text-base font-semibold text-slate-800 dark:text-slate-200 italic">{children}</p>;
}
return <p className="mb-3 last:mb-0">{children}</p>;
},
hr: () => (
<div className="my-5 flex items-center gap-3">
<div className="flex-1 border-t border-slate-200 dark:border-slate-700" />
<span className="text-slate-300 dark:text-slate-600 text-xs"></span>
<div className="flex-1 border-t border-slate-200 dark:border-slate-700" />
</div>
),
strong: ({ children }) => <strong className="font-semibold text-slate-800 dark:text-slate-200">{children}</strong>,
ul: ({ children }) => <ul className="list-disc list-inside text-left">{children}</ul>,
ol: ({ children }) => <ol className="list-decimal list-inside text-left">{children}</ol>,
}}
@@ -146,7 +181,7 @@ function NoticeContent({ notice, title, body, ctaLabel, titleId, bodyId, isDark,
{/* Inline image */}
{notice.media?.placement === 'inline' && (
<div
className="w-full overflow-hidden rounded-lg mb-4 max-w-[340px] mx-auto"
className="w-full overflow-hidden rounded-lg mb-4 mx-auto"
style={{ aspectRatio: notice.media.aspectRatio ?? '16/9' }}
>
<img
@@ -161,7 +196,7 @@ function NoticeContent({ notice, title, body, ctaLabel, titleId, bodyId, isDark,
{/* Highlights */}
{notice.highlights && notice.highlights.length > 0 && (
<ul className="max-w-[340px] mx-auto mb-4 space-y-2">
<ul className="mx-auto mb-4 space-y-2">
{notice.highlights.map((h, i) => {
const HIcon: React.ElementType | null = h.iconName
? ((LucideIcons as Record<string, unknown>)[h.iconName] as React.ElementType) ?? null
@@ -227,7 +262,16 @@ function NoticeContent({ notice, title, body, ctaLabel, titleId, bodyId, isDark,
{/* CTA + dismiss link */}
<div className="flex flex-col items-center gap-3 mt-2">
{ctaLabel ? (
{!isLastPage && total > 1 ? (
/* Non-last page: "Next" button to advance through all notices */
<button
id={`notice-cta-${notice.id}`}
onClick={onNext}
className="w-full h-11 rounded-lg bg-blue-600 hover:bg-blue-700 text-white font-medium transition-colors flex items-center justify-center gap-2"
>
{t('system_notice.pager.next')} <ChevronRight size={16} />
</button>
) : ctaLabel ? (
<button
id={`notice-cta-${notice.id}`}
onClick={onCTA}
@@ -244,7 +288,7 @@ function NoticeContent({ notice, title, body, ctaLabel, titleId, bodyId, isDark,
{t('common.ok')}
</button>
)}
{notice.dismissible && ctaLabel && (
{notice.dismissible && isLastPage && ctaLabel && (
<button
onClick={onDismiss}
className="text-sm text-slate-500 dark:text-slate-400 hover:text-slate-700 dark:hover:text-slate-200 transition-colors"
@@ -368,15 +412,16 @@ export function ModalRenderer({ notices }: Props) {
};
}, [notice?.id]); // eslint-disable-line react-hooks/exhaustive-deps
// ESC key — closes all modal notices (same as clicking X)
// ESC key — closes all modal notices (only on last page so users read all notices)
const isLastPage = notices.length <= 1 || idx === notices.length - 1;
useEffect(() => {
if (!visible || !notice?.dismissible) return;
if (!visible || !notice?.dismissible || !isLastPage) return;
const handler = (e: KeyboardEvent) => {
if (e.key === 'Escape') handleDismissAll();
};
document.addEventListener('keydown', handler);
return () => document.removeEventListener('keydown', handler);
}, [visible, notice?.dismissible]); // eslint-disable-line react-hooks/exhaustive-deps
}, [visible, notice?.dismissible, isLastPage]); // eslint-disable-line react-hooks/exhaustive-deps
// Arrow-key pager navigation
useEffect(() => {
@@ -569,7 +614,7 @@ export function ModalRenderer({ notices }: Props) {
}
// Desktop centered modal
const maxWidth = notice.severity === 'critical' ? 'max-w-[560px]' : 'max-w-[480px]';
const maxWidth = notice.severity === 'critical' ? 'max-w-[680px]' : 'max-w-[620px]';
const desktopMotion = prefersReducedMotion
? (visible ? 'opacity-100' : 'opacity-0')
: (visible ? 'opacity-100 scale-100' : 'opacity-0 scale-[0.97]');
@@ -578,7 +623,7 @@ export function ModalRenderer({ notices }: Props) {
<div
className={`fixed inset-0 z-50 bg-slate-950/40 backdrop-blur-[2px] transition-opacity ${dur} ${ease} ${visible ? 'opacity-100' : 'opacity-0'}`}
role="presentation"
onClick={notice.dismissible ? handleDismiss : undefined}
onClick={notice.dismissible && isLastPage ? handleDismissAll : undefined}
>
{/* Screen-reader page announcements */}
<span className="sr-only" role="status" aria-live="polite" aria-atomic="true">{pageAnnouncement}</span>
@@ -588,7 +633,7 @@ export function ModalRenderer({ notices }: Props) {
aria-modal="true"
aria-labelledby={titleId}
aria-describedby={bodyId}
className={`w-full ${maxWidth} rounded-2xl overflow-hidden shadow-xl border border-slate-200 dark:border-slate-800 bg-white dark:bg-slate-900 transition-all ${dur} ${ease} ${desktopMotion}`}
className={`w-full ${maxWidth} rounded-2xl overflow-hidden overflow-y-auto max-h-[90vh] shadow-xl border border-slate-200 dark:border-slate-800 bg-white dark:bg-slate-900 transition-all ${dur} ${ease} ${desktopMotion}`}
onClick={e => e.stopPropagation()}
>
<div ref={contentWrapperRef}>
+89 -55
View File
@@ -1,4 +1,5 @@
import { useState, useMemo, useEffect } from 'react'
import { useState, useMemo, useEffect, useRef } from 'react'
import ReactDOM from 'react-dom'
import { useTripStore } from '../../store/tripStore'
import { useCanDo } from '../../store/permissionsStore'
import { useToast } from '../shared/Toast'
@@ -37,7 +38,7 @@ type FilterType = 'all' | 'my' | 'overdue' | 'done' | string
interface Member { id: number; username: string; avatar: string | null }
export default function TodoListPanel({ tripId, items }: { tripId: number; items: TodoItem[] }) {
export default function TodoListPanel({ tripId, items, addItemSignal = 0 }: { tripId: number; items: TodoItem[]; addItemSignal?: number }) {
const { addTodoItem, updateTodoItem, deleteTodoItem, toggleTodoItem } = useTripStore()
const canEdit = useCanDo('packing_edit')
const toast = useToast()
@@ -55,6 +56,15 @@ export default function TodoListPanel({ tripId, items }: { tripId: number; items
const [filter, setFilter] = useState<FilterType>('all')
const [selectedId, setSelectedId] = useState<number | null>(null)
const [isAddingNew, setIsAddingNew] = useState(false)
const lastHandledAddSignal = useRef(addItemSignal)
useEffect(() => {
if (addItemSignal !== lastHandledAddSignal.current && addItemSignal > 0) {
setSelectedId(null)
setIsAddingNew(true)
}
lastHandledAddSignal.current = addItemSignal
}, [addItemSignal])
const [sortByPrio, setSortByPrio] = useState(false)
const [addingCategory, setAddingCategory] = useState(false)
const [newCategoryName, setNewCategoryName] = useState('')
@@ -160,12 +170,12 @@ export default function TodoListPanel({ tripId, items }: { tripId: number; items
{/* ── Left Sidebar ── */}
<div style={{
width: isMobile ? 52 : 220, flexShrink: 0, borderRight: '1px solid var(--border-faint)',
padding: isMobile ? '12px 6px' : '16px 10px', display: 'flex', flexDirection: 'column', gap: 2, overflowY: 'auto',
padding: isMobile ? '12px 6px' : '16px 12px 16px 0', display: 'flex', flexDirection: 'column', gap: 2, overflowY: 'auto',
transition: 'width 0.2s',
}}>
{/* Progress Card */}
{!isMobile && <div style={{
margin: '0 6px 12px', padding: '14px 14px 12px', borderRadius: 14,
margin: '0 0 12px', padding: '14px 14px 12px', borderRadius: 14,
background: 'var(--bg-hover)',
border: '1px solid var(--border-primary)',
boxShadow: '0 1px 2px rgba(0,0,0,0.02)',
@@ -192,9 +202,12 @@ export default function TodoListPanel({ tripId, items }: { tripId: number; items
<SidebarItem id="overdue" icon={AlertCircle} label={t('todo.filter.overdue')} count={overdueCount} />
<SidebarItem id="done" icon={CheckCheck} label={t('todo.filter.done')} count={doneCount} />
{/* Sort by priority */}
{/* Sort by */}
{!isMobile && <div style={{ fontSize: 10, fontWeight: 600, color: 'var(--text-faint)', padding: '16px 12px 4px', textTransform: 'uppercase', letterSpacing: '0.05em' }}>
{t('todo.sidebar.sortBy')}
</div>}
<button onClick={() => setSortByPrio(v => !v)}
title={isMobile ? t('todo.sortByPrio') : undefined}
title={isMobile ? t('todo.priority') : undefined}
style={{
display: 'flex', alignItems: 'center', justifyContent: isMobile ? 'center' : 'flex-start',
gap: isMobile ? 0 : 8, width: '100%', padding: isMobile ? '8px 0' : '7px 12px',
@@ -206,7 +219,7 @@ export default function TodoListPanel({ tripId, items }: { tripId: number; items
onMouseEnter={e => { if (!sortByPrio) e.currentTarget.style.background = 'var(--bg-hover)' }}
onMouseLeave={e => { if (!sortByPrio) e.currentTarget.style.background = 'transparent' }}>
<Flag size={isMobile ? 18 : 15} style={{ flexShrink: 0, opacity: 0.7 }} />
{!isMobile && <span style={{ flex: 1, textAlign: 'left' }}>{t('todo.sortByPrio')}</span>}
{!isMobile && <span style={{ flex: 1, textAlign: 'left' }}>{t('todo.priority')}</span>}
</button>
{/* Categories */}
@@ -251,27 +264,6 @@ export default function TodoListPanel({ tripId, items }: { tripId: number; items
</div>
</div>
{/* Add task */}
{canEdit && (
<div style={{ padding: '10px 20px', borderBottom: '1px solid var(--border-faint)' }}>
<button
onClick={() => { setSelectedId(null); setIsAddingNew(true) }}
style={{
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6,
width: '100%', padding: '9px 16px', borderRadius: 8,
background: isAddingNew ? 'var(--text-primary)' : 'var(--bg-hover)',
color: isAddingNew ? 'var(--bg-primary)' : 'var(--text-primary)',
border: '1px solid var(--border-primary)', cursor: 'pointer', fontFamily: 'inherit',
fontSize: 13, fontWeight: 600, transition: 'all 0.15s',
}}
onMouseEnter={e => { if (!isAddingNew) { e.currentTarget.style.background = 'var(--text-primary)'; e.currentTarget.style.color = 'var(--bg-primary)'; e.currentTarget.style.borderColor = 'var(--text-primary)' } }}
onMouseLeave={e => { if (!isAddingNew) { e.currentTarget.style.background = 'var(--bg-hover)'; e.currentTarget.style.color = 'var(--text-primary)'; e.currentTarget.style.borderColor = 'var(--border-primary)' } }}>
<Plus size={14} />
{t('todo.addItem')}
</button>
</div>
)}
{/* Task list */}
<div style={{ flex: 1, overflowY: 'auto', padding: '4px 0' }}>
{filtered.length === 0 ? null : (
@@ -407,19 +399,28 @@ export default function TodoListPanel({ tripId, items }: { tripId: number; items
</div>
</div>
)}
{isAddingNew && !selectedItem && !isMobile && (
<NewTaskPane
tripId={tripId}
categories={categories}
members={members}
defaultCategory={typeof filter === 'string' && categories.includes(filter) ? filter : null}
onCreated={(id) => { setIsAddingNew(false); setSelectedId(id) }}
onClose={() => setIsAddingNew(false)}
/>
)}
{isAddingNew && !selectedItem && isMobile && (
{isAddingNew && !selectedItem && !isMobile && ReactDOM.createPortal(
<div onClick={e => { if (e.target === e.currentTarget) setIsAddingNew(false) }}
style={{ position: 'fixed', inset: 0, zIndex: 1000, background: 'rgba(0,0,0,0.4)', display: 'flex', justifyContent: 'center', alignItems: 'flex-end' }}>
className="modal-backdrop"
style={{ position: 'fixed', inset: 0, zIndex: 300, background: 'rgba(15,23,42,0.5)', display: 'flex', justifyContent: 'center', alignItems: 'flex-start', paddingTop: 'calc(var(--nav-h) + 60px)', paddingBottom: 40 }}>
<div style={{ width: 'min(520px, 92vw)', maxHeight: 'calc(100vh - var(--nav-h) - 120px)', overflow: 'auto', borderRadius: 16, boxShadow: '0 20px 60px rgba(0,0,0,0.25)' }}
ref={el => { if (el) { const child = el.firstElementChild as HTMLElement; if (child) { child.style.width = '100%'; child.style.borderLeft = 'none'; child.style.borderRadius = '16px' } } }}>
<NewTaskPane
tripId={tripId}
categories={categories}
members={members}
defaultCategory={typeof filter === 'string' && categories.includes(filter) ? filter : null}
onCreated={(id) => { setIsAddingNew(false); setSelectedId(id) }}
onClose={() => setIsAddingNew(false)}
/>
</div>
</div>,
document.body
)}
{isAddingNew && !selectedItem && isMobile && ReactDOM.createPortal(
<div onClick={e => { if (e.target === e.currentTarget) setIsAddingNew(false) }}
className="modal-backdrop"
style={{ position: 'fixed', inset: 0, zIndex: 300, background: 'rgba(0,0,0,0.4)', display: 'flex', justifyContent: 'center', alignItems: 'flex-end' }}>
<div style={{ width: '100%', maxHeight: '85vh', borderRadius: '16px 16px 0 0', overflow: 'auto' }}
ref={el => { if (el) { const child = el.firstElementChild as HTMLElement; if (child) { child.style.width = '100%'; child.style.borderLeft = 'none'; child.style.borderRadius = '16px 16px 0 0' } } }}>
<NewTaskPane
@@ -431,7 +432,8 @@ export default function TodoListPanel({ tripId, items }: { tripId: number; items
onClose={() => setIsAddingNew(false)}
/>
</div>
</div>
</div>,
document.body
)}
</div>
)
@@ -647,6 +649,7 @@ function NewTaskPane({ tripId, categories, members, defaultCategory, onCreated,
const [desc, setDesc] = useState('')
const [dueDate, setDueDate] = useState('')
const [category, setCategory] = useState(defaultCategory || '')
const [addingCategory, setAddingCategoryInline] = useState(false)
const [assignedUserId, setAssignedUserId] = useState<number | null>(null)
const [priority, setPriority] = useState(0)
const [saving, setSaving] = useState(false)
@@ -657,9 +660,10 @@ function NewTaskPane({ tripId, categories, members, defaultCategory, onCreated,
if (!name.trim()) return
setSaving(true)
try {
const trimmedCategory = category.trim()
const item = await addTodoItem(tripId, {
name: name.trim(), description: desc || null, priority,
due_date: dueDate || null, category: category || null,
due_date: dueDate || null, category: trimmedCategory || null,
assigned_user_id: assignedUserId,
} as any)
if (item?.id) onCreated(item.id)
@@ -696,19 +700,49 @@ function NewTaskPane({ tripId, categories, members, defaultCategory, onCreated,
<div>
<label style={labelStyle}>{t('todo.detail.category')}</label>
<CustomSelect
value={category}
onChange={v => setCategory(v)}
options={[
{ value: '', label: t('todo.noCategory') },
...categories.map(c => ({
value: c, label: c,
icon: <span style={{ width: 8, height: 8, borderRadius: '50%', background: katColor(c, categories), display: 'inline-block' }} />,
})),
]}
placeholder={t('todo.noCategory')}
size="sm"
/>
{addingCategory ? (
<div style={{ display: 'flex', gap: 4 }}>
<input
autoFocus
value={category}
onChange={e => setCategory(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') setAddingCategoryInline(false); if (e.key === 'Escape') { setCategory(''); setAddingCategoryInline(false) } }}
placeholder={t('todo.newCategory')}
style={{ flex: 1, fontSize: 13, padding: '8px 10px', border: '1px solid var(--border-primary)', borderRadius: 8, background: 'var(--bg-primary)', color: 'var(--text-primary)', fontFamily: 'inherit', outline: 'none' }}
/>
<button type="button" onClick={() => setAddingCategoryInline(false)}
style={{ background: 'var(--bg-hover)', border: '1px solid var(--border-primary)', borderRadius: 8, padding: '0 10px', cursor: 'pointer', color: 'var(--text-primary)' }}>
<Check size={14} />
</button>
</div>
) : (
<div style={{ display: 'flex', gap: 4 }}>
<div style={{ flex: 1, minWidth: 0 }}>
<CustomSelect
value={category}
onChange={v => setCategory(v)}
options={[
{ value: '', label: t('todo.noCategory') },
...categories.map(c => ({
value: c, label: c,
icon: <span style={{ width: 8, height: 8, borderRadius: '50%', background: katColor(c, categories), display: 'inline-block' }} />,
})),
...(category && !categories.includes(category) ? [{
value: category, label: `${category} (${t('todo.newCategoryLabel') || 'new'})`,
icon: <span style={{ width: 8, height: 8, borderRadius: '50%', background: '#9ca3af', display: 'inline-block' }} />,
}] : []),
]}
placeholder={t('todo.noCategory')}
size="sm"
/>
</div>
<button type="button" onClick={() => { setCategory(''); setAddingCategoryInline(true) }}
title={t('todo.newCategory')}
style={{ background: 'var(--bg-hover)', border: '1px solid var(--border-primary)', borderRadius: 8, padding: '0 10px', cursor: 'pointer', color: 'var(--text-muted)', fontFamily: 'inherit' }}>
<Plus size={14} />
</button>
</div>
)}
</div>
<div>
+18
View File
@@ -0,0 +1,18 @@
import { useState, useEffect } from 'react'
/** Returns true when the viewport is below the lg breakpoint (1024px). */
export function useIsMobile(): boolean {
const [isMobile, setIsMobile] = useState(
() => typeof window !== 'undefined' && window.innerWidth < 1024,
)
useEffect(() => {
const mq = window.matchMedia('(max-width: 1023px)')
const handler = (e: MediaQueryListEvent) => setIsMobile(e.matches)
setIsMobile(mq.matches)
mq.addEventListener('change', handler)
return () => mq.removeEventListener('change', handler)
}, [])
return isMobile
}
+19 -2
View File
@@ -1017,6 +1017,15 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
'reservations.meta.flightNumber': 'رقم الرحلة',
'reservations.meta.from': 'من',
'reservations.meta.to': 'إلى',
'reservations.needsReview': 'مراجعة',
'reservations.needsReviewHint': 'تعذّر مطابقة المطار تلقائياً — يرجى تأكيد الموقع.',
'reservations.searchLocation': 'ابحث عن محطة، ميناء، عنوان...',
'airport.searchPlaceholder': 'رمز المطار أو المدينة (مثل FRA)',
'map.connections': 'الاتصالات',
'map.showConnections': 'عرض مسارات الحجوزات',
'map.hideConnections': 'إخفاء مسارات الحجوزات',
'settings.bookingLabels': 'تسميات مسارات الحجوزات',
'settings.bookingLabelsHint': 'عرض أسماء المحطات/المطارات على الخريطة. عند الإيقاف، يتم عرض الرمز فقط.',
'reservations.meta.trainNumber': 'رقم القطار',
'reservations.meta.platform': 'المنصة',
'reservations.meta.seat': 'المقعد',
@@ -1035,7 +1044,7 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
'reservations.type.hotel': 'إقامة',
'reservations.type.restaurant': 'مطعم',
'reservations.type.train': 'قطار',
'reservations.type.car': 'سيارة مستأجرة',
'reservations.type.car': 'سيارة',
'reservations.type.cruise': 'رحلة بحرية',
'reservations.type.event': 'فعالية',
'reservations.type.tour': 'جولة',
@@ -1799,7 +1808,11 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
'todo.unassigned': 'غير مُسنَد',
'todo.noCategory': 'بدون فئة',
'todo.hasDescription': 'له وصف',
'todo.addItem': 'إضافة مهمة جديدة...',
'todo.addItem': 'إضافة مهمة جديدة',
'todo.sidebar.sortBy': 'ترتيب حسب',
'todo.priority': 'الأولوية',
'todo.newCategoryLabel': 'جديد',
'budget.categoriesLabel': 'فئات',
'todo.newCategory': 'اسم الفئة',
'todo.addCategory': 'إضافة فئة',
'todo.newItem': 'مهمة جديدة',
@@ -2019,6 +2032,10 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
'system_notice.v3_mcp.highlight_scopes': '24 نطاق أذونات دقيق',
'system_notice.v3_mcp.highlight_deprecated': 'الرموز الثابتة trek_ مهملة',
'system_notice.v3_mcp.highlight_tools': 'مجموعة أدوات وإرشادات موسعة',
// System notices — personal thank you
'system_notice.v3_thankyou.title': 'كلمة شخصية مني',
'system_notice.v3_thankyou.body': 'قبل أن تمضي — أريد أن أتوقف لحظة.\n\nبدأ TREK كمشروع جانبي بنيته لرحلاتي الخاصة. لم أتخيل يومًا أنه سيكبر ليصبح شيئًا يعتمد عليه 4,000 منكم لتخطيط مغامراتهم. كل نجمة، كل مشكلة، كل طلب ميزة — أقرأها جميعًا، وهي ما يبقيني مستمرًا في الليالي المتأخرة بين عمل بدوام كامل والجامعة.\n\nأريدكم أن تعرفوا: TREK سيبقى دائمًا مفتوح المصدر، دائمًا مستضافًا ذاتيًا، دائمًا ملككم. لا تتبع، لا اشتراكات، لا شروط خفية. مجرد أداة بناها شخص يحب السفر بقدر ما تحبونه.\n\nشكر خاص لـ [jubnl](https://github.com/jubnl) — لقد أصبحت متعاونًا رائعًا. الكثير مما يجعل الإصدار 3.0 عظيمًا يحمل بصماتك. شكرًا لإيمانك بهذا المشروع عندما كان لا يزال في بداياته.\n\nولكل واحد منكم ممن أبلغ عن خطأ، أو ترجم نصًا، أو شارك TREK مع صديق، أو ببساطة استخدمه لتخطيط رحلة — **شكرًا لكم**. أنتم السبب في وجود هذا.\n\nإلى المزيد من المغامرات معًا.\n\n— Maurice\n\n---\n\n[انضم إلى المجتمع على Discord](https://discord.gg/7Q6M6jDwzf)\n\nإذا جعل TREK رحلاتك أفضل، [فنجان قهوة صغير](https://ko-fi.com/mauriceboe) يبقي الأضواء مشتعلة.',
}
export default ar
+19 -2
View File
@@ -986,6 +986,15 @@ const br: Record<string, string | { name: string; category: string }[]> = {
'reservations.meta.flightNumber': 'Nº do voo',
'reservations.meta.from': 'De',
'reservations.meta.to': 'Para',
'reservations.needsReview': 'Verificar',
'reservations.needsReviewHint': 'Aeroporto não pôde ser identificado automaticamente — confirme o local.',
'reservations.searchLocation': 'Buscar estação, porto, endereço...',
'airport.searchPlaceholder': 'Código ou cidade do aeroporto (ex. FRA)',
'map.connections': 'Conexões',
'map.showConnections': 'Mostrar rotas de reservas',
'map.hideConnections': 'Ocultar rotas de reservas',
'settings.bookingLabels': 'Rótulos das rotas de reservas',
'settings.bookingLabelsHint': 'Mostra nomes de estações / aeroportos no mapa. Desativado, apenas o ícone aparece.',
'reservations.meta.trainNumber': 'Nº do trem',
'reservations.meta.platform': 'Plataforma',
'reservations.meta.seat': 'Assento',
@@ -1004,7 +1013,7 @@ const br: Record<string, string | { name: string; category: string }[]> = {
'reservations.type.hotel': 'Hospedagem',
'reservations.type.restaurant': 'Restaurante',
'reservations.type.train': 'Trem',
'reservations.type.car': 'Carro alugado',
'reservations.type.car': 'Carro',
'reservations.type.cruise': 'Cruzeiro',
'reservations.type.event': 'Evento',
'reservations.type.tour': 'Passeio',
@@ -1748,7 +1757,11 @@ const br: Record<string, string | { name: string; category: string }[]> = {
'todo.unassigned': 'Não atribuído',
'todo.noCategory': 'Sem categoria',
'todo.hasDescription': 'Com descrição',
'todo.addItem': 'Adicionar nova tarefa...',
'todo.addItem': 'Nova tarefa',
'todo.sidebar.sortBy': 'Ordenar por',
'todo.priority': 'Prioridade',
'todo.newCategoryLabel': 'nova',
'budget.categoriesLabel': 'categorias',
'todo.newCategory': 'Nome da categoria',
'todo.addCategory': 'Adicionar categoria',
'todo.newItem': 'Nova tarefa',
@@ -2222,6 +2235,10 @@ const br: Record<string, string | { name: string; category: string }[]> = {
'system_notice.v3_mcp.highlight_scopes': '24 escopos de permissão granulares',
'system_notice.v3_mcp.highlight_deprecated': 'Tokens estáticos trek_ descontinuados',
'system_notice.v3_mcp.highlight_tools': 'Conjunto de ferramentas e prompts expandido',
// System notices — personal thank you
'system_notice.v3_thankyou.title': 'Uma nota pessoal minha',
'system_notice.v3_thankyou.body': 'Antes de seguir em frente — quero fazer uma pausa.\n\nO TREK começou como um projeto paralelo que criei para minhas próprias viagens. Nunca imaginei que cresceria a ponto de 4.000 de vocês confiarem nele para planejar suas aventuras. Cada estrela, cada issue, cada pedido de recurso — eu leio todos, e eles me mantêm firme nas noites longas entre um trabalho em tempo integral e a universidade.\n\nQuero que saibam: o TREK sempre será open source, sempre self-hosted, sempre de vocês. Sem rastreamento, sem assinaturas, sem pegadinhas. Apenas uma ferramenta feita por alguém que ama viajar tanto quanto vocês.\n\nAgradecimento especial ao [jubnl](https://github.com/jubnl) — você se tornou um colaborador incrível. Muito do que torna a versão 3.0 especial tem a sua marca. Obrigado por acreditar neste projeto quando ele ainda era bem cru.\n\nE a cada um de vocês que reportou um bug, traduziu uma string, compartilhou o TREK com um amigo ou simplesmente o usou para planejar uma viagem — **obrigado**. Vocês são a razão de tudo isso existir.\n\nQue venham muitas mais aventuras juntos.\n\n— Maurice\n\n---\n\n[Junte-se à comunidade no Discord](https://discord.gg/7Q6M6jDwzf)\n\nSe o TREK torna suas viagens melhores, um [cafezinho](https://ko-fi.com/mauriceboe) sempre mantém as luzes acesas.',
}
export default br
+19 -2
View File
@@ -1015,6 +1015,15 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
'reservations.meta.flightNumber': 'Číslo letu',
'reservations.meta.from': 'Z',
'reservations.meta.to': 'Do',
'reservations.needsReview': 'Zkontrolovat',
'reservations.needsReviewHint': 'Letiště nebylo možné automaticky rozpoznat — potvrďte prosím místo.',
'reservations.searchLocation': 'Hledat stanici, přístav, adresu...',
'airport.searchPlaceholder': 'Kód letiště nebo město (např. FRA)',
'map.connections': 'Spojení',
'map.showConnections': 'Zobrazit trasy rezervací',
'map.hideConnections': 'Skrýt trasy rezervací',
'settings.bookingLabels': 'Popisky tras rezervací',
'settings.bookingLabelsHint': 'Zobrazuje názvy stanic / letišť na mapě. Pokud je vypnuto, zobrazí se pouze ikona.',
'reservations.meta.trainNumber': 'Číslo vlaku',
'reservations.meta.platform': 'Nástupiště',
'reservations.meta.seat': 'Sedadlo',
@@ -1033,7 +1042,7 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
'reservations.type.hotel': 'Ubytování',
'reservations.type.restaurant': 'Restaurace',
'reservations.type.train': 'Vlak',
'reservations.type.car': 'Pronájem auta',
'reservations.type.car': 'Auto',
'reservations.type.cruise': 'Plavba',
'reservations.type.event': 'Událost',
'reservations.type.tour': 'Prohlídka',
@@ -1753,7 +1762,11 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
'todo.unassigned': 'Nepřiřazeno',
'todo.noCategory': 'Bez kategorie',
'todo.hasDescription': 'Má popis',
'todo.addItem': 'Přidat nový úkol...',
'todo.addItem': 'Přidat nový úkol',
'todo.sidebar.sortBy': 'Řadit podle',
'todo.priority': 'Priorita',
'todo.newCategoryLabel': 'nová',
'budget.categoriesLabel': 'kategorie',
'todo.newCategory': 'Název kategorie',
'todo.addCategory': 'Přidat kategorii',
'todo.newItem': 'Nový úkol',
@@ -2226,6 +2239,10 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
'system_notice.v3_mcp.highlight_scopes': '24 jemnozrnných oprávnění',
'system_notice.v3_mcp.highlight_deprecated': 'Statické tokeny trek_ zastaralé',
'system_notice.v3_mcp.highlight_tools': 'Rozšířená sada nástrojů a promptů',
// System notices — personal thank you
'system_notice.v3_thankyou.title': 'Osobní slovo ode mě',
'system_notice.v3_thankyou.body': 'Než budete pokračovat — chci se na chvíli zastavit.\n\nTREK začal jako vedlejší projekt, který jsem vytvořil pro své vlastní cesty. Nikdy jsem si nepředstavoval, že vyroste v něco, čemu 4 000 z vás důvěřuje při plánování svých dobrodružství. Každou hvězdičku, každý issue, každý požadavek na funkci — všechny čtu a právě ony mě drží při životě během pozdních nocí mezi prací na plný úvazek a univerzitou.\n\nChci, abyste věděli: TREK bude vždy open source, vždy self-hosted, vždy váš. Žádné sledování, žádná předplatná, žádné háčky. Jen nástroj vytvořený někým, kdo miluje cestování stejně jako vy.\n\nZvláštní poděkování patří [jubnl](https://github.com/jubnl) — stal ses neuvěřitelným spolupracovníkem. Tolik z toho, co dělá verzi 3.0 skvělou, nese tvůj rukopis. Děkuji, že jsi věřil tomuto projektu, když byl ještě v plenkách.\n\nA každému z vás, kdo nahlásil chybu, přeložil řetězec, sdílel TREK s přítelem nebo ho jednoduše použil k plánování cesty — **děkuji**. Vy jste důvod, proč tohle existuje.\n\nNa mnoho dalších dobrodružství společně.\n\n— Maurice\n\n---\n\n[Přidej se ke komunitě na Discordu](https://discord.gg/7Q6M6jDwzf)\n\nPokud ti TREK zlepšuje cestování, [malá káva](https://ko-fi.com/mauriceboe) vždy pomůže udržet světla rozsvícená.',
}
export default cs
+19 -2
View File
@@ -176,6 +176,8 @@ const de: Record<string, string | { name: string; category: string }[]> = {
'settings.temperature': 'Temperatureinheit',
'settings.timeFormat': 'Zeitformat',
'settings.routeCalculation': 'Routenberechnung',
'settings.bookingLabels': 'Orts-Labels auf Buchungsrouten',
'settings.bookingLabelsHint': 'Zeigt Bahnhofs-/Flughafennamen auf der Karte. Wenn aus, wird nur das Icon angezeigt.',
'settings.blurBookingCodes': 'Buchungscodes verbergen',
'settings.notifications': 'Benachrichtigungen',
'settings.notifyTripInvite': 'Trip-Einladungen',
@@ -1017,6 +1019,13 @@ const de: Record<string, string | { name: string; category: string }[]> = {
'reservations.meta.flightNumber': 'Flugnr.',
'reservations.meta.from': 'Von',
'reservations.meta.to': 'Nach',
'reservations.needsReview': 'Prüfen',
'reservations.needsReviewHint': 'Flughafen konnte nicht automatisch erkannt werden — bitte Ort bestätigen.',
'reservations.searchLocation': 'Bahnhof, Hafen, Adresse suchen…',
'airport.searchPlaceholder': 'Flughafencode oder Stadt (z. B. FRA)',
'map.connections': 'Verbindungen',
'map.showConnections': 'Buchungsrouten anzeigen',
'map.hideConnections': 'Buchungsrouten ausblenden',
'reservations.meta.trainNumber': 'Zugnr.',
'reservations.meta.platform': 'Gleis',
'reservations.meta.seat': 'Sitzplatz',
@@ -1035,7 +1044,7 @@ const de: Record<string, string | { name: string; category: string }[]> = {
'reservations.type.hotel': 'Unterkunft',
'reservations.type.restaurant': 'Restaurant',
'reservations.type.train': 'Zug',
'reservations.type.car': 'Mietwagen',
'reservations.type.car': 'Auto',
'reservations.type.cruise': 'Kreuzfahrt',
'reservations.type.event': 'Veranstaltung',
'reservations.type.tour': 'Tour',
@@ -1756,7 +1765,11 @@ const de: Record<string, string | { name: string; category: string }[]> = {
'todo.unassigned': 'Nicht zugewiesen',
'todo.noCategory': 'Keine Kategorie',
'todo.hasDescription': 'Hat Beschreibung',
'todo.addItem': 'Neue Aufgabe hinzufügen...',
'todo.addItem': 'Neue Aufgabe hinzufügen',
'todo.sidebar.sortBy': 'Sortieren nach',
'todo.priority': 'Priorität',
'todo.newCategoryLabel': 'neu',
'budget.categoriesLabel': 'Kategorien',
'todo.newCategory': 'Kategoriename',
'todo.addCategory': 'Kategorie hinzufügen',
'todo.newItem': 'Neue Aufgabe',
@@ -2226,6 +2239,10 @@ const de: Record<string, string | { name: string; category: string }[]> = {
'system_notice.v3_mcp.highlight_scopes': '24 feingranulare Berechtigungs-Scopes',
'system_notice.v3_mcp.highlight_deprecated': 'Statische trek_-Tokens veraltet',
'system_notice.v3_mcp.highlight_tools': 'Erweitertes Toolset & Prompts',
// System notices — persönlicher Dank
'system_notice.v3_thankyou.title': 'Ein persönliches Wort von mir',
'system_notice.v3_thankyou.body': 'Bevor du weiterklickst — einen Moment noch.\n\nTREK hat als Nebenprojekt für meine eigenen Reisen angefangen. Ich hätte nie gedacht, dass es jemals so weit kommt, dass 4.000 von euch damit ihre Abenteuer planen. Jeder Stern, jedes Issue, jeder Feature-Wunsch — ich lese sie alle, und sie halten mich am Laufen durch die späten Nächte zwischen Vollzeitjob und Studium.\n\nEins will ich euch sagen: TREK wird immer Open Source bleiben, immer self-hosted, immer eures. Kein Tracking, keine Abos, keine versteckten Haken. Einfach ein Tool, gebaut von jemandem, der das Reisen genauso liebt wie ihr.\n\nBesonderer Dank an [jubnl](https://github.com/jubnl) — du bist ein unglaublicher Mitstreiter geworden. So vieles, was 3.0 großartig macht, trägt deine Handschrift. Danke, dass du an dieses Projekt geglaubt hast, als es noch holprig war.\n\nUnd an jeden einzelnen von euch, der einen Bug gemeldet, einen String übersetzt, TREK mit Freunden geteilt oder einfach damit eine Reise geplant hat — **danke**. Ihr seid der Grund, warum es das hier gibt.\n\nAuf viele weitere Abenteuer zusammen.\n\n— Maurice\n\n---\n\n[Tritt der Community auf Discord bei](https://discord.gg/7Q6M6jDwzf)\n\nWenn TREK deine Reisen besser macht, hält ein [kleiner Kaffee](https://ko-fi.com/mauriceboe) die Lichter an.',
}
export default de
+20 -2
View File
@@ -176,6 +176,8 @@ const en: Record<string, string | { name: string; category: string }[]> = {
'settings.temperature': 'Temperature Unit',
'settings.timeFormat': 'Time Format',
'settings.routeCalculation': 'Route Calculation',
'settings.bookingLabels': 'Booking route labels',
'settings.bookingLabelsHint': 'Show station / airport names on the map. When off, only the icon is shown.',
'settings.blurBookingCodes': 'Blur Booking Codes',
'settings.notifications': 'Notifications',
'settings.notifyTripInvite': 'Trip invitations',
@@ -1070,6 +1072,13 @@ const en: Record<string, string | { name: string; category: string }[]> = {
'reservations.meta.flightNumber': 'Flight No.',
'reservations.meta.from': 'From',
'reservations.meta.to': 'To',
'reservations.needsReview': 'Review',
'reservations.needsReviewHint': 'Airport could not be matched automatically — please confirm the location.',
'reservations.searchLocation': 'Search station, port, address…',
'airport.searchPlaceholder': 'Airport code or city (e.g. FRA)',
'map.connections': 'Connections',
'map.showConnections': 'Show booking routes',
'map.hideConnections': 'Hide booking routes',
'reservations.meta.trainNumber': 'Train No.',
'reservations.meta.platform': 'Platform',
'reservations.meta.seat': 'Seat',
@@ -1088,7 +1097,7 @@ const en: Record<string, string | { name: string; category: string }[]> = {
'reservations.type.hotel': 'Accommodation',
'reservations.type.restaurant': 'Restaurant',
'reservations.type.train': 'Train',
'reservations.type.car': 'Rental Car',
'reservations.type.car': 'Car',
'reservations.type.cruise': 'Cruise',
'reservations.type.event': 'Event',
'reservations.type.tour': 'Tour',
@@ -1818,7 +1827,11 @@ const en: Record<string, string | { name: string; category: string }[]> = {
'todo.unassigned': 'Unassigned',
'todo.noCategory': 'No category',
'todo.hasDescription': 'Has description',
'todo.addItem': 'Add new task...',
'todo.addItem': 'Add new task',
'todo.sidebar.sortBy': 'Sort by',
'todo.priority': 'Priority',
'todo.newCategoryLabel': 'new',
'budget.categoriesLabel': 'categories',
'todo.newCategory': 'Category name',
'todo.addCategory': 'Add category',
'todo.newItem': 'New task',
@@ -1958,6 +1971,7 @@ const en: Record<string, string | { name: string; category: string }[]> = {
'journey.detail.noEntriesHint': 'Add a trip to get started with skeleton entries',
'journey.detail.noPhotos': 'No photos yet',
'journey.detail.noPhotosHint': 'Upload photos to entries or browse your Immich/Synology library',
'journey.detail.journeyTab': 'Journey',
'journey.detail.journeyStats': 'Journey Stats',
'journey.detail.syncedTrips': 'Synced Trips',
'journey.detail.noTripsLinked': 'No trips linked yet',
@@ -2248,6 +2262,10 @@ const en: Record<string, string | { name: string; category: string }[]> = {
'system_notice.v3_mcp.highlight_deprecated': 'Static trek_ tokens deprecated',
'system_notice.v3_mcp.highlight_tools': 'Expanded toolset & prompts',
// System notices — personal thank you
'system_notice.v3_thankyou.title': 'A personal note from me',
'system_notice.v3_thankyou.body': 'Before you go — I want to take a moment.\n\nTREK started as a side project I built for my own trips. I never imagined it would grow into something that 4,000 of you now trust to plan your adventures. Every star, every issue, every feature request — I read them all, and they keep me going through late nights between a full-time job and university.\n\nI want you to know: TREK will always be open source, always self-hosted, always yours. No tracking, no subscriptions, no strings attached. Just a tool built by someone who loves traveling as much as you do.\n\nSpecial thanks to [jubnl](https://github.com/jubnl) — you have become an incredible collaborator. So much of what makes 3.0 great carries your fingerprints. Thank you for believing in this project when it was still rough around the edges.\n\nAnd to every single one of you who filed a bug, translated a string, shared TREK with a friend, or simply used it to plan a trip — **thank you**. You are the reason this exists.\n\nHere\'s to many more adventures together.\n\n— Maurice\n\n---\n\n[Join the community on Discord](https://discord.gg/7Q6M6jDwzf)\n\nIf TREK makes your travels better, a [small coffee](https://ko-fi.com/mauriceboe) always keeps the lights on.',
// System notices — onboarding
'system_notice.welcome_v1.title': 'Welcome to TREK',
'system_notice.welcome_v1.body': 'Your all-in-one travel planner. Build itineraries, share trips with friends, and stay organized — online or offline.',
+19 -2
View File
@@ -990,7 +990,7 @@ const es: Record<string, string> = {
'reservations.type.hotel': 'Alojamiento',
'reservations.type.restaurant': 'Restaurante',
'reservations.type.train': 'Tren',
'reservations.type.car': 'Coche de alquiler',
'reservations.type.car': 'Coche',
'reservations.type.cruise': 'Crucero',
'reservations.type.event': 'Evento',
'reservations.type.tour': 'Excursión',
@@ -1618,6 +1618,15 @@ const es: Record<string, string> = {
'reservations.meta.flightNumber': 'N° de vuelo',
'reservations.meta.from': 'Desde',
'reservations.meta.to': 'Hasta',
'reservations.needsReview': 'Revisar',
'reservations.needsReviewHint': 'No se pudo identificar el aeropuerto automáticamente — por favor confirma la ubicación.',
'reservations.searchLocation': 'Buscar estación, puerto, dirección...',
'airport.searchPlaceholder': 'Código o ciudad del aeropuerto (ej. FRA)',
'map.connections': 'Conexiones',
'map.showConnections': 'Mostrar rutas de reservas',
'map.hideConnections': 'Ocultar rutas de reservas',
'settings.bookingLabels': 'Etiquetas de rutas de reservas',
'settings.bookingLabelsHint': 'Muestra nombres de estaciones / aeropuertos en el mapa. Desactivado, solo se muestra el icono.',
'reservations.meta.trainNumber': 'N° de tren',
'reservations.meta.platform': 'Andén',
'reservations.meta.seat': 'Asiento',
@@ -1758,7 +1767,11 @@ const es: Record<string, string> = {
'todo.unassigned': 'Sin asignar',
'todo.noCategory': 'Sin categoría',
'todo.hasDescription': 'Con descripción',
'todo.addItem': 'Añadir nueva tarea...',
'todo.addItem': 'Nueva tarea',
'todo.sidebar.sortBy': 'Ordenar por',
'todo.priority': 'Prioridad',
'todo.newCategoryLabel': 'nueva',
'budget.categoriesLabel': 'categorías',
'todo.newCategory': 'Nombre de la categoría',
'todo.addCategory': 'Añadir categoría',
'todo.newItem': 'Nueva tarea',
@@ -2228,6 +2241,10 @@ const es: Record<string, string> = {
'system_notice.v3_mcp.highlight_scopes': '24 ámbitos de permisos granulares',
'system_notice.v3_mcp.highlight_deprecated': 'Tokens estáticos trek_ obsoletos',
'system_notice.v3_mcp.highlight_tools': 'Herramientas y prompts ampliados',
// System notices — personal thank you
'system_notice.v3_thankyou.title': 'Una nota personal de mi parte',
'system_notice.v3_thankyou.body': 'Antes de seguir — quiero tomarme un momento.\n\nTREK empezó como un proyecto personal que construí para mis propios viajes. Nunca imaginé que crecería hasta convertirse en algo en lo que 4.000 de vosotros confían para planificar sus aventuras. Cada estrella, cada issue, cada solicitud de funcionalidad — los leo todos, y son lo que me mantiene en pie durante las noches largas entre un trabajo a jornada completa y la universidad.\n\nQuiero que sepáis: TREK siempre será open source, siempre self-hosted, siempre vuestro. Sin rastreo, sin suscripciones, sin letra pequeña. Solo una herramienta hecha por alguien que ama viajar tanto como vosotros.\n\nUn agradecimiento especial a [jubnl](https://github.com/jubnl) — te has convertido en un colaborador increíble. Mucho de lo que hace grande la versión 3.0 lleva tu huella. Gracias por creer en este proyecto cuando todavía era un borrador.\n\nY a cada uno de vosotros que reportó un bug, tradujo un texto, compartió TREK con un amigo o simplemente lo usó para planificar un viaje — **gracias**. Vosotros sois la razón de que esto exista.\n\nPor muchas más aventuras juntos.\n\n— Maurice\n\n---\n\n[Únete a la comunidad en Discord](https://discord.gg/7Q6M6jDwzf)\n\nSi TREK mejora tus viajes, un [pequeño café](https://ko-fi.com/mauriceboe) siempre mantiene las luces encendidas.',
}
export default es
+19 -2
View File
@@ -1013,6 +1013,15 @@ const fr: Record<string, string> = {
'reservations.meta.flightNumber': 'N° de vol',
'reservations.meta.from': 'De',
'reservations.meta.to': 'À',
'reservations.needsReview': 'Vérifier',
'reservations.needsReviewHint': 'L\'aéroport n\'a pas pu être identifié automatiquement — veuillez confirmer l\'emplacement.',
'reservations.searchLocation': 'Rechercher une gare, un port, une adresse…',
'airport.searchPlaceholder': 'Code ou ville de l\'aéroport (ex. FRA)',
'map.connections': 'Connexions',
'map.showConnections': 'Afficher les itinéraires',
'map.hideConnections': 'Masquer les itinéraires',
'settings.bookingLabels': 'Étiquettes des itinéraires',
'settings.bookingLabelsHint': 'Affiche les noms des gares / aéroports sur la carte. Si désactivé, seule l\'icône est affichée.',
'reservations.meta.trainNumber': 'N° de train',
'reservations.meta.platform': 'Quai',
'reservations.meta.seat': 'Place',
@@ -1031,7 +1040,7 @@ const fr: Record<string, string> = {
'reservations.type.hotel': 'Hébergement',
'reservations.type.restaurant': 'Restaurant',
'reservations.type.train': 'Train',
'reservations.type.car': 'Voiture de location',
'reservations.type.car': 'Voiture',
'reservations.type.cruise': 'Croisière',
'reservations.type.event': 'Événement',
'reservations.type.tour': 'Visite',
@@ -1752,7 +1761,11 @@ const fr: Record<string, string> = {
'todo.unassigned': 'Non assigné',
'todo.noCategory': 'Aucune catégorie',
'todo.hasDescription': 'Avec description',
'todo.addItem': 'Ajouter une tâche...',
'todo.addItem': 'Nouvelle tâche',
'todo.sidebar.sortBy': 'Trier par',
'todo.priority': 'Priorité',
'todo.newCategoryLabel': 'nouvelle',
'budget.categoriesLabel': 'catégories',
'todo.newCategory': 'Nom de la catégorie',
'todo.addCategory': 'Ajouter une catégorie',
'todo.newItem': 'Nouvelle tâche',
@@ -2222,6 +2235,10 @@ const fr: Record<string, string> = {
'system_notice.v3_mcp.highlight_scopes': '24 scopes de permissions granulaires',
'system_notice.v3_mcp.highlight_deprecated': 'Tokens statiques trek_ dépréciés',
'system_notice.v3_mcp.highlight_tools': 'Outils et prompts étendus',
// System notices — personal thank you
'system_notice.v3_thankyou.title': 'Un mot personnel de ma part',
'system_notice.v3_thankyou.body': 'Avant de continuer — je veux prendre un instant.\n\nTREK a commencé comme un projet perso que j\'ai construit pour mes propres voyages. Je n\'aurais jamais imaginé qu\'il grandirait au point que 4 000 d\'entre vous lui fassent confiance pour planifier vos aventures. Chaque étoile, chaque issue, chaque demande de fonctionnalité — je les lis toutes, et ce sont elles qui me font tenir pendant les nuits blanches entre un travail à temps plein et l\'université.\n\nJe veux que vous sachiez : TREK sera toujours open source, toujours auto-hébergé, toujours à vous. Pas de tracking, pas d\'abonnements, pas de conditions cachées. Juste un outil construit par quelqu\'un qui aime voyager autant que vous.\n\nUn merci tout particulier à [jubnl](https://github.com/jubnl) — tu es devenu un collaborateur incroyable. Une grande partie de ce qui rend la 3.0 géniale porte ton empreinte. Merci d\'avoir cru en ce projet quand il était encore brut.\n\nEt à chacun d\'entre vous qui a signalé un bug, traduit une chaîne, partagé TREK avec un ami ou simplement l\'a utilisé pour planifier un voyage — **merci**. Vous êtes la raison pour laquelle tout ceci existe.\n\nÀ de nombreuses autres aventures ensemble.\n\n— Maurice\n\n---\n\n[Rejoins la communauté sur Discord](https://discord.gg/7Q6M6jDwzf)\n\nSi TREK rend tes voyages meilleurs, un [petit café](https://ko-fi.com/mauriceboe) aide toujours à garder les lumières allumées.',
}
export default fr
+19 -2
View File
@@ -1015,6 +1015,15 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
'reservations.meta.flightNumber': 'Járatszám',
'reservations.meta.from': 'Honnan',
'reservations.meta.to': 'Hová',
'reservations.needsReview': 'Ellenőrzés',
'reservations.needsReviewHint': 'A repülőteret nem sikerült automatikusan azonosítani — erősítsd meg a helyet.',
'reservations.searchLocation': 'Állomás, kikötő, cím keresése...',
'airport.searchPlaceholder': 'Repülőtér kódja vagy város (pl. FRA)',
'map.connections': 'Kapcsolatok',
'map.showConnections': 'Foglalási útvonalak megjelenítése',
'map.hideConnections': 'Foglalási útvonalak elrejtése',
'settings.bookingLabels': 'Útvonal-címkék a foglalásokhoz',
'settings.bookingLabelsHint': 'Állomás- / repülőtér-nevek megjelenítése a térképen. Ha ki van kapcsolva, csak az ikon látszik.',
'reservations.meta.trainNumber': 'Vonatszám',
'reservations.meta.platform': 'Vágány',
'reservations.meta.seat': 'Ülés',
@@ -1033,7 +1042,7 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
'reservations.type.hotel': 'Szálloda',
'reservations.type.restaurant': 'Étterem',
'reservations.type.train': 'Vonat',
'reservations.type.car': 'Autóbérlés',
'reservations.type.car': 'Autó',
'reservations.type.cruise': 'Hajóút',
'reservations.type.event': 'Esemény',
'reservations.type.tour': 'Túra',
@@ -1750,7 +1759,11 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
'todo.unassigned': 'Nem hozzárendelt',
'todo.noCategory': 'Nincs kategória',
'todo.hasDescription': 'Van leírás',
'todo.addItem': 'Új feladat hozzáadása...',
'todo.addItem': 'Új feladat',
'todo.sidebar.sortBy': 'Rendezés',
'todo.priority': 'Prioritás',
'todo.newCategoryLabel': 'új',
'budget.categoriesLabel': 'kategóriák',
'todo.newCategory': 'Kategória neve',
'todo.addCategory': 'Kategória hozzáadása',
'todo.newItem': 'Új feladat',
@@ -2223,6 +2236,10 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
'system_notice.v3_mcp.highlight_scopes': '24 részletes engedélyezési hatókör',
'system_notice.v3_mcp.highlight_deprecated': 'Statikus trek_ tokenek elavultak',
'system_notice.v3_mcp.highlight_tools': 'Bővített eszközkészlet és promptok',
// System notices — personal thank you
'system_notice.v3_thankyou.title': 'Egy személyes gondolat tőlem',
'system_notice.v3_thankyou.body': 'Mielőtt továbbmennél — szeretnék egy pillanatra megállni.\n\nA TREK egy hobbiprojektként indult, amit a saját utazásaimhoz építettem. Sosem gondoltam volna, hogy valami olyanná nő, amire 4000-en bízzátok a kalandjaitok tervezését. Minden csillagot, minden issue-t, minden funkciókérést — mindet elolvasom, és ezek tartanak életben a késő éjszakákon a teljes állás és az egyetem között.\n\nSzeretnétek, ha tudnátok: a TREK mindig nyílt forráskódú marad, mindig self-hosted, mindig a tiétek. Nincs nyomkövetés, nincs előfizetés, nincsenek rejtett feltételek. Csak egy eszköz, amit valaki épített, aki ugyanúgy szereti az utazást, mint ti.\n\nKülönleges köszönet [jubnl](https://github.com/jubnl)-nek — hihetetlen társsá váltál. A 3.0 nagyszerűségének nagy része a te kézjegyedet viseli. Köszönöm, hogy hittél ebben a projektben, amikor még nyers volt.\n\nÉs mindannyiótoknak, akik hibát jelentettetek, szöveget fordítottatok, megosztottátok a TREK-et egy baráttal, vagy egyszerűen csak egy utazást terveztetek vele — **köszönöm**. Ti vagytok az ok, amiért ez létezik.\n\nSok további közös kalandért.\n\n— Maurice\n\n---\n\n[Csatlakozz a közösséghez a Discordon](https://discord.gg/7Q6M6jDwzf)\n\nHa a TREK jobbá teszi az utazásaidat, egy [kis kávé](https://ko-fi.com/mauriceboe) mindig segít, hogy égve maradjanak a fények.',
}
export default hu
+19 -2
View File
@@ -1070,6 +1070,15 @@ const id: Record<string, string | { name: string; category: string }[]> = {
'reservations.meta.flightNumber': 'No. Penerbangan',
'reservations.meta.from': 'Dari',
'reservations.meta.to': 'Ke',
'reservations.needsReview': 'Tinjau',
'reservations.needsReviewHint': 'Bandara tidak dapat dicocokkan otomatis — konfirmasi lokasi.',
'reservations.searchLocation': 'Cari stasiun, pelabuhan, alamat...',
'airport.searchPlaceholder': 'Kode bandara atau kota (mis. FRA)',
'map.connections': 'Koneksi',
'map.showConnections': 'Tampilkan rute pemesanan',
'map.hideConnections': 'Sembunyikan rute pemesanan',
'settings.bookingLabels': 'Label rute pemesanan',
'settings.bookingLabelsHint': 'Menampilkan nama stasiun / bandara di peta. Jika mati, hanya ikon ditampilkan.',
'reservations.meta.trainNumber': 'No. Kereta',
'reservations.meta.platform': 'Peron',
'reservations.meta.seat': 'Kursi',
@@ -1088,7 +1097,7 @@ const id: Record<string, string | { name: string; category: string }[]> = {
'reservations.type.hotel': 'Akomodasi',
'reservations.type.restaurant': 'Restoran',
'reservations.type.train': 'Kereta',
'reservations.type.car': 'Mobil Sewa',
'reservations.type.car': 'Mobil',
'reservations.type.cruise': 'Kapal Pesiar',
'reservations.type.event': 'Acara',
'reservations.type.tour': 'Tur',
@@ -1818,7 +1827,11 @@ const id: Record<string, string | { name: string; category: string }[]> = {
'todo.unassigned': 'Belum ditugaskan',
'todo.noCategory': 'Tanpa kategori',
'todo.hasDescription': 'Ada deskripsi',
'todo.addItem': 'Tambah tugas baru...',
'todo.addItem': 'Tugas baru',
'todo.sidebar.sortBy': 'Urutkan',
'todo.priority': 'Prioritas',
'todo.newCategoryLabel': 'baru',
'budget.categoriesLabel': 'kategori',
'todo.newCategory': 'Nama kategori',
'todo.addCategory': 'Tambah kategori',
'todo.newItem': 'Tugas baru',
@@ -2264,6 +2277,10 @@ const id: Record<string, string | { name: string; category: string }[]> = {
'system_notice.v3_mcp.highlight_scopes': '24 cakupan izin yang terperinci',
'system_notice.v3_mcp.highlight_deprecated': 'Token statis trek_ sudah usang',
'system_notice.v3_mcp.highlight_tools': 'Perangkat dan prompt yang diperluas',
// System notices — personal thank you
'system_notice.v3_thankyou.title': 'Catatan pribadi dari saya',
'system_notice.v3_thankyou.body': 'Sebelum kamu lanjut — saya ingin berhenti sejenak.\n\nTREK dimulai sebagai proyek sampingan yang saya buat untuk perjalanan saya sendiri. Saya tidak pernah membayangkan ia akan tumbuh menjadi sesuatu yang dipercaya oleh 4.000 dari kalian untuk merencanakan petualangan. Setiap bintang, setiap issue, setiap permintaan fitur — saya membaca semuanya, dan itulah yang membuat saya terus bertahan di malam-malam larut antara pekerjaan penuh waktu dan kuliah.\n\nSaya ingin kalian tahu: TREK akan selalu open source, selalu self-hosted, selalu milik kalian. Tanpa pelacakan, tanpa langganan, tanpa syarat tersembunyi. Hanya sebuah alat yang dibuat oleh seseorang yang mencintai traveling sama seperti kalian.\n\nTerima kasih khusus untuk [jubnl](https://github.com/jubnl) — kamu telah menjadi kolaborator yang luar biasa. Begitu banyak hal yang membuat versi 3.0 hebat memiliki jejakmu. Terima kasih telah percaya pada proyek ini ketika masih kasar.\n\nDan untuk setiap dari kalian yang melaporkan bug, menerjemahkan string, membagikan TREK kepada teman, atau sekadar menggunakannya untuk merencanakan perjalanan — **terima kasih**. Kalianlah alasan semua ini ada.\n\nUntuk lebih banyak petualangan bersama.\n\n— Maurice\n\n---\n\n[Bergabunglah dengan komunitas di Discord](https://discord.gg/7Q6M6jDwzf)\n\nJika TREK membuat perjalananmu lebih baik, [secangkir kopi kecil](https://ko-fi.com/mauriceboe) selalu membantu menjaga lampu tetap menyala.',
};
export default id;
+19 -2
View File
@@ -1014,6 +1014,15 @@ const it: Record<string, string | { name: string; category: string }[]> = {
'reservations.meta.flightNumber': 'N. volo',
'reservations.meta.from': 'Da',
'reservations.meta.to': 'A',
'reservations.needsReview': 'Verifica',
'reservations.needsReviewHint': 'L\'aeroporto non è stato riconosciuto automaticamente — conferma la posizione.',
'reservations.searchLocation': 'Cerca stazione, porto, indirizzo...',
'airport.searchPlaceholder': 'Codice o città dell\'aeroporto (es. FRA)',
'map.connections': 'Connessioni',
'map.showConnections': 'Mostra percorsi prenotati',
'map.hideConnections': 'Nascondi percorsi prenotati',
'settings.bookingLabels': 'Etichette percorsi prenotati',
'settings.bookingLabelsHint': 'Mostra i nomi di stazioni / aeroporti sulla mappa. Se disattivato, viene mostrata solo l\'icona.',
'reservations.meta.trainNumber': 'N. treno',
'reservations.meta.platform': 'Binario',
'reservations.meta.seat': 'Posto',
@@ -1032,7 +1041,7 @@ const it: Record<string, string | { name: string; category: string }[]> = {
'reservations.type.hotel': 'Alloggio',
'reservations.type.restaurant': 'Ristorante',
'reservations.type.train': 'Treno',
'reservations.type.car': 'Auto a noleggio',
'reservations.type.car': 'Auto',
'reservations.type.cruise': 'Crociera',
'reservations.type.event': 'Evento',
'reservations.type.tour': 'Tour',
@@ -1753,7 +1762,11 @@ const it: Record<string, string | { name: string; category: string }[]> = {
'todo.unassigned': 'Non assegnato',
'todo.noCategory': 'Nessuna categoria',
'todo.hasDescription': 'Ha descrizione',
'todo.addItem': 'Aggiungi nuova attività...',
'todo.addItem': 'Nuova attività',
'todo.sidebar.sortBy': 'Ordina per',
'todo.priority': 'Priorità',
'todo.newCategoryLabel': 'nuova',
'budget.categoriesLabel': 'categorie',
'todo.newCategory': 'Nome categoria',
'todo.addCategory': 'Aggiungi categoria',
'todo.newItem': 'Nuova attività',
@@ -2223,6 +2236,10 @@ const it: Record<string, string | { name: string; category: string }[]> = {
'system_notice.v3_mcp.highlight_scopes': '24 scope di autorizzazione granulari',
'system_notice.v3_mcp.highlight_deprecated': 'Token statici trek_ deprecati',
'system_notice.v3_mcp.highlight_tools': 'Strumenti e prompt estesi',
// System notices — personal thank you
'system_notice.v3_thankyou.title': 'Una nota personale da parte mia',
'system_notice.v3_thankyou.body': 'Prima di andare avanti — voglio prendermi un momento.\n\nTREK è nato come un progetto secondario che ho costruito per i miei viaggi. Non avrei mai immaginato che sarebbe cresciuto fino a diventare qualcosa di cui 4.000 di voi si fidano per pianificare le proprie avventure. Ogni stella, ogni issue, ogni richiesta di funzionalità — le leggo tutte, e sono loro a tenermi in piedi nelle notti tarde tra un lavoro a tempo pieno e l\'università.\n\nVoglio che sappiate: TREK sarà sempre open source, sempre self-hosted, sempre vostro. Nessun tracciamento, nessun abbonamento, nessuna fregatura. Solo uno strumento creato da qualcuno che ama viaggiare tanto quanto voi.\n\nUn ringraziamento speciale a [jubnl](https://github.com/jubnl) — sei diventato un collaboratore incredibile. Molto di ciò che rende la 3.0 fantastica porta la tua impronta. Grazie per aver creduto in questo progetto quando era ancora acerbo.\n\nE a ognuno di voi che ha segnalato un bug, tradotto una stringa, condiviso TREK con un amico o semplicemente lo ha usato per pianificare un viaggio — **grazie**. Voi siete il motivo per cui tutto questo esiste.\n\nA molte altre avventure insieme.\n\n— Maurice\n\n---\n\n[Unisciti alla community su Discord](https://discord.gg/7Q6M6jDwzf)\n\nSe TREK rende i tuoi viaggi migliori, un [piccolo caffè](https://ko-fi.com/mauriceboe) aiuta sempre a tenere le luci accese.',
}
export default it
+19 -2
View File
@@ -1013,6 +1013,15 @@ const nl: Record<string, string> = {
'reservations.meta.flightNumber': 'Vluchtnr.',
'reservations.meta.from': 'Van',
'reservations.meta.to': 'Naar',
'reservations.needsReview': 'Controleren',
'reservations.needsReviewHint': 'Luchthaven kon niet automatisch worden herkend — bevestig de locatie.',
'reservations.searchLocation': 'Station, haven, adres zoeken...',
'airport.searchPlaceholder': 'Luchthavencode of stad (bijv. FRA)',
'map.connections': 'Verbindingen',
'map.showConnections': 'Boekingsroutes tonen',
'map.hideConnections': 'Boekingsroutes verbergen',
'settings.bookingLabels': 'Routelabels voor boekingen',
'settings.bookingLabelsHint': 'Toon station- / luchthavennamen op de kaart. Indien uit, alleen het icoon.',
'reservations.meta.trainNumber': 'Treinnr.',
'reservations.meta.platform': 'Perron',
'reservations.meta.seat': 'Stoel',
@@ -1031,7 +1040,7 @@ const nl: Record<string, string> = {
'reservations.type.hotel': 'Accommodatie',
'reservations.type.restaurant': 'Restaurant',
'reservations.type.train': 'Trein',
'reservations.type.car': 'Huurauto',
'reservations.type.car': 'Auto',
'reservations.type.cruise': 'Cruise',
'reservations.type.event': 'Evenement',
'reservations.type.tour': 'Rondleiding',
@@ -1752,7 +1761,11 @@ const nl: Record<string, string> = {
'todo.unassigned': 'Niet toegewezen',
'todo.noCategory': 'Geen categorie',
'todo.hasDescription': 'Heeft beschrijving',
'todo.addItem': 'Nieuwe taak toevoegen...',
'todo.addItem': 'Nieuwe taak',
'todo.sidebar.sortBy': 'Sorteren op',
'todo.priority': 'Prioriteit',
'todo.newCategoryLabel': 'nieuw',
'budget.categoriesLabel': 'categorieën',
'todo.newCategory': 'Categorienaam',
'todo.addCategory': 'Categorie toevoegen',
'todo.newItem': 'Nieuwe taak',
@@ -2222,6 +2235,10 @@ const nl: Record<string, string> = {
'system_notice.v3_mcp.highlight_scopes': '24 gedetailleerde toestemmingsscopes',
'system_notice.v3_mcp.highlight_deprecated': 'Statische trek_-tokens verouderd',
'system_notice.v3_mcp.highlight_tools': 'Uitgebreide tools & prompts',
// System notices — personal thank you
'system_notice.v3_thankyou.title': 'Een persoonlijk woord van mij',
'system_notice.v3_thankyou.body': 'Voordat je verdergaat — ik wil even stilstaan.\n\nTREK begon als een zijproject dat ik bouwde voor mijn eigen reizen. Ik had nooit gedacht dat het zou uitgroeien tot iets waar 4.000 van jullie op vertrouwen om avonturen te plannen. Elke ster, elke issue, elk functieverzoek — ik lees ze allemaal, en ze houden me op de been tijdens de late avonden tussen een fulltime baan en de universiteit.\n\nIk wil dat jullie weten: TREK zal altijd open source zijn, altijd self-hosted, altijd van jullie. Geen tracking, geen abonnementen, geen addertjes. Gewoon een tool gebouwd door iemand die net zo veel van reizen houdt als jullie.\n\nSpeciale dank aan [jubnl](https://github.com/jubnl) — je bent een ongelooflijke medewerker geworden. Zo veel van wat 3.0 geweldig maakt draagt jouw vingerafdruk. Bedankt dat je in dit project geloofde toen het nog ruw was.\n\nEn aan ieder van jullie die een bug meldde, een string vertaalde, TREK deelde met een vriend of het simpelweg gebruikte om een reis te plannen — **bedankt**. Jullie zijn de reden dat dit bestaat.\n\nOp nog vele avonturen samen.\n\n— Maurice\n\n---\n\n[Sluit je aan bij de community op Discord](https://discord.gg/7Q6M6jDwzf)\n\nAls TREK je reizen beter maakt, houdt een [klein kopje koffie](https://ko-fi.com/mauriceboe) altijd de lichten aan.',
}
export default nl
+18 -1
View File
@@ -989,6 +989,15 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
'reservations.type.restaurant': 'Restauracja',
'reservations.type.train': 'Pociąg',
'reservations.type.car': 'Samochód',
'reservations.needsReview': 'Sprawdź',
'reservations.needsReviewHint': 'Nie udało się automatycznie dopasować lotniska — potwierdź lokalizację.',
'reservations.searchLocation': 'Szukaj stacji, portu, adresu...',
'airport.searchPlaceholder': 'Kod lotniska lub miasto (np. FRA)',
'map.connections': 'Połączenia',
'map.showConnections': 'Pokaż trasy rezerwacji',
'map.hideConnections': 'Ukryj trasy rezerwacji',
'settings.bookingLabels': 'Etykiety tras rezerwacji',
'settings.bookingLabelsHint': 'Pokazuje nazwy stacji / lotnisk na mapie. Gdy wyłączone, wyświetlana jest tylko ikona.',
'reservations.type.cruise': 'Rejs',
'reservations.type.event': 'Wydarzenie',
'reservations.type.tour': 'Wycieczka',
@@ -1801,7 +1810,11 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
'todo.unassigned': 'Nieprzypisane',
'todo.noCategory': 'Brak kategorii',
'todo.hasDescription': 'Ma opis',
'todo.addItem': 'Dodaj nowe zadanie...',
'todo.addItem': 'Nowe zadanie',
'todo.sidebar.sortBy': 'Sortuj wg',
'todo.priority': 'Priorytet',
'todo.newCategoryLabel': 'nowa',
'budget.categoriesLabel': 'kategorie',
'todo.newCategory': 'Nazwa kategorii',
'todo.addCategory': 'Dodaj kategorię',
'todo.newItem': 'Nowe zadanie',
@@ -2215,6 +2228,10 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
'system_notice.v3_mcp.highlight_scopes': '24 szczegółowe zakresy uprawnień',
'system_notice.v3_mcp.highlight_deprecated': 'Statyczne tokeny trek_ przestarzałe',
'system_notice.v3_mcp.highlight_tools': 'Rozszerzony zestaw narzędzi i promptów',
// System notices — personal thank you
'system_notice.v3_thankyou.title': 'Osobiste słowo ode mnie',
'system_notice.v3_thankyou.body': 'Zanim pójdziesz dalej — chcę się na chwilę zatrzymać.\n\nTREK zaczął się jako poboczny projekt, który zbudowałem na własne podróże. Nigdy nie wyobrażałem sobie, że wyrośnie na coś, czemu 4000 z was ufa przy planowaniu swoich przygód. Każda gwiazdka, każdy issue, każda prośba o funkcję — czytam je wszystkie i to one trzymają mnie na nogach podczas późnych nocy między pracą na pełny etat a uczelnią.\n\nChcę, żebyście wiedzieli: TREK zawsze będzie open source, zawsze self-hosted, zawsze wasz. Bez śledzenia, bez subskrypcji, bez haczyków. Po prostu narzędzie zbudowane przez kogoś, kto kocha podróżowanie tak samo jak wy.\n\nSzczególne podziękowania dla [jubnl](https://github.com/jubnl) — stałeś się niesamowitym współpracownikiem. Tak wiele z tego, co czyni wersję 3.0 wspaniałą, nosi twój ślad. Dziękuję, że uwierzyłeś w ten projekt, gdy był jeszcze surowy.\n\nI każdemu z was, kto zgłosił błąd, przetłumaczył tekst, podzielił się TREK z przyjacielem lub po prostu użył go do zaplanowania podróży — **dziękuję**. To wy jesteście powodem, dla którego to istnieje.\n\nZa wiele kolejnych wspólnych przygód.\n\n— Maurice\n\n---\n\n[Dołącz do społeczności na Discordzie](https://discord.gg/7Q6M6jDwzf)\n\nJeśli TREK sprawia, że Twoje podróże są lepsze, [mała kawa](https://ko-fi.com/mauriceboe) zawsze pomaga utrzymać światła włączone.',
}
export default pl
+19 -2
View File
@@ -1013,6 +1013,15 @@ const ru: Record<string, string> = {
'reservations.meta.flightNumber': 'Номер рейса',
'reservations.meta.from': 'Откуда',
'reservations.meta.to': 'Куда',
'reservations.needsReview': 'Проверить',
'reservations.needsReviewHint': 'Аэропорт не удалось определить автоматически — подтвердите местоположение.',
'reservations.searchLocation': 'Искать станцию, порт, адрес...',
'airport.searchPlaceholder': 'Код аэропорта или город (напр. FRA)',
'map.connections': 'Соединения',
'map.showConnections': 'Показать маршруты бронирований',
'map.hideConnections': 'Скрыть маршруты бронирований',
'settings.bookingLabels': 'Подписи маршрутов бронирований',
'settings.bookingLabelsHint': 'Отображает названия станций / аэропортов на карте. Если выключено, показывается только значок.',
'reservations.meta.trainNumber': 'Номер поезда',
'reservations.meta.platform': 'Платформа',
'reservations.meta.seat': 'Место',
@@ -1031,7 +1040,7 @@ const ru: Record<string, string> = {
'reservations.type.hotel': 'Жильё',
'reservations.type.restaurant': 'Ресторан',
'reservations.type.train': 'Поезд',
'reservations.type.car': 'Аренда авто',
'reservations.type.car': 'Автомобиль',
'reservations.type.cruise': 'Круиз',
'reservations.type.event': 'Мероприятие',
'reservations.type.tour': 'Экскурсия',
@@ -1749,7 +1758,11 @@ const ru: Record<string, string> = {
'todo.unassigned': 'Не назначено',
'todo.noCategory': 'Без категории',
'todo.hasDescription': 'Есть описание',
'todo.addItem': 'Добавить новую задачу...',
'todo.addItem': 'Новая задача',
'todo.sidebar.sortBy': 'Сортировать по',
'todo.priority': 'Приоритет',
'todo.newCategoryLabel': 'новая',
'budget.categoriesLabel': 'категорий',
'todo.newCategory': 'Название категории',
'todo.addCategory': 'Добавить категорию',
'todo.newItem': 'Новая задача',
@@ -2222,6 +2235,10 @@ const ru: Record<string, string> = {
'system_notice.v3_mcp.highlight_scopes': '24 детальных области разрешений',
'system_notice.v3_mcp.highlight_deprecated': 'Статические токены trek_ устарели',
'system_notice.v3_mcp.highlight_tools': 'Расширенный набор инструментов',
// System notices — personal thank you
'system_notice.v3_thankyou.title': 'Личное слово от меня',
'system_notice.v3_thankyou.body': 'Прежде чем продолжить — хочу остановиться на мгновение.\n\nTREK начинался как сторонний проект, который я создал для собственных поездок. Я никогда не думал, что он вырастет во что-то, чему 4 000 из вас доверяют планирование своих приключений. Каждая звёздочка, каждый issue, каждый запрос на фичу — я читаю их все, и именно они поддерживают меня в поздние ночи между основной работой и университетом.\n\nХочу, чтобы вы знали: TREK всегда будет open source, всегда self-hosted, всегда вашим. Никакого отслеживания, никаких подписок, никаких подвохов. Просто инструмент, созданный человеком, который любит путешествовать так же, как и вы.\n\nОсобая благодарность [jubnl](https://github.com/jubnl) — ты стал невероятным соратником. Многое из того, что делает версию 3.0 великолепной, несёт твой отпечаток. Спасибо, что поверил в этот проект, когда он был ещё сырым.\n\nИ каждому из вас, кто сообщил об ошибке, перевёл строку, поделился TREK с другом или просто использовал его для планирования поездки — **спасибо**. Вы — причина, по которой всё это существует.\n\nЗа множество новых приключений вместе.\n\n— Maurice\n\n---\n\n[Присоединяйся к сообществу в Discord](https://discord.gg/7Q6M6jDwzf)\n\nЕсли TREK делает твои путешествия лучше, [маленький кофе](https://ko-fi.com/mauriceboe) всегда помогает держать свет включённым.',
}
export default ru
+19 -2
View File
@@ -1013,6 +1013,15 @@ const zh: Record<string, string> = {
'reservations.meta.flightNumber': '航班号',
'reservations.meta.from': '出发',
'reservations.meta.to': '到达',
'reservations.needsReview': '待确认',
'reservations.needsReviewHint': '无法自动匹配机场 — 请确认位置。',
'reservations.searchLocation': '搜索车站、港口、地址...',
'airport.searchPlaceholder': '机场代码或城市(如 FRA',
'map.connections': '连接',
'map.showConnections': '显示预订路线',
'map.hideConnections': '隐藏预订路线',
'settings.bookingLabels': '预订路线标签',
'settings.bookingLabelsHint': '在地图上显示车站 / 机场名称。关闭时仅显示图标。',
'reservations.meta.trainNumber': '车次',
'reservations.meta.platform': '站台',
'reservations.meta.seat': '座位',
@@ -1031,7 +1040,7 @@ const zh: Record<string, string> = {
'reservations.type.hotel': '住宿',
'reservations.type.restaurant': '餐厅',
'reservations.type.train': '火车',
'reservations.type.car': '车',
'reservations.type.car': '车',
'reservations.type.cruise': '邮轮',
'reservations.type.event': '活动',
'reservations.type.tour': '旅游团',
@@ -1749,7 +1758,11 @@ const zh: Record<string, string> = {
'todo.unassigned': '未分配',
'todo.noCategory': '无分类',
'todo.hasDescription': '有描述',
'todo.addItem': '添加新任务...',
'todo.addItem': '新建任务',
'todo.sidebar.sortBy': '排序方式',
'todo.priority': '优先级',
'todo.newCategoryLabel': '新建',
'budget.categoriesLabel': '类别',
'todo.newCategory': '分类名称',
'todo.addCategory': '添加分类',
'todo.newItem': '新任务',
@@ -2222,6 +2235,10 @@ const zh: Record<string, string> = {
'system_notice.v3_mcp.highlight_scopes': '24 个细粒度权限范围',
'system_notice.v3_mcp.highlight_deprecated': '静态 trek_ 令牌已弃用',
'system_notice.v3_mcp.highlight_tools': '扩展工具集与提示词',
// System notices — personal thank you
'system_notice.v3_thankyou.title': '来自我的一封私人信',
'system_notice.v3_thankyou.body': '在你继续之前——我想停下来说几句。\n\nTREK 最初只是我为自己的旅行而做的一个业余项目。我从未想过它会成长为 4,000 人信赖的冒险规划工具。每一颗星标、每一个 issue、每一个功能请求——我都会读,它们在全职工作和大学学业之间的深夜里支撑着我继续前行。\n\n我想让你们知道:TREK 将永远开源,永远可自托管,永远属于你们。没有追踪,没有订阅,没有任何附加条件。只是一个热爱旅行的人为同样热爱旅行的你们打造的工具。\n\n特别感谢 [jubnl](https://github.com/jubnl)——你已经成为一位不可思议的合作者。3.0 版本中许多精彩之处都留下了你的印记。感谢你在这个项目还很粗糙的时候就选择了相信它。\n\n也感谢你们每一位——报告了 bug、翻译了文本、向朋友分享了 TREK,或者只是用它规划了一次旅行——**谢谢你们**。你们是这一切存在的原因。\n\n愿我们一起踏上更多的冒险旅程。\n\n— Maurice\n\n---\n\n[加入 Discord 社区](https://discord.gg/7Q6M6jDwzf)\n\n如果 TREK 让你的旅行更美好,一杯[小小的咖啡](https://ko-fi.com/mauriceboe)能让这盏灯一直亮着。',
}
export default zh
+19 -2
View File
@@ -1069,6 +1069,15 @@ const zhTw: Record<string, string> = {
'reservations.meta.flightNumber': '航班號',
'reservations.meta.from': '出發',
'reservations.meta.to': '到達',
'reservations.needsReview': '待確認',
'reservations.needsReviewHint': '無法自動匹配機場 — 請確認位置。',
'reservations.searchLocation': '搜尋車站、港口、地址...',
'airport.searchPlaceholder': '機場代碼或城市(例如 FRA',
'map.connections': '連接',
'map.showConnections': '顯示預訂路線',
'map.hideConnections': '隱藏預訂路線',
'settings.bookingLabels': '預訂路線標籤',
'settings.bookingLabelsHint': '在地圖上顯示車站 / 機場名稱。關閉時僅顯示圖示。',
'reservations.meta.trainNumber': '車次',
'reservations.meta.platform': '站臺',
'reservations.meta.seat': '座位',
@@ -1087,7 +1096,7 @@ const zhTw: Record<string, string> = {
'reservations.type.hotel': '住宿',
'reservations.type.restaurant': '餐廳',
'reservations.type.train': '火車',
'reservations.type.car': '車',
'reservations.type.car': '車',
'reservations.type.cruise': '郵輪',
'reservations.type.event': '活動',
'reservations.type.tour': '旅遊團',
@@ -1766,7 +1775,11 @@ const zhTw: Record<string, string> = {
'todo.unassigned': '未指派',
'todo.noCategory': '無分類',
'todo.hasDescription': '有說明',
'todo.addItem': '新增任務...',
'todo.addItem': '新增任務',
'todo.sidebar.sortBy': '排序方式',
'todo.priority': '優先順序',
'todo.newCategoryLabel': '新增',
'budget.categoriesLabel': '類別',
'todo.newCategory': '分類名稱',
'todo.addCategory': '新增分類',
'todo.newItem': '新任務',
@@ -2223,6 +2236,10 @@ const zhTw: Record<string, string> = {
'system_notice.v3_mcp.highlight_scopes': '24 個細粒度權限範圍',
'system_notice.v3_mcp.highlight_deprecated': '靜態 trek_ 令牌已棄用',
'system_notice.v3_mcp.highlight_tools': '擴展工具集與提示詞',
// System notices — personal thank you
'system_notice.v3_thankyou.title': '來自我的一封私人信',
'system_notice.v3_thankyou.body': '在你繼續之前——我想停下來說幾句。\n\nTREK 最初只是我為自己的旅行而做的一個業餘專案。我從未想過它會成長為 4,000 人信賴的冒險規劃工具。每一顆星標、每一個 issue、每一個功能請求——我都會讀,它們在全職工作和大學學業之間的深夜裡支撐著我繼續前行。\n\n我想讓你們知道:TREK 將永遠開源,永遠可自託管,永遠屬於你們。沒有追蹤,沒有訂閱,沒有任何附加條件。只是一個熱愛旅行的人為同樣熱愛旅行的你們打造的工具。\n\n特別感謝 [jubnl](https://github.com/jubnl)——你已經成為一位不可思議的合作者。3.0 版本中許多精彩之處都留下了你的印記。感謝你在這個專案還很粗糙的時候就選擇了相信它。\n\n也感謝你們每一位——回報了 bug、翻譯了文字、向朋友分享了 TREK,或者只是用它規劃了一次旅行——**謝謝你們**。你們是這一切存在的原因。\n\n願我們一起踏上更多的冒險旅程。\n\n— Maurice\n\n---\n\n[加入 Discord 社群](https://discord.gg/7Q6M6jDwzf)\n\n如果 TREK 讓你的旅行更美好,一杯[小小的咖啡](https://ko-fi.com/mauriceboe)能讓這盞燈一直亮著。',
}
export default zhTw
+7 -9
View File
@@ -323,7 +323,7 @@ body {
display: none;
}
/* Scrollbalken */
/* Scrollbars — styled on desktop, hidden on mobile */
::-webkit-scrollbar {
width: 6px;
height: 6px;
@@ -333,21 +333,23 @@ body {
height: 0;
width: 0;
}
::-webkit-scrollbar-track {
background: var(--scrollbar-track);
border-radius: 3px;
}
::-webkit-scrollbar-thumb {
background: var(--scrollbar-thumb);
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--scrollbar-hover);
}
@media (max-width: 767px) {
* { scrollbar-width: none; }
::-webkit-scrollbar { width: 0; height: 0; }
}
.route-info-pill { background: none !important; border: none !important; box-shadow: none !important; width: auto !important; height: auto !important; margin: 0 !important; }
.chat-scroll { overflow-y: auto !important; scrollbar-width: none; -webkit-overflow-scrolling: touch; }
.chat-scroll::-webkit-scrollbar { width: 0; background: transparent; }
@@ -405,6 +407,7 @@ img[alt="TREK"] {
}
.scroll-container {
scrollbar-width: thin;
scrollbar-color: var(--scrollbar-thumb) var(--scrollbar-track);
}
@@ -447,11 +450,6 @@ img[alt="TREK"] {
color-scheme: dark;
}
/* Scroll-Container */
.scroll-container {
scrollbar-width: thin;
scrollbar-color: #d1d5db #f1f5f9;
}
/* Toast-Animationen */
@keyframes slideUp {
+94 -28
View File
@@ -20,6 +20,9 @@ import {
Laugh, Smile, Meh, Annoyed, Frown,
Sun, CloudSun, Cloud, CloudRain, CloudLightning, Snowflake, ChevronDown, Eye, EyeOff,
} from 'lucide-react'
import MobileMapTimeline from '../components/Journey/MobileMapTimeline'
import MobileEntryView from '../components/Journey/MobileEntryView'
import { useIsMobile } from '../hooks/useIsMobile'
import type { JourneyEntry, JourneyPhoto, JourneyDetail } from '../store/journeyStore'
const GRADIENTS = [
@@ -84,7 +87,9 @@ export default function JourneyDetailPage() {
const fullMapRef = useRef<JourneyMapHandle>(null)
const [activeLocationId, setActiveLocationId] = useState<string | null>(null)
const isMobile = useIsMobile()
const [view, setView] = useState<'timeline' | 'gallery' | 'map'>('timeline')
const [viewingEntry, setViewingEntry] = useState<JourneyEntry | null>(null)
const [editingEntry, setEditingEntry] = useState<JourneyEntry | null>(null)
const [lightbox, setLightbox] = useState<{ photos: { id: number; src: string; caption?: string | null; provider?: string; asset_id?: string | null; owner_id?: number | null }[]; index: number } | null>(null)
const [deleteTarget, setDeleteTarget] = useState<JourneyEntry | null>(null)
@@ -202,10 +207,60 @@ export default function JourneyDetailPage() {
const dayGroups = groupByDate(timelineEntries)
const sortedDates = [...dayGroups.keys()].sort()
const showMobileCombined = isMobile && view === 'timeline'
return (
<div className="min-h-screen bg-zinc-50 dark:bg-zinc-950">
<Navbar />
<div style={{ paddingTop: 'var(--nav-h, 0px)' }}>
{/* Mobile combined map+timeline (Polarsteps-style) — renders as fullscreen overlay */}
{showMobileCombined && (
<MobileMapTimeline
entries={timelineEntries}
mapEntries={sidebarMapItems}
dark={document.documentElement.classList.contains('dark')}
onEntryClick={(entry) => setViewingEntry(entry)}
onAddEntry={() => {
const today = new Date().toISOString().split('T')[0]
setEditingEntry({ id: 0, journey_id: current.id, author_id: 0, type: 'entry', entry_date: today, visibility: 'private', sort_order: 0, photos: [], created_at: 0, updated_at: 0 } as JourneyEntry)
}}
/>
)}
{/* Fullscreen entry view (mobile) */}
{viewingEntry && (
<MobileEntryView
entry={viewingEntry}
onClose={() => setViewingEntry(null)}
onEdit={() => { setViewingEntry(null); setEditingEntry(viewingEntry); }}
onDelete={() => { setViewingEntry(null); setDeleteTarget(viewingEntry); }}
onPhotoClick={(photos, idx) => setLightbox({ photos: photos.map(p => ({ id: p.id, src: photoUrl(p, 'original'), caption: p.caption, provider: p.provider, asset_id: p.asset_id, owner_id: p.owner_id })), index: idx })}
/>
)}
{/* Floating tab toggle on mobile combined view */}
{showMobileCombined && (
<div className="fixed top-[calc(var(--nav-h,56px)+12px)] left-4 z-30">
<div className="flex bg-white/90 dark:bg-zinc-800/90 backdrop-blur-lg border border-zinc-200 dark:border-zinc-700 rounded-lg overflow-hidden shadow-lg">
<button
onClick={() => setView('timeline')}
className="flex items-center gap-1.5 px-3 py-[7px] text-[12px] font-medium bg-zinc-900 dark:bg-white text-white dark:text-zinc-900"
>
<MapPin size={13} />
{t('journey.detail.journeyTab') || 'Journey'}
</button>
<button
onClick={() => setView('gallery')}
className="flex items-center gap-1.5 px-3 py-[7px] text-[12px] font-medium text-zinc-500 hover:text-zinc-700 dark:hover:text-zinc-300"
>
<Grid size={13} />
{t('journey.share.gallery')}
</button>
</div>
</div>
)}
<div style={{ paddingTop: 'var(--nav-h, 0px)' }} className={showMobileCombined ? 'hidden' : ''}>
<div className="max-w-[1440px] mx-auto px-0 md:px-8 pt-0 md:py-6">
{/* Back link — desktop */}
@@ -298,11 +353,17 @@ export default function JourneyDetailPage() {
{/* View Controls */}
<div className="flex items-center justify-between mt-5 mb-5">
<div className="flex bg-white dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-lg overflow-hidden">
{[
{ id: 'timeline' as const, icon: List, label: t('journey.share.timeline') },
{ id: 'gallery' as const, icon: Grid, label: t('journey.share.gallery') },
{ id: 'map' as const, icon: MapPin, label: t('journey.share.map') },
].map(v => (
{(isMobile
? [
{ id: 'timeline' as const, icon: MapPin, label: t('journey.detail.journeyTab') || 'Journey' },
{ id: 'gallery' as const, icon: Grid, label: t('journey.share.gallery') },
]
: [
{ id: 'timeline' as const, icon: List, label: t('journey.share.timeline') },
{ id: 'gallery' as const, icon: Grid, label: t('journey.share.gallery') },
{ id: 'map' as const, icon: MapPin, label: t('journey.share.map') },
]
).map(v => (
<button
key={v.id}
onClick={() => setView(v.id)}
@@ -317,21 +378,21 @@ export default function JourneyDetailPage() {
</button>
))}
</div>
{view === 'timeline' && (
{(!isMobile ? view === 'timeline' : view !== 'gallery') && (
<button
onClick={() => {
const today = new Date().toISOString().split('T')[0]
setEditingEntry({ id: 0, journey_id: current.id, author_id: 0, type: 'entry', entry_date: today, visibility: 'private', sort_order: 0, photos: [], created_at: 0, updated_at: 0 } as JourneyEntry)
}}
className="w-8 h-8 rounded-lg bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 flex items-center justify-center hover:bg-zinc-800 dark:hover:bg-zinc-100"
className={`w-8 h-8 rounded-lg bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 flex items-center justify-center hover:bg-zinc-800 dark:hover:bg-zinc-100 ${isMobile && view === 'timeline' ? 'hidden' : ''}`}
>
<Plus size={16} />
</button>
)}
</div>
{/* Timeline */}
{view === 'timeline' && (
{/* Timeline (desktop only — mobile uses fullscreen combined view above) */}
{!isMobile && view === 'timeline' && (
<div className="flex flex-col gap-6 pb-24 md:pb-6">
{sortedDates.length === 0 && (
<div className="text-center py-16">
@@ -398,8 +459,8 @@ export default function JourneyDetailPage() {
/>
)}
{/* Full Map View */}
{view === 'map' && <div className="pb-24 md:pb-6"><MapView
{/* Full Map View (desktop only — mobile uses combined view) */}
{!isMobile && view === 'map' && <div className="pb-24 md:pb-6"><MapView
entries={current.entries}
mapEntries={mapEntries}
sortedDates={sortedDates}
@@ -908,11 +969,11 @@ function GalleryView({ entries, journeyId, userId, trips, onPhotoClick, onRefres
</div>
) : (
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-1.5 pb-24 md:pb-6">
{allPhotos.map(({ photo, entry }) => (
{allPhotos.map(({ photo, entry }, i) => (
<div
key={photo.id}
className="relative aspect-square rounded-lg overflow-hidden cursor-pointer group"
onClick={() => onPhotoClick(entry.photos, entry.photos.indexOf(photo))}
onClick={() => onPhotoClick(allPhotos.map(a => a.photo), i)}
>
<img
src={photoUrl(photo, 'thumbnail')}
@@ -1455,8 +1516,9 @@ function ProviderPicker({ provider, userId, entries, trips, existingAssetIds, on
const { t } = useTranslation()
const [filter, setFilter] = useState<'trip' | 'custom' | 'all' | 'album'>('trip')
const [photos, setPhotos] = useState<any[]>([])
const [albums, setAlbums] = useState<any[]>([])
const [albums, setAlbums] = useState<Array<{ id: string; albumName: string; assetCount: number; passphrase?: string }>>([])
const [selectedAlbum, setSelectedAlbum] = useState<string | null>(null)
const [selectedAlbumPassphrase, setSelectedAlbumPassphrase] = useState<string | undefined>(undefined)
const [loading, setLoading] = useState(false)
const [loadingMore, setLoadingMore] = useState(false)
const [hasMore, setHasMore] = useState(false)
@@ -1518,13 +1580,14 @@ function ProviderPicker({ provider, userId, entries, trips, existingAssetIds, on
searchPhotos(searchFrom, searchTo, searchPage + 1, true)
}
const loadAlbumPhotos = async (albumId: string) => {
const loadAlbumPhotos = async (album: { id: string; passphrase?: string }) => {
const signal = cancelPending()
setLoading(true)
setPhotos([])
setHasMore(false)
try {
const res = await fetch(`/api/integrations/memories/${provider}/albums/${albumId}/photos`, { credentials: 'include', signal })
const qs = album.passphrase ? `?passphrase=${encodeURIComponent(album.passphrase)}` : ''
const res = await fetch(`/api/integrations/memories/${provider}/albums/${album.id}/photos${qs}`, { credentials: 'include', signal })
if (res.ok) setPhotos((await res.json()).assets || [])
} catch (e: any) { if (e.name !== 'AbortError') {} }
if (!signal.aborted) setLoading(false)
@@ -1643,7 +1706,7 @@ function ProviderPicker({ provider, userId, entries, trips, existingAssetIds, on
{albums.map((a: any) => (
<button
key={a.id}
onClick={() => { setSelectedAlbum(a.id); loadAlbumPhotos(a.id) }}
onClick={() => { setSelectedAlbum(a.id); setSelectedAlbumPassphrase(a.passphrase); loadAlbumPhotos(a) }}
className={`px-2.5 py-1 rounded-lg text-[11px] font-medium whitespace-nowrap flex-shrink-0 border ${
selectedAlbum === a.id
? 'bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 border-zinc-900 dark:border-white'
@@ -1773,13 +1836,13 @@ function ProviderPicker({ provider, userId, entries, trips, existingAssetIds, on
}`}
>
<img
src={`/api/integrations/memories/${provider}/assets/0/${asset.id}/${userId}/thumbnail`}
src={`/api/integrations/memories/${provider}/assets/0/${asset.id}/${userId}/thumbnail${selectedAlbumPassphrase ? `?passphrase=${encodeURIComponent(selectedAlbumPassphrase)}` : ''}`}
alt=""
className="w-full h-full object-cover"
loading="lazy"
onError={e => {
const img = e.currentTarget
const original = `/api/integrations/memories/${provider}/assets/0/${asset.id}/${userId}/original`
const original = `/api/integrations/memories/${provider}/assets/0/${asset.id}/${userId}/original${selectedAlbumPassphrase ? `?passphrase=${encodeURIComponent(selectedAlbumPassphrase)}` : ''}`
if (!img.src.includes('/original')) img.src = original
}}
/>
@@ -2027,8 +2090,9 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa
}
return (
<div className="fixed inset-0 z-[200] flex items-center justify-center p-5" style={{ background: 'rgba(9,9,11,0.75)' }}>
<div className="bg-white dark:bg-zinc-900 rounded-2xl shadow-[0_20px_40px_rgba(0,0,0,0.2)] max-w-[640px] w-full max-h-[90vh] flex flex-col overflow-hidden">
<div className="fixed inset-0 z-[9999] flex items-end sm:items-center sm:justify-center sm:p-5" style={{ background: 'rgba(9,9,11,0.75)' }}>
<div className="bg-white dark:bg-zinc-900 sm:rounded-2xl shadow-[0_20px_40px_rgba(0,0,0,0.2)] sm:max-w-[640px] w-full flex flex-col overflow-hidden h-full sm:h-auto sm:max-h-[90vh]">
<div className="flex items-center justify-between px-6 py-4 border-b border-zinc-200 dark:border-zinc-700">
<h2 className="text-[16px] font-bold text-zinc-900 dark:text-white">{entry.id === 0 ? t('journey.detail.newEntry') : t('journey.detail.editEntry')}</h2>
@@ -2185,7 +2249,7 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa
</div>
<div className="flex flex-col gap-1.5">
{pros.map((p, i) => (
<div key={i} className="flex items-center gap-2 h-9 px-3 bg-green-50 dark:bg-green-900/10 border border-green-200 dark:border-green-800/30 rounded-[10px]">
<div key={i} className="flex items-center gap-2 h-9 px-3 border rounded-[10px] border-zinc-200 dark:border-zinc-700">
<span className="w-[5px] h-[5px] rounded-full bg-green-500 flex-shrink-0" />
<input
value={p}
@@ -2219,7 +2283,7 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa
</div>
<div className="flex flex-col gap-1.5">
{cons.map((c, i) => (
<div key={i} className="flex items-center gap-2 h-9 px-3 bg-red-50 dark:bg-red-900/10 border border-red-200 dark:border-red-800/30 rounded-[10px]">
<div key={i} className="flex items-center gap-2 h-9 px-3 border rounded-[10px] border-zinc-200 dark:border-zinc-700">
<span className="w-[5px] h-[5px] rounded-full bg-red-500 flex-shrink-0" />
<input
value={c}
@@ -2283,7 +2347,7 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa
/>
{locationLat && (
<div className="absolute right-2 top-1/2 -translate-y-1/2">
<MapPin size={13} className="text-emerald-500" />
<MapPin size={13} className="text-zinc-500 dark:text-zinc-400" />
</div>
)}
</div>
@@ -2330,8 +2394,10 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa
const active = mood === key
return (
<button key={key} onClick={() => setMood(active ? '' : key)}
className="flex items-center gap-1 px-2.5 py-1 rounded-full text-[11px] font-medium border transition-all"
style={{ background: active ? config.bg : 'transparent', color: active ? config.text : '#71717A', borderColor: active ? config.text + '30' : '#E4E4E7' }}>
className={`flex items-center gap-1 px-2.5 py-1 rounded-full text-[11px] font-medium border transition-all ${
active ? '' : 'border-zinc-200 dark:border-zinc-700 text-zinc-500'
}`}
style={active ? { background: config.bg, color: config.text, borderColor: config.text + '30' } : undefined}>
<Icon size={12} />
{t(config.label)}
</button>
@@ -2361,7 +2427,7 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa
</div>
<div className="flex items-center justify-end gap-2 px-6 py-4 border-t border-zinc-200 dark:border-zinc-700 bg-zinc-50 dark:bg-zinc-800/50">
<div className="flex items-center justify-end gap-2 px-6 py-4 border-t border-zinc-200 dark:border-zinc-700 bg-zinc-50 dark:bg-zinc-800/50" style={{ paddingBottom: 'max(16px, env(safe-area-inset-bottom, 16px))' }}>
<button onClick={onClose} className="px-3.5 py-2 rounded-lg border border-zinc-200 dark:border-zinc-600 text-[13px] font-medium text-zinc-700 dark:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-zinc-700">{t('common.cancel')}</button>
<button onClick={handleSave} disabled={saving} className="px-3.5 py-2 rounded-lg bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 text-[13px] font-medium hover:bg-zinc-800 dark:hover:bg-zinc-100 disabled:opacity-50">
{saving ? t('common.saving') : t('common.save')}
+17 -2
View File
@@ -7,6 +7,8 @@ import { List, Grid, MapPin, Camera, BookOpen, Image } from 'lucide-react'
import JourneyMap from '../components/Journey/JourneyMap'
import JournalBody from '../components/Journey/JournalBody'
import PhotoLightbox from '../components/Journey/PhotoLightbox'
import MobileMapTimeline from '../components/Journey/MobileMapTimeline'
import { useIsMobile } from '../hooks/useIsMobile'
interface PublicEntry {
id: number
@@ -62,6 +64,7 @@ export default function JourneyPublicPage() {
const [data, setData] = useState<any>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState(false)
const isMobile = useIsMobile()
const [view, setView] = useState<'timeline' | 'gallery' | 'map'>('timeline')
const [lightbox, setLightbox] = useState<{ photos: { id: string; src: string; caption?: string | null }[]; index: number } | null>(null)
const { t } = useTranslation()
@@ -202,8 +205,20 @@ export default function JourneyPublicPage() {
</div>
)}
{/* Timeline */}
{view === 'timeline' && perms.share_timeline && (
{/* Mobile combined map+timeline (public, read-only) */}
{isMobile && view === 'timeline' && perms.share_timeline && perms.share_map && (
<MobileMapTimeline
entries={entries}
mapEntries={mapEntries.map(e => ({ id: String(e.id), lat: e.location_lat!, lng: e.location_lng!, title: e.title, mood: e.mood, entry_date: e.entry_date }))}
dark={document.documentElement.classList.contains('dark')}
readOnly
onEntryClick={() => {}}
publicPhotoUrl={(photoId) => `/api/public/journey/${token}/photos/${photoId}/original`}
/>
)}
{/* Timeline (desktop, or mobile without map permission) */}
{(!isMobile || !perms.share_map) && view === 'timeline' && perms.share_timeline && (
<div className="flex flex-col gap-6">
{sortedDates.map(date => {
const dayEntries = groupedEntries.get(date)!
+117 -23
View File
@@ -35,36 +35,102 @@ import { useRouteCalculation } from '../hooks/useRouteCalculation'
import { usePlaceSelection } from '../hooks/usePlaceSelection'
import { usePlannerHistory } from '../hooks/usePlannerHistory'
import type { Accommodation, TripMember, Day, Place, Reservation, PackingItem, TodoItem } from '../types'
import { ListTodo } from 'lucide-react'
import { ListTodo, Upload, Plus } from 'lucide-react'
function ListsContainer({ tripId, packingItems, todoItems }: { tripId: number; packingItems: PackingItem[]; todoItems: TodoItem[] }) {
const [subTab, setSubTab] = useState<'packing' | 'todo'>(() => {
return (sessionStorage.getItem(`trip-lists-subtab-${tripId}`) as 'packing' | 'todo') || 'packing'
})
const setSubTabPersist = (tab: 'packing' | 'todo') => { setSubTab(tab); sessionStorage.setItem(`trip-lists-subtab-${tripId}`, tab) }
const [importPackingSignal, setImportPackingSignal] = useState(0)
const [addTodoSignal, setAddTodoSignal] = useState(0)
const { t } = useTranslation()
const tabs = [
{ id: 'packing' as const, label: t('todo.subtab.packing'), icon: PackageCheck, count: packingItems.length },
{ id: 'todo' as const, label: t('todo.subtab.todo'), icon: ListTodo, count: todoItems.length },
]
return (
<div>
<div style={{ display: 'flex', gap: 4, padding: '4px 16px 0', borderBottom: '1px solid var(--border-faint)', marginBottom: 8 }}>
{([
{ id: 'packing' as const, label: t('todo.subtab.packing'), icon: PackageCheck },
{ id: 'todo' as const, label: t('todo.subtab.todo'), icon: ListTodo },
]).map(tab => (
<button key={tab.id} onClick={() => setSubTabPersist(tab.id)}
style={{
display: 'flex', alignItems: 'center', gap: 5, fontSize: 12, fontWeight: 500, padding: '8px 14px',
border: 'none', cursor: 'pointer', fontFamily: 'inherit', background: 'none',
color: subTab === tab.id ? 'var(--text-primary)' : 'var(--text-faint)',
borderBottom: subTab === tab.id ? '2px solid var(--text-primary)' : '2px solid transparent',
marginBottom: -1, transition: 'color 0.15s',
}}>
<tab.icon size={14} />
{tab.label}
</button>
))}
<div style={{ padding: '24px 28px 0' }} className="max-md:!px-4 max-md:!pt-4">
<div style={{
background: 'var(--bg-tertiary)', borderRadius: 18,
padding: '14px 16px 14px 22px',
display: 'flex', alignItems: 'center', gap: 16, flexWrap: 'wrap',
}}>
<h2 style={{ margin: 0, fontSize: 18, fontWeight: 600, color: 'var(--text-primary)', letterSpacing: '-0.01em', flexShrink: 0 }}>
{t('trip.tabs.lists')}
</h2>
<div className="hidden md:block" style={{ width: 1, height: 22, background: 'var(--border-faint)', flexShrink: 0 }} />
<div style={{ display: 'inline-flex', gap: 4, flexWrap: 'wrap', flex: 1, minWidth: 0 }}>
{tabs.map(tab => {
const active = subTab === tab.id
const Icon = tab.icon
return (
<button key={tab.id} onClick={() => setSubTabPersist(tab.id)}
style={{
appearance: 'none', border: 'none', cursor: 'pointer', fontFamily: 'inherit',
display: 'inline-flex', alignItems: 'center', gap: 6,
padding: '6px 12px', borderRadius: 99, fontSize: 13, whiteSpace: 'nowrap',
background: active ? 'var(--bg-card)' : 'transparent',
color: active ? 'var(--text-primary)' : 'var(--text-muted)',
fontWeight: active ? 500 : 400,
boxShadow: active ? '0 1px 2px rgba(0,0,0,0.06)' : 'none',
transition: 'all 0.15s ease',
}}
>
<Icon size={13} style={{ color: active ? 'var(--text-primary)' : 'var(--text-faint)' }} />
<span className="hidden sm:inline">{tab.label}</span>
<span style={{
fontSize: 10, fontWeight: 600,
background: active ? 'var(--bg-tertiary)' : 'rgba(0,0,0,0.06)',
color: 'var(--text-faint)',
padding: '1px 6px', borderRadius: 99, minWidth: 16, textAlign: 'center',
}}>{tab.count}</span>
</button>
)
})}
</div>
{subTab === 'packing' && (
<button onClick={() => setImportPackingSignal(s => s + 1)} style={{
appearance: 'none', border: 'none', cursor: 'pointer', fontFamily: 'inherit',
display: 'inline-flex', alignItems: 'center', gap: 6,
padding: '9px 14px', borderRadius: 10, fontSize: 13, fontWeight: 500,
background: 'var(--accent)', color: 'var(--accent-text)', flexShrink: 0,
marginLeft: 'auto',
transition: 'opacity 0.15s ease',
}}
onMouseEnter={e => e.currentTarget.style.opacity = '0.88'}
onMouseLeave={e => e.currentTarget.style.opacity = '1'}
>
<Upload size={14} strokeWidth={2.5} />
<span className="hidden sm:inline">{t('packing.import')}</span>
</button>
)}
{subTab === 'todo' && (
<button onClick={() => setAddTodoSignal(s => s + 1)} style={{
appearance: 'none', border: 'none', cursor: 'pointer', fontFamily: 'inherit',
display: 'inline-flex', alignItems: 'center', gap: 6,
padding: '9px 14px', borderRadius: 10, fontSize: 13, fontWeight: 500,
background: 'var(--accent)', color: 'var(--accent-text)', flexShrink: 0,
marginLeft: 'auto',
transition: 'opacity 0.15s ease',
}}
onMouseEnter={e => e.currentTarget.style.opacity = '0.88'}
onMouseLeave={e => e.currentTarget.style.opacity = '1'}
>
<Plus size={14} strokeWidth={2.5} />
<span className="hidden sm:inline">{t('todo.addItem')}</span>
</button>
)}
</div>
</div>
<div style={{ padding: '16px 28px 0' }} className="max-md:!px-4">
{subTab === 'packing' && <PackingListPanel tripId={tripId} items={packingItems} openImportSignal={importPackingSignal} />}
{subTab === 'todo' && <TodoListPanel tripId={tripId} items={todoItems} addItemSignal={addTodoSignal} />}
</div>
{subTab === 'packing' && <PackingListPanel tripId={tripId} items={packingItems} />}
{subTab === 'todo' && <TodoListPanel tripId={tripId} items={todoItems} />}
</div>
)
}
@@ -168,6 +234,23 @@ export default function TripPlannerPage(): React.ReactElement | null {
const [mobileSidebarOpen, setMobileSidebarOpen] = useState<'left' | 'right' | null>(null)
const [deletePlaceId, setDeletePlaceId] = useState<number | null>(null)
const connectionsStorageKey = tripId ? `trek:visible-connections:${tripId}` : null
const [visibleConnections, setVisibleConnections] = useState<number[]>(() => {
if (typeof window === 'undefined' || !connectionsStorageKey) return []
try {
const stored = window.localStorage.getItem(connectionsStorageKey)
return stored ? JSON.parse(stored) as number[] : []
} catch { return [] }
})
useEffect(() => {
if (typeof window === 'undefined' || !connectionsStorageKey) return
window.localStorage.setItem(connectionsStorageKey, JSON.stringify(visibleConnections))
}, [connectionsStorageKey, visibleConnections])
const toggleConnection = useCallback((id: number) => {
setVisibleConnections(prev => prev.includes(id) ? prev.filter(x => x !== id) : [...prev, id])
}, [])
const [mapTransportDetail, setMapTransportDetail] = useState<Reservation | null>(null)
const [isMobile, setIsMobile] = useState(() => window.innerWidth < 768)
useEffect(() => {
const mq = window.matchMedia('(max-width: 767px)')
@@ -626,6 +709,13 @@ export default function TripPlannerPage(): React.ReactElement | null {
rightWidth={rightCollapsed ? 0 : rightWidth}
hasInspector={!!selectedPlace}
hasDayDetail={!!showDayDetail && !selectedPlace}
reservations={reservations}
showReservationStats={settings.route_calculation !== false}
visibleConnectionIds={visibleConnections}
onReservationClick={(rid) => {
const r = reservations.find(x => x.id === rid)
if (r) setMapTransportDetail(r)
}}
/>
@@ -672,6 +762,10 @@ export default function TripPlannerPage(): React.ReactElement | null {
onAssignToDay={handleAssignToDay}
onRouteCalculated={(r) => { if (r) { setRoute(r.coordinates); setRouteInfo({ distance: r.distanceText, duration: r.durationText, walkingText: r.walkingText, drivingText: r.drivingText }) } else { setRoute(null); setRouteInfo(null) } }}
reservations={reservations}
visibleConnectionIds={visibleConnections}
onToggleConnection={toggleConnection}
externalTransportDetail={mapTransportDetail}
onExternalTransportDetailHandled={() => setMapTransportDetail(null)}
onAddReservation={(dayId) => { setEditingReservation(null); tripActions.setSelectedDay(dayId); setShowReservationModal(true) }}
onDayDetail={(day) => { setShowDayDetail(day); setSelectedPlaceId(null); selectAssignment(null) }}
onRemoveAssignment={handleRemoveAssignment}
@@ -912,7 +1006,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
)}
{activeTab === 'buchungen' && (
<div style={{ height: '100%', maxWidth: 1800, margin: '0 auto', width: '100%', display: 'flex', flexDirection: 'column', overflowY: 'auto', overscrollBehavior: 'contain', paddingBottom: 'var(--bottom-nav-h)' }}>
<div style={{ height: '100%', width: '100%', display: 'flex', flexDirection: 'column', overflowY: 'auto', overscrollBehavior: 'contain', paddingBottom: 'var(--bottom-nav-h)' }}>
<ReservationsPanel
tripId={tripId}
reservations={reservations}
@@ -928,13 +1022,13 @@ export default function TripPlannerPage(): React.ReactElement | null {
)}
{activeTab === 'listen' && (
<div style={{ height: '100%', overflowY: 'auto', overscrollBehavior: 'contain', maxWidth: 1800, margin: '0 auto', width: '100%', padding: '8px 0', paddingBottom: 'calc(var(--bottom-nav-h) + 8px)' }}>
<div style={{ height: '100%', overflowY: 'auto', overscrollBehavior: 'contain', width: '100%', paddingBottom: 'var(--bottom-nav-h)' }}>
<ListsContainer tripId={tripId} packingItems={packingItems} todoItems={todoItems} />
</div>
)}
{activeTab === 'finanzplan' && (
<div style={{ height: '100%', overflowY: 'auto', overscrollBehavior: 'contain', maxWidth: 1800, margin: '0 auto', width: '100%', padding: '8px 0', paddingBottom: 'calc(var(--bottom-nav-h) + 8px)' }}>
<div style={{ height: '100%', overflowY: 'auto', overscrollBehavior: 'contain', width: '100%', paddingBottom: 'var(--bottom-nav-h)' }}>
<BudgetPanel tripId={tripId} tripMembers={tripMembers} />
</div>
)}
+16
View File
@@ -137,6 +137,20 @@ export interface BudgetMember {
paid: boolean
}
export interface ReservationEndpoint {
id?: number
reservation_id?: number
role: 'from' | 'to' | 'stop'
sequence: number
name: string
code: string | null
lat: number
lng: number
timezone: string | null
local_time: string | null
local_date: string | null
}
export interface Reservation {
id: number
trip_id: number
@@ -158,6 +172,8 @@ export interface Reservation {
accommodation_id?: number | null
day_plan_position?: number | null
metadata?: Record<string, string> | string | null
needs_review?: number
endpoints?: ReservationEndpoint[]
created_at: string
}
File diff suppressed because one or more lines are too long
+8 -39
View File
@@ -54,6 +54,7 @@
"@vitest/coverage-v8": "^3.2.4",
"nodemon": "^3.1.0",
"supertest": "^7.2.2",
"tz-lookup": "^6.1.25",
"vitest": "^3.2.4"
}
},
@@ -1189,9 +1190,6 @@
"arm"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1206,9 +1204,6 @@
"arm"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1223,9 +1218,6 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1240,9 +1232,6 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1257,9 +1246,6 @@
"loong64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1274,9 +1260,6 @@
"loong64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1291,9 +1274,6 @@
"ppc64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1308,9 +1288,6 @@
"ppc64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1325,9 +1302,6 @@
"riscv64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1342,9 +1316,6 @@
"riscv64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1359,9 +1330,6 @@
"s390x"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1376,9 +1344,6 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1393,9 +1358,6 @@
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -5867,6 +5829,13 @@
"node": ">=14.17"
}
},
"node_modules/tz-lookup": {
"version": "6.1.25",
"resolved": "https://registry.npmjs.org/tz-lookup/-/tz-lookup-6.1.25.tgz",
"integrity": "sha512-fFewT9o1uDzsW1QnUU1ValqaihFnwiUiiHr1S79/fxOzKXYYvX+EHeRnpvQJ9B3Qg67wPXT6QF2Esc4pFOrvLg==",
"dev": true,
"license": "CC0-1.0"
},
"node_modules/undefsafe": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz",
+1
View File
@@ -63,6 +63,7 @@
"@vitest/coverage-v8": "^3.2.4",
"nodemon": "^3.1.0",
"supertest": "^7.2.2",
"tz-lookup": "^6.1.25",
"vitest": "^3.2.4"
}
}
+108
View File
@@ -0,0 +1,108 @@
#!/usr/bin/env node
// Build server/data/airports.json from OurAirports (davidmegginson.github.io/ourairports-data).
// License: Public Domain. Keeps large/medium airports with an IATA code; timezone derived from coords via tz-lookup.
import fs from 'node:fs'
import path from 'node:path'
import https from 'node:https'
import { fileURLToPath } from 'node:url'
import tzLookup from 'tz-lookup'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const OUT = path.join(__dirname, '..', 'data', 'airports.json')
const SRC = 'https://davidmegginson.github.io/ourairports-data/airports.csv'
function fetchText(url) {
return new Promise((resolve, reject) => {
https.get(url, (res) => {
if (res.statusCode !== 200) return reject(new Error(`HTTP ${res.statusCode}`))
let data = ''
res.setEncoding('utf8')
res.on('data', chunk => { data += chunk })
res.on('end', () => resolve(data))
}).on('error', reject)
})
}
function parseCsv(text) {
const rows = []
let row = []
let cur = ''
let inQuotes = false
for (let i = 0; i < text.length; i++) {
const ch = text[i]
if (inQuotes) {
if (ch === '"') {
if (text[i + 1] === '"') { cur += '"'; i++ } else { inQuotes = false }
} else {
cur += ch
}
} else {
if (ch === '"') inQuotes = true
else if (ch === ',') { row.push(cur); cur = '' }
else if (ch === '\n') { row.push(cur); rows.push(row); row = []; cur = '' }
else if (ch === '\r') { /* skip */ }
else cur += ch
}
}
if (cur.length > 0 || row.length > 0) { row.push(cur); rows.push(row) }
return rows
}
const raw = await fetchText(SRC)
const rows = parseCsv(raw)
const header = rows[0]
const idx = (name) => header.indexOf(name)
const TYPE = idx('type')
const NAME = idx('name')
const LAT = idx('latitude_deg')
const LNG = idx('longitude_deg')
const COUNTRY = idx('iso_country')
const MUNICIPALITY = idx('municipality')
const SERVICE = idx('scheduled_service')
const ICAO = idx('icao_code')
const IATA = idx('iata_code')
const KEEP = new Set(['large_airport', 'medium_airport'])
const airports = []
let skippedNoTz = 0
for (let i = 1; i < rows.length; i++) {
const r = rows[i]
if (!r || r.length < header.length) continue
if (!KEEP.has(r[TYPE])) continue
const iata = r[IATA]?.trim().toUpperCase()
if (!iata || iata.length !== 3) continue
if (r[SERVICE] !== 'yes') continue
const lat = Number(r[LAT])
const lng = Number(r[LNG])
if (!Number.isFinite(lat) || !Number.isFinite(lng)) continue
let tz = null
try { tz = tzLookup(lat, lng) } catch { skippedNoTz++; continue }
if (!tz) { skippedNoTz++; continue }
airports.push({
iata,
icao: r[ICAO]?.trim().toUpperCase() || null,
name: r[NAME],
city: r[MUNICIPALITY] || '',
country: r[COUNTRY] || '',
lat: Math.round(lat * 1e6) / 1e6,
lng: Math.round(lng * 1e6) / 1e6,
tz,
})
}
const seen = new Map()
for (const a of airports) {
const existing = seen.get(a.iata)
if (!existing) { seen.set(a.iata, a); continue }
if (existing.icao && !a.icao) continue
if (!existing.icao && a.icao) seen.set(a.iata, a)
}
const unique = Array.from(seen.values()).sort((a, b) => a.iata.localeCompare(b.iata))
fs.writeFileSync(OUT, JSON.stringify(unique))
const size = fs.statSync(OUT).size
console.log(`Wrote ${unique.length} airports to ${OUT} (${(size / 1024).toFixed(1)} KB); skipped ${skippedNoTz} without timezone`)
+2
View File
@@ -23,6 +23,7 @@ import tagsRoutes from './routes/tags';
import categoriesRoutes from './routes/categories';
import adminRoutes from './routes/admin';
import mapsRoutes from './routes/maps';
import airportsRoutes from './routes/airports';
import filesRoutes from './routes/files';
import reservationsRoutes from './routes/reservations';
import dayNotesRoutes from './routes/dayNotes';
@@ -278,6 +279,7 @@ export function createApp(): express.Application {
app.use('/api/integrations/memories', memoriesRoutes);
app.use('/api/photos', photoRoutes);
app.use('/api/maps', mapsRoutes);
app.use('/api/airports', airportsRoutes);
app.use('/api/weather', weatherRoutes);
app.use('/api/settings', settingsRoutes);
app.use('/api/system-notices', systemNoticesRoutes);
+7
View File
@@ -128,4 +128,11 @@ function isOwner(tripId: number | string, userId: number): boolean {
return !!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId);
}
try {
const { backfillFlightEndpoints } = require('../services/airportService');
backfillFlightEndpoints();
} catch (err) {
console.error('[DB] Flight endpoint backfill failed:', err);
}
export { db, closeDb, reinitialize, getPlaceWithTags, canAccessTrip, isOwner };
+26
View File
@@ -1629,6 +1629,32 @@ function runMigrations(db: Database.Database): void {
)
`);
},
// Migration 104: Passphrase support for Synology shared-album links (#689)
() => {
try { db.exec('ALTER TABLE trip_album_links ADD COLUMN passphrase TEXT DEFAULT NULL'); } catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; }
try { db.exec('ALTER TABLE trek_photos ADD COLUMN passphrase TEXT DEFAULT NULL'); } catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; }
},
// Migration 105: Reservation endpoints (from/to points for flights, trains, ferries, car rentals) — #384 + #587
() => {
db.exec(`
CREATE TABLE IF NOT EXISTS reservation_endpoints (
id INTEGER PRIMARY KEY AUTOINCREMENT,
reservation_id INTEGER NOT NULL REFERENCES reservations(id) ON DELETE CASCADE,
role TEXT NOT NULL,
sequence INTEGER NOT NULL DEFAULT 0,
name TEXT NOT NULL,
code TEXT,
lat REAL NOT NULL,
lng REAL NOT NULL,
timezone TEXT,
local_time TEXT,
local_date TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
`);
db.exec('CREATE INDEX IF NOT EXISTS idx_reservation_endpoints_reservation_id ON reservation_endpoints(reservation_id)');
try { db.exec('ALTER TABLE reservations ADD COLUMN needs_review INTEGER NOT NULL DEFAULT 0'); } catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; }
},
];
if (currentVersion < migrations.length) {
+19
View File
@@ -0,0 +1,19 @@
import express, { Request, Response } from 'express';
import { authenticate } from '../middleware/auth';
import { searchAirports, findByIata } from '../services/airportService';
const router = express.Router();
router.get('/search', authenticate, (req: Request, res: Response) => {
const q = typeof req.query.q === 'string' ? req.query.q : '';
if (!q) return res.json([]);
res.json(searchAirports(q));
});
router.get('/:iata', authenticate, (req: Request, res: Response) => {
const airport = findByIata(req.params.iata);
if (!airport) return res.status(404).json({ error: 'Airport not found' });
res.json(airport);
});
export default router;
+8 -5
View File
@@ -80,7 +80,8 @@ router.get('/albums', authenticate, async (req: Request, res: Response) => {
router.get('/albums/:albumId/photos', authenticate, async (req: Request, res: Response) => {
const authReq = req as AuthRequest;
handleServiceResult(res, await getSynologyAlbumPhotos(authReq.user.id, req.params.albumId));
const passphrase = req.query.passphrase ? String(req.query.passphrase) : undefined;
handleServiceResult(res, await getSynologyAlbumPhotos(authReq.user.id, req.params.albumId, passphrase));
});
router.post('/trips/:tripId/album-links/:linkId/sync', authenticate, async (req: Request, res: Response) => {
@@ -100,8 +101,8 @@ router.post('/search', authenticate, async (req: Request, res: Response) => {
const page = _parseNumberBodyField(body.page, 1) - 1;
let limit = _parseNumberBodyField(body.limit, 100);
const size = _parseNumberBodyField(body.size, 0);
if(page > 0) offset = page*limit;
if(size > 0) limit = size;
if (size > 0) limit = size;
if (page > 0) offset = page * limit;
handleServiceResult(res, await searchSynologyPhotos(
authReq.user.id,
@@ -115,12 +116,13 @@ router.post('/search', authenticate, async (req: Request, res: Response) => {
router.get('/assets/:tripId/:photoId/:ownerId/info', authenticate, async (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { tripId, photoId, ownerId } = req.params;
const passphrase = req.query.passphrase ? String(req.query.passphrase) : undefined;
if (!canAccessUserPhoto(authReq.user.id, Number(ownerId), tripId, photoId, 'synologyphotos')) {
handleServiceResult(res, fail('You don\'t have access to this photo', 403));
}
else {
handleServiceResult(res, await getSynologyAssetInfo(authReq.user.id, photoId, Number(ownerId)));
handleServiceResult(res, await getSynologyAssetInfo(authReq.user.id, photoId, Number(ownerId), passphrase));
}
});
@@ -130,6 +132,7 @@ router.get('/assets/:tripId/:photoId/:ownerId/:kind', authenticate, async (req:
const VALID_SIZES = ['sm', 'm', 'xl'] as const;
const rawSize = String(req.query.size ?? 'sm');
const size = VALID_SIZES.includes(rawSize as any) ? rawSize : 'sm';
const passphrase = req.query.passphrase ? String(req.query.passphrase) : undefined;
if (kind !== 'thumbnail' && kind !== 'original') {
return handleServiceResult(res, fail('Invalid asset kind', 400));
@@ -139,7 +142,7 @@ router.get('/assets/:tripId/:photoId/:ownerId/:kind', authenticate, async (req:
handleServiceResult(res, fail('You don\'t have access to this photo', 403));
}
else{
await streamSynologyAsset(res, authReq.user.id, Number(ownerId), photoId, kind as 'thumbnail' | 'original', String(size));
await streamSynologyAsset(res, authReq.user.id, Number(ownerId), photoId, kind as 'thumbnail' | 'original', String(size), passphrase);
}
});
+2 -1
View File
@@ -84,7 +84,8 @@ router.get('/unified/trips/:tripId/album-links', authenticate, (req: Request, re
router.post('/unified/trips/:tripId/album-links', authenticate, async (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { tripId } = req.params;
const result = createTripAlbumLink(tripId, authReq.user.id, req.body?.provider, req.body?.album_id, req.body?.album_name);
const passphrase = req.body?.passphrase ? String(req.body.passphrase) : undefined;
const result = createTripAlbumLink(tripId, authReq.user.id, req.body?.provider, req.body?.album_id, req.body?.album_name, passphrase);
if ('error' in result) return res.status(result.error.status).json({ error: result.error.message });
res.json({ success: true });
});
+6 -4
View File
@@ -31,7 +31,7 @@ router.get('/', authenticate, (req: Request, res: Response) => {
router.post('/', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { tripId } = req.params;
const { title, reservation_time, reservation_end_time, location, confirmation_number, notes, day_id, place_id, assignment_id, status, type, accommodation_id, metadata, create_accommodation, create_budget_entry } = req.body;
const { title, reservation_time, reservation_end_time, location, confirmation_number, notes, day_id, place_id, assignment_id, status, type, accommodation_id, metadata, create_accommodation, create_budget_entry, endpoints, needs_review } = req.body;
const trip = verifyTripAccess(tripId, authReq.user.id);
if (!trip) return res.status(404).json({ error: 'Trip not found' });
@@ -44,7 +44,8 @@ router.post('/', authenticate, (req: Request, res: Response) => {
const { reservation, accommodationCreated } = createReservation(tripId, {
title, reservation_time, reservation_end_time, location,
confirmation_number, notes, day_id, place_id, assignment_id,
status, type, accommodation_id, metadata, create_accommodation
status, type, accommodation_id, metadata, create_accommodation,
endpoints, needs_review
});
if (accommodationCreated) {
@@ -101,7 +102,7 @@ router.put('/positions', authenticate, (req: Request, res: Response) => {
router.put('/:id', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { tripId, id } = req.params;
const { title, reservation_time, reservation_end_time, location, confirmation_number, notes, day_id, place_id, assignment_id, status, type, accommodation_id, metadata, create_accommodation, create_budget_entry } = req.body;
const { title, reservation_time, reservation_end_time, location, confirmation_number, notes, day_id, place_id, assignment_id, status, type, accommodation_id, metadata, create_accommodation, create_budget_entry, endpoints, needs_review } = req.body;
const trip = verifyTripAccess(tripId, authReq.user.id);
if (!trip) return res.status(404).json({ error: 'Trip not found' });
@@ -115,7 +116,8 @@ router.put('/:id', authenticate, (req: Request, res: Response) => {
const { reservation, accommodationChanged } = updateReservation(id, tripId, {
title, reservation_time, reservation_end_time, location,
confirmation_number, notes, day_id, place_id, assignment_id,
status, type, accommodation_id, metadata, create_accommodation
status, type, accommodation_id, metadata, create_accommodation,
endpoints, needs_review
}, current);
if (accommodationChanged) {
+109
View File
@@ -0,0 +1,109 @@
import fs from 'node:fs';
import path from 'node:path';
import { db } from '../db/database';
export interface Airport {
iata: string;
icao: string | null;
name: string;
city: string;
country: string;
lat: number;
lng: number;
tz: string;
}
let cache: Airport[] | null = null;
let byIata: Map<string, Airport> | null = null;
function load(): Airport[] {
if (cache) return cache;
const file = path.join(__dirname, '..', '..', 'data', 'airports.json');
if (!fs.existsSync(file)) {
console.warn('[airports] airports.json missing — run `node scripts/build-airports.mjs`');
cache = [];
byIata = new Map();
return cache;
}
const raw = fs.readFileSync(file, 'utf8');
cache = JSON.parse(raw) as Airport[];
byIata = new Map(cache.map(a => [a.iata, a]));
return cache;
}
export function findByIata(code: string): Airport | null {
load();
return byIata!.get(code.toUpperCase()) ?? null;
}
export function searchAirports(query: string, limit = 12): Airport[] {
const all = load();
const q = query.trim().toLowerCase();
if (!q) return [];
const upper = q.toUpperCase();
if (q.length === 3) {
const exact = byIata!.get(upper);
if (exact) return [exact];
}
const matches: Array<{ a: Airport; score: number }> = [];
for (const a of all) {
let score = 0;
if (a.iata === upper) score = 100;
else if (a.icao === upper) score = 90;
else if (a.iata.startsWith(upper)) score = 70;
else if (a.city.toLowerCase().startsWith(q)) score = 60;
else if (a.name.toLowerCase().startsWith(q)) score = 50;
else if (a.city.toLowerCase().includes(q)) score = 30;
else if (a.name.toLowerCase().includes(q)) score = 20;
if (score > 0) matches.push({ a, score });
}
matches.sort((x, y) => y.score - x.score || x.a.iata.localeCompare(y.a.iata));
return matches.slice(0, limit).map(m => m.a);
}
export function backfillFlightEndpoints(): void {
const pending = db.prepare(`
SELECT r.id, r.metadata, r.reservation_time, r.reservation_end_time
FROM reservations r
WHERE r.type = 'flight'
AND NOT EXISTS (SELECT 1 FROM reservation_endpoints e WHERE e.reservation_id = r.id)
`).all() as { id: number; metadata: string | null; reservation_time: string | null; reservation_end_time: string | null }[];
if (pending.length === 0) return;
load();
const insert = db.prepare(`
INSERT INTO reservation_endpoints (reservation_id, role, sequence, name, code, lat, lng, timezone, local_time, local_date)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
const markReview = db.prepare('UPDATE reservations SET needs_review = 1 WHERE id = ?');
let filled = 0;
let flagged = 0;
for (const r of pending) {
if (!r.metadata) { markReview.run(r.id); flagged++; continue; }
let meta: any;
try { meta = JSON.parse(r.metadata); } catch { markReview.run(r.id); flagged++; continue; }
const dep = meta.departure_airport ? findByIata(String(meta.departure_airport).slice(0, 3)) : null;
const arr = meta.arrival_airport ? findByIata(String(meta.arrival_airport).slice(0, 3)) : null;
if (!dep || !arr) { markReview.run(r.id); flagged++; continue; }
const split = (iso: string | null) => {
if (!iso) return { date: null as string | null, time: null as string | null };
const [date, time] = iso.split('T');
return { date: date || null, time: time ? time.slice(0, 5) : null };
};
const depParts = split(r.reservation_time);
const arrParts = split(r.reservation_end_time);
insert.run(r.id, 'from', 0, dep.city ? `${dep.city} (${dep.iata})` : dep.name, dep.iata, dep.lat, dep.lng, dep.tz, depParts.time, depParts.date);
insert.run(r.id, 'to', 1, arr.city ? `${arr.city} (${arr.iata})` : arr.name, arr.iata, arr.lat, arr.lng, arr.tz, arrParts.time, arrParts.date);
filled++;
}
console.log(`[airports] Backfill: ${filled} filled, ${flagged} flagged for review`);
}
+28 -3
View File
@@ -3,6 +3,7 @@ import { Readable } from 'node:stream';
import { Response } from 'express';
import { canAccessTrip, db } from "../../db/database";
import { safeFetch, SsrfBlockedError } from '../../utils/ssrfGuard';
import { decrypt_api_key } from '../apiKeyCrypto';
// helpers for handling return types
@@ -42,6 +43,7 @@ export function handleServiceResult<T>(res: Response, result: ServiceResult<T>):
export type Selection = {
provider: string;
asset_ids: string[];
passphrase?: string;
};
export type StatusResult = {
@@ -59,7 +61,7 @@ export type SyncAlbumResult = {
export type AlbumsList = {
albums: Array<{ id: string; albumName: string; assetCount: number }>
albums: Array<{ id: string; albumName: string; assetCount: number; passphrase?: string }>
};
export type Asset = {
@@ -230,17 +232,40 @@ export function getAlbumIdFromLink(tripId: string, linkId: string, userId: numbe
}
}
export function getAlbumLinkForSync(tripId: string, linkId: string, userId: number): ServiceResult<{ albumId: string; passphrase?: string }> {
const access = canAccessTrip(tripId, userId);
if (!access) return fail('Trip not found or access denied', 404);
try {
const row = db.prepare('SELECT album_id, passphrase FROM trip_album_links WHERE id = ? AND trip_id = ? AND user_id = ?')
.get(linkId, tripId, userId) as { album_id: string; passphrase: string | null } | null;
if (!row) return fail('Album link not found', 404);
const decrypted = row.passphrase ? decrypt_api_key(row.passphrase) ?? undefined : undefined;
return success({ albumId: row.album_id, passphrase: decrypted || undefined });
} catch {
return fail('Failed to retrieve album link', 500);
}
}
export function updateSyncTimeForAlbumLink(linkId: string): void {
db.prepare('UPDATE trip_album_links SET last_synced_at = CURRENT_TIMESTAMP WHERE id = ?').run(linkId);
}
export async function pipeAsset(url: string, response: Response, headers?: Record<string, string>, signal?: AbortSignal): Promise<void> {
export async function pipeAsset(url: string, response: Response, headers?: Record<string, string>, signal?: AbortSignal, defaultCacheControl?: string): Promise<void> {
try {
const resp = await safeFetch(url, { headers, signal: signal as any });
response.status(resp.status);
if (resp.headers.get('content-type')) response.set('Content-Type', resp.headers.get('content-type') as string);
if (resp.headers.get('cache-control')) response.set('Cache-Control', resp.headers.get('cache-control') as string);
if (!resp.ok) {
response.set('Cache-Control', 'no-store, max-age=0');
} else if (resp.headers.get('cache-control')) {
response.set('Cache-Control', resp.headers.get('cache-control') as string);
} else if (defaultCacheControl) {
response.set('Cache-Control', defaultCacheControl);
}
if (resp.headers.get('content-length')) response.set('Content-Length', resp.headers.get('content-length') as string);
if (resp.headers.get('content-disposition')) response.set('Content-Disposition', resp.headers.get('content-disposition') as string);
@@ -246,8 +246,7 @@ export async function streamImmichAsset(
? `${creds.immich_url}/api/assets/${assetId}/thumbnail?size=thumbnail`
: `${creds.immich_url}/api/assets/${assetId}/thumbnail?size=fullsize`;
response.set('Cache-Control', 'public, max-age=86400');
await pipeAsset(url, response, { 'x-api-key': creds.immich_api_key }, AbortSignal.timeout(timeout));
await pipeAsset(url, response, { 'x-api-key': creds.immich_api_key }, AbortSignal.timeout(timeout), 'public, max-age=86400');
}
// ── Albums ──────────────────────────────────────────────────────────────────
@@ -7,6 +7,7 @@ import { streamImmichAsset, getAssetInfo as getImmichAssetInfo } from './immichS
import { streamSynologyAsset, getSynologyAssetInfo } from './synologyService';
import type { ServiceResult, AssetInfo } from './helpersService';
import { fail, success } from './helpersService';
import { encrypt_api_key, decrypt_api_key } from '../apiKeyCrypto';
// ── Lookup / Register ────────────────────────────────────────────────────
@@ -14,15 +15,22 @@ export function getOrCreateTrekPhoto(
provider: string,
assetId: string,
ownerId: number,
passphrase?: string,
): number {
const existing = db.prepare(
'SELECT id FROM trek_photos WHERE provider = ? AND asset_id = ? AND owner_id = ?'
).get(provider, assetId, ownerId) as { id: number } | undefined;
if (existing) return existing.id;
if (existing) {
if (passphrase) {
db.prepare('UPDATE trek_photos SET passphrase = ? WHERE id = ? AND passphrase IS NULL')
.run(encrypt_api_key(passphrase), existing.id);
}
return existing.id;
}
const res = db.prepare(
'INSERT INTO trek_photos (provider, asset_id, owner_id) VALUES (?, ?, ?)'
).run(provider, assetId, ownerId);
'INSERT INTO trek_photos (provider, asset_id, owner_id, passphrase) VALUES (?, ?, ?, ?)'
).run(provider, assetId, ownerId, passphrase ? encrypt_api_key(passphrase) : null);
return Number(res.lastInsertRowid);
}
@@ -61,15 +69,18 @@ export async function streamPhoto(
return;
}
if (photo.file_path) {
const localPath = path.join(__dirname, '../../../uploads', photo.file_path);
if (fs.existsSync(localPath)) {
res.set('Cache-Control', 'public, max-age=86400');
res.sendFile(localPath);
return;
}
}
switch (photo.provider) {
case 'local': {
const filePath = path.join(__dirname, '../../../uploads', photo.file_path!);
if (!fs.existsSync(filePath)) {
res.status(404).json({ error: 'File not found' });
return;
}
res.set('Cache-Control', 'public, max-age=86400');
res.sendFile(filePath);
res.status(404).json({ error: 'File not found' });
return;
}
case 'immich': {
@@ -77,7 +88,8 @@ export async function streamPhoto(
return;
}
case 'synologyphotos': {
await streamSynologyAsset(res, userId, photo.owner_id!, photo.asset_id!, kind);
const passphrase = photo.passphrase ? (decrypt_api_key(photo.passphrase) || undefined) : undefined;
await streamSynologyAsset(res, userId, photo.owner_id!, photo.asset_id!, kind, undefined, passphrase);
return;
}
default:
@@ -112,7 +124,8 @@ export async function getPhotoInfo(
return success(result.data as AssetInfo);
}
case 'synologyphotos': {
return getSynologyAssetInfo(userId, photo.asset_id!, photo.owner_id!);
const passphrase = photo.passphrase ? (decrypt_api_key(photo.passphrase) || undefined) : undefined;
return getSynologyAssetInfo(userId, photo.asset_id!, photo.owner_id!, passphrase);
}
default:
return fail(`Unknown provider: ${photo.provider}`, 400);
+71 -42
View File
@@ -5,7 +5,7 @@ import { decrypt_api_key, encrypt_api_key, maybe_encrypt_api_key } from '../apiK
import { safeFetch, SsrfBlockedError, checkSsrf } from '../../utils/ssrfGuard';
import { addTripPhotos } from './unifiedService';
import {
getAlbumIdFromLink,
getAlbumLinkForSync,
updateSyncTimeForAlbumLink,
Selection,
ServiceResult,
@@ -432,41 +432,66 @@ export async function testSynologyConnection(userId: number, synologyUrl: string
return success({ connected: true, user: { name: synologyUsername } });
}
async function _fetchAllSynologyAlbums(userId: number, baseParams: ApiCallParams): Promise<ServiceResult<any[]>> {
const pageSize = 100;
const all: any[] = [];
let offset = 0;
while (true) {
const result = await _requestSynologyApi<{ list: any[] }>(userId, { ...baseParams, offset, limit: pageSize });
if (!result.success) return result as ServiceResult<any[]>;
const items = result.data.list || [];
all.push(...items);
if (items.length < pageSize) break;
offset += pageSize;
}
return success(all);
}
export async function listSynologyAlbums(userId: number): Promise<ServiceResult<AlbumsList>> {
const result = await _requestSynologyApi<{ list: SynologyPhotoItem[] }>(userId, {
api: 'SYNO.Foto.Browse.Album',
method: 'list',
version: 4,
offset: 0,
limit: 100,
});
if (!result.success) return result as ServiceResult<AlbumsList>;
const [personal, shared, sharedWithMe] = await Promise.allSettled([
_fetchAllSynologyAlbums(userId, { api: 'SYNO.Foto.Browse.Album', method: 'list', version: 4 }),
_fetchAllSynologyAlbums(userId, { api: 'SYNO.Foto.Browse.Album', method: 'list', version: 4, category: 'shared' }),
_fetchAllSynologyAlbums(userId, { api: 'SYNO.Foto.Sharing.Misc', method: 'list_shared_with_me_album', version: 1, additional: ['thumbnail', 'sharing_info'] }),
]);
const albums = (result.data.list || []).map((album: any) => ({
id: String(album.id),
albumName: album.name || '',
assetCount: album.item_count || 0,
}));
const map = new Map<string, { id: string; albumName: string; assetCount: number; passphrase?: string }>();
const addAlbums = (result: PromiseSettledResult<ServiceResult<any[]>>, extractPassphrase: (a: any) => string | undefined) => {
if (result.status === 'rejected') return;
if (!result.value.success) {
console.warn('[Synology] album list partial failure:', (result.value as any).error?.message);
return;
}
for (const album of result.value.data ?? []) {
const id = String(album.id);
const passphrase = extractPassphrase(album);
map.set(id, { id, albumName: album.name || '', assetCount: album.item_count || 0, passphrase });
}
};
addAlbums(personal, () => undefined);
addAlbums(shared, (a) => a.passphrase || undefined);
addAlbums(sharedWithMe, (a) => a.passphrase || a.sharing_info?.passphrase || undefined);
if (map.size === 0 && personal.status === 'fulfilled' && !personal.value.success) {
return personal.value as ServiceResult<AlbumsList>;
}
const albums = [...map.values()].sort((a, b) => a.albumName.localeCompare(b.albumName));
return success({ albums });
}
export async function getSynologyAlbumPhotos(userId: number, albumId: string): Promise<ServiceResult<AssetsList>> {
export async function getSynologyAlbumPhotos(userId: number, albumId: string, passphrase?: string): Promise<ServiceResult<AssetsList>> {
const allItems: SynologyPhotoItem[] = [];
const pageSize = 1000;
const pageSize = 50;
let offset = 0;
while (true) {
const result = await _requestSynologyApi<{ list: SynologyPhotoItem[] }>(userId, {
api: 'SYNO.Foto.Browse.Item',
method: 'list',
version: 1,
album_id: Number(albumId),
offset,
limit: pageSize,
additional: ['thumbnail'],
});
const params: ApiCallParams = passphrase
? { api: 'SYNO.Foto.Browse.Item', method: 'list', version: 1, passphrase, offset, limit: pageSize, additional: ['thumbnail'] }
: { api: 'SYNO.Foto.Browse.Item', method: 'list', version: 1, album_id: Number(albumId), offset, limit: pageSize, additional: ['thumbnail'] };
const result = await _requestSynologyApi<{ list: SynologyPhotoItem[] }>(userId, params);
if (!result.success) return result as ServiceResult<AssetsList>;
const items = result.data.list || [];
allItems.push(...items);
@@ -483,23 +508,21 @@ export async function getSynologyAlbumPhotos(userId: number, albumId: string): P
}
export async function syncSynologyAlbumLink(userId: number, tripId: string, linkId: string, sid: string): Promise<ServiceResult<SyncAlbumResult>> {
const response = getAlbumIdFromLink(tripId, linkId, userId);
const response = getAlbumLinkForSync(tripId, linkId, userId);
if (!response.success) return response as ServiceResult<SyncAlbumResult>;
const { albumId, passphrase } = response.data;
const allItems: SynologyPhotoItem[] = [];
const pageSize = 1000;
const pageSize = 50;
let offset = 0;
while (true) {
const result = await _requestSynologyApi<{ list: SynologyPhotoItem[] }>(userId, {
api: 'SYNO.Foto.Browse.Item',
method: 'list',
version: 1,
album_id: Number(response.data),
offset,
limit: pageSize,
additional: ['thumbnail'],
});
const itemParams: ApiCallParams = passphrase
? { api: 'SYNO.Foto.Browse.Item', method: 'list', version: 1, passphrase, offset, limit: pageSize, additional: ['thumbnail'] }
: { api: 'SYNO.Foto.Browse.Item', method: 'list', version: 1, album_id: Number(albumId), offset, limit: pageSize, additional: ['thumbnail'] };
const result = await _requestSynologyApi<{ list: SynologyPhotoItem[] }>(userId, itemParams);
if (!result.success) return result as ServiceResult<SyncAlbumResult>;
@@ -512,9 +535,9 @@ export async function syncSynologyAlbumLink(userId: number, tripId: string, link
const selection: Selection = {
provider: SYNOLOGY_PROVIDER,
asset_ids: allItems.map(item => String(item.additional?.thumbnail?.cache_key || '')).filter(id => id),
passphrase,
};
const result = await addTripPhotos(tripId, userId, true, [selection], sid, linkId);
if (!result.success) return result as ServiceResult<SyncAlbumResult>;
@@ -558,16 +581,18 @@ export async function searchSynologyPhotos(userId: number, from?: string, to?: s
});
}
export async function getSynologyAssetInfo(userId: number, photoId: string, targetUserId?: number): Promise<ServiceResult<AssetInfo>> {
export async function getSynologyAssetInfo(userId: number, photoId: string, targetUserId: number, passphrase?: string): Promise<ServiceResult<AssetInfo>> {
const parsedId = _splitPackedSynologyId(photoId);
if (!parsedId) return fail('Invalid photo ID format', 400);
const result = await _requestSynologyApi<{ list: SynologyPhotoItem[] }>(targetUserId, {
const infoParams: ApiCallParams = {
api: 'SYNO.Foto.Browse.Item',
method: 'get',
version: 5,
id: `[${Number(parsedId.id) + 1}]`, //for some reason synology wants id moved by one to get image info
additional: ['resolution', 'exif', 'gps', 'address', 'orientation', 'description'],
});
};
if (passphrase) infoParams.passphrase = passphrase;
const result = await _requestSynologyApi<{ list: SynologyPhotoItem[] }>(targetUserId, infoParams);
if (!result.success) return result as ServiceResult<AssetInfo>;
@@ -585,6 +610,8 @@ export async function streamSynologyAsset(
targetUserId: number,
photoId: string,
kind: 'thumbnail' | 'original',
size?: string,
passphrase?: string,
): Promise<void> {
const parsedId = _splitPackedSynologyId(photoId);
if (!parsedId) {
@@ -610,6 +637,7 @@ export async function streamSynologyAsset(
//size: 'sm' 240px| 'm' 320px| 'xl' 1280px| 'preview' ?
const resolvedSize = size || 'sm';
const params = kind === 'thumbnail'
? new URLSearchParams({
api: 'SYNO.Foto.Thumbnail',
@@ -618,7 +646,7 @@ export async function streamSynologyAsset(
mode: 'download',
id: parsedId.id,
type: 'unit',
size: 'sm',
size: resolvedSize,
cache_key: parsedId.cacheKey,
_sid: sid.data,
})
@@ -630,8 +658,9 @@ export async function streamSynologyAsset(
unit_id: `[${parsedId.id}]`,
_sid: sid.data,
});
if (passphrase) params.append('passphrase', passphrase);
const url = _buildSynologyEndpoint(synology_credentials.data.synology_url, params.toString());
await pipeAsset(url, response)
await pipeAsset(url, response, undefined, undefined, 'public, max-age=86400')
}
@@ -9,6 +9,7 @@ import {
Selection,
} from './helpersService';
import { getOrCreateTrekPhoto } from './photoResolverService';
import { encrypt_api_key } from '../apiKeyCrypto';
function _providers(): Array<{id: string; enabled: boolean}> {
@@ -104,13 +105,13 @@ export function listTripAlbumLinks(tripId: string, userId: number): ServiceResul
//-----------------------------------------------
// managing photos in trip
function _addTripPhoto(tripId: string, userId: number, provider: string, assetId: string, shared: boolean, albumLinkId?: string): ServiceResult<boolean> {
function _addTripPhoto(tripId: string, userId: number, provider: string, assetId: string, shared: boolean, albumLinkId?: string, passphrase?: string): ServiceResult<boolean> {
const providerResult = _validProvider(provider);
if (!providerResult.success) {
return providerResult as ServiceResult<boolean>;
}
try {
const photoId = getOrCreateTrekPhoto(provider, assetId, userId);
const photoId = getOrCreateTrekPhoto(provider, assetId, userId, passphrase);
const result = db.prepare(
'INSERT OR IGNORE INTO trip_photos (trip_id, user_id, photo_id, shared, album_link_id) VALUES (?, ?, ?, ?, ?)'
).run(tripId, userId, photoId, shared ? 1 : 0, albumLinkId || null);
@@ -147,7 +148,7 @@ export async function addTripPhotos(
for (const raw of selection.asset_ids) {
const assetId = String(raw || '').trim();
if (!assetId) continue;
const result = _addTripPhoto(tripId, userId, selection.provider, assetId, shared, albumLinkId);
const result = _addTripPhoto(tripId, userId, selection.provider, assetId, shared, albumLinkId, selection.passphrase);
if (!result.success) {
return result as ServiceResult<{ added: number; shared: boolean }>;
}
@@ -222,7 +223,7 @@ export function removeTripPhoto(
// ----------------------------------------------
// managing album links in trip
export function createTripAlbumLink(tripId: string, userId: number, providerRaw: unknown, albumIdRaw: unknown, albumNameRaw: unknown): ServiceResult<true> {
export function createTripAlbumLink(tripId: string, userId: number, providerRaw: unknown, albumIdRaw: unknown, albumNameRaw: unknown, passphrase?: string): ServiceResult<true> {
const access = canAccessTrip(tripId, userId);
if (!access) {
return fail('Trip not found or access denied', 404);
@@ -246,9 +247,10 @@ export function createTripAlbumLink(tripId: string, userId: number, providerRaw:
}
try {
const encryptedPassphrase = passphrase ? encrypt_api_key(passphrase) : null;
const result = db.prepare(
'INSERT OR IGNORE INTO trip_album_links (trip_id, user_id, provider, album_id, album_name) VALUES (?, ?, ?, ?, ?)'
).run(tripId, userId, provider, albumId, albumName);
'INSERT OR IGNORE INTO trip_album_links (trip_id, user_id, provider, album_id, album_name, passphrase) VALUES (?, ?, ?, ?, ?, ?)'
).run(tripId, userId, provider, albumId, albumName, encryptedPassphrase);
if (result.changes === 0) {
return fail('Album already linked', 409);
+80 -9
View File
@@ -1,10 +1,59 @@
import { db, canAccessTrip } from '../db/database';
import { Reservation } from '../types';
export interface ReservationEndpoint {
id?: number;
reservation_id?: number;
role: 'from' | 'to' | 'stop';
sequence: number;
name: string;
code: string | null;
lat: number;
lng: number;
timezone: string | null;
local_time: string | null;
local_date: string | null;
}
type EndpointInput = Omit<ReservationEndpoint, 'id' | 'reservation_id' | 'sequence'> & { sequence?: number };
export function verifyTripAccess(tripId: string | number, userId: number) {
return canAccessTrip(tripId, userId);
}
function loadEndpointsByTrip(tripId: string | number): Map<number, ReservationEndpoint[]> {
const rows = db.prepare(`
SELECT e.* FROM reservation_endpoints e
JOIN reservations r ON e.reservation_id = r.id
WHERE r.trip_id = ?
ORDER BY e.reservation_id, e.sequence
`).all(tripId) as ReservationEndpoint[];
const map = new Map<number, ReservationEndpoint[]>();
for (const r of rows) {
const list = map.get(r.reservation_id!) ?? [];
list.push(r);
map.set(r.reservation_id!, list);
}
return map;
}
function loadEndpoints(reservationId: number): ReservationEndpoint[] {
return db.prepare(
'SELECT * FROM reservation_endpoints WHERE reservation_id = ? ORDER BY sequence'
).all(reservationId) as ReservationEndpoint[];
}
const saveEndpoints = db.transaction((reservationId: number, endpoints: EndpointInput[]) => {
db.prepare('DELETE FROM reservation_endpoints WHERE reservation_id = ?').run(reservationId);
const insert = db.prepare(`
INSERT INTO reservation_endpoints (reservation_id, role, sequence, name, code, lat, lng, timezone, local_time, local_date)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
endpoints.forEach((e, i) => {
insert.run(reservationId, e.role, e.sequence ?? i, e.name, e.code ?? null, e.lat, e.lng, e.timezone ?? null, e.local_time ?? null, e.local_date ?? null);
});
});
export function listReservations(tripId: string | number) {
const reservations = db.prepare(`
SELECT r.*, d.day_number, p.name as place_name, r.assignment_id,
@@ -18,7 +67,6 @@ export function listReservations(tripId: string | number) {
ORDER BY r.reservation_time ASC, r.created_at ASC
`).all(tripId) as any[];
// Attach per-day positions for multi-day reservations
const dayPositions = db.prepare(`
SELECT rdp.reservation_id, rdp.day_id, rdp.position
FROM reservation_day_positions rdp
@@ -32,15 +80,18 @@ export function listReservations(tripId: string | number) {
posMap.get(dp.reservation_id)![dp.day_id] = dp.position;
}
const endpointsMap = loadEndpointsByTrip(tripId);
for (const r of reservations) {
r.day_positions = posMap.get(r.id) || null;
r.endpoints = endpointsMap.get(r.id) || [];
}
return reservations;
}
export function getReservationWithJoins(id: string | number) {
return db.prepare(`
const row = db.prepare(`
SELECT r.*, d.day_number, p.name as place_name, r.assignment_id,
ap.place_id as accommodation_place_id, acc_p.name as accommodation_name
FROM reservations r
@@ -49,7 +100,10 @@ export function getReservationWithJoins(id: string | number) {
LEFT JOIN day_accommodations ap ON r.accommodation_id = ap.id
LEFT JOIN places acc_p ON ap.place_id = acc_p.id
WHERE r.id = ?
`).get(id);
`).get(id) as any;
if (!row) return undefined;
row.endpoints = loadEndpoints(row.id);
return row;
}
interface CreateAccommodation {
@@ -76,13 +130,16 @@ interface CreateReservationData {
accommodation_id?: number;
metadata?: any;
create_accommodation?: CreateAccommodation;
endpoints?: EndpointInput[];
needs_review?: boolean;
}
export function createReservation(tripId: string | number, data: CreateReservationData): { reservation: any; accommodationCreated: boolean } {
const {
title, reservation_time, reservation_end_time, location,
confirmation_number, notes, day_id, place_id, assignment_id,
status, type, accommodation_id, metadata, create_accommodation
status, type, accommodation_id, metadata, create_accommodation,
endpoints, needs_review
} = data;
let accommodationCreated = false;
@@ -101,8 +158,8 @@ export function createReservation(tripId: string | number, data: CreateReservati
}
const result = db.prepare(`
INSERT INTO reservations (trip_id, day_id, place_id, assignment_id, title, reservation_time, reservation_end_time, location, confirmation_number, notes, status, type, accommodation_id, metadata)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
INSERT INTO reservations (trip_id, day_id, place_id, assignment_id, title, reservation_time, reservation_end_time, location, confirmation_number, notes, status, type, accommodation_id, metadata, needs_review)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
tripId,
day_id || null,
@@ -117,9 +174,14 @@ export function createReservation(tripId: string | number, data: CreateReservati
status || 'pending',
type || 'other',
resolvedAccommodationId,
metadata ? JSON.stringify(metadata) : null
metadata ? JSON.stringify(metadata) : null,
needs_review ? 1 : 0
);
if (endpoints && endpoints.length > 0) {
saveEndpoints(Number(result.lastInsertRowid), endpoints);
}
// Sync check-in/out to accommodation if linked
if (accommodation_id && metadata) {
const meta = typeof metadata === 'string' ? JSON.parse(metadata) : metadata;
@@ -187,13 +249,16 @@ interface UpdateReservationData {
accommodation_id?: number;
metadata?: any;
create_accommodation?: CreateAccommodation;
endpoints?: EndpointInput[];
needs_review?: boolean;
}
export function updateReservation(id: string | number, tripId: string | number, data: UpdateReservationData, current: Reservation): { reservation: any; accommodationChanged: boolean } {
const {
title, reservation_time, reservation_end_time, location,
confirmation_number, notes, day_id, place_id, assignment_id,
status, type, accommodation_id, metadata, create_accommodation
status, type, accommodation_id, metadata, create_accommodation,
endpoints, needs_review
} = data;
let accommodationChanged = false;
@@ -234,7 +299,8 @@ export function updateReservation(id: string | number, tripId: string | number,
status = COALESCE(?, status),
type = COALESCE(?, type),
accommodation_id = ?,
metadata = ?
metadata = ?,
needs_review = COALESCE(?, needs_review)
WHERE id = ?
`).run(
title || null,
@@ -250,9 +316,14 @@ export function updateReservation(id: string | number, tripId: string | number,
type || null,
resolvedAccId,
metadata !== undefined ? (metadata ? JSON.stringify(metadata) : null) : current.metadata,
needs_review === undefined ? null : (needs_review ? 1 : 0),
id
);
if (endpoints !== undefined) {
saveEndpoints(Number(id), endpoints);
}
// Sync check-in/out to accommodation if linked
const resolvedMeta = metadata !== undefined ? metadata : (current.metadata ? JSON.parse(current.metadata as string) : null);
if (resolvedAccId && resolvedMeta) {
+14
View File
@@ -100,6 +100,20 @@ export const SYSTEM_NOTICES: SystemNotice[] = [
priority: 70,
},
{
// Page 1 — personal thank-you from the creator (shown first)
id: 'v3-thankyou',
display: 'modal',
severity: 'info',
icon: 'Heart',
titleKey: 'system_notice.v3_thankyou.title',
bodyKey: 'system_notice.v3_thankyou.body',
dismissible: true,
conditions: [{ kind: 'existingUserBeforeVersion', version: '3.0.0' }],
publishedAt: '2026-04-16T00:00:00Z',
priority: 95,
},
// ── Onboarding ─────────────────────────────────────────────────────────────
{
+17
View File
@@ -139,6 +139,20 @@ export interface BudgetItemMember {
budget_item_id?: number;
}
export interface ReservationEndpoint {
id: number;
reservation_id: number;
role: 'from' | 'to' | 'stop';
sequence: number;
name: string;
code: string | null;
lat: number;
lng: number;
timezone: string | null;
local_time: string | null;
local_date: string | null;
}
export interface Reservation {
id: number;
trip_id: number;
@@ -155,6 +169,8 @@ export interface Reservation {
type: string;
accommodation_id?: number | null;
metadata?: string | null;
needs_review?: number;
endpoints?: ReservationEndpoint[];
created_at?: string;
day_number?: number;
place_name?: string;
@@ -350,6 +366,7 @@ export interface TrekPhoto {
thumbnail_path?: string | null;
width?: number | null;
height?: number | null;
passphrase?: string | null;
created_at: string;
}
@@ -396,6 +396,139 @@ describe('Synology search and albums', () => {
});
});
// ── Album listing — multi-source merge ───────────────────────────────────────
describe('Synology listSynologyAlbums multi-source merge', () => {
// Capture and restore the default safeFetch implementation around each test
// in this block so the persistent mockImplementation we set doesn't leak.
let _savedImpl: ((...args: any[]) => any) | undefined;
beforeEach(() => { _savedImpl = vi.mocked(safeFetch).getMockImplementation(); });
afterEach(() => { if (_savedImpl) vi.mocked(safeFetch).mockImplementation(_savedImpl); });
it('SYNO-027 — personal-only: shared and shared-with-me return failure → merged result contains personal albums, no error', async () => {
const { user } = createUser(testDb);
setSynologyCredentials(testDb, user.id, 'https://synology.example.com', 'admin', 'pass');
vi.mocked(safeFetch).mockImplementation((_url: string, init?: any) => {
// Always read both URL params and body params; body takes precedence for request-specific fields.
const urlParams = (() => { try { return new URL(String(_url)).searchParams; } catch { return new URLSearchParams(); } })();
const bodyParams: URLSearchParams = init?.body instanceof URLSearchParams ? init.body : new URLSearchParams(String(init?.body ?? ''));
const api = urlParams.get('api') || bodyParams.get('api') || '';
const category = bodyParams.get('category') || urlParams.get('category');
if (api === 'SYNO.API.Auth') {
return Promise.resolve({ ok: true, status: 200, headers: { get: () => 'application/json' }, json: async () => ({ success: true, data: { sid: 'sid-027' } }), body: null } as any);
}
if (api === 'SYNO.Foto.Browse.Album') {
if (!category) {
// personal albums
return Promise.resolve({ ok: true, status: 200, headers: { get: () => 'application/json' }, json: async () => ({ success: true, data: { list: [{ id: 1, name: 'Personal Album', item_count: 5 }] } }), body: null } as any);
}
// shared category → failure
return Promise.resolve({ ok: true, status: 200, headers: { get: () => 'application/json' }, json: async () => ({ success: false, error: { code: 400 } }), body: null } as any);
}
if (api === 'SYNO.Foto.Sharing.Misc') {
return Promise.resolve({ ok: true, status: 200, headers: { get: () => 'application/json' }, json: async () => ({ success: false, error: { code: 400 } }), body: null } as any);
}
return Promise.reject(new Error(`Unexpected API: ${api}`));
});
const res = await request(app)
.get(`${SYNO}/albums`)
.set('Cookie', authCookie(user.id));
expect(res.status).toBe(200);
expect(Array.isArray(res.body.albums)).toBe(true);
expect(res.body.albums).toHaveLength(1);
expect(res.body.albums[0]).toMatchObject({ albumName: 'Personal Album', assetCount: 5 });
});
it('SYNO-028 — full merge: personal + shared (with passphrase) + shared-with-me (with sharing_info.passphrase) → 4 albums with correct passphrases', async () => {
const { user } = createUser(testDb);
setSynologyCredentials(testDb, user.id, 'https://synology.example.com', 'admin', 'pass');
vi.mocked(safeFetch).mockImplementation((_url: string, init?: any) => {
const urlParams = (() => { try { return new URL(String(_url)).searchParams; } catch { return new URLSearchParams(); } })();
const bodyParams: URLSearchParams = init?.body instanceof URLSearchParams ? init.body : new URLSearchParams(String(init?.body ?? ''));
const api = urlParams.get('api') || bodyParams.get('api') || '';
const category = bodyParams.get('category') || urlParams.get('category');
if (api === 'SYNO.API.Auth') {
return Promise.resolve({ ok: true, status: 200, headers: { get: () => 'application/json' }, json: async () => ({ success: true, data: { sid: 'sid-028' } }), body: null } as any);
}
if (api === 'SYNO.Foto.Browse.Album') {
if (!category) {
return Promise.resolve({ ok: true, status: 200, headers: { get: () => 'application/json' }, json: async () => ({ success: true, data: { list: [{ id: 10, name: 'Alpha Album', item_count: 3 }, { id: 11, name: 'Beta Album', item_count: 7 }] } }), body: null } as any);
}
// shared category — one album with passphrase
return Promise.resolve({ ok: true, status: 200, headers: { get: () => 'application/json' }, json: async () => ({ success: true, data: { list: [{ id: 20, name: 'Shared Out', item_count: 2, passphrase: 'pp-abc' }] } }), body: null } as any);
}
if (api === 'SYNO.Foto.Sharing.Misc') {
return Promise.resolve({ ok: true, status: 200, headers: { get: () => 'application/json' }, json: async () => ({ success: true, data: { list: [{ id: 30, name: 'Shared With Me', item_count: 4, sharing_info: { passphrase: 'pp-xyz' } }] } }), body: null } as any);
}
return Promise.reject(new Error(`Unexpected API: ${api}`));
});
const res = await request(app)
.get(`${SYNO}/albums`)
.set('Cookie', authCookie(user.id));
expect(res.status).toBe(200);
expect(Array.isArray(res.body.albums)).toBe(true);
expect(res.body.albums).toHaveLength(4);
const byName = (name: string) => res.body.albums.find((a: any) => a.albumName === name);
expect(byName('Alpha Album')).toMatchObject({ id: '10', assetCount: 3 });
expect(byName('Beta Album')).toMatchObject({ id: '11', assetCount: 7 });
expect(byName('Shared Out')).toMatchObject({ id: '20', passphrase: 'pp-abc' });
expect(byName('Shared With Me')).toMatchObject({ id: '30', passphrase: 'pp-xyz' });
// personal albums carry no passphrase
expect(byName('Alpha Album').passphrase).toBeUndefined();
});
it('SYNO-029 — dedup: same album id=99 in personal and shared-with-me → last-write-wins gives passphrase from shared-with-me', async () => {
const { user } = createUser(testDb);
setSynologyCredentials(testDb, user.id, 'https://synology.example.com', 'admin', 'pass');
vi.mocked(safeFetch).mockImplementation((_url: string, init?: any) => {
const urlParams = (() => { try { return new URL(String(_url)).searchParams; } catch { return new URLSearchParams(); } })();
const bodyParams: URLSearchParams = init?.body instanceof URLSearchParams ? init.body : new URLSearchParams(String(init?.body ?? ''));
const api = urlParams.get('api') || bodyParams.get('api') || '';
const category = bodyParams.get('category') || urlParams.get('category');
if (api === 'SYNO.API.Auth') {
return Promise.resolve({ ok: true, status: 200, headers: { get: () => 'application/json' }, json: async () => ({ success: true, data: { sid: 'sid-029' } }), body: null } as any);
}
if (api === 'SYNO.Foto.Browse.Album') {
if (!category) {
// personal: album id=99 without passphrase
return Promise.resolve({ ok: true, status: 200, headers: { get: () => 'application/json' }, json: async () => ({ success: true, data: { list: [{ id: 99, name: 'Dup Album', item_count: 10 }] } }), body: null } as any);
}
// shared: no entries
return Promise.resolve({ ok: true, status: 200, headers: { get: () => 'application/json' }, json: async () => ({ success: true, data: { list: [] } }), body: null } as any);
}
if (api === 'SYNO.Foto.Sharing.Misc') {
// shared-with-me: same album id=99 with passphrase
return Promise.resolve({ ok: true, status: 200, headers: { get: () => 'application/json' }, json: async () => ({ success: true, data: { list: [{ id: 99, name: 'Dup Album', item_count: 10, passphrase: 'pp-dup' }] } }), body: null } as any);
}
return Promise.reject(new Error(`Unexpected API: ${api}`));
});
const res = await request(app)
.get(`${SYNO}/albums`)
.set('Cookie', authCookie(user.id));
expect(res.status).toBe(200);
expect(Array.isArray(res.body.albums)).toBe(true);
// Deduplicated to a single album
expect(res.body.albums).toHaveLength(1);
expect(res.body.albums[0]).toMatchObject({ id: '99', albumName: 'Dup Album' });
// shared-with-me wins (last write) → passphrase present
expect(res.body.albums[0].passphrase).toBe('pp-dup');
});
});
// ── Asset access ──────────────────────────────────────────────────────────────
describe('Synology asset access', () => {
@@ -571,6 +704,7 @@ describe('Synology auth checks', () => {
// ── Album sync ────────────────────────────────────────────────────────────────
import { addAlbumLink } from '../helpers/factories';
import { encrypt_api_key } from '../../src/services/apiKeyCrypto';
describe('Synology syncSynologyAlbumLink', () => {
it('SYNO-050 — POST sync happy path: trip owner with album link saves photos to DB', async () => {
@@ -632,6 +766,70 @@ describe('Synology syncSynologyAlbumLink', () => {
it('SYNO-053 — POST sync without auth returns 401', async () => {
expect((await request(app).post(`${SYNO}/trips/1/album-links/1/sync`)).status).toBe(401);
});
it('SYNO-054 — POST sync with passphrase link: uses passphrase in item-list call and persists encrypted passphrase on trek_photos', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
setSynologyCredentials(testDb, user.id, 'https://synology.example.com', 'admin', 'pass');
testDb.prepare("UPDATE photo_providers SET enabled = 1 WHERE id = 'synologyphotos'").run();
// Insert a link with an encrypted passphrase directly into the DB.
const rawPassphrase = 'syno-share-pass-abc';
const result = testDb.prepare(
'INSERT INTO trip_album_links (trip_id, user_id, provider, album_id, album_name, passphrase) VALUES (?, ?, ?, ?, ?, ?)'
).run(trip.id, user.id, 'synologyphotos', '99', 'Shared Album', encrypt_api_key(rawPassphrase));
const link = testDb.prepare('SELECT * FROM trip_album_links WHERE id = ?').get(result.lastInsertRowid) as any;
// Override safeFetch so browse-item only succeeds when called with the passphrase param.
vi.mocked(safeFetch).mockImplementation(async (url: any, init?: any) => {
const bodyParams = init?.body instanceof URLSearchParams
? init.body
: new URLSearchParams(String(init?.body ?? ''));
const apiName = bodyParams.get('api') || (new URL(String(url)).searchParams.get('api') ?? '');
if (apiName === 'SYNO.API.Auth') {
return { ok: true, status: 200, headers: { get: () => 'application/json' }, json: async () => ({ success: true, data: { sid: 'fake-sid-054' } }), body: null } as any;
}
if (apiName === 'SYNO.Foto.Browse.Item') {
// Only respond successfully when the passphrase param is present.
if (bodyParams.get('passphrase') !== rawPassphrase) {
return { ok: true, status: 200, headers: { get: () => 'application/json' }, json: async () => ({ success: false, error: { code: 105 } }), body: null } as any;
}
return {
ok: true, status: 200,
headers: { get: () => 'application/json' },
json: async () => ({
success: true,
data: {
list: [{ id: 201, filename: 'shared.jpg', filesize: 512000, time: 1717228800, additional: { thumbnail: { cache_key: '201_sharedkey' } } }],
},
}),
body: null,
} as any;
}
return Promise.reject(new Error(`SYNO-054: unexpected safeFetch call: api=${apiName}`));
});
const res = await request(app)
.post(`${SYNO}/trips/${trip.id}/album-links/${link.id}/sync`)
.set('Cookie', authCookie(user.id));
expect(res.status).toBe(200);
expect(res.body.added).toBeGreaterThan(0);
// The trek_photos row for the synced photo must have a non-null passphrase.
const photo = testDb.prepare(`
SELECT tkp.passphrase FROM trip_photos tp
JOIN trek_photos tkp ON tkp.id = tp.photo_id
WHERE tp.trip_id = ? AND tp.user_id = ?
LIMIT 1
`).get(trip.id, user.id) as { passphrase: string | null } | undefined;
expect(photo).toBeDefined();
expect(photo!.passphrase).not.toBeNull();
});
});
// ── Session retry logic ───────────────────────────────────────────────────────
@@ -691,8 +889,9 @@ describe('Synology session retry on error codes 106/107/119', () => {
expect(res.status).toBe(200);
expect(Array.isArray(res.body.albums)).toBe(true);
expect(res.body.albums[0]).toMatchObject({ albumName: 'Retry Album' });
// Four safeFetch calls: login, failed album list, re-login, successful album list
expect(vi.mocked(safeFetch)).toHaveBeenCalledTimes(4);
// Five safeFetch calls: login, failed album list (119), re-login, successful album list retry,
// plus one additional call for the shared or shared-with-me source (handled by default mock)
expect(vi.mocked(safeFetch)).toHaveBeenCalledTimes(5);
});
it('SYNO-061 — request retries with fresh session when API returns error code 106', async () => {
@@ -735,7 +934,9 @@ describe('Synology session retry on error codes 106/107/119', () => {
expect(res.status).toBe(200);
expect(res.body.albums[0]).toMatchObject({ albumName: 'Timeout Album' });
expect(vi.mocked(safeFetch)).toHaveBeenCalledTimes(4);
// Five safeFetch calls: login, failed album list (106), re-login, successful album list retry,
// plus one additional call for the shared or shared-with-me source (handled by default mock)
expect(vi.mocked(safeFetch)).toHaveBeenCalledTimes(5);
});
});
@@ -843,6 +1044,83 @@ describe('Synology searchSynologyPhotos date range', () => {
});
});
// ── Search pagination ─────────────────────────────────────────────────────────
describe('Synology search pagination', () => {
it('SYNO-025 — POST /search with { page: 2, size: 50 } sends offset=50 and limit=50 to Synology API', async () => {
const { user } = createUser(testDb);
setSynologyCredentials(testDb, user.id, 'https://synology.example.com', 'admin', 'pass');
let capturedBody: URLSearchParams | null = null;
vi.mocked(safeFetch)
.mockResolvedValueOnce({
// login
ok: true, status: 200,
headers: { get: () => 'application/json' },
json: async () => ({ success: true, data: { sid: 'fake-sid' } }),
body: null,
} as any)
.mockImplementationOnce((_url: string, init?: any) => {
capturedBody = init?.body instanceof URLSearchParams
? init.body
: new URLSearchParams(String(init?.body ?? ''));
return Promise.resolve({
ok: true, status: 200,
headers: { get: () => 'application/json' },
json: async () => ({ success: true, data: { list: [] } }),
body: null,
} as any);
});
const res = await request(app)
.post(`${SYNO}/search`)
.set('Cookie', authCookie(user.id))
.send({ page: 2, size: 50 });
expect(res.status).toBe(200);
expect(capturedBody).not.toBeNull();
// With the fix: limit=50 is resolved first, then offset = (2-1)*50 = 50
expect(capturedBody!.get('offset')).toBe('50');
expect(capturedBody!.get('limit')).toBe('50');
});
it('SYNO-026 — POST /search with { page: 3, size: 25 } sends offset=50 and limit=25 to Synology API', async () => {
const { user } = createUser(testDb);
setSynologyCredentials(testDb, user.id, 'https://synology.example.com', 'admin', 'pass');
let capturedBody: URLSearchParams | null = null;
vi.mocked(safeFetch)
.mockResolvedValueOnce({
ok: true, status: 200,
headers: { get: () => 'application/json' },
json: async () => ({ success: true, data: { sid: 'fake-sid' } }),
body: null,
} as any)
.mockImplementationOnce((_url: string, init?: any) => {
capturedBody = init?.body instanceof URLSearchParams
? init.body
: new URLSearchParams(String(init?.body ?? ''));
return Promise.resolve({
ok: true, status: 200,
headers: { get: () => 'application/json' },
json: async () => ({ success: true, data: { list: [] } }),
body: null,
} as any);
});
const res = await request(app)
.post(`${SYNO}/search`)
.set('Cookie', authCookie(user.id))
.send({ page: 3, size: 25 });
expect(res.status).toBe(200);
expect(capturedBody).not.toBeNull();
// page 3 → page index = 2 (after subtracting 1), offset = 2 * 25 = 50
expect(capturedBody!.get('offset')).toBe('50');
expect(capturedBody!.get('limit')).toBe('25');
});
});
// ── SSRF catch branch in _fetchSynologyJson ────────────────────────────────────
describe('Synology SSRF blocked error handling', () => {
@@ -865,13 +1143,21 @@ describe('Synology SSRF blocked error handling', () => {
expect(res.body.connected).toBe(false);
});
it('SYNO-081 — safeFetch throwing SsrfBlockedError during album list returns 400', async () => {
it('SYNO-081 — safeFetch throwing SsrfBlockedError during one album source is swallowed; other sources still return albums', async () => {
const { user } = createUser(testDb);
setSynologyCredentials(testDb, user.id, 'https://synology.example.com', 'admin', 'pass');
const { SsrfBlockedError: SsrfErr } = await import('../../src/utils/ssrfGuard');
// Auth succeeds, but the album-list call throws SsrfBlockedError
const emptyAlbumResponse = {
ok: true, status: 200,
headers: { get: () => 'application/json' },
json: async () => ({ success: true, data: { list: [{ id: 99, name: 'Shared Album', item_count: 2, passphrase: 'pp-test' }] } }),
body: null,
} as any;
// Auth succeeds, personal album source throws SSRF, shared + shared-with-me succeed.
// listSynologyAlbums uses Promise.allSettled so the SSRF failure is logged and skipped.
vi.mocked(safeFetch)
.mockResolvedValueOnce({
ok: true, status: 200,
@@ -879,14 +1165,17 @@ describe('Synology SSRF blocked error handling', () => {
json: async () => ({ success: true, data: { sid: 'sid-x' } }),
body: null,
} as any)
.mockRejectedValueOnce(new SsrfErr('Private IP detected'));
.mockRejectedValueOnce(new SsrfErr('Private IP detected'))
.mockResolvedValueOnce(emptyAlbumResponse)
.mockResolvedValueOnce(emptyAlbumResponse);
const res = await request(app)
.get(`${SYNO}/albums`)
.set('Cookie', authCookie(user.id));
// _fetchSynologyJson catches SsrfBlockedError and returns fail(message, 400)
expect(res.status).toBe(400);
expect(res.body.error).toBeDefined();
// Personal failed (SSRF), shared sources returned an album — 200 with non-empty list.
expect(res.status).toBe(200);
expect(Array.isArray(res.body.albums)).toBe(true);
expect(res.body.albums.length).toBeGreaterThan(0);
});
});