mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-22 14:51:45 +00:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 73ce54eac5 |
@@ -17,6 +17,7 @@ client/public/icons/*.png
|
||||
|
||||
# User data
|
||||
server/data/*
|
||||
!server/data/airports.json
|
||||
server/uploads/
|
||||
|
||||
# Environment
|
||||
|
||||
+2
-5
@@ -100,7 +100,7 @@ function RootRedirect() {
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const { loadUser, isAuthenticated, demoMode, setDemoMode, setDevMode, setIsPrerelease, setAppVersion, setHasMapsKey, setServerTimezone, setAppRequireMfa, setTripRemindersEnabled, setPlacesPhotosEnabled, setPlacesAutocompleteEnabled, setPlacesDetailsEnabled } = useAuthStore()
|
||||
const { loadUser, isAuthenticated, demoMode, setDemoMode, setDevMode, setIsPrerelease, setAppVersion, setHasMapsKey, setServerTimezone, setAppRequireMfa, setTripRemindersEnabled } = useAuthStore()
|
||||
const { loadSettings } = useSettingsStore()
|
||||
const { loadAddons } = useAddonStore()
|
||||
|
||||
@@ -116,7 +116,7 @@ export default function App() {
|
||||
loadUser()
|
||||
}
|
||||
}
|
||||
authApi.getAppConfig().then(async (config: { demo_mode?: boolean; dev_mode?: boolean; is_prerelease?: boolean; has_maps_key?: boolean; version?: string; timezone?: string; require_mfa?: boolean; trip_reminders_enabled?: boolean; places_photos_enabled?: boolean; places_autocomplete_enabled?: boolean; places_details_enabled?: boolean; permissions?: Record<string, PermissionLevel> }) => {
|
||||
authApi.getAppConfig().then(async (config: { demo_mode?: boolean; dev_mode?: boolean; is_prerelease?: boolean; has_maps_key?: boolean; version?: string; timezone?: string; require_mfa?: boolean; trip_reminders_enabled?: boolean; permissions?: Record<string, PermissionLevel> }) => {
|
||||
if (config?.demo_mode) setDemoMode(true)
|
||||
if (config?.dev_mode) setDevMode(true)
|
||||
if (config?.is_prerelease !== undefined) setIsPrerelease(config.is_prerelease)
|
||||
@@ -125,9 +125,6 @@ export default function App() {
|
||||
if (config?.timezone) setServerTimezone(config.timezone)
|
||||
if (config?.require_mfa !== undefined) setAppRequireMfa(!!config.require_mfa)
|
||||
if (config?.trip_reminders_enabled !== undefined) setTripRemindersEnabled(config.trip_reminders_enabled)
|
||||
if (config?.places_photos_enabled !== undefined) setPlacesPhotosEnabled(config.places_photos_enabled)
|
||||
if (config?.places_autocomplete_enabled !== undefined) setPlacesAutocompleteEnabled(config.places_autocomplete_enabled)
|
||||
if (config?.places_details_enabled !== undefined) setPlacesDetailsEnabled(config.places_details_enabled)
|
||||
if (config?.permissions) usePermissionsStore.getState().setPermissions(config.permissions)
|
||||
|
||||
if (config?.version) {
|
||||
|
||||
@@ -190,27 +190,18 @@ export const placesApi = {
|
||||
update: (tripId: number | string, id: number | string, data: Record<string, unknown>) => apiClient.put(`/trips/${tripId}/places/${id}`, data).then(r => r.data),
|
||||
delete: (tripId: number | string, id: number | string) => apiClient.delete(`/trips/${tripId}/places/${id}`).then(r => r.data),
|
||||
searchImage: (tripId: number | string, id: number | string) => apiClient.get(`/trips/${tripId}/places/${id}/image`).then(r => r.data),
|
||||
importGpx: (tripId: number | string, file: File, opts?: { waypoints?: boolean; routes?: boolean; tracks?: boolean }) => {
|
||||
const fd = new FormData()
|
||||
fd.append('file', file)
|
||||
if (opts?.waypoints !== undefined) fd.append('importWaypoints', String(opts.waypoints))
|
||||
if (opts?.routes !== undefined) fd.append('importRoutes', String(opts.routes))
|
||||
if (opts?.tracks !== undefined) fd.append('importTracks', String(opts.tracks))
|
||||
importGpx: (tripId: number | string, file: File) => {
|
||||
const fd = new FormData(); fd.append('file', file)
|
||||
return apiClient.post(`/trips/${tripId}/places/import/gpx`, fd, { headers: { 'Content-Type': 'multipart/form-data' } }).then(r => r.data)
|
||||
},
|
||||
importMapFile: (tripId: number | string, file: File, opts?: { points?: boolean; paths?: boolean }) => {
|
||||
const fd = new FormData()
|
||||
fd.append('file', file)
|
||||
if (opts?.points !== undefined) fd.append('importPoints', String(opts.points))
|
||||
if (opts?.paths !== undefined) fd.append('importPaths', String(opts.paths))
|
||||
importMapFile: (tripId: number | string, file: File) => {
|
||||
const fd = new FormData(); fd.append('file', file)
|
||||
return apiClient.post(`/trips/${tripId}/places/import/map`, fd, { headers: { 'Content-Type': 'multipart/form-data' } }).then(r => r.data)
|
||||
},
|
||||
importGoogleList: (tripId: number | string, url: string) =>
|
||||
apiClient.post(`/trips/${tripId}/places/import/google-list`, { url }).then(r => r.data),
|
||||
importNaverList: (tripId: number | string, url: string) =>
|
||||
apiClient.post(`/trips/${tripId}/places/import/naver-list`, { url }).then(r => r.data),
|
||||
bulkDelete: (tripId: number | string, ids: number[]) =>
|
||||
apiClient.post(`/trips/${tripId}/places/bulk-delete`, { ids }).then(r => r.data),
|
||||
}
|
||||
|
||||
export const assignmentsApi = {
|
||||
@@ -281,12 +272,6 @@ export const adminApi = {
|
||||
checkVersion: () => apiClient.get('/admin/version-check').then(r => r.data),
|
||||
getBagTracking: () => apiClient.get('/admin/bag-tracking').then(r => r.data),
|
||||
updateBagTracking: (enabled: boolean) => apiClient.put('/admin/bag-tracking', { enabled }).then(r => r.data),
|
||||
getPlacesPhotos: () => apiClient.get('/admin/places-photos').then(r => r.data),
|
||||
updatePlacesPhotos: (enabled: boolean) => apiClient.put('/admin/places-photos', { enabled }).then(r => r.data),
|
||||
getPlacesAutocomplete: () => apiClient.get('/admin/places-autocomplete').then(r => r.data),
|
||||
updatePlacesAutocomplete: (enabled: boolean) => apiClient.put('/admin/places-autocomplete', { enabled }).then(r => r.data),
|
||||
getPlacesDetails: () => apiClient.get('/admin/places-details').then(r => r.data),
|
||||
updatePlacesDetails: (enabled: boolean) => apiClient.put('/admin/places-details', { enabled }).then(r => r.data),
|
||||
getCollabFeatures: () => apiClient.get('/admin/collab-features').then(r => r.data),
|
||||
updateCollabFeatures: (features: Record<string, boolean>) => apiClient.put('/admin/collab-features', features).then(r => r.data),
|
||||
packingTemplates: () => apiClient.get('/admin/packing-templates').then(r => r.data),
|
||||
|
||||
@@ -416,8 +416,8 @@ describe('BudgetPanel', () => {
|
||||
render(<BudgetPanel tripId={1} />);
|
||||
await screen.findByText('Flight');
|
||||
await screen.findByText('Hotel');
|
||||
// Grand total card shows 300.00 (integer and decimal are rendered in separate spans)
|
||||
expect(document.body.textContent?.replace(/\s+/g, '')).toMatch(/300[,.]00/);
|
||||
// Grand total card shows 300.00
|
||||
expect(screen.getByText('300.00')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-BUDGET-033: read-only mode hides add/delete/edit controls', async () => {
|
||||
|
||||
@@ -4,69 +4,7 @@ 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, 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 { Plus, Trash2, Calculator, Wallet, Pencil, Users, Check, Info, ChevronDown, ChevronRight, Download, GripVertical } from 'lucide-react'
|
||||
import CustomSelect from '../shared/CustomSelect'
|
||||
import { budgetApi } from '../../api/client'
|
||||
import { CustomDatePicker } from '../shared/CustomDateTimePicker'
|
||||
@@ -423,47 +361,9 @@ interface PerPersonInlineProps {
|
||||
locale: string
|
||||
}
|
||||
|
||||
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)
|
||||
function PerPersonInline({ tripId, budgetItems, currency, locale }: PerPersonInlineProps) {
|
||||
const [data, setData] = useState(null)
|
||||
const fmt = (v) => fmtNum(v, locale, currency)
|
||||
|
||||
useEffect(() => {
|
||||
budgetApi.perPersonSummary(tripId).then(d => setData(d.summary)).catch(() => {})
|
||||
@@ -471,38 +371,25 @@ function PerPersonInline({ tripId, budgetItems, currency, locale, grandTotal, th
|
||||
|
||||
if (!data || data.length === 0) return null
|
||||
|
||||
const people = data.map((p: any) => ({ ...p, color: colorForUserId(p.user_id) }))
|
||||
|
||||
return (
|
||||
<>
|
||||
{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 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>
|
||||
</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>
|
||||
</>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -559,8 +446,6 @@ 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)
|
||||
@@ -704,69 +589,20 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
|
||||
}
|
||||
|
||||
// ── Main Layout ──────────────────────────────────────────────────────────
|
||||
const totalBudget = budgetItems.reduce((s, x) => s + (x.total_price || 0), 0)
|
||||
return (
|
||||
<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 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>
|
||||
<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: '24px 28px 40px', alignItems: 'flex-start', flexWrap: 'wrap' }} className="max-md:!px-4">
|
||||
<div style={{ display: 'flex', gap: 20, padding: '0 16px 40px', alignItems: 'flex-start', flexWrap: 'wrap' }}>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
{categoryNames.map((cat, ci) => {
|
||||
const items = grouped.get(cat) || []
|
||||
@@ -975,57 +811,61 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="w-full md:w-[320px]" style={{ flexShrink: 0, position: 'sticky', top: 16, alignSelf: 'flex-start' }}>
|
||||
<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 style={{
|
||||
background: theme.bg,
|
||||
borderRadius: 20, padding: 20, color: theme.text, marginBottom: 16,
|
||||
border: `1px solid ${theme.border}`,
|
||||
boxShadow: theme.shadow,
|
||||
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)',
|
||||
}}>
|
||||
<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 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>
|
||||
<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 style={{ fontSize: 11, color: 'rgba(255,255,255,0.5)', fontWeight: 500, letterSpacing: 0.5 }}>{t('budget.totalBudget')}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(() => {
|
||||
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 style={{ fontSize: 22, fontWeight: 700, lineHeight: 1, marginBottom: 4 }}>
|
||||
{Number(grandTotal).toLocaleString(locale, { minimumFractionDigits: currencyDecimals(currency), maximumFractionDigits: currencyDecimals(currency) })}
|
||||
</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} grandTotal={grandTotal} theme={theme} />
|
||||
<PerPersonInline tripId={tripId} budgetItems={budgetItems} currency={currency} locale={locale} />
|
||||
)}
|
||||
|
||||
{/* Settlement dropdown inside the total card */}
|
||||
{hasMultipleMembers && settlement && settlement.flows.length > 0 && (
|
||||
<div style={{ marginTop: 16, borderTop: `1px solid ${theme.divider}`, paddingTop: 12 }}>
|
||||
<div style={{ marginTop: 16, borderTop: '1px solid rgba(255,255,255,0.1)', 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: theme.sub, fontSize: 11, fontWeight: 600, letterSpacing: 0.5,
|
||||
color: 'rgba(255,255,255,0.6)', fontSize: 11, fontWeight: 600, letterSpacing: 0.5,
|
||||
}}>
|
||||
{settlementOpen ? <ChevronDown size={13} /> : <ChevronRight size={13} />}
|
||||
{t('budget.settlement')}
|
||||
@@ -1050,60 +890,53 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
|
||||
</button>
|
||||
|
||||
{settlementOpen && (
|
||||
<div style={{ marginTop: 12, display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
<div style={{ marginTop: 10, display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{settlement.flows.map((flow, i) => (
|
||||
<div key={i} style={{
|
||||
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' }}>
|
||||
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' }}>
|
||||
{fmt(flow.amount, currency)}
|
||||
</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>
|
||||
<span style={{ fontSize: 11, color: 'rgba(255,255,255,0.3)' }}>→</span>
|
||||
</div>
|
||||
<RingAvatar userId={flow.to.user_id} username={flow.to.username} avatarUrl={flow.to.avatar_url} size={32} innerBg={theme.centerBg} textColor={theme.text} />
|
||||
<ChipWithTooltip label={flow.to.username} avatarUrl={flow.to.avatar_url} size={28} />
|
||||
</div>
|
||||
))}
|
||||
|
||||
{settlement.balances.filter(b => Math.abs(b.balance) > 0.01).length > 0 && (
|
||||
<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 }}>
|
||||
<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 }}>
|
||||
{t('budget.netBalances')}
|
||||
</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>
|
||||
{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>
|
||||
)}
|
||||
</div>
|
||||
@@ -1112,115 +945,36 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
|
||||
)}
|
||||
</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>
|
||||
{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>
|
||||
|
||||
<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>
|
||||
<PieChart segments={pieSegments} size={180} totalLabel={t('budget.total')} />
|
||||
|
||||
<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 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>
|
||||
<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>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -779,81 +779,25 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
|
||||
document.body
|
||||
)}
|
||||
|
||||
{/* 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',
|
||||
}}>
|
||||
<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>
|
||||
{/* 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',
|
||||
}}>
|
||||
<Trash2 size={13} /> {t('files.trash') || 'Trash'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showTrash ? (
|
||||
@@ -891,7 +835,7 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
|
||||
{can('file_upload', trip) && <div
|
||||
{...getRootProps()}
|
||||
style={{
|
||||
margin: '16px 28px 0', border: '2px dashed', borderRadius: 14, padding: '20px 16px',
|
||||
margin: '16px 16px 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)',
|
||||
@@ -916,7 +860,7 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
|
||||
</div>}
|
||||
|
||||
{/* Filter tabs */}
|
||||
<div className="md:!hidden" style={{ display: 'flex', gap: 4, padding: '12px 16px 0', flexShrink: 0, flexWrap: 'wrap' }}>
|
||||
<div 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 }] : []),
|
||||
@@ -939,7 +883,7 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
|
||||
</div>
|
||||
|
||||
{/* File list */}
|
||||
<div style={{ flex: 1, overflowY: 'auto', padding: '12px 28px 16px' }} className="max-md:!px-4">
|
||||
<div style={{ flex: 1, overflowY: 'auto', padding: '12px 16px 16px' }}>
|
||||
{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' }} />
|
||||
|
||||
@@ -14,7 +14,6 @@ export interface MapMarkerItem {
|
||||
export interface JourneyMapHandle {
|
||||
highlightMarker: (id: string | null) => void
|
||||
focusMarker: (id: string) => void
|
||||
invalidateSize: () => void
|
||||
}
|
||||
|
||||
interface MapEntry {
|
||||
@@ -152,11 +151,7 @@ const JourneyMap = forwardRef<JourneyMapHandle, Props>(function JourneyMap(
|
||||
}
|
||||
}, [])
|
||||
|
||||
const invalidateSize = useCallback(() => {
|
||||
try { mapRef.current?.invalidateSize() } catch { /* map not yet initialized */ }
|
||||
}, [])
|
||||
|
||||
useImperativeHandle(ref, () => ({ highlightMarker, focusMarker, invalidateSize }), [])
|
||||
useImperativeHandle(ref, () => ({ highlightMarker, focusMarker }), [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import React from 'react'
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest'
|
||||
import { render, screen } from '../../../tests/helpers/render'
|
||||
import { fireEvent, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { fireEvent } from '@testing-library/react'
|
||||
import { resetAllStores } from '../../../tests/helpers/store'
|
||||
import { buildPlace } from '../../../tests/helpers/factories'
|
||||
import * as photoService from '../../services/photoService'
|
||||
@@ -17,13 +16,10 @@ vi.mock('react-leaflet', () => ({
|
||||
data-lng={position[1]}
|
||||
onClick={() => eventHandlers?.click?.()}
|
||||
>
|
||||
<button
|
||||
data-testid="marker-hover-trigger"
|
||||
onClick={() => eventHandlers?.mouseover?.({ originalEvent: { clientX: 100, clientY: 100 } })}
|
||||
/>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
Tooltip: ({ children }: any) => <div data-testid="tooltip">{children}</div>,
|
||||
Polyline: ({ positions }: any) => <div data-testid="polyline" data-points={JSON.stringify(positions)} />,
|
||||
CircleMarker: () => <div data-testid="circle-marker" />,
|
||||
Circle: () => <div data-testid="circle" />,
|
||||
@@ -105,26 +101,22 @@ describe('MapView', () => {
|
||||
expect(onMarkerClick).toHaveBeenCalledWith(42)
|
||||
})
|
||||
|
||||
it('FE-COMP-MAPVIEW-004: tooltip shows place name', async () => {
|
||||
const user = userEvent.setup()
|
||||
it('FE-COMP-MAPVIEW-004: tooltip shows place name', () => {
|
||||
const places = [buildMapPlace({ name: 'Eiffel Tower', lat: 48.8584, lng: 2.2945 })]
|
||||
render(<MapView places={places} />)
|
||||
await user.click(screen.getByTestId('marker-hover-trigger'))
|
||||
expect(screen.getByTestId('tooltip').textContent).toContain('Eiffel Tower')
|
||||
})
|
||||
|
||||
it('FE-COMP-MAPVIEW-005: tooltip shows category name when present', async () => {
|
||||
const user = userEvent.setup()
|
||||
it('FE-COMP-MAPVIEW-005: tooltip shows category name when present', () => {
|
||||
const places = [
|
||||
buildMapPlace({ name: 'Louvre', lat: 48.86, lng: 2.337, category_name: 'Museum', category_icon: null }),
|
||||
]
|
||||
render(<MapView places={places} />)
|
||||
await user.click(screen.getByTestId('marker-hover-trigger'))
|
||||
expect(screen.getByTestId('tooltip').textContent).toContain('Museum')
|
||||
})
|
||||
|
||||
it('FE-COMP-MAPVIEW-006: renders polyline when route has 2+ points', () => {
|
||||
render(<MapView route={[[[48.0, 2.0], [49.0, 3.0]]]} />)
|
||||
render(<MapView route={[[48.0, 2.0], [49.0, 3.0]]} />)
|
||||
expect(screen.getByTestId('polyline')).toBeTruthy()
|
||||
})
|
||||
|
||||
@@ -134,7 +126,7 @@ describe('MapView', () => {
|
||||
})
|
||||
|
||||
it('FE-COMP-MAPVIEW-008: does not render polyline for single-point route', () => {
|
||||
render(<MapView route={[[[48.0, 2.0]]]} />)
|
||||
render(<MapView route={[[48.0, 2.0]]} />)
|
||||
expect(screen.queryByTestId('polyline')).toBeNull()
|
||||
})
|
||||
|
||||
@@ -153,7 +145,7 @@ describe('MapView', () => {
|
||||
})
|
||||
|
||||
it('FE-COMP-MAPVIEW-011: renders RouteLabel marker when routeSegments provided with route', () => {
|
||||
const route = [[[48.0, 2.0], [49.0, 3.0]]] as [number, number][][][]
|
||||
const route = [[48.0, 2.0], [49.0, 3.0]] as [number, number][]
|
||||
const routeSegments = [
|
||||
{ mid: [48.5, 2.5] as [number, number], from: 0, to: 1, walkingText: '10 min', drivingText: '3 min' },
|
||||
]
|
||||
@@ -199,13 +191,11 @@ describe('MapView', () => {
|
||||
vi.mocked(photoService.getCached).mockReturnValue(null)
|
||||
})
|
||||
|
||||
it('FE-COMP-MAPVIEW-016: tooltip shows address when present', async () => {
|
||||
const user = userEvent.setup()
|
||||
it('FE-COMP-MAPVIEW-016: tooltip shows address when present', () => {
|
||||
const places = [
|
||||
buildMapPlace({ name: 'Eiffel Tower', lat: 48.8584, lng: 2.2945, address: '5 Av. Anatole France' }),
|
||||
]
|
||||
render(<MapView places={places} />)
|
||||
await user.click(screen.getByTestId('marker-hover-trigger'))
|
||||
expect(screen.getByTestId('tooltip').textContent).toContain('5 Av. Anatole France')
|
||||
})
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useRef, useState, useMemo, useCallback, createElement, memo } from 'react'
|
||||
import DOM from 'react-dom'
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import { MapContainer, TileLayer, Marker, Polyline, CircleMarker, Circle, useMap } from 'react-leaflet'
|
||||
import { MapContainer, TileLayer, Marker, Tooltip, Polyline, CircleMarker, Circle, useMap } from 'react-leaflet'
|
||||
import MarkerClusterGroup from 'react-leaflet-cluster'
|
||||
import L from 'leaflet'
|
||||
import 'leaflet.markercluster/dist/MarkerCluster.css'
|
||||
@@ -68,9 +68,9 @@ function createPlaceIcon(place, orderNumbers, isSelected) {
|
||||
">${label}</span>`
|
||||
}
|
||||
|
||||
// Prefer base64 data URLs (no zoom lag); also accept same-origin proxy URLs as a fallback
|
||||
// while the thumb is still being generated in the background
|
||||
if (place.image_url && (place.image_url.startsWith('data:') || place.image_url.startsWith('/api/maps/place-photo/'))) {
|
||||
// Base64 data URL thumbnails — no external image fetch during zoom
|
||||
// Only use base64 data URLs for markers — external URLs cause zoom lag
|
||||
if (place.image_url && place.image_url.startsWith('data:')) {
|
||||
const imgIcon = L.divIcon({
|
||||
className: '',
|
||||
html: `<div style="
|
||||
@@ -277,7 +277,6 @@ function RouteLabel({ midpoint, walkingText, drivingText }: RouteLabelProps) {
|
||||
|
||||
// Module-level photo cache shared with PlaceAvatar
|
||||
import { getCached, isLoading, fetchPhoto, onThumbReady, getAllThumbs } from '../../services/photoService'
|
||||
import { useAuthStore } from '../../store/authStore'
|
||||
|
||||
// Live location tracker — blue dot with pulse animation (like Apple/Google Maps)
|
||||
function LocationTracker() {
|
||||
@@ -369,35 +368,6 @@ function LocationTracker() {
|
||||
)
|
||||
}
|
||||
|
||||
interface MemoMarkerProps {
|
||||
place: any
|
||||
isSelected: boolean
|
||||
orderNumbers: number[] | null
|
||||
photoUrl: string | null
|
||||
onClickPlace: (id: number) => void
|
||||
onHover: (place: any, x: number, y: number) => void
|
||||
onHoverOut: () => void
|
||||
}
|
||||
|
||||
const MemoMarker = memo(function MemoMarker({
|
||||
place, isSelected, orderNumbers, photoUrl, onClickPlace, onHover, onHoverOut,
|
||||
}: MemoMarkerProps) {
|
||||
const icon = createPlaceIcon({ ...place, image_url: photoUrl }, orderNumbers, isSelected)
|
||||
return (
|
||||
<Marker
|
||||
position={[place.lat, place.lng]}
|
||||
icon={icon}
|
||||
eventHandlers={{
|
||||
click: () => onClickPlace(place.id),
|
||||
mouseover: (e: any) => onHover(place, e.originalEvent.clientX, e.originalEvent.clientY),
|
||||
mousemove: (e: any) => onHover(place, e.originalEvent.clientX, e.originalEvent.clientY),
|
||||
mouseout: onHoverOut,
|
||||
}}
|
||||
zIndexOffset={isSelected ? 1000 : 0}
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
||||
export const MapView = memo(function MapView({
|
||||
places = [],
|
||||
dayPlaces = [],
|
||||
@@ -437,51 +407,22 @@ export const MapView = memo(function MapView({
|
||||
return { paddingTopLeft: [left, top], paddingBottomRight: [right, bottom] }
|
||||
}, [leftWidth, rightWidth, hasInspector, hasDayDetail])
|
||||
|
||||
// Hover state for the single tooltip overlay (replaces per-marker <Tooltip>)
|
||||
const [hoveredPlace, setHoveredPlace] = useState<any>(null)
|
||||
const [tooltipPos, setTooltipPos] = useState<{ x: number; y: number } | null>(null)
|
||||
|
||||
const handleMarkerHover = useCallback((place: any, x: number, y: number) => {
|
||||
setHoveredPlace(place)
|
||||
setTooltipPos({ x, y })
|
||||
}, [])
|
||||
|
||||
const handleMarkerHoverOut = useCallback(() => {
|
||||
setHoveredPlace(null)
|
||||
}, [])
|
||||
|
||||
const handleMarkerClick = useCallback((id: number) => {
|
||||
onMarkerClick?.(id)
|
||||
}, [onMarkerClick])
|
||||
|
||||
// photoUrls: only base64 thumbs for smooth map zoom
|
||||
const [photoUrls, setPhotoUrls] = useState<Record<string, string>>(getAllThumbs)
|
||||
const placesPhotosEnabled = useAuthStore(s => s.placesPhotosEnabled)
|
||||
// Batch photo state updates through a RAF so N simultaneous photo loads
|
||||
// collapse into a single re-render instead of N separate renders.
|
||||
const pendingThumbsRef = useRef<Record<string, string>>({})
|
||||
const thumbRafRef = useRef<number | null>(null)
|
||||
|
||||
// Fetch photos via shared service — subscribe to thumb (base64) availability
|
||||
const placeIds = useMemo(() => places.map(p => p.id).join(','), [places])
|
||||
useEffect(() => {
|
||||
if (!places || places.length === 0 || !placesPhotosEnabled) return
|
||||
if (!places || places.length === 0) return
|
||||
const cleanups: (() => void)[] = []
|
||||
|
||||
const setThumb = (cacheKey: string, thumb: string) => {
|
||||
pendingThumbsRef.current[cacheKey] = thumb
|
||||
if (thumbRafRef.current !== null) return
|
||||
thumbRafRef.current = requestAnimationFrame(() => {
|
||||
thumbRafRef.current = null
|
||||
const pending = pendingThumbsRef.current
|
||||
pendingThumbsRef.current = {}
|
||||
setPhotoUrls(prev => {
|
||||
const hasChange = Object.entries(pending).some(([k, v]) => prev[k] !== v)
|
||||
return hasChange ? { ...prev, ...pending } : prev
|
||||
})
|
||||
})
|
||||
iconCache.clear()
|
||||
setPhotoUrls(prev => prev[cacheKey] === thumb ? prev : { ...prev, [cacheKey]: thumb })
|
||||
}
|
||||
|
||||
for (const place of places) {
|
||||
if (place.image_url && place.image_url.startsWith('data:')) continue
|
||||
const cacheKey = place.google_place_id || place.osm_id || `${place.lat},${place.lng}`
|
||||
if (!cacheKey) continue
|
||||
|
||||
@@ -491,24 +432,20 @@ export const MapView = memo(function MapView({
|
||||
continue
|
||||
}
|
||||
|
||||
// Subscribe for when thumb becomes available
|
||||
cleanups.push(onThumbReady(cacheKey, thumb => setThumb(cacheKey, thumb)))
|
||||
|
||||
// Always fetch through API — returns fresh URL + converts to base64
|
||||
if (!cached && !isLoading(cacheKey)) {
|
||||
const photoId = place.image_url || place.google_place_id || place.osm_id
|
||||
const photoId = place.google_place_id || place.osm_id
|
||||
if (photoId || (place.lat && place.lng)) {
|
||||
fetchPhoto(cacheKey, photoId || `coords:${place.lat}:${place.lng}`, place.lat, place.lng, place.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return () => {
|
||||
cleanups.forEach(fn => fn())
|
||||
if (thumbRafRef.current !== null) {
|
||||
cancelAnimationFrame(thumbRafRef.current)
|
||||
thumbRafRef.current = null
|
||||
}
|
||||
}
|
||||
}, [placeIds, placesPhotosEnabled])
|
||||
return () => cleanups.forEach(fn => fn())
|
||||
}, [placeIds])
|
||||
|
||||
const clusterIconCreateFunction = useCallback((cluster) => {
|
||||
const count = cluster.getChildCount()
|
||||
@@ -520,49 +457,57 @@ export const MapView = memo(function MapView({
|
||||
})
|
||||
}, [])
|
||||
|
||||
const isTouchDevice = typeof window !== 'undefined' && navigator.maxTouchPoints > 0
|
||||
const isTouchDevice = typeof window !== 'undefined' && ('ontouchstart' in window || navigator.maxTouchPoints > 0)
|
||||
|
||||
const markers = useMemo(() => places.map((place) => {
|
||||
const isSelected = place.id === selectedPlaceId
|
||||
const pck = place.google_place_id || place.osm_id || `${place.lat},${place.lng}`
|
||||
const photoUrl = (pck && photoUrls[pck]) || place.image_url || null
|
||||
const resolvedPhoto = (pck && photoUrls[pck]) || (place.image_url?.startsWith('data:') ? place.image_url : null) || null
|
||||
const orderNumbers = dayOrderMap[place.id] ?? null
|
||||
const icon = createPlaceIcon({ ...place, image_url: resolvedPhoto }, orderNumbers, isSelected)
|
||||
|
||||
return (
|
||||
<MemoMarker
|
||||
<Marker
|
||||
key={place.id}
|
||||
place={place}
|
||||
isSelected={isSelected}
|
||||
orderNumbers={orderNumbers}
|
||||
photoUrl={photoUrl}
|
||||
onClickPlace={handleMarkerClick}
|
||||
onHover={handleMarkerHover}
|
||||
onHoverOut={handleMarkerHoverOut}
|
||||
/>
|
||||
position={[place.lat, place.lng]}
|
||||
icon={icon}
|
||||
eventHandlers={{
|
||||
click: () => onMarkerClick && onMarkerClick(place.id),
|
||||
}}
|
||||
zIndexOffset={isSelected ? 1000 : 0}
|
||||
>
|
||||
<Tooltip
|
||||
direction="right"
|
||||
offset={[0, 0]}
|
||||
opacity={1}
|
||||
className="map-tooltip"
|
||||
permanent={isTouchDevice && isSelected}
|
||||
>
|
||||
<div style={{ fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif" }}>
|
||||
<div style={{ fontWeight: 600, fontSize: 12, color: 'var(--text-primary)', whiteSpace: 'nowrap' }}>
|
||||
{place.name}
|
||||
</div>
|
||||
{place.category_name && (() => {
|
||||
const CatIcon = getCategoryIcon(place.category_icon)
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 3, marginTop: 1 }}>
|
||||
<CatIcon size={10} style={{ color: place.category_color || 'var(--text-muted)', flexShrink: 0 }} />
|
||||
<span style={{ fontSize: 11, color: 'var(--text-muted)' }}>{place.category_name}</span>
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
{place.address && (
|
||||
<div style={{ fontSize: 11, color: 'var(--text-faint)', marginTop: 2, maxWidth: 180, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||||
{place.address}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Tooltip>
|
||||
</Marker>
|
||||
)
|
||||
}), [places, selectedPlaceId, dayOrderMap, photoUrls, handleMarkerClick, handleMarkerHover, handleMarkerHoverOut])
|
||||
|
||||
const gpxPolylines = useMemo(() => places.flatMap(place => {
|
||||
if (!place.route_geometry) return []
|
||||
try {
|
||||
const coords = JSON.parse(place.route_geometry) as [number, number][]
|
||||
if (!coords || coords.length < 2) return []
|
||||
return [(
|
||||
<Polyline
|
||||
key={`gpx-${place.id}`}
|
||||
positions={coords}
|
||||
color={place.category_color || '#3b82f6'}
|
||||
weight={3.5}
|
||||
opacity={0.75}
|
||||
/>
|
||||
)]
|
||||
} catch { return [] }
|
||||
}), [places])
|
||||
|
||||
const TooltipOverlay = hoveredPlace && tooltipPos && !isTouchDevice
|
||||
const CatIcon = TooltipOverlay ? getCategoryIcon(hoveredPlace.category_icon) : null
|
||||
}), [places, selectedPlaceId, dayOrderMap, photoUrls, onMarkerClick, isTouchDevice])
|
||||
|
||||
return (
|
||||
<>
|
||||
<MapContainer
|
||||
id="trek-map"
|
||||
center={center}
|
||||
@@ -603,18 +548,15 @@ export const MapView = memo(function MapView({
|
||||
{markers}
|
||||
</MarkerClusterGroup>
|
||||
|
||||
{route && route.length > 0 && (
|
||||
{route && route.length > 1 && (
|
||||
<>
|
||||
{route.map((seg, i) => seg.length > 1 && (
|
||||
<Polyline
|
||||
key={i}
|
||||
positions={seg}
|
||||
color="#111827"
|
||||
weight={3}
|
||||
opacity={0.9}
|
||||
dashArray="6, 5"
|
||||
/>
|
||||
))}
|
||||
<Polyline
|
||||
positions={route}
|
||||
color="#111827"
|
||||
weight={3}
|
||||
opacity={0.9}
|
||||
dashArray="6, 5"
|
||||
/>
|
||||
{routeSegments.map((seg, i) => (
|
||||
<RouteLabel key={i} midpoint={seg.mid} from={seg.from} to={seg.to} walkingText={seg.walkingText} drivingText={seg.drivingText} />
|
||||
))}
|
||||
@@ -622,7 +564,22 @@ export const MapView = memo(function MapView({
|
||||
)}
|
||||
|
||||
{/* GPX imported route geometries */}
|
||||
{gpxPolylines}
|
||||
{places.map((place) => {
|
||||
if (!place.route_geometry) return null
|
||||
try {
|
||||
const coords = JSON.parse(place.route_geometry) as [number, number][]
|
||||
if (!coords || coords.length < 2) return null
|
||||
return (
|
||||
<Polyline
|
||||
key={`gpx-${place.id}`}
|
||||
positions={coords}
|
||||
color={place.category_color || '#3b82f6'}
|
||||
weight={3.5}
|
||||
opacity={0.75}
|
||||
/>
|
||||
)
|
||||
} catch { return null }
|
||||
})}
|
||||
|
||||
<ReservationOverlay
|
||||
reservations={visibleReservations}
|
||||
@@ -631,38 +588,5 @@ export const MapView = memo(function MapView({
|
||||
onEndpointClick={onReservationClick}
|
||||
/>
|
||||
</MapContainer>
|
||||
|
||||
{TooltipOverlay && (
|
||||
<div data-testid="tooltip" style={{
|
||||
position: 'fixed',
|
||||
left: tooltipPos.x + 14,
|
||||
top: tooltipPos.y - 10,
|
||||
zIndex: 9999,
|
||||
pointerEvents: 'none',
|
||||
background: 'white',
|
||||
borderRadius: 8,
|
||||
boxShadow: '0 2px 10px rgba(0,0,0,0.15)',
|
||||
padding: '6px 10px',
|
||||
fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif",
|
||||
maxWidth: 220,
|
||||
whiteSpace: 'nowrap',
|
||||
}}>
|
||||
<div style={{ fontWeight: 600, fontSize: 12, color: '#111827', overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||||
{hoveredPlace.name}
|
||||
</div>
|
||||
{hoveredPlace.category_name && CatIcon && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 3, marginTop: 1 }}>
|
||||
<CatIcon size={10} style={{ color: hoveredPlace.category_color || '#6b7280', flexShrink: 0 }} />
|
||||
<span style={{ fontSize: 11, color: '#6b7280' }}>{hoveredPlace.category_name}</span>
|
||||
</div>
|
||||
)}
|
||||
{hoveredPlace.address && (
|
||||
<div style={{ fontSize: 11, color: '#9ca3af', marginTop: 2, overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||||
{hoveredPlace.address}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
})
|
||||
|
||||
@@ -308,7 +308,7 @@ export async function downloadJourneyBookPDF(journey: JourneyDetail) {
|
||||
|
||||
const iframe = document.createElement('iframe')
|
||||
iframe.style.cssText = 'flex:1;width:100%;border:none;'
|
||||
iframe.sandbox = 'allow-same-origin allow-modals allow-scripts'
|
||||
iframe.sandbox = 'allow-same-origin allow-modals'
|
||||
iframe.srcdoc = html
|
||||
|
||||
card.appendChild(header)
|
||||
|
||||
@@ -521,7 +521,7 @@ ${daysHtml}
|
||||
|
||||
const iframe = document.createElement('iframe')
|
||||
iframe.style.cssText = 'flex:1;width:100%;border:none;'
|
||||
iframe.sandbox = 'allow-same-origin allow-modals allow-scripts'
|
||||
iframe.sandbox = 'allow-same-origin allow-modals'
|
||||
iframe.srcdoc = html
|
||||
|
||||
card.appendChild(header)
|
||||
|
||||
@@ -729,11 +729,9 @@ function MenuItem({ icon, label, onClick, danger }: MenuItemProps) {
|
||||
interface PackingListPanelProps {
|
||||
tripId: number
|
||||
items: PackingItem[]
|
||||
openImportSignal?: number
|
||||
inlineHeader?: boolean
|
||||
}
|
||||
|
||||
export default function PackingListPanel({ tripId, items, openImportSignal = 0, inlineHeader = true }: PackingListPanelProps) {
|
||||
export default function PackingListPanel({ tripId, items }: PackingListPanelProps) {
|
||||
const [filter, setFilter] = useState('alle') // 'alle' | 'offen' | 'erledigt'
|
||||
const [addingCategory, setAddingCategory] = useState(false)
|
||||
const [newCatName, setNewCatName] = useState('')
|
||||
@@ -898,14 +896,6 @@ export default function PackingListPanel({ tripId, items, openImportSignal = 0,
|
||||
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)
|
||||
|
||||
@@ -1009,34 +999,15 @@ export default function PackingListPanel({ tripId, items, openImportSignal = 0,
|
||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', ...font }}>
|
||||
|
||||
{/* ── Header ── */}
|
||||
<div style={{ padding: inlineHeader ? '20px 24px 16px' : '0 0 16px', flexShrink: 0, borderBottom: inlineHeader ? '1px solid rgba(0,0,0,0.06)' : undefined }}>
|
||||
<div style={{ display: 'flex', alignItems: inlineHeader ? 'flex-start' : 'center', justifyContent: 'space-between', gap: 14 }}>
|
||||
{inlineHeader ? (
|
||||
<div>
|
||||
<h2 style={{ margin: 0, fontSize: 18, fontWeight: 700, color: 'var(--text-primary)' }}>{t('packing.title')}</h2>
|
||||
{items.length > 0 && (
|
||||
<p style={{ margin: '2px 0 0', fontSize: 12.5, color: 'var(--text-faint)' }}>
|
||||
{t('packing.progress', { packed: abgehakt, total: items.length, percent: fortschritt })}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
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>
|
||||
) : <span />
|
||||
)}
|
||||
<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 })}
|
||||
</p>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 6 }}>
|
||||
{inlineHeader && 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 && abgehakt > 0 && (
|
||||
<button onClick={handleClearChecked} style={{
|
||||
fontSize: 11.5, padding: '5px 10px', borderRadius: 99, border: '1px solid rgba(239,68,68,0.3)',
|
||||
@@ -1046,6 +1017,15 @@ export default function PackingListPanel({ tripId, items, openImportSignal = 0,
|
||||
<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={{
|
||||
@@ -1171,7 +1151,7 @@ export default function PackingListPanel({ tripId, items, openImportSignal = 0,
|
||||
|
||||
{/* ── Filter-Tabs ── */}
|
||||
{items.length > 0 && (
|
||||
<div style={{ display: 'flex', gap: 4, padding: '10px 0 0', flexShrink: 0 }}>
|
||||
<div style={{ display: 'flex', gap: 4, padding: '10px 16px 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',
|
||||
@@ -1185,7 +1165,7 @@ export default function PackingListPanel({ tripId, items, openImportSignal = 0,
|
||||
|
||||
{/* ── Liste + Bags Sidebar ── */}
|
||||
<div style={{ flex: 1, display: 'flex', overflow: 'hidden' }}>
|
||||
<div style={{ flex: 1, overflowY: 'auto', padding: '10px 0 16px' }}>
|
||||
<div style={{ flex: 1, overflowY: 'auto', padding: '10px 12px 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' }} />
|
||||
|
||||
@@ -440,27 +440,26 @@ describe('DayPlanSidebar', () => {
|
||||
type: 'flight',
|
||||
title: 'Paris to London',
|
||||
reservation_time: '2025-06-01T08:00:00',
|
||||
day_id: 10,
|
||||
})
|
||||
render(<DayPlanSidebar {...makeDefaultProps({ days: [day], reservations: [reservation] })} />)
|
||||
expect(screen.getByText('Paris to London')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-PLANNER-DAYPLAN-031: clicking transport item calls onEditTransport', async () => {
|
||||
it('FE-PLANNER-DAYPLAN-031: clicking transport item shows detail modal', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onEditTransport = vi.fn()
|
||||
const day = buildDay({ id: 10, date: '2025-06-01', title: 'Travel Day' })
|
||||
const reservation = buildReservation({
|
||||
id: 200,
|
||||
type: 'flight',
|
||||
title: 'Air France 123',
|
||||
reservation_time: '2025-06-01T08:00:00',
|
||||
day_id: 10,
|
||||
})
|
||||
render(<DayPlanSidebar {...makeDefaultProps({ days: [day], reservations: [reservation], onEditTransport })} />)
|
||||
render(<DayPlanSidebar {...makeDefaultProps({ days: [day], reservations: [reservation] })} />)
|
||||
await user.click(screen.getByText('Air France 123'))
|
||||
// Detail modal should appear (shows the title again in the modal)
|
||||
await waitFor(() => {
|
||||
expect(onEditTransport).toHaveBeenCalledWith(expect.objectContaining({ id: 200 }))
|
||||
const titles = screen.getAllByText('Air France 123')
|
||||
expect(titles.length).toBeGreaterThan(1)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -665,7 +664,6 @@ describe('DayPlanSidebar', () => {
|
||||
const reservation = buildReservation({
|
||||
id: 200, type: 'flight', title: 'CDG to LHR',
|
||||
reservation_time: '2025-06-01T08:00:00',
|
||||
day_id: 10,
|
||||
})
|
||||
render(<DayPlanSidebar {...makeDefaultProps({
|
||||
days: [day],
|
||||
@@ -686,8 +684,6 @@ describe('DayPlanSidebar', () => {
|
||||
id: 201, type: 'flight', title: 'Transatlantic',
|
||||
reservation_time: '2025-06-01T22:00:00',
|
||||
reservation_end_time: '2025-06-02T06:00:00',
|
||||
day_id: 10,
|
||||
end_day_id: 11,
|
||||
} as any)
|
||||
render(<DayPlanSidebar {...makeDefaultProps({
|
||||
days: [day1, day2],
|
||||
@@ -708,8 +704,6 @@ describe('DayPlanSidebar', () => {
|
||||
id: 300, type: 'car', title: 'Renault Rental',
|
||||
reservation_time: '2025-06-01T09:00:00',
|
||||
reservation_end_time: '2025-06-03T17:00:00',
|
||||
day_id: 10,
|
||||
end_day_id: 12,
|
||||
} as any)
|
||||
render(<DayPlanSidebar {...makeDefaultProps({
|
||||
days: [day1, day2, day3],
|
||||
@@ -792,22 +786,20 @@ describe('DayPlanSidebar', () => {
|
||||
|
||||
// ── Transport detail modal with metadata ───────────────────────────────
|
||||
|
||||
it('FE-PLANNER-DAYPLAN-051: clicking flight transport calls onEditTransport with reservation', async () => {
|
||||
it('FE-PLANNER-DAYPLAN-051: transport detail modal shows flight metadata', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onEditTransport = vi.fn()
|
||||
const day = buildDay({ id: 10, date: '2025-06-01', title: 'Travel' })
|
||||
const reservation = {
|
||||
...buildReservation({
|
||||
id: 202, type: 'flight', title: 'Paris to Berlin',
|
||||
reservation_time: '2025-06-01T07:30:00',
|
||||
day_id: 10,
|
||||
}),
|
||||
metadata: JSON.stringify({ airline: 'Lufthansa', flight_number: 'LH1234', departure_airport: 'CDG', arrival_airport: 'BER' }),
|
||||
}
|
||||
render(<DayPlanSidebar {...makeDefaultProps({ days: [day], reservations: [reservation as any], onEditTransport })} />)
|
||||
render(<DayPlanSidebar {...makeDefaultProps({ days: [day], reservations: [reservation as any] })} />)
|
||||
await user.click(screen.getByText('Paris to Berlin'))
|
||||
await waitFor(() => {
|
||||
expect(onEditTransport).toHaveBeenCalledWith(expect.objectContaining({ id: 202, type: 'flight' }))
|
||||
expect(screen.getByText('Lufthansa')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1132,7 +1124,6 @@ describe('DayPlanSidebar', () => {
|
||||
const flight = buildReservation({
|
||||
id: 201, type: 'flight', title: 'Afternoon Flight',
|
||||
reservation_time: '2025-06-01T14:00:00',
|
||||
day_id: 10,
|
||||
})
|
||||
render(<DayPlanSidebar {...makeDefaultProps({
|
||||
days: [day], places: [place], assignments: { '10': [assignment] }, reservations: [flight],
|
||||
@@ -1692,42 +1683,4 @@ describe('DayPlanSidebar', () => {
|
||||
// Optimize button should not be visible when no day is selected
|
||||
expect(screen.queryByRole('button', { name: /optimize/i })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
// ── Edit reservation pencil button ───────────────────────────────────────
|
||||
|
||||
it('FE-PLANNER-DAYPLAN-097: pencil button on non-transport reservation calls onEditReservation', async () => {
|
||||
const user = userEvent.setup()
|
||||
const place = buildPlace({ id: 1, name: 'Hotel du Lac' })
|
||||
const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' })
|
||||
const assignment = buildAssignment({ id: 99, day_id: 10, order_index: 0, place })
|
||||
const res = buildReservation({ id: 77, trip_id: 1, type: 'hotel', status: 'pending', assignment_id: 99 } as any)
|
||||
const onEditReservation = vi.fn()
|
||||
const onEditTransport = vi.fn()
|
||||
render(<DayPlanSidebar {...makeDefaultProps({
|
||||
days: [day], places: [place], assignments: { '10': [assignment] }, reservations: [res],
|
||||
onEditReservation, onEditTransport,
|
||||
})} />)
|
||||
const pencil = screen.getByTitle(/edit/i)
|
||||
await user.click(pencil)
|
||||
expect(onEditReservation).toHaveBeenCalledWith(res)
|
||||
expect(onEditTransport).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-PLANNER-DAYPLAN-098: pencil button on transport reservation calls onEditTransport', async () => {
|
||||
const user = userEvent.setup()
|
||||
const place = buildPlace({ id: 1, name: 'Geneva Airport' })
|
||||
const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' })
|
||||
const assignment = buildAssignment({ id: 99, day_id: 10, order_index: 0, place })
|
||||
const res = buildReservation({ id: 88, trip_id: 1, type: 'flight', status: 'pending', assignment_id: 99 } as any)
|
||||
const onEditReservation = vi.fn()
|
||||
const onEditTransport = vi.fn()
|
||||
render(<DayPlanSidebar {...makeDefaultProps({
|
||||
days: [day], places: [place], assignments: { '10': [assignment] }, reservations: [res],
|
||||
onEditReservation, onEditTransport,
|
||||
})} />)
|
||||
const pencil = screen.getByTitle(/edit/i)
|
||||
await user.click(pencil)
|
||||
expect(onEditTransport).toHaveBeenCalledWith(res)
|
||||
expect(onEditReservation).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/* eslint-disable @typescript-eslint/no-unnecessary-type-assertion */
|
||||
interface DragDataPayload { placeId?: string; assignmentId?: string; noteId?: string; reservationId?: string; fromDayId?: string; phase?: 'single' | 'start' | 'middle' | 'end' }
|
||||
interface DragDataPayload { placeId?: string; assignmentId?: string; noteId?: string; fromDayId?: string }
|
||||
declare global { interface Window { __dragData: DragDataPayload | null } }
|
||||
|
||||
import React, { useState, useEffect, useRef, useMemo } from 'react'
|
||||
@@ -183,11 +183,6 @@ interface DayPlanSidebarProps {
|
||||
canUndo?: boolean
|
||||
lastActionLabel?: string | null
|
||||
onUndo?: () => void
|
||||
onRouteRefresh?: () => void
|
||||
onAddTransport?: (dayId: number) => void
|
||||
onEditTransport?: (reservation: Reservation) => void
|
||||
onEditReservation?: (reservation: Reservation) => void
|
||||
onAddBookingToAssignment?: (dayId: number, assignmentId: number) => void
|
||||
}
|
||||
|
||||
const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
||||
@@ -211,11 +206,6 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
||||
canUndo = false,
|
||||
lastActionLabel = null,
|
||||
onUndo,
|
||||
onRouteRefresh,
|
||||
onAddTransport,
|
||||
onEditTransport,
|
||||
onEditReservation,
|
||||
onAddBookingToAssignment,
|
||||
}: DayPlanSidebarProps) {
|
||||
const toast = useToast()
|
||||
const { t, language, locale } = useTranslation()
|
||||
@@ -245,11 +235,11 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
||||
const [undoHover, setUndoHover] = useState(false)
|
||||
const [pdfHover, setPdfHover] = useState(false)
|
||||
const [icsHover, setIcsHover] = useState(false)
|
||||
const [hoveredAssignmentId, setHoveredAssignmentId] = useState<number | null>(null)
|
||||
const [dropTargetKey, _setDropTargetKey] = useState(null)
|
||||
const dropTargetRef = useRef(null)
|
||||
const setDropTargetKey = (key) => { dropTargetRef.current = key; _setDropTargetKey(key) }
|
||||
const [dragOverDayId, setDragOverDayId] = useState(null)
|
||||
const [hoveredId, setHoveredId] = useState(null)
|
||||
const [transportDetail, setTransportDetail] = useState(null)
|
||||
const [transportPosVersion, setTransportPosVersion] = useState(0)
|
||||
|
||||
@@ -275,21 +265,19 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
||||
// Drag-Daten aus dataTransfer, Ref oder window lesen (dataTransfer geht bei Re-Render verloren)
|
||||
const getDragData = (e) => {
|
||||
const dt = e?.dataTransfer
|
||||
// Interner Drag hat Vorrang (Ref wird nur bei assignmentId/noteId/reservationId gesetzt)
|
||||
// Interner Drag hat Vorrang (Ref wird nur bei assignmentId/noteId gesetzt)
|
||||
if (dragDataRef.current) {
|
||||
return {
|
||||
placeId: '',
|
||||
assignmentId: dragDataRef.current.assignmentId || '',
|
||||
noteId: dragDataRef.current.noteId || '',
|
||||
reservationId: dragDataRef.current.reservationId || '',
|
||||
fromDayId: parseInt(dragDataRef.current.fromDayId) || 0,
|
||||
phase: (dragDataRef.current.phase || 'single') as 'single' | 'start' | 'middle' | 'end',
|
||||
}
|
||||
}
|
||||
// Externer Drag (aus PlacesSidebar)
|
||||
const ext = window.__dragData || {}
|
||||
const placeId = dt?.getData('placeId') || ext.placeId || ''
|
||||
return { placeId, assignmentId: '', noteId: '', reservationId: '', fromDayId: 0, phase: 'single' as const }
|
||||
return { placeId, assignmentId: '', noteId: '', fromDayId: 0 }
|
||||
}
|
||||
|
||||
// Only auto-expand genuinely new days (not on initial load from storage)
|
||||
@@ -336,19 +324,26 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
||||
|
||||
const TRANSPORT_TYPES = new Set(['flight', 'train', 'bus', 'car', 'cruise'])
|
||||
|
||||
// Get span phase: how a reservation relates to a specific day (by id)
|
||||
const getSpanPhase = (r: Reservation, dayId: number): 'single' | 'start' | 'middle' | 'end' => {
|
||||
const startDayId = r.day_id
|
||||
const endDayId = r.end_day_id ?? startDayId
|
||||
if (!startDayId || startDayId === endDayId) return 'single'
|
||||
if (dayId === startDayId) return 'start'
|
||||
if (dayId === endDayId) return 'end'
|
||||
// Determine if a reservation's end_time represents a different date (multi-day)
|
||||
const getEndDate = (r: Reservation) => {
|
||||
const endStr = r.reservation_end_time || ''
|
||||
return endStr.includes('T') ? endStr.split('T')[0] : null
|
||||
}
|
||||
|
||||
// Get span phase: how a reservation relates to a specific day's date
|
||||
const getSpanPhase = (r: Reservation, dayDate: string): 'single' | 'start' | 'middle' | 'end' => {
|
||||
if (!r.reservation_time) return 'single'
|
||||
const startDate = r.reservation_time.split('T')[0]
|
||||
const endDate = getEndDate(r) || startDate
|
||||
if (startDate === endDate) return 'single'
|
||||
if (dayDate === startDate) return 'start'
|
||||
if (dayDate === endDate) return 'end'
|
||||
return 'middle'
|
||||
}
|
||||
|
||||
// Get the appropriate display time for a reservation on a specific day
|
||||
const getDisplayTimeForDay = (r: Reservation, dayId: number): string | null => {
|
||||
const phase = getSpanPhase(r, dayId)
|
||||
const getDisplayTimeForDay = (r: Reservation, dayDate: string): string | null => {
|
||||
const phase = getSpanPhase(r, dayDate)
|
||||
if (phase === 'end') return r.reservation_end_time || null
|
||||
if (phase === 'middle') return null
|
||||
return r.reservation_time || null
|
||||
@@ -362,56 +357,36 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
||||
return t(`reservations.span.${phase === 'start' ? 'start' : phase === 'end' ? 'end' : 'ongoing'}`)
|
||||
}
|
||||
|
||||
const getDayOrder = (day: (typeof days)[number]) => (day as any).day_number ?? days.indexOf(day)
|
||||
|
||||
const computeMultiDayMove = (r: Reservation, targetDayId: number, phase: 'single' | 'start' | 'middle' | 'end') => {
|
||||
const startId = r.day_id ?? targetDayId
|
||||
const endId = r.end_day_id ?? startId
|
||||
const order = (id: number) => { const d = days.find(x => x.id === id); return d ? getDayOrder(d) : 0 }
|
||||
if (phase === 'single' || startId === endId) return { day_id: targetDayId, end_day_id: targetDayId }
|
||||
if (phase === 'start') {
|
||||
if (order(targetDayId) > order(endId)) return { day_id: targetDayId, end_day_id: targetDayId }
|
||||
return { day_id: targetDayId, end_day_id: endId }
|
||||
}
|
||||
// phase === 'end'
|
||||
if (order(targetDayId) < order(startId)) return { day_id: targetDayId, end_day_id: targetDayId }
|
||||
return { day_id: startId, end_day_id: targetDayId }
|
||||
}
|
||||
|
||||
const getTransportForDay = (dayId: number) => {
|
||||
const day = days.find(d => d.id === dayId)
|
||||
if (!day?.date) return []
|
||||
const dayAssignmentIds = (assignments[String(dayId)] || []).map(a => a.id)
|
||||
return reservations.filter(r => {
|
||||
if (r.type === 'hotel') return false
|
||||
if (!r.reservation_time || r.type === 'hotel') return false
|
||||
if (r.assignment_id && dayAssignmentIds.includes(r.assignment_id)) return false
|
||||
const startDate = r.reservation_time.split('T')[0]
|
||||
const endDate = getEndDate(r)
|
||||
|
||||
const startDayId = r.day_id
|
||||
const endDayId = r.end_day_id ?? startDayId
|
||||
|
||||
if (startDayId == null) return false
|
||||
|
||||
if (endDayId !== startDayId) {
|
||||
const startDay = days.find(d => d.id === startDayId)
|
||||
const endDay = days.find(d => d.id === endDayId)
|
||||
const thisDay = days.find(d => d.id === dayId)
|
||||
if (!startDay || !endDay || !thisDay) return false
|
||||
return getDayOrder(thisDay) >= getDayOrder(startDay) && getDayOrder(thisDay) <= getDayOrder(endDay)
|
||||
if (endDate && endDate !== startDate) {
|
||||
// Multi-day: show on any day in range (car middle handled elsewhere)
|
||||
return day.date >= startDate && day.date <= endDate
|
||||
} else {
|
||||
// Single-day: show all non-hotel reservations that match this day's date
|
||||
return startDate === day.date
|
||||
}
|
||||
return startDayId === dayId
|
||||
})
|
||||
}
|
||||
|
||||
// Get car rentals that are in "active" (middle) phase for a day — shown in day header, not timeline
|
||||
const getActiveRentalsForDay = (dayId: number) => {
|
||||
const day = days.find(d => d.id === dayId)
|
||||
if (!day?.date) return []
|
||||
return reservations.filter(r => {
|
||||
if (r.type !== 'car') return false
|
||||
const startDayId = r.day_id
|
||||
const endDayId = r.end_day_id
|
||||
if (!startDayId || !endDayId || endDayId === startDayId) return false
|
||||
const startDay = days.find(d => d.id === startDayId)
|
||||
const endDay = days.find(d => d.id === endDayId)
|
||||
const thisDay = days.find(d => d.id === dayId)
|
||||
if (!startDay || !endDay || !thisDay) return false
|
||||
return getDayOrder(thisDay) > getDayOrder(startDay) && getDayOrder(thisDay) < getDayOrder(endDay)
|
||||
if (r.type !== 'car' || !r.reservation_time) return false
|
||||
const startDate = r.reservation_time.split('T')[0]
|
||||
const endDate = getEndDate(r)
|
||||
if (!endDate || endDate === startDate) return false
|
||||
return day.date > startDate && day.date < endDate
|
||||
})
|
||||
}
|
||||
|
||||
@@ -460,15 +435,11 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
||||
day_plan_position: computeTransportPosition(r, da) + idx * 0.01,
|
||||
}))
|
||||
// Mark as initialized immediately to prevent re-entry
|
||||
for (const p of positions) initedTransportIds.current.add(p.id)
|
||||
// Update store so subscribers see the new positions
|
||||
useTripStore.setState(state => ({
|
||||
reservations: state.reservations.map(r => {
|
||||
const p = positions.find(x => x.id === r.id)
|
||||
if (!p) return r
|
||||
return { ...r, day_plan_position: p.day_plan_position }
|
||||
})
|
||||
}))
|
||||
for (const p of positions) {
|
||||
initedTransportIds.current.add(p.id)
|
||||
const res = reservations.find(x => x.id === p.id)
|
||||
if (res) res.day_plan_position = p.day_plan_position
|
||||
}
|
||||
// Persist to server (fire and forget)
|
||||
reservationsApi.updatePositions(tripId, positions).catch(() => {})
|
||||
}
|
||||
@@ -477,6 +448,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
||||
const da = getDayAssignments(dayId)
|
||||
const dn = (dayNotes[String(dayId)] || []).slice().sort((a, b) => a.sort_order - b.sort_order)
|
||||
const transport = getTransportForDay(dayId)
|
||||
const dayDate = days.find(d => d.id === dayId)?.date || ''
|
||||
|
||||
// Initialize positions for transports that don't have one yet
|
||||
if (transport.some(r => r.day_plan_position == null)) {
|
||||
@@ -493,7 +465,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
||||
const timedTransports = transport.map(r => ({
|
||||
type: 'transport' as const,
|
||||
data: r,
|
||||
minutes: parseTimeToMinutes(getDisplayTimeForDay(r, dayId)) ?? 0,
|
||||
minutes: parseTimeToMinutes(getDisplayTimeForDay(r, dayDate)) ?? 0,
|
||||
})).sort((a, b) => a.minutes - b.minutes)
|
||||
|
||||
if (timedTransports.length === 0) return baseItems
|
||||
@@ -635,27 +607,23 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
||||
}
|
||||
|
||||
try {
|
||||
// Update transport positions in store FIRST so the useEffect triggered by
|
||||
// onReorder's optimistic assignment update reads the correct positions.
|
||||
if (transportUpdates.length) {
|
||||
useTripStore.setState(state => ({
|
||||
reservations: state.reservations.map(r => {
|
||||
const tu = transportUpdates.find(u => u.id === r.id)
|
||||
if (!tu) return r
|
||||
const day_positions = { ...(r.day_positions || {}), [dayId]: tu.day_plan_position }
|
||||
return { ...r, day_plan_position: tu.day_plan_position, day_positions }
|
||||
})
|
||||
}))
|
||||
setTransportPosVersion(v => v + 1)
|
||||
}
|
||||
if (assignmentIds.length) await onReorder(dayId, assignmentIds)
|
||||
if (transportUpdates.length) {
|
||||
onRouteRefresh?.()
|
||||
await reservationsApi.updatePositions(tripId, transportUpdates, dayId)
|
||||
}
|
||||
for (const n of noteUpdates) {
|
||||
await tripActions.updateDayNote(tripId, dayId, n.id, { sort_order: n.sort_order })
|
||||
}
|
||||
if (transportUpdates.length) {
|
||||
for (const tu of transportUpdates) {
|
||||
const res = reservations.find(r => r.id === tu.id)
|
||||
if (res) {
|
||||
res.day_plan_position = tu.day_plan_position
|
||||
// Update per-day position for multi-day reservations
|
||||
if (!res.day_positions) res.day_positions = {}
|
||||
res.day_positions[dayId] = tu.day_plan_position
|
||||
}
|
||||
}
|
||||
setTransportPosVersion(v => v + 1)
|
||||
await reservationsApi.updatePositions(tripId, transportUpdates, dayId)
|
||||
}
|
||||
if (prevAssignmentIds.length) {
|
||||
const capturedDayId = dayId
|
||||
const capturedPrevIds = prevAssignmentIds
|
||||
@@ -667,6 +635,13 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
||||
}
|
||||
|
||||
const handleMergedDrop = async (dayId, fromType, fromId, toType, toId, insertAfter = false) => {
|
||||
// Transport bookings themselves cannot be dragged
|
||||
if (fromType === 'transport') {
|
||||
toast.error(t('dayplan.cannotReorderTransport'))
|
||||
setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null
|
||||
return
|
||||
}
|
||||
|
||||
const m = getMergedItems(dayId)
|
||||
|
||||
// Check if a timed place is being moved → would it break chronological order?
|
||||
@@ -879,12 +854,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setDragOverDayId(null)
|
||||
const { placeId, assignmentId, noteId, reservationId: fromReservationId, fromDayId, phase } = getDragData(e)
|
||||
if (fromReservationId && fromDayId !== dayId) {
|
||||
const r = reservations.find(x => x.id === Number(fromReservationId))
|
||||
if (r) { const update = computeMultiDayMove(r, dayId, phase); tripActions.updateReservation(tripId, r.id, update).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError'))) }
|
||||
setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null; window.__dragData = null; return
|
||||
}
|
||||
const { placeId, assignmentId, noteId, fromDayId } = getDragData(e)
|
||||
if (placeId) {
|
||||
onAssignToDay?.(parseInt(placeId), dayId)
|
||||
} else if (assignmentId && fromDayId !== dayId) {
|
||||
@@ -1142,27 +1112,6 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
||||
>
|
||||
<Pencil size={15} strokeWidth={1.8} color="var(--text-secondary)" />
|
||||
</button>}
|
||||
{canEditDays && onAddTransport && (
|
||||
<button
|
||||
onClick={e => { e.stopPropagation(); onAddTransport(day.id) }}
|
||||
title={t('transport.addTransport')}
|
||||
style={{
|
||||
flexShrink: 0,
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
padding: '4px',
|
||||
cursor: 'pointer',
|
||||
opacity: 0.45,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
borderRadius: 4,
|
||||
}}
|
||||
onMouseEnter={e => { (e.currentTarget as HTMLElement).style.opacity = '1' }}
|
||||
onMouseLeave={e => { (e.currentTarget as HTMLElement).style.opacity = '0.45' }}
|
||||
>
|
||||
<Plus size={15} strokeWidth={1.8} color="var(--text-secondary)" />
|
||||
</button>
|
||||
)}
|
||||
{(() => {
|
||||
const dayAccs = accommodations.filter(a => day.id >= a.start_day_id && day.id <= a.end_day_id)
|
||||
// Sort: check-out first, then ongoing stays, then check-in last
|
||||
@@ -1242,7 +1191,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
||||
onDrop={e => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
const { placeId, assignmentId, noteId, reservationId: fromReservationId, fromDayId, phase } = getDragData(e)
|
||||
const { placeId, assignmentId, noteId, fromDayId } = getDragData(e)
|
||||
// Drop on transport card (detected via dropTargetRef for sync accuracy)
|
||||
if (dropTargetRef.current?.startsWith('transport-')) {
|
||||
const isAfter = dropTargetRef.current.startsWith('transport-after-')
|
||||
@@ -1251,11 +1200,6 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
||||
|
||||
if (placeId) {
|
||||
onAssignToDay?.(parseInt(placeId), day.id)
|
||||
} else if (fromReservationId && fromDayId !== day.id) {
|
||||
const r = reservations.find(x => x.id === Number(fromReservationId))
|
||||
if (r) { const update = computeMultiDayMove(r, day.id, phase); tripActions.updateReservation(tripId, r.id, update).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError'))) }
|
||||
} else if (fromReservationId) {
|
||||
handleMergedDrop(day.id, 'transport', Number(fromReservationId), 'transport', transportId, isAfter)
|
||||
} else if (assignmentId && fromDayId !== day.id) {
|
||||
tripActions.moveAssignment(tripId, Number(assignmentId), fromDayId, day.id).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError')))
|
||||
} else if (assignmentId) {
|
||||
@@ -1269,11 +1213,6 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
||||
return
|
||||
}
|
||||
|
||||
if (fromReservationId && fromDayId !== day.id) {
|
||||
const r = reservations.find(x => x.id === Number(fromReservationId))
|
||||
if (r) { const update = computeMultiDayMove(r, day.id, phase); tripActions.updateReservation(tripId, r.id, update).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError'))) }
|
||||
setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null; return
|
||||
}
|
||||
if (!assignmentId && !noteId && !placeId) { dragDataRef.current = null; window.__dragData = null; return }
|
||||
if (placeId) {
|
||||
onAssignToDay?.(parseInt(placeId), day.id)
|
||||
@@ -1320,6 +1259,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
||||
const cat = categories.find(c => c.id === place.category_id)
|
||||
const isPlaceSelected = selectedAssignmentId ? assignment.id === selectedAssignmentId : place.id === selectedPlaceId
|
||||
const isDraggingThis = draggingId === assignment.id
|
||||
const isHovered = hoveredId === assignment.id
|
||||
const placeIdx = placeItems.findIndex(i => i.data.id === assignment.id)
|
||||
|
||||
const arrowMove = (direction: 'up' | 'down') => {
|
||||
@@ -1372,17 +1312,11 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
||||
onDragOver={e => { e.preventDefault(); e.stopPropagation(); setDragOverDayId(null); if (dropTargetKey !== `place-${assignment.id}`) setDropTargetKey(`place-${assignment.id}`) }}
|
||||
onDrop={e => {
|
||||
e.preventDefault(); e.stopPropagation()
|
||||
const { placeId, assignmentId: fromAssignmentId, noteId, reservationId: fromReservationId, fromDayId, phase } = getDragData(e)
|
||||
const { placeId, assignmentId: fromAssignmentId, noteId, fromDayId } = getDragData(e)
|
||||
if (placeId) {
|
||||
const pos = placeItems.findIndex(i => i.data.id === assignment.id)
|
||||
onAssignToDay?.(parseInt(placeId), day.id, pos >= 0 ? pos : undefined)
|
||||
setDropTargetKey(null); window.__dragData = null
|
||||
} else if (fromReservationId && fromDayId !== day.id) {
|
||||
const r = reservations.find(x => x.id === Number(fromReservationId))
|
||||
if (r) { const update = computeMultiDayMove(r, day.id, phase); tripActions.updateReservation(tripId, r.id, update).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError'))) }
|
||||
setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null
|
||||
} else if (fromReservationId) {
|
||||
handleMergedDrop(day.id, 'transport', Number(fromReservationId), 'place', assignment.id)
|
||||
} else if (fromAssignmentId && fromDayId !== day.id) {
|
||||
const toIdx = getDayAssignments(day.id).findIndex(a => a.id === assignment.id)
|
||||
tripActions.moveAssignment(tripId, Number(fromAssignmentId), fromDayId, day.id, toIdx).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError')))
|
||||
@@ -1409,27 +1343,15 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
||||
{ divider: true },
|
||||
canEditDays && onDeletePlace && { label: t('common.delete'), icon: Trash2, danger: true, onClick: () => onDeletePlace(place.id) },
|
||||
])}
|
||||
onMouseEnter={e => {
|
||||
if (!isPlaceSelected && !lockedIds.has(assignment.id))
|
||||
e.currentTarget.style.background = 'var(--bg-hover)'
|
||||
const grip = e.currentTarget.querySelector('.dp-grip') as HTMLElement | null
|
||||
if (grip) grip.style.opacity = '1'
|
||||
setHoveredAssignmentId(assignment.id)
|
||||
}}
|
||||
onMouseLeave={e => {
|
||||
if (!isPlaceSelected && !lockedIds.has(assignment.id))
|
||||
e.currentTarget.style.background = 'transparent'
|
||||
const grip = e.currentTarget.querySelector('.dp-grip') as HTMLElement | null
|
||||
if (grip) grip.style.opacity = '0.3'
|
||||
setHoveredAssignmentId(null)
|
||||
}}
|
||||
onMouseEnter={() => setHoveredId(assignment.id)}
|
||||
onMouseLeave={() => setHoveredId(null)}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 8,
|
||||
padding: '7px 8px 7px 10px',
|
||||
cursor: 'pointer',
|
||||
background: lockedIds.has(assignment.id)
|
||||
? 'rgba(220,38,38,0.08)'
|
||||
: isPlaceSelected ? 'var(--bg-hover)' : 'transparent',
|
||||
: isPlaceSelected ? 'var(--bg-hover)' : (isHovered ? 'var(--bg-hover)' : 'transparent'),
|
||||
borderLeft: lockedIds.has(assignment.id)
|
||||
? '3px solid #dc2626'
|
||||
: '3px solid transparent',
|
||||
@@ -1437,7 +1359,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
||||
opacity: isDraggingThis ? 0.4 : 1,
|
||||
}}
|
||||
>
|
||||
{canEditDays && <div className="dp-grip" style={{ flexShrink: 0, color: 'var(--text-faint)', display: 'flex', alignItems: 'center', opacity: 0.3, transition: 'opacity 0.15s', cursor: 'grab' }}>
|
||||
{canEditDays && <div style={{ flexShrink: 0, color: 'var(--text-faint)', display: 'flex', alignItems: 'center', opacity: isHovered ? 1 : 0.3, transition: 'opacity 0.15s', cursor: 'grab' }}>
|
||||
<GripVertical size={13} strokeWidth={1.8} />
|
||||
</div>}
|
||||
<div
|
||||
@@ -1498,74 +1420,26 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
||||
const res = reservations.find(r => r.assignment_id === assignment.id)
|
||||
if (!res) return null
|
||||
const confirmed = res.status === 'confirmed'
|
||||
const hasEndpoints = onToggleConnection && (res.endpoints || []).length >= 2
|
||||
const active = hasEndpoints ? visibleConnectionIds.includes(res.id) : false
|
||||
return (
|
||||
<div style={{ marginTop: 3, display: 'inline-flex', alignItems: 'center', gap: 4 }}>
|
||||
<div style={{ display: 'inline-flex', alignItems: 'center', gap: 3, padding: '1px 6px', borderRadius: 5, fontSize: 9, fontWeight: 600,
|
||||
background: confirmed ? 'rgba(22,163,74,0.1)' : 'rgba(217,119,6,0.1)',
|
||||
color: confirmed ? '#16a34a' : '#d97706',
|
||||
}}>
|
||||
{(() => { const RI = RES_ICONS[res.type] || Ticket; return <RI size={8} /> })()}
|
||||
<span className="hidden sm:inline">{confirmed ? t('planner.resConfirmed') : t('planner.resPending')}</span>
|
||||
{res.reservation_time?.includes('T') && (
|
||||
<span style={{ fontWeight: 400 }}>
|
||||
{new Date(res.reservation_time).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit', hour12: timeFormat === '12h' })}
|
||||
{res.reservation_end_time && ` – ${res.reservation_end_time}`}
|
||||
</span>
|
||||
)}
|
||||
{(() => {
|
||||
const meta = typeof res.metadata === 'string' ? JSON.parse(res.metadata || '{}') : (res.metadata || {})
|
||||
if (!meta) return null
|
||||
if (meta.airline && meta.flight_number) return <span style={{ fontWeight: 400 }}>{meta.airline} {meta.flight_number}</span>
|
||||
if (meta.flight_number) return <span style={{ fontWeight: 400 }}>{meta.flight_number}</span>
|
||||
if (meta.train_number) return <span style={{ fontWeight: 400 }}>{meta.train_number}</span>
|
||||
return null
|
||||
})()}
|
||||
</div>
|
||||
{hasEndpoints && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={e => { e.stopPropagation(); onToggleConnection!(res.id) }}
|
||||
title={t(active ? 'map.hideConnections' : 'map.showConnections')}
|
||||
style={{
|
||||
flexShrink: 0, appearance: 'none',
|
||||
width: 20, height: 20, borderRadius: 4,
|
||||
display: 'grid', placeItems: 'center', cursor: 'pointer',
|
||||
border: 'none',
|
||||
background: active ? '#3b82f6' : '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={11} />
|
||||
</button>
|
||||
<div style={{ marginTop: 3, display: 'inline-flex', alignItems: 'center', gap: 3, padding: '1px 6px', borderRadius: 5, fontSize: 9, fontWeight: 600,
|
||||
background: confirmed ? 'rgba(22,163,74,0.1)' : 'rgba(217,119,6,0.1)',
|
||||
color: confirmed ? '#16a34a' : '#d97706',
|
||||
}}>
|
||||
{(() => { const RI = RES_ICONS[res.type] || Ticket; return <RI size={8} /> })()}
|
||||
<span className="hidden sm:inline">{confirmed ? t('planner.resConfirmed') : t('planner.resPending')}</span>
|
||||
{res.reservation_time?.includes('T') && (
|
||||
<span style={{ fontWeight: 400 }}>
|
||||
{new Date(res.reservation_time).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit', hour12: timeFormat === '12h' })}
|
||||
{res.reservation_end_time && ` – ${res.reservation_end_time}`}
|
||||
</span>
|
||||
)}
|
||||
{canEditDays && (() => {
|
||||
const isTransport = ['flight','train','car','cruise','bus'].includes(res.type)
|
||||
const handler = isTransport ? onEditTransport : onEditReservation
|
||||
if (!handler) return null
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={e => { e.stopPropagation(); handler(res) }}
|
||||
title={t('common.edit')}
|
||||
style={{
|
||||
flexShrink: 0, appearance: 'none',
|
||||
width: 20, height: 20, borderRadius: 4,
|
||||
display: 'grid', placeItems: 'center', cursor: 'pointer',
|
||||
border: 'none', background: 'transparent',
|
||||
color: 'var(--text-faint)',
|
||||
transition: 'all 0.12s',
|
||||
}}
|
||||
onMouseEnter={e => { e.currentTarget.style.color = 'var(--text-primary)' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.color = 'var(--text-faint)' }}
|
||||
>
|
||||
<Pencil size={11} />
|
||||
</button>
|
||||
)
|
||||
{(() => {
|
||||
const meta = typeof res.metadata === 'string' ? JSON.parse(res.metadata || '{}') : (res.metadata || {})
|
||||
if (!meta) return null
|
||||
if (meta.airline && meta.flight_number) return <span style={{ fontWeight: 400 }}>{meta.airline} {meta.flight_number}</span>
|
||||
if (meta.flight_number) return <span style={{ fontWeight: 400 }}>{meta.flight_number}</span>
|
||||
if (meta.train_number) return <span style={{ fontWeight: 400 }}>{meta.train_number}</span>
|
||||
return null
|
||||
})()}
|
||||
</div>
|
||||
)
|
||||
@@ -1588,7 +1462,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{canEditDays && <div className="reorder-buttons" style={{ flexShrink: 0, display: 'flex', gap: 1, transition: 'opacity 0.15s' }}>
|
||||
{canEditDays && <div className="reorder-buttons" style={{ flexShrink: 0, display: 'flex', gap: 1, opacity: isHovered ? 1 : undefined, transition: 'opacity 0.15s' }}>
|
||||
<button onClick={moveUp} disabled={idx === 0} style={{ background: 'none', border: 'none', padding: '1px 2px', cursor: idx === 0 ? 'default' : 'pointer', color: idx === 0 ? 'var(--border-primary)' : 'var(--text-faint)', display: 'flex', lineHeight: 1 }}>
|
||||
<ChevronUp size={12} strokeWidth={2} />
|
||||
</button>
|
||||
@@ -1596,32 +1470,6 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
||||
<ChevronDown size={12} strokeWidth={2} />
|
||||
</button>
|
||||
</div>}
|
||||
{canEditDays && onAddBookingToAssignment && hoveredAssignmentId === assignment.id && (
|
||||
<button
|
||||
onClick={e => {
|
||||
e.stopPropagation()
|
||||
onAddBookingToAssignment(day.id, assignment.id)
|
||||
}}
|
||||
title={t('reservations.addBooking')}
|
||||
style={{
|
||||
flexShrink: 0,
|
||||
background: 'none',
|
||||
border: '1px solid var(--border-primary)',
|
||||
borderRadius: 5,
|
||||
padding: '2px 6px',
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 3,
|
||||
fontSize: 10,
|
||||
fontWeight: 500,
|
||||
color: 'var(--text-muted)',
|
||||
fontFamily: 'inherit',
|
||||
}}
|
||||
>
|
||||
<Plus size={11} strokeWidth={2} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</React.Fragment>
|
||||
)
|
||||
@@ -1630,7 +1478,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
||||
// Transport booking (flight, train, bus, car, cruise)
|
||||
if (item.type === 'transport') {
|
||||
const res = item.data
|
||||
const spanPhase = getSpanPhase(res, day.id)
|
||||
const spanPhase = getSpanPhase(res, day.date)
|
||||
|
||||
// Car "active" (middle) days are shown in the day header, skip here
|
||||
if (res.type === 'car' && spanPhase === 'middle') return null
|
||||
@@ -1638,6 +1486,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
||||
const TransportIcon = RES_ICONS[res.type] || Ticket
|
||||
const color = '#3b82f6'
|
||||
const meta = typeof res.metadata === 'string' ? JSON.parse(res.metadata || '{}') : (res.metadata || {})
|
||||
const isTransportHovered = hoveredId === `transport-${res.id}`
|
||||
|
||||
// Subtitle aus Metadaten zusammensetzen
|
||||
let subtitle = ''
|
||||
@@ -1652,13 +1501,13 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
||||
|
||||
// Multi-day span phase
|
||||
const spanLabel = getSpanLabel(res, spanPhase)
|
||||
const displayTime = getDisplayTimeForDay(res, day.id)
|
||||
const displayTime = getDisplayTimeForDay(res, day.date)
|
||||
|
||||
return (
|
||||
<React.Fragment key={`transport-${res.id}-${day.id}`}>
|
||||
{showDropLine && <div style={{ height: 2, background: 'var(--text-primary)', borderRadius: 1, margin: '2px 8px' }} />}
|
||||
<div
|
||||
onClick={() => canEditDays && onEditTransport?.(res)}
|
||||
onClick={() => setTransportDetail(res)}
|
||||
onDragOver={e => {
|
||||
e.preventDefault(); e.stopPropagation()
|
||||
const rect = e.currentTarget.getBoundingClientRect()
|
||||
@@ -1666,26 +1515,13 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
||||
const key = inBottom ? `transport-after-${res.id}-${day.id}` : `transport-${res.id}-${day.id}`
|
||||
if (dropTargetRef.current !== key) setDropTargetKey(key)
|
||||
}}
|
||||
draggable={canEditDays && spanPhase !== 'middle'}
|
||||
onDragStart={e => {
|
||||
if (!canEditDays || spanPhase === 'middle') { e.preventDefault(); return }
|
||||
e.dataTransfer.effectAllowed = 'move'
|
||||
dragDataRef.current = { reservationId: String(res.id), fromDayId: String(day.id), phase: spanPhase }
|
||||
setDraggingId(res.id)
|
||||
}}
|
||||
onDragEnd={() => { setDraggingId(null); setDragOverDayId(null); setDropTargetKey(null); dragDataRef.current = null }}
|
||||
onDrop={e => {
|
||||
e.preventDefault(); e.stopPropagation()
|
||||
const rect = e.currentTarget.getBoundingClientRect()
|
||||
const insertAfter = e.clientY > rect.top + rect.height / 2
|
||||
const { placeId, assignmentId: fromAssignmentId, noteId, reservationId: fromReservationId, fromDayId, phase } = getDragData(e)
|
||||
const { placeId, assignmentId: fromAssignmentId, noteId, fromDayId } = getDragData(e)
|
||||
if (placeId) {
|
||||
onAssignToDay?.(parseInt(placeId), day.id)
|
||||
} else if (fromReservationId && fromDayId !== day.id) {
|
||||
const r2 = reservations.find(x => x.id === Number(fromReservationId))
|
||||
if (r2) { const update = computeMultiDayMove(r2, day.id, phase); tripActions.updateReservation(tripId, r2.id, update).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError'))) }
|
||||
} else if (fromReservationId) {
|
||||
handleMergedDrop(day.id, 'transport', Number(fromReservationId), 'transport', res.id, insertAfter)
|
||||
} else if (fromAssignmentId && fromDayId !== day.id) {
|
||||
tripActions.moveAssignment(tripId, Number(fromAssignmentId), fromDayId, day.id).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError')))
|
||||
} else if (fromAssignmentId) {
|
||||
@@ -1697,25 +1533,20 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
||||
}
|
||||
setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null; window.__dragData = null
|
||||
}}
|
||||
onMouseEnter={e => { e.currentTarget.style.background = `${color}12` }}
|
||||
onMouseLeave={e => { e.currentTarget.style.background = `${color}08` }}
|
||||
onMouseEnter={() => setHoveredId(`transport-${res.id}`)}
|
||||
onMouseLeave={() => setHoveredId(null)}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 8,
|
||||
padding: '7px 8px 7px 10px',
|
||||
margin: '1px 8px',
|
||||
borderRadius: 6,
|
||||
border: `1px solid ${color}33`,
|
||||
background: `${color}08`,
|
||||
cursor: canEditDays && onEditTransport ? 'pointer' : 'default', userSelect: 'none',
|
||||
background: isTransportHovered ? `${color}12` : `${color}08`,
|
||||
cursor: 'pointer', userSelect: 'none',
|
||||
transition: 'background 0.1s',
|
||||
opacity: draggingId === res.id ? 0.4 : spanPhase === 'middle' ? 0.65 : 1,
|
||||
opacity: spanPhase === 'middle' ? 0.65 : 1,
|
||||
}}
|
||||
>
|
||||
{canEditDays && spanPhase !== 'middle' && (
|
||||
<div className="dp-grip" style={{ flexShrink: 0, color: 'var(--text-faint)', display: 'flex', alignItems: 'center', opacity: 0.3, transition: 'opacity 0.15s', cursor: 'grab' }}>
|
||||
<GripVertical size={13} strokeWidth={1.8} />
|
||||
</div>
|
||||
)}
|
||||
<div style={{
|
||||
width: 28, height: 28, flexShrink: 0, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
borderRadius: '50%', background: `${color}18`,
|
||||
@@ -1785,6 +1616,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
||||
|
||||
// Notizkarte
|
||||
const note = item.data
|
||||
const isNoteHovered = hoveredId === `note-${note.id}`
|
||||
const NoteIcon = getNoteIcon(note.icon)
|
||||
const noteIdx = idx
|
||||
return (
|
||||
@@ -1797,14 +1629,8 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
||||
onDragOver={e => { e.preventDefault(); e.stopPropagation(); if (dropTargetKey !== `note-${note.id}`) setDropTargetKey(`note-${note.id}`) }}
|
||||
onDrop={e => {
|
||||
e.preventDefault(); e.stopPropagation()
|
||||
const { noteId: fromNoteId, assignmentId: fromAssignmentId, reservationId: fromReservationId, fromDayId, phase } = getDragData(e)
|
||||
if (fromReservationId && fromDayId !== day.id) {
|
||||
const r = reservations.find(x => x.id === Number(fromReservationId))
|
||||
if (r) { const update = computeMultiDayMove(r, day.id, phase); tripActions.updateReservation(tripId, r.id, update).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError'))) }
|
||||
setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null
|
||||
} else if (fromReservationId) {
|
||||
handleMergedDrop(day.id, 'transport', Number(fromReservationId), 'note', note.id)
|
||||
} else if (fromNoteId && fromDayId !== day.id) {
|
||||
const { noteId: fromNoteId, assignmentId: fromAssignmentId, fromDayId } = getDragData(e)
|
||||
if (fromNoteId && fromDayId !== day.id) {
|
||||
const tm = getMergedItems(day.id)
|
||||
const toIdx = tm.findIndex(i => i.type === 'note' && i.data.id === note.id)
|
||||
const so = toIdx <= 0 ? (tm[0]?.sortKey ?? 0) - 1 : (tm[toIdx - 1].sortKey + tm[toIdx].sortKey) / 2
|
||||
@@ -1827,30 +1653,20 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
||||
{ divider: true },
|
||||
{ label: t('common.delete'), icon: Trash2, danger: true, onClick: () => deleteNote(day.id, note.id) },
|
||||
]) : undefined}
|
||||
onMouseEnter={e => {
|
||||
const grip = e.currentTarget.querySelector('.dp-grip') as HTMLElement | null
|
||||
if (grip) grip.style.opacity = '1'
|
||||
const editBtns = e.currentTarget.querySelector('.note-edit-buttons') as HTMLElement | null
|
||||
if (editBtns) editBtns.style.opacity = '1'
|
||||
}}
|
||||
onMouseLeave={e => {
|
||||
const grip = e.currentTarget.querySelector('.dp-grip') as HTMLElement | null
|
||||
if (grip) grip.style.opacity = '0.3'
|
||||
const editBtns = e.currentTarget.querySelector('.note-edit-buttons') as HTMLElement | null
|
||||
if (editBtns) editBtns.style.opacity = '0'
|
||||
}}
|
||||
onMouseEnter={() => setHoveredId(`note-${note.id}`)}
|
||||
onMouseLeave={() => setHoveredId(null)}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 8,
|
||||
padding: '7px 8px 7px 2px',
|
||||
margin: '1px 8px',
|
||||
borderRadius: 6,
|
||||
border: '1px solid var(--border-faint)',
|
||||
background: 'var(--bg-hover)',
|
||||
background: isNoteHovered ? 'var(--bg-hover)' : 'var(--bg-hover)',
|
||||
opacity: draggingId === `note-${note.id}` ? 0.4 : 1,
|
||||
transition: 'background 0.1s', cursor: 'grab', userSelect: 'none',
|
||||
}}
|
||||
>
|
||||
{canEditDays && <div className="dp-grip" style={{ flexShrink: 0, color: 'var(--text-faint)', display: 'flex', alignItems: 'center', opacity: 0.3, transition: 'opacity 0.15s', cursor: 'grab' }}>
|
||||
{canEditDays && <div style={{ flexShrink: 0, color: 'var(--text-faint)', display: 'flex', alignItems: 'center', opacity: isNoteHovered ? 1 : 0.3, transition: 'opacity 0.15s', cursor: 'grab' }}>
|
||||
<GripVertical size={13} strokeWidth={1.8} />
|
||||
</div>}
|
||||
<div style={{ width: 28, height: 28, flexShrink: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', borderRadius: '50%', background: 'var(--bg-hover)', overflow: 'hidden' }}>
|
||||
@@ -1864,11 +1680,11 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
||||
<div className="collab-note-md" style={{ fontSize: 10.5, fontWeight: 400, color: 'var(--text-faint)', lineHeight: '1.3', marginTop: 2, wordBreak: 'break-word' }}><Markdown remarkPlugins={[remarkGfm]}>{note.time}</Markdown></div>
|
||||
)}
|
||||
</div>
|
||||
{canEditDays && <div className="note-edit-buttons" style={{ display: 'flex', gap: 1, flexShrink: 0, opacity: 0, transition: 'opacity 0.15s' }}>
|
||||
{canEditDays && <div className="note-edit-buttons" style={{ display: 'flex', gap: 1, flexShrink: 0, opacity: isNoteHovered ? 1 : 0, transition: 'opacity 0.15s' }}>
|
||||
<button onClick={e => openEditNote(day.id, note, e)} style={{ background: 'none', border: 'none', padding: 2, cursor: 'pointer', color: 'var(--text-faint)', display: 'flex' }}><Pencil size={10} /></button>
|
||||
<button onClick={e => deleteNote(day.id, note.id, e)} style={{ background: 'none', border: 'none', padding: 2, cursor: 'pointer', color: 'var(--text-faint)', display: 'flex' }}><Trash2 size={10} /></button>
|
||||
</div>}
|
||||
{canEditDays && <div className="reorder-buttons" style={{ flexShrink: 0, display: 'flex', gap: 1, transition: 'opacity 0.15s' }}>
|
||||
{canEditDays && <div className="reorder-buttons" style={{ flexShrink: 0, display: 'flex', gap: 1, opacity: isNoteHovered ? 1 : undefined, transition: 'opacity 0.15s' }}>
|
||||
<button onClick={e => { e.stopPropagation(); moveNote(day.id, note.id, 'up') }} disabled={noteIdx === 0} style={{ background: 'none', border: 'none', padding: '1px 2px', cursor: noteIdx === 0 ? 'default' : 'pointer', color: noteIdx === 0 ? 'var(--border-primary)' : 'var(--text-faint)', display: 'flex', lineHeight: 1 }}><ChevronUp size={12} strokeWidth={2} /></button>
|
||||
<button onClick={e => { e.stopPropagation(); moveNote(day.id, note.id, 'down') }} disabled={noteIdx === merged.length - 1} style={{ background: 'none', border: 'none', padding: '1px 2px', cursor: noteIdx === merged.length - 1 ? 'default' : 'pointer', color: noteIdx === merged.length - 1 ? 'var(--border-primary)' : 'var(--text-faint)', display: 'flex', lineHeight: 1 }}><ChevronDown size={12} strokeWidth={2} /></button>
|
||||
</div>}
|
||||
@@ -1883,17 +1699,12 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
||||
onDragOver={e => { e.preventDefault(); e.stopPropagation(); if (dropTargetKey !== `end-${day.id}`) setDropTargetKey(`end-${day.id}`) }}
|
||||
onDrop={e => {
|
||||
e.preventDefault(); e.stopPropagation()
|
||||
const { placeId, assignmentId, noteId, reservationId: fromReservationId, fromDayId, phase } = getDragData(e)
|
||||
const { placeId, assignmentId, noteId, fromDayId } = getDragData(e)
|
||||
// Neuer Ort von der Orte-Liste
|
||||
if (placeId) {
|
||||
onAssignToDay?.(parseInt(placeId), day.id)
|
||||
setDropTargetKey(null); window.__dragData = null; return
|
||||
}
|
||||
if (fromReservationId && fromDayId !== day.id) {
|
||||
const r = reservations.find(x => x.id === Number(fromReservationId))
|
||||
if (r) { const update = computeMultiDayMove(r, day.id, phase); tripActions.updateReservation(tripId, r.id, update).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError'))) }
|
||||
setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null; window.__dragData = null; return
|
||||
}
|
||||
if (!assignmentId && !noteId) { dragDataRef.current = null; window.__dragData = null; return }
|
||||
if (assignmentId && fromDayId !== day.id) {
|
||||
tripActions.moveAssignment(tripId, Number(assignmentId), fromDayId, day.id).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError')))
|
||||
|
||||
@@ -36,8 +36,6 @@ export default function FileImportModal({ isOpen, onClose, tripId, pushUndo, ini
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [summary, setSummary] = useState<PlacesImportSummary | null>(null)
|
||||
const [gpxOpts, setGpxOpts] = useState({ waypoints: true, routes: true, tracks: true })
|
||||
const [kmlOpts, setKmlOpts] = useState({ points: true, paths: true })
|
||||
|
||||
const validateFile = (f: File): string | null => {
|
||||
const ext = f.name.toLowerCase().split('.').pop()
|
||||
@@ -129,7 +127,7 @@ export default function FileImportModal({ isOpen, onClose, tripId, pushUndo, ini
|
||||
|
||||
try {
|
||||
if (ext === 'gpx') {
|
||||
const result = await placesApi.importGpx(tripId, file, gpxOpts)
|
||||
const result = await placesApi.importGpx(tripId, file)
|
||||
await loadTrip(tripId)
|
||||
if (result.count === 0 && result.skipped > 0) {
|
||||
toast.warning(t('places.importAllSkipped'))
|
||||
@@ -139,13 +137,15 @@ export default function FileImportModal({ isOpen, onClose, tripId, pushUndo, ini
|
||||
if (result.places?.length > 0) {
|
||||
const importedIds: number[] = result.places.map((p: { id: number }) => p.id)
|
||||
pushUndo?.(t('undo.importGpx'), async () => {
|
||||
try { await placesApi.bulkDelete(tripId, importedIds) } catch {}
|
||||
for (const id of importedIds) {
|
||||
try { await placesApi.delete(tripId, id) } catch {}
|
||||
}
|
||||
await loadTrip(tripId)
|
||||
})
|
||||
}
|
||||
handleClose()
|
||||
} else {
|
||||
const result = await placesApi.importMapFile(tripId, file, kmlOpts)
|
||||
const result = await placesApi.importMapFile(tripId, file)
|
||||
await loadTrip(tripId)
|
||||
setSummary(result.summary || null)
|
||||
if (result.count === 0 && (result.summary?.skippedCount ?? 0) > 0) {
|
||||
@@ -159,7 +159,9 @@ export default function FileImportModal({ isOpen, onClose, tripId, pushUndo, ini
|
||||
if (result.places?.length > 0) {
|
||||
const importedIds: number[] = result.places.map((p: { id: number }) => p.id)
|
||||
pushUndo?.(t('undo.importKeyholeMarkup'), async () => {
|
||||
try { await placesApi.bulkDelete(tripId, importedIds) } catch {}
|
||||
for (const id of importedIds) {
|
||||
try { await placesApi.delete(tripId, id) } catch {}
|
||||
}
|
||||
await loadTrip(tripId)
|
||||
})
|
||||
}
|
||||
@@ -175,12 +177,7 @@ export default function FileImportModal({ isOpen, onClose, tripId, pushUndo, ini
|
||||
}
|
||||
}
|
||||
|
||||
const fileExt = file?.name.toLowerCase().split('.').pop() ?? ''
|
||||
const isGpx = fileExt === 'gpx'
|
||||
const isKml = fileExt === 'kml' || fileExt === 'kmz'
|
||||
const gpxNoneSelected = isGpx && !gpxOpts.waypoints && !gpxOpts.routes && !gpxOpts.tracks
|
||||
const kmlNoneSelected = isKml && !kmlOpts.points && !kmlOpts.paths
|
||||
const canImport = !!file && !loading && !gpxNoneSelected && !kmlNoneSelected
|
||||
const canImport = !!file && !loading
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
@@ -245,58 +242,6 @@ export default function FileImportModal({ isOpen, onClose, tripId, pushUndo, ini
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isGpx && (
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-muted)', marginBottom: 6, textTransform: 'uppercase', letterSpacing: '0.05em' }}>
|
||||
{t('places.gpxImportTypes')}
|
||||
</div>
|
||||
{(['waypoints', 'routes', 'tracks'] as const).map(key => (
|
||||
<label key={key} onClick={() => setGpxOpts(prev => ({ ...prev, [key]: !prev[key] }))} style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '4px 0', cursor: 'pointer' }}>
|
||||
<div style={{
|
||||
width: 16, height: 16, borderRadius: 4, flexShrink: 0,
|
||||
border: gpxOpts[key] ? 'none' : '1.5px solid var(--border-primary)',
|
||||
background: gpxOpts[key] ? 'var(--accent)' : 'transparent',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
}}>
|
||||
{gpxOpts[key] && <svg width="10" height="10" viewBox="0 0 10 10"><polyline points="1.5,5 4,7.5 8.5,2" stroke="white" strokeWidth="1.8" fill="none" strokeLinecap="round" strokeLinejoin="round" /></svg>}
|
||||
</div>
|
||||
<span style={{ fontSize: 12, color: 'var(--text-primary)', userSelect: 'none' }}>
|
||||
{t(key === 'waypoints' ? 'places.gpxImportWaypoints' : key === 'routes' ? 'places.gpxImportRoutes' : 'places.gpxImportTracks')}
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
{gpxNoneSelected && (
|
||||
<div style={{ fontSize: 11, color: '#b45309', marginTop: 4 }}>{t('places.gpxImportNoneSelected')}</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isKml && (
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-muted)', marginBottom: 6, textTransform: 'uppercase', letterSpacing: '0.05em' }}>
|
||||
{t('places.kmlImportTypes')}
|
||||
</div>
|
||||
{(['points', 'paths'] as const).map(key => (
|
||||
<label key={key} onClick={() => setKmlOpts(prev => ({ ...prev, [key]: !prev[key] }))} style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '4px 0', cursor: 'pointer' }}>
|
||||
<div style={{
|
||||
width: 16, height: 16, borderRadius: 4, flexShrink: 0,
|
||||
border: kmlOpts[key] ? 'none' : '1.5px solid var(--border-primary)',
|
||||
background: kmlOpts[key] ? 'var(--accent)' : 'transparent',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
}}>
|
||||
{kmlOpts[key] && <svg width="10" height="10" viewBox="0 0 10 10"><polyline points="1.5,5 4,7.5 8.5,2" stroke="white" strokeWidth="1.8" fill="none" strokeLinecap="round" strokeLinejoin="round" /></svg>}
|
||||
</div>
|
||||
<span style={{ fontSize: 12, color: 'var(--text-primary)', userSelect: 'none' }}>
|
||||
{t(key === 'points' ? 'places.kmlImportPoints' : 'places.kmlImportPaths')}
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
{kmlNoneSelected && (
|
||||
<div style={{ fontSize: 11, color: '#b45309', marginTop: 4 }}>{t('places.kmlImportNoneSelected')}</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{summary && (
|
||||
<div style={{
|
||||
border: '1px solid var(--border-primary)', borderRadius: 10,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom'
|
||||
import { useState, useMemo, useEffect, useRef, useCallback } from 'react'
|
||||
import { Search, Plus, X, CalendarDays, Pencil, Trash2, ExternalLink, Navigation, Upload, ChevronDown, Check, MapPin, Eye, Route } from 'lucide-react'
|
||||
import { useState, useMemo, useEffect, useRef } from 'react'
|
||||
import { Search, Plus, X, CalendarDays, Pencil, Trash2, ExternalLink, Navigation, Upload, ChevronDown, Check, MapPin, Eye } from 'lucide-react'
|
||||
import PlaceAvatar from '../shared/PlaceAvatar'
|
||||
import { getCategoryIcon } from '../shared/categoryIcons'
|
||||
import { useTranslation } from '../../i18n'
|
||||
@@ -12,7 +12,6 @@ import { useTripStore } from '../../store/tripStore'
|
||||
import { useCanDo } from '../../store/permissionsStore'
|
||||
import type { Place, Category, Day, AssignmentsMap } from '../../types'
|
||||
import FileImportModal from './FileImportModal'
|
||||
import ConfirmDialog from '../shared/ConfirmDialog'
|
||||
|
||||
interface PlacesSidebarProps {
|
||||
tripId: number
|
||||
@@ -26,8 +25,6 @@ interface PlacesSidebarProps {
|
||||
onAssignToDay: (placeId: number, dayId: number) => void
|
||||
onEditPlace: (place: Place) => void
|
||||
onDeletePlace: (placeId: number) => void
|
||||
onBulkDeletePlaces?: (ids: number[]) => void
|
||||
onBulkDeleteConfirm?: (ids: number[]) => void
|
||||
days: Day[]
|
||||
isMobile: boolean
|
||||
onCategoryFilterChange?: (categoryIds: Set<string>) => void
|
||||
@@ -35,115 +32,9 @@ interface PlacesSidebarProps {
|
||||
pushUndo?: (label: string, undoFn: () => Promise<void> | void) => void
|
||||
}
|
||||
|
||||
interface MemoPlaceRowProps {
|
||||
place: Place
|
||||
category: Category | undefined
|
||||
isSelected: boolean
|
||||
isPlanned: boolean
|
||||
inDay: boolean
|
||||
isChecked: boolean
|
||||
selectMode: boolean
|
||||
selectedDayId: number | null
|
||||
canEditPlaces: boolean
|
||||
isMobile: boolean
|
||||
t: (key: string, params?: Record<string, any>) => string
|
||||
onPlaceClick: (id: number | null) => void
|
||||
onContextMenu: (e: React.MouseEvent, place: Place) => void
|
||||
onAssignToDay: (placeId: number, dayId?: number) => void
|
||||
toggleSelected: (id: number) => void
|
||||
setDayPickerPlace: (place: any) => void
|
||||
}
|
||||
|
||||
const MemoPlaceRow = React.memo(function MemoPlaceRow({
|
||||
place, category: cat, isSelected, isPlanned, inDay, isChecked,
|
||||
selectMode, selectedDayId, canEditPlaces, isMobile, t,
|
||||
onPlaceClick, onContextMenu, onAssignToDay, toggleSelected, setDayPickerPlace,
|
||||
}: MemoPlaceRowProps) {
|
||||
const hasGeometry = Boolean(place.route_geometry)
|
||||
return (
|
||||
<div
|
||||
key={place.id}
|
||||
draggable={!selectMode}
|
||||
onDragStart={e => {
|
||||
e.dataTransfer.setData('placeId', String(place.id))
|
||||
e.dataTransfer.effectAllowed = 'copy'
|
||||
window.__dragData = { placeId: String(place.id) }
|
||||
}}
|
||||
onClick={() => {
|
||||
if (selectMode) {
|
||||
toggleSelected(place.id)
|
||||
} else if (isMobile) {
|
||||
setDayPickerPlace(place)
|
||||
} else {
|
||||
onPlaceClick(isSelected ? null : place.id)
|
||||
}
|
||||
}}
|
||||
onContextMenu={selectMode ? undefined : e => onContextMenu(e, place)}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 10,
|
||||
padding: '9px 14px 9px 16px',
|
||||
cursor: selectMode ? 'pointer' : 'grab',
|
||||
background: isChecked ? 'color-mix(in srgb, var(--accent) 8%, transparent)' : isSelected ? 'var(--border-faint)' : 'transparent',
|
||||
borderBottom: '1px solid var(--border-faint)',
|
||||
transition: 'background 0.1s',
|
||||
contentVisibility: 'auto',
|
||||
containIntrinsicSize: '0 52px',
|
||||
}}
|
||||
onMouseEnter={e => { if (!isSelected && !isChecked) e.currentTarget.style.background = 'var(--bg-hover)' }}
|
||||
onMouseLeave={e => { if (!isSelected && !isChecked) e.currentTarget.style.background = 'transparent' }}
|
||||
>
|
||||
{selectMode && (
|
||||
<div style={{
|
||||
width: 16, height: 16, borderRadius: 4, flexShrink: 0,
|
||||
border: isChecked ? 'none' : '1.5px solid var(--border-primary)',
|
||||
background: isChecked ? 'var(--accent)' : 'transparent',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
}}>
|
||||
{isChecked && <Check size={10} strokeWidth={3} color="white" />}
|
||||
</div>
|
||||
)}
|
||||
<PlaceAvatar place={place} category={cat} size={34} />
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 5, overflow: 'hidden' }}>
|
||||
{hasGeometry && <Route size={11} strokeWidth={2} color="var(--text-faint)" style={{ flexShrink: 0 }} title="Track / Route" />}
|
||||
{cat && (() => {
|
||||
const CatIcon = getCategoryIcon(cat.icon)
|
||||
return <CatIcon size={11} strokeWidth={2} color={cat.color || '#6366f1'} style={{ flexShrink: 0 }} title={cat.name} />
|
||||
})()}
|
||||
<span style={{ fontSize: 13, fontWeight: 500, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', lineHeight: 1.2 }}>
|
||||
{place.name}
|
||||
</span>
|
||||
</div>
|
||||
{(place.description || place.address || cat?.name) && (
|
||||
<div style={{ marginTop: 2 }}>
|
||||
<span style={{ fontSize: 11, color: 'var(--text-faint)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', display: 'block', lineHeight: 1.2 }}>
|
||||
{place.description || place.address || cat?.name}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ flexShrink: 0, display: 'flex', alignItems: 'center' }}>
|
||||
{!selectMode && !inDay && selectedDayId && (
|
||||
<button
|
||||
onClick={e => { e.stopPropagation(); onAssignToDay(place.id) }}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
width: 20, height: 20, borderRadius: 6,
|
||||
background: 'var(--bg-hover)', border: 'none', cursor: 'pointer',
|
||||
color: 'var(--text-faint)', padding: 0, transition: 'background 0.15s, color 0.15s',
|
||||
}}
|
||||
onMouseEnter={e => { e.currentTarget.style.background = 'var(--accent)'; e.currentTarget.style.color = 'var(--accent-text)' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.background = 'var(--bg-hover)'; e.currentTarget.style.color = 'var(--text-faint)' }}
|
||||
><Plus size={12} strokeWidth={2.5} /></button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
const PlacesSidebar = React.memo(function PlacesSidebar({
|
||||
tripId, places, categories, assignments, selectedDayId, selectedPlaceId,
|
||||
onPlaceClick, onAddPlace, onAssignToDay, onEditPlace, onDeletePlace, onBulkDeletePlaces, onBulkDeleteConfirm, days, isMobile, onCategoryFilterChange, onPlacesFilterChange, pushUndo,
|
||||
onPlaceClick, onAddPlace, onAssignToDay, onEditPlace, onDeletePlace, days, isMobile, onCategoryFilterChange, onPlacesFilterChange, pushUndo,
|
||||
}: PlacesSidebarProps) {
|
||||
const { t } = useTranslation()
|
||||
const toast = useToast()
|
||||
@@ -219,7 +110,9 @@ const PlacesSidebar = React.memo(function PlacesSidebar({
|
||||
if (result.places?.length > 0) {
|
||||
const importedIds: number[] = result.places.map((p: { id: number }) => p.id)
|
||||
pushUndo?.(t(provider === 'google' ? 'undo.importGoogleList' : 'undo.importNaverList'), async () => {
|
||||
try { await placesApi.bulkDelete(tripId, importedIds) } catch {}
|
||||
for (const id of importedIds) {
|
||||
try { await placesApi.delete(tripId, id) } catch {}
|
||||
}
|
||||
await loadTrip(tripId)
|
||||
})
|
||||
}
|
||||
@@ -233,28 +126,6 @@ const PlacesSidebar = React.memo(function PlacesSidebar({
|
||||
const [search, setSearch] = useState('')
|
||||
const [filter, setFilter] = useState('all')
|
||||
const [categoryFilters, setCategoryFiltersLocal] = useState<Set<string>>(new Set())
|
||||
const [selectMode, setSelectMode] = useState(false)
|
||||
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set())
|
||||
const [pendingDeleteIds, setPendingDeleteIds] = useState<number[] | null>(null)
|
||||
|
||||
const exitSelectMode = () => { setSelectMode(false); setSelectedIds(new Set()) }
|
||||
|
||||
// Auto-exit when all selected places have been removed from the store (e.g. after bulk delete)
|
||||
useEffect(() => {
|
||||
if (!selectMode || selectedIds.size === 0) return
|
||||
const placeIdSet = new Set(places.map(p => p.id))
|
||||
if ([...selectedIds].every(id => !placeIdSet.has(id))) {
|
||||
setSelectMode(false)
|
||||
setSelectedIds(new Set())
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [places])
|
||||
|
||||
const toggleSelected = useCallback((id: number) => setSelectedIds(prev => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(id)) next.delete(id); else next.add(id)
|
||||
return next
|
||||
}), [])
|
||||
|
||||
const toggleCategoryFilter = (catId: string) => {
|
||||
setCategoryFiltersLocal(prev => {
|
||||
@@ -269,16 +140,12 @@ const PlacesSidebar = React.memo(function PlacesSidebar({
|
||||
const [mobileShowDays, setMobileShowDays] = useState(false)
|
||||
|
||||
// Alle geplanten Ort-IDs abrufen (einem Tag zugewiesen)
|
||||
const hasTracks = useMemo(() => places.some(p => p.route_geometry), [places])
|
||||
useEffect(() => { if (filter === 'tracks' && !hasTracks) setFilter('all') }, [hasTracks, filter])
|
||||
|
||||
const plannedIds = useMemo(() => new Set(
|
||||
Object.values(assignments).flatMap(da => da.map(a => a.place?.id).filter(Boolean))
|
||||
), [assignments])
|
||||
|
||||
const filtered = useMemo(() => places.filter(p => {
|
||||
if (filter === 'unplanned' && plannedIds.has(p.id)) return false
|
||||
if (filter === 'tracks' && !p.route_geometry) return false
|
||||
if (categoryFilters.size > 0) {
|
||||
if (p.category_id == null) {
|
||||
if (!categoryFilters.has('uncategorized')) return false
|
||||
@@ -292,26 +159,6 @@ const PlacesSidebar = React.memo(function PlacesSidebar({
|
||||
const isAssignedToSelectedDay = (placeId) =>
|
||||
selectedDayId && (assignments[String(selectedDayId)] || []).some(a => a.place?.id === placeId)
|
||||
|
||||
const selectedDayIdRef = useRef<number | null>(selectedDayId)
|
||||
useEffect(() => { selectedDayIdRef.current = selectedDayId }, [selectedDayId])
|
||||
|
||||
const inDaySet = useMemo(() => {
|
||||
if (!selectedDayId) return new Set<number>()
|
||||
return new Set<number>((assignments[String(selectedDayId)] || []).map((a: any) => a.place?.id).filter(Boolean))
|
||||
}, [assignments, selectedDayId])
|
||||
|
||||
const openContextMenu = useCallback((e: React.MouseEvent, place: Place) => {
|
||||
const selDayId = selectedDayIdRef.current
|
||||
ctxMenu.open(e, [
|
||||
canEditPlaces && { label: t('common.edit'), icon: Pencil, onClick: () => onEditPlace(place) },
|
||||
selDayId && { label: t('planner.addToDay'), icon: CalendarDays, onClick: () => onAssignToDay(place.id, selDayId) },
|
||||
place.website && { label: t('inspector.website'), icon: ExternalLink, onClick: () => window.open(place.website, '_blank') },
|
||||
(place.lat && place.lng) && { label: 'Google Maps', icon: Navigation, onClick: () => window.open(`https://www.google.com/maps/search/?api=1&query=${(place as any).google_place_id ? encodeURIComponent(place.name) + '&query_place_id=' + (place as any).google_place_id : place.lat + ',' + place.lng}`, '_blank') },
|
||||
{ divider: true },
|
||||
canEditPlaces && { label: t('common.delete'), icon: Trash2, danger: true, onClick: () => onDeletePlace(place.id) },
|
||||
])
|
||||
}, [ctxMenu.open, canEditPlaces, t, onEditPlace, onAssignToDay, onDeletePlace])
|
||||
|
||||
return (
|
||||
<div
|
||||
onDragEnter={handleSidebarDragEnter}
|
||||
@@ -372,67 +219,13 @@ const PlacesSidebar = React.memo(function PlacesSidebar({
|
||||
>
|
||||
<MapPin size={11} strokeWidth={2} /> {t(hasMultipleListImportProviders ? 'places.importList' : 'places.importGoogleList')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setSelectMode(v => !v); setSelectedIds(new Set()) }}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 5,
|
||||
padding: '5px 10px', borderRadius: 8,
|
||||
border: `1px solid ${selectMode ? 'var(--accent)' : 'var(--border-primary)'}`,
|
||||
background: selectMode ? 'color-mix(in srgb, var(--accent) 12%, transparent)' : 'none',
|
||||
color: selectMode ? 'var(--accent)' : 'var(--text-faint)', fontSize: 11, fontWeight: 500,
|
||||
cursor: 'pointer', fontFamily: 'inherit', flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<Check size={11} strokeWidth={2} /> {t('common.select')}
|
||||
</button>
|
||||
</div>
|
||||
{selectMode && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 8, padding: '6px 8px', borderRadius: 8, background: 'var(--bg-tertiary)', fontSize: 11 }}>
|
||||
<span style={{ flex: 1, color: 'var(--text-muted)', fontWeight: 500 }}>
|
||||
{t('places.selectionCount', { count: selectedIds.size })}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (selectedIds.size === filtered.length) {
|
||||
setSelectedIds(new Set())
|
||||
} else {
|
||||
setSelectedIds(new Set(filtered.map(p => p.id)))
|
||||
}
|
||||
}}
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-muted)', fontSize: 11, fontFamily: 'inherit', padding: '2px 4px', borderRadius: 4 }}
|
||||
>
|
||||
{selectedIds.size === filtered.length && filtered.length > 0 ? t('common.deselectAll') : t('common.selectAll')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (selectedIds.size === 0) return
|
||||
if (isMobile) {
|
||||
setPendingDeleteIds(Array.from(selectedIds))
|
||||
} else {
|
||||
onBulkDeletePlaces?.(Array.from(selectedIds))
|
||||
}
|
||||
}}
|
||||
disabled={selectedIds.size === 0}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 4, background: 'none', border: 'none',
|
||||
cursor: selectedIds.size > 0 ? 'pointer' : 'default',
|
||||
color: selectedIds.size > 0 ? '#ef4444' : 'var(--text-faint)',
|
||||
fontSize: 11, fontFamily: 'inherit', padding: '2px 4px', borderRadius: 4, fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
<Trash2 size={11} strokeWidth={2} /> {t('places.deleteSelected')}
|
||||
</button>
|
||||
<button onClick={exitSelectMode} style={{ background: 'none', border: 'none', cursor: 'pointer', display: 'flex', padding: 2 }}>
|
||||
<X size={12} strokeWidth={2} color="var(--text-faint)" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>}
|
||||
|
||||
{/* Filter-Tabs */}
|
||||
<div style={{ display: 'flex', gap: 4, marginBottom: 8 }}>
|
||||
{([{ id: 'all', label: t('places.all') }, { id: 'unplanned', label: t('places.unplanned') }, hasTracks ? { id: 'tracks', label: t('places.filterTracks') } : null] as const).filter(Boolean).map(f => (
|
||||
<button key={f.id} onClick={() => { setFilter(f.id); onPlacesFilterChange?.(f.id); setSelectedIds(new Set()) }} style={{
|
||||
{[{ id: 'all', label: t('places.all') }, { id: 'unplanned', label: t('places.unplanned') }].map(f => (
|
||||
<button key={f.id} onClick={() => { setFilter(f.id); onPlacesFilterChange?.(f.id) }} style={{
|
||||
padding: '4px 10px', borderRadius: 20, border: 'none', cursor: 'pointer',
|
||||
fontSize: 11, fontWeight: 500, fontFamily: 'inherit',
|
||||
background: filter === f.id ? 'var(--accent)' : 'var(--bg-tertiary)',
|
||||
@@ -447,7 +240,7 @@ const PlacesSidebar = React.memo(function PlacesSidebar({
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={e => { setSearch(e.target.value); if (selectMode) setSelectedIds(new Set()) }}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
placeholder={t('places.search')}
|
||||
style={{
|
||||
width: '100%', padding: '7px 30px 7px 30px', borderRadius: 10,
|
||||
@@ -570,29 +363,82 @@ const PlacesSidebar = React.memo(function PlacesSidebar({
|
||||
filtered.map(place => {
|
||||
const cat = categories.find(c => c.id === place.category_id)
|
||||
const isSelected = place.id === selectedPlaceId
|
||||
const inDay = isAssignedToSelectedDay(place.id)
|
||||
const isPlanned = plannedIds.has(place.id)
|
||||
const inDay = inDaySet.has(place.id)
|
||||
const isChecked = selectedIds.has(place.id)
|
||||
|
||||
return (
|
||||
<MemoPlaceRow
|
||||
<div
|
||||
key={place.id}
|
||||
place={place}
|
||||
category={cat}
|
||||
isSelected={isSelected}
|
||||
isPlanned={isPlanned}
|
||||
inDay={inDay}
|
||||
isChecked={isChecked}
|
||||
selectMode={selectMode}
|
||||
selectedDayId={selectedDayId}
|
||||
canEditPlaces={canEditPlaces}
|
||||
isMobile={isMobile}
|
||||
t={t}
|
||||
onPlaceClick={onPlaceClick}
|
||||
onContextMenu={openContextMenu}
|
||||
onAssignToDay={onAssignToDay}
|
||||
toggleSelected={toggleSelected}
|
||||
setDayPickerPlace={setDayPickerPlace}
|
||||
/>
|
||||
draggable
|
||||
onDragStart={e => {
|
||||
e.dataTransfer.setData('placeId', String(place.id))
|
||||
e.dataTransfer.effectAllowed = 'copy'
|
||||
// Backup in window für Cross-Component Drag (dataTransfer geht bei Re-Render verloren)
|
||||
window.__dragData = { placeId: String(place.id) }
|
||||
}}
|
||||
onClick={() => {
|
||||
if (isMobile) {
|
||||
setDayPickerPlace(place)
|
||||
} else {
|
||||
onPlaceClick(isSelected ? null : place.id)
|
||||
}
|
||||
}}
|
||||
onContextMenu={e => ctxMenu.open(e, [
|
||||
canEditPlaces && { label: t('common.edit'), icon: Pencil, onClick: () => onEditPlace(place) },
|
||||
selectedDayId && { label: t('planner.addToDay'), icon: CalendarDays, onClick: () => onAssignToDay(place.id, selectedDayId) },
|
||||
place.website && { label: t('inspector.website'), icon: ExternalLink, onClick: () => window.open(place.website, '_blank') },
|
||||
(place.lat && place.lng) && { label: 'Google Maps', icon: Navigation, onClick: () => window.open(`https://www.google.com/maps/search/?api=1&query=${place.google_place_id ? encodeURIComponent(place.name) + '&query_place_id=' + place.google_place_id : place.lat + ',' + place.lng}`, '_blank') },
|
||||
{ divider: true },
|
||||
canEditPlaces && { label: t('common.delete'), icon: Trash2, danger: true, onClick: () => onDeletePlace(place.id) },
|
||||
])}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 10,
|
||||
padding: '9px 14px 9px 16px',
|
||||
cursor: 'grab',
|
||||
background: isSelected ? 'var(--border-faint)' : 'transparent',
|
||||
borderBottom: '1px solid var(--border-faint)',
|
||||
transition: 'background 0.1s',
|
||||
contentVisibility: 'auto',
|
||||
containIntrinsicSize: '0 52px',
|
||||
}}
|
||||
onMouseEnter={e => { if (!isSelected) e.currentTarget.style.background = 'var(--bg-hover)' }}
|
||||
onMouseLeave={e => { if (!isSelected) e.currentTarget.style.background = 'transparent' }}
|
||||
>
|
||||
<PlaceAvatar place={place} category={cat} size={34} />
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 5, overflow: 'hidden' }}>
|
||||
{cat && (() => {
|
||||
const CatIcon = getCategoryIcon(cat.icon)
|
||||
return <CatIcon size={11} strokeWidth={2} color={cat.color || '#6366f1'} style={{ flexShrink: 0 }} title={cat.name} />
|
||||
})()}
|
||||
<span style={{ fontSize: 13, fontWeight: 500, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', lineHeight: 1.2 }}>
|
||||
{place.name}
|
||||
</span>
|
||||
</div>
|
||||
{(place.description || place.address || cat?.name) && (
|
||||
<div style={{ marginTop: 2 }}>
|
||||
<span style={{ fontSize: 11, color: 'var(--text-faint)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', display: 'block', lineHeight: 1.2 }}>
|
||||
{place.description || place.address || cat?.name}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ flexShrink: 0, display: 'flex', alignItems: 'center' }}>
|
||||
{!inDay && selectedDayId && (
|
||||
<button
|
||||
onClick={e => { e.stopPropagation(); onAssignToDay(place.id) }}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
width: 20, height: 20, borderRadius: 6,
|
||||
background: 'var(--bg-hover)', border: 'none', cursor: 'pointer',
|
||||
color: 'var(--text-faint)', padding: 0, transition: 'background 0.15s, color 0.15s',
|
||||
}}
|
||||
onMouseEnter={e => { e.currentTarget.style.background = 'var(--accent)'; e.currentTarget.style.color = 'var(--accent-text)' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.background = 'var(--bg-hover)'; e.currentTarget.style.color = 'var(--text-faint)' }}
|
||||
><Plus size={12} strokeWidth={2.5} /></button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
)}
|
||||
@@ -756,14 +602,6 @@ const PlacesSidebar = React.memo(function PlacesSidebar({
|
||||
initialFile={sidebarDropFile}
|
||||
/>
|
||||
<ContextMenu menu={ctxMenu.menu} onClose={ctxMenu.close} />
|
||||
{isMobile && (
|
||||
<ConfirmDialog
|
||||
isOpen={!!pendingDeleteIds?.length}
|
||||
onClose={() => setPendingDeleteIds(null)}
|
||||
onConfirm={() => { onBulkDeleteConfirm?.(pendingDeleteIds!); setPendingDeleteIds(null) }}
|
||||
message={t('trip.confirm.deletePlaces', { count: pendingDeleteIds?.length ?? 0 })}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
@@ -87,7 +87,7 @@ describe('ReservationModal', () => {
|
||||
});
|
||||
|
||||
it('FE-PLANNER-RESMODAL-003: shows "Edit Reservation" title when editing', () => {
|
||||
const res = buildReservation({ title: 'Nice Dinner', type: 'restaurant' });
|
||||
const res = buildReservation({ title: 'Flight NY', type: 'flight' });
|
||||
render(<ReservationModal {...defaultProps} reservation={res} />);
|
||||
expect(screen.getByText(/Edit Reservation/i)).toBeInTheDocument();
|
||||
});
|
||||
@@ -101,26 +101,34 @@ describe('ReservationModal', () => {
|
||||
expect(onSave).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-RESMODAL-005: all 5 type buttons are visible (transport types removed)', () => {
|
||||
it('FE-PLANNER-RESMODAL-005: all 9 type buttons are visible', () => {
|
||||
render(<ReservationModal {...defaultProps} />);
|
||||
expect(screen.getByRole('button', { name: /Flight/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /Accommodation/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /Restaurant/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /Train/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /Rental Car/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /Cruise/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /Event/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /Tour/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /Other/i })).toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: /^Flight$/i })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: /^Train$/i })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: /^Car$/i })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: /^Cruise$/i })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
// ── Type selection ──────────────────────────────────────────────────────────
|
||||
|
||||
it('FE-PLANNER-RESMODAL-006: clicking Event type button activates it', async () => {
|
||||
it('FE-PLANNER-RESMODAL-006: clicking Flight type button shows flight-specific fields', async () => {
|
||||
render(<ReservationModal {...defaultProps} />);
|
||||
const eventBtn = screen.getByRole('button', { name: /Event/i });
|
||||
await userEvent.click(eventBtn);
|
||||
expect(eventBtn).toHaveStyle({ background: 'var(--text-primary)' });
|
||||
await userEvent.click(screen.getByRole('button', { name: /Flight/i }));
|
||||
// Flight-specific airline field has placeholder="Lufthansa" (exact, not the title placeholder)
|
||||
expect(screen.getByPlaceholderText('Lufthansa')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-RESMODAL-007: flight type shows airline/airport fields', async () => {
|
||||
render(<ReservationModal {...defaultProps} />);
|
||||
await userEvent.click(screen.getByRole('button', { name: /Flight/i }));
|
||||
expect(screen.getByText(/Airline/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/^From$/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/^To$/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-RESMODAL-008: hotel type shows check-in/check-out time fields', async () => {
|
||||
@@ -131,10 +139,12 @@ describe('ReservationModal', () => {
|
||||
expect(screen.getByText(/Check-out/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-RESMODAL-009: restaurant type shows location field', async () => {
|
||||
it('FE-PLANNER-RESMODAL-009: train type shows train number/platform/seat fields', async () => {
|
||||
render(<ReservationModal {...defaultProps} />);
|
||||
await userEvent.click(screen.getByRole('button', { name: /Restaurant/i }));
|
||||
expect(screen.getByPlaceholderText(/Address, Airport/i)).toBeInTheDocument();
|
||||
await userEvent.click(screen.getByRole('button', { name: /Train/i }));
|
||||
expect(screen.getByText(/Train No\./i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Platform/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Seat/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-RESMODAL-010: hotel type hides assignment picker', async () => {
|
||||
@@ -173,10 +183,13 @@ describe('ReservationModal', () => {
|
||||
expect(screen.getByDisplayValue('Breakfast included')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-RESMODAL-014: editing pre-fills type — restaurant type shows location field', () => {
|
||||
const res = buildReservation({ type: 'restaurant', location: 'Via Roma 1' });
|
||||
it('FE-PLANNER-RESMODAL-014: editing pre-fills type — train type does not show flight fields', () => {
|
||||
const res = buildReservation({ type: 'train' });
|
||||
render(<ReservationModal {...defaultProps} reservation={res} />);
|
||||
expect(screen.getByDisplayValue('Via Roma 1')).toBeInTheDocument();
|
||||
// Flight-specific airline input has placeholder="Lufthansa" (exact) — should NOT appear for train type
|
||||
expect(screen.queryByPlaceholderText('Lufthansa')).not.toBeInTheDocument();
|
||||
// Train fields should appear
|
||||
expect(screen.getByText(/Train No\./i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// ── Validation ──────────────────────────────────────────────────────────────
|
||||
@@ -219,18 +232,18 @@ describe('ReservationModal', () => {
|
||||
|
||||
// ── Submit flow ─────────────────────────────────────────────────────────────
|
||||
|
||||
it('FE-PLANNER-RESMODAL-016: submitting valid restaurant booking calls onSave with correct shape', async () => {
|
||||
it('FE-PLANNER-RESMODAL-016: submitting valid flight calls onSave with correct shape', async () => {
|
||||
const onSave = vi.fn().mockResolvedValue(undefined);
|
||||
render(<ReservationModal {...defaultProps} onSave={onSave} />);
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: /Restaurant/i }));
|
||||
await userEvent.type(screen.getByPlaceholderText(/e\.g\. Lufthansa/i), 'Le Jules Verne');
|
||||
await userEvent.click(screen.getByRole('button', { name: /Flight/i }));
|
||||
await userEvent.type(screen.getByPlaceholderText(/e\.g\. Lufthansa/i), 'Air France 777');
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: /^Add$/i }));
|
||||
|
||||
await waitFor(() => expect(onSave).toHaveBeenCalled());
|
||||
expect(onSave).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ title: 'Le Jules Verne', type: 'restaurant' })
|
||||
expect.objectContaining({ title: 'Air France 777', type: 'flight' })
|
||||
);
|
||||
});
|
||||
|
||||
@@ -426,17 +439,17 @@ describe('ReservationModal', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('FE-PLANNER-RESMODAL-031: event type — saving calls onSave with event type', async () => {
|
||||
it('FE-PLANNER-RESMODAL-031: train type — saving calls onSave with train type', async () => {
|
||||
const onSave = vi.fn().mockResolvedValue(undefined);
|
||||
render(<ReservationModal {...defaultProps} onSave={onSave} />);
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: /Event/i }));
|
||||
await userEvent.type(screen.getByPlaceholderText(/e\.g\. Lufthansa/i), 'Louvre Museum');
|
||||
await userEvent.click(screen.getByRole('button', { name: /Train/i }));
|
||||
await userEvent.type(screen.getByPlaceholderText(/e\.g\. Lufthansa/i), 'Eurostar Paris');
|
||||
await userEvent.click(screen.getByRole('button', { name: /^Add$/i }));
|
||||
|
||||
await waitFor(() => expect(onSave).toHaveBeenCalled());
|
||||
expect(onSave).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ title: 'Louvre Museum', type: 'event' })
|
||||
expect.objectContaining({ title: 'Eurostar Paris', type: 'train' })
|
||||
);
|
||||
});
|
||||
|
||||
@@ -460,7 +473,7 @@ describe('ReservationModal', () => {
|
||||
|
||||
it('FE-PLANNER-RESMODAL-036: file upload to existing reservation calls onFileUpload', async () => {
|
||||
const onFileUpload = vi.fn().mockResolvedValue(undefined);
|
||||
const res = buildReservation({ id: 10, title: 'My Trip', type: 'other' });
|
||||
const res = buildReservation({ id: 10, title: 'My Trip', type: 'flight' });
|
||||
render(
|
||||
<ReservationModal
|
||||
{...defaultProps}
|
||||
@@ -562,18 +575,26 @@ describe('ReservationModal', () => {
|
||||
expect(screen.queryByPlaceholderText('0.00')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-RESMODAL-042: hotel type metadata saved with check-in time', 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: /Accommodation/i }));
|
||||
await userEvent.type(screen.getByPlaceholderText(/e\.g\. Lufthansa/i), 'Grand Hotel');
|
||||
await userEvent.click(screen.getByRole('button', { name: /Flight/i }));
|
||||
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.click(screen.getByRole('button', { name: /^Add$/i }));
|
||||
|
||||
await waitFor(() => expect(onSave).toHaveBeenCalled());
|
||||
expect(onSave).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ title: 'Grand Hotel', type: 'hotel' })
|
||||
expect.objectContaining({
|
||||
type: 'flight',
|
||||
metadata: expect.objectContaining({
|
||||
airline: 'Air France',
|
||||
flight_number: 'AF 447',
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
@@ -613,21 +634,22 @@ describe('ReservationModal', () => {
|
||||
expect(screen.getByText(/Budget category/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-RESMODAL-045: tour type shows time pickers', async () => {
|
||||
it('FE-PLANNER-RESMODAL-045: car type shows date/time section', async () => {
|
||||
render(<ReservationModal {...defaultProps} />);
|
||||
await userEvent.click(screen.getByRole('button', { name: /^Tour$/i }));
|
||||
await userEvent.click(screen.getByRole('button', { name: /Rental Car/i }));
|
||||
// Car type still shows date fields (not hotel which hides them)
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByTestId('time-picker').length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByTestId('date-picker').length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
it('FE-PLANNER-RESMODAL-046: other type renders and saves correctly', async () => {
|
||||
it('FE-PLANNER-RESMODAL-046: cruise type renders and saves correctly', async () => {
|
||||
const onSave = vi.fn().mockResolvedValue(undefined);
|
||||
render(<ReservationModal {...defaultProps} onSave={onSave} />);
|
||||
await userEvent.click(screen.getByRole('button', { name: /^Other$/i }));
|
||||
await userEvent.type(screen.getByPlaceholderText(/e\.g\. Lufthansa/i), 'Misc item');
|
||||
await userEvent.click(screen.getByRole('button', { name: /Cruise/i }));
|
||||
await userEvent.type(screen.getByPlaceholderText(/e\.g\. Lufthansa/i), 'Caribbean Cruise');
|
||||
await userEvent.click(screen.getByRole('button', { name: /^Add$/i }));
|
||||
await waitFor(() => expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ type: 'other' })));
|
||||
await waitFor(() => expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ type: 'cruise' })));
|
||||
});
|
||||
|
||||
it('FE-PLANNER-RESMODAL-047: clicking budget category select changes the value', async () => {
|
||||
@@ -708,17 +730,23 @@ describe('ReservationModal', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('FE-PLANNER-RESMODAL-035: hotel type saves correctly', async () => {
|
||||
it('FE-PLANNER-RESMODAL-035: flight with train number metadata saved correctly', async () => {
|
||||
const onSave = vi.fn().mockResolvedValue(undefined);
|
||||
render(<ReservationModal {...defaultProps} onSave={onSave} />);
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: /^Accommodation$/i }));
|
||||
await userEvent.type(screen.getByPlaceholderText(/e\.g\. Lufthansa/i), 'Hotel Test');
|
||||
await userEvent.click(screen.getByRole('button', { name: /Train/i }));
|
||||
await userEvent.type(screen.getByPlaceholderText(/e\.g\. Lufthansa/i), 'ICE 792');
|
||||
await userEvent.type(screen.getByPlaceholderText(/ICE 123/i), 'ICE 792');
|
||||
await userEvent.type(screen.getByPlaceholderText(/^12$/i), '5');
|
||||
await userEvent.type(screen.getByPlaceholderText(/42A/i), '14B');
|
||||
await userEvent.click(screen.getByRole('button', { name: /^Add$/i }));
|
||||
|
||||
await waitFor(() => expect(onSave).toHaveBeenCalled());
|
||||
expect(onSave).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: 'hotel' })
|
||||
expect.objectContaining({
|
||||
type: 'train',
|
||||
metadata: expect.objectContaining({ train_number: 'ICE 792', platform: '5', seat: '14B' }),
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,17 +5,72 @@ import { useTripStore } from '../../store/tripStore'
|
||||
import { useAddonStore } from '../../store/addonStore'
|
||||
import Modal from '../shared/Modal'
|
||||
import CustomSelect from '../shared/CustomSelect'
|
||||
import { Hotel, Utensils, Ticket, FileText, Users, Paperclip, X, ExternalLink, Link2 } from 'lucide-react'
|
||||
import { Plane, Hotel, Utensils, Train, Car, Ship, Ticket, FileText, Users, Paperclip, X, ExternalLink, Link2 } from 'lucide-react'
|
||||
import { useToast } from '../shared/Toast'
|
||||
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 },
|
||||
{ value: 'hotel', labelKey: 'reservations.type.hotel', Icon: Hotel },
|
||||
{ value: 'restaurant', labelKey: 'reservations.type.restaurant', Icon: Utensils },
|
||||
{ value: 'train', labelKey: 'reservations.type.train', Icon: Train },
|
||||
{ value: 'car', labelKey: 'reservations.type.car', Icon: Car },
|
||||
{ value: 'cruise', labelKey: 'reservations.type.cruise', Icon: Ship },
|
||||
{ value: 'event', labelKey: 'reservations.type.event', Icon: Ticket },
|
||||
{ value: 'tour', labelKey: 'reservations.type.tour', Icon: Users },
|
||||
{ value: 'other', labelKey: 'reservations.type.other', Icon: FileText },
|
||||
@@ -29,6 +84,7 @@ function buildAssignmentOptions(days, assignments, t, locale) {
|
||||
const dayLabel = day.title || t('dayplan.dayN', { n: day.day_number })
|
||||
const dateStr = day.date ? ` · ${formatDate(day.date, locale)}` : ''
|
||||
const groupLabel = `${dayLabel}${dateStr}`
|
||||
// Group header (non-selectable)
|
||||
options.push({ value: `_header_${day.id}`, label: groupLabel, disabled: true, isHeader: true })
|
||||
for (let i = 0; i < da.length; i++) {
|
||||
const place = da[i].place
|
||||
@@ -59,10 +115,9 @@ interface ReservationModalProps {
|
||||
onFileUpload?: (fd: FormData) => Promise<void>
|
||||
onFileDelete: (fileId: number) => Promise<void>
|
||||
accommodations?: Accommodation[]
|
||||
defaultAssignmentId?: number | null
|
||||
}
|
||||
|
||||
export function ReservationModal({ isOpen, onClose, onSave, reservation, days, places, assignments, selectedDayId, files = [], onFileUpload, onFileDelete, accommodations = [], defaultAssignmentId = null }: ReservationModalProps) {
|
||||
export function ReservationModal({ isOpen, onClose, onSave, reservation, days, places, assignments, selectedDayId, files = [], onFileUpload, onFileDelete, accommodations = [] }: ReservationModalProps) {
|
||||
const { id: tripId } = useParams<{ id: string }>()
|
||||
const loadFiles = useTripStore(s => s.loadFiles)
|
||||
const toast = useToast()
|
||||
@@ -80,16 +135,22 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
||||
const [form, setForm] = useState({
|
||||
title: '', type: 'other', status: 'pending',
|
||||
reservation_time: '', reservation_end_time: '', end_date: '', location: '', confirmation_number: '',
|
||||
notes: '', assignment_id: '' as string | number, accommodation_id: '' as string | number,
|
||||
notes: '', assignment_id: '', accommodation_id: '',
|
||||
price: '', budget_category: '',
|
||||
meta_airline: '', meta_flight_number: '', meta_departure_airport: '', meta_arrival_airport: '',
|
||||
meta_departure_timezone: '', meta_arrival_timezone: '',
|
||||
meta_train_number: '', meta_platform: '', meta_seat: '',
|
||||
meta_check_in_time: '', meta_check_in_end_time: '', meta_check_out_time: '',
|
||||
hotel_place_id: '' as string | number, hotel_start_day: '' as string | number, hotel_end_day: '' as string | number,
|
||||
hotel_place_id: '', hotel_start_day: '', hotel_end_day: '',
|
||||
})
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
const [uploadingFile, setUploadingFile] = useState(false)
|
||||
const [pendingFiles, setPendingFiles] = useState([])
|
||||
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),
|
||||
@@ -99,6 +160,7 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
||||
useEffect(() => {
|
||||
if (reservation) {
|
||||
const meta = typeof reservation.metadata === 'string' ? JSON.parse(reservation.metadata || '{}') : (reservation.metadata || {})
|
||||
// Parse end_date from reservation_end_time if it's a full ISO datetime
|
||||
const rawEnd = reservation.reservation_end_time || ''
|
||||
let endDate = ''
|
||||
let endTime = rawEnd
|
||||
@@ -121,6 +183,15 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
||||
notes: reservation.notes || '',
|
||||
assignment_id: reservation.assignment_id || '',
|
||||
accommodation_id: reservation.accommodation_id || '',
|
||||
meta_airline: meta.airline || '',
|
||||
meta_flight_number: meta.flight_number || '',
|
||||
meta_departure_airport: meta.departure_airport || '',
|
||||
meta_arrival_airport: meta.arrival_airport || '',
|
||||
meta_departure_timezone: meta.departure_timezone || '',
|
||||
meta_arrival_timezone: meta.arrival_timezone || '',
|
||||
meta_train_number: meta.train_number || '',
|
||||
meta_platform: meta.platform || '',
|
||||
meta_seat: meta.seat || '',
|
||||
meta_check_in_time: meta.check_in_time || '',
|
||||
meta_check_in_end_time: meta.check_in_end_time || '',
|
||||
meta_check_out_time: meta.check_out_time || '',
|
||||
@@ -130,26 +201,61 @@ 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',
|
||||
reservation_time: '', reservation_end_time: '', end_date: '', location: '', confirmation_number: '',
|
||||
notes: '', assignment_id: defaultAssignmentId ?? '', accommodation_id: '',
|
||||
notes: '', assignment_id: '', accommodation_id: '',
|
||||
price: '', budget_category: '',
|
||||
meta_airline: '', meta_flight_number: '', meta_departure_airport: '', meta_arrival_airport: '',
|
||||
meta_departure_timezone: '', meta_arrival_timezone: '',
|
||||
meta_train_number: '', meta_platform: '', meta_seat: '',
|
||||
meta_check_in_time: '', meta_check_in_end_time: '', meta_check_out_time: '',
|
||||
hotel_place_id: '', hotel_start_day: '', hotel_end_day: '',
|
||||
})
|
||||
setPendingFiles([])
|
||||
setFromPick({})
|
||||
setToPick({})
|
||||
}
|
||||
}, [reservation, isOpen, selectedDayId, defaultAssignmentId])
|
||||
}, [reservation, isOpen, selectedDayId])
|
||||
|
||||
const set = (field, value) => setForm(prev => ({ ...prev, [field]: value }))
|
||||
|
||||
// Validate that end datetime is after start datetime
|
||||
const isEndBeforeStart = (() => {
|
||||
if (!form.end_date || !form.reservation_time) return false
|
||||
const startDate = form.reservation_time.split('T')[0]
|
||||
const startTime = form.reservation_time.split('T')[1] || '00:00'
|
||||
const endTime = form.reservation_end_time || '00:00'
|
||||
// For flights, compare in UTC using timezone offsets
|
||||
if (form.type === 'flight') {
|
||||
const parseOffset = (tz: string): number | null => {
|
||||
if (!tz) return null
|
||||
const m = tz.trim().match(/^(?:UTC|GMT)?\s*([+-])(\d{1,2})(?::(\d{2}))?$/i)
|
||||
if (!m) return null
|
||||
const sign = m[1] === '+' ? 1 : -1
|
||||
return sign * (parseInt(m[2]) * 60 + parseInt(m[3] || '0'))
|
||||
}
|
||||
const depOffset = parseOffset(form.meta_departure_timezone)
|
||||
const arrOffset = parseOffset(form.meta_arrival_timezone)
|
||||
if (depOffset === null || arrOffset === null) return false
|
||||
const depMinutes = new Date(`${startDate}T${startTime}`).getTime() - depOffset * 60000
|
||||
const arrMinutes = new Date(`${form.end_date}T${endTime}`).getTime() - arrOffset * 60000
|
||||
return arrMinutes <= depMinutes
|
||||
}
|
||||
const startFull = `${startDate}T${startTime}`
|
||||
const endFull = `${form.end_date}T${endTime}`
|
||||
return endFull <= startFull
|
||||
@@ -162,11 +268,27 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
||||
setIsSaving(true)
|
||||
try {
|
||||
const metadata: Record<string, string> = {}
|
||||
if (form.type === 'hotel') {
|
||||
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 (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
|
||||
if (form.meta_check_out_time) metadata.check_out_time = form.meta_check_out_time
|
||||
} else if (form.type === 'train') {
|
||||
if (form.meta_train_number) metadata.train_number = form.meta_train_number
|
||||
if (form.meta_platform) metadata.platform = form.meta_platform
|
||||
if (form.meta_seat) metadata.seat = form.meta_seat
|
||||
}
|
||||
// Combine end_date + end_time into reservation_end_time
|
||||
let combinedEndTime = form.reservation_end_time
|
||||
if (form.end_date) {
|
||||
combinedEndTime = form.reservation_end_time ? `${form.end_date}T${form.reservation_end_time}` : form.end_date
|
||||
@@ -175,24 +297,40 @@ 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 || null),
|
||||
reservation_end_time: form.type === 'hotel' ? null : (combinedEndTime || null),
|
||||
reservation_time: form.type === 'hotel' ? null : form.reservation_time,
|
||||
reservation_end_time: form.type === 'hotel' ? null : combinedEndTime,
|
||||
location: form.location, confirmation_number: form.confirmation_number,
|
||||
notes: form.notes,
|
||||
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: [],
|
||||
endpoints: isTransport(form.type) ? endpoints : [],
|
||||
needs_review: false,
|
||||
}
|
||||
// Auto-create/update budget entry if price is set, or signal removal if cleared
|
||||
if (isBudgetEnabled) {
|
||||
saveData.create_budget_entry = form.price && parseFloat(form.price) > 0
|
||||
? { total_price: parseFloat(form.price), category: form.budget_category || t(`reservations.type.${form.type}`) || 'Other' }
|
||||
: { total_price: 0 }
|
||||
}
|
||||
// If hotel with place + days, pass hotel data for auto-creation or update
|
||||
if (form.type === 'hotel' && form.hotel_place_id && form.hotel_start_day && form.hotel_end_day) {
|
||||
saveData.create_accommodation = {
|
||||
place_id: form.hotel_place_id,
|
||||
@@ -290,7 +428,7 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
||||
|
||||
{/* Assignment Picker (hidden for hotels) */}
|
||||
{form.type !== 'hotel' && assignmentOptions.length > 0 && (
|
||||
<div>
|
||||
<div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<label style={labelStyle}>
|
||||
<Link2 size={10} style={{ display: 'inline', verticalAlign: '-1px', marginRight: 3 }} />
|
||||
@@ -317,88 +455,139 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Start Date/Time + End Date/Time + Status (hidden for hotels) */}
|
||||
{form.type !== 'hotel' && (
|
||||
<>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<label style={labelStyle}>{t('reservations.date')}</label>
|
||||
<CustomDatePicker
|
||||
value={(() => { const [d] = (form.reservation_time || '').split('T'); return d || '' })()}
|
||||
onChange={d => {
|
||||
const [, tm] = (form.reservation_time || '').split('T')
|
||||
set('reservation_time', d ? (tm ? `${d}T${tm}` : d) : '')
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<label style={labelStyle}>{t('reservations.startTime')}</label>
|
||||
<CustomTimePicker
|
||||
value={(() => { const [, tm] = (form.reservation_time || '').split('T'); return tm || '' })()}
|
||||
onChange={tm => {
|
||||
const [d] = (form.reservation_time || '').split('T')
|
||||
const selectedDay = days.find(dy => dy.id === selectedDayId)
|
||||
const date = d || selectedDay?.date || new Date().toISOString().split('T')[0]
|
||||
set('reservation_time', tm ? `${date}T${tm}` : date)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<label style={labelStyle}>{form.type === 'flight' ? t('reservations.departureDate') : form.type === 'car' ? t('reservations.pickupDate') : t('reservations.date')}</label>
|
||||
<CustomDatePicker
|
||||
value={(() => { const [d] = (form.reservation_time || '').split('T'); return d || '' })()}
|
||||
onChange={d => {
|
||||
const [, t] = (form.reservation_time || '').split('T')
|
||||
set('reservation_time', d ? (t ? `${d}T${t}` : d) : '')
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<label style={labelStyle}>{t('reservations.endDate')}</label>
|
||||
<CustomDatePicker
|
||||
value={form.end_date}
|
||||
onChange={d => set('end_date', d || '')}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<label style={labelStyle}>{t('reservations.endTime')}</label>
|
||||
<CustomTimePicker value={form.reservation_end_time} onChange={v => set('reservation_end_time', v)} />
|
||||
</div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<label style={labelStyle}>{form.type === 'flight' ? t('reservations.departureTime') : form.type === 'car' ? t('reservations.pickupTime') : t('reservations.startTime')}</label>
|
||||
<CustomTimePicker
|
||||
value={(() => { const [, t] = (form.reservation_time || '').split('T'); return t || '' })()}
|
||||
onChange={t => {
|
||||
const [d] = (form.reservation_time || '').split('T')
|
||||
const selectedDay = days.find(dy => dy.id === selectedDayId)
|
||||
const date = d || selectedDay?.date || new Date().toISOString().split('T')[0]
|
||||
set('reservation_time', t ? `${date}T${t}` : date)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{isEndBeforeStart && (
|
||||
<div style={{ fontSize: 11, color: '#ef4444', marginTop: -6 }}>{t('reservations.validation.endBeforeStart')}</div>
|
||||
{form.type === 'flight' && fromPick.airport && (
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<label style={labelStyle}>{t('reservations.meta.departureTimezone')}</label>
|
||||
<div style={{ ...inputStyle, padding: '8px 12px', color: 'var(--text-muted)', fontSize: 12, background: 'var(--bg-tertiary)' }}>
|
||||
{fromPick.airport.tz}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<label style={labelStyle}>{form.type === 'flight' ? t('reservations.arrivalDate') : form.type === 'car' ? t('reservations.returnDate') : t('reservations.endDate')}</label>
|
||||
<CustomDatePicker
|
||||
value={form.end_date}
|
||||
onChange={d => set('end_date', d || '')}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<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' && toPick.airport && (
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<label style={labelStyle}>{t('reservations.meta.arrivalTimezone')}</label>
|
||||
<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 */}
|
||||
{form.type !== 'hotel' && (
|
||||
{/* Location + Booking Code */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<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>
|
||||
|
||||
{/* Hotel 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-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)}
|
||||
placeholder="Lufthansa" style={inputStyle} />
|
||||
</div>
|
||||
<div>
|
||||
<label style={labelStyle}>{t('reservations.meta.flightNumber') || 'Flight No.'}</label>
|
||||
<input type="text" value={form.meta_flight_number} onChange={e => set('meta_flight_number', e.target.value)}
|
||||
placeholder="LH 123" style={inputStyle} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{form.type === 'hotel' && (
|
||||
<>
|
||||
{/* Hotel place + day range */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||
<div>
|
||||
<label style={labelStyle}>{t('reservations.meta.hotelPlace')}</label>
|
||||
@@ -442,7 +631,8 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||
{/* Check-in/out times + Status */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 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)} />
|
||||
@@ -455,10 +645,42 @@ 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>
|
||||
</>
|
||||
)}
|
||||
|
||||
{form.type === 'train' && (
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div>
|
||||
<label style={labelStyle}>{t('reservations.meta.trainNumber') || 'Train No.'}</label>
|
||||
<input type="text" value={form.meta_train_number} onChange={e => set('meta_train_number', e.target.value)}
|
||||
placeholder="ICE 123" style={inputStyle} />
|
||||
</div>
|
||||
<div>
|
||||
<label style={labelStyle}>{t('reservations.meta.platform') || 'Platform'}</label>
|
||||
<input type="text" value={form.meta_platform} onChange={e => set('meta_platform', e.target.value)}
|
||||
placeholder="12" style={inputStyle} />
|
||||
</div>
|
||||
<div>
|
||||
<label style={labelStyle}>{t('reservations.meta.seat') || 'Seat'}</label>
|
||||
<input type="text" value={form.meta_seat} onChange={e => set('meta_seat', e.target.value)}
|
||||
placeholder="42A" style={inputStyle} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Notes */}
|
||||
<div>
|
||||
<label style={labelStyle}>{t('reservations.notes')}</label>
|
||||
@@ -477,9 +699,12 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
||||
<span style={{ flex: 1, fontSize: 12, color: 'var(--text-secondary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{f.original_name}</span>
|
||||
<a href="#" onClick={(e) => { e.preventDefault(); openFile(f.url).catch(() => {}) }} style={{ color: 'var(--text-faint)', display: 'flex', flexShrink: 0, cursor: 'pointer' }}><ExternalLink size={11} /></a>
|
||||
<button type="button" onClick={async () => {
|
||||
// Always unlink, never delete the file
|
||||
// Clear primary reservation_id if it points to this reservation
|
||||
if (f.reservation_id === reservation?.id) {
|
||||
try { await apiClient.put(`/trips/${tripId}/files/${f.id}`, { reservation_id: null }) } catch {}
|
||||
}
|
||||
// Remove from file_links if linked there
|
||||
try {
|
||||
const linksRes = await apiClient.get(`/trips/${tripId}/files/${f.id}/links`)
|
||||
const link = (linksRes.data.links || []).find((l: any) => l.reservation_id === reservation?.id)
|
||||
@@ -512,6 +737,7 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
||||
<Paperclip size={11} />
|
||||
{uploadingFile ? t('reservations.uploading') : t('reservations.attachFile')}
|
||||
</button>}
|
||||
{/* Link existing file picker */}
|
||||
{reservation?.id && files.filter(f => !f.deleted_at && !attachedFiles.some(af => af.id === f.id)).length > 0 && (
|
||||
<div style={{ position: 'relative' }}>
|
||||
<button type="button" onClick={() => setShowFilePicker(v => !v)} style={{
|
||||
@@ -555,7 +781,7 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Price + Budget Category */}
|
||||
{/* Price + Budget Category — only shown when budget addon is enabled */}
|
||||
{isBudgetEnabled && (
|
||||
<>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
@@ -563,7 +789,7 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
||||
<label style={labelStyle}>{t('reservations.price')}</label>
|
||||
<input type="text" inputMode="decimal" value={form.price}
|
||||
onChange={e => { const v = e.target.value; if (v === '' || /^\d*[.,]?\d{0,2}$/.test(v)) set('price', v.replace(',', '.')) }}
|
||||
onPaste={e => { e.preventDefault(); let txt = e.clipboardData.getData('text').trim().replace(/[^\d.,-]/g, ''); const lc = txt.lastIndexOf(','), ld = txt.lastIndexOf('.'), dp = Math.max(lc, ld); if (dp > -1) { txt = txt.substring(0, dp).replace(/[.,]/g, '') + '.' + txt.substring(dp + 1) } else { txt = txt.replace(/[.,]/g, '') } set('price', txt) }}
|
||||
onPaste={e => { e.preventDefault(); let t = e.clipboardData.getData('text').trim().replace(/[^\d.,-]/g, ''); const lc = t.lastIndexOf(','), ld = t.lastIndexOf('.'), dp = Math.max(lc, ld); if (dp > -1) { t = t.substring(0, dp).replace(/[.,]/g, '') + '.' + t.substring(dp + 1) } else { t = t.replace(/[.,]/g, '') } set('price', t) }}
|
||||
placeholder="0.00"
|
||||
style={inputStyle} />
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useMemo, useEffect } from 'react'
|
||||
import { useState, useMemo } from 'react'
|
||||
import ReactDOM from 'react-dom'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
import { useCanDo } from '../../store/permissionsStore'
|
||||
@@ -69,10 +69,9 @@ interface ReservationCardProps {
|
||||
onNavigateToFiles: () => void
|
||||
assignmentLookup: Record<number, AssignmentLookupEntry>
|
||||
canEdit: boolean
|
||||
days?: Day[]
|
||||
}
|
||||
|
||||
function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateToFiles, assignmentLookup, canEdit, days = [] }: ReservationCardProps) {
|
||||
function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateToFiles, assignmentLookup, canEdit }: ReservationCardProps) {
|
||||
const { toggleReservationStatus } = useTripStore()
|
||||
const toast = useToast()
|
||||
const { t, locale } = useTranslation()
|
||||
@@ -110,21 +109,6 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo
|
||||
const hasCode = !!r.confirmation_number
|
||||
const dateCols = [hasDate, hasTime, hasCode].filter(Boolean).length
|
||||
|
||||
const TRANSPORT_TYPES_SET = new Set(['flight', 'train', 'bus', 'car', 'cruise'])
|
||||
const isTransportType = TRANSPORT_TYPES_SET.has(r.type)
|
||||
const startDay = r.day_id ? days.find(d => d.id === r.day_id) : undefined
|
||||
const endDay = r.end_day_id ? days.find(d => d.id === r.end_day_id) : undefined
|
||||
const dayLabel = (day: typeof startDay): string => {
|
||||
if (!day) return ''
|
||||
const base = day.title || t('dayplan.dayN', { n: day.day_number })
|
||||
if (day.date) {
|
||||
const d = new Date(day.date + 'T00:00:00Z')
|
||||
const dateStr = d.toLocaleDateString(locale, { day: 'numeric', month: 'short', timeZone: 'UTC' })
|
||||
return `${base} · ${dateStr}`
|
||||
}
|
||||
return base
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
borderRadius: 12, overflow: 'hidden', display: 'flex', flexDirection: 'column',
|
||||
@@ -202,15 +186,6 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo
|
||||
|
||||
{/* Body */}
|
||||
<div style={{ padding: 14, display: 'flex', flexDirection: 'column', gap: 12, flex: 1 }}>
|
||||
{/* Day label for transport reservations linked to a day */}
|
||||
{isTransportType && startDay && (
|
||||
<div>
|
||||
<div style={fieldLabelStyle}>{t('reservations.date')}</div>
|
||||
<div style={{ ...fieldValueStyle, textAlign: 'center' }}>
|
||||
{dayLabel(startDay)}{endDay && endDay.id !== startDay.id ? ` – ${dayLabel(endDay)}` : ''}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* Date / Time row */}
|
||||
{hasDate && (
|
||||
<div style={{ display: 'grid', gap: 10, gridTemplateColumns: hasTime ? '1fr 1fr' : '1fr' }}>
|
||||
@@ -407,20 +382,10 @@ interface SectionProps {
|
||||
children: React.ReactNode
|
||||
defaultOpen?: boolean
|
||||
accent: 'green' | string
|
||||
storageKey?: string
|
||||
}
|
||||
|
||||
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])
|
||||
function Section({ title, count, children, defaultOpen = true, accent }: SectionProps) {
|
||||
const [open, setOpen] = useState(defaultOpen)
|
||||
return (
|
||||
<div style={{ marginBottom: 28 }}>
|
||||
<button onClick={() => setOpen(o => !o)} style={{
|
||||
@@ -455,11 +420,9 @@ interface ReservationsPanelProps {
|
||||
onEdit: (reservation: Reservation) => void
|
||||
onDelete: (id: number) => void
|
||||
onNavigateToFiles: () => void
|
||||
titleKey?: string
|
||||
addManualKey?: string
|
||||
}
|
||||
|
||||
export default function ReservationsPanel({ tripId, reservations, days, assignments, files = [], onAdd, onEdit, onDelete, onNavigateToFiles, titleKey = 'reservations.title', addManualKey = 'reservations.addManual' }: ReservationsPanelProps) {
|
||||
export default function ReservationsPanel({ tripId, reservations, days, assignments, files = [], onAdd, onEdit, onDelete, onNavigateToFiles }: ReservationsPanelProps) {
|
||||
const { t, locale } = useTranslation()
|
||||
const can = useCanDo()
|
||||
const trip = useTripStore((s) => s.trip)
|
||||
@@ -510,7 +473,7 @@ export default function ReservationsPanel({ tripId, reservations, days, assignme
|
||||
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(titleKey)}
|
||||
{t('reservations.title')}
|
||||
</h2>
|
||||
|
||||
{reservations.length > 0 && (
|
||||
@@ -584,7 +547,7 @@ export default function ReservationsPanel({ tripId, reservations, days, assignme
|
||||
onMouseLeave={e => e.currentTarget.style.opacity = '1'}
|
||||
>
|
||||
<Plus size={14} strokeWidth={2.5} />
|
||||
<span className="hidden sm:inline">{t(addManualKey)}</span>
|
||||
<span className="hidden sm:inline">{t('reservations.addManual')}</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
@@ -605,13 +568,13 @@ export default function ReservationsPanel({ tripId, reservations, days, assignme
|
||||
) : (
|
||||
<>
|
||||
{allPending.length > 0 && (
|
||||
<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} days={days} />)}
|
||||
<Section title={t('reservations.pending')} count={allPending.length} accent="gray">
|
||||
{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" 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} days={days} />)}
|
||||
<Section title={t('reservations.confirmed')} count={allConfirmed.length} accent="green">
|
||||
{allConfirmed.map(r => <ReservationCard key={r.id} r={r} tripId={tripId} onEdit={onEdit} onDelete={onDelete} files={files} onNavigateToFiles={onNavigateToFiles} assignmentLookup={assignmentLookup} canEdit={canEdit} />)}
|
||||
</Section>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -1,422 +0,0 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Plane, Train, Car, Ship } from 'lucide-react'
|
||||
import Modal from '../shared/Modal'
|
||||
import CustomSelect from '../shared/CustomSelect'
|
||||
import CustomTimePicker from '../shared/CustomTimePicker'
|
||||
import AirportSelect, { type Airport } from './AirportSelect'
|
||||
import LocationSelect, { type LocationPoint } from './LocationSelect'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { formatDate } from '../../utils/formatters'
|
||||
import type { Day, Reservation, ReservationEndpoint } from '../../types'
|
||||
|
||||
const TRANSPORT_TYPES = ['flight', 'train', 'car', 'cruise'] as const
|
||||
type TransportType = typeof TRANSPORT_TYPES[number]
|
||||
|
||||
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 },
|
||||
{ value: 'train', labelKey: 'reservations.type.train', Icon: Train },
|
||||
{ value: 'car', labelKey: 'reservations.type.car', Icon: Car },
|
||||
{ value: 'cruise', labelKey: 'reservations.type.cruise', Icon: Ship },
|
||||
]
|
||||
|
||||
const defaultForm = {
|
||||
title: '',
|
||||
type: 'flight' as TransportType,
|
||||
status: 'pending' as 'pending' | 'confirmed',
|
||||
start_day_id: '' as string | number,
|
||||
end_day_id: '' as string | number,
|
||||
departure_time: '',
|
||||
arrival_time: '',
|
||||
confirmation_number: '',
|
||||
notes: '',
|
||||
meta_airline: '',
|
||||
meta_flight_number: '',
|
||||
meta_train_number: '',
|
||||
meta_platform: '',
|
||||
meta_seat: '',
|
||||
}
|
||||
|
||||
interface TransportModalProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
onSave: (data: Record<string, any>) => Promise<void>
|
||||
reservation: Reservation | null
|
||||
days: Day[]
|
||||
selectedDayId: number | null
|
||||
}
|
||||
|
||||
export function TransportModal({ isOpen, onClose, onSave, reservation, days, selectedDayId }: TransportModalProps) {
|
||||
const { t, locale } = useTranslation()
|
||||
const toast = useToast()
|
||||
const [form, setForm] = useState({ ...defaultForm })
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
const [fromPick, setFromPick] = useState<EndpointPick>({})
|
||||
const [toPick, setToPick] = useState<EndpointPick>({})
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return
|
||||
if (reservation) {
|
||||
const meta = typeof reservation.metadata === 'string'
|
||||
? JSON.parse(reservation.metadata || '{}')
|
||||
: (reservation.metadata || {})
|
||||
const eps = reservation.endpoints || []
|
||||
const from = eps.find(e => e.role === 'from')
|
||||
const to = eps.find(e => e.role === 'to')
|
||||
const type = (TRANSPORT_TYPES as readonly string[]).includes(reservation.type)
|
||||
? reservation.type as TransportType
|
||||
: 'flight'
|
||||
setForm({
|
||||
title: reservation.title || '',
|
||||
type,
|
||||
status: reservation.status || 'pending',
|
||||
start_day_id: reservation.day_id ?? '',
|
||||
end_day_id: reservation.end_day_id ?? '',
|
||||
departure_time: reservation.reservation_time?.split('T')[1]?.slice(0, 5) ?? '',
|
||||
arrival_time: reservation.reservation_end_time?.split('T')[1]?.slice(0, 5) ?? '',
|
||||
confirmation_number: reservation.confirmation_number || '',
|
||||
notes: reservation.notes || '',
|
||||
meta_airline: meta.airline || '',
|
||||
meta_flight_number: meta.flight_number || '',
|
||||
meta_train_number: meta.train_number || '',
|
||||
meta_platform: meta.platform || '',
|
||||
meta_seat: meta.seat || '',
|
||||
})
|
||||
if (type === 'flight') {
|
||||
setFromPick({ airport: airportFromEndpoint(from) || undefined })
|
||||
setToPick({ airport: airportFromEndpoint(to) || undefined })
|
||||
} else {
|
||||
setFromPick({ location: locationFromEndpoint(from) || undefined })
|
||||
setToPick({ location: locationFromEndpoint(to) || undefined })
|
||||
}
|
||||
} else {
|
||||
setForm({ ...defaultForm, start_day_id: selectedDayId ?? '' })
|
||||
setFromPick({})
|
||||
setToPick({})
|
||||
}
|
||||
}, [isOpen, reservation, selectedDayId])
|
||||
|
||||
const set = (field: string, value: any) => setForm(prev => ({ ...prev, [field]: value }))
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!form.title.trim()) return
|
||||
setIsSaving(true)
|
||||
try {
|
||||
const startDay = days.find(d => d.id === Number(form.start_day_id))
|
||||
const endDay = days.find(d => d.id === Number(form.end_day_id))
|
||||
|
||||
const buildTime = (day: Day | undefined, time: string): string | null => {
|
||||
if (!time) return null
|
||||
return day?.date ? `${day.date}T${time}` : `T${time}`
|
||||
}
|
||||
|
||||
const metadata: Record<string, string> = {}
|
||||
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 (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 === 'train') {
|
||||
if (form.meta_train_number) metadata.train_number = form.meta_train_number
|
||||
if (form.meta_platform) metadata.platform = form.meta_platform
|
||||
if (form.meta_seat) metadata.seat = form.meta_seat
|
||||
}
|
||||
|
||||
const startDate = startDay?.date ?? null
|
||||
const endDate = (endDay ?? startDay)?.date ?? null
|
||||
const endpoints: ReturnType<typeof endpointFromAirport>[] = []
|
||||
if (form.type === 'flight') {
|
||||
if (fromPick.airport) endpoints.push(endpointFromAirport(fromPick.airport, 'from', 0, startDate, form.departure_time || null))
|
||||
if (toPick.airport) endpoints.push(endpointFromAirport(toPick.airport, 'to', 1, endDate, form.arrival_time || null))
|
||||
} else {
|
||||
if (fromPick.location) endpoints.push(endpointFromLocation(fromPick.location, 'from', 0, startDate, form.departure_time || null))
|
||||
if (toPick.location) endpoints.push(endpointFromLocation(toPick.location, 'to', 1, endDate, form.arrival_time || null))
|
||||
}
|
||||
|
||||
const payload = {
|
||||
title: form.title,
|
||||
type: form.type,
|
||||
status: form.status,
|
||||
day_id: form.start_day_id ? Number(form.start_day_id) : null,
|
||||
end_day_id: form.end_day_id ? Number(form.end_day_id) : null,
|
||||
reservation_time: buildTime(startDay, form.departure_time),
|
||||
reservation_end_time: buildTime(endDay ?? startDay, form.arrival_time),
|
||||
location: null,
|
||||
confirmation_number: form.confirmation_number || null,
|
||||
notes: form.notes || null,
|
||||
metadata: Object.keys(metadata).length > 0 ? metadata : null,
|
||||
endpoints,
|
||||
needs_review: false,
|
||||
}
|
||||
await onSave(payload)
|
||||
} catch (err: unknown) {
|
||||
toast.error(err instanceof Error ? err.message : t('common.unknownError'))
|
||||
} finally {
|
||||
setIsSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const inputStyle = {
|
||||
width: '100%', border: '1px solid var(--border-primary)', borderRadius: 10,
|
||||
padding: '8px 12px', fontSize: 13, fontFamily: 'inherit',
|
||||
outline: 'none', boxSizing: 'border-box' as const, color: 'var(--text-primary)', background: 'var(--bg-input)',
|
||||
}
|
||||
const labelStyle = {
|
||||
display: 'block', fontSize: 11, fontWeight: 600, color: 'var(--text-faint)',
|
||||
marginBottom: 5, textTransform: 'uppercase' as const, letterSpacing: '0.03em',
|
||||
}
|
||||
|
||||
const dayOptions = [
|
||||
{ value: '', label: '—' },
|
||||
...days.map(d => ({
|
||||
value: d.id,
|
||||
label: d.title || `${t('dayplan.dayN', { n: d.day_number })}${d.date ? ` · ${formatDate(d.date, locale) ?? ''}` : ''}`,
|
||||
})),
|
||||
]
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title={reservation ? t('transport.modalTitle.edit') : t('transport.modalTitle.create')}
|
||||
size="2xl"
|
||||
>
|
||||
<form onSubmit={handleSubmit} style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||||
|
||||
{/* Type selector */}
|
||||
<div>
|
||||
<label style={labelStyle}>{t('reservations.bookingType')}</label>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 5 }}>
|
||||
{TYPE_OPTIONS.map(({ value, labelKey, Icon }) => (
|
||||
<button key={value} type="button" onClick={() => set('type', value)} style={{
|
||||
display: 'flex', alignItems: 'center', gap: 4,
|
||||
padding: '5px 10px', borderRadius: 99, border: '1px solid',
|
||||
fontSize: 11, fontWeight: 500, cursor: 'pointer', fontFamily: 'inherit', transition: 'all 0.12s',
|
||||
background: form.type === value ? 'var(--text-primary)' : 'var(--bg-card)',
|
||||
borderColor: form.type === value ? 'var(--text-primary)' : 'var(--border-primary)',
|
||||
color: form.type === value ? 'var(--bg-primary)' : 'var(--text-muted)',
|
||||
}}>
|
||||
<Icon size={11} /> {t(labelKey)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<div>
|
||||
<label style={labelStyle}>{t('reservations.titleLabel')} *</label>
|
||||
<input type="text" value={form.title} onChange={e => set('title', e.target.value)} required
|
||||
placeholder={t('reservations.titlePlaceholder')} style={inputStyle} />
|
||||
</div>
|
||||
|
||||
{/* From / To endpoints */}
|
||||
<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>
|
||||
|
||||
{/* Departure row */}
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<label style={labelStyle}>
|
||||
{form.type === 'flight' ? t('reservations.departureDate') : form.type === 'car' ? t('reservations.pickupDate') : t('reservations.date')}
|
||||
</label>
|
||||
<CustomSelect
|
||||
value={form.start_day_id}
|
||||
onChange={value => set('start_day_id', value)}
|
||||
placeholder={t('dayplan.dayN', { n: '?' })}
|
||||
options={dayOptions}
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<label style={labelStyle}>
|
||||
{form.type === 'flight' ? t('reservations.departureTime') : form.type === 'car' ? t('reservations.pickupTime') : t('reservations.startTime')}
|
||||
</label>
|
||||
<CustomTimePicker value={form.departure_time} onChange={v => set('departure_time', v)} />
|
||||
</div>
|
||||
{form.type === 'flight' && fromPick.airport && (
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<label style={labelStyle}>{t('reservations.meta.departureTimezone')}</label>
|
||||
<div style={{ ...inputStyle, padding: '8px 12px', color: 'var(--text-muted)', fontSize: 12, background: 'var(--bg-tertiary)' }}>
|
||||
{fromPick.airport.tz}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Arrival row */}
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<label style={labelStyle}>
|
||||
{form.type === 'flight' ? t('reservations.arrivalDate') : form.type === 'car' ? t('reservations.returnDate') : t('reservations.endDate')}
|
||||
</label>
|
||||
<CustomSelect
|
||||
value={form.end_day_id}
|
||||
onChange={value => set('end_day_id', value)}
|
||||
placeholder={t('dayplan.dayN', { n: '?' })}
|
||||
options={dayOptions}
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<label style={labelStyle}>
|
||||
{form.type === 'flight' ? t('reservations.arrivalTime') : form.type === 'car' ? t('reservations.returnTime') : t('reservations.endTime')}
|
||||
</label>
|
||||
<CustomTimePicker value={form.arrival_time} onChange={v => set('arrival_time', v)} />
|
||||
</div>
|
||||
{form.type === 'flight' && toPick.airport && (
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<label style={labelStyle}>{t('reservations.meta.arrivalTimezone')}</label>
|
||||
<div style={{ ...inputStyle, padding: '8px 12px', color: 'var(--text-muted)', fontSize: 12, background: 'var(--bg-tertiary)' }}>
|
||||
{toPick.airport.tz}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Flight-specific fields */}
|
||||
{form.type === 'flight' && (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label style={labelStyle}>{t('reservations.meta.airline')}</label>
|
||||
<input type="text" value={form.meta_airline} onChange={e => set('meta_airline', e.target.value)}
|
||||
placeholder="Lufthansa" style={inputStyle} />
|
||||
</div>
|
||||
<div>
|
||||
<label style={labelStyle}>{t('reservations.meta.flightNumber')}</label>
|
||||
<input type="text" value={form.meta_flight_number} onChange={e => set('meta_flight_number', e.target.value)}
|
||||
placeholder="LH 123" style={inputStyle} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Train-specific fields */}
|
||||
{form.type === 'train' && (
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div>
|
||||
<label style={labelStyle}>{t('reservations.meta.trainNumber')}</label>
|
||||
<input type="text" value={form.meta_train_number} onChange={e => set('meta_train_number', e.target.value)}
|
||||
placeholder="ICE 123" style={inputStyle} />
|
||||
</div>
|
||||
<div>
|
||||
<label style={labelStyle}>{t('reservations.meta.platform')}</label>
|
||||
<input type="text" value={form.meta_platform} onChange={e => set('meta_platform', e.target.value)}
|
||||
placeholder="12" style={inputStyle} />
|
||||
</div>
|
||||
<div>
|
||||
<label style={labelStyle}>{t('reservations.meta.seat')}</label>
|
||||
<input type="text" value={form.meta_seat} onChange={e => set('meta_seat', e.target.value)}
|
||||
placeholder="42A" style={inputStyle} />
|
||||
</div>
|
||||
</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>
|
||||
|
||||
{/* Notes */}
|
||||
<div>
|
||||
<label style={labelStyle}>{t('reservations.notes')}</label>
|
||||
<textarea value={form.notes} onChange={e => set('notes', e.target.value)} rows={2}
|
||||
placeholder={t('reservations.notesPlaceholder')}
|
||||
style={{ ...inputStyle, resize: 'none', lineHeight: 1.5 }} />
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8, paddingTop: 4, borderTop: '1px solid var(--border-secondary)' }}>
|
||||
<button type="button" onClick={onClose} style={{ padding: '8px 16px', borderRadius: 10, border: '1px solid var(--border-primary)', background: 'none', fontSize: 12, cursor: 'pointer', fontFamily: 'inherit', color: 'var(--text-muted)' }}>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button type="submit" disabled={isSaving || !form.title.trim()} style={{ padding: '8px 20px', borderRadius: 10, border: 'none', background: 'var(--text-primary)', color: 'var(--bg-primary)', fontSize: 12, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit', opacity: isSaving || !form.title.trim() ? 0.5 : 1 }}>
|
||||
{isSaving ? t('common.saving') : reservation ? t('common.update') : t('common.add')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react'
|
||||
import { Info, Coffee, Heart, ExternalLink, Bug, Lightbulb, BookOpen, Tent, Compass, Plane, Crown, Infinity as InfinityIcon } from 'lucide-react'
|
||||
import { Info, Coffee, Heart, ExternalLink, Bug, Lightbulb, BookOpen } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import Section from './Section'
|
||||
|
||||
@@ -7,229 +7,8 @@ interface Props {
|
||||
appVersion: string
|
||||
}
|
||||
|
||||
type SupporterTierId = 'no_return_ticket' | 'lost_luggage_vip' | 'business_class_dreamer' | 'budget_traveller' | 'hostel_bunkmate'
|
||||
|
||||
interface SupporterTier {
|
||||
id: SupporterTierId
|
||||
labelKey: string
|
||||
price: string
|
||||
gradient: string
|
||||
glow: string
|
||||
icon: typeof Tent
|
||||
}
|
||||
|
||||
const SUPPORTER_TIERS: SupporterTier[] = [
|
||||
{ id: 'no_return_ticket', labelKey: 'settings.about.supporter.tier.noReturnTicket', price: '∞', gradient: 'linear-gradient(135deg, #fbbf24, #ec4899 55%, #6366f1)', glow: 'rgba(236,72,153,0.45)', icon: InfinityIcon },
|
||||
{ id: 'lost_luggage_vip', labelKey: 'settings.about.supporter.tier.lostLuggageVip', price: '$30', gradient: 'linear-gradient(135deg, #a855f7, #ec4899)', glow: 'rgba(168,85,247,0.35)', icon: Crown },
|
||||
{ id: 'business_class_dreamer', labelKey: 'settings.about.supporter.tier.businessClassDreamer', price: '$15', gradient: 'linear-gradient(135deg, #6366f1, #0ea5e9)', glow: 'rgba(99,102,241,0.35)', icon: Plane },
|
||||
{ id: 'budget_traveller', labelKey: 'settings.about.supporter.tier.budgetTraveller', price: '$10', gradient: 'linear-gradient(135deg, #14b8a6, #06b6d4)', glow: 'rgba(20,184,166,0.3)', icon: Compass },
|
||||
{ id: 'hostel_bunkmate', labelKey: 'settings.about.supporter.tier.hostelBunkmate', price: '$5', gradient: 'linear-gradient(135deg, #64748b, #94a3b8)', glow: 'rgba(100,116,139,0.25)', icon: Tent },
|
||||
]
|
||||
|
||||
interface Supporter {
|
||||
username: string
|
||||
tier: SupporterTierId
|
||||
since: string
|
||||
link?: string
|
||||
}
|
||||
|
||||
const SUPPORTERS: Supporter[] = [
|
||||
{ username: 'Someone', tier: 'hostel_bunkmate', since: '2026-04' },
|
||||
]
|
||||
|
||||
function SupporterSection({ t, locale }: { t: (key: string, vars?: Record<string, string | number>) => string; locale: string }) {
|
||||
if (SUPPORTERS.length === 0) return null
|
||||
|
||||
const formatSince = (yearMonth: string): string => {
|
||||
const [y, m] = yearMonth.split('-').map(Number)
|
||||
if (!y || !m) return yearMonth
|
||||
try {
|
||||
return new Date(y, m - 1, 1).toLocaleDateString(locale, { year: 'numeric', month: 'long' })
|
||||
} catch { return yearMonth }
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="supporter-section">
|
||||
<style>{`
|
||||
.supporter-section { margin-top: 20px; }
|
||||
.supporter-card {
|
||||
position: relative;
|
||||
border-radius: 20px;
|
||||
padding: 22px 22px 18px;
|
||||
background: linear-gradient(180deg, rgba(99,102,241,0.06) 0%, rgba(236,72,153,0.04) 100%);
|
||||
border: 1px solid rgba(99,102,241,0.18);
|
||||
overflow: hidden;
|
||||
}
|
||||
.supporter-glow {
|
||||
position: absolute; inset: -60px; z-index: 0; pointer-events: none;
|
||||
background: radial-gradient(500px 240px at 15% -10%, rgba(99,102,241,0.18), transparent 60%), radial-gradient(400px 200px at 90% 110%, rgba(236,72,153,0.12), transparent 60%);
|
||||
animation: supporterGlow 6s ease-in-out infinite;
|
||||
}
|
||||
.supporter-header {
|
||||
position: relative; z-index: 1;
|
||||
display: flex; align-items: center; gap: 10px; flex-wrap: wrap;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.supporter-badge {
|
||||
display: inline-flex; align-items: center; gap: 6px;
|
||||
padding: 4px 10px; border-radius: 999px;
|
||||
background: linear-gradient(90deg, #6366f1, #ec4899, #fbbf24);
|
||||
background-size: 200% 100%;
|
||||
animation: supporterShimmer 4s ease-in-out infinite;
|
||||
color: #fff; font-weight: 700; font-size: 11px; letter-spacing: 0.04em; text-transform: uppercase;
|
||||
box-shadow: 0 4px 16px rgba(236,72,153,0.25);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.supporter-title {
|
||||
margin: 0; font-size: 16px; font-weight: 700;
|
||||
color: var(--text-primary); letter-spacing: -0.01em;
|
||||
}
|
||||
.supporter-subtitle {
|
||||
position: relative; z-index: 1;
|
||||
margin: 0 0 16px; font-size: 12.5px;
|
||||
color: var(--text-secondary); line-height: 1.55;
|
||||
}
|
||||
.supporter-tiers {
|
||||
position: relative; z-index: 1;
|
||||
display: flex; flex-direction: column; gap: 10px;
|
||||
}
|
||||
.supporter-tier {
|
||||
display: flex; align-items: flex-start; gap: 12px;
|
||||
padding: 10px 12px; border-radius: 14px;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-primary);
|
||||
}
|
||||
.supporter-tier-icon {
|
||||
width: 38px; height: 38px; border-radius: 11px; flex-shrink: 0;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
color: #fff;
|
||||
}
|
||||
.supporter-tier-body { flex: 1; min-width: 0; }
|
||||
.supporter-tier-head {
|
||||
display: flex; align-items: baseline; gap: 8px; flex-wrap: wrap;
|
||||
}
|
||||
.supporter-tier-label {
|
||||
font-size: 13.5px; font-weight: 700; color: var(--text-primary);
|
||||
}
|
||||
.supporter-tier-price {
|
||||
font-size: 11px; font-weight: 600; color: var(--text-faint);
|
||||
padding: 1px 7px; border-radius: 6px; background: var(--bg-tertiary);
|
||||
}
|
||||
.supporter-tier-chips {
|
||||
display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px;
|
||||
}
|
||||
.supporter-tier-empty {
|
||||
font-size: 11.5px; font-style: italic; color: var(--text-faint);
|
||||
}
|
||||
.supporter-chip {
|
||||
display: inline-flex; align-items: center; gap: 7px;
|
||||
padding: 4px 10px; border-radius: 999px;
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border-primary);
|
||||
text-decoration: none;
|
||||
transition: border-color 0.15s, box-shadow 0.15s;
|
||||
max-width: 100%;
|
||||
}
|
||||
.supporter-chip-name {
|
||||
font-size: 12px; font-weight: 600; color: var(--text-primary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.supporter-chip-since {
|
||||
font-size: 10.5px; font-weight: 500; color: var(--text-faint);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.supporter-chip-since-short { display: none; }
|
||||
@keyframes supporterShimmer {
|
||||
0%, 100% { background-position: 0% 50%; }
|
||||
50% { background-position: 100% 50%; }
|
||||
}
|
||||
@keyframes supporterGlow {
|
||||
0%, 100% { opacity: 0.4; }
|
||||
50% { opacity: 0.75; }
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.supporter-card { border-radius: 16px; padding: 16px 14px 14px; }
|
||||
.supporter-glow { inset: -40px; }
|
||||
.supporter-header { gap: 8px; }
|
||||
.supporter-badge { font-size: 10px; padding: 3px 9px; letter-spacing: 0.03em; }
|
||||
.supporter-title { font-size: 15px; flex-basis: 100%; }
|
||||
.supporter-subtitle { font-size: 12px; margin-bottom: 14px; }
|
||||
.supporter-tier { padding: 10px; gap: 10px; border-radius: 12px; }
|
||||
.supporter-tier-icon { width: 34px; height: 34px; border-radius: 10px; }
|
||||
.supporter-tier-label { font-size: 13px; }
|
||||
.supporter-tier-chips { gap: 5px; margin-top: 7px; }
|
||||
.supporter-chip { padding: 3px 9px; }
|
||||
.supporter-chip-since { font-size: 10px; }
|
||||
.supporter-chip-since-full { display: none; }
|
||||
.supporter-chip-since-short { display: inline; }
|
||||
}
|
||||
`}</style>
|
||||
<div className="supporter-card">
|
||||
<div className="supporter-glow" />
|
||||
|
||||
<div className="supporter-header">
|
||||
<span className="supporter-badge">{t('settings.about.supporters.badge')}</span>
|
||||
<h3 className="supporter-title">{t('settings.about.supporters.title')}</h3>
|
||||
</div>
|
||||
<p className="supporter-subtitle">{t('settings.about.supporters.subtitle')}</p>
|
||||
|
||||
<div className="supporter-tiers">
|
||||
{SUPPORTER_TIERS.map(tier => {
|
||||
const members = SUPPORTERS.filter(s => s.tier === tier.id)
|
||||
const empty = members.length === 0
|
||||
const TierIcon = tier.icon
|
||||
return (
|
||||
<div key={tier.id} className="supporter-tier" style={{ opacity: empty ? 0.55 : 1 }}>
|
||||
<div className="supporter-tier-icon" style={{ background: tier.gradient, boxShadow: `0 6px 18px ${tier.glow}` }}>
|
||||
<TierIcon size={18} strokeWidth={2.2} />
|
||||
</div>
|
||||
<div className="supporter-tier-body">
|
||||
<div className="supporter-tier-head">
|
||||
<span className="supporter-tier-label">{t(tier.labelKey)}</span>
|
||||
<span className="supporter-tier-price">{tier.price}</span>
|
||||
</div>
|
||||
<div className="supporter-tier-chips">
|
||||
{empty && (
|
||||
<span className="supporter-tier-empty">
|
||||
{t('settings.about.supporters.tierEmpty')}
|
||||
</span>
|
||||
)}
|
||||
{members.map(m => {
|
||||
const chipContent = (
|
||||
<>
|
||||
<span className="supporter-chip-name">{m.username}</span>
|
||||
<span className="supporter-chip-since supporter-chip-since-full">
|
||||
· {t('settings.about.supporters.since', { date: formatSince(m.since) })}
|
||||
</span>
|
||||
<span className="supporter-chip-since supporter-chip-since-short">
|
||||
· {formatSince(m.since)}
|
||||
</span>
|
||||
</>
|
||||
)
|
||||
return m.link ? (
|
||||
<a key={m.username} href={m.link} target="_blank" rel="noopener noreferrer" className="supporter-chip"
|
||||
onMouseEnter={e => { e.currentTarget.style.borderColor = 'var(--text-faint)'; e.currentTarget.style.boxShadow = `0 2px 8px ${tier.glow}` }}
|
||||
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.boxShadow = 'none' }}
|
||||
>
|
||||
{chipContent}
|
||||
</a>
|
||||
) : (
|
||||
<div key={m.username} className="supporter-chip">{chipContent}</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function AboutTab({ appVersion }: Props): React.ReactElement {
|
||||
const { t, locale } = useTranslation()
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<Section title={t('settings.about')} icon={Info}>
|
||||
@@ -362,8 +141,6 @@ export default function AboutTab({ appVersion }: Props): React.ReactElement {
|
||||
<ExternalLink size={14} className="ml-auto flex-shrink-0" style={{ color: 'var(--text-faint)' }} />
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<SupporterSection t={t} locale={locale} />
|
||||
</Section>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -37,10 +37,9 @@ describe('TodoListPanel', () => {
|
||||
expect(screen.getByText('Buy tickets')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-TODO-002: raising addItemSignal opens the new task form', async () => {
|
||||
const { rerender } = render(<TodoListPanel tripId={1} items={[]} addItemSignal={0} />);
|
||||
rerender(<TodoListPanel tripId={1} items={[]} addItemSignal={1} />);
|
||||
await screen.findByText('Create task');
|
||||
it('FE-COMP-TODO-002: shows Add new task button', () => {
|
||||
render(<TodoListPanel tripId={1} items={[]} />);
|
||||
expect(screen.getByText('Add new task...')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-TODO-003: sidebar filter buttons are rendered', () => {
|
||||
@@ -120,9 +119,11 @@ describe('TodoListPanel', () => {
|
||||
expect(screen.getByText(/1 \/ 2 completed/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-TODO-011: raising addItemSignal opens detail form with Create task button', async () => {
|
||||
const { rerender } = render(<TodoListPanel tripId={1} items={[]} addItemSignal={0} />);
|
||||
rerender(<TodoListPanel tripId={1} items={[]} addItemSignal={1} />);
|
||||
it('FE-COMP-TODO-011: clicking Add new task opens detail form', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<TodoListPanel tripId={1} items={[]} />);
|
||||
await user.click(screen.getByText('Add new task...'));
|
||||
// The detail pane shows "Create task" button
|
||||
await screen.findByText('Create task');
|
||||
});
|
||||
|
||||
@@ -397,12 +398,15 @@ describe('TodoListPanel', () => {
|
||||
return HttpResponse.json({ item: buildTodoItem({ id: 99, name: 'Brand New Task' }) });
|
||||
}),
|
||||
);
|
||||
const { rerender } = render(<TodoListPanel tripId={1} items={[]} addItemSignal={0} />);
|
||||
// Raising the signal opens the new task pane (simulates the toolbar button click)
|
||||
rerender(<TodoListPanel tripId={1} items={[]} addItemSignal={1} />);
|
||||
render(<TodoListPanel tripId={1} items={[]} />);
|
||||
// Open the new task pane
|
||||
await user.click(screen.getByText('Add new task...'));
|
||||
// Wait for "Create task" button to appear
|
||||
await screen.findByText('Create task');
|
||||
// Type a task name in the autoFocus input (Task name placeholder)
|
||||
const nameInput = screen.getByPlaceholderText('Task name');
|
||||
await user.type(nameInput, 'Brand New Task');
|
||||
// Click the Create task button
|
||||
await user.click(screen.getByText('Create task'));
|
||||
await waitFor(() => expect(postCalled).toBe(true));
|
||||
});
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useState, useMemo, useEffect, useRef } from 'react'
|
||||
import ReactDOM from 'react-dom'
|
||||
import { useState, useMemo, useEffect } from 'react'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
import { useCanDo } from '../../store/permissionsStore'
|
||||
import { useToast } from '../shared/Toast'
|
||||
@@ -38,7 +37,7 @@ type FilterType = 'all' | 'my' | 'overdue' | 'done' | string
|
||||
|
||||
interface Member { id: number; username: string; avatar: string | null }
|
||||
|
||||
export default function TodoListPanel({ tripId, items, addItemSignal = 0 }: { tripId: number; items: TodoItem[]; addItemSignal?: number }) {
|
||||
export default function TodoListPanel({ tripId, items }: { tripId: number; items: TodoItem[] }) {
|
||||
const { addTodoItem, updateTodoItem, deleteTodoItem, toggleTodoItem } = useTripStore()
|
||||
const canEdit = useCanDo('packing_edit')
|
||||
const toast = useToast()
|
||||
@@ -56,15 +55,6 @@ export default function TodoListPanel({ tripId, items, addItemSignal = 0 }: { tr
|
||||
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('')
|
||||
@@ -170,12 +160,12 @@ export default function TodoListPanel({ tripId, items, addItemSignal = 0 }: { tr
|
||||
{/* ── Left Sidebar ── */}
|
||||
<div style={{
|
||||
width: isMobile ? 52 : 220, flexShrink: 0, borderRight: '1px solid var(--border-faint)',
|
||||
padding: isMobile ? '12px 6px' : '16px 12px 16px 0', display: 'flex', flexDirection: 'column', gap: 2, overflowY: 'auto',
|
||||
padding: isMobile ? '12px 6px' : '16px 10px', display: 'flex', flexDirection: 'column', gap: 2, overflowY: 'auto',
|
||||
transition: 'width 0.2s',
|
||||
}}>
|
||||
{/* Progress Card */}
|
||||
{!isMobile && <div style={{
|
||||
margin: '0 0 12px', padding: '14px 14px 12px', borderRadius: 14,
|
||||
margin: '0 6px 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)',
|
||||
@@ -202,12 +192,9 @@ export default function TodoListPanel({ tripId, items, addItemSignal = 0 }: { tr
|
||||
<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 */}
|
||||
{!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>}
|
||||
{/* Sort by priority */}
|
||||
<button onClick={() => setSortByPrio(v => !v)}
|
||||
title={isMobile ? t('todo.priority') : undefined}
|
||||
title={isMobile ? t('todo.sortByPrio') : undefined}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: isMobile ? 'center' : 'flex-start',
|
||||
gap: isMobile ? 0 : 8, width: '100%', padding: isMobile ? '8px 0' : '7px 12px',
|
||||
@@ -219,7 +206,7 @@ export default function TodoListPanel({ tripId, items, addItemSignal = 0 }: { tr
|
||||
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.priority')}</span>}
|
||||
{!isMobile && <span style={{ flex: 1, textAlign: 'left' }}>{t('todo.sortByPrio')}</span>}
|
||||
</button>
|
||||
|
||||
{/* Categories */}
|
||||
@@ -264,6 +251,27 @@ export default function TodoListPanel({ tripId, items, addItemSignal = 0 }: { tr
|
||||
</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 : (
|
||||
@@ -399,27 +407,18 @@ export default function TodoListPanel({ tripId, items, addItemSignal = 0 }: { tr
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{isAddingNew && !selectedItem && !isMobile && ReactDOM.createPortal(
|
||||
<div onClick={e => { if (e.target === e.currentTarget) setIsAddingNew(false) }}
|
||||
className="modal-backdrop"
|
||||
style={{ position: 'fixed', inset: 0, zIndex: 1000, 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 && (
|
||||
<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 && ReactDOM.createPortal(
|
||||
{isAddingNew && !selectedItem && isMobile && (
|
||||
<div onClick={e => { if (e.target === e.currentTarget) setIsAddingNew(false) }}
|
||||
className="modal-backdrop"
|
||||
style={{ position: 'fixed', inset: 0, zIndex: 1000, background: 'rgba(0,0,0,0.4)', display: 'flex', justifyContent: 'center', alignItems: 'flex-end', paddingBottom: 'var(--bottom-nav-h)' }}>
|
||||
<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' } } }}>
|
||||
@@ -432,8 +431,7 @@ export default function TodoListPanel({ tripId, items, addItemSignal = 0 }: { tr
|
||||
onClose={() => setIsAddingNew(false)}
|
||||
/>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
@@ -649,7 +647,6 @@ 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)
|
||||
@@ -660,10 +657,9 @@ 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: trimmedCategory || null,
|
||||
due_date: dueDate || null, category: category || null,
|
||||
assigned_user_id: assignedUserId,
|
||||
} as any)
|
||||
if (item?.id) onCreated(item.id)
|
||||
@@ -700,49 +696,19 @@ function NewTaskPane({ tripId, categories, members, defaultCategory, onCreated,
|
||||
|
||||
<div>
|
||||
<label style={labelStyle}>{t('todo.detail.category')}</label>
|
||||
{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>
|
||||
)}
|
||||
<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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Sun, Cloud, CloudRain, CloudSnow, CloudDrizzle, CloudLightning, Wind } from 'lucide-react'
|
||||
import { fetchWeather } from '../../services/weatherQueue'
|
||||
import { weatherApi } from '../../api/client'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
|
||||
const WEATHER_ICON_MAP = {
|
||||
@@ -61,7 +61,7 @@ export default function WeatherWidget({ lat, lng, date, compact = false }: Weath
|
||||
// Climate data: use from cache but re-fetch in background to upgrade to forecast
|
||||
else if (cached.type === 'climate') {
|
||||
setWeather(cached)
|
||||
fetchWeather(lat, lng, date)
|
||||
weatherApi.get(lat, lng, date)
|
||||
.then(data => {
|
||||
if (!data.error && data.temp !== undefined && data.type === 'forecast') {
|
||||
setWeatherCache(cacheKey, data)
|
||||
@@ -77,7 +77,7 @@ export default function WeatherWidget({ lat, lng, date, compact = false }: Weath
|
||||
return
|
||||
}
|
||||
setLoading(true)
|
||||
fetchWeather(lat, lng, date)
|
||||
weatherApi.get(lat, lng, date)
|
||||
.then(data => {
|
||||
if (data.error || data.temp === undefined) {
|
||||
setFailed(true)
|
||||
|
||||
@@ -40,7 +40,7 @@ export default function ConfirmDialog({
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-[10000] flex items-center justify-center px-4"
|
||||
className="fixed inset-0 z-[60] flex items-center justify-center px-4"
|
||||
style={{ backgroundColor: 'rgba(15, 23, 42, 0.5)' }}
|
||||
onClick={onClose}
|
||||
>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import React, { useState, useEffect, useRef } from 'react'
|
||||
import { getCategoryIcon } from './categoryIcons'
|
||||
import { getCached, isLoading, fetchPhoto, onThumbReady } from '../../services/photoService'
|
||||
import { useAuthStore } from '../../store/authStore'
|
||||
import type { Place } from '../../types'
|
||||
|
||||
interface Category {
|
||||
@@ -19,12 +18,10 @@ export default React.memo(function PlaceAvatar({ place, size = 32, category }: P
|
||||
const [photoSrc, setPhotoSrc] = useState<string | null>(place.image_url || null)
|
||||
const [visible, setVisible] = useState(false)
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
const placesPhotosEnabled = useAuthStore(s => s.placesPhotosEnabled)
|
||||
|
||||
// Observe visibility — fetch photo only when avatar enters viewport
|
||||
useEffect(() => {
|
||||
if (place.image_url) { setVisible(true); return }
|
||||
if (!placesPhotosEnabled) return
|
||||
const el = ref.current
|
||||
if (!el) return
|
||||
// Check if already cached — show immediately without waiting for intersection
|
||||
@@ -40,7 +37,6 @@ export default React.memo(function PlaceAvatar({ place, size = 32, category }: P
|
||||
useEffect(() => {
|
||||
if (!visible) return
|
||||
if (place.image_url) { setPhotoSrc(place.image_url); return }
|
||||
if (!placesPhotosEnabled) return
|
||||
const photoId = place.google_place_id || place.osm_id
|
||||
if (!photoId && !(place.lat && place.lng)) { setPhotoSrc(null); return }
|
||||
|
||||
|
||||
@@ -1,123 +1,50 @@
|
||||
import { useState, useCallback, useRef, useEffect, useMemo } from 'react'
|
||||
import { useState, useCallback, useRef, useEffect } from 'react'
|
||||
import { useSettingsStore } from '../store/settingsStore'
|
||||
import { useTripStore } from '../store/tripStore'
|
||||
import { calculateSegments } from '../components/Map/RouteCalculator'
|
||||
import type { TripStoreState } from '../store/tripStore'
|
||||
import type { RouteSegment, RouteResult } from '../types'
|
||||
|
||||
const TRANSPORT_TYPES = ['flight', 'train', 'bus', 'car', 'cruise']
|
||||
|
||||
/**
|
||||
* Manages route calculation state for a selected day. Extracts geo-coded waypoints from
|
||||
* day assignments, draws a straight-line route, and optionally fetches per-segment
|
||||
* driving/walking durations via OSRM. Aborts in-flight requests when the day changes.
|
||||
*/
|
||||
export function useRouteCalculation(tripStore: TripStoreState, selectedDayId: number | null) {
|
||||
const [route, setRoute] = useState<[number, number][][] | null>(null)
|
||||
const [route, setRoute] = useState<[number, number][] | null>(null)
|
||||
const [routeInfo, setRouteInfo] = useState<RouteResult | null>(null)
|
||||
const [routeSegments, setRouteSegments] = useState<RouteSegment[]>([])
|
||||
const routeCalcEnabled = useSettingsStore((s) => s.settings.route_calculation) !== false
|
||||
const routeAbortRef = useRef<AbortController | null>(null)
|
||||
const reservationsForSignature = useTripStore((s) => s.reservations)
|
||||
// Keep a ref to the latest tripStore so updateRouteForDay never has a stale closure
|
||||
const tripStoreRef = useRef(tripStore)
|
||||
tripStoreRef.current = tripStore
|
||||
|
||||
const updateRouteForDay = useCallback(async (dayId: number | null) => {
|
||||
if (routeAbortRef.current) routeAbortRef.current.abort()
|
||||
if (!dayId) { setRoute(null); setRouteSegments([]); return }
|
||||
// Read directly from store (not a render-phase ref) so callers after optimistic
|
||||
// updates or non-optimistic deletes always see the latest assignments.
|
||||
const currentAssignments = useTripStore.getState().assignments || {}
|
||||
const currentAssignments = tripStoreRef.current.assignments || {}
|
||||
const da = (currentAssignments[String(dayId)] || []).slice().sort((a, b) => a.order_index - b.order_index)
|
||||
const allReservations = useTripStore.getState().reservations || []
|
||||
const allDays = useTripStore.getState().days || []
|
||||
const dayOrder = (id: number | null | undefined): number | null => {
|
||||
if (id == null) return null
|
||||
const d = allDays.find(x => x.id === id)
|
||||
return d ? ((d as any).day_number ?? allDays.indexOf(d)) : null
|
||||
}
|
||||
const thisOrder = dayOrder(dayId)
|
||||
|
||||
// Transport reservations for this day with a known position — mirrors getTransportForDay semantics
|
||||
const dayTransports = thisOrder == null ? [] : allReservations.filter(r => {
|
||||
if (!TRANSPORT_TYPES.includes(r.type)) return false
|
||||
const startId = r.day_id
|
||||
if (startId == null) return false
|
||||
const endId = r.end_day_id ?? startId
|
||||
if (startId === endId) {
|
||||
if (startId !== dayId) return false
|
||||
} else {
|
||||
const startOrder = dayOrder(startId)
|
||||
const endOrder = dayOrder(endId)
|
||||
if (startOrder == null || endOrder == null) return false
|
||||
if (thisOrder < startOrder || thisOrder > endOrder) return false
|
||||
}
|
||||
const pos = r.day_positions?.[dayId] ?? r.day_positions?.[String(dayId)] ?? r.day_plan_position
|
||||
return pos != null
|
||||
})
|
||||
|
||||
// Build a unified list of places + transports sorted by effective position,
|
||||
// then derive segments by resetting whenever a transport appears — mirrors getMergedItems order.
|
||||
type Entry = { kind: 'place'; lat: number; lng: number } | { kind: 'transport' }
|
||||
const entries: (Entry & { pos: number })[] = [
|
||||
...da.filter(a => a.place?.lat && a.place?.lng).map(a => ({
|
||||
kind: 'place' as const, lat: a.place.lat!, lng: a.place.lng!, pos: a.order_index,
|
||||
})),
|
||||
...dayTransports.map(r => ({
|
||||
kind: 'transport' as const,
|
||||
pos: (r.day_positions?.[dayId] ?? r.day_positions?.[String(dayId)] ?? r.day_plan_position) as number,
|
||||
})),
|
||||
].sort((a, b) => a.pos - b.pos)
|
||||
|
||||
const segments: [number, number][][] = []
|
||||
let currentSeg: [number, number][] = []
|
||||
for (const entry of entries) {
|
||||
if (entry.kind === 'place') {
|
||||
currentSeg.push([entry.lat, entry.lng])
|
||||
} else {
|
||||
if (currentSeg.length >= 2) segments.push([...currentSeg])
|
||||
currentSeg = []
|
||||
}
|
||||
}
|
||||
if (currentSeg.length >= 2) segments.push(currentSeg)
|
||||
|
||||
const geocodedWaypoints = da.map(a => a.place).filter(p => p?.lat && p?.lng) as { lat: number; lng: number }[]
|
||||
|
||||
if (segments.length === 0 && geocodedWaypoints.length < 2) {
|
||||
setRoute(null); setRouteSegments([]); return
|
||||
}
|
||||
setRoute(segments.length > 0 ? segments : null)
|
||||
const waypoints = da.map((a) => a.place).filter((p) => p?.lat && p?.lng)
|
||||
if (waypoints.length < 2) { setRoute(null); setRouteSegments([]); return }
|
||||
setRoute(waypoints.map((p) => [p.lat!, p.lng!]))
|
||||
if (!routeCalcEnabled) { setRouteSegments([]); return }
|
||||
const controller = new AbortController()
|
||||
routeAbortRef.current = controller
|
||||
try {
|
||||
const calcSegments = await calculateSegments(geocodedWaypoints, { signal: controller.signal })
|
||||
if (!controller.signal.aborted) setRouteSegments(calcSegments)
|
||||
const segments = await calculateSegments(waypoints as { lat: number; lng: number }[], { signal: controller.signal })
|
||||
if (!controller.signal.aborted) setRouteSegments(segments)
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof Error && err.name !== 'AbortError') setRouteSegments([])
|
||||
else if (!(err instanceof Error)) setRouteSegments([])
|
||||
}
|
||||
}, [routeCalcEnabled])
|
||||
|
||||
// Stable signature for transport reservations on the selected day — changes when a transport
|
||||
// is added, removed, or repositioned, ensuring route recalc fires even on transport-only reorders.
|
||||
const transportSignature = useMemo(() => {
|
||||
if (!selectedDayId) return ''
|
||||
return reservationsForSignature
|
||||
.filter(r => TRANSPORT_TYPES.includes(r.type))
|
||||
.map(r => {
|
||||
const pos = r.day_positions?.[selectedDayId] ?? r.day_positions?.[String(selectedDayId)] ?? r.day_plan_position
|
||||
return `${r.id}:${r.day_id ?? ''}:${r.end_day_id ?? ''}:${r.reservation_time ?? ''}:${pos ?? ''}`
|
||||
})
|
||||
.sort()
|
||||
.join('|')
|
||||
}, [reservationsForSignature, selectedDayId])
|
||||
|
||||
// Recalculate when assignments or transport positions for the SELECTED day change
|
||||
// Only recalculate when assignments for the SELECTED day change
|
||||
const selectedDayAssignments = selectedDayId ? tripStore.assignments?.[String(selectedDayId)] : null
|
||||
useEffect(() => {
|
||||
if (!selectedDayId) { setRoute(null); setRouteSegments([]); return }
|
||||
updateRouteForDay(selectedDayId)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [selectedDayId, selectedDayAssignments, transportSignature])
|
||||
}, [selectedDayId, selectedDayAssignments])
|
||||
|
||||
return { route, routeSegments, routeInfo, setRoute, setRouteInfo, updateRouteForDay }
|
||||
}
|
||||
|
||||
@@ -14,9 +14,6 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
|
||||
'common.add': 'إضافة',
|
||||
'common.loading': 'جارٍ التحميل...',
|
||||
'common.import': 'استيراد',
|
||||
'common.select': 'تحديد',
|
||||
'common.selectAll': 'تحديد الكل',
|
||||
'common.deselectAll': 'إلغاء تحديد الكل',
|
||||
'common.error': 'خطأ',
|
||||
'common.unknownError': 'خطأ غير معروف',
|
||||
'common.tooManyAttempts': 'محاولات كثيرة جدًا. يرجى المحاولة لاحقًا.',
|
||||
@@ -316,16 +313,6 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
|
||||
'settings.about.featureRequest': 'اقتراح ميزة',
|
||||
'settings.about.featureRequestHint': 'اقترح ميزة جديدة',
|
||||
'settings.about.wikiHint': 'التوثيق والأدلة',
|
||||
'settings.about.supporters.badge': 'الداعمون الشهريون',
|
||||
'settings.about.supporters.title': 'رفاق رحلة TREK',
|
||||
'settings.about.supporters.subtitle': 'بينما تخطّط لمسارك التالي، يساعد هؤلاء الأشخاص في التخطيط لمستقبل TREK. تذهب مساهمتهم الشهرية مباشرةً إلى التطوير والساعات الفعلية المبذولة — حتى يظلّ TREK مفتوح المصدر.',
|
||||
'settings.about.supporters.since': 'داعم منذ {date}',
|
||||
'settings.about.supporters.tierEmpty': 'كن الأول',
|
||||
'settings.about.supporter.tier.noReturnTicket': 'No Return Ticket',
|
||||
'settings.about.supporter.tier.lostLuggageVip': 'Lost Luggage VIP',
|
||||
'settings.about.supporter.tier.businessClassDreamer': 'Business Class Dreamer',
|
||||
'settings.about.supporter.tier.budgetTraveller': 'Budget Traveller',
|
||||
'settings.about.supporter.tier.hostelBunkmate': 'Hostel Bunkmate',
|
||||
'settings.about.description': 'TREK هو مخطط سفر مستضاف ذاتيًا يساعدك على تنظيم رحلاتك من أول فكرة حتى آخر ذكرى. تخطيط يومي، ميزانية، قوائم تعبئة، صور والمزيد — كل شيء في مكان واحد، على خادمك الخاص.',
|
||||
'settings.about.madeWith': 'صُنع بـ',
|
||||
'settings.about.madeBy': 'بواسطة موريس ومجتمع مفتوح المصدر متنامٍ.',
|
||||
@@ -601,12 +588,6 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
|
||||
'admin.fileTypesSaved': 'تم حفظ إعدادات أنواع الملفات',
|
||||
|
||||
// Packing Templates & Bag Tracking
|
||||
'admin.placesPhotos.title': 'صور الأماكن',
|
||||
'admin.placesPhotos.subtitle': 'جلب الصور من Google Places API. عطّلها للحفاظ على حصة API. صور Wikimedia غير متأثرة.',
|
||||
'admin.placesAutocomplete.title': 'الإكمال التلقائي للأماكن',
|
||||
'admin.placesAutocomplete.subtitle': 'استخدام Google Places API لاقتراحات البحث. عطّلها للحفاظ على حصة API.',
|
||||
'admin.placesDetails.title': 'تفاصيل الأماكن',
|
||||
'admin.placesDetails.subtitle': 'جلب معلومات تفصيلية عن الأماكن (الساعات، التقييم، الموقع) من Google Places API. عطّلها للحفاظ على حصة API.',
|
||||
'admin.bagTracking.title': 'تتبع الأمتعة',
|
||||
'admin.bagTracking.subtitle': 'تفعيل الوزن وتعيين الأمتعة للعناصر',
|
||||
'admin.collab.chat.title': 'الدردشة',
|
||||
@@ -870,7 +851,6 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
|
||||
|
||||
// Trip Planner
|
||||
'trip.tabs.plan': 'الخطة',
|
||||
'trip.tabs.transports': 'المواصلات',
|
||||
'trip.tabs.reservations': 'الحجوزات',
|
||||
'trip.tabs.reservationsShort': 'حجز',
|
||||
'trip.tabs.packing': 'قائمة التجهيز',
|
||||
@@ -893,8 +873,6 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
|
||||
'trip.toast.reservationAdded': 'تمت إضافة الحجز',
|
||||
'trip.toast.deleted': 'تم الحذف',
|
||||
'trip.confirm.deletePlace': 'هل تريد حذف هذا المكان؟',
|
||||
'trip.confirm.deletePlaces': 'حذف {count} أماكن؟',
|
||||
'trip.toast.placesDeleted': 'تم حذف {count} أماكن',
|
||||
|
||||
// Day Plan Sidebar
|
||||
'dayplan.emptyDay': 'لا توجد أماكن مخططة لهذا اليوم',
|
||||
@@ -939,17 +917,6 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
|
||||
'places.importFileError': 'فشل الاستيراد',
|
||||
'places.importAllSkipped': 'جميع الأماكن موجودة بالفعل في الرحلة.',
|
||||
'places.gpxImported': 'تم استيراد {count} مكان من GPX',
|
||||
'places.gpxImportTypes': 'ما الذي تريد استيراده؟',
|
||||
'places.gpxImportWaypoints': 'نقاط الطريق',
|
||||
'places.gpxImportRoutes': 'المسارات',
|
||||
'places.gpxImportTracks': 'المسارات (مع هندسة الطريق)',
|
||||
'places.gpxImportNoneSelected': 'اختر نوعاً واحداً على الأقل للاستيراد.',
|
||||
'places.kmlImportTypes': 'ما الذي تريد استيراده؟',
|
||||
'places.kmlImportPoints': 'نقاط (Placemarks)',
|
||||
'places.kmlImportPaths': 'مسارات (LineStrings)',
|
||||
'places.kmlImportNoneSelected': 'اختر نوعًا واحدًا على الأقل.',
|
||||
'places.selectionCount': '{count} محدد',
|
||||
'places.deleteSelected': 'حذف المحدد',
|
||||
'places.kmlKmzImported': 'تم استيراد {count} مكان من KMZ/KML',
|
||||
'places.urlResolved': 'تم استيراد المكان من الرابط',
|
||||
'places.importList': 'استيراد قائمة',
|
||||
@@ -966,7 +933,6 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
|
||||
'places.assignToDay': 'إلى أي يوم تريد الإضافة؟',
|
||||
'places.all': 'الكل',
|
||||
'places.unplanned': 'غير مخطط',
|
||||
'places.filterTracks': 'المسارات',
|
||||
'places.search': 'ابحث عن أماكن...',
|
||||
'places.allCategories': 'كل الفئات',
|
||||
'places.categoriesSelected': 'فئات',
|
||||
@@ -1051,15 +1017,6 @@ 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': 'المقعد',
|
||||
@@ -1078,7 +1035,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': 'جولة',
|
||||
@@ -1139,7 +1096,6 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
|
||||
'reservations.span.end': 'النهاية',
|
||||
'reservations.span.ongoing': 'جارٍ',
|
||||
'reservations.validation.endBeforeStart': 'يجب أن يكون تاريخ/وقت الانتهاء بعد تاريخ/وقت البدء',
|
||||
'reservations.addBooking': 'إضافة حجز',
|
||||
|
||||
// Budget
|
||||
'budget.title': 'الميزانية',
|
||||
@@ -1792,7 +1748,6 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
|
||||
'undo.reorder': 'تمت إعادة ترتيب الأماكن',
|
||||
'undo.optimize': 'تم تحسين المسار',
|
||||
'undo.deletePlace': 'تم حذف المكان',
|
||||
'undo.deletePlaces': 'تم حذف الأماكن',
|
||||
'undo.moveDay': 'تم نقل المكان إلى يوم آخر',
|
||||
'undo.lock': 'تم تبديل قفل المكان',
|
||||
'undo.importGpx': 'استيراد GPX',
|
||||
@@ -1852,11 +1807,7 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
|
||||
'todo.unassigned': 'غير مُسنَد',
|
||||
'todo.noCategory': 'بدون فئة',
|
||||
'todo.hasDescription': 'له وصف',
|
||||
'todo.addItem': 'إضافة مهمة جديدة',
|
||||
'todo.sidebar.sortBy': 'ترتيب حسب',
|
||||
'todo.priority': 'الأولوية',
|
||||
'todo.newCategoryLabel': 'جديد',
|
||||
'budget.categoriesLabel': 'فئات',
|
||||
'todo.addItem': 'إضافة مهمة جديدة...',
|
||||
'todo.newCategory': 'اسم الفئة',
|
||||
'todo.addCategory': 'إضافة فئة',
|
||||
'todo.newItem': 'مهمة جديدة',
|
||||
@@ -2084,11 +2035,6 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
|
||||
// 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) يبقي الأضواء مشتعلة.',
|
||||
'transport.addTransport': 'Add transport',
|
||||
'transport.modalTitle.create': 'Add transport',
|
||||
'transport.modalTitle.edit': 'Edit transport',
|
||||
'transport.title': 'المواصلات',
|
||||
'transport.addManual': 'نقل يدوي',
|
||||
}
|
||||
|
||||
export default ar
|
||||
|
||||
@@ -10,9 +10,6 @@ const br: Record<string, string | { name: string; category: string }[]> = {
|
||||
'common.add': 'Adicionar',
|
||||
'common.loading': 'Carregando...',
|
||||
'common.import': 'Importar',
|
||||
'common.select': 'Selecionar',
|
||||
'common.selectAll': 'Selecionar tudo',
|
||||
'common.deselectAll': 'Desmarcar tudo',
|
||||
'common.error': 'Erro',
|
||||
'common.unknownError': 'Erro desconhecido',
|
||||
'common.tooManyAttempts': 'Muitas tentativas. Tente novamente mais tarde.',
|
||||
@@ -243,16 +240,6 @@ const br: Record<string, string | { name: string; category: string }[]> = {
|
||||
'settings.about.featureRequest': 'Solicitar recurso',
|
||||
'settings.about.featureRequestHint': 'Sugira um novo recurso',
|
||||
'settings.about.wikiHint': 'Documentação e guias',
|
||||
'settings.about.supporters.badge': 'Apoiadores Mensais',
|
||||
'settings.about.supporters.title': 'Companheiros de viagem do TREK',
|
||||
'settings.about.supporters.subtitle': 'Enquanto você planeja sua próxima rota, essas pessoas planejam junto o futuro do TREK. A contribuição mensal delas vai direto para o desenvolvimento e horas reais investidas — para o TREK continuar Open Source.',
|
||||
'settings.about.supporters.since': 'apoiador desde {date}',
|
||||
'settings.about.supporters.tierEmpty': 'Seja o primeiro',
|
||||
'settings.about.supporter.tier.noReturnTicket': 'No Return Ticket',
|
||||
'settings.about.supporter.tier.lostLuggageVip': 'Lost Luggage VIP',
|
||||
'settings.about.supporter.tier.businessClassDreamer': 'Business Class Dreamer',
|
||||
'settings.about.supporter.tier.budgetTraveller': 'Budget Traveller',
|
||||
'settings.about.supporter.tier.hostelBunkmate': 'Hostel Bunkmate',
|
||||
'settings.about.description': 'TREK é um planejador de viagens auto-hospedado que ajuda você a organizar suas viagens da primeira ideia à última lembrança. Planejamento diário, orçamento, listas de bagagem, fotos e muito mais — tudo em um só lugar, no seu próprio servidor.',
|
||||
'settings.about.madeWith': 'Feito com',
|
||||
'settings.about.madeBy': 'por Maurice e uma crescente comunidade open-source.',
|
||||
@@ -559,12 +546,6 @@ const br: Record<string, string | { name: string; category: string }[]> = {
|
||||
'admin.fileTypesSaved': 'Configurações de tipos de arquivo salvas',
|
||||
|
||||
// Packing Templates & Bag Tracking
|
||||
'admin.placesPhotos.title': 'Fotos de Locais',
|
||||
'admin.placesPhotos.subtitle': 'Busca fotos da Google Places API. Desative para economizar cota da API. Fotos do Wikimedia não são afetadas.',
|
||||
'admin.placesAutocomplete.title': 'Autocompletar de Locais',
|
||||
'admin.placesAutocomplete.subtitle': 'Usa a Google Places API para sugestões de pesquisa. Desative para economizar cota da API.',
|
||||
'admin.placesDetails.title': 'Detalhes do Local',
|
||||
'admin.placesDetails.subtitle': 'Busca informações detalhadas do local (horários, avaliação, site) da Google Places API. Desative para economizar cota da API.',
|
||||
'admin.bagTracking.title': 'Rastreamento de malas',
|
||||
'admin.bagTracking.subtitle': 'Ativar peso e atribuição de mala para itens da lista',
|
||||
'admin.collab.chat.title': 'Chat',
|
||||
@@ -840,7 +821,6 @@ const br: Record<string, string | { name: string; category: string }[]> = {
|
||||
|
||||
// Trip Planner
|
||||
'trip.tabs.plan': 'Plano',
|
||||
'trip.tabs.transports': 'Transportes',
|
||||
'trip.tabs.reservations': 'Reservas',
|
||||
'trip.tabs.reservationsShort': 'Reservas',
|
||||
'trip.tabs.packing': 'Lista de mala',
|
||||
@@ -862,8 +842,6 @@ const br: Record<string, string | { name: string; category: string }[]> = {
|
||||
'trip.toast.reservationAdded': 'Reserva adicionada',
|
||||
'trip.toast.deleted': 'Excluído',
|
||||
'trip.confirm.deletePlace': 'Tem certeza de que deseja excluir este lugar?',
|
||||
'trip.confirm.deletePlaces': 'Excluir {count} lugares?',
|
||||
'trip.toast.placesDeleted': '{count} lugares excluídos',
|
||||
'trip.loadingPhotos': 'Carregando fotos dos lugares...',
|
||||
|
||||
// Day Plan Sidebar
|
||||
@@ -909,17 +887,6 @@ const br: Record<string, string | { name: string; category: string }[]> = {
|
||||
'places.importFileError': 'Importação falhou',
|
||||
'places.importAllSkipped': 'Todos os lugares já estavam na viagem.',
|
||||
'places.gpxImported': '{count} lugares importados do GPX',
|
||||
'places.gpxImportTypes': 'O que deseja importar?',
|
||||
'places.gpxImportWaypoints': 'Pontos de caminho',
|
||||
'places.gpxImportRoutes': 'Rotas',
|
||||
'places.gpxImportTracks': 'Trilhas (com geometria de percurso)',
|
||||
'places.gpxImportNoneSelected': 'Selecione pelo menos um tipo para importar.',
|
||||
'places.kmlImportTypes': 'O que deseja importar?',
|
||||
'places.kmlImportPoints': 'Pontos (Placemarks)',
|
||||
'places.kmlImportPaths': 'Caminhos (LineStrings)',
|
||||
'places.kmlImportNoneSelected': 'Selecione pelo menos um tipo.',
|
||||
'places.selectionCount': '{count} selecionado(s)',
|
||||
'places.deleteSelected': 'Excluir seleção',
|
||||
'places.kmlKmzImported': '{count} lugares importados de KMZ/KML',
|
||||
'places.urlResolved': 'Lugar importado da URL',
|
||||
'places.importList': 'Importar lista',
|
||||
@@ -936,7 +903,6 @@ const br: Record<string, string | { name: string; category: string }[]> = {
|
||||
'places.assignToDay': 'Adicionar a qual dia?',
|
||||
'places.all': 'Todos',
|
||||
'places.unplanned': 'Não planejados',
|
||||
'places.filterTracks': 'Trilhas',
|
||||
'places.search': 'Buscar lugares...',
|
||||
'places.allCategories': 'Todas as categorias',
|
||||
'places.categoriesSelected': 'categorias',
|
||||
@@ -1020,15 +986,6 @@ 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',
|
||||
@@ -1047,7 +1004,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',
|
||||
'reservations.type.car': 'Carro alugado',
|
||||
'reservations.type.cruise': 'Cruzeiro',
|
||||
'reservations.type.event': 'Evento',
|
||||
'reservations.type.tour': 'Passeio',
|
||||
@@ -1108,7 +1065,6 @@ const br: Record<string, string | { name: string; category: string }[]> = {
|
||||
'reservations.span.end': 'Fim',
|
||||
'reservations.span.ongoing': 'Em andamento',
|
||||
'reservations.validation.endBeforeStart': 'A data/hora final deve ser posterior à data/hora inicial',
|
||||
'reservations.addBooking': 'Adicionar reserva',
|
||||
|
||||
// Budget
|
||||
'budget.title': 'Orçamento',
|
||||
@@ -1733,7 +1689,6 @@ const br: Record<string, string | { name: string; category: string }[]> = {
|
||||
'undo.reorder': 'Locais reordenados',
|
||||
'undo.optimize': 'Rota otimizada',
|
||||
'undo.deletePlace': 'Local excluído',
|
||||
'undo.deletePlaces': 'Lugares excluídos',
|
||||
'undo.moveDay': 'Local movido para outro dia',
|
||||
'undo.lock': 'Bloqueio do local alternado',
|
||||
'undo.importGpx': 'Importação de GPX',
|
||||
@@ -1793,11 +1748,7 @@ 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': 'Nova tarefa',
|
||||
'todo.sidebar.sortBy': 'Ordenar por',
|
||||
'todo.priority': 'Prioridade',
|
||||
'todo.newCategoryLabel': 'nova',
|
||||
'budget.categoriesLabel': 'categorias',
|
||||
'todo.addItem': 'Adicionar nova tarefa...',
|
||||
'todo.newCategory': 'Nome da categoria',
|
||||
'todo.addCategory': 'Adicionar categoria',
|
||||
'todo.newItem': 'Nova tarefa',
|
||||
@@ -2287,11 +2238,6 @@ const br: Record<string, string | { name: string; category: string }[]> = {
|
||||
// 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.',
|
||||
'transport.addTransport': 'Add transport',
|
||||
'transport.modalTitle.create': 'Add transport',
|
||||
'transport.modalTitle.edit': 'Edit transport',
|
||||
'transport.title': 'Transportes',
|
||||
'transport.addManual': 'Transporte Manual',
|
||||
}
|
||||
|
||||
export default br
|
||||
|
||||
@@ -10,9 +10,6 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
|
||||
'common.add': 'Přidat',
|
||||
'common.loading': 'Načítání...',
|
||||
'common.import': 'Importovat',
|
||||
'common.select': 'Vybrat',
|
||||
'common.selectAll': 'Vybrat vše',
|
||||
'common.deselectAll': 'Zrušit výběr všeho',
|
||||
'common.error': 'Chyba',
|
||||
'common.unknownError': 'Neznámá chyba',
|
||||
'common.tooManyAttempts': 'Příliš mnoho pokusů. Zkuste to prosím znovu.',
|
||||
@@ -267,16 +264,6 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
|
||||
'settings.about.featureRequest': 'Navrhnout funkci',
|
||||
'settings.about.featureRequestHint': 'Navrhněte novou funkci',
|
||||
'settings.about.wikiHint': 'Dokumentace a návody',
|
||||
'settings.about.supporters.badge': 'Měsíční podporovatelé',
|
||||
'settings.about.supporters.title': 'Společníci na cestě s TREK',
|
||||
'settings.about.supporters.subtitle': 'Zatímco plánuješ další trasu, tihle lidé plánují společně se mnou budoucnost TREK. Jejich měsíční příspěvek jde přímo na vývoj a reálně strávené hodiny — aby TREK zůstal Open Source.',
|
||||
'settings.about.supporters.since': 'podporovatel od {date}',
|
||||
'settings.about.supporters.tierEmpty': 'Buď první',
|
||||
'settings.about.supporter.tier.noReturnTicket': 'No Return Ticket',
|
||||
'settings.about.supporter.tier.lostLuggageVip': 'Lost Luggage VIP',
|
||||
'settings.about.supporter.tier.businessClassDreamer': 'Business Class Dreamer',
|
||||
'settings.about.supporter.tier.budgetTraveller': 'Budget Traveller',
|
||||
'settings.about.supporter.tier.hostelBunkmate': 'Hostel Bunkmate',
|
||||
'settings.about.description': 'TREK je samohostovaný plánovač cest, který vám pomůže organizovat výlety od prvního nápadu po poslední vzpomínku. Denní plánování, rozpočet, balicí seznamy, fotky a mnoho dalšího — vše na jednom místě, na vašem vlastním serveru.',
|
||||
'settings.about.madeWith': 'Vytvořeno s',
|
||||
'settings.about.madeBy': 'Mauricem a rostoucí open-source komunitou.',
|
||||
@@ -559,12 +546,6 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
|
||||
'admin.fileTypesSaved': 'Nastavení souborů uloženo',
|
||||
|
||||
// Šablony balení (Packing Templates)
|
||||
'admin.placesPhotos.title': 'Fotografie míst',
|
||||
'admin.placesPhotos.subtitle': 'Načítání fotografií z Google Places API. Zakázáním ušetříte kvótu API. Fotografie z Wikimedia nejsou ovlivněny.',
|
||||
'admin.placesAutocomplete.title': 'Automatické doplňování míst',
|
||||
'admin.placesAutocomplete.subtitle': 'Použití Google Places API pro návrhy vyhledávání. Zakázáním ušetříte kvótu API.',
|
||||
'admin.placesDetails.title': 'Podrobnosti o místě',
|
||||
'admin.placesDetails.subtitle': 'Načítání podrobných informací o místě (hodiny, hodnocení, web) z Google Places API. Zakázáním ušetříte kvótu API.',
|
||||
'admin.bagTracking.title': 'Sledování zavazadel',
|
||||
'admin.bagTracking.subtitle': 'Povolit váhu a přiřazení k zavazadlům u položek balení',
|
||||
'admin.collab.chat.title': 'Chat',
|
||||
@@ -868,7 +849,6 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
|
||||
|
||||
// Plánovač cesty (Trip Planner)
|
||||
'trip.tabs.plan': 'Plán',
|
||||
'trip.tabs.transports': 'Doprava',
|
||||
'trip.tabs.reservations': 'Rezervace',
|
||||
'trip.tabs.reservationsShort': 'Rez.',
|
||||
'trip.tabs.packing': 'Seznam věcí',
|
||||
@@ -891,8 +871,6 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
|
||||
'trip.toast.reservationAdded': 'Rezervace přidána',
|
||||
'trip.toast.deleted': 'Smazáno',
|
||||
'trip.confirm.deletePlace': 'Opravdu chcete toto místo smazat?',
|
||||
'trip.confirm.deletePlaces': 'Smazat {count} míst?',
|
||||
'trip.toast.placesDeleted': '{count} míst smazáno',
|
||||
|
||||
// Denní plán (Day Plan)
|
||||
'dayplan.emptyDay': 'Na tento den nejsou naplánována žádná místa',
|
||||
@@ -937,17 +915,6 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
|
||||
'places.importFileError': 'Import se nezdařil',
|
||||
'places.importAllSkipped': 'Všechna místa již byla v cestě.',
|
||||
'places.gpxImported': '{count} míst importováno z GPX',
|
||||
'places.gpxImportTypes': 'Co chcete importovat?',
|
||||
'places.gpxImportWaypoints': 'Trasové body',
|
||||
'places.gpxImportRoutes': 'Trasy',
|
||||
'places.gpxImportTracks': 'Trasy GPS (s geometrií)',
|
||||
'places.gpxImportNoneSelected': 'Vyberte alespoň jeden typ k importu.',
|
||||
'places.kmlImportTypes': 'Co chcete importovat?',
|
||||
'places.kmlImportPoints': 'Body (Placemarks)',
|
||||
'places.kmlImportPaths': 'Trasy (LineStrings)',
|
||||
'places.kmlImportNoneSelected': 'Vyberte alespoň jeden typ.',
|
||||
'places.selectionCount': '{count} vybráno',
|
||||
'places.deleteSelected': 'Smazat vybrané',
|
||||
'places.kmlKmzImported': 'Importováno {count} míst z KMZ/KML',
|
||||
'places.urlResolved': 'Místo importováno z URL',
|
||||
'places.importList': 'Import seznamu',
|
||||
@@ -964,7 +931,6 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
|
||||
'places.assignToDay': 'Přidat do kterého dne?',
|
||||
'places.all': 'Vše',
|
||||
'places.unplanned': 'Nezařazené',
|
||||
'places.filterTracks': 'Trasy',
|
||||
'places.search': 'Hledat místa...',
|
||||
'places.allCategories': 'Všechny kategorie',
|
||||
'places.categoriesSelected': 'kategorií',
|
||||
@@ -1049,15 +1015,6 @@ 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',
|
||||
@@ -1076,7 +1033,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': 'Auto',
|
||||
'reservations.type.car': 'Pronájem auta',
|
||||
'reservations.type.cruise': 'Plavba',
|
||||
'reservations.type.event': 'Událost',
|
||||
'reservations.type.tour': 'Prohlídka',
|
||||
@@ -1137,7 +1094,6 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
|
||||
'reservations.span.end': 'Konec',
|
||||
'reservations.span.ongoing': 'Probíhá',
|
||||
'reservations.validation.endBeforeStart': 'Datum/čas konce musí být po datu/čase začátku',
|
||||
'reservations.addBooking': 'Přidat rezervaci',
|
||||
|
||||
// Rozpočet (Budget)
|
||||
'budget.title': 'Rozpočet',
|
||||
@@ -1736,7 +1692,6 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
|
||||
'undo.reorder': 'Místa přeseřazena',
|
||||
'undo.optimize': 'Trasa optimalizována',
|
||||
'undo.deletePlace': 'Místo smazáno',
|
||||
'undo.deletePlaces': 'Místa smazána',
|
||||
'undo.moveDay': 'Místo přesunuto na jiný den',
|
||||
'undo.lock': 'Zámek místa přepnut',
|
||||
'undo.importGpx': 'Import GPX',
|
||||
@@ -1798,11 +1753,7 @@ 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.sidebar.sortBy': 'Řadit podle',
|
||||
'todo.priority': 'Priorita',
|
||||
'todo.newCategoryLabel': 'nová',
|
||||
'budget.categoriesLabel': 'kategorie',
|
||||
'todo.addItem': 'Přidat nový úkol...',
|
||||
'todo.newCategory': 'Název kategorie',
|
||||
'todo.addCategory': 'Přidat kategorii',
|
||||
'todo.newItem': 'Nový úkol',
|
||||
@@ -2291,11 +2242,6 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
|
||||
// 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á.',
|
||||
'transport.addTransport': 'Add transport',
|
||||
'transport.modalTitle.create': 'Add transport',
|
||||
'transport.modalTitle.edit': 'Edit transport',
|
||||
'transport.title': 'Doprava',
|
||||
'transport.addManual': 'Ruční doprava',
|
||||
}
|
||||
|
||||
export default cs
|
||||
|
||||
@@ -10,9 +10,6 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
||||
'common.add': 'Hinzufügen',
|
||||
'common.loading': 'Laden...',
|
||||
'common.import': 'Importieren',
|
||||
'common.select': 'Auswählen',
|
||||
'common.selectAll': 'Alle auswählen',
|
||||
'common.deselectAll': 'Alle abwählen',
|
||||
'common.error': 'Fehler',
|
||||
'common.unknownError': 'Unbekannter Fehler',
|
||||
'common.tooManyAttempts': 'Zu viele Versuche. Bitte versuchen Sie es später erneut.',
|
||||
@@ -316,16 +313,6 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
||||
'settings.about.featureRequest': 'Feature vorschlagen',
|
||||
'settings.about.featureRequestHint': 'Schlage ein neues Feature vor',
|
||||
'settings.about.wikiHint': 'Dokumentation & Anleitungen',
|
||||
'settings.about.supporters.badge': 'Monatliche Unterstützer',
|
||||
'settings.about.supporters.title': 'Reisebegleitung für TREK',
|
||||
'settings.about.supporters.subtitle': 'Während du deine nächste Route planst, planen diese Leute mit, wie TREK weitergeht. Ihr monatlicher Beitrag fließt direkt in Entwicklung und echten Zeitaufwand — damit TREK Open Source bleibt.',
|
||||
'settings.about.supporters.since': 'Unterstützer seit {date}',
|
||||
'settings.about.supporters.tierEmpty': 'Sei die/der Erste',
|
||||
'settings.about.supporter.tier.noReturnTicket': 'No Return Ticket',
|
||||
'settings.about.supporter.tier.lostLuggageVip': 'Lost Luggage VIP',
|
||||
'settings.about.supporter.tier.businessClassDreamer': 'Business Class Dreamer',
|
||||
'settings.about.supporter.tier.budgetTraveller': 'Budget Traveller',
|
||||
'settings.about.supporter.tier.hostelBunkmate': 'Hostel Bunkmate',
|
||||
'settings.about.description': 'TREK ist ein selbst gehosteter Reiseplaner, der dir hilft, deine Trips von der ersten Idee bis zur letzten Erinnerung zu organisieren. Tagesplanung, Budget, Packlisten, Fotos und vieles mehr — alles an einem Ort, auf deinem eigenen Server.',
|
||||
'settings.about.madeWith': 'Entwickelt mit',
|
||||
'settings.about.madeBy': 'von Maurice und einer wachsenden Open-Source-Community.',
|
||||
@@ -564,12 +551,6 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
||||
'admin.fileTypesFormat': 'Kommagetrennte Endungen (z.B. jpg,png,pdf,doc). Verwende * um alle Typen zu erlauben.',
|
||||
'admin.fileTypesSaved': 'Dateityp-Einstellungen gespeichert',
|
||||
|
||||
'admin.placesPhotos.title': 'Ortsfotos',
|
||||
'admin.placesPhotos.subtitle': 'Fotos von der Google Places API laden. Deaktivieren, um API-Kontingent zu sparen. Wikimedia-Fotos sind davon nicht betroffen.',
|
||||
'admin.placesAutocomplete.title': 'Orts-Autovervollständigung',
|
||||
'admin.placesAutocomplete.subtitle': 'Google Places API für Suchvorschläge nutzen. Deaktivieren, um API-Kontingent zu sparen.',
|
||||
'admin.placesDetails.title': 'Ortsdetails',
|
||||
'admin.placesDetails.subtitle': 'Detaillierte Ortsinformationen (Öffnungszeiten, Bewertung, Website) von der Google Places API laden. Deaktivieren, um API-Kontingent zu sparen.',
|
||||
// Packing Templates & Bag Tracking
|
||||
'admin.bagTracking.title': 'Gepäck-Tracking',
|
||||
'admin.bagTracking.subtitle': 'Gewicht und Gepäckstück-Zuordnung für Packlisteneinträge aktivieren',
|
||||
@@ -873,7 +854,6 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
||||
|
||||
// Trip Planner
|
||||
'trip.tabs.plan': 'Karte',
|
||||
'trip.tabs.transports': 'Transporte',
|
||||
'trip.tabs.reservations': 'Buchungen',
|
||||
'trip.tabs.reservationsShort': 'Buchung',
|
||||
'trip.tabs.packing': 'Liste',
|
||||
@@ -896,8 +876,6 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
||||
'trip.toast.reservationAdded': 'Reservierung hinzugefügt',
|
||||
'trip.toast.deleted': 'Gelöscht',
|
||||
'trip.confirm.deletePlace': 'Möchtest du diesen Ort wirklich löschen?',
|
||||
'trip.confirm.deletePlaces': '{count} Orte löschen?',
|
||||
'trip.toast.placesDeleted': '{count} Orte gelöscht',
|
||||
|
||||
// Day Plan Sidebar
|
||||
'dayplan.emptyDay': 'Keine Orte für diesen Tag geplant',
|
||||
@@ -942,17 +920,6 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
||||
'places.importFileError': 'Import fehlgeschlagen',
|
||||
'places.importAllSkipped': 'Alle Orte waren bereits in der Reise.',
|
||||
'places.gpxImported': '{count} Orte aus GPX importiert',
|
||||
'places.gpxImportTypes': 'Was soll importiert werden?',
|
||||
'places.gpxImportWaypoints': 'Wegpunkte',
|
||||
'places.gpxImportRoutes': 'Routen',
|
||||
'places.gpxImportTracks': 'Tracks (mit Streckenverlauf)',
|
||||
'places.gpxImportNoneSelected': 'Wähle mindestens einen Typ zum Importieren.',
|
||||
'places.kmlImportTypes': 'Was möchtest du importieren?',
|
||||
'places.kmlImportPoints': 'Punkte (Placemarks)',
|
||||
'places.kmlImportPaths': 'Pfade (LineStrings)',
|
||||
'places.kmlImportNoneSelected': 'Wähle mindestens einen Typ aus.',
|
||||
'places.selectionCount': '{count} ausgewählt',
|
||||
'places.deleteSelected': 'Auswahl löschen',
|
||||
'places.kmlKmzImported': '{count} Orte aus KMZ/KML importiert',
|
||||
'places.urlResolved': 'Ort aus URL importiert',
|
||||
'places.importList': 'Listenimport',
|
||||
@@ -969,7 +936,6 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
||||
'places.assignToDay': 'Zu welchem Tag hinzufügen?',
|
||||
'places.all': 'Alle',
|
||||
'places.unplanned': 'Ungeplant',
|
||||
'places.filterTracks': 'Tracks',
|
||||
'places.search': 'Orte suchen...',
|
||||
'places.allCategories': 'Alle Kategorien',
|
||||
'places.categoriesSelected': 'Kategorien',
|
||||
@@ -1078,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': 'Auto',
|
||||
'reservations.type.car': 'Mietwagen',
|
||||
'reservations.type.cruise': 'Kreuzfahrt',
|
||||
'reservations.type.event': 'Veranstaltung',
|
||||
'reservations.type.tour': 'Tour',
|
||||
@@ -1139,7 +1105,6 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
||||
'reservations.span.end': 'Ende',
|
||||
'reservations.span.ongoing': 'Laufend',
|
||||
'reservations.validation.endBeforeStart': 'Enddatum/-zeit muss nach dem Startdatum/-zeit liegen',
|
||||
'reservations.addBooking': 'Buchung hinzufügen',
|
||||
|
||||
// Budget
|
||||
'budget.title': 'Budget',
|
||||
@@ -1741,7 +1706,6 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
||||
'undo.reorder': 'Orte neu sortiert',
|
||||
'undo.optimize': 'Route optimiert',
|
||||
'undo.deletePlace': 'Ort gelöscht',
|
||||
'undo.deletePlaces': 'Orte gelöscht',
|
||||
'undo.moveDay': 'Ort zu anderem Tag verschoben',
|
||||
'undo.lock': 'Ortssperre umgeschaltet',
|
||||
'undo.importGpx': 'GPX-Import',
|
||||
@@ -1801,11 +1765,7 @@ 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.sidebar.sortBy': 'Sortieren nach',
|
||||
'todo.priority': 'Priorität',
|
||||
'todo.newCategoryLabel': 'neu',
|
||||
'budget.categoriesLabel': 'Kategorien',
|
||||
'todo.addItem': 'Neue Aufgabe hinzufügen...',
|
||||
'todo.newCategory': 'Kategoriename',
|
||||
'todo.addCategory': 'Kategorie hinzufügen',
|
||||
'todo.newItem': 'Neue Aufgabe',
|
||||
@@ -2291,11 +2251,6 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
||||
// 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.',
|
||||
'transport.addTransport': 'Add transport',
|
||||
'transport.modalTitle.create': 'Add transport',
|
||||
'transport.modalTitle.edit': 'Edit transport',
|
||||
'transport.title': 'Transporte',
|
||||
'transport.addManual': 'Manuelles Transportmittel',
|
||||
}
|
||||
|
||||
export default de
|
||||
|
||||
@@ -10,9 +10,6 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
||||
'common.add': 'Add',
|
||||
'common.loading': 'Loading...',
|
||||
'common.import': 'Import',
|
||||
'common.select': 'Select',
|
||||
'common.selectAll': 'Select all',
|
||||
'common.deselectAll': 'Deselect all',
|
||||
'common.error': 'Error',
|
||||
'common.unknownError': 'Unknown error',
|
||||
'common.tooManyAttempts': 'Too many attempts. Please try again later.',
|
||||
@@ -375,16 +372,6 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
||||
'settings.about.featureRequest': 'Feature Request',
|
||||
'settings.about.featureRequestHint': 'Suggest a new feature',
|
||||
'settings.about.wikiHint': 'Documentation & guides',
|
||||
'settings.about.supporters.badge': 'Monthly Supporters',
|
||||
'settings.about.supporters.title': 'Travel companions for TREK',
|
||||
'settings.about.supporters.subtitle': "While you're planning your next route, these folks are helping plan TREK's future. Their monthly contribution goes straight into development and real hours spent — so TREK stays Open Source.",
|
||||
'settings.about.supporters.since': 'supporter since {date}',
|
||||
'settings.about.supporters.tierEmpty': 'Be the first',
|
||||
'settings.about.supporter.tier.noReturnTicket': 'No Return Ticket',
|
||||
'settings.about.supporter.tier.lostLuggageVip': 'Lost Luggage VIP',
|
||||
'settings.about.supporter.tier.businessClassDreamer': 'Business Class Dreamer',
|
||||
'settings.about.supporter.tier.budgetTraveller': 'Budget Traveller',
|
||||
'settings.about.supporter.tier.hostelBunkmate': 'Hostel Bunkmate',
|
||||
'settings.about.description': 'TREK is a self-hosted travel planner that helps you organize your trips from the first idea to the last memory. Day planning, budget, packing lists, photos and much more — all in one place, on your own server.',
|
||||
'settings.about.madeWith': 'Made with',
|
||||
'settings.about.madeBy': 'by Maurice and a growing open-source community.',
|
||||
@@ -624,12 +611,6 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
||||
'admin.fileTypesFormat': 'Comma-separated extensions (e.g. jpg,png,pdf,doc). Use * to allow all types.',
|
||||
'admin.fileTypesSaved': 'File type settings saved',
|
||||
|
||||
'admin.placesPhotos.title': 'Place Photos',
|
||||
'admin.placesPhotos.subtitle': 'Fetch photos from the Google Places API. Disable to save API quota. Wikimedia photos are unaffected.',
|
||||
'admin.placesAutocomplete.title': 'Place Autocomplete',
|
||||
'admin.placesAutocomplete.subtitle': 'Use the Google Places API for search suggestions. Disable to save API quota.',
|
||||
'admin.placesDetails.title': 'Place Details',
|
||||
'admin.placesDetails.subtitle': 'Fetch detailed place information (hours, rating, website) from the Google Places API. Disable to save API quota.',
|
||||
// Packing Templates & Bag Tracking
|
||||
'admin.bagTracking.title': 'Bag Tracking',
|
||||
'admin.bagTracking.subtitle': 'Enable weight and bag assignment for packing items',
|
||||
@@ -930,7 +911,6 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
||||
|
||||
// Trip Planner
|
||||
'trip.tabs.plan': 'Plan',
|
||||
'trip.tabs.transports': 'Transports',
|
||||
'trip.tabs.reservations': 'Bookings',
|
||||
'trip.tabs.reservationsShort': 'Book',
|
||||
'trip.tabs.packing': 'Packing List',
|
||||
@@ -953,8 +933,6 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
||||
'trip.toast.reservationAdded': 'Reservation added',
|
||||
'trip.toast.deleted': 'Deleted',
|
||||
'trip.confirm.deletePlace': 'Are you sure you want to delete this place?',
|
||||
'trip.confirm.deletePlaces': 'Delete {count} places?',
|
||||
'trip.toast.placesDeleted': '{count} places deleted',
|
||||
|
||||
// Day Plan Sidebar
|
||||
'dayplan.emptyDay': 'No places planned for this day',
|
||||
@@ -999,17 +977,6 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
||||
'places.importFileError': 'Import failed',
|
||||
'places.importAllSkipped': 'All places were already in the trip.',
|
||||
'places.gpxImported': '{count} places imported from GPX',
|
||||
'places.gpxImportTypes': 'What do you want to import?',
|
||||
'places.gpxImportWaypoints': 'Waypoints',
|
||||
'places.gpxImportRoutes': 'Routes',
|
||||
'places.gpxImportTracks': 'Tracks (with path geometry)',
|
||||
'places.gpxImportNoneSelected': 'Select at least one type to import.',
|
||||
'places.kmlImportTypes': 'What do you want to import?',
|
||||
'places.kmlImportPoints': 'Points (Placemarks)',
|
||||
'places.kmlImportPaths': 'Paths (LineStrings)',
|
||||
'places.kmlImportNoneSelected': 'Select at least one type to import.',
|
||||
'places.selectionCount': '{count} selected',
|
||||
'places.deleteSelected': 'Delete selected',
|
||||
'places.kmlKmzImported': '{count} places imported from KMZ/KML',
|
||||
'places.urlResolved': 'Place imported from URL',
|
||||
'places.importList': 'List Import',
|
||||
@@ -1026,7 +993,6 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
||||
'places.assignToDay': 'Add to which day?',
|
||||
'places.all': 'All',
|
||||
'places.unplanned': 'Unplanned',
|
||||
'places.filterTracks': 'Tracks',
|
||||
'places.search': 'Search places...',
|
||||
'places.allCategories': 'All Categories',
|
||||
'places.categoriesSelected': 'categories',
|
||||
@@ -1135,7 +1101,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': 'Car',
|
||||
'reservations.type.car': 'Rental Car',
|
||||
'reservations.type.cruise': 'Cruise',
|
||||
'reservations.type.event': 'Event',
|
||||
'reservations.type.tour': 'Tour',
|
||||
@@ -1196,7 +1162,6 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
||||
'reservations.span.end': 'End',
|
||||
'reservations.span.ongoing': 'Ongoing',
|
||||
'reservations.validation.endBeforeStart': 'End date/time must be after start date/time',
|
||||
'reservations.addBooking': 'Add booking',
|
||||
|
||||
// Budget
|
||||
'budget.title': 'Budget',
|
||||
@@ -1810,7 +1775,6 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
||||
'undo.reorder': 'Places reordered',
|
||||
'undo.optimize': 'Route optimized',
|
||||
'undo.deletePlace': 'Place deleted',
|
||||
'undo.deletePlaces': 'Places deleted',
|
||||
'undo.moveDay': 'Place moved to another day',
|
||||
'undo.lock': 'Place lock toggled',
|
||||
'undo.importGpx': 'GPX import',
|
||||
@@ -1867,11 +1831,7 @@ 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.sidebar.sortBy': 'Sort by',
|
||||
'todo.priority': 'Priority',
|
||||
'todo.newCategoryLabel': 'new',
|
||||
'budget.categoriesLabel': 'categories',
|
||||
'todo.addItem': 'Add new task...',
|
||||
'todo.newCategory': 'Category name',
|
||||
'todo.addCategory': 'Add category',
|
||||
'todo.newItem': 'New task',
|
||||
@@ -2329,11 +2289,6 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
||||
'system_notice.pager.counter': '{current} / {total}',
|
||||
'system_notice.pager.goto': 'Go to notice {n}',
|
||||
'system_notice.pager.position': 'Notice {current} of {total}',
|
||||
'transport.addTransport': 'Add transport',
|
||||
'transport.modalTitle.create': 'Add transport',
|
||||
'transport.modalTitle.edit': 'Edit transport',
|
||||
'transport.title': 'Transports',
|
||||
'transport.addManual': 'Manual Transport',
|
||||
}
|
||||
|
||||
export default en
|
||||
|
||||
@@ -10,9 +10,6 @@ const es: Record<string, string> = {
|
||||
'common.add': 'Añadir',
|
||||
'common.loading': 'Cargando...',
|
||||
'common.import': 'Importar',
|
||||
'common.select': 'Seleccionar',
|
||||
'common.selectAll': 'Seleccionar todo',
|
||||
'common.deselectAll': 'Deseleccionar todo',
|
||||
'common.error': 'Error',
|
||||
'common.unknownError': 'Error desconocido',
|
||||
'common.tooManyAttempts': 'Demasiados intentos. Inténtelo de nuevo más tarde.',
|
||||
@@ -312,16 +309,6 @@ const es: Record<string, string> = {
|
||||
'settings.about.featureRequest': 'Solicitar función',
|
||||
'settings.about.featureRequestHint': 'Sugiere una nueva función',
|
||||
'settings.about.wikiHint': 'Documentación y guías',
|
||||
'settings.about.supporters.badge': 'Patrocinadores Mensuales',
|
||||
'settings.about.supporters.title': 'Compañía de viaje para TREK',
|
||||
'settings.about.supporters.subtitle': 'Mientras planeas tu próxima ruta, estas personas ayudan a planear el futuro de TREK. Su aporte mensual va directo al desarrollo y a las horas reales invertidas — para que TREK siga siendo Open Source.',
|
||||
'settings.about.supporters.since': 'patrocinador desde {date}',
|
||||
'settings.about.supporters.tierEmpty': 'Sé el primero',
|
||||
'settings.about.supporter.tier.noReturnTicket': 'No Return Ticket',
|
||||
'settings.about.supporter.tier.lostLuggageVip': 'Lost Luggage VIP',
|
||||
'settings.about.supporter.tier.businessClassDreamer': 'Business Class Dreamer',
|
||||
'settings.about.supporter.tier.budgetTraveller': 'Budget Traveller',
|
||||
'settings.about.supporter.tier.hostelBunkmate': 'Hostel Bunkmate',
|
||||
'settings.about.description': 'TREK es un planificador de viajes autoalojado que te ayuda a organizar tus viajes desde la primera idea hasta el último recuerdo. Planificación diaria, presupuesto, listas de equipaje, fotos y mucho más — todo en un solo lugar, en tu propio servidor.',
|
||||
'settings.about.madeWith': 'Hecho con',
|
||||
'settings.about.madeBy': 'por Maurice y una creciente comunidad de código abierto.',
|
||||
@@ -554,12 +541,6 @@ const es: Record<string, string> = {
|
||||
'admin.fileTypesFormat': 'Extensiones separadas por comas (p. ej. jpg,png,pdf,doc). Usa * para permitir todos los tipos.',
|
||||
'admin.fileTypesSaved': 'Ajustes de tipos de archivo guardados',
|
||||
|
||||
'admin.placesPhotos.title': 'Fotos de Lugares',
|
||||
'admin.placesPhotos.subtitle': 'Obtiene fotos de la Google Places API. Desactiva para ahorrar cuota de API. Las fotos de Wikimedia no se ven afectadas.',
|
||||
'admin.placesAutocomplete.title': 'Autocompletado de Lugares',
|
||||
'admin.placesAutocomplete.subtitle': 'Usa la Google Places API para sugerencias de búsqueda. Desactiva para ahorrar cuota de API.',
|
||||
'admin.placesDetails.title': 'Detalles del Lugar',
|
||||
'admin.placesDetails.subtitle': 'Obtiene información detallada del lugar (horarios, valoración, web) de la Google Places API. Desactiva para ahorrar cuota de API.',
|
||||
'admin.bagTracking.title': 'Seguimiento de equipaje',
|
||||
'admin.bagTracking.subtitle': 'Activar peso y asignación de equipaje para artículos de la lista',
|
||||
'admin.collab.chat.title': 'Chat',
|
||||
@@ -843,7 +824,6 @@ const es: Record<string, string> = {
|
||||
|
||||
// Trip Planner
|
||||
'trip.tabs.plan': 'Plan',
|
||||
'trip.tabs.transports': 'Transportes',
|
||||
'trip.tabs.reservations': 'Reservas',
|
||||
'trip.tabs.reservationsShort': 'Reservas',
|
||||
'trip.tabs.packing': 'Lista de equipaje',
|
||||
@@ -866,8 +846,6 @@ const es: Record<string, string> = {
|
||||
'trip.toast.reservationAdded': 'Reserva añadida',
|
||||
'trip.toast.deleted': 'Eliminado',
|
||||
'trip.confirm.deletePlace': '¿Seguro que quieres eliminar este lugar?',
|
||||
'trip.confirm.deletePlaces': '¿Eliminar {count} lugares?',
|
||||
'trip.toast.placesDeleted': '{count} lugares eliminados',
|
||||
|
||||
// Day Plan Sidebar
|
||||
'dayplan.emptyDay': 'No hay lugares planificados para este día',
|
||||
@@ -912,17 +890,6 @@ const es: Record<string, string> = {
|
||||
'places.importFileError': 'Importación fallida',
|
||||
'places.importAllSkipped': 'Todos los lugares ya estaban en el viaje.',
|
||||
'places.gpxImported': '{count} lugares importados desde GPX',
|
||||
'places.gpxImportTypes': '¿Qué deseas importar?',
|
||||
'places.gpxImportWaypoints': 'Puntos de ruta',
|
||||
'places.gpxImportRoutes': 'Rutas',
|
||||
'places.gpxImportTracks': 'Tracks (con geometría de ruta)',
|
||||
'places.gpxImportNoneSelected': 'Selecciona al menos un tipo para importar.',
|
||||
'places.kmlImportTypes': '¿Qué deseas importar?',
|
||||
'places.kmlImportPoints': 'Puntos (Placemarks)',
|
||||
'places.kmlImportPaths': 'Rutas (LineStrings)',
|
||||
'places.kmlImportNoneSelected': 'Selecciona al menos un tipo.',
|
||||
'places.selectionCount': '{count} seleccionado(s)',
|
||||
'places.deleteSelected': 'Eliminar selección',
|
||||
'places.kmlKmzImported': '{count} lugares importados desde KMZ/KML',
|
||||
'places.urlResolved': 'Lugar importado desde URL',
|
||||
'places.importList': 'Importar lista',
|
||||
@@ -939,7 +906,6 @@ const es: Record<string, string> = {
|
||||
'places.assignToDay': '¿A qué día añadirlo?',
|
||||
'places.all': 'Todo',
|
||||
'places.unplanned': 'Sin planificar',
|
||||
'places.filterTracks': 'Rutas',
|
||||
'places.search': 'Buscar lugares...',
|
||||
'places.allCategories': 'Todas las categorías',
|
||||
'places.categoriesSelected': 'categorías',
|
||||
@@ -1024,7 +990,7 @@ const es: Record<string, string> = {
|
||||
'reservations.type.hotel': 'Alojamiento',
|
||||
'reservations.type.restaurant': 'Restaurante',
|
||||
'reservations.type.train': 'Tren',
|
||||
'reservations.type.car': 'Coche',
|
||||
'reservations.type.car': 'Coche de alquiler',
|
||||
'reservations.type.cruise': 'Crucero',
|
||||
'reservations.type.event': 'Evento',
|
||||
'reservations.type.tour': 'Excursión',
|
||||
@@ -1085,7 +1051,6 @@ const es: Record<string, string> = {
|
||||
'reservations.span.end': 'Fin',
|
||||
'reservations.span.ongoing': 'En curso',
|
||||
'reservations.validation.endBeforeStart': 'La fecha/hora de fin debe ser posterior a la de inicio',
|
||||
'reservations.addBooking': 'Añadir reserva',
|
||||
|
||||
// Budget
|
||||
'budget.title': 'Presupuesto',
|
||||
@@ -1653,15 +1618,6 @@ 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',
|
||||
@@ -1743,7 +1699,6 @@ const es: Record<string, string> = {
|
||||
'undo.reorder': 'Lugares reordenados',
|
||||
'undo.optimize': 'Ruta optimizada',
|
||||
'undo.deletePlace': 'Lugar eliminado',
|
||||
'undo.deletePlaces': 'Lugares eliminados',
|
||||
'undo.moveDay': 'Lugar movido a otro día',
|
||||
'undo.lock': 'Bloqueo de lugar activado/desactivado',
|
||||
'undo.importGpx': 'Importación GPX',
|
||||
@@ -1803,11 +1758,7 @@ const es: Record<string, string> = {
|
||||
'todo.unassigned': 'Sin asignar',
|
||||
'todo.noCategory': 'Sin categoría',
|
||||
'todo.hasDescription': 'Con descripción',
|
||||
'todo.addItem': 'Nueva tarea',
|
||||
'todo.sidebar.sortBy': 'Ordenar por',
|
||||
'todo.priority': 'Prioridad',
|
||||
'todo.newCategoryLabel': 'nueva',
|
||||
'budget.categoriesLabel': 'categorías',
|
||||
'todo.addItem': 'Añadir nueva tarea...',
|
||||
'todo.newCategory': 'Nombre de la categoría',
|
||||
'todo.addCategory': 'Añadir categoría',
|
||||
'todo.newItem': 'Nueva tarea',
|
||||
@@ -2293,11 +2244,6 @@ const es: Record<string, string> = {
|
||||
// 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.',
|
||||
'transport.addTransport': 'Add transport',
|
||||
'transport.modalTitle.create': 'Add transport',
|
||||
'transport.modalTitle.edit': 'Edit transport',
|
||||
'transport.title': 'Transportes',
|
||||
'transport.addManual': 'Transporte manual',
|
||||
}
|
||||
|
||||
export default es
|
||||
|
||||
@@ -10,9 +10,6 @@ const fr: Record<string, string> = {
|
||||
'common.add': 'Ajouter',
|
||||
'common.loading': 'Chargement…',
|
||||
'common.import': 'Importer',
|
||||
'common.select': 'Sélectionner',
|
||||
'common.selectAll': 'Tout sélectionner',
|
||||
'common.deselectAll': 'Tout désélectionner',
|
||||
'common.error': 'Erreur',
|
||||
'common.unknownError': 'Erreur inconnue',
|
||||
'common.tooManyAttempts': 'Trop de tentatives. Veuillez réessayer plus tard.',
|
||||
@@ -311,16 +308,6 @@ const fr: Record<string, string> = {
|
||||
'settings.about.featureRequest': 'Proposer une fonctionnalité',
|
||||
'settings.about.featureRequestHint': 'Suggérez une nouvelle fonctionnalité',
|
||||
'settings.about.wikiHint': 'Documentation et guides',
|
||||
'settings.about.supporters.badge': 'Soutiens Mensuels',
|
||||
'settings.about.supporters.title': 'Compagnons de voyage pour TREK',
|
||||
'settings.about.supporters.subtitle': 'Pendant que tu planifies ton prochain itinéraire, ces personnes aident à planifier l\'avenir de TREK. Leur contribution mensuelle va directement au développement et aux heures réellement passées — pour que TREK reste Open Source.',
|
||||
'settings.about.supporters.since': 'soutien depuis {date}',
|
||||
'settings.about.supporters.tierEmpty': 'Sois le premier',
|
||||
'settings.about.supporter.tier.noReturnTicket': 'No Return Ticket',
|
||||
'settings.about.supporter.tier.lostLuggageVip': 'Lost Luggage VIP',
|
||||
'settings.about.supporter.tier.businessClassDreamer': 'Business Class Dreamer',
|
||||
'settings.about.supporter.tier.budgetTraveller': 'Budget Traveller',
|
||||
'settings.about.supporter.tier.hostelBunkmate': 'Hostel Bunkmate',
|
||||
'settings.about.description': 'TREK est un planificateur de voyage auto-hébergé qui vous aide à organiser vos voyages de la première idée au dernier souvenir. Planification journalière, budget, listes de bagages, photos et bien plus — le tout au même endroit, sur votre propre serveur.',
|
||||
'settings.about.madeWith': 'Fait avec',
|
||||
'settings.about.madeBy': 'par Maurice et une communauté open-source grandissante.',
|
||||
@@ -558,12 +545,6 @@ const fr: Record<string, string> = {
|
||||
'admin.fileTypesFormat': 'Extensions séparées par des virgules (ex. jpg,png,pdf,doc). Utilisez * pour autoriser tous les types.',
|
||||
'admin.fileTypesSaved': 'Paramètres des types de fichiers enregistrés',
|
||||
|
||||
'admin.placesPhotos.title': 'Photos de lieux',
|
||||
'admin.placesPhotos.subtitle': "Récupère les photos depuis l'API Google Places. Désactivez pour économiser le quota API. Les photos Wikimedia ne sont pas affectées.",
|
||||
'admin.placesAutocomplete.title': 'Autocomplétion des lieux',
|
||||
'admin.placesAutocomplete.subtitle': "Utilise l'API Google Places pour les suggestions de recherche. Désactivez pour économiser le quota API.",
|
||||
'admin.placesDetails.title': 'Détails du lieu',
|
||||
'admin.placesDetails.subtitle': "Récupère les informations détaillées du lieu (horaires, note, site web) depuis l'API Google Places. Désactivez pour économiser le quota API.",
|
||||
'admin.bagTracking.title': 'Suivi des bagages',
|
||||
'admin.bagTracking.subtitle': 'Activer le poids et l\'attribution de bagages pour les articles',
|
||||
'admin.collab.chat.title': 'Chat',
|
||||
@@ -867,7 +848,6 @@ const fr: Record<string, string> = {
|
||||
|
||||
// Trip Planner
|
||||
'trip.tabs.plan': 'Plan',
|
||||
'trip.tabs.transports': 'Transports',
|
||||
'trip.tabs.reservations': 'Réservations',
|
||||
'trip.tabs.reservationsShort': 'Résa',
|
||||
'trip.tabs.packing': 'Liste de bagages',
|
||||
@@ -890,8 +870,6 @@ const fr: Record<string, string> = {
|
||||
'trip.toast.reservationAdded': 'Réservation ajoutée',
|
||||
'trip.toast.deleted': 'Supprimé',
|
||||
'trip.confirm.deletePlace': 'Voulez-vous vraiment supprimer ce lieu ?',
|
||||
'trip.confirm.deletePlaces': 'Supprimer {count} lieux?',
|
||||
'trip.toast.placesDeleted': '{count} lieux supprimés',
|
||||
|
||||
// Day Plan Sidebar
|
||||
'dayplan.emptyDay': 'Aucun lieu prévu pour ce jour',
|
||||
@@ -936,17 +914,6 @@ const fr: Record<string, string> = {
|
||||
'places.importFileError': 'Importation échouée',
|
||||
'places.importAllSkipped': 'Tous les lieux étaient déjà dans le voyage.',
|
||||
'places.gpxImported': '{count} lieux importés depuis GPX',
|
||||
'places.gpxImportTypes': 'Que voulez-vous importer?',
|
||||
'places.gpxImportWaypoints': 'Points de passage',
|
||||
'places.gpxImportRoutes': 'Itinéraires',
|
||||
'places.gpxImportTracks': 'Traces (avec géométrie)',
|
||||
'places.gpxImportNoneSelected': 'Sélectionnez au moins un type à importer.',
|
||||
'places.kmlImportTypes': 'Que souhaitez-vous importer ?',
|
||||
'places.kmlImportPoints': 'Points (Placemarks)',
|
||||
'places.kmlImportPaths': 'Chemins (LineStrings)',
|
||||
'places.kmlImportNoneSelected': 'Sélectionnez au moins un type.',
|
||||
'places.selectionCount': '{count} sélectionné(s)',
|
||||
'places.deleteSelected': 'Supprimer la sélection',
|
||||
'places.kmlKmzImported': '{count} lieux importés depuis KMZ/KML',
|
||||
'places.urlResolved': 'Lieu importé depuis l\'URL',
|
||||
'places.importList': 'Import de liste',
|
||||
@@ -963,7 +930,6 @@ const fr: Record<string, string> = {
|
||||
'places.assignToDay': 'Ajouter à quel jour ?',
|
||||
'places.all': 'Tous',
|
||||
'places.unplanned': 'Non planifiés',
|
||||
'places.filterTracks': 'Traces',
|
||||
'places.search': 'Rechercher des lieux…',
|
||||
'places.allCategories': 'Toutes les catégories',
|
||||
'places.categoriesSelected': 'catégories',
|
||||
@@ -1047,15 +1013,6 @@ 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',
|
||||
@@ -1074,7 +1031,7 @@ const fr: Record<string, string> = {
|
||||
'reservations.type.hotel': 'Hébergement',
|
||||
'reservations.type.restaurant': 'Restaurant',
|
||||
'reservations.type.train': 'Train',
|
||||
'reservations.type.car': 'Voiture',
|
||||
'reservations.type.car': 'Voiture de location',
|
||||
'reservations.type.cruise': 'Croisière',
|
||||
'reservations.type.event': 'Événement',
|
||||
'reservations.type.tour': 'Visite',
|
||||
@@ -1135,7 +1092,6 @@ const fr: Record<string, string> = {
|
||||
'reservations.span.end': 'Fin',
|
||||
'reservations.span.ongoing': 'En cours',
|
||||
'reservations.validation.endBeforeStart': 'La date/heure de fin doit être postérieure à la date/heure de début',
|
||||
'reservations.addBooking': 'Ajouter une réservation',
|
||||
|
||||
// Budget
|
||||
'budget.title': 'Budget',
|
||||
@@ -1737,7 +1693,6 @@ const fr: Record<string, string> = {
|
||||
'undo.reorder': 'Lieux réorganisés',
|
||||
'undo.optimize': 'Itinéraire optimisé',
|
||||
'undo.deletePlace': 'Lieu supprimé',
|
||||
'undo.deletePlaces': 'Lieux supprimés',
|
||||
'undo.moveDay': 'Lieu déplacé vers un autre jour',
|
||||
'undo.lock': 'Verrouillage du lieu modifié',
|
||||
'undo.importGpx': 'Import GPX',
|
||||
@@ -1797,11 +1752,7 @@ const fr: Record<string, string> = {
|
||||
'todo.unassigned': 'Non assigné',
|
||||
'todo.noCategory': 'Aucune catégorie',
|
||||
'todo.hasDescription': 'Avec description',
|
||||
'todo.addItem': 'Nouvelle tâche',
|
||||
'todo.sidebar.sortBy': 'Trier par',
|
||||
'todo.priority': 'Priorité',
|
||||
'todo.newCategoryLabel': 'nouvelle',
|
||||
'budget.categoriesLabel': 'catégories',
|
||||
'todo.addItem': 'Ajouter une tâche...',
|
||||
'todo.newCategory': 'Nom de la catégorie',
|
||||
'todo.addCategory': 'Ajouter une catégorie',
|
||||
'todo.newItem': 'Nouvelle tâche',
|
||||
@@ -2287,11 +2238,6 @@ const fr: Record<string, string> = {
|
||||
// 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.',
|
||||
'transport.addTransport': 'Add transport',
|
||||
'transport.modalTitle.create': 'Add transport',
|
||||
'transport.modalTitle.edit': 'Edit transport',
|
||||
'transport.title': 'Transports',
|
||||
'transport.addManual': 'Transport manuel',
|
||||
}
|
||||
|
||||
export default fr
|
||||
|
||||
@@ -10,9 +10,6 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
|
||||
'common.add': 'Hozzáadás',
|
||||
'common.loading': 'Betöltés...',
|
||||
'common.import': 'Importálás',
|
||||
'common.select': 'Kiválaszt',
|
||||
'common.selectAll': 'Mindet kiválaszt',
|
||||
'common.deselectAll': 'Összes kijelölés megszüntetése',
|
||||
'common.error': 'Hiba',
|
||||
'common.unknownError': 'Ismeretlen hiba',
|
||||
'common.tooManyAttempts': 'Túl sok próbálkozás. Kérjük, próbálja újra később.',
|
||||
@@ -266,16 +263,6 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
|
||||
'settings.about.featureRequest': 'Funkció javaslat',
|
||||
'settings.about.featureRequestHint': 'Javasolj egy új funkciót',
|
||||
'settings.about.wikiHint': 'Dokumentáció és útmutatók',
|
||||
'settings.about.supporters.badge': 'Havi támogatók',
|
||||
'settings.about.supporters.title': 'Útitársak a TREK mellett',
|
||||
'settings.about.supporters.subtitle': 'Miközben te a következő útvonaladat tervezed, ők a TREK jövőjét tervezik velem együtt. Havi hozzájárulásuk közvetlenül fejlesztésre és valódi órákra fordítódik — hogy a TREK Open Source maradhasson.',
|
||||
'settings.about.supporters.since': 'támogató {date} óta',
|
||||
'settings.about.supporters.tierEmpty': 'Légy az első',
|
||||
'settings.about.supporter.tier.noReturnTicket': 'No Return Ticket',
|
||||
'settings.about.supporter.tier.lostLuggageVip': 'Lost Luggage VIP',
|
||||
'settings.about.supporter.tier.businessClassDreamer': 'Business Class Dreamer',
|
||||
'settings.about.supporter.tier.budgetTraveller': 'Budget Traveller',
|
||||
'settings.about.supporter.tier.hostelBunkmate': 'Hostel Bunkmate',
|
||||
'settings.about.description': 'A TREK egy saját szerveren üzemeltetett útitervező, amely segít az utazásaid megszervezésében az első ötlettől az utolsó emlékig. Napi tervezés, költségvetés, csomagolási listák, fotók és még sok más — minden egy helyen, a saját szervereden.',
|
||||
'settings.about.madeWith': 'Készítve',
|
||||
'settings.about.madeBy': 'Maurice és egy növekvő nyílt forráskódú közösség által.',
|
||||
@@ -559,12 +546,6 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
|
||||
'admin.fileTypesSaved': 'Fájltípus-beállítások mentve',
|
||||
|
||||
// Csomagolási sablonok és poggyászkövetés
|
||||
'admin.placesPhotos.title': 'Helyfotók',
|
||||
'admin.placesPhotos.subtitle': 'Fotók lekérése a Google Places API-ból. Tiltsa le az API-kvóta megtakarításához. A Wikimedia-fotók nem érintettek.',
|
||||
'admin.placesAutocomplete.title': 'Hely automatikus kiegészítése',
|
||||
'admin.placesAutocomplete.subtitle': 'A Google Places API használata keresési javaslatokhoz. Tiltsa le az API-kvóta megtakarításához.',
|
||||
'admin.placesDetails.title': 'Hely részletei',
|
||||
'admin.placesDetails.subtitle': 'Részletes helyinformációk lekérése (nyitvatartás, értékelés, weboldal) a Google Places API-ból. Tiltsa le az API-kvóta megtakarításához.',
|
||||
'admin.bagTracking.title': 'Poggyászkövetés',
|
||||
'admin.bagTracking.subtitle': 'Súly- és táskahozzárendelés engedélyezése csomagolási tételeknél',
|
||||
'admin.collab.chat.title': 'Chat',
|
||||
@@ -868,7 +849,6 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
|
||||
|
||||
// Utazástervező
|
||||
'trip.tabs.plan': 'Terv',
|
||||
'trip.tabs.transports': 'Közlekedés',
|
||||
'trip.tabs.reservations': 'Foglalások',
|
||||
'trip.tabs.reservationsShort': 'Foglalás',
|
||||
'trip.tabs.packing': 'Csomagolási lista',
|
||||
@@ -890,8 +870,6 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
|
||||
'trip.toast.reservationAdded': 'Foglalás hozzáadva',
|
||||
'trip.toast.deleted': 'Törölve',
|
||||
'trip.confirm.deletePlace': 'Biztosan törölni szeretnéd ezt a helyet?',
|
||||
'trip.confirm.deletePlaces': '{count} helyet töröl?',
|
||||
'trip.toast.placesDeleted': '{count} hely törölve',
|
||||
'trip.loadingPhotos': 'Helyek fotóinak betöltése...',
|
||||
|
||||
// Napi terv oldalsáv
|
||||
@@ -937,17 +915,6 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
|
||||
'places.importFileError': 'Importálás sikertelen',
|
||||
'places.importAllSkipped': 'Minden hely már szerepel az utazásban.',
|
||||
'places.gpxImported': '{count} hely importálva GPX-ből',
|
||||
'places.gpxImportTypes': 'Mit szeretnél importálni?',
|
||||
'places.gpxImportWaypoints': 'Útpontok',
|
||||
'places.gpxImportRoutes': 'Útvonalak',
|
||||
'places.gpxImportTracks': 'Nyomvonalak (útvonalgeometriával)',
|
||||
'places.gpxImportNoneSelected': 'Válassz legalább egy típust az importáláshoz.',
|
||||
'places.kmlImportTypes': 'Mit szeretnél importálni?',
|
||||
'places.kmlImportPoints': 'Pontok (Placemarks)',
|
||||
'places.kmlImportPaths': 'Útvonalak (LineStrings)',
|
||||
'places.kmlImportNoneSelected': 'Válassz legalább egy típust.',
|
||||
'places.selectionCount': '{count} kiválasztva',
|
||||
'places.deleteSelected': 'Kijelöltek törlése',
|
||||
'places.kmlKmzImported': '{count} hely importálva KMZ/KML-ből',
|
||||
'places.urlResolved': 'Hely importálva URL-ből',
|
||||
'places.importList': 'Lista importálás',
|
||||
@@ -964,7 +931,6 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
|
||||
'places.assignToDay': 'Melyik naphoz adod?',
|
||||
'places.all': 'Összes',
|
||||
'places.unplanned': 'Nem tervezett',
|
||||
'places.filterTracks': 'Nyomvonalak',
|
||||
'places.search': 'Helyek keresése...',
|
||||
'places.allCategories': 'Összes kategória',
|
||||
'places.categoriesSelected': 'kategória',
|
||||
@@ -1049,15 +1015,6 @@ 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',
|
||||
@@ -1076,7 +1033,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ó',
|
||||
'reservations.type.car': 'Autóbérlés',
|
||||
'reservations.type.cruise': 'Hajóút',
|
||||
'reservations.type.event': 'Esemény',
|
||||
'reservations.type.tour': 'Túra',
|
||||
@@ -1136,7 +1093,6 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
|
||||
'reservations.span.end': 'Vége',
|
||||
'reservations.span.ongoing': 'Folyamatban',
|
||||
'reservations.validation.endBeforeStart': 'A befejezés dátuma/időpontja a kezdés utáni kell legyen',
|
||||
'reservations.addBooking': 'Foglalás hozzáadása',
|
||||
|
||||
// Költségvetés
|
||||
'budget.title': 'Költségvetés',
|
||||
@@ -1735,7 +1691,6 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
|
||||
'undo.reorder': 'Helyek átrendezve',
|
||||
'undo.optimize': 'Útvonal optimalizálva',
|
||||
'undo.deletePlace': 'Hely törölve',
|
||||
'undo.deletePlaces': 'Helyek törölve',
|
||||
'undo.moveDay': 'Hely áthelyezve másik napra',
|
||||
'undo.lock': 'Hely zárolása váltva',
|
||||
'undo.importGpx': 'GPX importálás',
|
||||
@@ -1795,11 +1750,7 @@ 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',
|
||||
'todo.sidebar.sortBy': 'Rendezés',
|
||||
'todo.priority': 'Prioritás',
|
||||
'todo.newCategoryLabel': 'új',
|
||||
'budget.categoriesLabel': 'kategóriák',
|
||||
'todo.addItem': 'Új feladat hozzáadása...',
|
||||
'todo.newCategory': 'Kategória neve',
|
||||
'todo.addCategory': 'Kategória hozzáadása',
|
||||
'todo.newItem': 'Új feladat',
|
||||
@@ -2288,11 +2239,6 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
|
||||
// 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.',
|
||||
'transport.addTransport': 'Add transport',
|
||||
'transport.modalTitle.create': 'Add transport',
|
||||
'transport.modalTitle.edit': 'Edit transport',
|
||||
'transport.title': 'Közlekedés',
|
||||
'transport.addManual': 'Kézi közlekedés',
|
||||
}
|
||||
|
||||
export default hu
|
||||
|
||||
@@ -10,9 +10,6 @@ const id: Record<string, string | { name: string; category: string }[]> = {
|
||||
'common.add': 'Tambah',
|
||||
'common.loading': 'Memuat...',
|
||||
'common.import': 'Impor',
|
||||
'common.select': 'Pilih',
|
||||
'common.selectAll': 'Pilih semua',
|
||||
'common.deselectAll': 'Batalkan semua pilihan',
|
||||
'common.error': 'Kesalahan',
|
||||
'common.unknownError': 'Kesalahan tidak diketahui',
|
||||
'common.tooManyAttempts': 'Terlalu banyak percobaan. Coba lagi nanti.',
|
||||
@@ -373,16 +370,6 @@ const id: Record<string, string | { name: string; category: string }[]> = {
|
||||
'settings.about.featureRequest': 'Permintaan Fitur',
|
||||
'settings.about.featureRequestHint': 'Sarankan fitur baru',
|
||||
'settings.about.wikiHint': 'Dokumentasi & panduan',
|
||||
'settings.about.supporters.badge': 'Pendukung Bulanan',
|
||||
'settings.about.supporters.title': 'Rekan perjalanan untuk TREK',
|
||||
'settings.about.supporters.subtitle': 'Saat kamu merencanakan rute berikutnya, orang-orang ini ikut merencanakan masa depan TREK. Kontribusi bulanan mereka langsung masuk ke pengembangan dan jam kerja nyata — supaya TREK tetap Open Source.',
|
||||
'settings.about.supporters.since': 'pendukung sejak {date}',
|
||||
'settings.about.supporters.tierEmpty': 'Jadilah yang pertama',
|
||||
'settings.about.supporter.tier.noReturnTicket': 'No Return Ticket',
|
||||
'settings.about.supporter.tier.lostLuggageVip': 'Lost Luggage VIP',
|
||||
'settings.about.supporter.tier.businessClassDreamer': 'Business Class Dreamer',
|
||||
'settings.about.supporter.tier.budgetTraveller': 'Budget Traveller',
|
||||
'settings.about.supporter.tier.hostelBunkmate': 'Hostel Bunkmate',
|
||||
'settings.about.description': 'TREK adalah perencana perjalanan self-hosted yang membantu kamu mengatur perjalanan dari ide pertama hingga kenangan terakhir. Perencanaan harian, anggaran, daftar bawaan, foto dan masih banyak lagi — semua di satu tempat, di servermu sendiri.',
|
||||
'settings.about.madeWith': 'Dibuat dengan',
|
||||
'settings.about.madeBy': 'oleh Maurice dan komunitas open-source yang terus berkembang.',
|
||||
@@ -623,12 +610,6 @@ const id: Record<string, string | { name: string; category: string }[]> = {
|
||||
'admin.fileTypesSaved': 'Pengaturan jenis file disimpan',
|
||||
|
||||
// Packing Templates & Bag Tracking
|
||||
'admin.placesPhotos.title': 'Foto Tempat',
|
||||
'admin.placesPhotos.subtitle': 'Mengambil foto dari Google Places API. Nonaktifkan untuk menghemat kuota API. Foto Wikimedia tidak terpengaruh.',
|
||||
'admin.placesAutocomplete.title': 'Pelengkapan Otomatis Tempat',
|
||||
'admin.placesAutocomplete.subtitle': 'Menggunakan Google Places API untuk saran pencarian. Nonaktifkan untuk menghemat kuota API.',
|
||||
'admin.placesDetails.title': 'Detail Tempat',
|
||||
'admin.placesDetails.subtitle': 'Mengambil informasi detail tempat (jam, penilaian, situs web) dari Google Places API. Nonaktifkan untuk menghemat kuota API.',
|
||||
'admin.bagTracking.title': 'Pelacak Tas',
|
||||
'admin.bagTracking.subtitle': 'Aktifkan berat dan penugasan tas untuk item packing',
|
||||
'admin.collab.chat.title': 'Chat',
|
||||
@@ -928,7 +909,6 @@ const id: Record<string, string | { name: string; category: string }[]> = {
|
||||
|
||||
// Trip Planner
|
||||
'trip.tabs.plan': 'Rencana',
|
||||
'trip.tabs.transports': 'Transportasi',
|
||||
'trip.tabs.reservations': 'Pemesanan',
|
||||
'trip.tabs.reservationsShort': 'Pesan',
|
||||
'trip.tabs.packing': 'Daftar Perlengkapan',
|
||||
@@ -951,8 +931,6 @@ const id: Record<string, string | { name: string; category: string }[]> = {
|
||||
'trip.toast.reservationAdded': 'Reservasi ditambahkan',
|
||||
'trip.toast.deleted': 'Dihapus',
|
||||
'trip.confirm.deletePlace': 'Apakah kamu yakin ingin menghapus tempat ini?',
|
||||
'trip.confirm.deletePlaces': 'Hapus {count} tempat?',
|
||||
'trip.toast.placesDeleted': '{count} tempat dihapus',
|
||||
|
||||
// Day Plan Sidebar
|
||||
'dayplan.emptyDay': 'Belum ada tempat yang direncanakan untuk hari ini',
|
||||
@@ -997,17 +975,6 @@ const id: Record<string, string | { name: string; category: string }[]> = {
|
||||
'places.importFileError': 'Impor gagal',
|
||||
'places.importAllSkipped': 'Semua tempat sudah ada di perjalanan.',
|
||||
'places.gpxImported': '{count} tempat diimpor dari GPX',
|
||||
'places.gpxImportTypes': 'Apa yang ingin diimpor?',
|
||||
'places.gpxImportWaypoints': 'Titik jalan',
|
||||
'places.gpxImportRoutes': 'Rute',
|
||||
'places.gpxImportTracks': 'Trek (dengan geometri jalur)',
|
||||
'places.gpxImportNoneSelected': 'Pilih setidaknya satu jenis untuk diimpor.',
|
||||
'places.kmlImportTypes': 'Apa yang ingin diimpor?',
|
||||
'places.kmlImportPoints': 'Titik (Placemarks)',
|
||||
'places.kmlImportPaths': 'Jalur (LineStrings)',
|
||||
'places.kmlImportNoneSelected': 'Pilih setidaknya satu jenis.',
|
||||
'places.selectionCount': '{count} dipilih',
|
||||
'places.deleteSelected': 'Hapus yang dipilih',
|
||||
'places.kmlKmzImported': '{count} tempat diimpor dari KMZ/KML',
|
||||
'places.urlResolved': 'Tempat diimpor dari URL',
|
||||
'places.importList': 'Impor Daftar',
|
||||
@@ -1024,7 +991,6 @@ const id: Record<string, string | { name: string; category: string }[]> = {
|
||||
'places.assignToDay': 'Tambah ke hari mana?',
|
||||
'places.all': 'Semua',
|
||||
'places.unplanned': 'Belum direncanakan',
|
||||
'places.filterTracks': 'Trek',
|
||||
'places.search': 'Cari tempat...',
|
||||
'places.allCategories': 'Semua Kategori',
|
||||
'places.categoriesSelected': 'kategori',
|
||||
@@ -1108,15 +1074,6 @@ 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',
|
||||
@@ -1135,7 +1092,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',
|
||||
'reservations.type.car': 'Mobil Sewa',
|
||||
'reservations.type.cruise': 'Kapal Pesiar',
|
||||
'reservations.type.event': 'Acara',
|
||||
'reservations.type.tour': 'Tur',
|
||||
@@ -1196,7 +1153,6 @@ const id: Record<string, string | { name: string; category: string }[]> = {
|
||||
'reservations.span.end': 'Selesai',
|
||||
'reservations.span.ongoing': 'Berlangsung',
|
||||
'reservations.validation.endBeforeStart': 'Tanggal/waktu selesai harus setelah tanggal/waktu mulai',
|
||||
'reservations.addBooking': 'Tambah pemesanan',
|
||||
|
||||
// Budget
|
||||
'budget.title': 'Anggaran',
|
||||
@@ -1810,7 +1766,6 @@ const id: Record<string, string | { name: string; category: string }[]> = {
|
||||
'undo.reorder': 'Tempat diurutkan ulang',
|
||||
'undo.optimize': 'Rute dioptimalkan',
|
||||
'undo.deletePlace': 'Tempat dihapus',
|
||||
'undo.deletePlaces': 'Tempat dihapus',
|
||||
'undo.moveDay': 'Tempat dipindah ke hari lain',
|
||||
'undo.lock': 'Kunci tempat diubah',
|
||||
'undo.importGpx': 'Impor GPX',
|
||||
@@ -1867,11 +1822,7 @@ const id: Record<string, string | { name: string; category: string }[]> = {
|
||||
'todo.unassigned': 'Belum ditugaskan',
|
||||
'todo.noCategory': 'Tanpa kategori',
|
||||
'todo.hasDescription': 'Ada deskripsi',
|
||||
'todo.addItem': 'Tugas baru',
|
||||
'todo.sidebar.sortBy': 'Urutkan',
|
||||
'todo.priority': 'Prioritas',
|
||||
'todo.newCategoryLabel': 'baru',
|
||||
'budget.categoriesLabel': 'kategori',
|
||||
'todo.addItem': 'Tambah tugas baru...',
|
||||
'todo.newCategory': 'Nama kategori',
|
||||
'todo.addCategory': 'Tambah kategori',
|
||||
'todo.newItem': 'Tugas baru',
|
||||
@@ -2329,11 +2280,6 @@ const id: Record<string, string | { name: string; category: string }[]> = {
|
||||
// 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.',
|
||||
'transport.addTransport': 'Add transport',
|
||||
'transport.modalTitle.create': 'Add transport',
|
||||
'transport.modalTitle.edit': 'Edit transport',
|
||||
'transport.title': 'Transportasi',
|
||||
'transport.addManual': 'Transportasi Manual',
|
||||
};
|
||||
|
||||
export default id;
|
||||
|
||||
@@ -10,9 +10,6 @@ const it: Record<string, string | { name: string; category: string }[]> = {
|
||||
'common.add': 'Aggiungi',
|
||||
'common.loading': 'Caricamento...',
|
||||
'common.import': 'Importa',
|
||||
'common.select': 'Seleziona',
|
||||
'common.selectAll': 'Seleziona tutto',
|
||||
'common.deselectAll': 'Deseleziona tutto',
|
||||
'common.error': 'Errore',
|
||||
'common.unknownError': 'Errore sconosciuto',
|
||||
'common.tooManyAttempts': 'Troppi tentativi. Riprova più tardi.',
|
||||
@@ -266,16 +263,6 @@ const it: Record<string, string | { name: string; category: string }[]> = {
|
||||
'settings.about.featureRequest': 'Richiedi funzionalità',
|
||||
'settings.about.featureRequestHint': 'Suggerisci una nuova funzionalità',
|
||||
'settings.about.wikiHint': 'Documentazione e guide',
|
||||
'settings.about.supporters.badge': 'Sostenitori Mensili',
|
||||
'settings.about.supporters.title': 'Compagni di viaggio per TREK',
|
||||
'settings.about.supporters.subtitle': 'Mentre pianifichi il tuo prossimo itinerario, queste persone aiutano a pianificare il futuro di TREK. Il loro contributo mensile va direttamente allo sviluppo e alle ore realmente investite — per mantenere TREK Open Source.',
|
||||
'settings.about.supporters.since': 'sostenitore da {date}',
|
||||
'settings.about.supporters.tierEmpty': 'Sii il primo',
|
||||
'settings.about.supporter.tier.noReturnTicket': 'No Return Ticket',
|
||||
'settings.about.supporter.tier.lostLuggageVip': 'Lost Luggage VIP',
|
||||
'settings.about.supporter.tier.businessClassDreamer': 'Business Class Dreamer',
|
||||
'settings.about.supporter.tier.budgetTraveller': 'Budget Traveller',
|
||||
'settings.about.supporter.tier.hostelBunkmate': 'Hostel Bunkmate',
|
||||
'settings.about.description': 'TREK è un pianificatore di viaggi self-hosted che ti aiuta a organizzare i tuoi viaggi dalla prima idea all\'ultimo ricordo. Pianificazione giornaliera, budget, liste bagagli, foto e molto altro — tutto in un unico posto, sul tuo server.',
|
||||
'settings.about.madeWith': 'Fatto con',
|
||||
'settings.about.madeBy': 'da Maurice e una crescente comunità open-source.',
|
||||
@@ -558,12 +545,6 @@ const it: Record<string, string | { name: string; category: string }[]> = {
|
||||
'admin.fileTypesFormat': 'Estensioni separate da virgola (es. jpg,png,pdf,doc). Usa * per consentire tutti i tipi.',
|
||||
'admin.fileTypesSaved': 'Impostazioni dei tipi di file salvate',
|
||||
// Packing Templates & Bag Tracking
|
||||
'admin.placesPhotos.title': 'Foto dei luoghi',
|
||||
'admin.placesPhotos.subtitle': "Recupera le foto dall'API Google Places. Disabilita per risparmiare la quota API. Le foto di Wikimedia non sono interessate.",
|
||||
'admin.placesAutocomplete.title': 'Completamento automatico dei luoghi',
|
||||
'admin.placesAutocomplete.subtitle': "Utilizza l'API Google Places per i suggerimenti di ricerca. Disabilita per risparmiare la quota API.",
|
||||
'admin.placesDetails.title': 'Dettagli del luogo',
|
||||
'admin.placesDetails.subtitle': "Recupera informazioni dettagliate sul luogo (orari, valutazione, sito web) dall'API Google Places. Disabilita per risparmiare la quota API.",
|
||||
'admin.bagTracking.title': 'Tracciamento valigia',
|
||||
'admin.bagTracking.subtitle': 'Abilita il peso e l\'assegnazione della valigia per gli elementi della lista valigia',
|
||||
'admin.collab.chat.title': 'Chat',
|
||||
@@ -868,7 +849,6 @@ const it: Record<string, string | { name: string; category: string }[]> = {
|
||||
|
||||
// Trip Planner
|
||||
'trip.tabs.plan': 'Programma',
|
||||
'trip.tabs.transports': 'Trasporti',
|
||||
'trip.tabs.reservations': 'Prenotazioni',
|
||||
'trip.tabs.reservationsShort': 'Pren.',
|
||||
'trip.tabs.packing': 'Lista valigia',
|
||||
@@ -890,8 +870,6 @@ const it: Record<string, string | { name: string; category: string }[]> = {
|
||||
'trip.toast.reservationAdded': 'Prenotazione aggiunta',
|
||||
'trip.toast.deleted': 'Eliminato',
|
||||
'trip.confirm.deletePlace': 'Sei sicuro di voler eliminare questo luogo?',
|
||||
'trip.confirm.deletePlaces': 'Eliminare {count} luoghi?',
|
||||
'trip.toast.placesDeleted': '{count} luoghi eliminati',
|
||||
'trip.loadingPhotos': 'Caricamento foto dei luoghi...',
|
||||
|
||||
// Day Plan Sidebar
|
||||
@@ -937,17 +915,6 @@ const it: Record<string, string | { name: string; category: string }[]> = {
|
||||
'places.importFileError': 'Importazione non riuscita',
|
||||
'places.importAllSkipped': 'Tutti i luoghi erano già nel viaggio.',
|
||||
'places.gpxImported': '{count} luoghi importati da GPX',
|
||||
'places.gpxImportTypes': 'Cosa vuoi importare?',
|
||||
'places.gpxImportWaypoints': 'Waypoint',
|
||||
'places.gpxImportRoutes': 'Percorsi',
|
||||
'places.gpxImportTracks': 'Tracce (con geometria percorso)',
|
||||
'places.gpxImportNoneSelected': 'Seleziona almeno un tipo da importare.',
|
||||
'places.kmlImportTypes': 'Cosa vuoi importare?',
|
||||
'places.kmlImportPoints': 'Punti (Placemarks)',
|
||||
'places.kmlImportPaths': 'Percorsi (LineStrings)',
|
||||
'places.kmlImportNoneSelected': 'Seleziona almeno un tipo.',
|
||||
'places.selectionCount': '{count} selezionato/i',
|
||||
'places.deleteSelected': 'Elimina selezionati',
|
||||
'places.kmlKmzImported': '{count} luoghi importati da KMZ/KML',
|
||||
'places.urlResolved': 'Luogo importato dall\'URL',
|
||||
'places.importList': 'Importa lista',
|
||||
@@ -964,7 +931,6 @@ const it: Record<string, string | { name: string; category: string }[]> = {
|
||||
'places.assignToDay': 'A quale giorno aggiungere?',
|
||||
'places.all': 'Tutti',
|
||||
'places.unplanned': 'Non pianificati',
|
||||
'places.filterTracks': 'Tracce',
|
||||
'places.search': 'Cerca luoghi...',
|
||||
'places.allCategories': 'Tutte le categorie',
|
||||
'places.categoriesSelected': 'categorie',
|
||||
@@ -1048,15 +1014,6 @@ 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',
|
||||
@@ -1075,7 +1032,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',
|
||||
'reservations.type.car': 'Auto a noleggio',
|
||||
'reservations.type.cruise': 'Crociera',
|
||||
'reservations.type.event': 'Evento',
|
||||
'reservations.type.tour': 'Tour',
|
||||
@@ -1136,7 +1093,6 @@ const it: Record<string, string | { name: string; category: string }[]> = {
|
||||
'reservations.span.end': 'Fine',
|
||||
'reservations.span.ongoing': 'In corso',
|
||||
'reservations.validation.endBeforeStart': 'La data/ora di fine deve essere successiva alla data/ora di inizio',
|
||||
'reservations.addBooking': 'Aggiungi prenotazione',
|
||||
|
||||
// Budget
|
||||
'budget.title': 'Budget',
|
||||
@@ -1739,7 +1695,6 @@ const it: Record<string, string | { name: string; category: string }[]> = {
|
||||
'undo.reorder': 'Luoghi riordinati',
|
||||
'undo.optimize': 'Percorso ottimizzato',
|
||||
'undo.deletePlace': 'Luogo eliminato',
|
||||
'undo.deletePlaces': 'Luoghi eliminati',
|
||||
'undo.moveDay': 'Luogo spostato in altro giorno',
|
||||
'undo.lock': 'Blocco luogo modificato',
|
||||
'undo.importGpx': 'Importazione GPX',
|
||||
@@ -1798,11 +1753,7 @@ const it: Record<string, string | { name: string; category: string }[]> = {
|
||||
'todo.unassigned': 'Non assegnato',
|
||||
'todo.noCategory': 'Nessuna categoria',
|
||||
'todo.hasDescription': 'Ha descrizione',
|
||||
'todo.addItem': 'Nuova attività',
|
||||
'todo.sidebar.sortBy': 'Ordina per',
|
||||
'todo.priority': 'Priorità',
|
||||
'todo.newCategoryLabel': 'nuova',
|
||||
'budget.categoriesLabel': 'categorie',
|
||||
'todo.addItem': 'Aggiungi nuova attività...',
|
||||
'todo.newCategory': 'Nome categoria',
|
||||
'todo.addCategory': 'Aggiungi categoria',
|
||||
'todo.newItem': 'Nuova attività',
|
||||
@@ -2288,11 +2239,6 @@ const it: Record<string, string | { name: string; category: string }[]> = {
|
||||
// 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.',
|
||||
'transport.addTransport': 'Add transport',
|
||||
'transport.modalTitle.create': 'Add transport',
|
||||
'transport.modalTitle.edit': 'Edit transport',
|
||||
'transport.title': 'Trasporti',
|
||||
'transport.addManual': 'Trasporto manuale',
|
||||
}
|
||||
|
||||
export default it
|
||||
|
||||
@@ -10,9 +10,6 @@ const nl: Record<string, string> = {
|
||||
'common.add': 'Toevoegen',
|
||||
'common.loading': 'Laden...',
|
||||
'common.import': 'Importeren',
|
||||
'common.select': 'Selecteren',
|
||||
'common.selectAll': 'Alles selecteren',
|
||||
'common.deselectAll': 'Alles deselecteren',
|
||||
'common.error': 'Fout',
|
||||
'common.unknownError': 'Onbekende fout',
|
||||
'common.tooManyAttempts': 'Te veel pogingen. Probeer het later opnieuw.',
|
||||
@@ -311,16 +308,6 @@ const nl: Record<string, string> = {
|
||||
'settings.about.featureRequest': 'Feature aanvragen',
|
||||
'settings.about.featureRequestHint': 'Stel een nieuwe functie voor',
|
||||
'settings.about.wikiHint': 'Documentatie en handleidingen',
|
||||
'settings.about.supporters.badge': 'Maandelijkse Steuners',
|
||||
'settings.about.supporters.title': 'Reisgezelschap voor TREK',
|
||||
'settings.about.supporters.subtitle': 'Terwijl jij je volgende route plant, plannen deze mensen mee aan de toekomst van TREK. Hun maandelijkse bijdrage gaat rechtstreeks naar ontwikkeling en echte uren — zodat TREK Open Source blijft.',
|
||||
'settings.about.supporters.since': 'steuner sinds {date}',
|
||||
'settings.about.supporters.tierEmpty': 'Wees de eerste',
|
||||
'settings.about.supporter.tier.noReturnTicket': 'No Return Ticket',
|
||||
'settings.about.supporter.tier.lostLuggageVip': 'Lost Luggage VIP',
|
||||
'settings.about.supporter.tier.businessClassDreamer': 'Business Class Dreamer',
|
||||
'settings.about.supporter.tier.budgetTraveller': 'Budget Traveller',
|
||||
'settings.about.supporter.tier.hostelBunkmate': 'Hostel Bunkmate',
|
||||
'settings.about.description': 'TREK is een zelf-gehoste reisplanner die je helpt je reizen te organiseren van het eerste idee tot de laatste herinnering. Dagplanning, budget, paklijsten, foto\'s en nog veel meer — alles op één plek, op je eigen server.',
|
||||
'settings.about.madeWith': 'Gemaakt met',
|
||||
'settings.about.madeBy': 'door Maurice en een groeiende open-source community.',
|
||||
@@ -559,12 +546,6 @@ const nl: Record<string, string> = {
|
||||
'admin.fileTypesFormat': 'Kommagescheiden extensies (bijv. jpg,png,pdf,doc). Gebruik * om alle typen toe te staan.',
|
||||
'admin.fileTypesSaved': 'Bestandstype-instellingen opgeslagen',
|
||||
|
||||
'admin.placesPhotos.title': "Plaatsfoto's",
|
||||
'admin.placesPhotos.subtitle': "Haalt foto's op via de Google Places API. Schakel uit om API-quota te besparen. Wikimedia-foto's worden niet beïnvloed.",
|
||||
'admin.placesAutocomplete.title': 'Plaatsautocomplete',
|
||||
'admin.placesAutocomplete.subtitle': 'Gebruikt de Google Places API voor zoeksuggesties. Schakel uit om API-quota te besparen.',
|
||||
'admin.placesDetails.title': 'Plaatsdetails',
|
||||
'admin.placesDetails.subtitle': 'Haalt gedetailleerde plaatsinformatie (openingstijden, beoordeling, website) op via de Google Places API. Schakel uit om API-quota te besparen.',
|
||||
'admin.bagTracking.title': 'Bagagetracking',
|
||||
'admin.bagTracking.subtitle': 'Gewicht en bagagetoewijzing inschakelen voor paklijstitems',
|
||||
'admin.collab.chat.title': 'Chat',
|
||||
@@ -867,7 +848,6 @@ const nl: Record<string, string> = {
|
||||
|
||||
// Trip Planner
|
||||
'trip.tabs.plan': 'Plan',
|
||||
'trip.tabs.transports': 'Transport',
|
||||
'trip.tabs.reservations': 'Boekingen',
|
||||
'trip.tabs.reservationsShort': 'Boek',
|
||||
'trip.tabs.packing': 'Paklijst',
|
||||
@@ -890,8 +870,6 @@ const nl: Record<string, string> = {
|
||||
'trip.toast.reservationAdded': 'Reservering toegevoegd',
|
||||
'trip.toast.deleted': 'Verwijderd',
|
||||
'trip.confirm.deletePlace': 'Weet je zeker dat je deze plaats wilt verwijderen?',
|
||||
'trip.confirm.deletePlaces': '{count} plaatsen verwijderen?',
|
||||
'trip.toast.placesDeleted': '{count} plaatsen verwijderd',
|
||||
|
||||
// Day Plan Sidebar
|
||||
'dayplan.emptyDay': 'Geen plaatsen gepland voor deze dag',
|
||||
@@ -936,17 +914,6 @@ const nl: Record<string, string> = {
|
||||
'places.importFileError': 'Importeren mislukt',
|
||||
'places.importAllSkipped': 'Alle plaatsen waren al in de reis.',
|
||||
'places.gpxImported': '{count} plaatsen geïmporteerd uit GPX',
|
||||
'places.gpxImportTypes': 'Wat wil je importeren?',
|
||||
'places.gpxImportWaypoints': 'Waypoints',
|
||||
'places.gpxImportRoutes': 'Routes',
|
||||
'places.gpxImportTracks': 'Tracks (met routegeometrie)',
|
||||
'places.gpxImportNoneSelected': 'Selecteer minstens één type om te importeren.',
|
||||
'places.kmlImportTypes': 'Wat wil je importeren?',
|
||||
'places.kmlImportPoints': 'Punten (Placemarks)',
|
||||
'places.kmlImportPaths': 'Paden (LineStrings)',
|
||||
'places.kmlImportNoneSelected': 'Selecteer minstens één type.',
|
||||
'places.selectionCount': '{count} geselecteerd',
|
||||
'places.deleteSelected': 'Selectie verwijderen',
|
||||
'places.kmlKmzImported': '{count} plaatsen geïmporteerd uit KMZ/KML',
|
||||
'places.urlResolved': 'Plaats geïmporteerd van URL',
|
||||
'places.importList': 'Lijst importeren',
|
||||
@@ -963,7 +930,6 @@ const nl: Record<string, string> = {
|
||||
'places.assignToDay': 'Aan welke dag toevoegen?',
|
||||
'places.all': 'Alle',
|
||||
'places.unplanned': 'Ongepland',
|
||||
'places.filterTracks': 'Tracks',
|
||||
'places.search': 'Plaatsen zoeken...',
|
||||
'places.allCategories': 'Alle categorieën',
|
||||
'places.categoriesSelected': 'categorieën',
|
||||
@@ -1047,15 +1013,6 @@ 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',
|
||||
@@ -1074,7 +1031,7 @@ const nl: Record<string, string> = {
|
||||
'reservations.type.hotel': 'Accommodatie',
|
||||
'reservations.type.restaurant': 'Restaurant',
|
||||
'reservations.type.train': 'Trein',
|
||||
'reservations.type.car': 'Auto',
|
||||
'reservations.type.car': 'Huurauto',
|
||||
'reservations.type.cruise': 'Cruise',
|
||||
'reservations.type.event': 'Evenement',
|
||||
'reservations.type.tour': 'Rondleiding',
|
||||
@@ -1135,7 +1092,6 @@ const nl: Record<string, string> = {
|
||||
'reservations.span.end': 'Einde',
|
||||
'reservations.span.ongoing': 'Lopend',
|
||||
'reservations.validation.endBeforeStart': 'Einddatum/-tijd moet na de startdatum/-tijd liggen',
|
||||
'reservations.addBooking': 'Boeking toevoegen',
|
||||
|
||||
// Budget
|
||||
'budget.title': 'Budget',
|
||||
@@ -1737,7 +1693,6 @@ const nl: Record<string, string> = {
|
||||
'undo.reorder': 'Locaties hergeordend',
|
||||
'undo.optimize': 'Route geoptimaliseerd',
|
||||
'undo.deletePlace': 'Locatie verwijderd',
|
||||
'undo.deletePlaces': 'Plaatsen verwijderd',
|
||||
'undo.moveDay': 'Locatie naar andere dag verplaatst',
|
||||
'undo.lock': 'Vergrendeling locatie gewijzigd',
|
||||
'undo.importGpx': 'GPX-import',
|
||||
@@ -1797,11 +1752,7 @@ const nl: Record<string, string> = {
|
||||
'todo.unassigned': 'Niet toegewezen',
|
||||
'todo.noCategory': 'Geen categorie',
|
||||
'todo.hasDescription': 'Heeft beschrijving',
|
||||
'todo.addItem': 'Nieuwe taak',
|
||||
'todo.sidebar.sortBy': 'Sorteren op',
|
||||
'todo.priority': 'Prioriteit',
|
||||
'todo.newCategoryLabel': 'nieuw',
|
||||
'budget.categoriesLabel': 'categorieën',
|
||||
'todo.addItem': 'Nieuwe taak toevoegen...',
|
||||
'todo.newCategory': 'Categorienaam',
|
||||
'todo.addCategory': 'Categorie toevoegen',
|
||||
'todo.newItem': 'Nieuwe taak',
|
||||
@@ -2287,11 +2238,6 @@ const nl: Record<string, string> = {
|
||||
// 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.',
|
||||
'transport.addTransport': 'Add transport',
|
||||
'transport.modalTitle.create': 'Add transport',
|
||||
'transport.modalTitle.edit': 'Edit transport',
|
||||
'transport.title': 'Transport',
|
||||
'transport.addManual': 'Handmatig transport',
|
||||
}
|
||||
|
||||
export default nl
|
||||
|
||||
@@ -281,16 +281,6 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
|
||||
'settings.about.featureRequest': 'Zaproponuj funkcję',
|
||||
'settings.about.featureRequestHint': 'Zaproponuj nową funkcję',
|
||||
'settings.about.wikiHint': 'Dokumentacja i poradniki',
|
||||
'settings.about.supporters.badge': 'Miesięczni Patroni',
|
||||
'settings.about.supporters.title': 'Towarzystwo podróży dla TREK',
|
||||
'settings.about.supporters.subtitle': 'Gdy planujesz kolejną trasę, te osoby planują razem ze mną przyszłość TREK. Ich comiesięczny wkład idzie bezpośrednio na rozwój i realnie przepracowane godziny — aby TREK pozostał Open Source.',
|
||||
'settings.about.supporters.since': 'patron od {date}',
|
||||
'settings.about.supporters.tierEmpty': 'Bądź pierwszy',
|
||||
'settings.about.supporter.tier.noReturnTicket': 'No Return Ticket',
|
||||
'settings.about.supporter.tier.lostLuggageVip': 'Lost Luggage VIP',
|
||||
'settings.about.supporter.tier.businessClassDreamer': 'Business Class Dreamer',
|
||||
'settings.about.supporter.tier.budgetTraveller': 'Budget Traveller',
|
||||
'settings.about.supporter.tier.hostelBunkmate': 'Hostel Bunkmate',
|
||||
'settings.about.description': 'TREK to samodzielnie hostowany planer podróży, który pomaga organizować wyprawy od pierwszego pomysłu po ostatnie wspomnienie. Planowanie dzienne, budżet, listy pakowania, zdjęcia i wiele więcej — wszystko w jednym miejscu, na własnym serwerze.',
|
||||
'settings.about.madeWith': 'Stworzone z',
|
||||
'settings.about.madeBy': 'przez Maurice\'a i rosnącą społeczność open-source.',
|
||||
@@ -528,12 +518,6 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
|
||||
'admin.fileTypesSaved': 'Ustawienia typów plików zostały zapisane',
|
||||
|
||||
// Packing Templates & Bag Tracking
|
||||
'admin.placesPhotos.title': 'Zdjęcia miejsc',
|
||||
'admin.placesPhotos.subtitle': 'Pobiera zdjęcia z Google Places API. Wyłącz, aby zaoszczędzić limit API. Zdjęcia z Wikimedia nie są objęte.',
|
||||
'admin.placesAutocomplete.title': 'Autouzupełnianie miejsc',
|
||||
'admin.placesAutocomplete.subtitle': 'Używa Google Places API do sugestii wyszukiwania. Wyłącz, aby zaoszczędzić limit API.',
|
||||
'admin.placesDetails.title': 'Szczegóły miejsca',
|
||||
'admin.placesDetails.subtitle': 'Pobiera szczegółowe informacje o miejscu (godziny, ocena, strona) z Google Places API. Wyłącz, aby zaoszczędzić limit API.',
|
||||
'admin.bagTracking.title': 'Kontrola bagażu',
|
||||
'admin.bagTracking.subtitle': 'Włącz wagę i przypisywanie do toreb dla przedmiotów do pakowania',
|
||||
'admin.collab.chat.title': 'Czat',
|
||||
@@ -832,7 +816,6 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
|
||||
|
||||
// Trip Planner
|
||||
'trip.tabs.plan': 'Plan',
|
||||
'trip.tabs.transports': 'Transport',
|
||||
'trip.tabs.reservations': 'Rezerwacje',
|
||||
'trip.tabs.reservationsShort': 'Rezerwacje',
|
||||
'trip.tabs.packing': 'Lista pakowania',
|
||||
@@ -854,8 +837,6 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
|
||||
'trip.toast.reservationAdded': 'Rezerwacja została dodana',
|
||||
'trip.toast.deleted': 'Usunięto',
|
||||
'trip.confirm.deletePlace': 'Czy na pewno chcesz usunąć to miejsce?',
|
||||
'trip.confirm.deletePlaces': 'Usunąć {count} miejsc?',
|
||||
'trip.toast.placesDeleted': '{count} miejsc usunięto',
|
||||
|
||||
// Day Plan Sidebar
|
||||
'dayplan.emptyDay': 'Brak miejsc zaplanowanych na ten dzień',
|
||||
@@ -900,17 +881,6 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
|
||||
'places.importFileError': 'Import nie powiódł się',
|
||||
'places.importAllSkipped': 'Wszystkie miejsca były już w podróży.',
|
||||
'places.gpxImported': '{count} miejsc zaimportowanych z GPX',
|
||||
'places.gpxImportTypes': 'Co chcesz zaimportować?',
|
||||
'places.gpxImportWaypoints': 'Punkty trasy',
|
||||
'places.gpxImportRoutes': 'Trasy',
|
||||
'places.gpxImportTracks': 'Trasy GPS (ze śladem)',
|
||||
'places.gpxImportNoneSelected': 'Wybierz co najmniej jeden typ do importu.',
|
||||
'places.kmlImportTypes': 'Co chcesz zaimportować?',
|
||||
'places.kmlImportPoints': 'Punkty (Placemarks)',
|
||||
'places.kmlImportPaths': 'Ścieżki (LineStrings)',
|
||||
'places.kmlImportNoneSelected': 'Wybierz co najmniej jeden typ.',
|
||||
'places.selectionCount': '{count} zaznaczono',
|
||||
'places.deleteSelected': 'Usuń wybrane',
|
||||
'places.kmlKmzImported': 'Zaimportowano {count} miejsc z KMZ/KML',
|
||||
'places.urlResolved': 'Miejsce zaimportowane z URL',
|
||||
'places.kmlKmzSummaryValues': 'Placemarks: {total} • Zaimportowano: {created} • Pominięto: {skipped}',
|
||||
@@ -918,7 +888,6 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
|
||||
'places.assignToDay': 'Do którego dnia dodać?',
|
||||
'places.all': 'Wszystkie',
|
||||
'places.unplanned': 'Niezaplanowane',
|
||||
'places.filterTracks': 'Trasy',
|
||||
'places.search': 'Szukaj miejsc...',
|
||||
'places.allCategories': 'Wszystkie kategorie',
|
||||
'places.categoriesSelected': 'kategorii',
|
||||
@@ -1020,15 +989,6 @@ 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',
|
||||
@@ -1089,7 +1049,6 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
|
||||
'reservations.span.end': 'Koniec',
|
||||
'reservations.span.ongoing': 'W trakcie',
|
||||
'reservations.validation.endBeforeStart': 'Data/godzina zakończenia musi być późniejsza niż data/godzina rozpoczęcia',
|
||||
'reservations.addBooking': 'Dodaj rezerwację',
|
||||
|
||||
// Budget
|
||||
'budget.title': 'Budżet',
|
||||
@@ -1621,9 +1580,6 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
|
||||
'collab.polls.delete': 'Usuń',
|
||||
'collab.polls.closedSection': 'Zamknięte',
|
||||
'common.import': 'Importuj',
|
||||
'common.select': 'Wybierz',
|
||||
'common.selectAll': 'Zaznacz wszystko',
|
||||
'common.deselectAll': 'Odznacz wszystko',
|
||||
'common.saved': 'Zapisano',
|
||||
'trips.reminder': 'Przypomnienie',
|
||||
'trips.reminderNone': 'Brak',
|
||||
@@ -1797,7 +1753,6 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
|
||||
'undo.reorder': 'Kolejność zmieniona',
|
||||
'undo.optimize': 'Trasa zoptymalizowana',
|
||||
'undo.deletePlace': 'Miejsce usunięte',
|
||||
'undo.deletePlaces': 'Miejsca usunięte',
|
||||
'undo.moveDay': 'Miejsce przeniesione',
|
||||
'undo.lock': 'Blokada przełączona',
|
||||
'undo.importGpx': 'Import GPX',
|
||||
@@ -1850,11 +1805,7 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
|
||||
'todo.unassigned': 'Nieprzypisane',
|
||||
'todo.noCategory': 'Brak kategorii',
|
||||
'todo.hasDescription': 'Ma opis',
|
||||
'todo.addItem': 'Nowe zadanie',
|
||||
'todo.sidebar.sortBy': 'Sortuj wg',
|
||||
'todo.priority': 'Priorytet',
|
||||
'todo.newCategoryLabel': 'nowa',
|
||||
'budget.categoriesLabel': 'kategorie',
|
||||
'todo.addItem': 'Dodaj nowe zadanie...',
|
||||
'todo.newCategory': 'Nazwa kategorii',
|
||||
'todo.addCategory': 'Dodaj kategorię',
|
||||
'todo.newItem': 'Nowe zadanie',
|
||||
@@ -2280,11 +2231,6 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
|
||||
// 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.',
|
||||
'transport.addTransport': 'Add transport',
|
||||
'transport.modalTitle.create': 'Add transport',
|
||||
'transport.modalTitle.edit': 'Edit transport',
|
||||
'transport.title': 'Transport',
|
||||
'transport.addManual': 'Ręczny transport',
|
||||
}
|
||||
|
||||
export default pl
|
||||
|
||||
@@ -10,9 +10,6 @@ const ru: Record<string, string> = {
|
||||
'common.add': 'Добавить',
|
||||
'common.loading': 'Загрузка...',
|
||||
'common.import': 'Импорт',
|
||||
'common.select': 'Выбрать',
|
||||
'common.selectAll': 'Выбрать всё',
|
||||
'common.deselectAll': 'Снять выделение со всех',
|
||||
'common.error': 'Ошибка',
|
||||
'common.unknownError': 'Неизвестная ошибка',
|
||||
'common.tooManyAttempts': 'Слишком много попыток. Попробуйте позже.',
|
||||
@@ -311,16 +308,6 @@ const ru: Record<string, string> = {
|
||||
'settings.about.featureRequest': 'Предложить функцию',
|
||||
'settings.about.featureRequestHint': 'Предложите новую функцию',
|
||||
'settings.about.wikiHint': 'Документация и руководства',
|
||||
'settings.about.supporters.badge': 'Ежемесячные спонсоры',
|
||||
'settings.about.supporters.title': 'Спутники TREK',
|
||||
'settings.about.supporters.subtitle': 'Пока ты планируешь следующий маршрут, эти люди планируют вместе со мной будущее TREK. Их ежемесячный взнос идёт напрямую в разработку и реально потраченные часы — чтобы TREK оставался Open Source.',
|
||||
'settings.about.supporters.since': 'спонсор с {date}',
|
||||
'settings.about.supporters.tierEmpty': 'Стань первым',
|
||||
'settings.about.supporter.tier.noReturnTicket': 'No Return Ticket',
|
||||
'settings.about.supporter.tier.lostLuggageVip': 'Lost Luggage VIP',
|
||||
'settings.about.supporter.tier.businessClassDreamer': 'Business Class Dreamer',
|
||||
'settings.about.supporter.tier.budgetTraveller': 'Budget Traveller',
|
||||
'settings.about.supporter.tier.hostelBunkmate': 'Hostel Bunkmate',
|
||||
'settings.about.description': 'TREK — это самостоятельно размещаемый планировщик путешествий, который помогает организовать поездки от первой идеи до последнего воспоминания. Планирование по дням, бюджет, списки вещей, фото и многое другое — всё в одном месте, на вашем собственном сервере.',
|
||||
'settings.about.madeWith': 'Сделано с',
|
||||
'settings.about.madeBy': 'Морисом и растущим open-source сообществом.',
|
||||
@@ -559,12 +546,6 @@ const ru: Record<string, string> = {
|
||||
'admin.fileTypesFormat': 'Расширения через запятую (напр. jpg,png,pdf,doc). Используйте * для разрешения всех типов.',
|
||||
'admin.fileTypesSaved': 'Настройки типов файлов сохранены',
|
||||
|
||||
'admin.placesPhotos.title': 'Фотографии мест',
|
||||
'admin.placesPhotos.subtitle': 'Загрузка фотографий из Google Places API. Отключите для экономии квоты API. Фотографии Wikimedia не затронуты.',
|
||||
'admin.placesAutocomplete.title': 'Автодополнение мест',
|
||||
'admin.placesAutocomplete.subtitle': 'Использование Google Places API для поисковых подсказок. Отключите для экономии квоты API.',
|
||||
'admin.placesDetails.title': 'Сведения о месте',
|
||||
'admin.placesDetails.subtitle': 'Загрузка подробной информации о месте (часы работы, рейтинг, веб-сайт) из Google Places API. Отключите для экономии квоты API.',
|
||||
'admin.bagTracking.title': 'Отслеживание багажа',
|
||||
'admin.bagTracking.subtitle': 'Включить вес и привязку к багажу для вещей',
|
||||
'admin.collab.chat.title': 'Чат',
|
||||
@@ -867,7 +848,6 @@ const ru: Record<string, string> = {
|
||||
|
||||
// Trip Planner
|
||||
'trip.tabs.plan': 'План',
|
||||
'trip.tabs.transports': 'Транспорт',
|
||||
'trip.tabs.reservations': 'Бронирования',
|
||||
'trip.tabs.reservationsShort': 'Брони',
|
||||
'trip.tabs.packing': 'Список вещей',
|
||||
@@ -890,8 +870,6 @@ const ru: Record<string, string> = {
|
||||
'trip.toast.reservationAdded': 'Бронирование добавлено',
|
||||
'trip.toast.deleted': 'Удалено',
|
||||
'trip.confirm.deletePlace': 'Вы уверены, что хотите удалить это место?',
|
||||
'trip.confirm.deletePlaces': 'Удалить {count} мест?',
|
||||
'trip.toast.placesDeleted': '{count} мест удалено',
|
||||
|
||||
// Day Plan Sidebar
|
||||
'dayplan.emptyDay': 'На этот день мест не запланировано',
|
||||
@@ -936,17 +914,6 @@ const ru: Record<string, string> = {
|
||||
'places.importFileError': 'Ошибка импорта',
|
||||
'places.importAllSkipped': 'Все места уже были в поездке.',
|
||||
'places.gpxImported': '{count} мест импортировано из GPX',
|
||||
'places.gpxImportTypes': 'Что импортировать?',
|
||||
'places.gpxImportWaypoints': 'Путевые точки',
|
||||
'places.gpxImportRoutes': 'Маршруты',
|
||||
'places.gpxImportTracks': 'Треки (с геометрией пути)',
|
||||
'places.gpxImportNoneSelected': 'Выберите хотя бы один тип для импорта.',
|
||||
'places.kmlImportTypes': 'Что вы хотите импортировать?',
|
||||
'places.kmlImportPoints': 'Точки (Placemarks)',
|
||||
'places.kmlImportPaths': 'Маршруты (LineStrings)',
|
||||
'places.kmlImportNoneSelected': 'Выберите хотя бы один тип.',
|
||||
'places.selectionCount': '{count} выбрано',
|
||||
'places.deleteSelected': 'Удалить выбранные',
|
||||
'places.kmlKmzImported': '{count} мест импортировано из KMZ/KML',
|
||||
'places.urlResolved': 'Место импортировано из URL',
|
||||
'places.importList': 'Импорт списка',
|
||||
@@ -963,7 +930,6 @@ const ru: Record<string, string> = {
|
||||
'places.assignToDay': 'Добавить в какой день?',
|
||||
'places.all': 'Все',
|
||||
'places.unplanned': 'Незапланированные',
|
||||
'places.filterTracks': 'Треки',
|
||||
'places.search': 'Поиск мест...',
|
||||
'places.allCategories': 'Все категории',
|
||||
'places.categoriesSelected': 'категорий',
|
||||
@@ -1047,15 +1013,6 @@ 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': 'Место',
|
||||
@@ -1074,7 +1031,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': 'Экскурсия',
|
||||
@@ -1135,7 +1092,6 @@ const ru: Record<string, string> = {
|
||||
'reservations.span.end': 'Конец',
|
||||
'reservations.span.ongoing': 'Продолжается',
|
||||
'reservations.validation.endBeforeStart': 'Дата/время окончания должны быть позже даты/времени начала',
|
||||
'reservations.addBooking': 'Добавить бронирование',
|
||||
|
||||
// Budget
|
||||
'budget.title': 'Бюджет',
|
||||
@@ -1734,7 +1690,6 @@ const ru: Record<string, string> = {
|
||||
'undo.reorder': 'Места переупорядочены',
|
||||
'undo.optimize': 'Маршрут оптимизирован',
|
||||
'undo.deletePlace': 'Место удалено',
|
||||
'undo.deletePlaces': 'Места удалены',
|
||||
'undo.moveDay': 'Место перемещено в другой день',
|
||||
'undo.lock': 'Блокировка места изменена',
|
||||
'undo.importGpx': 'Импорт GPX',
|
||||
@@ -1794,11 +1749,7 @@ const ru: Record<string, string> = {
|
||||
'todo.unassigned': 'Не назначено',
|
||||
'todo.noCategory': 'Без категории',
|
||||
'todo.hasDescription': 'Есть описание',
|
||||
'todo.addItem': 'Новая задача',
|
||||
'todo.sidebar.sortBy': 'Сортировать по',
|
||||
'todo.priority': 'Приоритет',
|
||||
'todo.newCategoryLabel': 'новая',
|
||||
'budget.categoriesLabel': 'категорий',
|
||||
'todo.addItem': 'Добавить новую задачу...',
|
||||
'todo.newCategory': 'Название категории',
|
||||
'todo.addCategory': 'Добавить категорию',
|
||||
'todo.newItem': 'Новая задача',
|
||||
@@ -2287,11 +2238,6 @@ const ru: Record<string, string> = {
|
||||
// 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) всегда помогает держать свет включённым.',
|
||||
'transport.addTransport': 'Add transport',
|
||||
'transport.modalTitle.create': 'Add transport',
|
||||
'transport.modalTitle.edit': 'Edit transport',
|
||||
'transport.title': 'Транспорт',
|
||||
'transport.addManual': 'Ручной транспорт',
|
||||
}
|
||||
|
||||
export default ru
|
||||
|
||||
@@ -10,9 +10,6 @@ const zh: Record<string, string> = {
|
||||
'common.add': '添加',
|
||||
'common.loading': '加载中...',
|
||||
'common.import': '导入',
|
||||
'common.select': '选择',
|
||||
'common.selectAll': '全选',
|
||||
'common.deselectAll': '取消全选',
|
||||
'common.error': '错误',
|
||||
'common.unknownError': '未知错误',
|
||||
'common.tooManyAttempts': '尝试次数过多,请稍后再试。',
|
||||
@@ -311,16 +308,6 @@ const zh: Record<string, string> = {
|
||||
'settings.about.featureRequest': '功能建议',
|
||||
'settings.about.featureRequestHint': '建议一个新功能',
|
||||
'settings.about.wikiHint': '文档和指南',
|
||||
'settings.about.supporters.badge': '月度支持者',
|
||||
'settings.about.supporters.title': '与 TREK 同行的伙伴',
|
||||
'settings.about.supporters.subtitle': '当你在规划下一段路线时,这些人也在一起规划 TREK 的未来。他们每月的支持直接用于开发与真实投入的时间——让 TREK 保持开源。',
|
||||
'settings.about.supporters.since': '{date} 起的支持者',
|
||||
'settings.about.supporters.tierEmpty': '成为第一个',
|
||||
'settings.about.supporter.tier.noReturnTicket': 'No Return Ticket',
|
||||
'settings.about.supporter.tier.lostLuggageVip': 'Lost Luggage VIP',
|
||||
'settings.about.supporter.tier.businessClassDreamer': 'Business Class Dreamer',
|
||||
'settings.about.supporter.tier.budgetTraveller': 'Budget Traveller',
|
||||
'settings.about.supporter.tier.hostelBunkmate': 'Hostel Bunkmate',
|
||||
'settings.about.description': 'TREK 是一个自托管的旅行规划工具,帮助你从最初的想法到最后的回忆,全程组织你的旅行。日程规划、预算、行李清单、照片等——一切尽在一处,在你自己的服务器上。',
|
||||
'settings.about.madeWith': '用',
|
||||
'settings.about.madeBy': '由 Maurice 和不断壮大的开源社区打造。',
|
||||
@@ -559,12 +546,6 @@ const zh: Record<string, string> = {
|
||||
'admin.fileTypesFormat': '以逗号分隔的扩展名(如 jpg,png,pdf,doc)。使用 * 允许所有类型。',
|
||||
'admin.fileTypesSaved': '文件类型设置已保存',
|
||||
|
||||
'admin.placesPhotos.title': '地点照片',
|
||||
'admin.placesPhotos.subtitle': '从 Google Places API 获取照片。禁用可节省 API 配额。Wikimedia 照片不受影响。',
|
||||
'admin.placesAutocomplete.title': '地点自动补全',
|
||||
'admin.placesAutocomplete.subtitle': '使用 Google Places API 提供搜索建议。禁用可节省 API 配额。',
|
||||
'admin.placesDetails.title': '地点详情',
|
||||
'admin.placesDetails.subtitle': '从 Google Places API 获取地点详细信息(营业时间、评分、网站)。禁用可节省 API 配额。',
|
||||
'admin.bagTracking.title': '行李追踪',
|
||||
'admin.bagTracking.subtitle': '为打包物品启用重量和行李分配',
|
||||
'admin.collab.chat.title': '聊天',
|
||||
@@ -867,7 +848,6 @@ const zh: Record<string, string> = {
|
||||
|
||||
// Trip Planner
|
||||
'trip.tabs.plan': '计划',
|
||||
'trip.tabs.transports': '交通',
|
||||
'trip.tabs.reservations': '预订',
|
||||
'trip.tabs.reservationsShort': '预订',
|
||||
'trip.tabs.packing': '行李清单',
|
||||
@@ -890,8 +870,6 @@ const zh: Record<string, string> = {
|
||||
'trip.toast.reservationAdded': '预订已添加',
|
||||
'trip.toast.deleted': '已删除',
|
||||
'trip.confirm.deletePlace': '确定要删除这个地点吗?',
|
||||
'trip.confirm.deletePlaces': '删除 {count} 个地点?',
|
||||
'trip.toast.placesDeleted': '已删除 {count} 个地点',
|
||||
|
||||
// Day Plan Sidebar
|
||||
'dayplan.emptyDay': '当天暂无计划',
|
||||
@@ -936,17 +914,6 @@ const zh: Record<string, string> = {
|
||||
'places.importFileError': '导入失败',
|
||||
'places.importAllSkipped': '所有地点已在行程中。',
|
||||
'places.gpxImported': '已从 GPX 导入 {count} 个地点',
|
||||
'places.gpxImportTypes': '要导入什么?',
|
||||
'places.gpxImportWaypoints': '路点',
|
||||
'places.gpxImportRoutes': '路线',
|
||||
'places.gpxImportTracks': '轨迹(含路径几何)',
|
||||
'places.gpxImportNoneSelected': '请至少选择一种导入类型。',
|
||||
'places.kmlImportTypes': '要导入什么?',
|
||||
'places.kmlImportPoints': '点(Placemarks)',
|
||||
'places.kmlImportPaths': '路径(LineStrings)',
|
||||
'places.kmlImportNoneSelected': '请至少选择一种类型。',
|
||||
'places.selectionCount': '已选 {count} 项',
|
||||
'places.deleteSelected': '删除所选',
|
||||
'places.kmlKmzImported': '已从 KMZ/KML 导入 {count} 个地点',
|
||||
'places.urlResolved': '已从 URL 导入地点',
|
||||
'places.importList': '列表导入',
|
||||
@@ -963,7 +930,6 @@ const zh: Record<string, string> = {
|
||||
'places.assignToDay': '添加到哪一天?',
|
||||
'places.all': '全部',
|
||||
'places.unplanned': '未规划',
|
||||
'places.filterTracks': '路线',
|
||||
'places.search': '搜索地点...',
|
||||
'places.allCategories': '所有分类',
|
||||
'places.categoriesSelected': '个分类',
|
||||
@@ -1047,15 +1013,6 @@ 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': '座位',
|
||||
@@ -1074,7 +1031,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': '旅游团',
|
||||
@@ -1135,7 +1092,6 @@ const zh: Record<string, string> = {
|
||||
'reservations.span.end': '结束',
|
||||
'reservations.span.ongoing': '进行中',
|
||||
'reservations.validation.endBeforeStart': '结束日期/时间必须晚于开始日期/时间',
|
||||
'reservations.addBooking': '添加预订',
|
||||
|
||||
// Budget
|
||||
'budget.title': '预算',
|
||||
@@ -1734,7 +1690,6 @@ const zh: Record<string, string> = {
|
||||
'undo.reorder': '地点已重新排序',
|
||||
'undo.optimize': '路线已优化',
|
||||
'undo.deletePlace': '地点已删除',
|
||||
'undo.deletePlaces': '地点已删除',
|
||||
'undo.moveDay': '地点已移至另一天',
|
||||
'undo.lock': '地点锁定已切换',
|
||||
'undo.importGpx': 'GPX 导入',
|
||||
@@ -1794,11 +1749,7 @@ const zh: Record<string, string> = {
|
||||
'todo.unassigned': '未分配',
|
||||
'todo.noCategory': '无分类',
|
||||
'todo.hasDescription': '有描述',
|
||||
'todo.addItem': '新建任务',
|
||||
'todo.sidebar.sortBy': '排序方式',
|
||||
'todo.priority': '优先级',
|
||||
'todo.newCategoryLabel': '新建',
|
||||
'budget.categoriesLabel': '类别',
|
||||
'todo.addItem': '添加新任务...',
|
||||
'todo.newCategory': '分类名称',
|
||||
'todo.addCategory': '添加分类',
|
||||
'todo.newItem': '新任务',
|
||||
@@ -2287,11 +2238,6 @@ const zh: Record<string, string> = {
|
||||
// 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)能让这盏灯一直亮着。',
|
||||
'transport.addTransport': 'Add transport',
|
||||
'transport.modalTitle.create': 'Add transport',
|
||||
'transport.modalTitle.edit': 'Edit transport',
|
||||
'transport.title': '交通',
|
||||
'transport.addManual': '手动添加交通',
|
||||
}
|
||||
|
||||
export default zh
|
||||
|
||||
@@ -10,9 +10,6 @@ const zhTw: Record<string, string> = {
|
||||
'common.add': '新增',
|
||||
'common.loading': '載入中...',
|
||||
'common.import': '匯入',
|
||||
'common.select': '選擇',
|
||||
'common.selectAll': '全選',
|
||||
'common.deselectAll': '取消全選',
|
||||
'common.error': '錯誤',
|
||||
'common.unknownError': '未知錯誤',
|
||||
'common.tooManyAttempts': '嘗試次數過多,請稍後再試。',
|
||||
@@ -370,16 +367,6 @@ const zhTw: Record<string, string> = {
|
||||
'settings.about.featureRequest': '功能建議',
|
||||
'settings.about.featureRequestHint': '建議新功能',
|
||||
'settings.about.wikiHint': '文件與指南',
|
||||
'settings.about.supporters.badge': '月度支持者',
|
||||
'settings.about.supporters.title': '與 TREK 同行的夥伴',
|
||||
'settings.about.supporters.subtitle': '當你規劃下一段路線時,這些人也在一起規劃 TREK 的未來。他們每月的支持直接用於開發與實際投入的時間——讓 TREK 保持開源。',
|
||||
'settings.about.supporters.since': '自 {date} 起的支持者',
|
||||
'settings.about.supporters.tierEmpty': '成為第一個',
|
||||
'settings.about.supporter.tier.noReturnTicket': 'No Return Ticket',
|
||||
'settings.about.supporter.tier.lostLuggageVip': 'Lost Luggage VIP',
|
||||
'settings.about.supporter.tier.businessClassDreamer': 'Business Class Dreamer',
|
||||
'settings.about.supporter.tier.budgetTraveller': 'Budget Traveller',
|
||||
'settings.about.supporter.tier.hostelBunkmate': 'Hostel Bunkmate',
|
||||
'settings.about.description': 'TREK 是一款自架旅遊規劃器,幫助您從最初構想到最後回憶,整理每次旅行。日程規劃、預算、行李清單、照片及更多功能——全部集中在您自己的伺服器上。',
|
||||
'settings.about.madeWith': '以',
|
||||
'settings.about.madeBy': '由 Maurice 及不斷成長的開源社群製作。',
|
||||
@@ -619,12 +606,6 @@ const zhTw: Record<string, string> = {
|
||||
'admin.fileTypesFormat': '以逗號分隔的副檔名(如 jpg,png,pdf,doc)。使用 * 允許所有型別。',
|
||||
'admin.fileTypesSaved': '檔案型別設定已儲存',
|
||||
|
||||
'admin.placesPhotos.title': '地點照片',
|
||||
'admin.placesPhotos.subtitle': '從 Google Places API 獲取照片。停用可節省 API 配額。Wikimedia 照片不受影響。',
|
||||
'admin.placesAutocomplete.title': '地點自動補全',
|
||||
'admin.placesAutocomplete.subtitle': '使用 Google Places API 提供搜尋建議。停用可節省 API 配額。',
|
||||
'admin.placesDetails.title': '地點詳情',
|
||||
'admin.placesDetails.subtitle': '從 Google Places API 獲取地點詳細資訊(營業時間、評分、網站)。停用可節省 API 配額。',
|
||||
'admin.bagTracking.title': '行李追蹤',
|
||||
'admin.bagTracking.subtitle': '為打包物品啟用重量和行李分配',
|
||||
'admin.collab.chat.title': '聊天',
|
||||
@@ -927,7 +908,6 @@ const zhTw: Record<string, string> = {
|
||||
|
||||
// Trip Planner
|
||||
'trip.tabs.plan': '計劃',
|
||||
'trip.tabs.transports': '交通',
|
||||
'trip.tabs.reservations': '預訂',
|
||||
'trip.tabs.reservationsShort': '預訂',
|
||||
'trip.tabs.packing': '行李清單',
|
||||
@@ -950,8 +930,6 @@ const zhTw: Record<string, string> = {
|
||||
'trip.toast.reservationAdded': '預訂已新增',
|
||||
'trip.toast.deleted': '已刪除',
|
||||
'trip.confirm.deletePlace': '確定要刪除這個地點嗎?',
|
||||
'trip.confirm.deletePlaces': '刪除 {count} 個地點?',
|
||||
'trip.toast.placesDeleted': '已刪除 {count} 個地點',
|
||||
|
||||
// Day Plan Sidebar
|
||||
'dayplan.emptyDay': '當天暫無計劃',
|
||||
@@ -996,17 +974,6 @@ const zhTw: Record<string, string> = {
|
||||
'places.importFileError': '匯入失敗',
|
||||
'places.importAllSkipped': '所有地點已在行程中。',
|
||||
'places.gpxImported': '已從 GPX 匯入 {count} 個地點',
|
||||
'places.gpxImportTypes': '要匯入什麼?',
|
||||
'places.gpxImportWaypoints': '路點',
|
||||
'places.gpxImportRoutes': '路線',
|
||||
'places.gpxImportTracks': '軌跡(含路徑幾何)',
|
||||
'places.gpxImportNoneSelected': '請至少選擇一種匯入類型。',
|
||||
'places.kmlImportTypes': '要匯入什麼?',
|
||||
'places.kmlImportPoints': '點(Placemarks)',
|
||||
'places.kmlImportPaths': '路徑(LineStrings)',
|
||||
'places.kmlImportNoneSelected': '請至少選擇一種類型。',
|
||||
'places.selectionCount': '已選 {count} 項',
|
||||
'places.deleteSelected': '刪除所選',
|
||||
'places.kmlKmzImported': '已從 KMZ/KML 匯入 {count} 個地點',
|
||||
'places.urlResolved': '已從 URL 匯入地點',
|
||||
'places.importList': '列表匯入',
|
||||
@@ -1023,7 +990,6 @@ const zhTw: Record<string, string> = {
|
||||
'places.assignToDay': '新增到哪一天?',
|
||||
'places.all': '全部',
|
||||
'places.unplanned': '未規劃',
|
||||
'places.filterTracks': '路線',
|
||||
'places.search': '搜尋地點...',
|
||||
'places.allCategories': '所有分類',
|
||||
'places.categoriesSelected': '個分類',
|
||||
@@ -1107,15 +1073,6 @@ 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': '座位',
|
||||
@@ -1134,7 +1091,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': '旅遊團',
|
||||
@@ -1195,7 +1152,6 @@ const zhTw: Record<string, string> = {
|
||||
'reservations.span.end': '結束',
|
||||
'reservations.span.ongoing': '進行中',
|
||||
'reservations.validation.endBeforeStart': '結束日期/時間必須晚於開始日期/時間',
|
||||
'reservations.addBooking': '新增預訂',
|
||||
|
||||
// Budget
|
||||
'budget.title': '預算',
|
||||
@@ -1794,7 +1750,6 @@ const zhTw: Record<string, string> = {
|
||||
'undo.reorder': '地點已重新排序',
|
||||
'undo.optimize': '路線已最佳化',
|
||||
'undo.deletePlace': '地點已刪除',
|
||||
'undo.deletePlaces': '地點已刪除',
|
||||
'undo.moveDay': '地點已移至另一天',
|
||||
'undo.lock': '地點鎖定已切換',
|
||||
'undo.importGpx': 'GPX 匯入',
|
||||
@@ -1815,11 +1770,7 @@ const zhTw: Record<string, string> = {
|
||||
'todo.unassigned': '未指派',
|
||||
'todo.noCategory': '無分類',
|
||||
'todo.hasDescription': '有說明',
|
||||
'todo.addItem': '新增任務',
|
||||
'todo.sidebar.sortBy': '排序方式',
|
||||
'todo.priority': '優先順序',
|
||||
'todo.newCategoryLabel': '新增',
|
||||
'budget.categoriesLabel': '類別',
|
||||
'todo.addItem': '新增任務...',
|
||||
'todo.newCategory': '分類名稱',
|
||||
'todo.addCategory': '新增分類',
|
||||
'todo.newItem': '新任務',
|
||||
@@ -2288,11 +2239,6 @@ const zhTw: Record<string, string> = {
|
||||
// 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)能讓這盞燈一直亮著。',
|
||||
'transport.addTransport': 'Add transport',
|
||||
'transport.modalTitle.create': 'Add transport',
|
||||
'transport.modalTitle.edit': 'Edit transport',
|
||||
'transport.title': '交通',
|
||||
'transport.addManual': '手動新增交通',
|
||||
}
|
||||
|
||||
export default zhTw
|
||||
@@ -194,18 +194,6 @@ export default function AdminPage(): React.ReactElement {
|
||||
const [bagTrackingEnabled, setBagTrackingEnabled] = useState<boolean>(false)
|
||||
useEffect(() => { adminApi.getBagTracking().then(d => setBagTrackingEnabled(d.enabled)).catch(() => {}) }, [])
|
||||
|
||||
// Places photos
|
||||
const [placesPhotosEnabled, setPlacesPhotosEnabledState] = useState<boolean>(true)
|
||||
useEffect(() => { adminApi.getPlacesPhotos().then(d => setPlacesPhotosEnabledState(d.enabled)).catch(() => {}) }, [])
|
||||
|
||||
// Places autocomplete
|
||||
const [placesAutocompleteEnabled, setPlacesAutocompleteEnabledState] = useState<boolean>(true)
|
||||
useEffect(() => { adminApi.getPlacesAutocomplete().then(d => setPlacesAutocompleteEnabledState(d.enabled)).catch(() => {}) }, [])
|
||||
|
||||
// Places details
|
||||
const [placesDetailsEnabled, setPlacesDetailsEnabledState] = useState<boolean>(true)
|
||||
useEffect(() => { adminApi.getPlacesDetails().then(d => setPlacesDetailsEnabledState(d.enabled)).catch(() => {}) }, [])
|
||||
|
||||
// Collab features
|
||||
const [collabFeatures, setCollabFeatures] = useState<{ chat: boolean; notes: boolean; polls: boolean; whatsnext: boolean }>({ chat: true, notes: true, polls: true, whatsnext: true })
|
||||
useEffect(() => { adminApi.getCollabFeatures().then(d => setCollabFeatures(d)).catch(() => {}) }, [])
|
||||
@@ -254,7 +242,7 @@ export default function AdminPage(): React.ReactElement {
|
||||
const [updateInfo, setUpdateInfo] = useState<UpdateInfo | null>(null)
|
||||
const [showUpdateModal, setShowUpdateModal] = useState<boolean>(false)
|
||||
|
||||
const { user: currentUser, updateApiKeys, setAppRequireMfa, setTripRemindersEnabled, setPlacesPhotosEnabled, setPlacesAutocompleteEnabled, setPlacesDetailsEnabled, logout } = useAuthStore()
|
||||
const { user: currentUser, updateApiKeys, setAppRequireMfa, setTripRemindersEnabled, logout } = useAuthStore()
|
||||
const navigate = useNavigate()
|
||||
const toast = useToast()
|
||||
|
||||
@@ -1035,66 +1023,6 @@ export default function AdminPage(): React.ReactElement {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Place Photos Toggle */}
|
||||
<div className="flex items-center justify-between py-3 border-t border-slate-100">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-slate-700">{t('admin.placesPhotos.title')}</p>
|
||||
<p className="text-xs text-slate-400 mt-0.5">{t('admin.placesPhotos.subtitle')}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={async () => {
|
||||
const next = !placesPhotosEnabled
|
||||
setPlacesPhotosEnabledState(next)
|
||||
setPlacesPhotosEnabled(next)
|
||||
try { await adminApi.updatePlacesPhotos(next) } catch { setPlacesPhotosEnabledState(!next); setPlacesPhotosEnabled(!next) }
|
||||
}}
|
||||
className="relative inline-flex h-6 w-11 items-center rounded-full transition-colors"
|
||||
style={{ background: placesPhotosEnabled ? 'var(--text-primary)' : 'var(--border-primary)' }}
|
||||
>
|
||||
<span className="absolute left-0.5 h-5 w-5 rounded-full bg-white transition-transform duration-200" style={{ transform: placesPhotosEnabled ? 'translateX(20px)' : 'translateX(0)' }} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Place Autocomplete Toggle */}
|
||||
<div className="flex items-center justify-between py-3 border-t border-slate-100">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-slate-700">{t('admin.placesAutocomplete.title')}</p>
|
||||
<p className="text-xs text-slate-400 mt-0.5">{t('admin.placesAutocomplete.subtitle')}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={async () => {
|
||||
const next = !placesAutocompleteEnabled
|
||||
setPlacesAutocompleteEnabledState(next)
|
||||
setPlacesAutocompleteEnabled(next)
|
||||
try { await adminApi.updatePlacesAutocomplete(next) } catch { setPlacesAutocompleteEnabledState(!next); setPlacesAutocompleteEnabled(!next) }
|
||||
}}
|
||||
className="relative inline-flex h-6 w-11 items-center rounded-full transition-colors"
|
||||
style={{ background: placesAutocompleteEnabled ? 'var(--text-primary)' : 'var(--border-primary)' }}
|
||||
>
|
||||
<span className="absolute left-0.5 h-5 w-5 rounded-full bg-white transition-transform duration-200" style={{ transform: placesAutocompleteEnabled ? 'translateX(20px)' : 'translateX(0)' }} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Place Details Toggle */}
|
||||
<div className="flex items-center justify-between py-3 border-t border-slate-100">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-slate-700">{t('admin.placesDetails.title')}</p>
|
||||
<p className="text-xs text-slate-400 mt-0.5">{t('admin.placesDetails.subtitle')}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={async () => {
|
||||
const next = !placesDetailsEnabled
|
||||
setPlacesDetailsEnabledState(next)
|
||||
setPlacesDetailsEnabled(next)
|
||||
try { await adminApi.updatePlacesDetails(next) } catch { setPlacesDetailsEnabledState(!next); setPlacesDetailsEnabled(!next) }
|
||||
}}
|
||||
className="relative inline-flex h-6 w-11 items-center rounded-full transition-colors"
|
||||
style={{ background: placesDetailsEnabled ? 'var(--text-primary)' : 'var(--border-primary)' }}
|
||||
>
|
||||
<span className="absolute left-0.5 h-5 w-5 rounded-full bg-white transition-transform duration-200" style={{ transform: placesDetailsEnabled ? 'translateX(20px)' : 'translateX(0)' }} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Open-Meteo Weather Info */}
|
||||
<div className="rounded-lg border border-emerald-200 bg-emerald-50 dark:bg-emerald-950/30 dark:border-emerald-800 overflow-hidden">
|
||||
<div className="px-4 py-3 flex items-center justify-between">
|
||||
|
||||
@@ -416,10 +416,15 @@ describe('DashboardPage', () => {
|
||||
expect(screen.getAllByText('Paris Adventure').length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
// Find settings button — the gear icon button (icon-only, no visible label)
|
||||
// Find settings button — it's the gear icon button without title or text
|
||||
const allBtns = screen.getAllByRole('button');
|
||||
const settingsButton = allBtns.find(btn =>
|
||||
btn.querySelector('.lucide-settings') && !btn.textContent?.trim()
|
||||
const settingsButton = allBtns.find(
|
||||
btn => {
|
||||
const title = btn.getAttribute('title');
|
||||
const text = btn.textContent?.trim() || '';
|
||||
// Settings gear: no title, no meaningful text, not the notification bell
|
||||
return !title && !text && btn.querySelector('.lucide-settings');
|
||||
}
|
||||
);
|
||||
|
||||
expect(settingsButton).toBeDefined();
|
||||
@@ -641,10 +646,14 @@ describe('DashboardPage', () => {
|
||||
expect(screen.getAllByText('Paris Adventure').length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
// Open widget settings — gear icon button (icon-only, no visible label)
|
||||
// Open widget settings
|
||||
const allBtns = screen.getAllByRole('button');
|
||||
const settingsButton = allBtns.find(btn =>
|
||||
btn.querySelector('.lucide-settings') && !btn.textContent?.trim()
|
||||
const settingsButton = allBtns.find(
|
||||
btn => {
|
||||
const title = btn.getAttribute('title');
|
||||
const text = btn.textContent?.trim() || '';
|
||||
return !title && !text && btn.querySelector('.lucide-settings');
|
||||
}
|
||||
);
|
||||
|
||||
expect(settingsButton).toBeDefined();
|
||||
|
||||
@@ -897,76 +897,61 @@ export default function DashboardPage(): React.ReactElement {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Desktop header — unified toolbar */}
|
||||
<div className="hidden md:block" style={{ marginBottom: 20 }}>
|
||||
<div style={{
|
||||
background: 'var(--bg-tertiary)', borderRadius: 18,
|
||||
border: '1px solid var(--border-primary)',
|
||||
boxShadow: '0 1px 3px rgba(0,0,0,0.06), 0 1px 2px rgba(0,0,0,0.04)',
|
||||
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('dashboard.title')}
|
||||
</h2>
|
||||
<div style={{ width: 1, height: 22, background: 'var(--border-faint)', flexShrink: 0 }} />
|
||||
<span style={{ fontSize: 13, color: 'var(--text-muted)' }}>
|
||||
{/* Desktop header */}
|
||||
<div className="hidden md:flex" style={{ alignItems: 'center', justifyContent: 'space-between', marginBottom: 28 }}>
|
||||
<div>
|
||||
<h1 style={{ margin: 0, fontSize: 24, fontWeight: 800, color: 'var(--text-primary)' }}>{t('dashboard.title')}</h1>
|
||||
<p style={{ margin: '3px 0 0', fontSize: 13, color: '#9ca3af' }}>
|
||||
{isLoading ? t('common.loading')
|
||||
: trips.length > 0 ? `${t(trips.length !== 1 ? 'dashboard.subtitle.activeMany' : 'dashboard.subtitle.activeOne', { count: trips.length })}${archivedTrips.length > 0 ? t('dashboard.subtitle.archivedSuffix', { count: archivedTrips.length }) : ''}`
|
||||
: t('dashboard.subtitle.empty')}
|
||||
</span>
|
||||
|
||||
<div style={{ display: 'inline-flex', gap: 6, alignItems: 'center', marginLeft: 'auto', flexShrink: 0 }}>
|
||||
<button
|
||||
onClick={toggleViewMode}
|
||||
title={viewMode === 'grid' ? t('dashboard.listView') : t('dashboard.gridView')}
|
||||
style={{
|
||||
appearance: 'none', border: 'none', cursor: 'pointer', fontFamily: 'inherit',
|
||||
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||||
padding: '7px 11px', borderRadius: 99,
|
||||
background: 'transparent', color: 'var(--text-muted)',
|
||||
transition: 'all 0.15s ease',
|
||||
}}
|
||||
onMouseEnter={e => { e.currentTarget.style.background = 'var(--bg-card)'; e.currentTarget.style.color = 'var(--text-primary)' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.background = 'transparent'; e.currentTarget.style.color = 'var(--text-muted)' }}
|
||||
>
|
||||
{viewMode === 'grid' ? <List size={15} /> : <LayoutGrid size={15} />}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowWidgetSettings(s => s ? false : true)}
|
||||
title={t('dashboard.widgets') || 'Widgets'}
|
||||
style={{
|
||||
appearance: 'none', border: 'none', cursor: 'pointer', fontFamily: 'inherit',
|
||||
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||||
padding: '7px 11px', borderRadius: 99,
|
||||
background: showWidgetSettings ? 'var(--bg-card)' : 'transparent',
|
||||
color: showWidgetSettings ? 'var(--text-primary)' : 'var(--text-muted)',
|
||||
boxShadow: showWidgetSettings ? '0 1px 2px rgba(0,0,0,0.06)' : 'none',
|
||||
transition: 'all 0.15s ease',
|
||||
}}
|
||||
onMouseEnter={e => { if (!showWidgetSettings) { e.currentTarget.style.background = 'var(--bg-card)'; e.currentTarget.style.color = 'var(--text-primary)' } }}
|
||||
onMouseLeave={e => { if (!showWidgetSettings) { e.currentTarget.style.background = 'transparent'; e.currentTarget.style.color = 'var(--text-muted)' } }}
|
||||
>
|
||||
<Settings size={15} />
|
||||
</button>
|
||||
{can('trip_create') && (
|
||||
<button
|
||||
onClick={() => { setEditingTrip(null); setShowForm(true) }}
|
||||
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: 2,
|
||||
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} /> {t('dashboard.newTrip')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</p>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'stretch' }}>
|
||||
{/* View mode toggle */}
|
||||
<button
|
||||
onClick={toggleViewMode}
|
||||
title={viewMode === 'grid' ? t('dashboard.listView') : t('dashboard.gridView')}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
padding: '0 14px', height: 37,
|
||||
background: 'var(--bg-card)', border: '1px solid var(--border-primary)', borderRadius: 12,
|
||||
cursor: 'pointer', color: 'var(--text-faint)', fontFamily: 'inherit',
|
||||
transition: 'background 0.15s, border-color 0.15s',
|
||||
}}
|
||||
onMouseEnter={e => { e.currentTarget.style.background = 'var(--bg-hover)'; e.currentTarget.style.borderColor = 'var(--text-faint)' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.background = 'var(--bg-card)'; e.currentTarget.style.borderColor = 'var(--border-primary)' }}
|
||||
>
|
||||
{viewMode === 'grid' ? <List size={15} /> : <LayoutGrid size={15} />}
|
||||
</button>
|
||||
{/* Widget settings */}
|
||||
<button
|
||||
onClick={() => setShowWidgetSettings(s => s ? false : true)}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
padding: '0 14px', height: 37,
|
||||
background: 'var(--bg-card)', border: '1px solid var(--border-primary)', borderRadius: 12,
|
||||
cursor: 'pointer', color: 'var(--text-faint)', fontFamily: 'inherit',
|
||||
transition: 'background 0.15s, border-color 0.15s',
|
||||
}}
|
||||
onMouseEnter={e => { e.currentTarget.style.background = 'var(--bg-hover)'; e.currentTarget.style.borderColor = 'var(--text-faint)' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.background = 'var(--bg-card)'; e.currentTarget.style.borderColor = 'var(--border-primary)' }}
|
||||
>
|
||||
<Settings size={15} />
|
||||
</button>
|
||||
{can('trip_create') && <button
|
||||
onClick={() => { setEditingTrip(null); setShowForm(true) }}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 7, padding: '9px 18px',
|
||||
background: 'var(--accent)', color: 'var(--accent-text)', border: 'none', borderRadius: 12,
|
||||
fontSize: 13, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit',
|
||||
boxShadow: '0 2px 8px rgba(0,0,0,0.15)',
|
||||
}}
|
||||
onMouseEnter={e => e.currentTarget.style.opacity = '0.85'}
|
||||
onMouseLeave={e => e.currentTarget.style.opacity = '1'}
|
||||
>
|
||||
<Plus size={15} /> {t('dashboard.newTrip')}
|
||||
</button>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -265,8 +265,8 @@ describe('JourneyDetailPage', () => {
|
||||
await renderAndWait();
|
||||
const timelineBtn = screen.getByRole('button', { name: /timeline/i });
|
||||
expect(timelineBtn).toBeInTheDocument();
|
||||
// Timeline entries are visible by default (gallery also mounted but hidden, so multiple matches are expected)
|
||||
expect(screen.getAllByText('Arrived in Rome').length).toBeGreaterThanOrEqual(1);
|
||||
// Timeline entries are visible by default
|
||||
expect(screen.getByText('Arrived in Rome')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -274,8 +274,8 @@ describe('JourneyDetailPage', () => {
|
||||
describe('FE-PAGE-JOURNEYDETAIL-004: Shows entry cards with titles', () => {
|
||||
it('renders all entry titles in timeline view', async () => {
|
||||
await renderAndWait();
|
||||
expect(screen.getAllByText('Arrived in Rome').length).toBeGreaterThanOrEqual(1);
|
||||
expect(screen.getAllByText('Florence Day').length).toBeGreaterThanOrEqual(1);
|
||||
expect(screen.getByText('Arrived in Rome')).toBeInTheDocument();
|
||||
expect(screen.getByText('Florence Day')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -615,7 +615,7 @@ describe('JourneyDetailPage', () => {
|
||||
|
||||
render(<JourneyDetailPage />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByText('Venice Visit').length).toBeGreaterThanOrEqual(1);
|
||||
expect(screen.getByText('Venice Visit')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Skeleton card shows "Add Entry" CTA
|
||||
@@ -655,10 +655,10 @@ describe('JourneyDetailPage', () => {
|
||||
|
||||
render(<JourneyDetailPage />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByText('Quick stop at cafe').length).toBeGreaterThanOrEqual(1);
|
||||
expect(screen.getByText('Quick stop at cafe')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.getAllByText(/Cafe Roma/).length).toBeGreaterThanOrEqual(1);
|
||||
expect(screen.getByText(/Cafe Roma/)).toBeInTheDocument();
|
||||
expect(screen.getByText('Grabbed an espresso')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1117,9 +1117,8 @@ describe('JourneyDetailPage', () => {
|
||||
|
||||
// Map view renders a location list with entry titles/location names
|
||||
// The MapView component shows entry names in clickable location items
|
||||
// (timeline is still mounted but hidden, so multiple matches are expected)
|
||||
expect(screen.getAllByText('Arrived in Rome').length).toBeGreaterThanOrEqual(1);
|
||||
expect(screen.getAllByText('Florence Day').length).toBeGreaterThanOrEqual(1);
|
||||
expect(screen.getByText('Arrived in Rome')).toBeInTheDocument();
|
||||
expect(screen.getByText('Florence Day')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1178,8 +1177,8 @@ describe('JourneyDetailPage', () => {
|
||||
expect(dayBadges.length).toBeGreaterThanOrEqual(2);
|
||||
|
||||
// Each day group shows its entries
|
||||
expect(screen.getAllByText('Arrived in Rome').length).toBeGreaterThanOrEqual(1);
|
||||
expect(screen.getAllByText('Florence Day').length).toBeGreaterThanOrEqual(1);
|
||||
expect(screen.getByText('Arrived in Rome')).toBeInTheDocument();
|
||||
expect(screen.getByText('Florence Day')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1879,10 +1878,8 @@ describe('JourneyDetailPage', () => {
|
||||
expect(screen.getAllByTestId('journey-map').length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
// Click the "Arrived in Rome" location item in the map view's location list
|
||||
// (timeline is still mounted but hidden, so find the one inside a cursor-pointer container)
|
||||
const romeItems = screen.getAllByText('Arrived in Rome');
|
||||
const romeItem = romeItems.find(el => el.closest('[class*="cursor-pointer"]')) ?? romeItems[0];
|
||||
// Click the "Arrived in Rome" location item
|
||||
const romeItem = screen.getByText('Arrived in Rome');
|
||||
await user.click(romeItem);
|
||||
|
||||
// After clicking, the item should gain active styles (translate-x-0.5 on the container)
|
||||
|
||||
@@ -164,12 +164,6 @@ export default function JourneyDetailPage() {
|
||||
setActiveLocationId(id)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (view === 'map') {
|
||||
requestAnimationFrame(() => fullMapRef.current?.invalidateSize())
|
||||
}
|
||||
}, [view])
|
||||
|
||||
const mapEntries = useMemo(
|
||||
() => (current?.entries || []).filter(e => e.location_lat && e.location_lng),
|
||||
[current?.entries]
|
||||
@@ -419,8 +413,8 @@ export default function JourneyDetailPage() {
|
||||
</div>
|
||||
|
||||
{/* Timeline (desktop only — mobile uses fullscreen combined view above) */}
|
||||
{!isMobile && (
|
||||
<div className={`flex flex-col gap-6 pb-24 md:pb-6${view === 'timeline' ? '' : ' hidden'}`}>
|
||||
{!isMobile && view === 'timeline' && (
|
||||
<div className="flex flex-col gap-6 pb-24 md:pb-6">
|
||||
{sortedDates.length === 0 && (
|
||||
<div className="text-center py-16">
|
||||
<div className="w-16 h-16 rounded-full bg-zinc-100 dark:bg-zinc-800 flex items-center justify-center mx-auto mb-4">
|
||||
@@ -475,7 +469,7 @@ export default function JourneyDetailPage() {
|
||||
)}
|
||||
|
||||
{/* Gallery View */}
|
||||
<div className={view === 'gallery' ? '' : 'hidden'}>
|
||||
{view === 'gallery' && (
|
||||
<GalleryView
|
||||
entries={current.entries}
|
||||
journeyId={current.id}
|
||||
@@ -484,21 +478,17 @@ export default function JourneyDetailPage() {
|
||||
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 })}
|
||||
onRefresh={() => loadJourney(Number(id))}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Full Map View (desktop only — mobile uses combined view) */}
|
||||
{!isMobile && (
|
||||
<div className={`pb-24 md:pb-6${view === 'map' ? '' : ' hidden'}`}>
|
||||
<MapView
|
||||
entries={current.entries}
|
||||
mapEntries={mapEntries}
|
||||
sortedDates={sortedDates}
|
||||
activeLocationId={activeLocationId}
|
||||
fullMapRef={fullMapRef}
|
||||
onLocationClick={handleLocationClick}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{!isMobile && view === 'map' && <div className="pb-24 md:pb-6"><MapView
|
||||
entries={current.entries}
|
||||
mapEntries={mapEntries}
|
||||
sortedDates={sortedDates}
|
||||
activeLocationId={activeLocationId}
|
||||
fullMapRef={fullMapRef}
|
||||
onLocationClick={handleLocationClick}
|
||||
/></div>}
|
||||
</div>
|
||||
|
||||
{/* Right sidebar — hidden on mobile */}
|
||||
@@ -1052,7 +1042,7 @@ function GalleryView({ entries, journeyId, userId, trips, onPhotoClick, onRefres
|
||||
trips={trips}
|
||||
existingAssetIds={new Set(entries.flatMap(e => (e.photos || []).filter(p => p.asset_id).map(p => p.asset_id!)))}
|
||||
onClose={() => setShowPicker(false)}
|
||||
onAdd={async (groups, entryId) => {
|
||||
onAdd={async (assetIds, entryId, passphrase) => {
|
||||
let targetId = entryId
|
||||
if (!targetId) {
|
||||
try {
|
||||
@@ -1065,12 +1055,10 @@ function GalleryView({ entries, journeyId, userId, trips, onPhotoClick, onRefres
|
||||
} catch { return }
|
||||
}
|
||||
let added = 0
|
||||
for (const group of groups) {
|
||||
try {
|
||||
const result = await journeyApi.addProviderPhotos(targetId, pickerProvider!, group.assetIds, undefined, group.passphrase)
|
||||
added += result.added || 0
|
||||
} catch {}
|
||||
}
|
||||
try {
|
||||
const result = await journeyApi.addProviderPhotos(targetId, pickerProvider!, assetIds, undefined, passphrase)
|
||||
added = result.added || 0
|
||||
} catch {}
|
||||
if (added > 0) {
|
||||
toast.success(t('journey.photosAdded', { count: added }))
|
||||
onRefresh()
|
||||
@@ -1544,7 +1532,7 @@ function ProviderPicker({ provider, userId, entries, trips, existingAssetIds, on
|
||||
trips: JourneyTrip[]
|
||||
existingAssetIds: Set<string>
|
||||
onClose: () => void
|
||||
onAdd: (groups: Array<{ assetIds: string[]; passphrase?: string }>, entryId: number | null) => Promise<void>
|
||||
onAdd: (assetIds: string[], entryId: number | null, passphrase?: string) => Promise<void>
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const [filter, setFilter] = useState<'trip' | 'custom' | 'all' | 'album'>('trip')
|
||||
@@ -1558,7 +1546,7 @@ function ProviderPicker({ provider, userId, entries, trips, existingAssetIds, on
|
||||
const [searchPage, setSearchPage] = useState(1)
|
||||
const [searchFrom, setSearchFrom] = useState('')
|
||||
const [searchTo, setSearchTo] = useState('')
|
||||
const [selected, setSelected] = useState<Map<string, { albumId?: string; passphrase?: string }>>(new Map())
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set())
|
||||
const [customFrom, setCustomFrom] = useState('')
|
||||
const [customTo, setCustomTo] = useState('')
|
||||
const [targetEntryId, setTargetEntryId] = useState<number | null>(null)
|
||||
@@ -1650,12 +1638,8 @@ function ProviderPicker({ provider, userId, entries, trips, existingAssetIds, on
|
||||
|
||||
const toggleAsset = (id: string) => {
|
||||
setSelected(prev => {
|
||||
const next = new Map(prev)
|
||||
if (next.has(id)) {
|
||||
next.delete(id)
|
||||
} else {
|
||||
next.set(id, { albumId: selectedAlbum ?? undefined, passphrase: selectedAlbumPassphrase })
|
||||
}
|
||||
const next = new Set(prev)
|
||||
if (next.has(id)) next.delete(id); else next.add(id)
|
||||
return next
|
||||
})
|
||||
}
|
||||
@@ -1817,9 +1801,9 @@ function ProviderPicker({ provider, userId, entries, trips, existingAssetIds, on
|
||||
<button
|
||||
onClick={() => {
|
||||
if (allSelected) {
|
||||
setSelected(new Map())
|
||||
setSelected(new Set())
|
||||
} else {
|
||||
setSelected(new Map(selectable.map((a: any) => [a.id, { albumId: selectedAlbum ?? undefined, passphrase: selectedAlbumPassphrase }])))
|
||||
setSelected(new Set(selectable.map((a: any) => a.id)))
|
||||
}
|
||||
}}
|
||||
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg text-[11px] font-medium border border-zinc-200 dark:border-zinc-700 text-zinc-500 dark:text-zinc-400 hover:bg-zinc-50 dark:hover:bg-zinc-800"
|
||||
@@ -1921,16 +1905,7 @@ function ProviderPicker({ provider, userId, entries, trips, existingAssetIds, on
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
const groupMap = new Map<string | undefined, string[]>()
|
||||
for (const [assetId, { passphrase }] of selected.entries()) {
|
||||
const list = groupMap.get(passphrase) || []
|
||||
list.push(assetId)
|
||||
groupMap.set(passphrase, list)
|
||||
}
|
||||
const groups = [...groupMap.entries()].map(([passphrase, assetIds]) => ({ assetIds, passphrase }))
|
||||
onAdd(groups, targetEntryId)
|
||||
}}
|
||||
onClick={() => onAdd([...selected], targetEntryId, selectedAlbumPassphrase)}
|
||||
disabled={selected.size === 0}
|
||||
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-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
|
||||
@@ -150,41 +150,39 @@ export default function JourneyPage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Header — desktop (unified toolbar) */}
|
||||
<div className="hidden md:block px-8 pt-10 pb-7">
|
||||
<div style={{
|
||||
background: 'var(--bg-tertiary)', borderRadius: 18,
|
||||
border: '1px solid var(--border-primary)',
|
||||
boxShadow: '0 1px 3px rgba(0,0,0,0.06), 0 1px 2px rgba(0,0,0,0.04)',
|
||||
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('journey.title')}
|
||||
</h2>
|
||||
<div style={{ width: 1, height: 22, background: 'var(--border-faint)', flexShrink: 0 }} />
|
||||
<span style={{ fontSize: 13, color: 'var(--text-muted)' }}>
|
||||
{t('journey.frontpage.subtitle')}
|
||||
</span>
|
||||
|
||||
<div style={{ display: 'inline-flex', gap: 6, alignItems: 'center', marginLeft: 'auto', flexShrink: 0 }}>
|
||||
<button
|
||||
onClick={() => openCreateModal()}
|
||||
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: 2,
|
||||
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} />
|
||||
{t('journey.frontpage.createJourney')}
|
||||
</button>
|
||||
</div>
|
||||
{/* Header — desktop */}
|
||||
<div className="hidden md:flex items-start justify-between px-8 pt-10 pb-7">
|
||||
<div>
|
||||
<h1 className="text-[32px] font-extrabold tracking-[-0.025em] text-zinc-900 dark:text-white leading-none">{t('journey.title')}</h1>
|
||||
<p className="text-[13px] text-zinc-500 mt-1.5">{t("journey.frontpage.subtitle")}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{searchOpen && (
|
||||
<input
|
||||
value={searchQuery}
|
||||
onChange={e => setSearchQuery(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Escape') { setSearchQuery(''); setSearchOpen(false) } }}
|
||||
placeholder={t('journey.search.placeholder')}
|
||||
autoFocus
|
||||
className="w-52 px-3 py-2 border border-zinc-200 dark:border-zinc-700 rounded-[10px] text-[13px] bg-white dark:bg-zinc-800 text-zinc-900 dark:text-white focus:border-zinc-400 focus:outline-none"
|
||||
/>
|
||||
)}
|
||||
<button
|
||||
onClick={() => {
|
||||
setSearchOpen(s => !s)
|
||||
if (searchOpen) setSearchQuery('')
|
||||
}}
|
||||
className={`w-9 h-9 rounded-[10px] border border-zinc-200 dark:border-zinc-700 flex items-center justify-center text-zinc-500 transition-colors ${searchOpen ? 'bg-zinc-100 dark:bg-zinc-700' : 'bg-white dark:bg-zinc-800 hover:bg-zinc-50 dark:hover:bg-zinc-700'}`}
|
||||
>
|
||||
{searchOpen ? <X size={15} /> : <Search size={15} />}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => openCreateModal()}
|
||||
className="inline-flex items-center gap-1.5 px-3.5 py-2 rounded-[10px] 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 transition-all hover:-translate-y-px"
|
||||
>
|
||||
<Plus size={14} />
|
||||
{t('journey.frontpage.createJourney')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -14,7 +14,6 @@ import PlaceFormModal from '../components/Planner/PlaceFormModal'
|
||||
import TripFormModal from '../components/Trips/TripFormModal'
|
||||
import TripMembersModal from '../components/Trips/TripMembersModal'
|
||||
import { ReservationModal } from '../components/Planner/ReservationModal'
|
||||
import { TransportModal } from '../components/Planner/TransportModal'
|
||||
// MemoriesPanel moved to Journey addon
|
||||
import ReservationsPanel from '../components/Planner/ReservationsPanel'
|
||||
import PackingListPanel from '../components/Packing/PackingListPanel'
|
||||
@@ -24,12 +23,11 @@ import BudgetPanel from '../components/Budget/BudgetPanel'
|
||||
import CollabPanel from '../components/Collab/CollabPanel'
|
||||
import Navbar from '../components/Layout/Navbar'
|
||||
import { useToast } from '../components/shared/Toast'
|
||||
import { Map, X, PanelLeftClose, PanelLeftOpen, PanelRightClose, PanelRightOpen, Ticket, PackageCheck, Wallet, FolderOpen, Users, Train } from 'lucide-react'
|
||||
import { Map, X, PanelLeftClose, PanelLeftOpen, PanelRightClose, PanelRightOpen, Ticket, PackageCheck, Wallet, FolderOpen, Users } from 'lucide-react'
|
||||
import { useTranslation } from '../i18n'
|
||||
import { addonsApi, accommodationsApi, authApi, tripsApi, assignmentsApi, mapsApi } from '../api/client'
|
||||
import { accommodationRepo } from '../repo/accommodationRepo'
|
||||
import { offlineDb } from '../db/offlineDb'
|
||||
import { useAuthStore } from '../store/authStore'
|
||||
import ConfirmDialog from '../components/shared/ConfirmDialog'
|
||||
import { useResizablePanels } from '../hooks/useResizablePanels'
|
||||
import { useTripWebSocket } from '../hooks/useTripWebSocket'
|
||||
@@ -37,102 +35,36 @@ 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, Upload, Plus } from 'lucide-react'
|
||||
import { ListTodo } 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={{ 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} inlineHeader={false} />}
|
||||
{subTab === 'todo' && <TodoListPanel tripId={tripId} items={todoItems} addItemSignal={addTodoSignal} />}
|
||||
<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>
|
||||
{subTab === 'packing' && <PackingListPanel tripId={tripId} items={packingItems} />}
|
||||
{subTab === 'todo' && <TodoListPanel tripId={tripId} items={todoItems} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -143,7 +75,6 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
||||
const toast = useToast()
|
||||
const { t, language } = useTranslation()
|
||||
const { settings } = useSettingsStore()
|
||||
const placesPhotosEnabled = useAuthStore(s => s.placesPhotosEnabled)
|
||||
const trip = useTripStore(s => s.trip)
|
||||
const days = useTripStore(s => s.days)
|
||||
const places = useTripStore(s => s.places)
|
||||
@@ -193,11 +124,8 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
||||
}).catch(() => {})
|
||||
}, [])
|
||||
|
||||
const TRANSPORT_TYPES = new Set(['flight', 'train', 'car', 'cruise', 'bus'])
|
||||
|
||||
const TRIP_TABS = [
|
||||
{ id: 'plan', label: t('trip.tabs.plan'), icon: Map },
|
||||
{ id: 'transports', label: t('trip.tabs.transports'), icon: Train },
|
||||
{ id: 'buchungen', label: t('trip.tabs.reservations'), shortLabel: t('trip.tabs.reservationsShort'), icon: Ticket },
|
||||
...(enabledAddons.packing ? [{ id: 'listen', label: t('trip.tabs.lists'), shortLabel: t('trip.tabs.listsShort'), icon: PackageCheck }] : []),
|
||||
...(enabledAddons.budget ? [{ id: 'finanzplan', label: t('trip.tabs.budget'), icon: Wallet }] : []),
|
||||
@@ -236,24 +164,9 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
||||
const [showMembersModal, setShowMembersModal] = useState<boolean>(false)
|
||||
const [showReservationModal, setShowReservationModal] = useState<boolean>(false)
|
||||
const [editingReservation, setEditingReservation] = useState<Reservation | null>(null)
|
||||
const [bookingForAssignmentId, setBookingForAssignmentId] = useState<number | null>(null)
|
||||
const [showTransportModal, setShowTransportModal] = useState<boolean>(false)
|
||||
const [editingTransport, setEditingTransport] = useState<Reservation | null>(null)
|
||||
const [transportModalDayId, setTransportModalDayId] = useState<number | null>(null)
|
||||
const [fitKey, setFitKey] = useState<number>(0)
|
||||
const initialFitTripId = useRef<number | null>(null)
|
||||
const [mobileSidebarOpen, setMobileSidebarOpen] = useState<'left' | 'right' | null>(null)
|
||||
const [deletePlaceId, setDeletePlaceId] = useState<number | null>(null)
|
||||
const [deletePlaceIds, setDeletePlaceIds] = useState<number[] | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!trip) return
|
||||
if (initialFitTripId.current === trip.id) return
|
||||
const hasGeoPlaces = places.some(p => p.lat != null && p.lng != null)
|
||||
if (!hasGeoPlaces) return
|
||||
initialFitTripId.current = trip.id
|
||||
setFitKey(k => k + 1)
|
||||
}, [trip, places])
|
||||
|
||||
const connectionsStorageKey = tripId ? `trek:visible-connections:${tripId}` : null
|
||||
const [visibleConnections, setVisibleConnections] = useState<number[]>(() => {
|
||||
@@ -282,7 +195,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
||||
|
||||
// Start photo fetches during splash screen so images are ready when map mounts
|
||||
useEffect(() => {
|
||||
if (isLoading || !places || places.length === 0 || !placesPhotosEnabled) return
|
||||
if (isLoading || !places || places.length === 0) return
|
||||
for (const p of places) {
|
||||
if (p.image_url) continue
|
||||
const cacheKey = p.google_place_id || p.osm_id || `${p.lat},${p.lng}`
|
||||
@@ -352,7 +265,6 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
||||
|
||||
return places.filter(p => {
|
||||
if (!p.lat || !p.lng) return false
|
||||
if (mapPlacesFilter === 'tracks' && !p.route_geometry) return false
|
||||
if (mapCategoryFilter.size > 0) {
|
||||
if (p.category_id == null) {
|
||||
if (!mapCategoryFilter.has('uncategorized')) return false
|
||||
@@ -466,7 +378,6 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
||||
try {
|
||||
await tripActions.deletePlace(tripId, deletePlaceId)
|
||||
if (selectedPlaceId === deletePlaceId) setSelectedPlaceId(null)
|
||||
updateRouteForDay(selectedDayId)
|
||||
toast.success(t('trip.toast.placeDeleted'))
|
||||
if (capturedPlace) {
|
||||
pushUndo(t('undo.deletePlace'), async () => {
|
||||
@@ -486,38 +397,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
||||
})
|
||||
}
|
||||
} catch (err: unknown) { toast.error(err instanceof Error ? err.message : t('common.unknownError')) }
|
||||
}, [deletePlaceId, tripId, toast, selectedPlaceId, selectedDayId, updateRouteForDay, pushUndo])
|
||||
|
||||
const confirmDeletePlaces = useCallback(async (ids?: number[]) => {
|
||||
const targetIds = ids ?? deletePlaceIds
|
||||
if (!targetIds?.length) return
|
||||
const state = useTripStore.getState()
|
||||
const capturedPlaces = state.places.filter(p => targetIds.includes(p.id))
|
||||
const capturedAssignments = Object.entries(state.assignments).flatMap(([dayId, as]) =>
|
||||
as.filter(a => a.place?.id != null && targetIds.includes(a.place.id)).map(a => ({ dayId: Number(dayId), placeId: a.place!.id, orderIndex: a.order_index }))
|
||||
)
|
||||
try {
|
||||
await tripActions.deletePlacesMany(tripId, targetIds)
|
||||
if (selectedPlaceId != null && targetIds.includes(selectedPlaceId)) setSelectedPlaceId(null)
|
||||
if (!ids) setDeletePlaceIds(null)
|
||||
updateRouteForDay(selectedDayId)
|
||||
toast.success(t('trip.toast.placesDeleted', { count: capturedPlaces.length }))
|
||||
if (capturedPlaces.length > 0) {
|
||||
pushUndo(t('undo.deletePlaces'), async () => {
|
||||
for (const place of capturedPlaces) {
|
||||
const newPlace = await tripActions.addPlace(tripId, {
|
||||
name: place.name, description: place.description,
|
||||
lat: place.lat, lng: place.lng, address: place.address,
|
||||
category_id: place.category_id, icon: place.icon, price: place.price,
|
||||
})
|
||||
for (const a of capturedAssignments.filter(x => x.placeId === place.id)) {
|
||||
await tripActions.assignPlaceToDay(tripId, a.dayId, newPlace.id, a.orderIndex)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
} catch (err: unknown) { toast.error(err instanceof Error ? err.message : t('common.unknownError')) }
|
||||
}, [deletePlaceIds, tripId, toast, selectedPlaceId, selectedDayId, updateRouteForDay, pushUndo])
|
||||
}, [deletePlaceId, tripId, toast, selectedPlaceId, pushUndo])
|
||||
|
||||
const handleAssignToDay = useCallback(async (placeId, dayId, position) => {
|
||||
const target = dayId || selectedDayId
|
||||
@@ -543,7 +423,6 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
||||
const capturedOrderIndex = capturedAssignment?.order_index ?? 0
|
||||
try {
|
||||
await tripActions.removeAssignment(tripId, dayId, assignmentId)
|
||||
updateRouteForDay(dayId)
|
||||
if (capturedPlaceId != null) {
|
||||
const capturedDayId = dayId
|
||||
const capturedPos = capturedOrderIndex
|
||||
@@ -567,11 +446,17 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
||||
await tripActions.reorderAssignments(tripId, capturedDayId, capturedPrevIds)
|
||||
})
|
||||
})
|
||||
.catch(err => toast.error(err instanceof Error ? err.message : t('trip.toast.reorderError')))
|
||||
updateRouteForDay(dayId)
|
||||
.catch(() => {})
|
||||
// Update route immediately from orderedIds
|
||||
const dayItems = useTripStore.getState().assignments[String(dayId)] || []
|
||||
const ordered = orderedIds.map(id => dayItems.find(a => a.id === id)).filter(Boolean)
|
||||
const waypoints = ordered.map(a => a.place).filter(p => p?.lat && p?.lng)
|
||||
if (waypoints.length >= 2) setRoute(waypoints.map(p => [p.lat, p.lng]))
|
||||
else setRoute(null)
|
||||
setRouteInfo(null)
|
||||
}
|
||||
catch { toast.error(t('trip.toast.reorderError')) }
|
||||
}, [tripId, toast, pushUndo, updateRouteForDay])
|
||||
}, [tripId, toast, pushUndo])
|
||||
|
||||
const handleUpdateDayTitle = useCallback(async (dayId, title) => {
|
||||
try { await tripActions.updateDayTitle(tripId, dayId, title) }
|
||||
@@ -601,21 +486,6 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
||||
} catch (err: unknown) { toast.error(err instanceof Error ? err.message : t('common.unknownError')) }
|
||||
}
|
||||
|
||||
const handleSaveTransport = async (data) => {
|
||||
try {
|
||||
if (editingTransport) {
|
||||
await tripActions.updateReservation(tripId, editingTransport.id, data)
|
||||
toast.success(t('trip.toast.reservationUpdated'))
|
||||
} else {
|
||||
await tripActions.addReservation(tripId, data)
|
||||
toast.success(t('trip.toast.reservationAdded'))
|
||||
}
|
||||
setShowTransportModal(false)
|
||||
setEditingTransport(null)
|
||||
setTransportModalDayId(null)
|
||||
} catch (err: unknown) { toast.error(err instanceof Error ? err.message : t('common.unknownError')) }
|
||||
}
|
||||
|
||||
const handleDeleteReservation = async (id) => {
|
||||
try {
|
||||
await tripActions.deleteReservation(tripId, id)
|
||||
@@ -824,16 +694,13 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
||||
onReorder={handleReorder}
|
||||
onUpdateDayTitle={handleUpdateDayTitle}
|
||||
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) } }}
|
||||
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) }}
|
||||
onAddTransport={can('day_edit', trip) ? (dayId) => { setTransportModalDayId(dayId); setEditingTransport(null); setShowTransportModal(true) } : undefined}
|
||||
onEditTransport={can('day_edit', trip) ? (reservation) => { setEditingTransport(reservation); setTransportModalDayId(reservation.day_id ?? null); setShowTransportModal(true) } : undefined}
|
||||
onEditReservation={can('reservation_edit', trip) ? (r) => { setEditingReservation(r); setShowReservationModal(true) } : undefined}
|
||||
onDayDetail={(day) => { setShowDayDetail(day); setSelectedPlaceId(null); selectAssignment(null) }}
|
||||
onRemoveAssignment={handleRemoveAssignment}
|
||||
onEditPlace={(place, assignmentId) => { setEditingPlace(place); setEditingAssignmentId(assignmentId || null); setShowPlaceForm(true) }}
|
||||
@@ -845,8 +712,6 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
||||
canUndo={canUndo}
|
||||
lastActionLabel={lastActionLabel}
|
||||
onUndo={handleUndo}
|
||||
onRouteRefresh={() => { if (selectedDayId) updateRouteForDay(selectedDayId) }}
|
||||
onAddBookingToAssignment={can('day_edit', trip) ? (dayId, assignmentId) => { tripActions.setSelectedDay(dayId); setBookingForAssignmentId(assignmentId); setEditingReservation(null); setShowReservationModal(true) } : undefined}
|
||||
/>
|
||||
{!leftCollapsed && (
|
||||
<div
|
||||
@@ -906,7 +771,6 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
||||
onAssignToDay={handleAssignToDay}
|
||||
onEditPlace={(place) => { setEditingPlace(place); setEditingAssignmentId(null); setShowPlaceForm(true) }}
|
||||
onDeletePlace={(placeId) => handleDeletePlace(placeId)}
|
||||
onBulkDeletePlaces={(ids) => setDeletePlaceIds(ids)}
|
||||
onCategoryFilterChange={setMapCategoryFilter}
|
||||
onPlacesFilterChange={setMapPlacesFilter}
|
||||
pushUndo={pushUndo}
|
||||
@@ -1064,8 +928,8 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
||||
</div>
|
||||
<div style={{ flex: 1, overflow: 'auto' }}>
|
||||
{mobileSidebarOpen === 'left'
|
||||
? <DayPlanSidebar tripId={tripId} trip={trip} days={days} places={places} categories={categories} assignments={assignments} selectedDayId={selectedDayId} selectedPlaceId={selectedPlaceId} selectedAssignmentId={selectedAssignmentId} onSelectDay={(id) => { handleSelectDay(id); setMobileSidebarOpen(null) }} onPlaceClick={(placeId, assignmentId) => { handlePlaceClick(placeId, assignmentId); setMobileSidebarOpen(null) }} onReorder={handleReorder} onUpdateDayTitle={handleUpdateDayTitle} onAssignToDay={handleAssignToDay} onRouteCalculated={(r) => { if (r) { setRoute(r.coordinates); setRouteInfo({ distance: r.distanceText, duration: r.durationText }) } }} reservations={reservations} onAddReservation={(dayId) => { setEditingReservation(null); tripActions.setSelectedDay(dayId); setShowReservationModal(true); setMobileSidebarOpen(null) }} onAddPlace={() => { setEditingPlace(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onDayDetail={(day) => { setShowDayDetail(day); setSelectedPlaceId(null); selectAssignment(null); setMobileSidebarOpen(null) }} accommodations={tripAccommodations} onNavigateToFiles={() => { setMobileSidebarOpen(null); handleTabChange('dateien') }} onExpandedDaysChange={setExpandedDayIds} pushUndo={pushUndo} canUndo={canUndo} lastActionLabel={lastActionLabel} onUndo={handleUndo} onEditTransport={can('day_edit', trip) ? (reservation) => { setEditingTransport(reservation); setTransportModalDayId(reservation.day_id ?? null); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onEditReservation={can('reservation_edit', trip) ? (r) => { setEditingReservation(r); setShowReservationModal(true); setMobileSidebarOpen(null) } : undefined} />
|
||||
: <PlacesSidebar tripId={tripId} places={places} categories={categories} assignments={assignments} selectedDayId={selectedDayId} selectedPlaceId={selectedPlaceId} onPlaceClick={(placeId) => { handlePlaceClick(placeId); setMobileSidebarOpen(null) }} onAddPlace={() => { setEditingPlace(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onAssignToDay={handleAssignToDay} onEditPlace={(place) => { setEditingPlace(place); setEditingAssignmentId(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onDeletePlace={(placeId) => handleDeletePlace(placeId)} onBulkDeletePlaces={(ids) => setDeletePlaceIds(ids)} onBulkDeleteConfirm={(ids) => confirmDeletePlaces(ids)} days={days} isMobile onCategoryFilterChange={setMapCategoryFilter} onPlacesFilterChange={setMapPlacesFilter} pushUndo={pushUndo} />
|
||||
? <DayPlanSidebar tripId={tripId} trip={trip} days={days} places={places} categories={categories} assignments={assignments} selectedDayId={selectedDayId} selectedPlaceId={selectedPlaceId} selectedAssignmentId={selectedAssignmentId} onSelectDay={(id) => { handleSelectDay(id); setMobileSidebarOpen(null) }} onPlaceClick={(placeId, assignmentId) => { handlePlaceClick(placeId, assignmentId); setMobileSidebarOpen(null) }} onReorder={handleReorder} onUpdateDayTitle={handleUpdateDayTitle} onAssignToDay={handleAssignToDay} onRouteCalculated={(r) => { if (r) { setRoute(r.coordinates); setRouteInfo({ distance: r.distanceText, duration: r.durationText }) } }} reservations={reservations} onAddReservation={(dayId) => { setEditingReservation(null); tripActions.setSelectedDay(dayId); setShowReservationModal(true); setMobileSidebarOpen(null) }} onAddPlace={() => { setEditingPlace(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onDayDetail={(day) => { setShowDayDetail(day); setSelectedPlaceId(null); setSelectedAssignmentId(null); setMobileSidebarOpen(null) }} accommodations={tripAccommodations} onNavigateToFiles={() => { setMobileSidebarOpen(null); handleTabChange('dateien') }} onExpandedDaysChange={setExpandedDayIds} pushUndo={pushUndo} canUndo={canUndo} lastActionLabel={lastActionLabel} onUndo={handleUndo} />
|
||||
: <PlacesSidebar tripId={tripId} places={places} categories={categories} assignments={assignments} selectedDayId={selectedDayId} selectedPlaceId={selectedPlaceId} onPlaceClick={(placeId) => { handlePlaceClick(placeId); setMobileSidebarOpen(null) }} onAddPlace={() => { setEditingPlace(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onAssignToDay={handleAssignToDay} onEditPlace={(place) => { setEditingPlace(place); setEditingAssignmentId(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onDeletePlace={(placeId) => handleDeletePlace(placeId)} days={days} isMobile onCategoryFilterChange={setMapCategoryFilter} onPlacesFilterChange={setMapPlacesFilter} pushUndo={pushUndo} />
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
@@ -1075,29 +939,11 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'transports' && (
|
||||
<div style={{ height: '100%', width: '100%', display: 'flex', flexDirection: 'column', overflowY: 'auto', overscrollBehavior: 'contain', paddingBottom: 'var(--bottom-nav-h)' }}>
|
||||
<ReservationsPanel
|
||||
tripId={tripId}
|
||||
reservations={reservations.filter(r => TRANSPORT_TYPES.has(r.type))}
|
||||
days={days}
|
||||
assignments={assignments}
|
||||
files={files}
|
||||
onAdd={() => { setEditingTransport(null); setShowTransportModal(true) }}
|
||||
onEdit={(r) => { setEditingTransport(r); setShowTransportModal(true) }}
|
||||
onDelete={handleDeleteReservation}
|
||||
onNavigateToFiles={() => handleTabChange('dateien')}
|
||||
titleKey="transport.title"
|
||||
addManualKey="transport.addManual"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'buchungen' && (
|
||||
<div style={{ height: '100%', width: '100%', display: 'flex', flexDirection: 'column', overflowY: 'auto', overscrollBehavior: 'contain', paddingBottom: 'var(--bottom-nav-h)' }}>
|
||||
<div style={{ height: '100%', maxWidth: 1800, margin: '0 auto', width: '100%', display: 'flex', flexDirection: 'column', overflowY: 'auto', overscrollBehavior: 'contain', paddingBottom: 'var(--bottom-nav-h)' }}>
|
||||
<ReservationsPanel
|
||||
tripId={tripId}
|
||||
reservations={reservations.filter(r => !TRANSPORT_TYPES.has(r.type))}
|
||||
reservations={reservations}
|
||||
days={days}
|
||||
assignments={assignments}
|
||||
files={files}
|
||||
@@ -1110,13 +956,13 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
||||
)}
|
||||
|
||||
{activeTab === 'listen' && (
|
||||
<div style={{ height: '100%', overflowY: 'auto', overscrollBehavior: 'contain', width: '100%', paddingBottom: 'var(--bottom-nav-h)' }}>
|
||||
<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)' }}>
|
||||
<ListsContainer tripId={tripId} packingItems={packingItems} todoItems={todoItems} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'finanzplan' && (
|
||||
<div style={{ height: '100%', overflowY: 'auto', overscrollBehavior: 'contain', width: '100%', paddingBottom: 'var(--bottom-nav-h)' }}>
|
||||
<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)' }}>
|
||||
<BudgetPanel tripId={tripId} tripMembers={tripMembers} />
|
||||
</div>
|
||||
)}
|
||||
@@ -1148,8 +994,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
||||
<PlaceFormModal isOpen={showPlaceForm} onClose={() => { setShowPlaceForm(false); setEditingPlace(null); setEditingAssignmentId(null); setPrefillCoords(null) }} onSave={handleSavePlace} place={editingPlace} prefillCoords={prefillCoords} assignmentId={editingAssignmentId} dayAssignments={editingAssignmentId ? Object.values(assignments).flat() : []} tripId={tripId} categories={categories} onCategoryCreated={cat => tripActions.addCategory?.(cat)} />
|
||||
<TripFormModal isOpen={showTripForm} onClose={() => setShowTripForm(false)} onSave={async (data) => { await tripActions.updateTrip(tripId, data); toast.success(t('trip.toast.tripUpdated')) }} trip={trip} />
|
||||
<TripMembersModal isOpen={showMembersModal} onClose={() => setShowMembersModal(false)} tripId={tripId} tripTitle={trip?.title} />
|
||||
<ReservationModal isOpen={showReservationModal} onClose={() => { setShowReservationModal(false); setEditingReservation(null); setBookingForAssignmentId(null) }} onSave={handleSaveReservation} reservation={editingReservation} days={days} places={places} assignments={assignments} selectedDayId={selectedDayId} files={files} onFileUpload={canUploadFiles ? (fd) => tripActions.addFile(tripId, fd) : undefined} onFileDelete={(id) => tripActions.deleteFile(tripId, id)} accommodations={tripAccommodations} defaultAssignmentId={bookingForAssignmentId} />
|
||||
{showTransportModal && <TransportModal isOpen={showTransportModal} onClose={() => { setShowTransportModal(false); setEditingTransport(null); setTransportModalDayId(null) }} onSave={handleSaveTransport} reservation={editingTransport} days={days} selectedDayId={transportModalDayId} />}
|
||||
<ReservationModal isOpen={showReservationModal} onClose={() => { setShowReservationModal(false); setEditingReservation(null) }} onSave={handleSaveReservation} reservation={editingReservation} days={days} places={places} assignments={assignments} selectedDayId={selectedDayId} files={files} onFileUpload={canUploadFiles ? (fd) => tripActions.addFile(tripId, fd) : undefined} onFileDelete={(id) => tripActions.deleteFile(tripId, id)} accommodations={tripAccommodations} />
|
||||
<ConfirmDialog
|
||||
isOpen={!!deletePlaceId}
|
||||
onClose={() => setDeletePlaceId(null)}
|
||||
@@ -1157,13 +1002,6 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
||||
title={t('common.delete')}
|
||||
message={t('trip.confirm.deletePlace')}
|
||||
/>
|
||||
<ConfirmDialog
|
||||
isOpen={!!deletePlaceIds?.length}
|
||||
onClose={() => setDeletePlaceIds(null)}
|
||||
onConfirm={confirmDeletePlaces}
|
||||
title={t('common.delete')}
|
||||
message={t('trip.confirm.deletePlaces', { count: deletePlaceIds?.length ?? 0 })}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -138,15 +138,19 @@ export default function VacayPage(): React.ReactElement {
|
||||
|
||||
<div style={{ paddingTop: 'var(--nav-h)' }}>
|
||||
<div className="max-w-[1800px] mx-auto px-3 sm:px-4 py-4 sm:py-6">
|
||||
{/* Mobile + tablet header (filter toggle lives here) */}
|
||||
<div className="lg:hidden flex items-center justify-between mb-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-4 sm:mb-5">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-9 h-9 rounded-xl flex items-center justify-center" style={{ background: 'var(--bg-secondary)' }}>
|
||||
<CalendarDays size={18} style={{ color: 'var(--text-primary)' }} />
|
||||
</div>
|
||||
<h1 className="text-lg font-bold" style={{ color: 'var(--text-primary)' }}>{t('admin.addons.catalog.vacay.name')}</h1>
|
||||
<div>
|
||||
<h1 className="text-lg sm:text-xl font-bold" style={{ color: 'var(--text-primary)' }}>{t('admin.addons.catalog.vacay.name')}</h1>
|
||||
<p className="text-xs hidden sm:block" style={{ color: 'var(--text-muted)' }}>{t('vacay.subtitle')}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Mobile sidebar toggle */}
|
||||
<button
|
||||
onClick={() => setShowMobileSidebar(true)}
|
||||
className="lg:hidden flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm transition-colors"
|
||||
@@ -160,46 +164,11 @@ export default function VacayPage(): React.ReactElement {
|
||||
style={{ background: 'var(--bg-secondary)', color: 'var(--text-muted)' }}
|
||||
>
|
||||
<Settings size={14} />
|
||||
<span className="hidden sm:inline">{t('vacay.settings')}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Desktop header — unified toolbar (sidebar is always visible at this width) */}
|
||||
<div className="hidden lg:block" style={{ marginBottom: 20 }}>
|
||||
<div style={{
|
||||
background: 'var(--bg-tertiary)', borderRadius: 18,
|
||||
border: '1px solid var(--border-primary)',
|
||||
boxShadow: '0 1px 3px rgba(0,0,0,0.06), 0 1px 2px rgba(0,0,0,0.04)',
|
||||
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('admin.addons.catalog.vacay.name')}
|
||||
</h2>
|
||||
<div style={{ width: 1, height: 22, background: 'var(--border-faint)', flexShrink: 0 }} />
|
||||
<span style={{ fontSize: 13, color: 'var(--text-muted)' }}>
|
||||
{t('vacay.subtitle')}
|
||||
</span>
|
||||
<div style={{ display: 'inline-flex', gap: 6, alignItems: 'center', marginLeft: 'auto', flexShrink: 0 }}>
|
||||
<button
|
||||
onClick={() => setShowSettings(true)}
|
||||
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: 2,
|
||||
transition: 'opacity 0.15s ease',
|
||||
}}
|
||||
onMouseEnter={e => e.currentTarget.style.opacity = '0.88'}
|
||||
onMouseLeave={e => e.currentTarget.style.opacity = '1'}
|
||||
>
|
||||
<Settings size={14} strokeWidth={2.5} /> {t('vacay.settings')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main layout */}
|
||||
<div className="flex gap-4 items-start">
|
||||
{/* Desktop Sidebar */}
|
||||
|
||||
@@ -84,26 +84,4 @@ export const placeRepo = {
|
||||
offlineDb.places.delete(Number(id))
|
||||
return result
|
||||
},
|
||||
|
||||
async deleteMany(tripId: number | string, ids: number[]): Promise<unknown> {
|
||||
if (!navigator.onLine) {
|
||||
await offlineDb.places.bulkDelete(ids)
|
||||
for (const id of ids) {
|
||||
const mutId = generateUUID()
|
||||
await mutationQueue.enqueue({
|
||||
id: mutId,
|
||||
tripId: Number(tripId),
|
||||
method: 'DELETE',
|
||||
url: `/trips/${tripId}/places/${id}`,
|
||||
body: undefined,
|
||||
resource: 'places',
|
||||
entityId: id,
|
||||
})
|
||||
}
|
||||
return { deleted: ids, count: ids.length }
|
||||
}
|
||||
const result = await placesApi.bulkDelete(tripId, ids)
|
||||
await offlineDb.places.bulkDelete(ids)
|
||||
return result
|
||||
},
|
||||
}
|
||||
|
||||
@@ -12,29 +12,6 @@ const listeners = new Map<string, Set<(entry: PhotoEntry) => void>>()
|
||||
// Separate thumb listeners — called when thumbDataUrl becomes available after initial load
|
||||
const thumbListeners = new Map<string, Set<(thumb: string) => void>>()
|
||||
|
||||
// Concurrency limiter — at most N photo API requests in flight at once.
|
||||
// Prevents flooding the server (and external APIs it calls) when many places appear at once.
|
||||
const MAX_CONCURRENT = 5
|
||||
let activeRequests = 0
|
||||
const requestQueue: Array<() => void> = []
|
||||
|
||||
function acquireRequestSlot(): Promise<void> {
|
||||
if (activeRequests < MAX_CONCURRENT) {
|
||||
activeRequests++
|
||||
return Promise.resolve()
|
||||
}
|
||||
return new Promise(resolve => requestQueue.push(resolve))
|
||||
}
|
||||
|
||||
function releaseRequestSlot(): void {
|
||||
const next = requestQueue.shift()
|
||||
if (next) {
|
||||
next()
|
||||
} else {
|
||||
activeRequests--
|
||||
}
|
||||
}
|
||||
|
||||
function notify(key: string, entry: PhotoEntry) {
|
||||
listeners.get(key)?.forEach(fn => fn(entry))
|
||||
listeners.delete(key)
|
||||
@@ -108,53 +85,38 @@ export function fetchPhoto(
|
||||
return
|
||||
}
|
||||
|
||||
// If photoId is already our stable proxy URL, use it directly — no API round-trip needed
|
||||
if (photoId && photoId.startsWith('/api/maps/place-photo/')) {
|
||||
const entry: PhotoEntry = { photoUrl: photoId, thumbDataUrl: null }
|
||||
cache.set(cacheKey, entry)
|
||||
callback?.(entry)
|
||||
notify(cacheKey, entry)
|
||||
// Generate base64 thumb in background
|
||||
urlToBase64(photoId).then(thumb => {
|
||||
if (thumb) { entry.thumbDataUrl = thumb; notifyThumb(cacheKey, thumb) }
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
inFlight.add(cacheKey)
|
||||
acquireRequestSlot().then(() =>
|
||||
mapsApi.placePhoto(photoId, lat, lng, name)
|
||||
.then(async (data: { photoUrl?: string }) => {
|
||||
const photoUrl = data.photoUrl || null
|
||||
if (!photoUrl) {
|
||||
const entry: PhotoEntry = { photoUrl: null, thumbDataUrl: null }
|
||||
cache.set(cacheKey, entry)
|
||||
callback?.(entry)
|
||||
notify(cacheKey, entry)
|
||||
return
|
||||
}
|
||||
|
||||
// Store URL first — sidebar can show immediately
|
||||
const entry: PhotoEntry = { photoUrl, thumbDataUrl: null }
|
||||
cache.set(cacheKey, entry)
|
||||
callback?.(entry)
|
||||
notify(cacheKey, entry)
|
||||
|
||||
// Generate base64 thumb in background
|
||||
const thumb = await urlToBase64(photoUrl)
|
||||
if (thumb) {
|
||||
entry.thumbDataUrl = thumb
|
||||
notifyThumb(cacheKey, thumb)
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
mapsApi.placePhoto(photoId, lat, lng, name)
|
||||
.then(async (data: { photoUrl?: string }) => {
|
||||
const photoUrl = data.photoUrl || null
|
||||
if (!photoUrl) {
|
||||
const entry: PhotoEntry = { photoUrl: null, thumbDataUrl: null }
|
||||
cache.set(cacheKey, entry)
|
||||
callback?.(entry)
|
||||
notify(cacheKey, entry)
|
||||
})
|
||||
.finally(() => { inFlight.delete(cacheKey); releaseRequestSlot() })
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
// Store URL first — sidebar can show immediately
|
||||
const entry: PhotoEntry = { photoUrl, thumbDataUrl: null }
|
||||
cache.set(cacheKey, entry)
|
||||
callback?.(entry)
|
||||
notify(cacheKey, entry)
|
||||
|
||||
// Generate base64 thumb in background
|
||||
const thumb = await urlToBase64(photoUrl)
|
||||
if (thumb) {
|
||||
entry.thumbDataUrl = thumb
|
||||
notifyThumb(cacheKey, thumb)
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
const entry: PhotoEntry = { photoUrl: null, thumbDataUrl: null }
|
||||
cache.set(cacheKey, entry)
|
||||
callback?.(entry)
|
||||
notify(cacheKey, entry)
|
||||
})
|
||||
.finally(() => { inFlight.delete(cacheKey) })
|
||||
}
|
||||
|
||||
export function getAllThumbs(): Record<string, string> {
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
import { weatherApi } from '../api/client'
|
||||
|
||||
const MAX_CONCURRENT = 3
|
||||
let active = 0
|
||||
const queue: Array<() => void> = []
|
||||
|
||||
function acquire(): Promise<void> {
|
||||
if (active < MAX_CONCURRENT) { active++; return Promise.resolve() }
|
||||
return new Promise(resolve => queue.push(resolve))
|
||||
}
|
||||
|
||||
function release(): void {
|
||||
const next = queue.shift()
|
||||
if (next) next()
|
||||
else active--
|
||||
}
|
||||
|
||||
export async function fetchWeather(lat: number, lng: number, date: string) {
|
||||
await acquire()
|
||||
try {
|
||||
return await weatherApi.get(lat, lng, date)
|
||||
} finally {
|
||||
release()
|
||||
}
|
||||
}
|
||||
@@ -33,9 +33,6 @@ interface AuthState {
|
||||
/** Server policy: all users must enable MFA */
|
||||
appRequireMfa: boolean
|
||||
tripRemindersEnabled: boolean
|
||||
placesPhotosEnabled: boolean
|
||||
placesAutocompleteEnabled: boolean
|
||||
placesDetailsEnabled: boolean
|
||||
|
||||
login: (email: string, password: string) => Promise<LoginResult>
|
||||
completeMfaLogin: (mfaToken: string, code: string) => Promise<AuthResponse>
|
||||
@@ -56,9 +53,6 @@ interface AuthState {
|
||||
setServerTimezone: (tz: string) => void
|
||||
setAppRequireMfa: (val: boolean) => void
|
||||
setTripRemindersEnabled: (val: boolean) => void
|
||||
setPlacesPhotosEnabled: (val: boolean) => void
|
||||
setPlacesAutocompleteEnabled: (val: boolean) => void
|
||||
setPlacesDetailsEnabled: (val: boolean) => void
|
||||
demoLogin: () => Promise<AuthResponse>
|
||||
}
|
||||
|
||||
@@ -80,9 +74,6 @@ export const useAuthStore = create<AuthState>()(
|
||||
serverTimezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||
appRequireMfa: false,
|
||||
tripRemindersEnabled: false,
|
||||
placesPhotosEnabled: true,
|
||||
placesAutocompleteEnabled: true,
|
||||
placesDetailsEnabled: true,
|
||||
|
||||
login: async (email: string, password: string) => {
|
||||
authSequence++
|
||||
@@ -266,9 +257,6 @@ export const useAuthStore = create<AuthState>()(
|
||||
setServerTimezone: (tz: string) => set({ serverTimezone: tz }),
|
||||
setAppRequireMfa: (val: boolean) => set({ appRequireMfa: val }),
|
||||
setTripRemindersEnabled: (val: boolean) => set({ tripRemindersEnabled: val }),
|
||||
setPlacesPhotosEnabled: (val: boolean) => set({ placesPhotosEnabled: val }),
|
||||
setPlacesAutocompleteEnabled: (val: boolean) => set({ placesAutocompleteEnabled: val }),
|
||||
setPlacesDetailsEnabled: (val: boolean) => set({ placesDetailsEnabled: val }),
|
||||
|
||||
demoLogin: async () => {
|
||||
authSequence++
|
||||
|
||||
@@ -12,7 +12,6 @@ export interface PlacesSlice {
|
||||
addPlace: (tripId: number | string, placeData: Partial<Place>) => Promise<Place>
|
||||
updatePlace: (tripId: number | string, placeId: number, placeData: Partial<Place>) => Promise<Place>
|
||||
deletePlace: (tripId: number | string, placeId: number) => Promise<void>
|
||||
deletePlacesMany: (tripId: number | string, placeIds: number[]) => Promise<void>
|
||||
}
|
||||
|
||||
export const createPlacesSlice = (set: SetState, get: GetState): PlacesSlice => ({
|
||||
@@ -81,28 +80,4 @@ export const createPlacesSlice = (set: SetState, get: GetState): PlacesSlice =>
|
||||
throw new Error(getApiErrorMessage(err, 'Error deleting place'))
|
||||
}
|
||||
},
|
||||
|
||||
deletePlacesMany: async (tripId, placeIds) => {
|
||||
if (placeIds.length === 0) return
|
||||
try {
|
||||
await placeRepo.deleteMany(tripId, placeIds)
|
||||
const idSet = new Set(placeIds)
|
||||
set(state => {
|
||||
const updatedAssignments = { ...state.assignments }
|
||||
let changed = false
|
||||
for (const [dayId, items] of Object.entries(state.assignments)) {
|
||||
if (items.some((a: Assignment) => a.place?.id != null && idSet.has(a.place.id))) {
|
||||
updatedAssignments[dayId] = items.filter((a: Assignment) => !idSet.has(a.place?.id!))
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
return {
|
||||
places: state.places.filter(p => !idSet.has(p.id)),
|
||||
...(changed ? { assignments: updatedAssignments } : {}),
|
||||
}
|
||||
})
|
||||
} catch (err: unknown) {
|
||||
throw new Error(getApiErrorMessage(err, 'Error deleting places'))
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
@@ -167,7 +167,6 @@ export interface Reservation {
|
||||
notes: string | null
|
||||
url: string | null
|
||||
day_id?: number | null
|
||||
end_day_id?: number | null
|
||||
place_id?: number | null
|
||||
assignment_id?: number | null
|
||||
accommodation_id?: number | null
|
||||
@@ -213,7 +212,6 @@ export interface Settings {
|
||||
show_place_description: boolean
|
||||
route_calculation?: boolean
|
||||
blur_booking_codes?: boolean
|
||||
map_booking_labels?: boolean
|
||||
}
|
||||
|
||||
export interface AssignmentsMap {
|
||||
|
||||
@@ -2,7 +2,6 @@ import { renderHook, act } from '@testing-library/react';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { useRouteCalculation } from '../../../src/hooks/useRouteCalculation';
|
||||
import { useSettingsStore } from '../../../src/store/settingsStore';
|
||||
import { useTripStore } from '../../../src/store/tripStore';
|
||||
import { buildAssignment, buildPlace } from '../../helpers/factories';
|
||||
import type { TripStoreState } from '../../../src/store/tripStore';
|
||||
import type { RouteSegment } from '../../../src/types';
|
||||
@@ -18,10 +17,6 @@ vi.mock('../../../src/components/Map/RouteCalculator', () => ({
|
||||
const { calculateSegments } = await import('../../../src/components/Map/RouteCalculator');
|
||||
|
||||
function buildMockStore(assignments: Record<string, ReturnType<typeof buildAssignment>[]> = {}): Partial<TripStoreState> {
|
||||
// Also populate the real Zustand store so updateRouteForDay (which reads from
|
||||
// useTripStore.getState()) sees the same assignments as the hook's tripStore param.
|
||||
// Reset reservations and days to empty so transport-split logic doesn't interfere.
|
||||
useTripStore.setState({ assignments, reservations: [], days: [] } as any);
|
||||
return { assignments } as Partial<TripStoreState>;
|
||||
}
|
||||
|
||||
@@ -40,8 +35,6 @@ describe('useRouteCalculation', () => {
|
||||
vi.clearAllMocks();
|
||||
// Default: route_calculation disabled
|
||||
useSettingsStore.setState({ settings: { route_calculation: false } as any });
|
||||
// Reset trip store assignments so each test starts clean
|
||||
useTripStore.setState({ assignments: {} } as any);
|
||||
(calculateSegments as ReturnType<typeof vi.fn>).mockResolvedValue(MOCK_SEGMENTS);
|
||||
});
|
||||
|
||||
@@ -78,9 +71,9 @@ describe('useRouteCalculation', () => {
|
||||
);
|
||||
|
||||
await act(async () => {});
|
||||
// route is an array of segments; no transport → single segment with all places
|
||||
expect(result.current.route).toEqual([
|
||||
[[p1.lat, p1.lng], [p2.lat, p2.lng]],
|
||||
[p1.lat, p1.lng],
|
||||
[p2.lat, p2.lng],
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -140,7 +133,8 @@ describe('useRouteCalculation', () => {
|
||||
|
||||
// After sort: a2 (order_index=0) first, then a1 (order_index=1)
|
||||
expect(result.current.route).toEqual([
|
||||
[[p2.lat, p2.lng], [p1.lat, p1.lng]],
|
||||
[p2.lat, p2.lng],
|
||||
[p1.lat, p1.lng],
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -272,7 +266,7 @@ describe('useRouteCalculation', () => {
|
||||
expect(result.current.setRouteInfo).toBeTypeOf('function');
|
||||
});
|
||||
|
||||
it('FE-HOOK-ROUTE-013: route recalculates when assignments change via store update', async () => {
|
||||
it('FE-HOOK-ROUTE-013: hook uses tripStoreRef — late store updates reflected correctly', async () => {
|
||||
useSettingsStore.setState({ settings: { route_calculation: true } as any });
|
||||
|
||||
const p1 = buildPlace({ lat: 10, lng: 10 });
|
||||
@@ -289,13 +283,14 @@ describe('useRouteCalculation', () => {
|
||||
await act(async () => {});
|
||||
|
||||
expect(result.current.route).toEqual([
|
||||
[[p1.lat, p1.lng], [p2.lat, p2.lng]],
|
||||
[p1.lat, p1.lng],
|
||||
[p2.lat, p2.lng],
|
||||
]);
|
||||
|
||||
// Now add a third place — update both the local store object and the Zustand store
|
||||
// Now add a third place
|
||||
const p3 = buildPlace({ lat: 30, lng: 30 });
|
||||
const a3 = buildAssignment({ day_id: 5, order_index: 2, place: p3 });
|
||||
storeData = buildMockStore({ '5': [a1, a2, a3] }); // also calls useTripStore.setState
|
||||
storeData = buildMockStore({ '5': [a1, a2, a3] });
|
||||
|
||||
await act(async () => {
|
||||
rerender();
|
||||
@@ -304,7 +299,9 @@ describe('useRouteCalculation', () => {
|
||||
await act(async () => {});
|
||||
|
||||
expect(result.current.route).toEqual([
|
||||
[[p1.lat, p1.lng], [p2.lat, p2.lng], [p3.lat, p3.lng]],
|
||||
[p1.lat, p1.lng],
|
||||
[p2.lat, p2.lng],
|
||||
[p3.lat, p3.lng],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -134,8 +134,6 @@ describe('fetchPhoto — in-flight deduplication', () => {
|
||||
svc.fetchPhoto('k', 'pid', undefined, undefined, undefined, cb1);
|
||||
svc.fetchPhoto('k', 'pid', undefined, undefined, undefined, cb2);
|
||||
|
||||
// acquireRequestSlot() is async (Promise.resolve), so flush microtasks before asserting
|
||||
await flush();
|
||||
expect(mockPlacePhoto).toHaveBeenCalledTimes(1);
|
||||
|
||||
resolve({ photoUrl: 'https://example.com/photo.jpg' });
|
||||
|
||||
+8
-19
@@ -69,10 +69,10 @@ There are **no database rows for notice definitions**. The registry is code-only
|
||||
├── reads user_notice_dismissals
|
||||
├── filters SYSTEM_NOTICES:
|
||||
│ – not dismissed
|
||||
│ – within [minVersion, maxVersion) range for the running app version
|
||||
│ – not expired (expiresAt)
|
||||
│ – all conditions pass (AND logic)
|
||||
├── sorts by priority → severity → publishedAt (desc)
|
||||
└── strips server-only fields (conditions, publishedAt, minVersion, maxVersion, priority)
|
||||
└── strips server-only fields (conditions, publishedAt, expiresAt, priority)
|
||||
│
|
||||
▼
|
||||
6. Client receives SystemNoticeDTO[]
|
||||
@@ -138,7 +138,7 @@ export const SYSTEM_NOTICES: SystemNotice[] = [
|
||||
|
||||
**Never remove or renumber an entry. Never reuse an ID.**
|
||||
|
||||
Dismissals are stored in the database keyed by `id`. Removing an entry means dismissed users would see it again if you ever add a notice with the same ID. If a notice is no longer needed, set `maxVersion` to the upper version on which it should appear (e.g. `4.0.0` means show notice until `4.0.0` is reached) — do not delete the entry.
|
||||
Dismissals are stored in the database keyed by `id`. Removing an entry means dismissed users would see it again if you ever add a notice with the same ID. If a notice is no longer needed, add `expiresAt` to stop it from being shown — do not delete the entry.
|
||||
|
||||
---
|
||||
|
||||
@@ -162,16 +162,13 @@ Dismissals are stored in the database keyed by `id`. Removing an entry means dis
|
||||
| Field | Type | Description |
|
||||
|---|---|---|
|
||||
| `priority` | `number` | Higher number = shown first. Primary sort key. Default: `0`. |
|
||||
| `minVersion` | `string` | Lowest app version (inclusive, semver) that should show this notice. Omit for no lower bound. |
|
||||
| `maxVersion` | `string` | Upper bound (exclusive, semver) — notice is hidden once this version ships. `maxVersion: '4.0.0'` means shown on `< 4.0.0`. Omit for no upper bound. |
|
||||
| `expiresAt` | `string` | ISO 8601 date. Notice is automatically hidden after this date. Preferred over deleting entries. |
|
||||
| `icon` | `string` | Lucide icon name (e.g. `'Sparkles'`, `'ImageOff'`). Shown in the modal's severity icon circle. Falls back to the severity default icon if absent or unrecognised. |
|
||||
| `bodyParams` | `Record<string, string>` | Interpolation parameters for `bodyKey`. Values replace `{key}` placeholders in the translated string. **Never hardcode version numbers or dates directly in translation strings — use this instead.** |
|
||||
| `media` | `NoticeMedia` | Image to display in the modal. See below. |
|
||||
| `highlights` | `Array<{ labelKey: string; iconName?: string }>` | Bullet-point feature list rendered below the body in modals. Each entry is a translation key + optional Lucide icon name. |
|
||||
| `cta` | `NoticeCta` | Primary action button. See [§8 CTAs](#8-ctas-call-to-action). |
|
||||
|
||||
> **Version bounds:** The range is `[minVersion, maxVersion)` — lower bound inclusive, upper bound exclusive. So `maxVersion: '4.0.0'` hides the notice once the app reaches 4.0.0. Both bounds are compared after stripping prerelease/build metadata via `semver.coerce`, so a server running `3.0.0-pre.42` is treated as `3.0.0` — consistent with `existingUserBeforeVersion` and staging environments behave like production.
|
||||
|
||||
### `NoticeMedia`
|
||||
|
||||
```typescript
|
||||
@@ -599,25 +596,17 @@ The registry integrity test will catch any `actionId` that appears in the regist
|
||||
|
||||
### Retire a notice (stop showing it)
|
||||
|
||||
**Do not delete the entry.** Set `maxVersion` to the last app version on which the notice should appear. Once the app is upgraded past that version, the service filters it out automatically. The database row for dismissed users remains harmless.
|
||||
**Do not delete the entry.** Set `expiresAt`:
|
||||
|
||||
```typescript
|
||||
{
|
||||
id: 'old-campaign',
|
||||
// ... all existing fields unchanged ...
|
||||
maxVersion: '3.1.0', // hidden once 3.1.0 ships (exclusive upper bound)
|
||||
expiresAt: '2026-07-01T00:00:00Z',
|
||||
}
|
||||
```
|
||||
|
||||
To scope a notice to a specific version window (e.g. a v3-only announcement), combine both bounds:
|
||||
|
||||
```typescript
|
||||
{
|
||||
id: 'v3-only',
|
||||
minVersion: '3.0.0',
|
||||
maxVersion: '4.0.0', // shown on >= 3.0.0 and < 4.0.0
|
||||
}
|
||||
```
|
||||
After the expiry date the service filters it out automatically. The database row for dismissed users remains harmless.
|
||||
|
||||
---
|
||||
|
||||
@@ -762,4 +751,4 @@ cd client && npm run test -- SystemNoticeModal
|
||||
| CTA labels ≤ 20 chars, sentence case, a verb | Consistent button copy across the app. |
|
||||
| Priorities must be set explicitly for upgrade notices | Adjacent notices form a multipage group; ordering matters for the reading flow. |
|
||||
| `action` CTA `actionId` must be registered client-side | The registry integrity test enforces this. Add both the registry entry and the `registerNoticeAction` call in the same PR. |
|
||||
| `maxVersion` over deletion for retiring notices | See §12. Deletion would cause dismissed users to re-see the notice if the ID were ever reused. |
|
||||
| `expiresAt` over deletion for retiring notices | See above. |
|
||||
|
||||
@@ -1,133 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
// Chrome Performance Trace Analyzer — outputs a compact summary
|
||||
// Usage: node analyze-trace.js <trace.json>
|
||||
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
|
||||
const file = process.argv[2]
|
||||
if (!file) { console.error('Usage: node analyze-trace.js <trace.json>'); process.exit(1) }
|
||||
|
||||
console.log(`Reading ${path.basename(file)} (${(fs.statSync(file).size / 1e6).toFixed(1)} MB)...`)
|
||||
const raw = fs.readFileSync(file, 'utf8')
|
||||
console.log('Parsing...')
|
||||
const trace = JSON.parse(raw)
|
||||
const events = Array.isArray(trace) ? trace : (trace.traceEvents || [])
|
||||
console.log(`Total events: ${events.length.toLocaleString()}\n`)
|
||||
|
||||
// ── 1. Long Tasks (> 50ms on main thread) ────────────────────────────────────
|
||||
const LONG_TASK_MS = 50
|
||||
const tasks = events
|
||||
.filter(e => e.ph === 'X' && e.dur && e.dur > LONG_TASK_MS * 1000)
|
||||
.sort((a, b) => b.dur - a.dur)
|
||||
.slice(0, 30)
|
||||
|
||||
console.log(`═══ TOP LONG TASKS (>${LONG_TASK_MS}ms) ═══`)
|
||||
for (const t of tasks) {
|
||||
const ms = (t.dur / 1000).toFixed(1)
|
||||
const name = t.name || '(unknown)'
|
||||
const cat = t.cat || ''
|
||||
console.log(` ${ms.padStart(8)}ms ${name} [${cat}]`)
|
||||
}
|
||||
|
||||
// ── 2. Summarise all complete events by name ──────────────────────────────────
|
||||
const byName = new Map()
|
||||
for (const e of events) {
|
||||
if (e.ph !== 'X' || !e.dur) continue
|
||||
const key = e.name
|
||||
const existing = byName.get(key)
|
||||
if (existing) {
|
||||
existing.totalMs += e.dur / 1000
|
||||
existing.count++
|
||||
if (e.dur > existing.maxMs * 1000) existing.maxMs = e.dur / 1000
|
||||
} else {
|
||||
byName.set(key, { totalMs: e.dur / 1000, count: 1, maxMs: e.dur / 1000 })
|
||||
}
|
||||
}
|
||||
const topByTotal = [...byName.entries()]
|
||||
.sort((a, b) => b[1].totalMs - a[1].totalMs)
|
||||
.slice(0, 40)
|
||||
|
||||
console.log('\n═══ TOP EVENTS BY TOTAL TIME ═══')
|
||||
console.log(' Total(ms) Max(ms) Count Name')
|
||||
for (const [name, s] of topByTotal) {
|
||||
console.log(
|
||||
` ${s.totalMs.toFixed(1).padStart(9)} ${s.maxMs.toFixed(1).padStart(7)} ${String(s.count).padStart(5)} ${name}`
|
||||
)
|
||||
}
|
||||
|
||||
// ── 3. React-specific events ──────────────────────────────────────────────────
|
||||
const reactKeywords = ['react', 'React', 'setState', 'useState', 'useMemo', 'useEffect',
|
||||
'reconcil', 'Reconcil', 'render', 'Render', 'commit', 'Commit', 'fiber', 'Fiber',
|
||||
'Marker', 'MapView', 'photoUrl', 'createPlace', 'markers']
|
||||
const reactEvents = [...byName.entries()]
|
||||
.filter(([name]) => reactKeywords.some(k => name.includes(k)))
|
||||
.sort((a, b) => b[1].totalMs - a[1].totalMs)
|
||||
.slice(0, 30)
|
||||
|
||||
if (reactEvents.length > 0) {
|
||||
console.log('\n═══ REACT / MAP EVENTS ═══')
|
||||
for (const [name, s] of reactEvents) {
|
||||
console.log(` ${s.totalMs.toFixed(1).padStart(9)}ms total ${s.maxMs.toFixed(1).padStart(7)}ms max ${s.count}x ${name}`)
|
||||
}
|
||||
}
|
||||
|
||||
// ── 4. V8 / JS heavy hitters ─────────────────────────────────────────────────
|
||||
const jsEvents = [...byName.entries()]
|
||||
.filter(([, s]) => s.totalMs > 20)
|
||||
.filter(([name]) => {
|
||||
const cat = (events.find(e => e.name === name)?.cat || '')
|
||||
return cat.includes('v8') || cat.includes('devtools.timeline') || name.includes('JS') || name.includes('Compile') || name.includes('GC')
|
||||
})
|
||||
.sort((a, b) => b[1].totalMs - a[1].totalMs)
|
||||
.slice(0, 20)
|
||||
|
||||
if (jsEvents.length > 0) {
|
||||
console.log('\n═══ V8 / JS EVENTS (>20ms total) ═══')
|
||||
for (const [name, s] of jsEvents) {
|
||||
console.log(` ${s.totalMs.toFixed(1).padStart(9)}ms ${s.count}x ${name}`)
|
||||
}
|
||||
}
|
||||
|
||||
// ── 5. CPU profile — top self-time functions ─────────────────────────────────
|
||||
const profileChunks = events.filter(e => e.name === 'ProfileChunk')
|
||||
if (profileChunks.length > 0) {
|
||||
const selfTime = new Map()
|
||||
for (const chunk of profileChunks) {
|
||||
const nodes = chunk.args?.data?.cpuProfile?.nodes || []
|
||||
const samples = chunk.args?.data?.cpuProfile?.samples || []
|
||||
const timeDeltas = chunk.args?.data?.timeDeltas || []
|
||||
// Build node map
|
||||
const nodeMap = new Map(nodes.map(n => [n.id, n]))
|
||||
// Accumulate self time per node
|
||||
for (let i = 0; i < samples.length; i++) {
|
||||
const nodeId = samples[i]
|
||||
const dt = (timeDeltas[i] || 0) / 1000 // µs → ms
|
||||
const node = nodeMap.get(nodeId)
|
||||
if (!node) continue
|
||||
const fn = node.callFrame?.functionName || '(anonymous)'
|
||||
const url = node.callFrame?.url || ''
|
||||
const line = node.callFrame?.lineNumber || 0
|
||||
const key = `${fn} @ ${url.split('/').slice(-2).join('/')}:${line}`
|
||||
selfTime.set(key, (selfTime.get(key) || 0) + dt)
|
||||
}
|
||||
}
|
||||
const topSelf = [...selfTime.entries()]
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.slice(0, 40)
|
||||
|
||||
console.log('\n═══ CPU PROFILE — TOP SELF-TIME FUNCTIONS ═══')
|
||||
for (const [name, ms] of topSelf) {
|
||||
console.log(` ${ms.toFixed(1).padStart(8)}ms ${name}`)
|
||||
}
|
||||
}
|
||||
|
||||
// ── 6. Paint / Layout costs ───────────────────────────────────────────────────
|
||||
const renderCats = ['Layout', 'UpdateLayoutTree', 'Paint', 'CompositeLayers', 'RasterTask']
|
||||
console.log('\n═══ RENDERING COSTS ═══')
|
||||
for (const cat of renderCats) {
|
||||
const s = byName.get(cat)
|
||||
if (s) console.log(` ${s.totalMs.toFixed(1).padStart(9)}ms total ${s.maxMs.toFixed(1).padStart(7)}ms max ${s.count}x ${cat}`)
|
||||
}
|
||||
|
||||
console.log('\nDone.')
|
||||
@@ -1,96 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
|
||||
const file = process.argv[2]
|
||||
if (!file) { console.error('Usage: node analyze-react-profiler.cjs <profile.json>'); process.exit(1) }
|
||||
|
||||
const raw = JSON.parse(fs.readFileSync(path.resolve(file), 'utf8'))
|
||||
const root = raw.dataForRoots[0]
|
||||
const commits = root.commitData
|
||||
|
||||
// snapshots: array of [fiberId, {displayName, ...}]
|
||||
const nameMap = new Map()
|
||||
for (const snap of root.snapshots) {
|
||||
const id = snap[0]
|
||||
const data = snap[1]
|
||||
if (data?.displayName) nameMap.set(id, data.displayName)
|
||||
}
|
||||
|
||||
console.log(`Commits: ${commits.length} Tracked components: ${nameMap.size}`)
|
||||
|
||||
// Probe the unit of fiberActualDurations against the known commit duration
|
||||
// fiberActualDurations contains durations for the subtree — the root fiber's
|
||||
// actual duration should be >= commit.duration. Find a plausible scale factor.
|
||||
const c0 = commits[0]
|
||||
const knownDur = c0.duration // already in ms per React DevTools spec
|
||||
const rootId = root.rootID ?? 1
|
||||
// Check a few values to pick scale
|
||||
const sampleDurs = c0.fiberActualDurations.slice(0, 10).map(e => e[1])
|
||||
console.log(`\nDebug — commit[0].duration=${knownDur}ms, first 5 raw fiberActualDurations values:`, sampleDurs.slice(0,5))
|
||||
// If max sample > 10*knownDur, values are in units of 1/100 ms; otherwise already ms
|
||||
const maxSample = Math.max(...c0.fiberActualDurations.map(e => e[1]))
|
||||
const scale = maxSample > knownDur * 10 ? 0.01 : 1
|
||||
|
||||
console.log(`Unit scale: ${scale === 0.01 ? '1/100 ms (dividing by 100)' : 'ms (no conversion)'}\n`)
|
||||
|
||||
// --- 1. Commit summary ---
|
||||
const fmt = (v) => v == null ? ' -' : (v * 1).toFixed(1).padStart(7)
|
||||
console.log('=== Commit summary ===')
|
||||
console.log(' # t(s) dur(ms) passive(ms) effects(ms) priority')
|
||||
const sorted = [...commits].map((c, i) => ({ i, ...c })).sort((a, b) => b.duration - a.duration)
|
||||
for (const c of sorted.slice(0, 15)) {
|
||||
const ts = (c.timestamp / 1000).toFixed(3)
|
||||
console.log(` ${String(c.i).padStart(2)} ${ts} ${fmt(c.duration)} ${fmt(c.passiveEffectDuration)} ${fmt(c.effectDuration)} ${c.priorityLevel ?? ''}`)
|
||||
}
|
||||
|
||||
// --- 2. Aggregate self + actual duration per component ---
|
||||
const selfTotals = new Map() // name → { total, count, max }
|
||||
const actualTotals = new Map()
|
||||
|
||||
for (const commit of commits) {
|
||||
for (const [id, raw] of commit.fiberActualDurations) {
|
||||
const dur = raw * scale
|
||||
const name = nameMap.get(id) ?? `(fiber#${id})`
|
||||
const e = actualTotals.get(name) ?? { total: 0, count: 0, max: 0 }
|
||||
e.total += dur; e.count += 1; e.max = Math.max(e.max, dur)
|
||||
actualTotals.set(name, e)
|
||||
}
|
||||
for (const [id, raw] of commit.fiberSelfDurations) {
|
||||
const dur = raw * scale
|
||||
const name = nameMap.get(id) ?? `(fiber#${id})`
|
||||
const e = selfTotals.get(name) ?? { total: 0, count: 0, max: 0 }
|
||||
e.total += dur; e.count += 1; e.max = Math.max(e.max, dur)
|
||||
selfTotals.set(name, e)
|
||||
}
|
||||
}
|
||||
|
||||
const ranked = [...selfTotals.entries()]
|
||||
.sort((a, b) => b[1].total - a[1].total)
|
||||
.filter(([, s]) => s.total > 0.5)
|
||||
|
||||
console.log('\n=== Top 40 components by SELF render time (excludes children) ===')
|
||||
console.log(' Component Self total Renders Self max Actual total')
|
||||
for (const [name, s] of ranked.slice(0, 40)) {
|
||||
const actual = actualTotals.get(name) ?? { total: 0 }
|
||||
console.log(
|
||||
` ${name.padEnd(48)} ${s.total.toFixed(1).padStart(8)} ms` +
|
||||
` ${String(s.count).padStart(6)}x` +
|
||||
` ${s.max.toFixed(1).padStart(7)} ms` +
|
||||
` ${actual.total.toFixed(1).padStart(10)} ms`
|
||||
)
|
||||
}
|
||||
|
||||
console.log('\n=== Most frequently re-rendering components (top 20) ===')
|
||||
const byCount = [...selfTotals.entries()].sort((a, b) => b[1].count - a[1].count)
|
||||
console.log(' Component Renders Self total')
|
||||
for (const [name, s] of byCount.slice(0, 20)) {
|
||||
console.log(` ${name.padEnd(48)} ${String(s.count).padStart(6)}x ${s.total.toFixed(1).padStart(8)} ms`)
|
||||
}
|
||||
|
||||
const totalPassive = commits.reduce((a, c) => a + (c.passiveEffectDuration ?? 0), 0)
|
||||
const totalCommit = commits.reduce((a, c) => a + c.duration, 0)
|
||||
console.log(`\n=== Totals ===`)
|
||||
console.log(` Total commit render time: ${totalCommit.toFixed(1)} ms (${commits.length} commits)`)
|
||||
console.log(` Total passive effect time: ${totalPassive.toFixed(1)} ms (useEffect)`)
|
||||
console.log(` Avg commit duration: ${(totalCommit / commits.length).toFixed(1)} ms`)
|
||||
@@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env node
|
||||
// Build server/assets/airports.json from OurAirports (davidmegginson.github.io/ourairports-data).
|
||||
// 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'
|
||||
@@ -9,7 +9,7 @@ import { fileURLToPath } from 'node:url'
|
||||
import tzLookup from 'tz-lookup'
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||
const OUT = path.join(__dirname, '..', 'assets', 'airports.json')
|
||||
const OUT = path.join(__dirname, '..', 'data', 'airports.json')
|
||||
const SRC = 'https://davidmegginson.github.io/ourairports-data/airports.csv'
|
||||
|
||||
function fetchText(url) {
|
||||
|
||||
@@ -1634,55 +1634,7 @@ function runMigrations(db: Database.Database): void {
|
||||
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: Persistent Google place photo disk cache registry
|
||||
() => {
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS google_place_photo_meta (
|
||||
place_id TEXT PRIMARY KEY,
|
||||
attribution TEXT,
|
||||
fetched_at INTEGER NOT NULL,
|
||||
error_at INTEGER
|
||||
)
|
||||
`);
|
||||
},
|
||||
// Migration 106: Persistent Place Details row cache
|
||||
() => {
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS place_details_cache (
|
||||
place_id TEXT NOT NULL,
|
||||
lang TEXT NOT NULL DEFAULT '',
|
||||
expanded INTEGER NOT NULL DEFAULT 0,
|
||||
payload_json TEXT NOT NULL,
|
||||
fetched_at INTEGER NOT NULL,
|
||||
PRIMARY KEY (place_id, lang, expanded)
|
||||
)
|
||||
`);
|
||||
},
|
||||
// Migration 107: Backfill expired signed Google photo URLs to stable proxy URLs
|
||||
{ raw: () => {
|
||||
db.exec(`
|
||||
UPDATE places
|
||||
SET image_url = '/api/maps/place-photo/' || google_place_id || '/bytes',
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE google_place_id IS NOT NULL
|
||||
AND image_url IS NOT NULL
|
||||
AND image_url != ''
|
||||
AND (
|
||||
(image_url LIKE '%googleusercontent.com%' AND image_url LIKE '%/places/%/photos/%')
|
||||
OR (image_url LIKE '%places.googleapis.com%' AND image_url LIKE '%/places/%/photos/%')
|
||||
)
|
||||
`);
|
||||
}},
|
||||
// Migration 108: Disk cache metadata for remote-provider photo thumbnails (Immich / Synology)
|
||||
() => db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS trek_photo_cache_meta (
|
||||
cache_key TEXT PRIMARY KEY,
|
||||
content_type TEXT NOT NULL DEFAULT 'image/jpeg',
|
||||
fetched_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_trek_photo_cache_meta_fetched_at ON trek_photo_cache_meta (fetched_at);
|
||||
`),
|
||||
// Migration 109: Reservation endpoints (from/to points for flights, trains, ferries, car rentals) — #384 + #587
|
||||
// Migration 105: Reservation endpoints (from/to points for flights, trains, ferries, car rentals) — #384 + #587
|
||||
() => {
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS reservation_endpoints (
|
||||
@@ -1703,41 +1655,6 @@ function runMigrations(db: Database.Database): void {
|
||||
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; }
|
||||
},
|
||||
// Migration 110 — link transport reservations to days via day_id / end_day_id
|
||||
() => {
|
||||
try {
|
||||
db.exec('ALTER TABLE reservations ADD COLUMN end_day_id INTEGER REFERENCES days(id) ON DELETE SET NULL');
|
||||
} catch (err: any) {
|
||||
if (!err.message?.includes('duplicate column name')) throw err;
|
||||
}
|
||||
|
||||
db.exec(`
|
||||
UPDATE reservations
|
||||
SET day_id = (
|
||||
SELECT d.id FROM days d
|
||||
WHERE d.trip_id = reservations.trip_id
|
||||
AND d.date = substr(reservations.reservation_time, 1, 10)
|
||||
LIMIT 1
|
||||
)
|
||||
WHERE type IN ('flight','train','car','cruise','bus')
|
||||
AND reservation_time IS NOT NULL
|
||||
AND day_id IS NULL
|
||||
`);
|
||||
|
||||
db.exec(`
|
||||
UPDATE reservations
|
||||
SET end_day_id = (
|
||||
SELECT d.id FROM days d
|
||||
WHERE d.trip_id = reservations.trip_id
|
||||
AND d.date = substr(reservations.reservation_end_time, 1, 10)
|
||||
LIMIT 1
|
||||
)
|
||||
WHERE type IN ('flight','train','car','cruise','bus')
|
||||
AND reservation_end_time IS NOT NULL
|
||||
AND end_day_id IS NULL
|
||||
AND substr(reservations.reservation_end_time, 1, 10) != substr(reservations.reservation_time, 1, 10)
|
||||
`);
|
||||
},
|
||||
];
|
||||
|
||||
if (currentVersion < migrations.length) {
|
||||
|
||||
@@ -165,7 +165,6 @@ function createTables(db: Database.Database): void {
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE,
|
||||
day_id INTEGER REFERENCES days(id) ON DELETE SET NULL,
|
||||
end_day_id INTEGER REFERENCES days(id) ON DELETE SET NULL,
|
||||
place_id INTEGER REFERENCES places(id) ON DELETE SET NULL,
|
||||
assignment_id INTEGER REFERENCES day_assignments(id) ON DELETE SET NULL,
|
||||
title TEXT NOT NULL,
|
||||
|
||||
@@ -49,7 +49,6 @@ const server = app.listen(PORT, () => {
|
||||
scheduler.startVersionCheck();
|
||||
scheduler.startDemoReset();
|
||||
scheduler.startIdempotencyCleanup();
|
||||
scheduler.startTrekPhotoCacheCleanup();
|
||||
const { startTokenCleanup } = require('./services/ephemeralTokens');
|
||||
startTokenCleanup();
|
||||
import('./websocket').then(({ setupWebSocket }) => {
|
||||
|
||||
@@ -201,63 +201,6 @@ router.put('/bag-tracking', (req: Request, res: Response) => {
|
||||
res.json(result);
|
||||
});
|
||||
|
||||
// ── Places Photos ───────────────────────────────────────────────────────
|
||||
|
||||
router.get('/places-photos', (_req: Request, res: Response) => {
|
||||
res.json(svc.getPlacesPhotos());
|
||||
});
|
||||
|
||||
router.put('/places-photos', (req: Request, res: Response) => {
|
||||
if (typeof req.body.enabled !== 'boolean') return res.status(400).json({ error: 'enabled must be a boolean' });
|
||||
const result = svc.updatePlacesPhotos(req.body.enabled);
|
||||
const authReq = req as AuthRequest;
|
||||
writeAudit({
|
||||
userId: authReq.user.id,
|
||||
action: 'admin.places_photos',
|
||||
ip: getClientIp(req),
|
||||
details: { enabled: result.enabled },
|
||||
});
|
||||
res.json(result);
|
||||
});
|
||||
|
||||
// ── Places Autocomplete ──────────────────────────────────────────────────
|
||||
|
||||
router.get('/places-autocomplete', (_req: Request, res: Response) => {
|
||||
res.json(svc.getPlacesAutocomplete());
|
||||
});
|
||||
|
||||
router.put('/places-autocomplete', (req: Request, res: Response) => {
|
||||
if (typeof req.body.enabled !== 'boolean') return res.status(400).json({ error: 'enabled must be a boolean' });
|
||||
const result = svc.updatePlacesAutocomplete(req.body.enabled);
|
||||
const authReq = req as AuthRequest;
|
||||
writeAudit({
|
||||
userId: authReq.user.id,
|
||||
action: 'admin.places_autocomplete',
|
||||
ip: getClientIp(req),
|
||||
details: { enabled: result.enabled },
|
||||
});
|
||||
res.json(result);
|
||||
});
|
||||
|
||||
// ── Places Details ───────────────────────────────────────────────────────
|
||||
|
||||
router.get('/places-details', (_req: Request, res: Response) => {
|
||||
res.json(svc.getPlacesDetails());
|
||||
});
|
||||
|
||||
router.put('/places-details', (req: Request, res: Response) => {
|
||||
if (typeof req.body.enabled !== 'boolean') return res.status(400).json({ error: 'enabled must be a boolean' });
|
||||
const result = svc.updatePlacesDetails(req.body.enabled);
|
||||
const authReq = req as AuthRequest;
|
||||
writeAudit({
|
||||
userId: authReq.user.id,
|
||||
action: 'admin.places_details',
|
||||
ip: getClientIp(req),
|
||||
details: { enabled: result.enabled },
|
||||
});
|
||||
res.json(result);
|
||||
});
|
||||
|
||||
// ── Collab Features ───────────────────────────────────────────────────────
|
||||
|
||||
router.get('/collab-features', (_req: Request, res: Response) => {
|
||||
|
||||
@@ -4,14 +4,11 @@ import { AuthRequest } from '../types';
|
||||
import {
|
||||
searchPlaces,
|
||||
getPlaceDetails,
|
||||
getPlaceDetailsExpanded,
|
||||
getPlacePhoto,
|
||||
reverseGeocode,
|
||||
resolveGoogleMapsUrl,
|
||||
autocompletePlaces,
|
||||
} from '../services/mapsService';
|
||||
import { db } from '../db/database';
|
||||
import { serveFilePath } from '../services/placePhotoCache';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
@@ -35,9 +32,6 @@ router.post('/search', authenticate, async (req: Request, res: Response) => {
|
||||
|
||||
// POST /autocomplete
|
||||
router.post('/autocomplete', authenticate, async (req: Request, res: Response) => {
|
||||
const autocompleteEnabledRow = db.prepare("SELECT value FROM app_settings WHERE key = 'places_autocomplete_enabled'").get() as { value: string } | undefined;
|
||||
if (autocompleteEnabledRow?.value === 'false') return res.status(200).json({ suggestions: [], source: 'disabled' });
|
||||
|
||||
const authReq = req as AuthRequest;
|
||||
const { input, lang, locationBias } = req.body;
|
||||
|
||||
@@ -76,18 +70,11 @@ router.post('/autocomplete', authenticate, async (req: Request, res: Response) =
|
||||
|
||||
// GET /details/:placeId
|
||||
router.get('/details/:placeId', authenticate, async (req: Request, res: Response) => {
|
||||
const detailsEnabledRow = db.prepare("SELECT value FROM app_settings WHERE key = 'places_details_enabled'").get() as { value: string } | undefined;
|
||||
if (detailsEnabledRow?.value === 'false') return res.status(200).json({ place: null, disabled: true });
|
||||
|
||||
const authReq = req as AuthRequest;
|
||||
const { placeId } = req.params;
|
||||
const expand = req.query.expand as string | undefined;
|
||||
|
||||
try {
|
||||
const refresh = req.query.refresh === '1';
|
||||
const result = expand
|
||||
? await getPlaceDetailsExpanded(authReq.user.id, placeId, req.query.lang as string, refresh)
|
||||
: await getPlaceDetails(authReq.user.id, placeId, req.query.lang as string);
|
||||
const result = await getPlaceDetails(authReq.user.id, placeId, req.query.lang as string);
|
||||
res.json(result);
|
||||
} catch (err: unknown) {
|
||||
const status = (err as { status?: number }).status || 500;
|
||||
@@ -101,12 +88,6 @@ router.get('/details/:placeId', authenticate, async (req: Request, res: Response
|
||||
router.get('/place-photo/:placeId', authenticate, async (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { placeId } = req.params;
|
||||
|
||||
// Kill-switch only applies to Google Places API fetches — Wikimedia (coords: prefix) is always allowed
|
||||
if (!placeId.startsWith('coords:')) {
|
||||
const photosEnabledRow = db.prepare("SELECT value FROM app_settings WHERE key = 'places_photos_enabled'").get() as { value: string } | undefined;
|
||||
if (photosEnabledRow?.value === 'false') return res.status(200).json({ photoUrl: null });
|
||||
}
|
||||
const lat = parseFloat(req.query.lat as string);
|
||||
const lng = parseFloat(req.query.lng as string);
|
||||
|
||||
@@ -121,15 +102,6 @@ router.get('/place-photo/:placeId', authenticate, async (req: Request, res: Resp
|
||||
}
|
||||
});
|
||||
|
||||
// GET /place-photo/:placeId/bytes — serve cached photo bytes from disk
|
||||
router.get('/place-photo/:placeId/bytes', authenticate, (req: Request, res: Response) => {
|
||||
const { placeId } = req.params;
|
||||
const fp = serveFilePath(placeId);
|
||||
if (!fp) return res.status(404).json({ error: 'Photo not cached' });
|
||||
res.set('Cache-Control', 'public, max-age=2592000, immutable');
|
||||
res.sendFile(fp);
|
||||
});
|
||||
|
||||
// GET /reverse
|
||||
router.get('/reverse', authenticate, async (req: Request, res: Response) => {
|
||||
const { lat, lng, lang } = req.query as { lat: string; lng: string; lang?: string };
|
||||
|
||||
@@ -12,13 +12,11 @@ import {
|
||||
getPlace,
|
||||
updatePlace,
|
||||
deletePlace,
|
||||
deletePlacesMany,
|
||||
importGpx,
|
||||
importMapFile,
|
||||
importGoogleList,
|
||||
importNaverList,
|
||||
searchPlaceImage,
|
||||
type KmlImportOptions,
|
||||
} from '../services/placeService';
|
||||
import { onPlaceCreated, onPlaceUpdated, onPlaceDeleted } from '../services/journeyService';
|
||||
|
||||
@@ -67,18 +65,9 @@ router.post('/import/gpx', authenticate, requireTripAccess, uploadMulter.single(
|
||||
const file = req.file as Express.Multer.File | undefined;
|
||||
if (!file) return res.status(400).json({ error: 'No file uploaded' });
|
||||
|
||||
const parseBool = (v: unknown, defaultVal: boolean) => v === undefined || v === null ? defaultVal : String(v) === 'true';
|
||||
const importWaypoints = parseBool(req.body.importWaypoints, true);
|
||||
const importRoutes = parseBool(req.body.importRoutes, true);
|
||||
const importTracks = parseBool(req.body.importTracks, true);
|
||||
|
||||
if (!importWaypoints && !importRoutes && !importTracks) {
|
||||
return res.status(400).json({ error: 'No import types selected' });
|
||||
}
|
||||
|
||||
const result = importGpx(tripId, file.buffer, { importWaypoints, importRoutes, importTracks });
|
||||
const result = importGpx(tripId, file.buffer);
|
||||
if (!result) {
|
||||
return res.status(400).json({ error: 'No matching places found in GPX file' });
|
||||
return res.status(400).json({ error: 'No waypoints found in GPX file' });
|
||||
}
|
||||
|
||||
res.status(201).json({ places: result.places, count: result.count, skipped: result.skipped });
|
||||
@@ -97,18 +86,8 @@ router.post('/import/map', authenticate, requireTripAccess, uploadMulter.single(
|
||||
const file = req.file as Express.Multer.File | undefined;
|
||||
if (!file) return res.status(400).json({ error: 'No file uploaded' });
|
||||
|
||||
const parseBool = (v: unknown, defaultVal: boolean) => v === undefined || v === null ? defaultVal : String(v) === 'true';
|
||||
const importPoints = parseBool(req.body.importPoints, true);
|
||||
const importPaths = parseBool(req.body.importPaths, true);
|
||||
|
||||
if (!importPoints && !importPaths) {
|
||||
return res.status(400).json({ error: 'No import types selected' });
|
||||
}
|
||||
|
||||
const kmlOpts: KmlImportOptions = { importPoints, importPaths };
|
||||
|
||||
try {
|
||||
const result = await importMapFile(tripId, file.buffer, file.originalname, kmlOpts);
|
||||
const result = await importMapFile(tripId, file.buffer, file.originalname);
|
||||
if (result.summary?.totalPlacemarks === 0) {
|
||||
return res.status(400).json({ error: 'No valid Placemarks found in map file', summary: result.summary });
|
||||
}
|
||||
@@ -222,30 +201,6 @@ router.put('/:id', authenticate, requireTripAccess, validateStringLengths({ name
|
||||
try { onPlaceUpdated(place.id); } catch {}
|
||||
});
|
||||
|
||||
// Bulk delete (must be before /:id)
|
||||
router.post('/bulk-delete', authenticate, requireTripAccess, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
if (!checkPermission('place_edit', authReq.user.role, authReq.trip!.user_id, authReq.user.id, authReq.trip!.user_id !== authReq.user.id))
|
||||
return res.status(403).json({ error: 'No permission' });
|
||||
|
||||
const { tripId } = req.params;
|
||||
const { ids } = req.body as { ids?: unknown };
|
||||
if (!Array.isArray(ids) || ids.some(v => typeof v !== 'number'))
|
||||
return res.status(400).json({ error: 'ids must be an array of numbers' });
|
||||
|
||||
const idList = ids as number[];
|
||||
if (idList.length === 0) return res.json({ deleted: [], count: 0 });
|
||||
|
||||
for (const id of idList) { try { onPlaceDeleted(id); } catch {} }
|
||||
const deleted = deletePlacesMany(tripId, idList);
|
||||
|
||||
res.json({ deleted, count: deleted.length });
|
||||
const socketId = req.headers['x-socket-id'] as string;
|
||||
for (const id of deleted) {
|
||||
broadcast(tripId, 'place:deleted', { placeId: id }, socketId);
|
||||
}
|
||||
});
|
||||
|
||||
router.delete('/:id', authenticate, requireTripAccess, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
if (!checkPermission('place_edit', authReq.user.role, authReq.trip!.user_id, authReq.user.id, authReq.trip!.user_id !== authReq.user.id))
|
||||
|
||||
@@ -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, end_day_id, place_id, assignment_id, status, type, accommodation_id, metadata, create_accommodation, create_budget_entry, endpoints, needs_review } = 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' });
|
||||
@@ -43,7 +43,7 @@ router.post('/', authenticate, (req: Request, res: Response) => {
|
||||
|
||||
const { reservation, accommodationCreated } = createReservation(tripId, {
|
||||
title, reservation_time, reservation_end_time, location,
|
||||
confirmation_number, notes, day_id, end_day_id, place_id, assignment_id,
|
||||
confirmation_number, notes, day_id, place_id, assignment_id,
|
||||
status, type, accommodation_id, metadata, create_accommodation,
|
||||
endpoints, needs_review
|
||||
});
|
||||
@@ -102,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, end_day_id, place_id, assignment_id, status, type, accommodation_id, metadata, create_accommodation, create_budget_entry, endpoints, needs_review } = 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 +115,7 @@ 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, end_day_id, place_id, assignment_id,
|
||||
confirmation_number, notes, day_id, place_id, assignment_id,
|
||||
status, type, accommodation_id, metadata, create_accommodation,
|
||||
endpoints, needs_review
|
||||
}, current);
|
||||
|
||||
+1
-25
@@ -248,36 +248,12 @@ function startIdempotencyCleanup(): void {
|
||||
}, { timezone: tz });
|
||||
}
|
||||
|
||||
// Trek photo cache cleanup: every 2 hours — evict disk files and DB rows past their 1h TTL
|
||||
let trekPhotoCacheTask: ScheduledTask | null = null;
|
||||
|
||||
function startTrekPhotoCacheCleanup(): void {
|
||||
if (trekPhotoCacheTask) { trekPhotoCacheTask.stop(); trekPhotoCacheTask = null; }
|
||||
|
||||
// Run once immediately on startup to evict any entries left over from a previous run
|
||||
try {
|
||||
const { sweepExpired } = require('./services/memories/trekPhotoCache');
|
||||
sweepExpired();
|
||||
} catch { /* cache dir may not exist yet — harmless */ }
|
||||
|
||||
trekPhotoCacheTask = cron.schedule('0 */2 * * *', () => {
|
||||
try {
|
||||
const { sweepExpired } = require('./services/memories/trekPhotoCache');
|
||||
sweepExpired();
|
||||
} catch (err: unknown) {
|
||||
const { logError: le } = require('./services/auditLog');
|
||||
le(`Trek photo cache cleanup: ${err instanceof Error ? err.message : err}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function stop(): void {
|
||||
if (currentTask) { currentTask.stop(); currentTask = null; }
|
||||
if (demoTask) { demoTask.stop(); demoTask = null; }
|
||||
if (reminderTask) { reminderTask.stop(); reminderTask = null; }
|
||||
if (versionCheckTask) { versionCheckTask.stop(); versionCheckTask = null; }
|
||||
if (idempotencyCleanupTask) { idempotencyCleanupTask.stop(); idempotencyCleanupTask = null; }
|
||||
if (trekPhotoCacheTask) { trekPhotoCacheTask.stop(); trekPhotoCacheTask = null; }
|
||||
}
|
||||
|
||||
export { start, stop, startDemoReset, startTripReminders, startVersionCheck, startIdempotencyCleanup, startTrekPhotoCacheCleanup, loadSettings, saveSettings, VALID_INTERVALS };
|
||||
export { start, stop, startDemoReset, startTripReminders, startVersionCheck, startIdempotencyCleanup, loadSettings, saveSettings, VALID_INTERVALS };
|
||||
|
||||
@@ -459,42 +459,6 @@ export function updateBagTracking(enabled: boolean) {
|
||||
return { enabled: !!enabled };
|
||||
}
|
||||
|
||||
// ── Places Photos ─────────────────────────────────────────────────────────
|
||||
|
||||
export function getPlacesPhotos() {
|
||||
const row = db.prepare("SELECT value FROM app_settings WHERE key = 'places_photos_enabled'").get() as { value: string } | undefined;
|
||||
return { enabled: row?.value !== 'false' };
|
||||
}
|
||||
|
||||
export function updatePlacesPhotos(enabled: boolean) {
|
||||
db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('places_photos_enabled', ?)").run(enabled ? 'true' : 'false');
|
||||
return { enabled: !!enabled };
|
||||
}
|
||||
|
||||
// ── Places Autocomplete ────────────────────────────────────────────────────
|
||||
|
||||
export function getPlacesAutocomplete() {
|
||||
const row = db.prepare("SELECT value FROM app_settings WHERE key = 'places_autocomplete_enabled'").get() as { value: string } | undefined;
|
||||
return { enabled: row?.value !== 'false' };
|
||||
}
|
||||
|
||||
export function updatePlacesAutocomplete(enabled: boolean) {
|
||||
db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('places_autocomplete_enabled', ?)").run(enabled ? 'true' : 'false');
|
||||
return { enabled: !!enabled };
|
||||
}
|
||||
|
||||
// ── Places Details ─────────────────────────────────────────────────────────
|
||||
|
||||
export function getPlacesDetails() {
|
||||
const row = db.prepare("SELECT value FROM app_settings WHERE key = 'places_details_enabled'").get() as { value: string } | undefined;
|
||||
return { enabled: row?.value !== 'false' };
|
||||
}
|
||||
|
||||
export function updatePlacesDetails(enabled: boolean) {
|
||||
db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('places_details_enabled', ?)").run(enabled ? 'true' : 'false');
|
||||
return { enabled: !!enabled };
|
||||
}
|
||||
|
||||
// ── Collab Features ───────────────────────────────────────────────────────
|
||||
|
||||
const COLLAB_FEATURE_KEYS = ['collab_chat_enabled', 'collab_notes_enabled', 'collab_polls_enabled', 'collab_whatsnext_enabled'] as const;
|
||||
|
||||
@@ -18,7 +18,7 @@ let byIata: Map<string, Airport> | null = null;
|
||||
|
||||
function load(): Airport[] {
|
||||
if (cache) return cache;
|
||||
const file = path.join(__dirname, '..', '..', 'assets', 'airports.json');
|
||||
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 = [];
|
||||
|
||||
@@ -264,54 +264,6 @@ function getPlacesForTrips(tripIds: number[]): Place[] {
|
||||
return db.prepare(`SELECT * FROM places WHERE trip_id IN (${placeholders})`).all(...tripIds) as Place[];
|
||||
}
|
||||
|
||||
// ── Country resolution (batch DB cache + sync fallback + background geocoding) ──
|
||||
|
||||
function resolvePlaceCountries(places: Place[]): Map<number, string> {
|
||||
const out = new Map<number, string>();
|
||||
const geoPlaces = places.filter(p => p.lat && p.lng);
|
||||
const placeIds = geoPlaces.map(p => p.id);
|
||||
|
||||
const cached = placeIds.length > 0
|
||||
? (db.prepare(
|
||||
`SELECT place_id, country_code FROM place_regions WHERE place_id IN (${placeIds.map(() => '?').join(',')})`
|
||||
).all(...placeIds) as { place_id: number; country_code: string }[])
|
||||
: [];
|
||||
const cachedMap = new Map(cached.map(r => [r.place_id, r.country_code]));
|
||||
|
||||
const uncachedForGeocode: Place[] = [];
|
||||
for (const p of places) {
|
||||
const fromDb = cachedMap.get(p.id);
|
||||
if (fromDb) { out.set(p.id, fromDb); continue; }
|
||||
const sync = resolveCountryCodeSync(p);
|
||||
if (sync) { out.set(p.id, sync); continue; }
|
||||
if (p.lat && p.lng && !geocodingInFlight.has(p.id)) {
|
||||
uncachedForGeocode.push(p);
|
||||
}
|
||||
}
|
||||
|
||||
if (uncachedForGeocode.length > 0) {
|
||||
const insertStmt = db.prepare(
|
||||
'INSERT OR REPLACE INTO place_regions (place_id, country_code, region_code, region_name) VALUES (?, ?, ?, ?)'
|
||||
);
|
||||
for (const p of uncachedForGeocode) geocodingInFlight.add(p.id);
|
||||
void (async () => {
|
||||
try {
|
||||
for (const place of uncachedForGeocode) {
|
||||
try {
|
||||
const info = await reverseGeocodeRegion(place.lat!, place.lng!);
|
||||
if (info) insertStmt.run(place.id, info.country_code, info.region_code, info.region_name);
|
||||
} catch { /* continue */ }
|
||||
finally { geocodingInFlight.delete(place.id); }
|
||||
}
|
||||
} catch {
|
||||
for (const p of uncachedForGeocode) geocodingInFlight.delete(p.id);
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
// ── getStats ────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function getStats(userId: number) {
|
||||
@@ -327,10 +279,9 @@ export async function getStats(userId: number) {
|
||||
const places = getPlacesForTrips(tripIds);
|
||||
|
||||
interface CountryEntry { code: string; places: { id: number; name: string; lat: number | null; lng: number | null }[]; tripIds: Set<number> }
|
||||
const placeCountries = resolvePlaceCountries(places);
|
||||
const countrySet = new Map<string, CountryEntry>();
|
||||
for (const place of places) {
|
||||
const code = placeCountries.get(place.id);
|
||||
const code = await resolveCountryCode(place);
|
||||
if (code) {
|
||||
if (!countrySet.has(code)) {
|
||||
countrySet.set(code, { code, places: [], tripIds: new Set() });
|
||||
|
||||
@@ -229,12 +229,6 @@ export function getAppConfig(authenticatedUser: { id: number } | null) {
|
||||
const activeChannels = notifChannelsRaw === 'none' ? [] : notifChannelsRaw.split(',').map((c: string) => c.trim()).filter(Boolean);
|
||||
const hasWebhookEnabled = activeChannels.includes('webhook');
|
||||
const tripRemindersEnabled = tripReminderSetting !== 'false';
|
||||
const placesPhotosSetting = (db.prepare("SELECT value FROM app_settings WHERE key = 'places_photos_enabled'").get() as { value: string } | undefined)?.value;
|
||||
const placesPhotosEnabled = placesPhotosSetting !== 'false';
|
||||
const placesAutocompleteSetting = (db.prepare("SELECT value FROM app_settings WHERE key = 'places_autocomplete_enabled'").get() as { value: string } | undefined)?.value;
|
||||
const placesAutocompleteEnabled = placesAutocompleteSetting !== 'false';
|
||||
const placesDetailsSetting = (db.prepare("SELECT value FROM app_settings WHERE key = 'places_details_enabled'").get() as { value: string } | undefined)?.value;
|
||||
const placesDetailsEnabled = placesDetailsSetting !== 'false';
|
||||
const setupComplete = userCount > 0 && !(db.prepare("SELECT id FROM users WHERE role = 'admin' AND must_change_password = 1 LIMIT 1").get());
|
||||
|
||||
return {
|
||||
@@ -264,9 +258,6 @@ export function getAppConfig(authenticatedUser: { id: number } | null) {
|
||||
notification_channels: activeChannels,
|
||||
available_channels: { email: hasSmtpHost, webhook: hasWebhookEnabled, inapp: true },
|
||||
trip_reminders_enabled: tripRemindersEnabled,
|
||||
places_photos_enabled: placesPhotosEnabled,
|
||||
places_autocomplete_enabled: placesAutocompleteEnabled,
|
||||
places_details_enabled: placesDetailsEnabled,
|
||||
permissions: authenticatedUser ? getAllPermissions() : undefined,
|
||||
dev_mode: process.env.NODE_ENV === 'development',
|
||||
};
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { db } from '../db/database';
|
||||
import { broadcastToUser } from '../websocket';
|
||||
import type { Journey, JourneyEntry, JourneyPhoto, JourneyContributor } from '../types';
|
||||
import { getOrCreateTrekPhoto, getOrCreateLocalTrekPhoto, setTrekPhotoProvider, deleteTrekPhotoIfOrphan } from './memories/photoResolverService';
|
||||
import { getOrCreateTrekPhoto, getOrCreateLocalTrekPhoto, setTrekPhotoProvider } from './memories/photoResolverService';
|
||||
|
||||
function ts(): number {
|
||||
return Date.now();
|
||||
@@ -718,7 +718,6 @@ export function deletePhoto(photoId: number, userId: number): (JourneyPhoto & {
|
||||
if (!canEdit(photo.journey_id, userId)) return null;
|
||||
|
||||
db.prepare('DELETE FROM journey_photos WHERE id = ?').run(photoId);
|
||||
deleteTrekPhotoIfOrphan(photo.photo_id);
|
||||
|
||||
// clean up empty Gallery entries left behind
|
||||
const remaining = db.prepare('SELECT 1 FROM journey_photos WHERE entry_id = ?').get(photo.entry_id);
|
||||
|
||||
@@ -6,7 +6,6 @@ export interface ParsedKmlPlacemark {
|
||||
lat: number | null;
|
||||
lng: number | null;
|
||||
folderName: string | null;
|
||||
routeGeometry: string | null;
|
||||
}
|
||||
|
||||
export interface KmlPlacemarkNode {
|
||||
@@ -98,26 +97,6 @@ export function sanitizeKmlDescription(value: unknown): string | null {
|
||||
return decoded || null;
|
||||
}
|
||||
|
||||
export function parseKmlLineStringCoordinates(value: unknown): Array<{ lat: number; lng: number; ele: number | null }> | null {
|
||||
const coordinates = asTrimmedString(value);
|
||||
if (!coordinates) return null;
|
||||
|
||||
const points = coordinates
|
||||
.trim()
|
||||
.split(/\s+/)
|
||||
.map(coord => {
|
||||
const parts = coord.split(',');
|
||||
const lng = Number.parseFloat(parts[0] ?? '');
|
||||
const lat = Number.parseFloat(parts[1] ?? '');
|
||||
const eleRaw = parts[2] != null ? Number.parseFloat(parts[2]) : NaN;
|
||||
if (!Number.isFinite(lat) || !Number.isFinite(lng)) return null;
|
||||
return { lat, lng, ele: Number.isFinite(eleRaw) ? eleRaw : null };
|
||||
})
|
||||
.filter((p): p is { lat: number; lng: number; ele: number | null } => p !== null);
|
||||
|
||||
return points.length >= 2 ? points : null;
|
||||
}
|
||||
|
||||
export function parseKmlPointCoordinates(value: unknown): { lat: number; lng: number } | null {
|
||||
const coordinates = asTrimmedString(value);
|
||||
if (!coordinates) return null;
|
||||
@@ -188,25 +167,13 @@ export function extractKmlPlacemarkNodes(kmlRoot: any): KmlPlacemarkNode[] {
|
||||
}
|
||||
|
||||
export function parsePlacemarkNode(node: KmlPlacemarkNode): ParsedKmlPlacemark {
|
||||
const pointCoords = parseKmlPointCoordinates(node.placemark?.Point?.coordinates);
|
||||
|
||||
let routeGeometry: string | null = null;
|
||||
let pathFirstPt: { lat: number; lng: number } | null = null;
|
||||
if (!pointCoords) {
|
||||
const linePts = parseKmlLineStringCoordinates(node.placemark?.LineString?.coordinates);
|
||||
if (linePts) {
|
||||
pathFirstPt = { lat: linePts[0].lat, lng: linePts[0].lng };
|
||||
const hasAllEle = linePts.every(p => p.ele !== null);
|
||||
routeGeometry = JSON.stringify(linePts.map(p => hasAllEle ? [p.lat, p.lng, p.ele] : [p.lat, p.lng]));
|
||||
}
|
||||
}
|
||||
const coordinates = parseKmlPointCoordinates(node.placemark?.Point?.coordinates);
|
||||
|
||||
return {
|
||||
name: asTrimmedString(node.placemark?.name),
|
||||
description: sanitizeKmlDescription(node.placemark?.description),
|
||||
lat: pointCoords?.lat ?? pathFirstPt?.lat ?? null,
|
||||
lng: pointCoords?.lng ?? pathFirstPt?.lng ?? null,
|
||||
lat: coordinates?.lat ?? null,
|
||||
lng: coordinates?.lng ?? null,
|
||||
folderName: node.folderName,
|
||||
routeGeometry,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2,19 +2,6 @@ import { db } from '../db/database';
|
||||
import { decrypt_api_key } from './apiKeyCrypto';
|
||||
import { checkSsrf } from '../utils/ssrfGuard';
|
||||
|
||||
// ── Google API call counter ───────────────────────────────────────────────────
|
||||
|
||||
let googleApiCallCount = 0;
|
||||
|
||||
export function getGoogleApiCallCount(): number { return googleApiCallCount; }
|
||||
export function resetGoogleApiCallCount(): void { googleApiCallCount = 0; }
|
||||
|
||||
function googleFetch(endpoint: string, label: string, init?: RequestInit): Promise<Response> {
|
||||
googleApiCallCount++;
|
||||
console.debug(`[Google API] #${googleApiCallCount} ${label} → ${endpoint}`);
|
||||
return fetch(endpoint, init);
|
||||
}
|
||||
|
||||
// ── Interfaces ───────────────────────────────────────────────────────────────
|
||||
|
||||
interface NominatimResult {
|
||||
@@ -68,32 +55,26 @@ interface GooglePlaceDetails extends GooglePlaceResult {
|
||||
|
||||
const UA = 'TREK Travel Planner (https://github.com/mauriceboe/TREK)';
|
||||
|
||||
// ── Photo cache (disk-backed) ────────────────────────────────────────────────
|
||||
import * as placePhotoCache from './placePhotoCache';
|
||||
// ── Photo cache ──────────────────────────────────────────────────────────────
|
||||
|
||||
// ── Concurrency limiter for outbound photo fetches ───────────────────────────
|
||||
// Caps simultaneous Wikimedia/Google photo requests so a bulk import of hundreds
|
||||
// of places cannot monopolise the event loop or trigger external API rate limits.
|
||||
const MAX_CONCURRENT_PHOTO_FETCHES = 5;
|
||||
let photoFetchActive = 0;
|
||||
const photoFetchQueue: Array<() => void> = [];
|
||||
const photoCache = new Map<string, { photoUrl: string; attribution: string | null; fetchedAt: number; error?: boolean }>();
|
||||
const PHOTO_TTL = 12 * 60 * 60 * 1000; // 12 hours
|
||||
const ERROR_TTL = 5 * 60 * 1000; // 5 min for errors
|
||||
const CACHE_MAX_ENTRIES = 1000;
|
||||
const CACHE_PRUNE_TARGET = 500;
|
||||
const CACHE_CLEANUP_INTERVAL = 5 * 60 * 1000; // 5 minutes
|
||||
|
||||
function acquirePhotoFetchSlot(): Promise<void> {
|
||||
if (photoFetchActive < MAX_CONCURRENT_PHOTO_FETCHES) {
|
||||
photoFetchActive++;
|
||||
return Promise.resolve();
|
||||
setInterval(() => {
|
||||
const now = Date.now();
|
||||
for (const [key, entry] of photoCache) {
|
||||
if (now - entry.fetchedAt > PHOTO_TTL) photoCache.delete(key);
|
||||
}
|
||||
return new Promise(resolve => photoFetchQueue.push(resolve));
|
||||
}
|
||||
|
||||
function releasePhotoFetchSlot(): void {
|
||||
const next = photoFetchQueue.shift();
|
||||
if (next) {
|
||||
next();
|
||||
} else {
|
||||
photoFetchActive--;
|
||||
if (photoCache.size > CACHE_MAX_ENTRIES) {
|
||||
const entries = [...photoCache.entries()].sort((a, b) => a[1].fetchedAt - b[1].fetchedAt);
|
||||
const toDelete = entries.slice(0, entries.length - CACHE_PRUNE_TARGET);
|
||||
toDelete.forEach(([key]) => photoCache.delete(key));
|
||||
}
|
||||
}
|
||||
}, CACHE_CLEANUP_INTERVAL);
|
||||
|
||||
// ── API key retrieval ────────────────────────────────────────────────────────
|
||||
|
||||
@@ -330,7 +311,7 @@ export async function searchPlaces(userId: number, query: string, lang?: string)
|
||||
return { places, source: 'openstreetmap' };
|
||||
}
|
||||
|
||||
const response = await googleFetch('https://places.googleapis.com/v1/places:searchText', 'searchText', {
|
||||
const response = await fetch('https://places.googleapis.com/v1/places:searchText', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
@@ -390,7 +371,7 @@ export async function autocompletePlaces(
|
||||
};
|
||||
}
|
||||
|
||||
const response = await googleFetch('https://places.googleapis.com/v1/places:autocomplete', 'autocomplete', {
|
||||
const response = await fetch('https://places.googleapis.com/v1/places:autocomplete', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
@@ -470,79 +451,12 @@ export async function getPlaceDetails(userId: number, placeId: string, lang?: st
|
||||
}
|
||||
|
||||
// Google details
|
||||
const langKey = lang || 'de';
|
||||
const apiKey = getMapsKey(userId);
|
||||
if (!apiKey) {
|
||||
throw Object.assign(new Error('Google Maps API key not configured'), { status: 400 });
|
||||
}
|
||||
|
||||
// Check DB cache first (lean mask, expanded=0) — 7-day TTL
|
||||
const DETAILS_TTL = 7 * 24 * 60 * 60 * 1000;
|
||||
const cached = db.prepare(
|
||||
'SELECT payload_json, fetched_at FROM place_details_cache WHERE place_id = ? AND lang = ? AND expanded = 0'
|
||||
).get(placeId, langKey) as { payload_json: string; fetched_at: number } | undefined;
|
||||
if (cached && Date.now() - cached.fetched_at < DETAILS_TTL) return { place: JSON.parse(cached.payload_json) };
|
||||
|
||||
const response = await googleFetch(`https://places.googleapis.com/v1/places/${placeId}?languageCode=${langKey}`, `getPlaceDetails(${placeId})`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'X-Goog-Api-Key': apiKey,
|
||||
'X-Goog-FieldMask': 'id,displayName,formattedAddress,location,rating,userRatingCount,websiteUri,nationalPhoneNumber,regularOpeningHours,googleMapsUri',
|
||||
},
|
||||
});
|
||||
|
||||
const data = await response.json() as GooglePlaceDetails & { error?: { message?: string } };
|
||||
|
||||
if (!response.ok) {
|
||||
const err = new Error(data.error?.message || 'Google Places API error') as Error & { status: number };
|
||||
err.status = response.status;
|
||||
throw err;
|
||||
}
|
||||
|
||||
const place = {
|
||||
google_place_id: data.id,
|
||||
name: data.displayName?.text || '',
|
||||
address: data.formattedAddress || '',
|
||||
lat: data.location?.latitude || null,
|
||||
lng: data.location?.longitude || null,
|
||||
rating: data.rating || null,
|
||||
rating_count: data.userRatingCount || null,
|
||||
website: data.websiteUri || null,
|
||||
phone: data.nationalPhoneNumber || null,
|
||||
opening_hours: data.regularOpeningHours?.weekdayDescriptions || null,
|
||||
open_now: data.regularOpeningHours?.openNow ?? null,
|
||||
google_maps_url: data.googleMapsUri || null,
|
||||
summary: null,
|
||||
reviews: [],
|
||||
source: 'google' as const,
|
||||
cached_at: Date.now(),
|
||||
};
|
||||
|
||||
try {
|
||||
db.prepare(
|
||||
'INSERT OR REPLACE INTO place_details_cache (place_id, lang, expanded, payload_json, fetched_at) VALUES (?, ?, 0, ?, ?)'
|
||||
).run(placeId, langKey, JSON.stringify(place), Date.now());
|
||||
} catch (dbErr) {
|
||||
console.error('Failed to cache place details:', dbErr);
|
||||
}
|
||||
|
||||
return { place };
|
||||
}
|
||||
|
||||
export async function getPlaceDetailsExpanded(userId: number, placeId: string, lang?: string, refresh = false): Promise<{ place: Record<string, unknown> }> {
|
||||
const langKey = lang || 'de';
|
||||
const apiKey = getMapsKey(userId);
|
||||
if (!apiKey) throw Object.assign(new Error('Google Maps API key not configured'), { status: 400 });
|
||||
|
||||
// Check DB cache for expanded result
|
||||
if (!refresh) {
|
||||
const cached = db.prepare(
|
||||
'SELECT payload_json FROM place_details_cache WHERE place_id = ? AND lang = ? AND expanded = 1'
|
||||
).get(placeId, langKey) as { payload_json: string } | undefined;
|
||||
if (cached) return { place: JSON.parse(cached.payload_json) };
|
||||
}
|
||||
|
||||
const response = await googleFetch(`https://places.googleapis.com/v1/places/${placeId}?languageCode=${langKey}`, `getPlaceDetailsExpanded(${placeId})`, {
|
||||
const response = await fetch(`https://places.googleapis.com/v1/places/${placeId}?languageCode=${lang || 'de'}`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'X-Goog-Api-Key': apiKey,
|
||||
@@ -580,21 +494,12 @@ export async function getPlaceDetailsExpanded(userId: number, placeId: string, l
|
||||
photo: r.authorAttribution?.photoUri || null,
|
||||
})),
|
||||
source: 'google' as const,
|
||||
cached_at: Date.now(),
|
||||
};
|
||||
|
||||
try {
|
||||
db.prepare(
|
||||
'INSERT OR REPLACE INTO place_details_cache (place_id, lang, expanded, payload_json, fetched_at) VALUES (?, ?, 1, ?, ?)'
|
||||
).run(placeId, langKey, JSON.stringify(place), Date.now());
|
||||
} catch (dbErr) {
|
||||
console.error('Failed to cache expanded place details:', dbErr);
|
||||
}
|
||||
|
||||
return { place };
|
||||
}
|
||||
|
||||
// ── Place photo (Google or Wikimedia, disk-cached) ────────────────────────────
|
||||
// ── Place photo (Google or Wikimedia, with caching + DB persistence) ─────────
|
||||
|
||||
export async function getPlacePhoto(
|
||||
userId: number,
|
||||
@@ -603,115 +508,84 @@ export async function getPlacePhoto(
|
||||
lng: number,
|
||||
name?: string,
|
||||
): Promise<{ photoUrl: string; attribution: string | null }> {
|
||||
// Disk cache hit — serve immediately, no Google call
|
||||
const diskHit = placePhotoCache.get(placeId);
|
||||
if (diskHit) return { photoUrl: diskHit.photoUrl, attribution: diskHit.attribution };
|
||||
|
||||
// Recent error — don't hammer the API
|
||||
if (placePhotoCache.getErrored(placeId)) {
|
||||
throw Object.assign(new Error('(Cache) No photo available'), { status: 404 });
|
||||
// Check cache first
|
||||
const cached = photoCache.get(placeId);
|
||||
if (cached) {
|
||||
const ttl = cached.error ? ERROR_TTL : PHOTO_TTL;
|
||||
if (Date.now() - cached.fetchedAt < ttl) {
|
||||
if (cached.error) throw Object.assign(new Error('(Cache) No photo available'), { status: 404 });
|
||||
return { photoUrl: cached.photoUrl, attribution: cached.attribution };
|
||||
}
|
||||
photoCache.delete(placeId);
|
||||
}
|
||||
|
||||
// Deduplicate concurrent requests for the same placeId
|
||||
const existing = placePhotoCache.getInFlight(placeId);
|
||||
if (existing) {
|
||||
const result = await existing;
|
||||
if (!result) throw Object.assign(new Error('(Cache) No photo available'), { status: 404 });
|
||||
return { photoUrl: `/api/maps/place-photo/${encodeURIComponent(placeId)}/bytes`, attribution: result.attribution };
|
||||
const apiKey = getMapsKey(userId);
|
||||
const isCoordLookup = placeId.startsWith('coords:');
|
||||
|
||||
// No Google key or coordinate-only lookup -> try Wikimedia
|
||||
if (!apiKey || isCoordLookup) {
|
||||
if (!isNaN(lat) && !isNaN(lng)) {
|
||||
try {
|
||||
const wiki = await fetchWikimediaPhoto(lat, lng, name);
|
||||
if (wiki) {
|
||||
photoCache.set(placeId, { ...wiki, fetchedAt: Date.now() });
|
||||
return wiki;
|
||||
} else {
|
||||
photoCache.set(placeId, { photoUrl: '', attribution: null, fetchedAt: Date.now(), error: true });
|
||||
}
|
||||
} catch { /* fall through */ }
|
||||
}
|
||||
throw Object.assign(new Error('(Wikimedia) No photo available'), { status: 404 });
|
||||
}
|
||||
|
||||
const fetchPromise = (async (): Promise<{ filePath: string; attribution: string | null } | null> => {
|
||||
await acquirePhotoFetchSlot();
|
||||
try {
|
||||
const apiKey = getMapsKey(userId);
|
||||
const isCoordLookup = placeId.startsWith('coords:');
|
||||
// Google Photos
|
||||
const detailsRes = await fetch(`https://places.googleapis.com/v1/places/${placeId}`, {
|
||||
headers: {
|
||||
'X-Goog-Api-Key': apiKey,
|
||||
'X-Goog-FieldMask': 'photos',
|
||||
},
|
||||
});
|
||||
const details = await detailsRes.json() as GooglePlaceDetails & { error?: { message?: string } };
|
||||
|
||||
// No Google key or coordinate-only lookup → try Wikimedia (URL-based, not byte-cached)
|
||||
if (!apiKey || isCoordLookup) {
|
||||
if (!isNaN(lat) && !isNaN(lng)) {
|
||||
try {
|
||||
const wiki = await fetchWikimediaPhoto(lat, lng, name);
|
||||
if (wiki) {
|
||||
// Wikimedia photos: fetch bytes and cache to disk
|
||||
const ssrf = await checkSsrf(wiki.photoUrl, true);
|
||||
if (!ssrf.allowed) throw Object.assign(new Error('Photo URL blocked'), { status: 403 });
|
||||
const imgRes = await fetch(wiki.photoUrl);
|
||||
if (imgRes.ok) {
|
||||
const bytes = Buffer.from(await imgRes.arrayBuffer());
|
||||
const cached = await placePhotoCache.put(placeId, bytes, wiki.attribution);
|
||||
return { filePath: cached.filePath, attribution: cached.attribution };
|
||||
}
|
||||
}
|
||||
} catch { /* fall through */ }
|
||||
}
|
||||
placePhotoCache.markError(placeId);
|
||||
return null;
|
||||
}
|
||||
if (!detailsRes.ok) {
|
||||
console.error('Google Places photo details error:', details.error?.message || detailsRes.status);
|
||||
photoCache.set(placeId, { photoUrl: '', attribution: null, fetchedAt: Date.now(), error: true });
|
||||
throw Object.assign(new Error('(Google Places) Photo could not be retrieved'), { status: 404 });
|
||||
}
|
||||
|
||||
// Google Photos — fetch details to get photo name
|
||||
const detailsRes = await googleFetch(`https://places.googleapis.com/v1/places/${placeId}`, `getPlacePhoto/details(${placeId})`, {
|
||||
headers: {
|
||||
'X-Goog-Api-Key': apiKey,
|
||||
'X-Goog-FieldMask': 'photos',
|
||||
},
|
||||
});
|
||||
const details = await detailsRes.json() as GooglePlaceDetails & { error?: { message?: string } };
|
||||
if (!details.photos?.length) {
|
||||
photoCache.set(placeId, { photoUrl: '', attribution: null, fetchedAt: Date.now(), error: true });
|
||||
throw Object.assign(new Error('(Google Places) No photo available'), { status: 404 });
|
||||
}
|
||||
|
||||
if (!detailsRes.ok) {
|
||||
console.error('Google Places photo details error:', details.error?.message || detailsRes.status);
|
||||
placePhotoCache.markError(placeId);
|
||||
return null;
|
||||
}
|
||||
const photo = details.photos[0];
|
||||
const photoName = photo.name;
|
||||
const attribution = photo.authorAttributions?.[0]?.displayName || null;
|
||||
|
||||
if (!details.photos?.length) {
|
||||
placePhotoCache.markError(placeId);
|
||||
return null;
|
||||
}
|
||||
const mediaRes = await fetch(
|
||||
`https://places.googleapis.com/v1/${photoName}/media?maxHeightPx=400&skipHttpRedirect=true`,
|
||||
{ headers: { 'X-Goog-Api-Key': apiKey } }
|
||||
);
|
||||
const mediaData = await mediaRes.json() as { photoUri?: string };
|
||||
const photoUrl = mediaData.photoUri;
|
||||
|
||||
const photo = details.photos[0];
|
||||
const photoName = photo.name;
|
||||
const attribution = photo.authorAttributions?.[0]?.displayName || null;
|
||||
if (!photoUrl) {
|
||||
photoCache.set(placeId, { photoUrl: '', attribution, fetchedAt: Date.now(), error: true });
|
||||
throw Object.assign(new Error('(Google Places) Photo URL not available'), { status: 404 });
|
||||
}
|
||||
|
||||
// Fetch actual image bytes
|
||||
const mediaRes = await googleFetch(
|
||||
`https://places.googleapis.com/v1/${photoName}/media?maxHeightPx=400`,
|
||||
`getPlacePhoto/media(${placeId})`,
|
||||
{ headers: { 'X-Goog-Api-Key': apiKey } }
|
||||
);
|
||||
photoCache.set(placeId, { photoUrl, attribution, fetchedAt: Date.now() });
|
||||
|
||||
if (!mediaRes.ok) {
|
||||
placePhotoCache.markError(placeId);
|
||||
return null;
|
||||
}
|
||||
// Persist photo URL to database
|
||||
try {
|
||||
db.prepare(
|
||||
'UPDATE places SET image_url = ?, updated_at = CURRENT_TIMESTAMP WHERE google_place_id = ? AND (image_url IS NULL OR image_url = ?)'
|
||||
).run(photoUrl, placeId, '');
|
||||
} catch (dbErr) {
|
||||
console.error('Failed to persist photo URL to database:', dbErr);
|
||||
}
|
||||
|
||||
const bytes = Buffer.from(await mediaRes.arrayBuffer());
|
||||
if (!bytes.length) {
|
||||
placePhotoCache.markError(placeId);
|
||||
return null;
|
||||
}
|
||||
|
||||
const cached = await placePhotoCache.put(placeId, bytes, attribution);
|
||||
|
||||
// Persist stable proxy URL to database
|
||||
try {
|
||||
db.prepare(
|
||||
'UPDATE places SET image_url = ?, updated_at = CURRENT_TIMESTAMP WHERE google_place_id = ? AND (image_url IS NULL OR image_url = \'\')'
|
||||
).run(cached.photoUrl, placeId);
|
||||
} catch (dbErr) {
|
||||
console.error('Failed to persist photo URL to database:', dbErr);
|
||||
}
|
||||
|
||||
return { filePath: cached.filePath, attribution };
|
||||
} finally {
|
||||
releasePhotoFetchSlot();
|
||||
}
|
||||
})();
|
||||
|
||||
placePhotoCache.setInFlight(placeId, fetchPromise);
|
||||
|
||||
const result = await fetchPromise;
|
||||
if (!result) throw Object.assign(new Error('No photo available'), { status: 404 });
|
||||
return { photoUrl: `/api/maps/place-photo/${encodeURIComponent(placeId)}/bytes`, attribution: result.attribution };
|
||||
return { photoUrl, attribution };
|
||||
}
|
||||
|
||||
// ── Reverse geocoding ────────────────────────────────────────────────────────
|
||||
|
||||
@@ -230,30 +230,6 @@ export async function getAssetInfo(
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchImmichThumbnailBytes(
|
||||
userId: number,
|
||||
assetId: string,
|
||||
ownerUserId?: number
|
||||
): Promise<{ bytes: Buffer; contentType: string } | { error: string; status: number }> {
|
||||
const effectiveUserId = ownerUserId ?? userId;
|
||||
const creds = getImmichCredentials(effectiveUserId);
|
||||
if (!creds) return { error: 'Not found', status: 404 };
|
||||
|
||||
const url = `${creds.immich_url}/api/assets/${assetId}/thumbnail?size=thumbnail`;
|
||||
try {
|
||||
const resp = await safeFetch(url, {
|
||||
headers: { 'x-api-key': creds.immich_api_key },
|
||||
signal: AbortSignal.timeout(10000) as any,
|
||||
});
|
||||
if (!resp.ok) return { error: 'Upstream error', status: resp.status };
|
||||
const contentType = resp.headers.get('content-type') || 'image/jpeg';
|
||||
const bytes = Buffer.from(await resp.arrayBuffer());
|
||||
return { bytes, contentType };
|
||||
} catch {
|
||||
return { error: 'Proxy error', status: 502 };
|
||||
}
|
||||
}
|
||||
|
||||
export async function streamImmichAsset(
|
||||
response: Response,
|
||||
userId: number,
|
||||
|
||||
@@ -3,12 +3,11 @@ import path from 'path';
|
||||
import fs from 'fs';
|
||||
import { db } from '../../db/database';
|
||||
import type { TrekPhoto } from '../../types';
|
||||
import { streamImmichAsset, fetchImmichThumbnailBytes, getAssetInfo as getImmichAssetInfo } from './immichService';
|
||||
import { streamSynologyAsset, fetchSynologyThumbnailBytes, getSynologyAssetInfo } from './synologyService';
|
||||
import { streamImmichAsset, getAssetInfo as getImmichAssetInfo } from './immichService';
|
||||
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';
|
||||
import * as photoCache from './trekPhotoCache';
|
||||
|
||||
// ── Lookup / Register ────────────────────────────────────────────────────
|
||||
|
||||
@@ -23,7 +22,7 @@ export function getOrCreateTrekPhoto(
|
||||
).get(provider, assetId, ownerId) as { id: number } | undefined;
|
||||
if (existing) {
|
||||
if (passphrase) {
|
||||
db.prepare('UPDATE trek_photos SET passphrase = ? WHERE id = ?')
|
||||
db.prepare('UPDATE trek_photos SET passphrase = ? WHERE id = ? AND passphrase IS NULL')
|
||||
.run(encrypt_api_key(passphrase), existing.id);
|
||||
}
|
||||
return existing.id;
|
||||
@@ -58,36 +57,6 @@ export function resolveTrekPhoto(photoId: number): TrekPhoto | null {
|
||||
|
||||
// ── Streaming ────────────────────────────────────────────────────────────
|
||||
|
||||
async function streamCachedThumbnail(
|
||||
res: Response,
|
||||
photo: TrekPhoto,
|
||||
fetchBytes: () => Promise<{ bytes: Buffer; contentType: string } | { error: string; status: number }>,
|
||||
fallback: () => Promise<unknown>,
|
||||
): Promise<void> {
|
||||
const key = photoCache.cacheKey(photo.provider!, photo.asset_id!, 'thumbnail', photo.owner_id!);
|
||||
|
||||
if (photoCache.serveFresh(res, key)) return;
|
||||
|
||||
const existing = photoCache.getInFlight(key);
|
||||
if (existing) {
|
||||
const bytes = await existing;
|
||||
if (bytes && photoCache.serveFresh(res, key)) return;
|
||||
await fallback();
|
||||
return;
|
||||
}
|
||||
|
||||
const promise = fetchBytes().then(async result => {
|
||||
if ('error' in result) return null;
|
||||
await photoCache.put(key, result.bytes, result.contentType);
|
||||
return result.bytes;
|
||||
});
|
||||
photoCache.setInFlight(key, promise);
|
||||
|
||||
const bytes = await promise;
|
||||
if (bytes && photoCache.serveFresh(res, key)) return;
|
||||
await fallback();
|
||||
}
|
||||
|
||||
export async function streamPhoto(
|
||||
res: Response,
|
||||
userId: number,
|
||||
@@ -115,27 +84,11 @@ export async function streamPhoto(
|
||||
return;
|
||||
}
|
||||
case 'immich': {
|
||||
if (kind === 'thumbnail') {
|
||||
await streamCachedThumbnail(
|
||||
res, photo,
|
||||
() => fetchImmichThumbnailBytes(userId, photo.asset_id!, photo.owner_id!),
|
||||
() => streamImmichAsset(res, userId, photo.asset_id!, kind, photo.owner_id!),
|
||||
);
|
||||
return;
|
||||
}
|
||||
await streamImmichAsset(res, userId, photo.asset_id!, kind, photo.owner_id!);
|
||||
return;
|
||||
}
|
||||
case 'synologyphotos': {
|
||||
const passphrase = photo.passphrase ? (decrypt_api_key(photo.passphrase) || undefined) : undefined;
|
||||
if (kind === 'thumbnail') {
|
||||
await streamCachedThumbnail(
|
||||
res, photo,
|
||||
() => fetchSynologyThumbnailBytes(userId, photo.owner_id!, photo.asset_id!, passphrase),
|
||||
() => streamSynologyAsset(res, userId, photo.owner_id!, photo.asset_id!, kind, undefined, passphrase),
|
||||
);
|
||||
return;
|
||||
}
|
||||
await streamSynologyAsset(res, userId, photo.owner_id!, photo.asset_id!, kind, undefined, passphrase);
|
||||
return;
|
||||
}
|
||||
@@ -192,19 +145,6 @@ export function setTrekPhotoProvider(
|
||||
).run(provider, assetId, ownerId, trekPhotoId);
|
||||
}
|
||||
|
||||
// ── Orphan cleanup ───────────────────────────────────────────────────────
|
||||
|
||||
export function deleteTrekPhotoIfOrphan(photoId: number): void {
|
||||
const stillUsed = db.prepare(`
|
||||
SELECT 1 FROM trip_photos WHERE photo_id = ?
|
||||
UNION ALL
|
||||
SELECT 1 FROM journey_photos WHERE photo_id = ?
|
||||
LIMIT 1
|
||||
`).get(photoId, photoId);
|
||||
if (stillUsed) return;
|
||||
db.prepare("DELETE FROM trek_photos WHERE id = ? AND provider != 'local'").run(photoId);
|
||||
}
|
||||
|
||||
// ── Delete local file for a trek_photo ──────────────────────────────────
|
||||
|
||||
export function getTrekPhotoFilePath(photoId: number): string | null {
|
||||
|
||||
@@ -604,47 +604,6 @@ export async function getSynologyAssetInfo(userId: number, photoId: string, targ
|
||||
return success(normalized);
|
||||
}
|
||||
|
||||
export async function fetchSynologyThumbnailBytes(
|
||||
userId: number,
|
||||
targetUserId: number,
|
||||
photoId: string,
|
||||
passphrase?: string,
|
||||
): Promise<{ bytes: Buffer; contentType: string } | { error: string; status: number }> {
|
||||
const parsedId = _splitPackedSynologyId(photoId);
|
||||
if (!parsedId) return { error: 'Invalid photo ID format', status: 400 };
|
||||
|
||||
const synology_credentials = _getSynologyCredentials(targetUserId);
|
||||
if (!synology_credentials.success) return { error: 'Credentials error', status: 500 };
|
||||
|
||||
const sid = await _getSynologySession(targetUserId);
|
||||
if (!sid.success) return { error: 'Session error', status: 500 };
|
||||
if (!sid.data) return { error: 'Session ID missing', status: 500 };
|
||||
|
||||
const params = new URLSearchParams({
|
||||
api: 'SYNO.Foto.Thumbnail',
|
||||
method: 'get',
|
||||
version: '2',
|
||||
mode: 'download',
|
||||
id: parsedId.id,
|
||||
type: 'unit',
|
||||
size: 'sm',
|
||||
cache_key: parsedId.cacheKey,
|
||||
_sid: sid.data,
|
||||
});
|
||||
if (passphrase) params.append('passphrase', passphrase);
|
||||
|
||||
const url = _buildSynologyEndpoint(synology_credentials.data.synology_url, params.toString());
|
||||
try {
|
||||
const resp = await safeFetch(url);
|
||||
if (!resp.ok) return { error: 'Upstream error', status: resp.status };
|
||||
const contentType = resp.headers.get('content-type') || 'image/jpeg';
|
||||
const bytes = Buffer.from(await resp.arrayBuffer());
|
||||
return { bytes, contentType };
|
||||
} catch {
|
||||
return { error: 'Proxy error', status: 502 };
|
||||
}
|
||||
}
|
||||
|
||||
export async function streamSynologyAsset(
|
||||
response: Response,
|
||||
userId: number,
|
||||
|
||||
@@ -1,91 +0,0 @@
|
||||
import path from 'node:path';
|
||||
import fs from 'node:fs';
|
||||
import fsPromises from 'node:fs/promises';
|
||||
import crypto from 'node:crypto';
|
||||
import { Response } from 'express';
|
||||
import { db } from '../../db/database';
|
||||
|
||||
const TREK_PHOTO_DIR = path.join(__dirname, '../../../uploads/photos/trek');
|
||||
export const CACHE_TTL = 60 * 60 * 1000; // 1 hour
|
||||
|
||||
const inFlight = new Map<string, Promise<Buffer | null>>();
|
||||
|
||||
export function cacheKey(provider: string, assetId: string, kind: string, ownerId: number): string {
|
||||
return crypto.createHash('sha1').update(`${provider}:${assetId}:${kind}:${ownerId}`).digest('hex');
|
||||
}
|
||||
|
||||
function ensureDir(): void {
|
||||
if (!fs.existsSync(TREK_PHOTO_DIR)) {
|
||||
fs.mkdirSync(TREK_PHOTO_DIR, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
function cachedFilePath(key: string): string {
|
||||
return path.join(TREK_PHOTO_DIR, `${key}.bin`);
|
||||
}
|
||||
|
||||
export function getFresh(key: string): { filePath: string; contentType: string } | null {
|
||||
const row = db.prepare(
|
||||
'SELECT content_type, fetched_at FROM trek_photo_cache_meta WHERE cache_key = ?'
|
||||
).get(key) as { content_type: string; fetched_at: number } | undefined;
|
||||
|
||||
if (!row) return null;
|
||||
|
||||
if (Date.now() - row.fetched_at >= CACHE_TTL) {
|
||||
db.prepare('DELETE FROM trek_photo_cache_meta WHERE cache_key = ?').run(key);
|
||||
return null;
|
||||
}
|
||||
|
||||
const fp = cachedFilePath(key);
|
||||
if (!fs.existsSync(fp)) {
|
||||
db.prepare('DELETE FROM trek_photo_cache_meta WHERE cache_key = ?').run(key);
|
||||
return null;
|
||||
}
|
||||
|
||||
return { filePath: fp, contentType: row.content_type };
|
||||
}
|
||||
|
||||
export async function put(key: string, bytes: Buffer, contentType: string): Promise<void> {
|
||||
ensureDir();
|
||||
const fp = cachedFilePath(key);
|
||||
const tmp = fp + '.tmp';
|
||||
|
||||
await fsPromises.writeFile(tmp, bytes);
|
||||
await fsPromises.rename(tmp, fp);
|
||||
|
||||
db.prepare(
|
||||
'INSERT OR REPLACE INTO trek_photo_cache_meta (cache_key, content_type, fetched_at) VALUES (?, ?, ?)'
|
||||
).run(key, contentType, Date.now());
|
||||
}
|
||||
|
||||
export function serveFresh(res: Response, key: string): boolean {
|
||||
const entry = getFresh(key);
|
||||
if (!entry) return false;
|
||||
|
||||
res.set('Content-Type', entry.contentType);
|
||||
res.set('Cache-Control', 'public, max-age=3600');
|
||||
res.sendFile(entry.filePath);
|
||||
return true;
|
||||
}
|
||||
|
||||
export function getInFlight(key: string): Promise<Buffer | null> | undefined {
|
||||
return inFlight.get(key);
|
||||
}
|
||||
|
||||
export function setInFlight(key: string, promise: Promise<Buffer | null>): void {
|
||||
inFlight.set(key, promise);
|
||||
promise.finally(() => inFlight.delete(key));
|
||||
}
|
||||
|
||||
export function sweepExpired(): void {
|
||||
const cutoff = Date.now() - CACHE_TTL * 2;
|
||||
const stale = db.prepare(
|
||||
'SELECT cache_key FROM trek_photo_cache_meta WHERE fetched_at < ?'
|
||||
).all(cutoff) as { cache_key: string }[];
|
||||
|
||||
for (const row of stale) {
|
||||
db.prepare('DELETE FROM trek_photo_cache_meta WHERE cache_key = ?').run(row.cache_key);
|
||||
const fp = cachedFilePath(row.cache_key);
|
||||
if (fs.existsSync(fp)) fs.unlinkSync(fp);
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
mapDbError,
|
||||
Selection,
|
||||
} from './helpersService';
|
||||
import { getOrCreateTrekPhoto, deleteTrekPhotoIfOrphan } from './photoResolverService';
|
||||
import { getOrCreateTrekPhoto } from './photoResolverService';
|
||||
import { encrypt_api_key } from '../apiKeyCrypto';
|
||||
|
||||
|
||||
@@ -212,7 +212,6 @@ export function removeTripPhoto(
|
||||
AND photo_id = ?
|
||||
`).run(tripId, userId, photoId);
|
||||
|
||||
deleteTrekPhotoIfOrphan(photoId);
|
||||
broadcast(tripId, 'memories:updated', { userId }, sid);
|
||||
|
||||
return success(true);
|
||||
@@ -270,20 +269,13 @@ export function removeAlbumLink(tripId: string, linkId: string, userId: number):
|
||||
}
|
||||
|
||||
try {
|
||||
const linkedPhotos = db.prepare('SELECT photo_id FROM trip_photos WHERE trip_id = ? AND album_link_id = ?')
|
||||
.all(tripId, linkId) as Array<{ photo_id: number }>;
|
||||
|
||||
db.transaction(() => {
|
||||
db.prepare('DELETE FROM trip_photos WHERE trip_id = ? AND album_link_id = ?')
|
||||
.run(tripId, linkId);
|
||||
db.prepare('DELETE FROM trip_album_links WHERE id = ? AND trip_id = ? AND user_id = ?')
|
||||
.run(linkId, tripId, userId);
|
||||
})();
|
||||
|
||||
for (const { photo_id } of linkedPhotos) {
|
||||
deleteTrekPhotoIfOrphan(photo_id);
|
||||
}
|
||||
|
||||
|
||||
return success(true);
|
||||
} catch (error) {
|
||||
return mapDbError(error, 'Failed to remove album link');
|
||||
|
||||
@@ -1,108 +0,0 @@
|
||||
import path from 'node:path';
|
||||
import fs from 'node:fs';
|
||||
import fsPromises from 'node:fs/promises';
|
||||
import crypto from 'node:crypto';
|
||||
import { db } from '../db/database';
|
||||
|
||||
const GOOGLE_PHOTO_DIR = path.join(__dirname, '../../uploads/photos/google');
|
||||
const ERROR_TTL = 5 * 60 * 1000;
|
||||
|
||||
// In-flight dedup — prevents stampedes when multiple requests hit the same uncached placeId simultaneously
|
||||
const inFlight = new Map<string, Promise<{ filePath: string; attribution: string | null } | null>>();
|
||||
|
||||
// In-memory set of placeIds whose file is confirmed on disk this session.
|
||||
// Avoids a synchronous fs.existsSync() call on every cache hit after the first verification.
|
||||
const knownOnDisk = new Set<string>();
|
||||
|
||||
// Ensure upload dir exists once at startup — avoids sync FS calls inside put() on every write.
|
||||
try {
|
||||
fs.mkdirSync(GOOGLE_PHOTO_DIR, { recursive: true });
|
||||
} catch { /* already exists */ }
|
||||
|
||||
function filePath(placeId: string): string {
|
||||
// Hash to avoid filename collisions — coords:lat:lng pseudo-IDs contain characters that
|
||||
// collapse identically under sanitization (e.g. ':' and '.' both → '_')
|
||||
const hash = crypto.createHash('sha1').update(placeId).digest('hex');
|
||||
return path.join(GOOGLE_PHOTO_DIR, `${hash}.jpg`);
|
||||
}
|
||||
|
||||
function proxyUrl(placeId: string): string {
|
||||
return `/api/maps/place-photo/${encodeURIComponent(placeId)}/bytes`;
|
||||
}
|
||||
|
||||
interface CachedPhoto {
|
||||
photoUrl: string;
|
||||
filePath: string;
|
||||
attribution: string | null;
|
||||
}
|
||||
|
||||
export function get(placeId: string): CachedPhoto | null {
|
||||
const row = db.prepare(
|
||||
'SELECT attribution FROM google_place_photo_meta WHERE place_id = ? AND error_at IS NULL'
|
||||
).get(placeId) as { attribution: string | null } | undefined;
|
||||
|
||||
if (!row) return null;
|
||||
|
||||
const fp = filePath(placeId);
|
||||
|
||||
if (!knownOnDisk.has(placeId)) {
|
||||
// First time this placeId is checked this session — verify the file exists on disk.
|
||||
// (Guards against volume wipes or manual deletion between server restarts.)
|
||||
if (!fs.existsSync(fp)) {
|
||||
db.prepare('DELETE FROM google_place_photo_meta WHERE place_id = ?').run(placeId);
|
||||
return null;
|
||||
}
|
||||
knownOnDisk.add(placeId);
|
||||
}
|
||||
|
||||
return { photoUrl: proxyUrl(placeId), filePath: fp, attribution: row.attribution };
|
||||
}
|
||||
|
||||
export function getErrored(placeId: string): boolean {
|
||||
const row = db.prepare(
|
||||
'SELECT error_at FROM google_place_photo_meta WHERE place_id = ? AND error_at IS NOT NULL'
|
||||
).get(placeId) as { error_at: number } | undefined;
|
||||
|
||||
if (!row) return false;
|
||||
return Date.now() - row.error_at < ERROR_TTL;
|
||||
}
|
||||
|
||||
export function markError(placeId: string): void {
|
||||
knownOnDisk.delete(placeId);
|
||||
db.prepare(
|
||||
'INSERT OR REPLACE INTO google_place_photo_meta (place_id, attribution, fetched_at, error_at) VALUES (?, NULL, ?, ?)'
|
||||
).run(placeId, Date.now(), Date.now());
|
||||
}
|
||||
|
||||
export async function put(placeId: string, bytes: Buffer, attribution: string | null): Promise<CachedPhoto> {
|
||||
const fp = filePath(placeId);
|
||||
const tmp = fp + '.tmp';
|
||||
|
||||
await fsPromises.writeFile(tmp, bytes);
|
||||
await fsPromises.rename(tmp, fp);
|
||||
|
||||
knownOnDisk.add(placeId);
|
||||
|
||||
db.prepare(
|
||||
'INSERT OR REPLACE INTO google_place_photo_meta (place_id, attribution, fetched_at, error_at) VALUES (?, ?, ?, NULL)'
|
||||
).run(placeId, attribution, Date.now());
|
||||
|
||||
return { photoUrl: proxyUrl(placeId), filePath: fp, attribution };
|
||||
}
|
||||
|
||||
export function getInFlight(placeId: string): Promise<{ filePath: string; attribution: string | null } | null> | undefined {
|
||||
return inFlight.get(placeId);
|
||||
}
|
||||
|
||||
export function setInFlight(placeId: string, promise: Promise<{ filePath: string; attribution: string | null } | null>): void {
|
||||
inFlight.set(placeId, promise);
|
||||
promise.finally(() => inFlight.delete(placeId));
|
||||
}
|
||||
|
||||
export function serveFilePath(placeId: string): string | null {
|
||||
if (knownOnDisk.has(placeId)) return filePath(placeId);
|
||||
const fp = filePath(placeId);
|
||||
if (!fs.existsSync(fp)) return null;
|
||||
knownOnDisk.add(placeId);
|
||||
return fp;
|
||||
}
|
||||
@@ -240,22 +240,6 @@ export function deletePlace(tripId: string, placeId: string): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
export function deletePlacesMany(tripId: string, ids: number[]): number[] {
|
||||
if (ids.length === 0) return [];
|
||||
const selectStmt = db.prepare('SELECT id FROM places WHERE id = ? AND trip_id = ?');
|
||||
const deleteStmt = db.prepare('DELETE FROM places WHERE id = ?');
|
||||
const deleted: number[] = [];
|
||||
const run = db.transaction((list: number[]) => {
|
||||
for (const id of list) {
|
||||
if (!selectStmt.get(id, tripId)) continue;
|
||||
deleteStmt.run(id);
|
||||
deleted.push(id);
|
||||
}
|
||||
});
|
||||
run(ids);
|
||||
return deleted;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Import GPX
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -342,20 +326,7 @@ function trackInsertedInDedupSet(
|
||||
}
|
||||
}
|
||||
|
||||
export interface GpxImportOptions {
|
||||
importWaypoints?: boolean;
|
||||
importRoutes?: boolean;
|
||||
importTracks?: boolean;
|
||||
}
|
||||
|
||||
export interface KmlImportOptions {
|
||||
importPoints?: boolean;
|
||||
importPaths?: boolean;
|
||||
}
|
||||
|
||||
export function importGpx(tripId: string, fileBuffer: Buffer, opts: GpxImportOptions = {}) {
|
||||
const { importWaypoints = true, importRoutes = true, importTracks = true } = opts;
|
||||
|
||||
export function importGpx(tripId: string, fileBuffer: Buffer) {
|
||||
const parsed = gpxParser.parse(fileBuffer.toString('utf-8'));
|
||||
const gpx = parsed?.gpx;
|
||||
if (!gpx) return null;
|
||||
@@ -367,46 +338,41 @@ export function importGpx(tripId: string, fileBuffer: Buffer, opts: GpxImportOpt
|
||||
const waypoints: WaypointEntry[] = [];
|
||||
|
||||
// 1) Parse <wpt> elements (named waypoints / POIs)
|
||||
if (importWaypoints) {
|
||||
for (const wpt of gpx.wpt ?? []) {
|
||||
const lat = num(wpt['@_lat']);
|
||||
const lng = num(wpt['@_lon']);
|
||||
if (lat === null || lng === null) continue;
|
||||
waypoints.push({ lat, lng, name: str(wpt.name) || `Waypoint ${waypoints.length + 1}`, description: str(wpt.desc) });
|
||||
}
|
||||
for (const wpt of gpx.wpt ?? []) {
|
||||
const lat = num(wpt['@_lat']);
|
||||
const lng = num(wpt['@_lon']);
|
||||
if (lat === null || lng === null) continue;
|
||||
waypoints.push({ lat, lng, name: str(wpt.name) || `Waypoint ${waypoints.length + 1}`, description: str(wpt.desc) });
|
||||
}
|
||||
|
||||
// 2) Parse <rte> routes as polyline-places (one place per route with route_geometry)
|
||||
if (importRoutes) {
|
||||
// 2) If no <wpt>, try <rte> route points as individual places
|
||||
if (waypoints.length === 0) {
|
||||
for (const rte of gpx.rte ?? []) {
|
||||
const pts = (rte.rtept ?? [])
|
||||
.map((pt: Record<string, unknown>) => ({ lat: num(pt['@_lat']), lng: num(pt['@_lon']), ele: num(pt['ele']) }))
|
||||
.filter((p: { lat: number | null; lng: number | null; ele: number | null }) => p.lat !== null && p.lng !== null) as Array<{ lat: number; lng: number; ele: number | null }>;
|
||||
if (pts.length === 0) continue;
|
||||
const hasAllEle = pts.every(p => p.ele !== null);
|
||||
const routeGeometry = pts.map(p => hasAllEle ? [p.lat, p.lng, p.ele] : [p.lat, p.lng]);
|
||||
waypoints.push({ lat: pts[0].lat, lng: pts[0].lng, name: str(rte.name) || 'GPX Route', description: str(rte.desc), routeGeometry: JSON.stringify(routeGeometry) });
|
||||
for (const rtept of rte.rtept ?? []) {
|
||||
const lat = num(rtept['@_lat']);
|
||||
const lng = num(rtept['@_lon']);
|
||||
if (lat === null || lng === null) continue;
|
||||
waypoints.push({ lat, lng, name: str(rtept.name) || `Route Point ${waypoints.length + 1}`, description: str(rtept.desc) });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3) Extract full track geometry from <trk>
|
||||
if (importTracks) {
|
||||
for (const trk of gpx.trk ?? []) {
|
||||
const trackPoints: { lat: number; lng: number; ele: number | null }[] = [];
|
||||
for (const seg of trk.trkseg ?? []) {
|
||||
for (const pt of seg.trkpt ?? []) {
|
||||
const lat = num(pt['@_lat']);
|
||||
const lng = num(pt['@_lon']);
|
||||
if (lat === null || lng === null) continue;
|
||||
trackPoints.push({ lat, lng, ele: num(pt.ele) });
|
||||
}
|
||||
// 3) Extract full track geometry from <trk> (always, even if <wpt> were found)
|
||||
for (const trk of gpx.trk ?? []) {
|
||||
const trackPoints: { lat: number; lng: number; ele: number | null }[] = [];
|
||||
for (const seg of trk.trkseg ?? []) {
|
||||
for (const pt of seg.trkpt ?? []) {
|
||||
const lat = num(pt['@_lat']);
|
||||
const lng = num(pt['@_lon']);
|
||||
if (lat === null || lng === null) continue;
|
||||
trackPoints.push({ lat, lng, ele: num(pt.ele) });
|
||||
}
|
||||
if (trackPoints.length === 0) continue;
|
||||
const start = trackPoints[0];
|
||||
const hasAllEle = trackPoints.every(p => p.ele !== null);
|
||||
const routeGeometry = trackPoints.map(p => hasAllEle ? [p.lat, p.lng, p.ele] : [p.lat, p.lng]);
|
||||
waypoints.push({ lat: start.lat, lng: start.lng, name: str(trk.name) || 'GPX Track', description: str(trk.desc), routeGeometry: JSON.stringify(routeGeometry) });
|
||||
}
|
||||
if (trackPoints.length === 0) continue;
|
||||
const start = trackPoints[0];
|
||||
const hasAllEle = trackPoints.every(p => p.ele !== null);
|
||||
const routeGeometry = trackPoints.map(p => hasAllEle ? [p.lat, p.lng, p.ele] : [p.lat, p.lng]);
|
||||
waypoints.push({ lat: start.lat, lng: start.lng, name: str(trk.name) || 'GPX Track', description: str(trk.desc), routeGeometry: JSON.stringify(routeGeometry) });
|
||||
}
|
||||
|
||||
if (waypoints.length === 0) return null;
|
||||
@@ -435,8 +401,7 @@ export function importGpx(tripId: string, fileBuffer: Buffer, opts: GpxImportOpt
|
||||
return { places: created, count: created.length, skipped };
|
||||
}
|
||||
|
||||
export function importKmlPlaces(tripId: string, fileBuffer: Buffer, opts: KmlImportOptions = {}): PlaceImportResult {
|
||||
const { importPoints = true, importPaths = true } = opts;
|
||||
export function importKmlPlaces(tripId: string, fileBuffer: Buffer): PlaceImportResult {
|
||||
const decoded = decodeUtf8WithWarning(fileBuffer);
|
||||
|
||||
const validationResult = XMLValidator.validate(decoded.text);
|
||||
@@ -465,32 +430,19 @@ export function importKmlPlaces(tripId: string, fileBuffer: Buffer, opts: KmlImp
|
||||
let dupCount = 0;
|
||||
|
||||
const insertStmt = db.prepare(`
|
||||
INSERT INTO places (trip_id, name, description, lat, lng, category_id, transport_mode, route_geometry)
|
||||
VALUES (?, ?, ?, ?, ?, ?, 'walking', ?)
|
||||
INSERT INTO places (trip_id, name, description, lat, lng, category_id, transport_mode)
|
||||
VALUES (?, ?, ?, ?, ?, ?, 'walking')
|
||||
`);
|
||||
|
||||
const insertAll = db.transaction(() => {
|
||||
let fallbackIndex = 1;
|
||||
for (const node of placemarkNodes) {
|
||||
const parsedPlacemark = parsePlacemarkNode(node);
|
||||
const isPath = parsedPlacemark.routeGeometry !== null;
|
||||
|
||||
// Unsupported geometry type (polygon, multi-geometry, no geometry, etc.)
|
||||
// KML geometry support is intentionally limited to <Placemark><Point> coordinates.
|
||||
if (parsedPlacemark.lat === null || parsedPlacemark.lng === null) {
|
||||
summary.skippedCount += 1;
|
||||
summary.errors.push(`Skipped Placemark ${fallbackIndex}: unsupported geometry type.`);
|
||||
fallbackIndex += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Type filtering: respect importPoints / importPaths opts
|
||||
if (isPath && !importPaths) {
|
||||
summary.skippedCount += 1;
|
||||
fallbackIndex += 1;
|
||||
continue;
|
||||
}
|
||||
if (!isPath && !importPoints) {
|
||||
summary.skippedCount += 1;
|
||||
summary.errors.push(`Skipped Placemark ${fallbackIndex}: missing Point coordinates.`);
|
||||
fallbackIndex += 1;
|
||||
continue;
|
||||
}
|
||||
@@ -514,7 +466,6 @@ export function importKmlPlaces(tripId: string, fileBuffer: Buffer, opts: KmlImp
|
||||
parsedPlacemark.lat,
|
||||
parsedPlacemark.lng,
|
||||
categoryId,
|
||||
parsedPlacemark.routeGeometry,
|
||||
);
|
||||
|
||||
const place = getPlaceWithTags(Number(result.lastInsertRowid));
|
||||
@@ -563,15 +514,15 @@ export async function unpackKmzToKml(
|
||||
return preferredEntry.buffer();
|
||||
}
|
||||
|
||||
export async function importKmzPlaces(tripId: string, kmzBuffer: Buffer, opts: KmlImportOptions = {}): Promise<PlaceImportResult> {
|
||||
export async function importKmzPlaces(tripId: string, kmzBuffer: Buffer): Promise<PlaceImportResult> {
|
||||
const kmlBuffer = await unpackKmzToKml(kmzBuffer);
|
||||
return importKmlPlaces(tripId, kmlBuffer, opts);
|
||||
return importKmlPlaces(tripId, kmlBuffer);
|
||||
}
|
||||
|
||||
export async function importMapFile(tripId: string, fileBuffer: Buffer, filename: string, opts: KmlImportOptions = {}): Promise<PlaceImportResult> {
|
||||
export async function importMapFile(tripId: string, fileBuffer: Buffer, filename: string): Promise<PlaceImportResult> {
|
||||
const ext = filename.toLowerCase().split('.').pop();
|
||||
if (ext === 'kmz') return importKmzPlaces(tripId, fileBuffer, opts);
|
||||
if (ext === 'kml') return importKmlPlaces(tripId, fileBuffer, opts);
|
||||
if (ext === 'kmz') return importKmzPlaces(tripId, fileBuffer);
|
||||
if (ext === 'kml') return importKmlPlaces(tripId, fileBuffer);
|
||||
throw new Error(`Unsupported map file format: .${ext}. Please upload a .kml or .kmz file.`);
|
||||
}
|
||||
|
||||
|
||||
@@ -123,7 +123,6 @@ interface CreateReservationData {
|
||||
confirmation_number?: string;
|
||||
notes?: string;
|
||||
day_id?: number;
|
||||
end_day_id?: number;
|
||||
place_id?: number;
|
||||
assignment_id?: number;
|
||||
status?: string;
|
||||
@@ -138,7 +137,7 @@ interface CreateReservationData {
|
||||
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, end_day_id, place_id, assignment_id,
|
||||
confirmation_number, notes, day_id, place_id, assignment_id,
|
||||
status, type, accommodation_id, metadata, create_accommodation,
|
||||
endpoints, needs_review
|
||||
} = data;
|
||||
@@ -159,12 +158,11 @@ export function createReservation(tripId: string | number, data: CreateReservati
|
||||
}
|
||||
|
||||
const result = db.prepare(`
|
||||
INSERT INTO reservations (trip_id, day_id, end_day_id, place_id, assignment_id, title, reservation_time, reservation_end_time, location, confirmation_number, notes, status, type, accommodation_id, metadata, needs_review)
|
||||
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,
|
||||
end_day_id ?? null,
|
||||
place_id || null,
|
||||
assignment_id || null,
|
||||
title,
|
||||
@@ -244,7 +242,6 @@ interface UpdateReservationData {
|
||||
confirmation_number?: string;
|
||||
notes?: string;
|
||||
day_id?: number;
|
||||
end_day_id?: number | null;
|
||||
place_id?: number;
|
||||
assignment_id?: number;
|
||||
status?: string;
|
||||
@@ -259,7 +256,7 @@ interface UpdateReservationData {
|
||||
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, end_day_id, place_id, assignment_id,
|
||||
confirmation_number, notes, day_id, place_id, assignment_id,
|
||||
status, type, accommodation_id, metadata, create_accommodation,
|
||||
endpoints, needs_review
|
||||
} = data;
|
||||
@@ -297,7 +294,6 @@ export function updateReservation(id: string | number, tripId: string | number,
|
||||
confirmation_number = ?,
|
||||
notes = ?,
|
||||
day_id = ?,
|
||||
end_day_id = ?,
|
||||
place_id = ?,
|
||||
assignment_id = ?,
|
||||
status = COALESCE(?, status),
|
||||
@@ -314,7 +310,6 @@ export function updateReservation(id: string | number, tripId: string | number,
|
||||
confirmation_number !== undefined ? (confirmation_number || null) : current.confirmation_number,
|
||||
notes !== undefined ? (notes || null) : current.notes,
|
||||
day_id !== undefined ? (day_id || null) : current.day_id,
|
||||
end_day_id !== undefined ? (end_day_id ?? null) : (current as any).end_day_id ?? null,
|
||||
place_id !== undefined ? (place_id || null) : current.place_id,
|
||||
assignment_id !== undefined ? (assignment_id || null) : current.assignment_id,
|
||||
status || null,
|
||||
|
||||
@@ -95,7 +95,6 @@ const WMO_DESCRIPTION_EN: Record<number, string> = {
|
||||
// ── Cache management ────────────────────────────────────────────────────
|
||||
|
||||
const weatherCache = new Map<string, { data: WeatherResult; expiresAt: number }>();
|
||||
const inFlight = new Map<string, Promise<WeatherResult>>();
|
||||
const CACHE_MAX_ENTRIES = 1000;
|
||||
const CACHE_PRUNE_TARGET = 500;
|
||||
const CACHE_CLEANUP_INTERVAL = 5 * 60 * 1000; // 5 minutes
|
||||
@@ -147,7 +146,7 @@ export function estimateCondition(tempAvg: number, precipMm: number): string {
|
||||
|
||||
// ── getWeather ──────────────────────────────────────────────────────────
|
||||
|
||||
async function _getWeatherImpl(
|
||||
export async function getWeather(
|
||||
lat: string,
|
||||
lng: string,
|
||||
date: string | undefined,
|
||||
@@ -282,27 +281,9 @@ async function _getWeatherImpl(
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function getWeather(
|
||||
lat: string,
|
||||
lng: string,
|
||||
date: string | undefined,
|
||||
lang: string,
|
||||
): Promise<WeatherResult> {
|
||||
const ck = cacheKey(lat, lng, date);
|
||||
const cached = getCached(ck);
|
||||
if (cached) return cached;
|
||||
|
||||
const inFlightKey = `${ck}:${lang}`;
|
||||
const existing = inFlight.get(inFlightKey);
|
||||
if (existing) return existing;
|
||||
const promise = _getWeatherImpl(lat, lng, date, lang);
|
||||
inFlight.set(inFlightKey, promise);
|
||||
try { return await promise; } finally { inFlight.delete(inFlightKey); }
|
||||
}
|
||||
|
||||
// ── getDetailedWeather ──────────────────────────────────────────────────
|
||||
|
||||
async function _getDetailedWeatherImpl(
|
||||
export async function getDetailedWeather(
|
||||
lat: string,
|
||||
lng: string,
|
||||
date: string,
|
||||
@@ -453,24 +434,6 @@ async function _getDetailedWeatherImpl(
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function getDetailedWeather(
|
||||
lat: string,
|
||||
lng: string,
|
||||
date: string,
|
||||
lang: string,
|
||||
): Promise<WeatherResult> {
|
||||
const ck = `detailed_${cacheKey(lat, lng, date)}`;
|
||||
const cached = getCached(ck);
|
||||
if (cached) return cached;
|
||||
|
||||
const inFlightKey = `${ck}:${lang}`;
|
||||
const existing = inFlight.get(inFlightKey);
|
||||
if (existing) return existing;
|
||||
const promise = _getDetailedWeatherImpl(lat, lng, date, lang);
|
||||
inFlight.set(inFlightKey, promise);
|
||||
try { return await promise; } finally { inFlight.delete(inFlightKey); }
|
||||
}
|
||||
|
||||
// ── ApiError ────────────────────────────────────────────────────────────
|
||||
|
||||
export class ApiError extends Error {
|
||||
|
||||
@@ -27,8 +27,6 @@ export const SYSTEM_NOTICES: SystemNotice[] = [
|
||||
conditions: [{ kind: 'existingUserBeforeVersion', version: '3.0.0' }],
|
||||
publishedAt: '2026-04-16T00:00:00Z',
|
||||
priority: 90,
|
||||
minVersion: '3.0.0',
|
||||
maxVersion: '4.0.0',
|
||||
},
|
||||
|
||||
{
|
||||
@@ -57,8 +55,6 @@ export const SYSTEM_NOTICES: SystemNotice[] = [
|
||||
],
|
||||
publishedAt: '2026-04-16T00:00:00Z',
|
||||
priority: 80,
|
||||
minVersion: '3.0.0',
|
||||
maxVersion: '4.0.0',
|
||||
},
|
||||
|
||||
{
|
||||
@@ -82,8 +78,6 @@ export const SYSTEM_NOTICES: SystemNotice[] = [
|
||||
],
|
||||
publishedAt: '2026-04-16T00:00:00Z',
|
||||
priority: 75,
|
||||
minVersion: '3.0.0',
|
||||
maxVersion: '4.0.0',
|
||||
},
|
||||
|
||||
{
|
||||
@@ -104,8 +98,6 @@ export const SYSTEM_NOTICES: SystemNotice[] = [
|
||||
conditions: [{ kind: 'existingUserBeforeVersion', version: '3.0.0' }],
|
||||
publishedAt: '2026-04-16T00:00:00Z',
|
||||
priority: 70,
|
||||
minVersion: '3.0.0',
|
||||
maxVersion: '4.0.0',
|
||||
},
|
||||
|
||||
{
|
||||
@@ -120,8 +112,6 @@ export const SYSTEM_NOTICES: SystemNotice[] = [
|
||||
conditions: [{ kind: 'existingUserBeforeVersion', version: '3.0.0' }],
|
||||
publishedAt: '2026-04-16T00:00:00Z',
|
||||
priority: 95,
|
||||
minVersion: '3.0.0',
|
||||
maxVersion: '4.0.0',
|
||||
},
|
||||
|
||||
// ── Onboarding ─────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -3,7 +3,7 @@ import semver from 'semver';
|
||||
import { db } from '../db/database.js';
|
||||
import { SYSTEM_NOTICES } from './registry.js';
|
||||
import { evaluate } from './conditions.js';
|
||||
import type { SystemNotice, SystemNoticeDTO } from './types.js';
|
||||
import type { SystemNoticeDTO } from './types.js';
|
||||
|
||||
function getCurrentAppVersion(): string {
|
||||
const fromEnv = semver.valid(process.env.APP_VERSION ?? '');
|
||||
@@ -16,21 +16,6 @@ function getCurrentAppVersion(): string {
|
||||
}
|
||||
}
|
||||
|
||||
export function isNoticeVersionActive(n: SystemNotice, currentAppVersion: string): boolean {
|
||||
const appVersion = semver.coerce(currentAppVersion)?.version ?? '0.0.0';
|
||||
if (n.minVersion !== undefined) {
|
||||
const min = semver.valid(n.minVersion);
|
||||
if (!min) { console.warn(`[systemNotices] "${n.id}" invalid minVersion "${n.minVersion}" — skipping`); return false; }
|
||||
if (semver.lt(appVersion, min)) return false;
|
||||
}
|
||||
if (n.maxVersion !== undefined) {
|
||||
const max = semver.valid(n.maxVersion);
|
||||
if (!max) { console.warn(`[systemNotices] "${n.id}" invalid maxVersion "${n.maxVersion}" — skipping`); return false; }
|
||||
if (semver.gte(appVersion, max)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function severityWeight(s: string): number {
|
||||
return s === 'critical' ? 2 : s === 'warn' ? 1 : 0;
|
||||
}
|
||||
@@ -59,7 +44,7 @@ export function getActiveNoticesFor(userId: number): SystemNoticeDTO[] {
|
||||
return SYSTEM_NOTICES
|
||||
.filter(n => {
|
||||
if (dismissedIds.has(n.id)) return false;
|
||||
if (!isNoticeVersionActive(n, currentAppVersion)) return false;
|
||||
if (n.expiresAt && now > new Date(n.expiresAt)) return false;
|
||||
return evaluate(n, ctx);
|
||||
})
|
||||
.sort((a, b) => {
|
||||
@@ -69,7 +54,7 @@ export function getActiveNoticesFor(userId: number): SystemNoticeDTO[] {
|
||||
if (sw !== 0) return sw;
|
||||
return new Date(b.publishedAt).getTime() - new Date(a.publishedAt).getTime();
|
||||
})
|
||||
.map(({ conditions: _c, publishedAt: _p, minVersion: _mn, maxVersion: _mx, priority: _pr, ...dto }) => dto);
|
||||
.map(({ conditions: _c, publishedAt: _p, expiresAt: _e, priority: _pr, ...dto }) => dto);
|
||||
}
|
||||
|
||||
export function dismissNotice(userId: number, noticeId: string): boolean {
|
||||
|
||||
@@ -37,10 +37,9 @@ export interface SystemNotice {
|
||||
dismissible: boolean;
|
||||
conditions: NoticeCondition[];
|
||||
publishedAt: string;
|
||||
minVersion?: string;
|
||||
maxVersion?: string;
|
||||
expiresAt?: string;
|
||||
priority?: number;
|
||||
}
|
||||
|
||||
// DTO sent to client (same shape minus the conditions — server evaluates those)
|
||||
export type SystemNoticeDTO = Omit<SystemNotice, 'conditions' | 'publishedAt' | 'minVersion' | 'maxVersion' | 'priority'>;
|
||||
export type SystemNoticeDTO = Omit<SystemNotice, 'conditions' | 'publishedAt' | 'expiresAt' | 'priority'>;
|
||||
|
||||
@@ -157,7 +157,6 @@ export interface Reservation {
|
||||
id: number;
|
||||
trip_id: number;
|
||||
day_id?: number | null;
|
||||
end_day_id?: number | null;
|
||||
place_id?: number | null;
|
||||
assignment_id?: number | null;
|
||||
title: string;
|
||||
|
||||
Vendored
-17773
File diff suppressed because it is too large
Load Diff
Vendored
-3181
File diff suppressed because it is too large
Load Diff
Vendored
BIN
Binary file not shown.
@@ -1169,74 +1169,3 @@ describe('Synology SSRF blocked error handling', () => {
|
||||
expect(res.body.albums.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Passphrase persistence fixes ─────────────────────────────────────────────
|
||||
|
||||
import { getOrCreateTrekPhoto, deleteTrekPhotoIfOrphan } from '../../src/services/memories/photoResolverService';
|
||||
import { decrypt_api_key } from '../../src/services/apiKeyCrypto';
|
||||
|
||||
describe('trek_photos passphrase healing (SYNO-090)', () => {
|
||||
it('SYNO-090 — getOrCreateTrekPhoto overwrites an existing bad passphrase when a new one is supplied', () => {
|
||||
const { user } = createUser(testDb);
|
||||
|
||||
const wrongPass = 'wrong-passphrase';
|
||||
const correctPass = 'correct-passphrase';
|
||||
|
||||
const id1 = getOrCreateTrekPhoto('synologyphotos', 'asset-heal-test', user.id, wrongPass);
|
||||
const row1 = testDb.prepare('SELECT passphrase FROM trek_photos WHERE id = ?').get(id1) as { passphrase: string };
|
||||
expect(decrypt_api_key(row1.passphrase)).toBe(wrongPass);
|
||||
|
||||
const id2 = getOrCreateTrekPhoto('synologyphotos', 'asset-heal-test', user.id, correctPass);
|
||||
expect(id2).toBe(id1);
|
||||
const row2 = testDb.prepare('SELECT passphrase FROM trek_photos WHERE id = ?').get(id2) as { passphrase: string };
|
||||
expect(decrypt_api_key(row2.passphrase)).toBe(correctPass);
|
||||
});
|
||||
});
|
||||
|
||||
describe('trek_photos orphan cleanup (SYNO-091)', () => {
|
||||
it('SYNO-091 — deleteTrekPhotoIfOrphan removes the trek_photos row when no trip_photos or journey_photos reference it', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
testDb.prepare("UPDATE photo_providers SET enabled = 1 WHERE id = 'synologyphotos'").run();
|
||||
|
||||
const trekPhotoId = getOrCreateTrekPhoto('synologyphotos', 'asset-orphan-test', user.id, 'pass-A');
|
||||
|
||||
testDb.prepare(
|
||||
'INSERT OR IGNORE INTO trip_photos (trip_id, user_id, photo_id, shared) VALUES (?, ?, ?, 1)'
|
||||
).run(trip.id, user.id, trekPhotoId);
|
||||
|
||||
// Still referenced — must not be deleted.
|
||||
deleteTrekPhotoIfOrphan(trekPhotoId);
|
||||
expect(testDb.prepare('SELECT id FROM trek_photos WHERE id = ?').get(trekPhotoId)).toBeDefined();
|
||||
|
||||
// Remove the reference, then orphan-cleanup should delete the trek_photos row.
|
||||
testDb.prepare('DELETE FROM trip_photos WHERE photo_id = ?').run(trekPhotoId);
|
||||
deleteTrekPhotoIfOrphan(trekPhotoId);
|
||||
expect(testDb.prepare('SELECT id FROM trek_photos WHERE id = ?').get(trekPhotoId)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('SYNO-092 — re-adding a previously removed Synology photo stores the new passphrase correctly', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
testDb.prepare("UPDATE photo_providers SET enabled = 1 WHERE id = 'synologyphotos'").run();
|
||||
|
||||
const firstPass = 'first-passphrase';
|
||||
const secondPass = 'second-passphrase';
|
||||
|
||||
// Add with wrong passphrase, then remove (simulating the bug scenario).
|
||||
const id1 = getOrCreateTrekPhoto('synologyphotos', 'asset-readd-test', user.id, firstPass);
|
||||
testDb.prepare(
|
||||
'INSERT OR IGNORE INTO trip_photos (trip_id, user_id, photo_id, shared) VALUES (?, ?, ?, 1)'
|
||||
).run(trip.id, user.id, id1);
|
||||
testDb.prepare('DELETE FROM trip_photos WHERE photo_id = ?').run(id1);
|
||||
deleteTrekPhotoIfOrphan(id1);
|
||||
|
||||
// trek_photos row should be gone.
|
||||
expect(testDb.prepare('SELECT id FROM trek_photos WHERE id = ?').get(id1)).toBeUndefined();
|
||||
|
||||
// Re-add with the correct passphrase.
|
||||
const id2 = getOrCreateTrekPhoto('synologyphotos', 'asset-readd-test', user.id, secondPass);
|
||||
const row = testDb.prepare('SELECT passphrase FROM trek_photos WHERE id = ?').get(id2) as { passphrase: string };
|
||||
expect(decrypt_api_key(row.passphrase)).toBe(secondPass);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -771,7 +771,7 @@ describe('KML/KMZ Import', () => {
|
||||
expect(res.body.summary.totalPlacemarks).toBe(3);
|
||||
expect(res.body.summary.skippedCount).toBe(1);
|
||||
expect(Array.isArray(res.body.summary.errors)).toBe(true);
|
||||
expect(res.body.summary.errors.join(' ')).toContain('unsupported geometry type');
|
||||
expect(res.body.summary.errors.join(' ')).toContain('missing Point coordinates');
|
||||
|
||||
const nested = res.body.places.find((p: any) => p.name === 'Nested Place');
|
||||
expect(nested).toBeDefined();
|
||||
@@ -862,7 +862,7 @@ describe('GPX Import — edge cases', () => {
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.attach('file', emptyGpx, { filename: 'empty.gpx', contentType: 'application/gpx+xml' });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toMatch(/no matching places/i);
|
||||
expect(res.body.error).toMatch(/no waypoints/i);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -107,11 +107,9 @@ describe('GET /api/system-notices/active', () => {
|
||||
// welcome-v1 is also in the registry and matches firstLogin, so at least TEST_NOTICE is present
|
||||
const testNotice = res.body.find((n: { id: string }) => n.id === TEST_NOTICE.id);
|
||||
expect(testNotice).toBeDefined();
|
||||
// DTO should not expose conditions, publishedAt, minVersion, maxVersion, priority
|
||||
// DTO should not expose conditions, publishedAt, expiresAt, priority
|
||||
expect(testNotice.conditions).toBeUndefined();
|
||||
expect(testNotice.publishedAt).toBeUndefined();
|
||||
expect(testNotice.minVersion).toBeUndefined();
|
||||
expect(testNotice.maxVersion).toBeUndefined();
|
||||
} finally {
|
||||
const idx = SYSTEM_NOTICES.indexOf(TEST_NOTICE);
|
||||
if (idx !== -1) SYSTEM_NOTICES.splice(idx, 1);
|
||||
|
||||
@@ -8,19 +8,10 @@
|
||||
*/
|
||||
import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest';
|
||||
|
||||
const { mockDbGet, mockDbRun, mockCheckSsrf, mockCacheGet, mockCacheGetErrored, mockCachePut, mockCacheGetInFlight, mockCacheSetInFlight } = vi.hoisted(() => ({
|
||||
const { mockDbGet, mockDbRun, mockCheckSsrf } = vi.hoisted(() => ({
|
||||
mockDbGet: vi.fn(() => undefined as any),
|
||||
mockDbRun: vi.fn(),
|
||||
mockCheckSsrf: vi.fn(async () => ({ allowed: true })),
|
||||
mockCacheGet: vi.fn(() => null as any),
|
||||
mockCacheGetErrored: vi.fn(() => false),
|
||||
mockCachePut: vi.fn(async (placeId: string, _bytes: Buffer, attribution: string | null) => ({
|
||||
photoUrl: `/api/maps/place-photo/${encodeURIComponent(placeId)}/bytes`,
|
||||
filePath: `/tmp/${placeId}.jpg`,
|
||||
attribution,
|
||||
})),
|
||||
mockCacheGetInFlight: vi.fn(() => undefined),
|
||||
mockCacheSetInFlight: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../../src/db/database', () => ({
|
||||
@@ -42,16 +33,6 @@ vi.mock('../../../src/config', () => ({
|
||||
ENCRYPTION_KEY: '0'.repeat(64),
|
||||
}));
|
||||
|
||||
vi.mock('../../../src/services/placePhotoCache', () => ({
|
||||
get: (placeId: string) => mockCacheGet(placeId),
|
||||
getErrored: (placeId: string) => mockCacheGetErrored(placeId),
|
||||
put: (placeId: string, bytes: Buffer, attribution: string | null) => mockCachePut(placeId, bytes, attribution),
|
||||
markError: vi.fn(),
|
||||
getInFlight: (placeId: string) => mockCacheGetInFlight(placeId),
|
||||
setInFlight: (placeId: string, p: Promise<any>) => mockCacheSetInFlight(placeId, p),
|
||||
serveFilePath: vi.fn(() => null),
|
||||
}));
|
||||
|
||||
import {
|
||||
parseOpeningHours,
|
||||
buildOsmDetails,
|
||||
@@ -65,19 +46,6 @@ afterEach(() => {
|
||||
mockDbRun.mockReset();
|
||||
mockCheckSsrf.mockReset();
|
||||
mockCheckSsrf.mockResolvedValue({ allowed: true });
|
||||
mockCacheGet.mockReset();
|
||||
mockCacheGet.mockReturnValue(null);
|
||||
mockCacheGetErrored.mockReset();
|
||||
mockCacheGetErrored.mockReturnValue(false);
|
||||
mockCachePut.mockReset();
|
||||
mockCachePut.mockImplementation(async (placeId: string, _bytes: Buffer, attribution: string | null) => ({
|
||||
photoUrl: `/api/maps/place-photo/${encodeURIComponent(placeId)}/bytes`,
|
||||
filePath: `/tmp/${placeId}.jpg`,
|
||||
attribution,
|
||||
}));
|
||||
mockCacheGetInFlight.mockReset();
|
||||
mockCacheGetInFlight.mockReturnValue(undefined);
|
||||
mockCacheSetInFlight.mockReset();
|
||||
});
|
||||
|
||||
// ── parseOpeningHours ─────────────────────────────────────────────────────────
|
||||
@@ -1027,9 +995,11 @@ describe('getPlaceDetails (fetch stubbed)', () => {
|
||||
expect(place.rating_count).toBe(200000);
|
||||
expect(place.open_now).toBe(true);
|
||||
expect(place.source).toBe('google');
|
||||
// Lean mask — reviews/summary not fetched in getPlaceDetails; use getPlaceDetailsExpanded for those
|
||||
expect(place.reviews).toHaveLength(0);
|
||||
expect(place.summary).toBeNull();
|
||||
expect(place.reviews).toHaveLength(1);
|
||||
expect(place.reviews[0].author).toBe('John');
|
||||
expect(place.reviews[0].rating).toBe(5);
|
||||
expect(place.reviews[0].text).toBe('Amazing!');
|
||||
expect(place.reviews[0].photo).toBe('https://photo.url');
|
||||
});
|
||||
|
||||
it('MAPS-041c: throws with status when Google API returns non-ok response', async () => {
|
||||
@@ -1046,10 +1016,8 @@ describe('getPlaceDetails (fetch stubbed)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('MAPS-041d: getPlaceDetailsExpanded maps reviews with optional fields absent to null', async () => {
|
||||
it('MAPS-041d: maps reviews with optional fields absent to null', async () => {
|
||||
mockDbGet.mockReturnValueOnce({ maps_api_key: 'gkey' });
|
||||
// expanded=1 cache miss → return undefined
|
||||
mockDbGet.mockReturnValueOnce(undefined);
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
@@ -1060,8 +1028,8 @@ describe('getPlaceDetails (fetch stubbed)', () => {
|
||||
],
|
||||
}),
|
||||
}));
|
||||
const { getPlaceDetailsExpanded } = await import('../../../src/services/mapsService');
|
||||
const result = await getPlaceDetailsExpanded(1, 'ChIJ456');
|
||||
const { getPlaceDetails } = await import('../../../src/services/mapsService');
|
||||
const result = await getPlaceDetails(1, 'ChIJ456');
|
||||
const review = (result.place as any).reviews[0];
|
||||
expect(review.author).toBeNull();
|
||||
expect(review.rating).toBeNull();
|
||||
@@ -1136,10 +1104,8 @@ describe('getPlaceDetails (fetch stubbed)', () => {
|
||||
expect((result.place as any).open_now).toBe(false);
|
||||
});
|
||||
|
||||
it('MAPS-041g: getPlaceDetailsExpanded truncates reviews to first 5 entries', async () => {
|
||||
it('MAPS-041g: truncates reviews to first 5 entries', async () => {
|
||||
mockDbGet.mockReturnValueOnce({ maps_api_key: 'gkey' });
|
||||
// expanded=1 cache miss
|
||||
mockDbGet.mockReturnValueOnce(undefined);
|
||||
const manyReviews = Array.from({ length: 8 }, (_, i) => ({
|
||||
authorAttribution: { displayName: `User${i}` },
|
||||
rating: 4,
|
||||
@@ -1150,8 +1116,8 @@ describe('getPlaceDetails (fetch stubbed)', () => {
|
||||
ok: true,
|
||||
json: async () => ({ id: 'ChIJMany', reviews: manyReviews }),
|
||||
}));
|
||||
const { getPlaceDetailsExpanded } = await import('../../../src/services/mapsService');
|
||||
const result = await getPlaceDetailsExpanded(1, 'ChIJMany');
|
||||
const { getPlaceDetails } = await import('../../../src/services/mapsService');
|
||||
const result = await getPlaceDetails(1, 'ChIJMany');
|
||||
expect((result.place as any).reviews).toHaveLength(5);
|
||||
});
|
||||
});
|
||||
@@ -1159,26 +1125,16 @@ describe('getPlaceDetails (fetch stubbed)', () => {
|
||||
// ── getPlacePhoto (fetch stubbed) ────────────────────────────────────────────
|
||||
|
||||
describe('getPlacePhoto (fetch stubbed)', () => {
|
||||
it('MAPS-042: returns proxy URL for coordinate-based lookup via Wikimedia (no API key)', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn()
|
||||
// First call: Wikimedia Commons API
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
query: { pages: { '1': { thumbnail: { source: 'https://wiki.org/photo.jpg' } } } },
|
||||
}),
|
||||
})
|
||||
// Second call: fetch Wikimedia image bytes
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
arrayBuffer: async () => new ArrayBuffer(100),
|
||||
})
|
||||
);
|
||||
it('MAPS-042: returns Wikimedia photo for coordinate-based lookup (no API key)', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
query: { pages: { '1': { thumbnail: { source: 'https://wiki.org/photo.jpg' } } } },
|
||||
}),
|
||||
}));
|
||||
const { getPlacePhoto } = await import('../../../src/services/mapsService');
|
||||
const placeId = 'coords:48.8,2.3';
|
||||
const result = await getPlacePhoto(999, placeId, 48.8, 2.3, 'Eiffel Tower');
|
||||
expect(result.photoUrl).toBe(`/api/maps/place-photo/${encodeURIComponent(placeId)}/bytes`);
|
||||
expect(mockCachePut).toHaveBeenCalledOnce();
|
||||
const result = await getPlacePhoto(999, 'coords:48.8,2.3', 48.8, 2.3, 'Eiffel Tower');
|
||||
expect(result.photoUrl).toBe('https://wiki.org/photo.jpg');
|
||||
});
|
||||
|
||||
it('MAPS-043: throws 404 when Wikimedia returns nothing and no API key', async () => {
|
||||
@@ -1190,28 +1146,37 @@ describe('getPlacePhoto (fetch stubbed)', () => {
|
||||
await expect(getPlacePhoto(999, 'coords:0.0,0.0', 0, 0)).rejects.toMatchObject({ status: 404 });
|
||||
});
|
||||
|
||||
it('MAPS-043b: returns cached photo when disk cache returns a hit', async () => {
|
||||
const placeId = `coords:cache-test-${Date.now()}`;
|
||||
const cachedUrl = `/api/maps/place-photo/${encodeURIComponent(placeId)}/bytes`;
|
||||
mockCacheGet.mockReturnValue({
|
||||
photoUrl: cachedUrl,
|
||||
filePath: `/tmp/${placeId}.jpg`,
|
||||
attribution: null,
|
||||
});
|
||||
it('MAPS-043b: returns cached photo when cache entry is fresh and valid', async () => {
|
||||
// First call populates cache; second call should use cache without fetching
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
query: { pages: { '1': { thumbnail: { source: 'https://wiki.org/cached.jpg' } } } },
|
||||
}),
|
||||
}));
|
||||
const { getPlacePhoto } = await import('../../../src/services/mapsService');
|
||||
const uniqueId = `coords:cache-test-${Date.now()}`;
|
||||
const first = await getPlacePhoto(999, uniqueId, 48.8, 2.3, 'Cache Test');
|
||||
const fetchMock = vi.fn();
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
const { getPlacePhoto } = await import('../../../src/services/mapsService');
|
||||
const result = await getPlacePhoto(999, placeId, 48.8, 2.3, 'Cache Test');
|
||||
expect(result.photoUrl).toBe(cachedUrl);
|
||||
const second = await getPlacePhoto(999, uniqueId, 48.8, 2.3, 'Cache Test');
|
||||
expect(second.photoUrl).toBe(first.photoUrl);
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('MAPS-043c: throws 404 from error cache without making a network request', async () => {
|
||||
mockCacheGetErrored.mockReturnValue(true);
|
||||
const fetchMock = vi.fn();
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
it('MAPS-043c: throws 404 from cache when cached entry is an error', async () => {
|
||||
// Seed the cache with an error entry by triggering a no-result Wikimedia call
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ query: { pages: {} } }),
|
||||
}));
|
||||
const { getPlacePhoto } = await import('../../../src/services/mapsService');
|
||||
const errorId = `coords:error-cache-${Date.now()}`;
|
||||
// First call causes error to be cached
|
||||
await expect(getPlacePhoto(999, errorId, 0, 0)).rejects.toMatchObject({ status: 404 });
|
||||
// Second call should throw directly from cache (no fetch)
|
||||
const fetchMock = vi.fn();
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
await expect(getPlacePhoto(999, errorId, 0, 0)).rejects.toMatchObject({ status: 404 });
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -1229,7 +1194,7 @@ describe('getPlacePhoto (fetch stubbed)', () => {
|
||||
await expect(getPlacePhoto(999, throwId, 48.8, 2.3, 'Place')).rejects.toMatchObject({ status: 404 });
|
||||
});
|
||||
|
||||
it('MAPS-044: returns proxy URL via Google path when API key present and photos exist', async () => {
|
||||
it('MAPS-044: returns photo via Google path when API key present and photos exist', async () => {
|
||||
mockDbGet.mockReturnValueOnce({ maps_api_key: 'gkey' });
|
||||
const fetchMock = vi.fn()
|
||||
// First call: get place details (with photos)
|
||||
@@ -1239,18 +1204,17 @@ describe('getPlacePhoto (fetch stubbed)', () => {
|
||||
photos: [{ name: 'places/ChIJABC/photos/photo1', authorAttributions: [{ displayName: 'Photographer' }] }],
|
||||
}),
|
||||
})
|
||||
// Second call: fetch image bytes
|
||||
// Second call: get media URL
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
arrayBuffer: async () => new ArrayBuffer(200),
|
||||
json: async () => ({ photoUri: 'https://lh3.googleusercontent.com/photo.jpg' }),
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
const { getPlacePhoto } = await import('../../../src/services/mapsService');
|
||||
const uniqueId = `ChIJABC-${Date.now()}`;
|
||||
const result = await getPlacePhoto(1, uniqueId, 48.8, 2.3, 'Place');
|
||||
expect(result.photoUrl).toBe(`/api/maps/place-photo/${encodeURIComponent(uniqueId)}/bytes`);
|
||||
expect(result.photoUrl).toBe('https://lh3.googleusercontent.com/photo.jpg');
|
||||
expect(result.attribution).toBe('Photographer');
|
||||
expect(mockCachePut).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('MAPS-044b: throws 404 when Google details fetch returns non-ok', async () => {
|
||||
@@ -1276,7 +1240,7 @@ describe('getPlacePhoto (fetch stubbed)', () => {
|
||||
await expect(getPlacePhoto(1, noPhotoId, 48.8, 2.3)).rejects.toMatchObject({ status: 404 });
|
||||
});
|
||||
|
||||
it('MAPS-044d: throws 404 when media endpoint returns non-ok status', async () => {
|
||||
it('MAPS-044d: throws 404 when media endpoint returns no photoUri', async () => {
|
||||
mockDbGet.mockReturnValueOnce({ maps_api_key: 'gkey' });
|
||||
const fetchMock = vi.fn()
|
||||
.mockResolvedValueOnce({
|
||||
@@ -1286,9 +1250,8 @@ describe('getPlacePhoto (fetch stubbed)', () => {
|
||||
}),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 403,
|
||||
arrayBuffer: async () => new ArrayBuffer(0),
|
||||
ok: true,
|
||||
json: async () => ({}), // no photoUri
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
const { getPlacePhoto } = await import('../../../src/services/mapsService');
|
||||
@@ -1296,7 +1259,7 @@ describe('getPlacePhoto (fetch stubbed)', () => {
|
||||
await expect(getPlacePhoto(1, noUriId, 48.8, 2.3)).rejects.toMatchObject({ status: 404 });
|
||||
});
|
||||
|
||||
it('MAPS-044e: returns proxy URL with null attribution when authorAttributions is empty', async () => {
|
||||
it('MAPS-044e: returns photo with null attribution when authorAttributions is empty', async () => {
|
||||
mockDbGet.mockReturnValueOnce({ maps_api_key: 'gkey' });
|
||||
const fetchMock = vi.fn()
|
||||
.mockResolvedValueOnce({
|
||||
@@ -1307,34 +1270,28 @@ describe('getPlacePhoto (fetch stubbed)', () => {
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
arrayBuffer: async () => new ArrayBuffer(150),
|
||||
json: async () => ({ photoUri: 'https://lh3.googleusercontent.com/noattr.jpg' }),
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
const { getPlacePhoto } = await import('../../../src/services/mapsService');
|
||||
const noAttrId = `ChIJNoAttr-${Date.now()}`;
|
||||
const result = await getPlacePhoto(1, noAttrId, 48.8, 2.3);
|
||||
expect(result.photoUrl).toBe(`/api/maps/place-photo/${encodeURIComponent(noAttrId)}/bytes`);
|
||||
expect(result.photoUrl).toBe('https://lh3.googleusercontent.com/noattr.jpg');
|
||||
expect(result.attribution).toBeNull();
|
||||
});
|
||||
|
||||
it('MAPS-044f: uses Wikimedia and returns proxy URL when API key present but placeId is coords: prefix', async () => {
|
||||
it('MAPS-044f: uses Wikimedia when API key present but placeId is coords: prefix', async () => {
|
||||
mockDbGet.mockReturnValueOnce({ maps_api_key: 'gkey' });
|
||||
vi.stubGlobal('fetch', vi.fn()
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
query: { pages: { '1': { thumbnail: { source: 'https://wiki.org/coords-photo.jpg' } } } },
|
||||
}),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
arrayBuffer: async () => new ArrayBuffer(120),
|
||||
})
|
||||
);
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
query: { pages: { '1': { thumbnail: { source: 'https://wiki.org/coords-photo.jpg' } } } },
|
||||
}),
|
||||
}));
|
||||
const { getPlacePhoto } = await import('../../../src/services/mapsService');
|
||||
// Use a unique placeId to avoid hitting the in-memory cache from other tests
|
||||
const uniqueId = `coords:44f-test-${Date.now()}`;
|
||||
const result = await getPlacePhoto(1, uniqueId, 48.8, 2.3, 'Coords Place');
|
||||
expect(result.photoUrl).toBe(`/api/maps/place-photo/${encodeURIComponent(uniqueId)}/bytes`);
|
||||
expect(mockCachePut).toHaveBeenCalledOnce();
|
||||
expect(result.photoUrl).toBe('https://wiki.org/coords-photo.jpg');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -277,24 +277,19 @@ describe('importGpx', () => {
|
||||
expect(result.places[1].name).toBe('London');
|
||||
});
|
||||
|
||||
it('PLACE-SVC-022 — imports <rte> as a single polyline-place with routeGeometry', () => {
|
||||
it('PLACE-SVC-022 — falls back to <rte> route points when no <wpt> elements exist', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const gpx = Buffer.from(`<?xml version="1.0"?><gpx version="1.1">
|
||||
<rte>
|
||||
<name>My Route</name>
|
||||
<rtept lat="48.8566" lon="2.3522"><name>Start</name></rtept>
|
||||
<rtept lat="51.5074" lon="-0.1278"><name>End</name></rtept>
|
||||
</rte>
|
||||
</gpx>`);
|
||||
const result = importGpx(String(trip.id), gpx) as any;
|
||||
expect(result.places).toHaveLength(1);
|
||||
expect(result.places[0].name).toBe('My Route');
|
||||
expect(result.places[0].lat).toBe(48.8566);
|
||||
expect(result.places[0].lng).toBe(2.3522);
|
||||
expect(result.places[0].route_geometry).toBeTruthy();
|
||||
const coords = JSON.parse(result.places[0].route_geometry);
|
||||
expect(coords).toHaveLength(2);
|
||||
expect(result.places).toHaveLength(2);
|
||||
expect(result.places[0].name).toBe('Start');
|
||||
expect(result.places[1].name).toBe('End');
|
||||
});
|
||||
|
||||
it('PLACE-SVC-023 — imports <trk> track as a single place with routeGeometry', () => {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import semver from 'semver';
|
||||
import { SYSTEM_NOTICES } from '../../../src/systemNotices/registry.js';
|
||||
|
||||
/** Collect all actionIds registered via registerNoticeAction() in client source files. */
|
||||
@@ -46,21 +45,4 @@ describe('registry integrity', () => {
|
||||
expect(() => new Date(n.publishedAt).toISOString()).not.toThrow();
|
||||
}
|
||||
});
|
||||
|
||||
it('minVersion and maxVersion are valid semver when set, and minVersion <= maxVersion when both set', () => {
|
||||
for (const n of SYSTEM_NOTICES) {
|
||||
if (n.minVersion !== undefined) {
|
||||
expect(semver.valid(n.minVersion), `notice "${n.id}" has invalid minVersion "${n.minVersion}"`).not.toBeNull();
|
||||
}
|
||||
if (n.maxVersion !== undefined) {
|
||||
expect(semver.valid(n.maxVersion), `notice "${n.id}" has invalid maxVersion "${n.maxVersion}"`).not.toBeNull();
|
||||
}
|
||||
if (n.minVersion && n.maxVersion) {
|
||||
expect(
|
||||
semver.lte(n.minVersion, n.maxVersion),
|
||||
`notice "${n.id}": minVersion ${n.minVersion} > maxVersion ${n.maxVersion}`
|
||||
).toBe(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user