mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-19 13:21:46 +00:00
Merge remote-tracking branch 'origin/dev' into dev-maurice
# Conflicts: # client/src/components/Todo/TodoListPanel.tsx # server/src/db/migrations.ts
This commit is contained in:
+5
-2
@@ -100,7 +100,7 @@ function RootRedirect() {
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const { loadUser, isAuthenticated, demoMode, setDemoMode, setDevMode, setIsPrerelease, setAppVersion, setHasMapsKey, setServerTimezone, setAppRequireMfa, setTripRemindersEnabled } = useAuthStore()
|
||||
const { loadUser, isAuthenticated, demoMode, setDemoMode, setDevMode, setIsPrerelease, setAppVersion, setHasMapsKey, setServerTimezone, setAppRequireMfa, setTripRemindersEnabled, setPlacesPhotosEnabled, setPlacesAutocompleteEnabled, setPlacesDetailsEnabled } = 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; 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; places_photos_enabled?: boolean; places_autocomplete_enabled?: boolean; places_details_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,6 +125,9 @@ 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) {
|
||||
|
||||
@@ -272,6 +272,12 @@ 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),
|
||||
@@ -331,8 +337,8 @@ export const journeyApi = {
|
||||
|
||||
// Photos
|
||||
uploadPhotos: (entryId: number, formData: FormData) => apiClient.post(`/journeys/entries/${entryId}/photos`, formData, { headers: { 'Content-Type': undefined as any } }).then(r => r.data),
|
||||
addProviderPhoto: (entryId: number, provider: string, assetId: string, caption?: string) => apiClient.post(`/journeys/entries/${entryId}/provider-photos`, { provider, asset_id: assetId, caption }).then(r => r.data),
|
||||
addProviderPhotos: (entryId: number, provider: string, assetIds: string[], caption?: string) => apiClient.post(`/journeys/entries/${entryId}/provider-photos`, { provider, asset_ids: assetIds, caption }).then(r => r.data),
|
||||
addProviderPhoto: (entryId: number, provider: string, assetId: string, caption?: string, passphrase?: string) => apiClient.post(`/journeys/entries/${entryId}/provider-photos`, { provider, asset_id: assetId, caption, ...(passphrase ? { passphrase } : {}) }).then(r => r.data),
|
||||
addProviderPhotos: (entryId: number, provider: string, assetIds: string[], caption?: string, passphrase?: string) => apiClient.post(`/journeys/entries/${entryId}/provider-photos`, { provider, asset_ids: assetIds, caption, ...(passphrase ? { passphrase } : {}) }).then(r => r.data),
|
||||
linkPhoto: (entryId: number, photoId: number) => apiClient.post(`/journeys/entries/${entryId}/link-photo`, { photo_id: photoId }).then(r => r.data),
|
||||
updatePhoto: (photoId: number, data: Record<string, unknown>) => apiClient.patch(`/journeys/photos/${photoId}`, data).then(r => r.data),
|
||||
deletePhoto: (photoId: number) => apiClient.delete(`/journeys/photos/${photoId}`).then(r => r.data),
|
||||
|
||||
@@ -94,7 +94,7 @@ function ImageLightbox({ files, initialIndex, onClose }: ImageLightboxProps) {
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.92)', zIndex: 2000, display: 'flex', flexDirection: 'column' }}
|
||||
style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.92)', zIndex: 2000, display: 'flex', flexDirection: 'column', paddingBottom: 'var(--bottom-nav-h)' }}
|
||||
onClick={onClose}
|
||||
onTouchStart={e => setTouchStart(e.touches[0].clientX)}
|
||||
onTouchEnd={e => {
|
||||
|
||||
@@ -14,6 +14,7 @@ export interface MapMarkerItem {
|
||||
export interface JourneyMapHandle {
|
||||
highlightMarker: (id: string | null) => void
|
||||
focusMarker: (id: string) => void
|
||||
invalidateSize: () => void
|
||||
}
|
||||
|
||||
interface MapEntry {
|
||||
@@ -151,7 +152,11 @@ const JourneyMap = forwardRef<JourneyMapHandle, Props>(function JourneyMap(
|
||||
}
|
||||
}, [])
|
||||
|
||||
useImperativeHandle(ref, () => ({ highlightMarker, focusMarker }), [])
|
||||
const invalidateSize = useCallback(() => {
|
||||
try { mapRef.current?.invalidateSize() } catch { /* map not yet initialized */ }
|
||||
}, [])
|
||||
|
||||
useImperativeHandle(ref, () => ({ highlightMarker, focusMarker, invalidateSize }), [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return
|
||||
|
||||
@@ -69,6 +69,7 @@ export default function PhotoLightbox({ photos, startIndex = 0, onClose }: Props
|
||||
position: 'fixed', inset: 0, zIndex: 500,
|
||||
background: 'rgba(0,0,0,0.92)', backdropFilter: 'blur(20px)',
|
||||
display: 'flex', flexDirection: 'column',
|
||||
paddingBottom: 'var(--bottom-nav-h)',
|
||||
}}
|
||||
onTouchStart={onTouchStart}
|
||||
onTouchEnd={onTouchEnd}
|
||||
|
||||
@@ -68,9 +68,9 @@ function createPlaceIcon(place, orderNumbers, isSelected) {
|
||||
">${label}</span>`
|
||||
}
|
||||
|
||||
// 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:')) {
|
||||
// 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/'))) {
|
||||
const imgIcon = L.divIcon({
|
||||
className: '',
|
||||
html: `<div style="
|
||||
@@ -277,6 +277,7 @@ 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() {
|
||||
@@ -409,20 +410,19 @@ export const MapView = memo(function MapView({
|
||||
|
||||
// photoUrls: only base64 thumbs for smooth map zoom
|
||||
const [photoUrls, setPhotoUrls] = useState<Record<string, string>>(getAllThumbs)
|
||||
const placesPhotosEnabled = useAuthStore(s => s.placesPhotosEnabled)
|
||||
|
||||
// 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) return
|
||||
if (!places || places.length === 0 || !placesPhotosEnabled) return
|
||||
const cleanups: (() => void)[] = []
|
||||
|
||||
const setThumb = (cacheKey: string, thumb: string) => {
|
||||
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
|
||||
|
||||
@@ -435,9 +435,9 @@ export const MapView = memo(function MapView({
|
||||
// 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.google_place_id || place.osm_id
|
||||
// Use the persisted proxy URL as photoId so photoService generates a base64 thumb from it
|
||||
const photoId = place.image_url || 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)
|
||||
}
|
||||
@@ -445,7 +445,7 @@ export const MapView = memo(function MapView({
|
||||
}
|
||||
|
||||
return () => cleanups.forEach(fn => fn())
|
||||
}, [placeIds])
|
||||
}, [placeIds, placesPhotosEnabled])
|
||||
|
||||
const clusterIconCreateFunction = useCallback((cluster) => {
|
||||
const count = cluster.getChildCount()
|
||||
@@ -462,7 +462,7 @@ export const MapView = memo(function MapView({
|
||||
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 resolvedPhoto = (pck && photoUrls[pck]) || (place.image_url?.startsWith('data:') ? place.image_url : null) || null
|
||||
const resolvedPhoto = (pck && photoUrls[pck]) || place.image_url || null
|
||||
const orderNumbers = dayOrderMap[place.id] ?? null
|
||||
const icon = createPlaceIcon({ ...place, image_url: resolvedPhoto }, orderNumbers, isSelected)
|
||||
|
||||
|
||||
@@ -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'
|
||||
iframe.sandbox = 'allow-same-origin allow-modals allow-scripts'
|
||||
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'
|
||||
iframe.sandbox = 'allow-same-origin allow-modals allow-scripts'
|
||||
iframe.srcdoc = html
|
||||
|
||||
card.appendChild(header)
|
||||
|
||||
@@ -1267,7 +1267,7 @@ export default function PackingListPanel({ tripId, items, openImportSignal = 0 }
|
||||
|
||||
{/* ── Bag Modal (mobile + click) ── */}
|
||||
{showBagModal && bagTrackingEnabled && (
|
||||
<div style={{ position: 'fixed', inset: 0, zIndex: 100, background: 'rgba(0,0,0,0.3)', display: 'flex', alignItems: 'flex-start', justifyContent: 'center', padding: 20, paddingTop: 140, overflowY: 'auto' }}
|
||||
<div style={{ position: 'fixed', inset: 0, zIndex: 100, background: 'rgba(0,0,0,0.3)', display: 'flex', alignItems: 'flex-start', justifyContent: 'center', padding: 20, paddingTop: 140, paddingBottom: 'calc(20px + var(--bottom-nav-h))', overflowY: 'auto' }}
|
||||
onClick={() => setShowBagModal(false)}>
|
||||
<div style={{ background: 'var(--bg-card)', borderRadius: 16, width: '100%', maxWidth: 360, maxHeight: 'calc(100vh - 80px)', overflow: 'auto', padding: 20, boxShadow: '0 16px 48px rgba(0,0,0,0.15)', flexShrink: 0 }}
|
||||
onClick={e => e.stopPropagation()}>
|
||||
|
||||
@@ -79,6 +79,7 @@ export function PhotoLightbox({ photos, initialIndex, onClose, onUpdate, onDelet
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 bg-black/95 flex items-center justify-center"
|
||||
style={{ paddingBottom: 'var(--bottom-nav-h)' }}
|
||||
onClick={onClose}
|
||||
>
|
||||
{/* Main area */}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { flushSync } from 'react-dom';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Info, AlertTriangle, AlertOctagon, X, ChevronLeft, ChevronRight } from 'lucide-react';
|
||||
import * as LucideIcons from 'lucide-react';
|
||||
@@ -70,159 +71,168 @@ function NoticeContent({ notice, title, body, ctaLabel, titleId, bodyId, isDark,
|
||||
: DefaultIcon;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col relative">
|
||||
<div className="flex flex-col relative" style={{ flex: '1 1 0', minHeight: '100%' }}>
|
||||
{/* Dismiss X button — only on last page so users read all notices */}
|
||||
{notice.dismissible && isLastPage && (
|
||||
<button
|
||||
onClick={onDismissAll}
|
||||
className="absolute top-4 right-4 p-2 rounded-lg text-slate-400 hover:text-slate-600 dark:hover:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors"
|
||||
className="absolute top-4 right-4 z-10 p-2 rounded-lg text-slate-400 hover:text-slate-600 dark:hover:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors"
|
||||
aria-label="Dismiss"
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Hero image (not inline) */}
|
||||
{notice.media && notice.media.placement !== 'inline' && (
|
||||
<div
|
||||
className="w-full overflow-hidden"
|
||||
style={{ aspectRatio: notice.media.aspectRatio ?? '16/9' }}
|
||||
>
|
||||
<img
|
||||
src={isDark && notice.media.srcDark ? notice.media.srcDark : notice.media.src}
|
||||
alt={t(notice.media.altKey)}
|
||||
className="w-full h-full object-cover"
|
||||
fetchPriority="high"
|
||||
decoding="async"
|
||||
onError={e => { (e.target as HTMLImageElement).style.display = 'none'; }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Special warm header for Heart icon (thank-you notice) */}
|
||||
{notice.icon === 'Heart' && !notice.media && (
|
||||
<div className="relative overflow-hidden bg-gradient-to-br from-rose-500 via-pink-500 to-indigo-500 px-8 py-5 text-center">
|
||||
<div className="absolute inset-0 opacity-10" style={{ backgroundImage: 'radial-gradient(circle at 20% 50%, white 1px, transparent 1px), radial-gradient(circle at 80% 20%, white 1px, transparent 1px), radial-gradient(circle at 60% 80%, white 1px, transparent 1px)', backgroundSize: '60px 60px, 80px 80px, 40px 40px' }} />
|
||||
<div className="relative flex items-center justify-center gap-3">
|
||||
<div className="w-10 h-10 rounded-full bg-white/20 backdrop-blur-sm flex items-center justify-center ring-2 ring-white/10">
|
||||
<LucideIcon size={20} className="text-white" />
|
||||
</div>
|
||||
<div className="text-left">
|
||||
<h2 id={titleId} className="text-lg font-bold text-white leading-tight">{title}</h2>
|
||||
<p className="text-xs text-white/60 font-medium">TREK 3.0</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={notice.icon === 'Heart' && !notice.media ? 'px-8 py-6' : 'p-8'}>
|
||||
{/* Severity icon (when no hero and not Heart) */}
|
||||
{!notice.media && notice.icon !== 'Heart' && (
|
||||
<div className={`w-16 h-16 rounded-full flex items-center justify-center mx-auto mb-4 ${SEVERITY_ACCENT[notice.severity] ?? ''}`}>
|
||||
<LucideIcon size={28} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Title (not for Heart — rendered in gradient header) */}
|
||||
{(notice.icon !== 'Heart' || notice.media) && (
|
||||
<h2
|
||||
id={titleId}
|
||||
className="text-xl font-semibold text-center text-slate-900 dark:text-slate-100 mb-3"
|
||||
>
|
||||
{title}
|
||||
</h2>
|
||||
)}
|
||||
|
||||
{/* Body — markdown (long body text uses left-aligned layout) */}
|
||||
<div
|
||||
id={bodyId}
|
||||
className="text-sm leading-relaxed text-slate-600 dark:text-slate-400 mx-auto mb-4 text-center"
|
||||
>
|
||||
<React.Suspense fallback={<p className="text-sm text-slate-500">{body}</p>}>
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
rehypePlugins={[rehypeSanitize]}
|
||||
components={{
|
||||
a: ({ href, children }) => (
|
||||
<a
|
||||
href={href}
|
||||
className="text-indigo-600 dark:text-indigo-400 underline decoration-indigo-300 dark:decoration-indigo-700 hover:decoration-indigo-500 dark:hover:decoration-indigo-400 underline-offset-2 transition-colors"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
p: ({ children }) => {
|
||||
// Signature line styling (e.g. "— Maurice")
|
||||
const text = typeof children === 'string' ? children : Array.isArray(children) ? children.find(c => typeof c === 'string') : '';
|
||||
if (typeof text === 'string' && text.trim().startsWith('—') && text.trim().length < 30) {
|
||||
return <p className="mt-4 mb-3 text-base font-semibold text-slate-800 dark:text-slate-200 italic">{children}</p>;
|
||||
}
|
||||
return <p className="mb-3 last:mb-0">{children}</p>;
|
||||
},
|
||||
hr: () => (
|
||||
<div className="my-5 flex items-center gap-3">
|
||||
<div className="flex-1 border-t border-slate-200 dark:border-slate-700" />
|
||||
<span className="text-slate-300 dark:text-slate-600 text-xs">♡</span>
|
||||
<div className="flex-1 border-t border-slate-200 dark:border-slate-700" />
|
||||
</div>
|
||||
),
|
||||
strong: ({ children }) => <strong className="font-semibold text-slate-800 dark:text-slate-200">{children}</strong>,
|
||||
ul: ({ children }) => <ul className="list-disc list-inside text-left">{children}</ul>,
|
||||
ol: ({ children }) => <ol className="list-decimal list-inside text-left">{children}</ol>,
|
||||
}}
|
||||
>
|
||||
{body}
|
||||
</ReactMarkdown>
|
||||
</React.Suspense>
|
||||
</div>
|
||||
|
||||
{/* Inline image */}
|
||||
{notice.media?.placement === 'inline' && (
|
||||
{/* Scrollable content — vertically centered when shorter than available space */}
|
||||
<div className="flex flex-col justify-center" style={{ flex: '1 1 0' }}>
|
||||
{/* Hero image (not inline) */}
|
||||
{notice.media && notice.media.placement !== 'inline' && (
|
||||
<div
|
||||
className="w-full overflow-hidden rounded-lg mb-4 mx-auto"
|
||||
className="w-full overflow-hidden"
|
||||
style={{ aspectRatio: notice.media.aspectRatio ?? '16/9' }}
|
||||
>
|
||||
<img
|
||||
src={isDark && notice.media.srcDark ? notice.media.srcDark : notice.media.src}
|
||||
alt={t(notice.media.altKey)}
|
||||
className="w-full h-full object-cover"
|
||||
fetchPriority="high"
|
||||
decoding="async"
|
||||
onError={e => { (e.target as HTMLImageElement).style.display = 'none'; }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Highlights */}
|
||||
{notice.highlights && notice.highlights.length > 0 && (
|
||||
<ul className="mx-auto mb-4 space-y-2">
|
||||
{notice.highlights.map((h, i) => {
|
||||
const HIcon: React.ElementType | null = h.iconName
|
||||
? ((LucideIcons as Record<string, unknown>)[h.iconName] as React.ElementType) ?? null
|
||||
: null;
|
||||
return (
|
||||
<li key={i} className="flex items-center gap-2 text-sm text-slate-700 dark:text-slate-300">
|
||||
{HIcon
|
||||
? <HIcon size={16} className="text-blue-500 shrink-0" />
|
||||
: <span className="text-blue-500 shrink-0">✓</span>
|
||||
}
|
||||
{t(h.labelKey)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
{/* Special warm header for Heart icon (thank-you notice) */}
|
||||
{notice.icon === 'Heart' && !notice.media && (
|
||||
<div className="relative overflow-hidden bg-gradient-to-br from-rose-500 via-pink-500 to-indigo-500 px-8 py-5 text-center">
|
||||
<div className="absolute inset-0 opacity-10" style={{ backgroundImage: 'radial-gradient(circle at 20% 50%, white 1px, transparent 1px), radial-gradient(circle at 80% 20%, white 1px, transparent 1px), radial-gradient(circle at 60% 80%, white 1px, transparent 1px)', backgroundSize: '60px 60px, 80px 80px, 40px 40px' }} />
|
||||
<div className="relative flex items-center justify-center gap-3">
|
||||
<div className="w-10 h-10 rounded-full bg-white/20 backdrop-blur-sm flex items-center justify-center ring-2 ring-white/10">
|
||||
<LucideIcon size={20} className="text-white" />
|
||||
</div>
|
||||
<div className="text-left">
|
||||
<h2 id={titleId} className="text-lg font-bold text-white leading-tight">{title}</h2>
|
||||
<p className="text-xs text-white/60 font-medium">TREK 3.0</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={`${notice.icon === 'Heart' && !notice.media ? 'px-8 py-6' : 'p-8'} flex flex-col`}>
|
||||
{/* Severity icon (when no hero and not Heart) */}
|
||||
{!notice.media && notice.icon !== 'Heart' && (
|
||||
<div className={`w-16 h-16 rounded-full flex items-center justify-center mx-auto mb-4 ${SEVERITY_ACCENT[notice.severity] ?? ''}`}>
|
||||
<LucideIcon size={28} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Title (not for Heart — rendered in gradient header) */}
|
||||
{(notice.icon !== 'Heart' || notice.media) && (
|
||||
<h2
|
||||
id={titleId}
|
||||
className="text-xl font-semibold text-center text-slate-900 dark:text-slate-100 mb-3"
|
||||
>
|
||||
{title}
|
||||
</h2>
|
||||
)}
|
||||
|
||||
{/* Body — markdown */}
|
||||
<div
|
||||
id={bodyId}
|
||||
className="text-sm leading-relaxed text-slate-600 dark:text-slate-400 mx-auto mb-4 text-center"
|
||||
>
|
||||
<React.Suspense fallback={<p className="text-sm text-slate-500">{body}</p>}>
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
rehypePlugins={[rehypeSanitize]}
|
||||
components={{
|
||||
a: ({ href, children }) => (
|
||||
<a
|
||||
href={href}
|
||||
className="text-indigo-600 dark:text-indigo-400 underline decoration-indigo-300 dark:decoration-indigo-700 hover:decoration-indigo-500 dark:hover:decoration-indigo-400 underline-offset-2 transition-colors"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
p: ({ children }) => {
|
||||
// Signature line styling (e.g. "— Maurice")
|
||||
const text = typeof children === 'string' ? children : Array.isArray(children) ? children.find(c => typeof c === 'string') : '';
|
||||
if (typeof text === 'string' && text.trim().startsWith('—') && text.trim().length < 30) {
|
||||
return <p className="mt-4 mb-3 text-base font-semibold text-slate-800 dark:text-slate-200 italic">{children}</p>;
|
||||
}
|
||||
return <p className="mb-3 last:mb-0">{children}</p>;
|
||||
},
|
||||
hr: () => (
|
||||
<div className="my-5 flex items-center gap-3">
|
||||
<div className="flex-1 border-t border-slate-200 dark:border-slate-700" />
|
||||
<span className="text-slate-300 dark:text-slate-600 text-xs">♡</span>
|
||||
<div className="flex-1 border-t border-slate-200 dark:border-slate-700" />
|
||||
</div>
|
||||
),
|
||||
strong: ({ children }) => <strong className="font-semibold text-slate-800 dark:text-slate-200">{children}</strong>,
|
||||
ul: ({ children }) => <ul className="list-disc list-inside text-left">{children}</ul>,
|
||||
ol: ({ children }) => <ol className="list-decimal list-inside text-left">{children}</ol>,
|
||||
}}
|
||||
>
|
||||
{body}
|
||||
</ReactMarkdown>
|
||||
</React.Suspense>
|
||||
</div>
|
||||
|
||||
{/* Inline image */}
|
||||
{notice.media?.placement === 'inline' && (
|
||||
<div
|
||||
className="w-full overflow-hidden rounded-lg mb-4 mx-auto"
|
||||
style={{ aspectRatio: notice.media.aspectRatio ?? '16/9' }}
|
||||
>
|
||||
<img
|
||||
src={isDark && notice.media.srcDark ? notice.media.srcDark : notice.media.src}
|
||||
alt={t(notice.media.altKey)}
|
||||
className="w-full h-full object-cover"
|
||||
decoding="async"
|
||||
onError={e => { (e.target as HTMLImageElement).style.display = 'none'; }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Highlights */}
|
||||
{notice.highlights && notice.highlights.length > 0 && (
|
||||
<ul className="mx-auto mb-4 space-y-2">
|
||||
{notice.highlights.map((h, i) => {
|
||||
const HIcon: React.ElementType | null = h.iconName
|
||||
? ((LucideIcons as Record<string, unknown>)[h.iconName] as React.ElementType) ?? null
|
||||
: null;
|
||||
return (
|
||||
<li key={i} className="flex items-center gap-2 text-sm text-slate-700 dark:text-slate-300">
|
||||
{HIcon
|
||||
? <HIcon size={16} className="text-blue-500 shrink-0" />
|
||||
: <span className="text-blue-500 shrink-0">✓</span>
|
||||
}
|
||||
{t(h.labelKey)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sticky footer — pager + CTA, always anchored at the bottom of the slot */}
|
||||
<div
|
||||
className="sticky bottom-0 px-8 pt-4 flex flex-col gap-3 bg-white dark:bg-slate-900 border-t border-slate-100 dark:border-slate-800"
|
||||
style={{ paddingBottom: 'calc(var(--bottom-nav-h) + 1rem)' }}
|
||||
>
|
||||
{/* Pager — dots, arrows, counter (only when multiple notices) */}
|
||||
{total > 1 && (
|
||||
<div className="flex flex-col items-center gap-1 mb-4">
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={onPrev}
|
||||
disabled={!canPage || currentPage === 0}
|
||||
aria-label={t('system_notice.pager.prev')}
|
||||
className="p-1 rounded text-slate-400 hover:text-slate-600 dark:hover:text-slate-300 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
|
||||
className="px-2 py-1 rounded border border-slate-200 dark:border-slate-700 text-slate-500 hover:text-slate-700 dark:hover:text-slate-200 hover:bg-slate-100 dark:hover:bg-slate-800 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
<ChevronLeft size={14} />
|
||||
</button>
|
||||
@@ -246,7 +256,7 @@ function NoticeContent({ notice, title, body, ctaLabel, titleId, bodyId, isDark,
|
||||
onClick={onNext}
|
||||
disabled={!canPage || currentPage === total - 1}
|
||||
aria-label={t('system_notice.pager.next')}
|
||||
className="p-1 rounded text-slate-400 hover:text-slate-600 dark:hover:text-slate-300 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
|
||||
className="px-2 py-1 rounded border border-slate-200 dark:border-slate-700 text-slate-500 hover:text-slate-700 dark:hover:text-slate-200 hover:bg-slate-100 dark:hover:bg-slate-800 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
<ChevronRight size={14} />
|
||||
</button>
|
||||
@@ -261,17 +271,8 @@ function NoticeContent({ notice, title, body, ctaLabel, titleId, bodyId, isDark,
|
||||
)}
|
||||
|
||||
{/* CTA + dismiss link */}
|
||||
<div className="flex flex-col items-center gap-3 mt-2">
|
||||
{!isLastPage && total > 1 ? (
|
||||
/* Non-last page: "Next" button to advance through all notices */
|
||||
<button
|
||||
id={`notice-cta-${notice.id}`}
|
||||
onClick={onNext}
|
||||
className="w-full h-11 rounded-lg bg-blue-600 hover:bg-blue-700 text-white font-medium transition-colors flex items-center justify-center gap-2"
|
||||
>
|
||||
{t('system_notice.pager.next')} <ChevronRight size={16} />
|
||||
</button>
|
||||
) : ctaLabel ? (
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
{ctaLabel && isLastPage ? (
|
||||
<button
|
||||
id={`notice-cta-${notice.id}`}
|
||||
onClick={onCTA}
|
||||
@@ -279,10 +280,10 @@ function NoticeContent({ notice, title, body, ctaLabel, titleId, bodyId, isDark,
|
||||
>
|
||||
{ctaLabel}
|
||||
</button>
|
||||
) : (
|
||||
) : (notice.dismissible || isLastPage) && (
|
||||
<button
|
||||
id={`notice-cta-${notice.id}`}
|
||||
onClick={onDismissAll}
|
||||
onClick={isLastPage ? onDismissAll : onNext}
|
||||
className="w-full h-11 rounded-lg bg-blue-600 hover:bg-blue-700 text-white font-medium transition-colors"
|
||||
>
|
||||
{t('common.ok')}
|
||||
@@ -327,7 +328,13 @@ export function ModalRenderer({ notices }: Props) {
|
||||
// Non-dismissible notices lock the pager so users must act before advancing.
|
||||
const canPage = notice?.dismissible !== false;
|
||||
|
||||
const touchStartX = useRef<number | null>(null);
|
||||
const touchStartY = useRef<number | null>(null);
|
||||
// 'h' once we classify the gesture as horizontal, 'v' for vertical, null = unclassified
|
||||
const dragLockRef = useRef<'h' | 'v' | null>(null);
|
||||
// Sheet scroll offset at the moment the touch began — used to suppress dismiss-drag
|
||||
// when the user is scrolled into content and pans down to scroll back up.
|
||||
const scrollTopAtTouchStart = useRef(0);
|
||||
// Keep a ref to the current notice id so dismiss/CTA handlers see the latest value
|
||||
const noticeIdRef = useRef<string | null>(null);
|
||||
noticeIdRef.current = notice?.id ?? null;
|
||||
@@ -339,7 +346,15 @@ export function ModalRenderer({ notices }: Props) {
|
||||
// contentWrapperRef: the div wrapping NoticeContent — we animate its transform directly.
|
||||
const isPageNavRef = useRef(false);
|
||||
const slideDirRef = useRef<'left' | 'right'>('right');
|
||||
const contentWrapperRef = useRef<HTMLDivElement>(null);
|
||||
// Mobile drag strip — wraps all 3 slots and is translated to reveal prev/current/next
|
||||
const stripRef = useRef<HTMLDivElement>(null);
|
||||
// The sheet element itself — animated on vertical drag-to-dismiss
|
||||
const sheetRef = useRef<HTMLDivElement>(null);
|
||||
const clipRef = useRef<HTMLDivElement>(null);
|
||||
// Individual slot scroll containers (prev / center / next)
|
||||
const prevSlotRef = useRef<HTMLDivElement>(null);
|
||||
const contentWrapperRef = useRef<HTMLDivElement>(null); // center slot
|
||||
const nextSlotRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Mobile breakpoint
|
||||
useEffect(() => {
|
||||
@@ -455,6 +470,12 @@ export function ModalRenderer({ notices }: Props) {
|
||||
return () => { document.body.style.overflow = ''; };
|
||||
}, [visible, notice]);
|
||||
|
||||
// Reset center slot scroll to top on navigation (keyboard / pager buttons).
|
||||
useEffect(() => {
|
||||
if (!isMobile) return;
|
||||
contentWrapperRef.current?.scrollTo({ top: 0 });
|
||||
}, [idx, isMobile]);
|
||||
|
||||
function announceIndex(newIdx: number, total: number) {
|
||||
setPageAnnouncement(
|
||||
t('system_notice.pager.position')
|
||||
@@ -498,6 +519,17 @@ export function ModalRenderer({ notices }: Props) {
|
||||
}
|
||||
}
|
||||
|
||||
function animatedDismissAll() {
|
||||
const sheet = sheetRef.current;
|
||||
if (!sheet || prefersReducedMotion) { handleDismissAll(); return; }
|
||||
sheet.style.transition = 'transform 300ms ease-out';
|
||||
sheet.style.transform = 'translateY(110%)';
|
||||
sheet.addEventListener('transitionend', function onDone() {
|
||||
sheet.removeEventListener('transitionend', onDone);
|
||||
handleDismissAll();
|
||||
}, { once: true });
|
||||
}
|
||||
|
||||
// Sets up the content wrapper's start transform SYNCHRONOUSLY (before React
|
||||
// re-renders with the new notice), then flags the grace-delay effect to slide
|
||||
// rather than hide+show.
|
||||
@@ -576,6 +608,38 @@ export function ModalRenderer({ notices }: Props) {
|
||||
? (visible ? 'opacity-100' : 'opacity-0')
|
||||
: (visible ? 'opacity-100 translate-y-0' : 'opacity-100 translate-y-full');
|
||||
|
||||
// Build ContentProps for an adjacent slot so NoticeContent renders correctly
|
||||
function buildSlotProps(n: SystemNoticeDTO, slotIdx: number): ContentProps {
|
||||
const slotRawBody = t(n.bodyKey);
|
||||
const slotBody = n.bodyParams
|
||||
? Object.entries(n.bodyParams).reduce(
|
||||
(s, [k, v]) => s.replace(new RegExp(`\\{${k}\\}`, 'g'), v),
|
||||
slotRawBody
|
||||
)
|
||||
: slotRawBody;
|
||||
return {
|
||||
notice: n,
|
||||
title: t(n.titleKey),
|
||||
body: slotBody,
|
||||
ctaLabel: n.cta ? t(n.cta.labelKey) : null,
|
||||
titleId: `notice-title-${n.id}`,
|
||||
bodyId: `notice-body-${n.id}`,
|
||||
isDark,
|
||||
onDismiss: handleDismiss,
|
||||
onDismissAll: handleDismissAll,
|
||||
onCTA: handleCTA,
|
||||
total: notices.length,
|
||||
currentPage: slotIdx,
|
||||
canPage,
|
||||
onPrev: handlePrev,
|
||||
onNext: handleNext,
|
||||
onGoto: handleGoto,
|
||||
};
|
||||
}
|
||||
|
||||
const prevNotice = notices[idx - 1] ?? null;
|
||||
const nextNotice = notices[idx + 1] ?? null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50" role="presentation">
|
||||
{/* Screen-reader page announcements */}
|
||||
@@ -583,30 +647,150 @@ export function ModalRenderer({ notices }: Props) {
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className={`absolute inset-0 bg-slate-950/40 backdrop-blur-[2px] transition-opacity ${dur} ${ease} ${visible ? 'opacity-100' : 'opacity-0'}`}
|
||||
onClick={notice.dismissible ? handleDismiss : undefined}
|
||||
onClick={notice.dismissible ? animatedDismissAll : undefined}
|
||||
/>
|
||||
{/* Bottom sheet */}
|
||||
<div
|
||||
ref={sheetRef}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby={titleId}
|
||||
aria-describedby={bodyId}
|
||||
className={`absolute bottom-0 left-0 right-0 rounded-t-3xl overflow-hidden max-h-[85dvh] overflow-y-auto bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 shadow-xl transition-all ${dur} ${ease} ${mobileMotion}`}
|
||||
onTouchStart={e => { touchStartY.current = e.touches[0].clientY; }}
|
||||
onTouchEnd={e => {
|
||||
if (touchStartY.current !== null && notice.dismissible) {
|
||||
const delta = e.changedTouches[0].clientY - touchStartY.current;
|
||||
if (delta > 80) handleDismiss();
|
||||
className={`absolute bottom-0 left-0 right-0 rounded-t-3xl overflow-hidden h-[85dvh] flex flex-col bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 shadow-xl transition-[opacity,transform] ${dur} ${ease} ${mobileMotion}`}
|
||||
style={{ touchAction: 'pan-y' }}
|
||||
onTouchStart={e => {
|
||||
touchStartX.current = e.touches[0].clientX;
|
||||
touchStartY.current = e.touches[0].clientY;
|
||||
dragLockRef.current = null;
|
||||
scrollTopAtTouchStart.current = contentWrapperRef.current?.scrollTop ?? 0;
|
||||
}}
|
||||
onTouchMove={e => {
|
||||
if (prefersReducedMotion) return;
|
||||
const startX = touchStartX.current;
|
||||
const startY = touchStartY.current;
|
||||
if (startX === null || startY === null) return;
|
||||
const dx = e.touches[0].clientX - startX;
|
||||
const dy = e.touches[0].clientY - startY;
|
||||
// Classify gesture direction on first significant movement
|
||||
if (!dragLockRef.current) {
|
||||
if (Math.abs(dx) > 8 || Math.abs(dy) > 8) {
|
||||
dragLockRef.current = Math.abs(dx) >= Math.abs(dy) ? 'h' : 'v';
|
||||
// Reset adjacent slots to top before they slide into view.
|
||||
if (dragLockRef.current === 'h') {
|
||||
prevSlotRef.current?.scrollTo({ top: 0 });
|
||||
nextSlotRef.current?.scrollTo({ top: 0 });
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (dragLockRef.current === 'h') {
|
||||
const strip = stripRef.current;
|
||||
if (!strip) return;
|
||||
strip.style.transition = 'none';
|
||||
// Strip base = -33.333% (center slot visible); dx offsets from there
|
||||
strip.style.transform = `translateX(calc(-33.333% + ${dx}px))`;
|
||||
} else if (dragLockRef.current === 'v' && notice.dismissible) {
|
||||
// Only intercept downward drag for dismiss when the sheet is scrolled to the top.
|
||||
// If scrolled into content, let native pan-y scroll it back up.
|
||||
if (scrollTopAtTouchStart.current > 0) return;
|
||||
const sheet = sheetRef.current;
|
||||
if (!sheet || dy <= 0) return;
|
||||
sheet.style.transition = 'none';
|
||||
sheet.style.transform = `translateY(${dy}px)`;
|
||||
}
|
||||
}}
|
||||
onTouchEnd={e => {
|
||||
const startX = touchStartX.current;
|
||||
const startY = touchStartY.current;
|
||||
touchStartX.current = null;
|
||||
touchStartY.current = null;
|
||||
const lock = dragLockRef.current;
|
||||
dragLockRef.current = null;
|
||||
|
||||
if (lock === 'h') {
|
||||
if (startX === null) return;
|
||||
const deltaX = e.changedTouches[0].clientX - startX;
|
||||
const strip = stripRef.current;
|
||||
if (!strip) return;
|
||||
|
||||
const goNext = isRtlLanguage(language) ? deltaX > 50 : deltaX < -50;
|
||||
const goPrev = isRtlLanguage(language) ? deltaX < -50 : deltaX > 50;
|
||||
const canGoNext = canPage && idx < notices.length - 1;
|
||||
const canGoPrev = canPage && idx > 0;
|
||||
|
||||
if ((goNext && canGoNext) || (goPrev && canGoPrev)) {
|
||||
// Animate strip to the adjacent slot (-66.666% = next, 0% = prev)
|
||||
strip.style.transition = 'transform 200ms ease-out';
|
||||
strip.style.transform = goNext ? 'translateX(-66.666%)' : 'translateX(0%)';
|
||||
strip.addEventListener('transitionend', function onDone() {
|
||||
strip.removeEventListener('transitionend', onDone);
|
||||
strip.style.transition = 'none';
|
||||
// Render new content into the center slot BEFORE moving the strip,
|
||||
// so the browser never paints old content at the center position.
|
||||
const newIdx = goNext ? idx + 1 : idx - 1;
|
||||
flushSync(() => {
|
||||
isPageNavRef.current = true;
|
||||
setIdx(newIdx);
|
||||
announceIndex(newIdx, notices.length);
|
||||
});
|
||||
// Reset all slot scrolls so the new center starts at top.
|
||||
prevSlotRef.current?.scrollTo({ top: 0 });
|
||||
contentWrapperRef.current?.scrollTo({ top: 0 });
|
||||
nextSlotRef.current?.scrollTo({ top: 0 });
|
||||
strip.style.transform = 'translateX(-33.333%)';
|
||||
}, { once: true });
|
||||
} else {
|
||||
// Spring back to center
|
||||
strip.style.transition = 'transform 300ms cubic-bezier(0.34,1.56,0.64,1)';
|
||||
strip.style.transform = 'translateX(-33.333%)';
|
||||
strip.addEventListener('transitionend', function onSnap() {
|
||||
strip.removeEventListener('transitionend', onSnap);
|
||||
strip.style.transition = '';
|
||||
strip.style.transform = 'translateX(-33.333%)';
|
||||
}, { once: true });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Vertical drag — animated dismiss or spring back (only when at scroll top)
|
||||
if (lock === 'v' && startY !== null && scrollTopAtTouchStart.current === 0) {
|
||||
const deltaY = e.changedTouches[0].clientY - startY;
|
||||
const sheet = sheetRef.current;
|
||||
if (deltaY > 80 && notice.dismissible) {
|
||||
animatedDismissAll();
|
||||
} else if (sheet && deltaY > 0) {
|
||||
sheet.style.transition = 'transform 300ms cubic-bezier(0.34,1.56,0.64,1)';
|
||||
sheet.style.transform = 'translateY(0)';
|
||||
sheet.addEventListener('transitionend', function onSnap() {
|
||||
sheet.removeEventListener('transitionend', onSnap);
|
||||
sheet.style.transition = '';
|
||||
sheet.style.transform = '';
|
||||
}, { once: true });
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
{/* Drag handle */}
|
||||
<div className="pt-3 pb-1 flex justify-center">
|
||||
{/* Drag handle — fixed, does not scroll */}
|
||||
<div className="pt-3 pb-1 flex justify-center shrink-0">
|
||||
<div className="w-9 h-1 rounded-full bg-slate-300 dark:bg-slate-600" />
|
||||
</div>
|
||||
<div ref={contentWrapperRef}>
|
||||
<NoticeContent {...contentProps} />
|
||||
{/* Clip container — fills remaining sheet height, hides adjacent slots */}
|
||||
<div style={{ flex: '1 1 0', minHeight: 0, overflow: 'hidden', width: '100%' }}>
|
||||
{/* 3-slot strip: [prev][current][next] — starts at -33.333% to show current */}
|
||||
<div
|
||||
ref={stripRef}
|
||||
style={{ display: 'flex', width: '300%', height: '100%', alignItems: 'stretch', transform: 'translateX(-33.333%)' }}
|
||||
>
|
||||
<div ref={prevSlotRef} style={{ width: '33.333%', overflowY: 'auto', display: 'flex', flexDirection: 'column' }}>
|
||||
{prevNotice && <NoticeContent {...buildSlotProps(prevNotice, idx - 1)} />}
|
||||
</div>
|
||||
<div ref={contentWrapperRef} style={{ width: '33.333%', overflowY: 'auto', display: 'flex', flexDirection: 'column' }}>
|
||||
<NoticeContent {...contentProps} />
|
||||
</div>
|
||||
<div ref={nextSlotRef} style={{ width: '33.333%', overflowY: 'auto', display: 'flex', flexDirection: 'column' }}>
|
||||
{nextNotice && <NoticeContent {...buildSlotProps(nextNotice, idx + 1)} />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -386,7 +386,7 @@ export default function TodoListPanel({ tripId, items, addItemSignal = 0 }: { tr
|
||||
)}
|
||||
{selectedItem && !isAddingNew && isMobile && (
|
||||
<div onClick={e => { if (e.target === e.currentTarget) setSelectedId(null) }}
|
||||
style={{ position: 'fixed', inset: 0, zIndex: 1000, background: 'rgba(0,0,0,0.4)', display: 'flex', justifyContent: 'center', alignItems: 'flex-end' }}>
|
||||
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' } } }}>
|
||||
<DetailPane
|
||||
@@ -402,7 +402,7 @@ export default function TodoListPanel({ tripId, items, addItemSignal = 0 }: { tr
|
||||
{isAddingNew && !selectedItem && !isMobile && ReactDOM.createPortal(
|
||||
<div onClick={e => { if (e.target === e.currentTarget) setIsAddingNew(false) }}
|
||||
className="modal-backdrop"
|
||||
style={{ position: 'fixed', inset: 0, zIndex: 300, background: 'rgba(15,23,42,0.5)', display: 'flex', justifyContent: 'center', alignItems: 'flex-start', paddingTop: 'calc(var(--nav-h) + 60px)', paddingBottom: 40 }}>
|
||||
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
|
||||
@@ -420,7 +420,7 @@ export default function TodoListPanel({ tripId, items, addItemSignal = 0 }: { tr
|
||||
{isAddingNew && !selectedItem && isMobile && ReactDOM.createPortal(
|
||||
<div onClick={e => { if (e.target === e.currentTarget) setIsAddingNew(false) }}
|
||||
className="modal-backdrop"
|
||||
style={{ position: 'fixed', inset: 0, zIndex: 300, background: 'rgba(0,0,0,0.4)', display: 'flex', justifyContent: 'center', alignItems: 'flex-end' }}>
|
||||
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' } } }}>
|
||||
<NewTaskPane
|
||||
|
||||
@@ -51,7 +51,7 @@ export default function Modal({
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-[200] flex items-start sm:items-center justify-center px-4 modal-backdrop"
|
||||
style={{ backgroundColor: 'rgba(15, 23, 42, 0.5)', paddingTop: 70, paddingBottom: 20, overflow: 'hidden' }}
|
||||
style={{ backgroundColor: 'rgba(15, 23, 42, 0.5)', paddingTop: 70, paddingBottom: 'calc(20px + var(--bottom-nav-h))', overflow: 'hidden' }}
|
||||
onMouseDown={e => { mouseDownTarget.current = e.target }}
|
||||
onClick={e => {
|
||||
if (e.target === e.currentTarget && mouseDownTarget.current === e.currentTarget) onClose()
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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 {
|
||||
@@ -18,10 +19,12 @@ 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
|
||||
@@ -37,6 +40,7 @@ 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 }
|
||||
|
||||
|
||||
@@ -588,6 +588,12 @@ 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': 'الدردشة',
|
||||
@@ -1579,6 +1585,14 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
|
||||
'memories.confirmShareTitle': 'مشاركة مع أعضاء الرحلة؟',
|
||||
'memories.confirmShareHint': '{count} صور ستكون مرئية لجميع أعضاء هذه الرحلة. يمكنك جعل الصور الفردية خاصة لاحقًا.',
|
||||
'memories.confirmShareButton': 'مشاركة الصور',
|
||||
'journey.search.placeholder': 'البحث في الرحلات…',
|
||||
'journey.search.noResults': 'لا توجد رحلات تطابق "{query}"',
|
||||
'journey.status.archived': 'مؤرشف',
|
||||
'journey.settings.endJourney': 'أرشفة الرحلة',
|
||||
'journey.settings.reopenJourney': 'استعادة الرحلة',
|
||||
'journey.settings.archived': 'تم أرشفة الرحلة',
|
||||
'journey.settings.reopened': 'تمت إعادة فتح الرحلة',
|
||||
'journey.settings.endDescription': 'يخفي شارة البث المباشر. يمكنك إعادة الفتح في أي وقت.',
|
||||
'journey.settings.failedToDelete': 'فشل في الحذف',
|
||||
'journey.entries.deleteTitle': 'حذف الإدخال',
|
||||
'journey.photosUploaded': 'تم رفع {count} صورة',
|
||||
@@ -1889,6 +1903,10 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
|
||||
'admin.notifications.adminNtfyPanel.testFailed': 'فشل إرسال Ntfy التجريبي',
|
||||
'admin.notifications.adminNtfyPanel.alwaysOnHint': 'يُرسل Ntfy للمسؤول دائمًا عند تهيئة موضوع',
|
||||
'admin.notifications.adminNotificationsHint': 'حدد القنوات التي تُسلّم إشعارات المسؤول (مثل تنبيهات الإصدارات). يُرسل الـ Webhook تلقائيًا عند تعيين رابط URL لـ Webhook المسؤول.',
|
||||
'admin.notifications.tripReminders.title': 'تذكيرات الرحلات',
|
||||
'admin.notifications.tripReminders.hint': 'إرسال تذكير قبل بدء الرحلة (يتطلب تعيين أيام التذكير على الرحلة).',
|
||||
'admin.notifications.tripReminders.enabled': 'تم تفعيل تذكيرات الرحلات',
|
||||
'admin.notifications.tripReminders.disabled': 'تم تعطيل تذكيرات الرحلات',
|
||||
'admin.tabs.notifications': 'الإشعارات',
|
||||
'notifications.versionAvailable.title': 'تحديث متاح',
|
||||
'notifications.versionAvailable.text': 'TREK {version} متاح الآن.',
|
||||
|
||||
@@ -546,6 +546,12 @@ 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',
|
||||
@@ -1838,6 +1844,10 @@ const br: Record<string, string | { name: string; category: string }[]> = {
|
||||
'admin.notifications.adminNtfyPanel.testFailed': 'Falha ao enviar Ntfy de teste',
|
||||
'admin.notifications.adminNtfyPanel.alwaysOnHint': 'O Ntfy de admin sempre dispara quando um tópico está configurado',
|
||||
'admin.notifications.adminNotificationsHint': 'Configure quais canais entregam notificações de admin (ex. alertas de versão). O webhook dispara automaticamente se uma URL de webhook de admin estiver definida.',
|
||||
'admin.notifications.tripReminders.title': 'Lembretes de viagem',
|
||||
'admin.notifications.tripReminders.hint': 'Envia uma notificação de lembrete antes do início de uma viagem (requer dias de lembrete definidos na viagem).',
|
||||
'admin.notifications.tripReminders.enabled': 'Lembretes de viagem ativados',
|
||||
'admin.notifications.tripReminders.disabled': 'Lembretes de viagem desativados',
|
||||
'admin.tabs.notifications': 'Notificações',
|
||||
'notifications.versionAvailable.title': 'Atualização disponível',
|
||||
'notifications.versionAvailable.text': 'TREK {version} já está disponível.',
|
||||
@@ -1885,6 +1895,8 @@ const br: Record<string, string | { name: string; category: string }[]> = {
|
||||
'memories.saveRouteNotConfigured': 'A rota de salvamento não está configurada para este provedor',
|
||||
'memories.testRouteNotConfigured': 'A rota de teste não está configurada para este provedor',
|
||||
'memories.fillRequiredFields': 'Por favor preencha todos os campos obrigatórios',
|
||||
'journey.search.placeholder': 'Buscar jornadas…',
|
||||
'journey.search.noResults': 'Nenhuma jornada corresponde a "{query}"',
|
||||
'journey.title': 'Jornada',
|
||||
'journey.subtitle': 'Registre suas viagens em tempo real',
|
||||
'journey.new': 'Nova jornada',
|
||||
@@ -1906,6 +1918,7 @@ const br: Record<string, string | { name: string; category: string }[]> = {
|
||||
'journey.status.active': 'Ativa',
|
||||
'journey.status.completed': 'Concluída',
|
||||
'journey.status.upcoming': 'Próxima',
|
||||
'journey.status.archived': 'Arquivado',
|
||||
'journey.checkin.add': 'Fazer check-in',
|
||||
'journey.checkin.namePlaceholder': 'Nome do local',
|
||||
'journey.checkin.notesPlaceholder': 'Notas (opcional)',
|
||||
@@ -2059,6 +2072,11 @@ const br: Record<string, string | { name: string; category: string }[]> = {
|
||||
'journey.settings.name': 'Nome',
|
||||
'journey.settings.subtitle': 'Subtítulo',
|
||||
'journey.settings.subtitlePlaceholder': 'ex. Tailândia, Vietnã e Camboja',
|
||||
'journey.settings.endJourney': 'Arquivar Jornada',
|
||||
'journey.settings.reopenJourney': 'Restaurar Jornada',
|
||||
'journey.settings.archived': 'Jornada arquivada',
|
||||
'journey.settings.reopened': 'Jornada reaberta',
|
||||
'journey.settings.endDescription': 'Oculta o selo Ao Vivo. Você pode reabrir a qualquer momento.',
|
||||
'journey.settings.delete': 'Excluir',
|
||||
'journey.settings.deleteJourney': 'Excluir jornada',
|
||||
'journey.settings.deleteMessage': 'Excluir "{title}"? Todas as entradas e fotos serão perdidas.',
|
||||
|
||||
@@ -546,6 +546,12 @@ 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',
|
||||
@@ -1843,6 +1849,10 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
|
||||
'admin.notifications.adminNtfyPanel.testFailed': 'Testovací Ntfy selhalo',
|
||||
'admin.notifications.adminNtfyPanel.alwaysOnHint': 'Admin Ntfy odesílá vždy, když je nakonfigurováno téma',
|
||||
'admin.notifications.adminNotificationsHint': 'Nastavte, které kanály doručují admin oznámení (např. upozornění na verze). Webhook odesílá automaticky, pokud je nastavena URL admin webhooku.',
|
||||
'admin.notifications.tripReminders.title': 'Připomínky výletů',
|
||||
'admin.notifications.tripReminders.hint': 'Odešle upozornění před začátkem výletu (vyžaduje nastavené dny připomínky na výletu).',
|
||||
'admin.notifications.tripReminders.enabled': 'Připomínky výletů aktivovány',
|
||||
'admin.notifications.tripReminders.disabled': 'Připomínky výletů deaktivovány',
|
||||
'admin.tabs.notifications': 'Oznámení',
|
||||
'notifications.versionAvailable.title': 'Dostupná aktualizace',
|
||||
'notifications.versionAvailable.text': 'TREK {version} je nyní k dispozici.',
|
||||
@@ -1890,6 +1900,8 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
|
||||
'memories.saveRouteNotConfigured': 'Trasa uložení není nakonfigurována pro tohoto poskytovatele',
|
||||
'memories.testRouteNotConfigured': 'Testovací trasa není nakonfigurována pro tohoto poskytovatele',
|
||||
'memories.fillRequiredFields': 'Prosím vyplňte všechna povinná pole',
|
||||
'journey.search.placeholder': 'Hledat cesty…',
|
||||
'journey.search.noResults': 'Žádné cesty neodpovídají „{query}"',
|
||||
'journey.title': 'Cestovní deník',
|
||||
'journey.subtitle': 'Zaznamenávejte své cesty průběžně',
|
||||
'journey.new': 'Nový cestovní deník',
|
||||
@@ -1911,6 +1923,7 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
|
||||
'journey.status.active': 'Aktivní',
|
||||
'journey.status.completed': 'Dokončeno',
|
||||
'journey.status.upcoming': 'Nadcházející',
|
||||
'journey.status.archived': 'Archivováno',
|
||||
'journey.checkin.add': 'Odbavit se',
|
||||
'journey.checkin.namePlaceholder': 'Název místa',
|
||||
'journey.checkin.notesPlaceholder': 'Poznámky (volitelné)',
|
||||
@@ -2064,6 +2077,11 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
|
||||
'journey.settings.name': 'Název',
|
||||
'journey.settings.subtitle': 'Podtitul',
|
||||
'journey.settings.subtitlePlaceholder': 'např. Thajsko, Vietnam a Kambodža',
|
||||
'journey.settings.endJourney': 'Archivovat cestu',
|
||||
'journey.settings.reopenJourney': 'Obnovit cestu',
|
||||
'journey.settings.archived': 'Cesta archivována',
|
||||
'journey.settings.reopened': 'Cesta znovu otevřena',
|
||||
'journey.settings.endDescription': 'Skryje odznak Živě. Kdykoli jej lze znovu otevřít.',
|
||||
'journey.settings.delete': 'Smazat',
|
||||
'journey.settings.deleteJourney': 'Smazat cestovní deník',
|
||||
'journey.settings.deleteMessage': 'Smazat „{title}"? Všechny záznamy a fotky budou ztraceny.',
|
||||
|
||||
@@ -551,6 +551,12 @@ 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',
|
||||
@@ -1846,6 +1852,10 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
||||
'admin.notifications.adminNtfyPanel.testFailed': 'Test-Ntfy fehlgeschlagen',
|
||||
'admin.notifications.adminNtfyPanel.alwaysOnHint': 'Admin-Ntfy sendet immer, wenn ein Thema konfiguriert ist',
|
||||
'admin.notifications.adminNotificationsHint': 'Konfiguriere, welche Kanäle Admin-Benachrichtigungen liefern (z. B. Versions-Updates). Der Webhook sendet automatisch, wenn eine Admin-Webhook-URL gesetzt ist.',
|
||||
'admin.notifications.tripReminders.title': 'Reiseerinnerungen',
|
||||
'admin.notifications.tripReminders.hint': 'Sendet eine Erinnerungsbenachrichtigung vor Reisebeginn (erfordert gesetzte Erinnerungstage bei der Reise).',
|
||||
'admin.notifications.tripReminders.enabled': 'Reiseerinnerungen aktiviert',
|
||||
'admin.notifications.tripReminders.disabled': 'Reiseerinnerungen deaktiviert',
|
||||
'admin.tabs.notifications': 'Benachrichtigungen',
|
||||
'notifications.versionAvailable.title': 'Update verfügbar',
|
||||
'notifications.versionAvailable.text': 'TREK {version} ist jetzt verfügbar.',
|
||||
@@ -1887,6 +1897,8 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
||||
'notif.dev.unknown_event.text': 'Ereignistyp "{event}" ist nicht in EVENT_NOTIFICATION_CONFIG registriert',
|
||||
|
||||
// Journey Addon
|
||||
'journey.search.placeholder': 'Reisen suchen…',
|
||||
'journey.search.noResults': 'Keine Reisen passen zu „{query}"',
|
||||
'journey.title': 'Journey',
|
||||
'journey.subtitle': 'Dokumentiere deine Reisen unterwegs',
|
||||
'journey.new': 'Neue Journey',
|
||||
@@ -1908,6 +1920,7 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
||||
'journey.status.active': 'Aktiv',
|
||||
'journey.status.completed': 'Abgeschlossen',
|
||||
'journey.status.upcoming': 'Anstehend',
|
||||
'journey.status.archived': 'Archiviert',
|
||||
'journey.checkin.add': 'Einchecken',
|
||||
'journey.checkin.namePlaceholder': 'Ortsname',
|
||||
'journey.checkin.notesPlaceholder': 'Notizen (optional)',
|
||||
@@ -2065,6 +2078,11 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
||||
'journey.settings.name': 'Name',
|
||||
'journey.settings.subtitle': 'Untertitel',
|
||||
'journey.settings.subtitlePlaceholder': 'z.B. Thailand, Vietnam & Kambodscha',
|
||||
'journey.settings.endJourney': 'Reise archivieren',
|
||||
'journey.settings.reopenJourney': 'Reise wiederherstellen',
|
||||
'journey.settings.archived': 'Reise archiviert',
|
||||
'journey.settings.reopened': 'Reise erneut geöffnet',
|
||||
'journey.settings.endDescription': 'Blendet das Live-Abzeichen aus. Sie können jederzeit wieder öffnen.',
|
||||
'journey.settings.delete': 'Löschen',
|
||||
'journey.settings.deleteJourney': 'Journey löschen',
|
||||
'journey.settings.deleteMessage': '"{title}" löschen? Alle Einträge und Fotos gehen verloren.',
|
||||
|
||||
@@ -253,6 +253,10 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
||||
'admin.notifications.adminNtfyPanel.testFailed': 'Test ntfy failed',
|
||||
'admin.notifications.adminNtfyPanel.alwaysOnHint': 'Admin ntfy always fires when a topic is configured',
|
||||
'admin.notifications.adminNotificationsHint': 'Configure which channels deliver admin-only notifications (e.g. version alerts).',
|
||||
'admin.notifications.tripReminders.title': 'Trip Reminders',
|
||||
'admin.notifications.tripReminders.hint': 'Send a reminder notification before a trip starts (requires reminder days to be set on the trip).',
|
||||
'admin.notifications.tripReminders.enabled': 'Trip reminders enabled',
|
||||
'admin.notifications.tripReminders.disabled': 'Trip reminders disabled',
|
||||
'admin.smtp.title': 'Email & Notifications',
|
||||
'admin.smtp.hint': 'SMTP configuration for sending email notifications.',
|
||||
'admin.smtp.testButton': 'Send test email',
|
||||
@@ -607,6 +611,12 @@ 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',
|
||||
@@ -1890,6 +1900,8 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
||||
'notif.dev.unknown_event.text': 'Event type "{event}" is not registered in EVENT_NOTIFICATION_CONFIG',
|
||||
|
||||
// Journey addon
|
||||
'journey.search.placeholder': 'Search journeys…',
|
||||
'journey.search.noResults': 'No journeys match "{query}"',
|
||||
'journey.title': 'Journey',
|
||||
'journey.subtitle': 'Track your travels as they happen',
|
||||
'journey.new': 'New Journey',
|
||||
@@ -1911,6 +1923,7 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
||||
'journey.status.active': 'Active',
|
||||
'journey.status.completed': 'Completed',
|
||||
'journey.status.upcoming': 'Upcoming',
|
||||
'journey.status.archived': 'Archived',
|
||||
'journey.checkin.add': 'Check in',
|
||||
'journey.checkin.namePlaceholder': 'Location name',
|
||||
'journey.checkin.notesPlaceholder': 'Notes (optional)',
|
||||
@@ -2089,6 +2102,11 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
||||
'journey.settings.name': 'Name',
|
||||
'journey.settings.subtitle': 'Subtitle',
|
||||
'journey.settings.subtitlePlaceholder': 'e.g. Thailand, Vietnam & Cambodia',
|
||||
'journey.settings.endJourney': 'Archive Journey',
|
||||
'journey.settings.reopenJourney': 'Restore Journey',
|
||||
'journey.settings.archived': 'Journey archived',
|
||||
'journey.settings.reopened': 'Journey reopened',
|
||||
'journey.settings.endDescription': 'Hides the Live badge. You can reopen anytime.',
|
||||
'journey.settings.delete': 'Delete',
|
||||
'journey.settings.deleteJourney': 'Delete Journey',
|
||||
'journey.settings.deleteMessage': 'Delete "{title}"? All entries and photos will be lost.',
|
||||
|
||||
@@ -541,6 +541,12 @@ 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',
|
||||
@@ -1848,6 +1854,10 @@ const es: Record<string, string> = {
|
||||
'admin.notifications.adminNtfyPanel.testFailed': 'Error al enviar el Ntfy de prueba',
|
||||
'admin.notifications.adminNtfyPanel.alwaysOnHint': 'El Ntfy de admin siempre se activa cuando hay un tema configurado',
|
||||
'admin.notifications.adminNotificationsHint': 'Configura qué canales entregan notificaciones de admin (ej. alertas de versión). El webhook se activa automáticamente si hay una URL de webhook de admin configurada.',
|
||||
'admin.notifications.tripReminders.title': 'Recordatorios de viaje',
|
||||
'admin.notifications.tripReminders.hint': 'Envía una notificación de recordatorio antes de que comience un viaje (requiere días de recordatorio configurados en el viaje).',
|
||||
'admin.notifications.tripReminders.enabled': 'Recordatorios de viaje activados',
|
||||
'admin.notifications.tripReminders.disabled': 'Recordatorios de viaje desactivados',
|
||||
'admin.tabs.notifications': 'Notificaciones',
|
||||
'notifications.versionAvailable.title': 'Actualización disponible',
|
||||
'notifications.versionAvailable.text': 'TREK {version} ya está disponible.',
|
||||
@@ -1892,6 +1902,8 @@ const es: Record<string, string> = {
|
||||
'common.justNow': 'justo ahora',
|
||||
'common.hoursAgo': 'hace {count}h',
|
||||
'common.daysAgo': 'hace {count}d',
|
||||
'journey.search.placeholder': 'Buscar viajes…',
|
||||
'journey.search.noResults': 'Ningún viaje coincide con "{query}"',
|
||||
'journey.title': 'Travesía',
|
||||
'journey.subtitle': 'Registra tus viajes en tiempo real',
|
||||
'journey.new': 'Nueva travesía',
|
||||
@@ -1913,6 +1925,7 @@ const es: Record<string, string> = {
|
||||
'journey.status.active': 'Activa',
|
||||
'journey.status.completed': 'Completada',
|
||||
'journey.status.upcoming': 'Próxima',
|
||||
'journey.status.archived': 'Archivado',
|
||||
'journey.checkin.add': 'Registrar ubicación',
|
||||
'journey.checkin.namePlaceholder': 'Nombre del lugar',
|
||||
'journey.checkin.notesPlaceholder': 'Notas (opcional)',
|
||||
@@ -2066,6 +2079,11 @@ const es: Record<string, string> = {
|
||||
'journey.settings.name': 'Nombre',
|
||||
'journey.settings.subtitle': 'Subtítulo',
|
||||
'journey.settings.subtitlePlaceholder': 'p. ej. Tailandia, Vietnam y Camboya',
|
||||
'journey.settings.endJourney': 'Archivar viaje',
|
||||
'journey.settings.reopenJourney': 'Restaurar viaje',
|
||||
'journey.settings.archived': 'Viaje archivado',
|
||||
'journey.settings.reopened': 'Viaje reabierto',
|
||||
'journey.settings.endDescription': 'Oculta la insignia En Vivo. Puedes reabrirlo en cualquier momento.',
|
||||
'journey.settings.delete': 'Eliminar',
|
||||
'journey.settings.deleteJourney': 'Eliminar travesía',
|
||||
'journey.settings.deleteMessage': '¿Eliminar "{title}"? Todas las entradas y fotos se perderán.',
|
||||
|
||||
@@ -545,6 +545,12 @@ 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',
|
||||
@@ -1842,6 +1848,10 @@ const fr: Record<string, string> = {
|
||||
'admin.notifications.adminNtfyPanel.testFailed': 'Échec de l\'envoi du Ntfy de test',
|
||||
'admin.notifications.adminNtfyPanel.alwaysOnHint': 'Le Ntfy admin s\'active toujours lorsqu\'un sujet est configuré',
|
||||
'admin.notifications.adminNotificationsHint': 'Configurez quels canaux envoient les notifications admin (ex. alertes de version). Le webhook s\'active automatiquement si une URL webhook admin est définie.',
|
||||
'admin.notifications.tripReminders.title': 'Rappels de voyage',
|
||||
'admin.notifications.tripReminders.hint': 'Envoie une notification de rappel avant le début d\'un voyage (nécessite des jours de rappel définis sur le voyage).',
|
||||
'admin.notifications.tripReminders.enabled': 'Rappels de voyage activés',
|
||||
'admin.notifications.tripReminders.disabled': 'Rappels de voyage désactivés',
|
||||
'admin.tabs.notifications': 'Notifications',
|
||||
'notifications.versionAvailable.title': 'Mise à jour disponible',
|
||||
'notifications.versionAvailable.text': 'TREK {version} est maintenant disponible.',
|
||||
@@ -1886,6 +1896,8 @@ const fr: Record<string, string> = {
|
||||
'common.justNow': 'à l\'instant',
|
||||
'common.hoursAgo': 'il y a {count}h',
|
||||
'common.daysAgo': 'il y a {count}j',
|
||||
'journey.search.placeholder': 'Rechercher des journaux…',
|
||||
'journey.search.noResults': 'Aucun journal ne correspond à « {query} »',
|
||||
'journey.title': 'Journal de voyage',
|
||||
'journey.subtitle': 'Suivez vos voyages en temps réel',
|
||||
'journey.new': 'Nouveau journal',
|
||||
@@ -1907,6 +1919,7 @@ const fr: Record<string, string> = {
|
||||
'journey.status.active': 'Actif',
|
||||
'journey.status.completed': 'Terminé',
|
||||
'journey.status.upcoming': 'À venir',
|
||||
'journey.status.archived': 'Archivé',
|
||||
'journey.checkin.add': 'Check-in',
|
||||
'journey.checkin.namePlaceholder': 'Nom du lieu',
|
||||
'journey.checkin.notesPlaceholder': 'Notes (facultatif)',
|
||||
@@ -2060,6 +2073,11 @@ const fr: Record<string, string> = {
|
||||
'journey.settings.name': 'Nom',
|
||||
'journey.settings.subtitle': 'Sous-titre',
|
||||
'journey.settings.subtitlePlaceholder': 'ex. Thaïlande, Vietnam et Cambodge',
|
||||
'journey.settings.endJourney': 'Archiver le journal',
|
||||
'journey.settings.reopenJourney': 'Restaurer le journal',
|
||||
'journey.settings.archived': 'Journal archivé',
|
||||
'journey.settings.reopened': 'Journal rouvert',
|
||||
'journey.settings.endDescription': 'Masque l\'indicateur En direct. Vous pouvez rouvrir à tout moment.',
|
||||
'journey.settings.delete': 'Supprimer',
|
||||
'journey.settings.deleteJourney': 'Supprimer le journal',
|
||||
'journey.settings.deleteMessage': 'Supprimer « {title} » ? Toutes les entrées et photos seront perdues.',
|
||||
|
||||
@@ -546,6 +546,12 @@ 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',
|
||||
@@ -1840,6 +1846,10 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
|
||||
'admin.notifications.adminNtfyPanel.testFailed': 'Teszt Ntfy sikertelen',
|
||||
'admin.notifications.adminNtfyPanel.alwaysOnHint': 'Az admin Ntfy mindig küld, ha egy téma konfigurálva van',
|
||||
'admin.notifications.adminNotificationsHint': 'Állítsa be, hogy mely csatornák szállítsák az admin értesítéseket (pl. verziófrissítési figyelmeztetések). A webhook automatikusan küld, ha admin webhook URL van megadva.',
|
||||
'admin.notifications.tripReminders.title': 'Utazási emlékeztetők',
|
||||
'admin.notifications.tripReminders.hint': 'Emlékeztető értesítést küld az utazás kezdete előtt (az utazásnál megadott emlékeztető napok szükségesek).',
|
||||
'admin.notifications.tripReminders.enabled': 'Utazási emlékeztetők engedélyezve',
|
||||
'admin.notifications.tripReminders.disabled': 'Utazási emlékeztetők letiltva',
|
||||
'admin.tabs.notifications': 'Értesítések',
|
||||
'notifications.versionAvailable.title': 'Elérhető frissítés',
|
||||
'notifications.versionAvailable.text': 'A TREK {version} már elérhető.',
|
||||
@@ -1887,6 +1897,8 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
|
||||
'memories.saveRouteNotConfigured': 'A mentési útvonal nincs konfigurálva ehhez a szolgáltatóhoz',
|
||||
'memories.testRouteNotConfigured': 'A tesztútvonal nincs konfigurálva ehhez a szolgáltatóhoz',
|
||||
'memories.fillRequiredFields': 'Kérjük töltse ki az összes kötelező mezőt',
|
||||
'journey.search.placeholder': 'Utak keresése…',
|
||||
'journey.search.noResults': 'Nincs „{query}" kifejezéssel egyező út',
|
||||
'journey.title': 'Útinaplók',
|
||||
'journey.subtitle': 'Kövesse nyomon utazásait valós időben',
|
||||
'journey.new': 'Új útinapló',
|
||||
@@ -1908,6 +1920,7 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
|
||||
'journey.status.active': 'Aktív',
|
||||
'journey.status.completed': 'Befejezett',
|
||||
'journey.status.upcoming': 'Közelgő',
|
||||
'journey.status.archived': 'Archivált',
|
||||
'journey.checkin.add': 'Bejelentkezés',
|
||||
'journey.checkin.namePlaceholder': 'Helyszín neve',
|
||||
'journey.checkin.notesPlaceholder': 'Jegyzetek (opcionális)',
|
||||
@@ -2061,6 +2074,11 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
|
||||
'journey.settings.name': 'Név',
|
||||
'journey.settings.subtitle': 'Alcím',
|
||||
'journey.settings.subtitlePlaceholder': 'pl. Thaiföld, Vietnam és Kambodzsa',
|
||||
'journey.settings.endJourney': 'Út archiválása',
|
||||
'journey.settings.reopenJourney': 'Út visszaállítása',
|
||||
'journey.settings.archived': 'Út archiválva',
|
||||
'journey.settings.reopened': 'Út újranyitva',
|
||||
'journey.settings.endDescription': 'Elrejti az Élő jelzést. Bármikor újranyitható.',
|
||||
'journey.settings.delete': 'Törlés',
|
||||
'journey.settings.deleteJourney': 'Útinapló törlése',
|
||||
'journey.settings.deleteMessage': 'Törlöd a(z) „{title}" útinaplót? Minden bejegyzés és fotó elveszik.',
|
||||
|
||||
@@ -251,6 +251,10 @@ const id: Record<string, string | { name: string; category: string }[]> = {
|
||||
'admin.notifications.adminNtfyPanel.testFailed': 'Uji Ntfy gagal',
|
||||
'admin.notifications.adminNtfyPanel.alwaysOnHint': 'Admin Ntfy selalu berjalan jika topik dikonfigurasi',
|
||||
'admin.notifications.adminNotificationsHint': 'Atur saluran mana yang mengirimkan notifikasi khusus admin (mis. peringatan versi).',
|
||||
'admin.notifications.tripReminders.title': 'Pengingat Perjalanan',
|
||||
'admin.notifications.tripReminders.hint': 'Mengirim notifikasi pengingat sebelum perjalanan dimulai (memerlukan hari pengingat yang diatur pada perjalanan).',
|
||||
'admin.notifications.tripReminders.enabled': 'Pengingat perjalanan diaktifkan',
|
||||
'admin.notifications.tripReminders.disabled': 'Pengingat perjalanan dinonaktifkan',
|
||||
'admin.smtp.title': 'Email & Notifikasi',
|
||||
'admin.smtp.hint': 'Konfigurasi SMTP untuk pengiriman notifikasi email.',
|
||||
'admin.smtp.testButton': 'Kirim email uji',
|
||||
@@ -606,6 +610,12 @@ 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',
|
||||
@@ -1890,6 +1900,8 @@ const id: Record<string, string | { name: string; category: string }[]> = {
|
||||
'notif.dev.unknown_event.text': 'Tipe event "{event}" tidak terdaftar di EVENT_NOTIFICATION_CONFIG',
|
||||
|
||||
// Journey addon
|
||||
'journey.search.placeholder': 'Cari perjalanan…',
|
||||
'journey.search.noResults': 'Tidak ada perjalanan yang cocok dengan "{query}"',
|
||||
'journey.title': 'Journey',
|
||||
'journey.subtitle': 'Lacak perjalananmu saat terjadi',
|
||||
'journey.new': 'Journey Baru',
|
||||
@@ -1911,6 +1923,7 @@ const id: Record<string, string | { name: string; category: string }[]> = {
|
||||
'journey.status.active': 'Aktif',
|
||||
'journey.status.completed': 'Selesai',
|
||||
'journey.status.upcoming': 'Mendatang',
|
||||
'journey.status.archived': 'Diarsipkan',
|
||||
'journey.checkin.add': 'Check in',
|
||||
'journey.checkin.namePlaceholder': 'Nama lokasi',
|
||||
'journey.checkin.notesPlaceholder': 'Catatan (opsional)',
|
||||
@@ -2088,6 +2101,11 @@ const id: Record<string, string | { name: string; category: string }[]> = {
|
||||
'journey.settings.name': 'Nama',
|
||||
'journey.settings.subtitle': 'Subjudul',
|
||||
'journey.settings.subtitlePlaceholder': 'mis. Thailand, Vietnam & Kamboja',
|
||||
'journey.settings.endJourney': 'Arsipkan Perjalanan',
|
||||
'journey.settings.reopenJourney': 'Pulihkan Perjalanan',
|
||||
'journey.settings.archived': 'Perjalanan diarsipkan',
|
||||
'journey.settings.reopened': 'Perjalanan dibuka kembali',
|
||||
'journey.settings.endDescription': 'Menyembunyikan lencana Langsung. Anda dapat membuka kembali kapan saja.',
|
||||
'journey.settings.delete': 'Hapus',
|
||||
'journey.settings.deleteJourney': 'Hapus Journey',
|
||||
'journey.settings.deleteMessage': 'Hapus "{title}"? Semua entri dan foto akan hilang.',
|
||||
|
||||
@@ -545,6 +545,12 @@ 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',
|
||||
@@ -1843,6 +1849,10 @@ const it: Record<string, string | { name: string; category: string }[]> = {
|
||||
'admin.notifications.adminNtfyPanel.testFailed': 'Invio Ntfy di test fallito',
|
||||
'admin.notifications.adminNtfyPanel.alwaysOnHint': 'Il Ntfy admin si attiva sempre quando un argomento è configurato',
|
||||
'admin.notifications.adminNotificationsHint': 'Configura quali canali consegnano le notifiche admin (es. avvisi di versione). Il webhook si attiva automaticamente se è impostato un URL webhook admin.',
|
||||
'admin.notifications.tripReminders.title': 'Promemoria viaggio',
|
||||
'admin.notifications.tripReminders.hint': 'Invia una notifica promemoria prima dell\'inizio di un viaggio (richiede giorni di promemoria impostati sul viaggio).',
|
||||
'admin.notifications.tripReminders.enabled': 'Promemoria viaggio attivati',
|
||||
'admin.notifications.tripReminders.disabled': 'Promemoria viaggio disattivati',
|
||||
'admin.tabs.notifications': 'Notifiche',
|
||||
'notifications.versionAvailable.title': 'Aggiornamento disponibile',
|
||||
'notifications.versionAvailable.text': 'TREK {version} è ora disponibile.',
|
||||
@@ -1887,6 +1897,8 @@ const it: Record<string, string | { name: string; category: string }[]> = {
|
||||
'common.justNow': 'proprio ora',
|
||||
'common.hoursAgo': '{count}h fa',
|
||||
'common.daysAgo': '{count}g fa',
|
||||
'journey.search.placeholder': 'Cerca viaggi…',
|
||||
'journey.search.noResults': 'Nessun viaggio corrisponde a "{query}"',
|
||||
'journey.title': 'Diario di viaggio',
|
||||
'journey.subtitle': 'Segui i tuoi viaggi in tempo reale',
|
||||
'journey.new': 'Nuovo diario',
|
||||
@@ -1908,6 +1920,7 @@ const it: Record<string, string | { name: string; category: string }[]> = {
|
||||
'journey.status.active': 'Attivo',
|
||||
'journey.status.completed': 'Completato',
|
||||
'journey.status.upcoming': 'In arrivo',
|
||||
'journey.status.archived': 'Archiviato',
|
||||
'journey.checkin.add': 'Check-in',
|
||||
'journey.checkin.namePlaceholder': 'Nome del luogo',
|
||||
'journey.checkin.notesPlaceholder': 'Note (facoltativo)',
|
||||
@@ -2061,6 +2074,11 @@ const it: Record<string, string | { name: string; category: string }[]> = {
|
||||
'journey.settings.name': 'Nome',
|
||||
'journey.settings.subtitle': 'Sottotitolo',
|
||||
'journey.settings.subtitlePlaceholder': 'es. Thailandia, Vietnam e Cambogia',
|
||||
'journey.settings.endJourney': 'Archivia il viaggio',
|
||||
'journey.settings.reopenJourney': 'Ripristina il viaggio',
|
||||
'journey.settings.archived': 'Viaggio archiviato',
|
||||
'journey.settings.reopened': 'Viaggio riaperto',
|
||||
'journey.settings.endDescription': 'Nasconde il badge In diretta. Puoi riaprire in qualsiasi momento.',
|
||||
'journey.settings.delete': 'Elimina',
|
||||
'journey.settings.deleteJourney': 'Elimina diario',
|
||||
'journey.settings.deleteMessage': 'Eliminare "{title}"? Tutte le voci e le foto andranno perse.',
|
||||
|
||||
@@ -546,6 +546,12 @@ 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',
|
||||
@@ -1842,6 +1848,10 @@ const nl: Record<string, string> = {
|
||||
'admin.notifications.adminNtfyPanel.testFailed': 'Test-Ntfy mislukt',
|
||||
'admin.notifications.adminNtfyPanel.alwaysOnHint': 'Admin-Ntfy verstuurt altijd wanneer een onderwerp is geconfigureerd',
|
||||
'admin.notifications.adminNotificationsHint': 'Stel in via welke kanalen admin-meldingen worden bezorgd (bijv. versie-updates). De webhook verstuurt automatisch als er een admin-webhook-URL is ingesteld.',
|
||||
'admin.notifications.tripReminders.title': 'Reisherinneringen',
|
||||
'admin.notifications.tripReminders.hint': 'Stuurt een herinneringsmelding voor de start van een reis (vereist ingestelde herinneringsdagen bij de reis).',
|
||||
'admin.notifications.tripReminders.enabled': 'Reisherinneringen ingeschakeld',
|
||||
'admin.notifications.tripReminders.disabled': 'Reisherinneringen uitgeschakeld',
|
||||
'admin.tabs.notifications': 'Meldingen',
|
||||
'notifications.versionAvailable.title': 'Update beschikbaar',
|
||||
'notifications.versionAvailable.text': 'TREK {version} is nu beschikbaar.',
|
||||
@@ -1886,6 +1896,8 @@ const nl: Record<string, string> = {
|
||||
'common.justNow': 'zojuist',
|
||||
'common.hoursAgo': '{count}u geleden',
|
||||
'common.daysAgo': '{count}d geleden',
|
||||
'journey.search.placeholder': 'Reizen zoeken…',
|
||||
'journey.search.noResults': 'Geen reizen komen overeen met "{query}"',
|
||||
'journey.title': 'Reisverslag',
|
||||
'journey.subtitle': 'Leg je reizen vast terwijl je onderweg bent',
|
||||
'journey.new': 'Nieuw reisverslag',
|
||||
@@ -1907,6 +1919,7 @@ const nl: Record<string, string> = {
|
||||
'journey.status.active': 'Actief',
|
||||
'journey.status.completed': 'Voltooid',
|
||||
'journey.status.upcoming': 'Gepland',
|
||||
'journey.status.archived': 'Gearchiveerd',
|
||||
'journey.checkin.add': 'Inchecken',
|
||||
'journey.checkin.namePlaceholder': 'Locatienaam',
|
||||
'journey.checkin.notesPlaceholder': 'Notities (optioneel)',
|
||||
@@ -2060,6 +2073,11 @@ const nl: Record<string, string> = {
|
||||
'journey.settings.name': 'Naam',
|
||||
'journey.settings.subtitle': 'Ondertitel',
|
||||
'journey.settings.subtitlePlaceholder': 'bijv. Thailand, Vietnam & Cambodja',
|
||||
'journey.settings.endJourney': 'Reis archiveren',
|
||||
'journey.settings.reopenJourney': 'Reis herstellen',
|
||||
'journey.settings.archived': 'Reis gearchiveerd',
|
||||
'journey.settings.reopened': 'Reis heropend',
|
||||
'journey.settings.endDescription': 'Verbergt het Live-badge. Je kunt het altijd heropenen.',
|
||||
'journey.settings.delete': 'Verwijderen',
|
||||
'journey.settings.deleteJourney': 'Reisverslag verwijderen',
|
||||
'journey.settings.deleteMessage': '"{title}" verwijderen? Alle vermeldingen en foto\'s gaan verloren.',
|
||||
|
||||
@@ -518,6 +518,12 @@ 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',
|
||||
@@ -1642,6 +1648,10 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
|
||||
'admin.notifications.adminNtfyPanel.testFailed': 'Wysyłanie testowego Ntfy nie powiodło się',
|
||||
'admin.notifications.adminNtfyPanel.alwaysOnHint': 'Admin Ntfy zawsze wysyła po skonfigurowaniu tematu',
|
||||
'admin.notifications.adminNotificationsHint': 'Skonfiguruj, które kanały dostarczają powiadomienia admina (np. alerty o wersjach). Webhook wysyła automatycznie, gdy ustawiony jest URL webhooka admina.',
|
||||
'admin.notifications.tripReminders.title': 'Przypomnienia o podróżach',
|
||||
'admin.notifications.tripReminders.hint': 'Wysyła powiadomienie z przypomnieniem przed rozpoczęciem podróży (wymaga ustawienia dni przypomnienia dla podróży).',
|
||||
'admin.notifications.tripReminders.enabled': 'Przypomnienia o podróżach włączone',
|
||||
'admin.notifications.tripReminders.disabled': 'Przypomnienia o podróżach wyłączone',
|
||||
'admin.webhook.hint': 'Pozwól użytkownikom konfigurować własne adresy URL webhooka dla powiadomień (Discord, Slack itp.).',
|
||||
'settings.notificationsDisabled': 'Powiadomienia nie są skonfigurowane.',
|
||||
'settings.notificationPreferences.noChannels': 'Brak skonfigurowanych kanałów powiadomień. Poproś administratora o skonfigurowanie powiadomień e-mail lub webhook.',
|
||||
@@ -1879,6 +1889,8 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
|
||||
'memories.saveRouteNotConfigured': 'Trasa zapisu nie jest skonfigurowana dla tego dostawcy',
|
||||
'memories.testRouteNotConfigured': 'Trasa testowa nie jest skonfigurowana dla tego dostawcy',
|
||||
'memories.fillRequiredFields': 'Proszę wypełnić wszystkie wymagane pola',
|
||||
'journey.search.placeholder': 'Szukaj podróży…',
|
||||
'journey.search.noResults': 'Brak podróży pasujących do „{query}"',
|
||||
'journey.title': 'Dziennik podróży',
|
||||
'journey.subtitle': 'Dokumentuj swoje podróże na bieżąco',
|
||||
'journey.new': 'Nowy dziennik podróży',
|
||||
@@ -1900,6 +1912,7 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
|
||||
'journey.status.active': 'Aktywny',
|
||||
'journey.status.completed': 'Zakończony',
|
||||
'journey.status.upcoming': 'Nadchodzący',
|
||||
'journey.status.archived': 'Zarchiwizowano',
|
||||
'journey.checkin.add': 'Zamelduj się',
|
||||
'journey.checkin.namePlaceholder': 'Nazwa miejsca',
|
||||
'journey.checkin.notesPlaceholder': 'Notatki (opcjonalnie)',
|
||||
@@ -2053,6 +2066,11 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
|
||||
'journey.settings.name': 'Nazwa',
|
||||
'journey.settings.subtitle': 'Podtytuł',
|
||||
'journey.settings.subtitlePlaceholder': 'np. Tajlandia, Wietnam i Kambodża',
|
||||
'journey.settings.endJourney': 'Archiwizuj podróż',
|
||||
'journey.settings.reopenJourney': 'Przywróć podróż',
|
||||
'journey.settings.archived': 'Podróż zarchiwizowana',
|
||||
'journey.settings.reopened': 'Podróż wznowiona',
|
||||
'journey.settings.endDescription': 'Ukrywa odznakę Na żywo. Możesz wznowić w dowolnym momencie.',
|
||||
'journey.settings.delete': 'Usuń',
|
||||
'journey.settings.deleteJourney': 'Usuń dziennik podróży',
|
||||
'journey.settings.deleteMessage': 'Usunąć „{title}"? Wszystkie wpisy i zdjęcia zostaną utracone.',
|
||||
|
||||
@@ -546,6 +546,12 @@ 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': 'Чат',
|
||||
@@ -1839,6 +1845,10 @@ const ru: Record<string, string> = {
|
||||
'admin.notifications.adminNtfyPanel.testFailed': 'Ошибка отправки тестового Ntfy',
|
||||
'admin.notifications.adminNtfyPanel.alwaysOnHint': 'Ntfy администратора всегда отправляется при наличии настроенной темы',
|
||||
'admin.notifications.adminNotificationsHint': 'Настройте, какие каналы доставляют уведомления администратора (например, оповещения о версиях). Вебхук отправляется автоматически, если задан URL вебхука администратора.',
|
||||
'admin.notifications.tripReminders.title': 'Напоминания о поездках',
|
||||
'admin.notifications.tripReminders.hint': 'Отправляет напоминание перед началом поездки (необходимо указать дни напоминания в параметрах поездки).',
|
||||
'admin.notifications.tripReminders.enabled': 'Напоминания о поездках включены',
|
||||
'admin.notifications.tripReminders.disabled': 'Напоминания о поездках отключены',
|
||||
'admin.tabs.notifications': 'Уведомления',
|
||||
'notifications.versionAvailable.title': 'Доступно обновление',
|
||||
'notifications.versionAvailable.text': 'TREK {version} теперь доступен.',
|
||||
@@ -1886,6 +1896,8 @@ const ru: Record<string, string> = {
|
||||
'memories.saveRouteNotConfigured': 'Маршрут сохранения не настроен для этого провайдера',
|
||||
'memories.testRouteNotConfigured': 'Маршрут тестирования не настроен для этого провайдера',
|
||||
'memories.fillRequiredFields': 'Пожалуйста, заполните все обязательные поля',
|
||||
'journey.search.placeholder': 'Поиск путешествий…',
|
||||
'journey.search.noResults': 'Путешествий по запросу «{query}» не найдено',
|
||||
'journey.title': 'Путешествие',
|
||||
'journey.subtitle': 'Отслеживайте свои путешествия в реальном времени',
|
||||
'journey.new': 'Новое путешествие',
|
||||
@@ -1907,6 +1919,7 @@ const ru: Record<string, string> = {
|
||||
'journey.status.active': 'Активно',
|
||||
'journey.status.completed': 'Завершено',
|
||||
'journey.status.upcoming': 'Предстоящее',
|
||||
'journey.status.archived': 'В архиве',
|
||||
'journey.checkin.add': 'Отметиться',
|
||||
'journey.checkin.namePlaceholder': 'Название места',
|
||||
'journey.checkin.notesPlaceholder': 'Заметки (необязательно)',
|
||||
@@ -2060,6 +2073,11 @@ const ru: Record<string, string> = {
|
||||
'journey.settings.name': 'Название',
|
||||
'journey.settings.subtitle': 'Подзаголовок',
|
||||
'journey.settings.subtitlePlaceholder': 'напр. Таиланд, Вьетнам и Камбоджа',
|
||||
'journey.settings.endJourney': 'Архивировать путешествие',
|
||||
'journey.settings.reopenJourney': 'Восстановить путешествие',
|
||||
'journey.settings.archived': 'Путешествие архивировано',
|
||||
'journey.settings.reopened': 'Путешествие возобновлено',
|
||||
'journey.settings.endDescription': 'Скрывает значок «В эфире». Вы можете возобновить в любое время.',
|
||||
'journey.settings.delete': 'Удалить',
|
||||
'journey.settings.deleteJourney': 'Удалить путешествие',
|
||||
'journey.settings.deleteMessage': 'Удалить «{title}»? Все записи и фото будут потеряны.',
|
||||
|
||||
@@ -546,6 +546,12 @@ 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': '聊天',
|
||||
@@ -1839,6 +1845,10 @@ const zh: Record<string, string> = {
|
||||
'admin.notifications.adminNtfyPanel.testFailed': '测试 Ntfy 失败',
|
||||
'admin.notifications.adminNtfyPanel.alwaysOnHint': '配置主题后管理员 Ntfy 始终触发',
|
||||
'admin.notifications.adminNotificationsHint': '配置哪些渠道发送管理员通知(如版本更新提醒)。设置管理员 Webhook URL 后,Webhook 将自动触发。',
|
||||
'admin.notifications.tripReminders.title': '行程提醒',
|
||||
'admin.notifications.tripReminders.hint': '在行程开始前发送提醒通知(需要在行程中设置提醒天数)。',
|
||||
'admin.notifications.tripReminders.enabled': '行程提醒已启用',
|
||||
'admin.notifications.tripReminders.disabled': '行程提醒已禁用',
|
||||
'admin.tabs.notifications': '通知',
|
||||
'notifications.versionAvailable.title': '有可用更新',
|
||||
'notifications.versionAvailable.text': 'TREK {version} 现已可用。',
|
||||
@@ -1886,6 +1896,8 @@ const zh: Record<string, string> = {
|
||||
'memories.saveRouteNotConfigured': '此提供商未配置保存路由',
|
||||
'memories.testRouteNotConfigured': '此提供商未配置测试路由',
|
||||
'memories.fillRequiredFields': '请填写所有必填字段',
|
||||
'journey.search.placeholder': '搜索旅程…',
|
||||
'journey.search.noResults': '没有与"{query}"匹配的旅程',
|
||||
'journey.title': '旅程',
|
||||
'journey.subtitle': '实时记录你的旅行',
|
||||
'journey.new': '新建旅程',
|
||||
@@ -1907,6 +1919,7 @@ const zh: Record<string, string> = {
|
||||
'journey.status.active': '进行中',
|
||||
'journey.status.completed': '已完成',
|
||||
'journey.status.upcoming': '即将开始',
|
||||
'journey.status.archived': '已归档',
|
||||
'journey.checkin.add': '签到',
|
||||
'journey.checkin.namePlaceholder': '地点名称',
|
||||
'journey.checkin.notesPlaceholder': '备注(可选)',
|
||||
@@ -2060,6 +2073,11 @@ const zh: Record<string, string> = {
|
||||
'journey.settings.name': '名称',
|
||||
'journey.settings.subtitle': '副标题',
|
||||
'journey.settings.subtitlePlaceholder': '例如 泰国、越南和柬埔寨',
|
||||
'journey.settings.endJourney': '归档旅程',
|
||||
'journey.settings.reopenJourney': '恢复旅程',
|
||||
'journey.settings.archived': '旅程已归档',
|
||||
'journey.settings.reopened': '旅程已重新开启',
|
||||
'journey.settings.endDescription': '隐藏直播标记。您可以随时重新开启。',
|
||||
'journey.settings.delete': '删除',
|
||||
'journey.settings.deleteJourney': '删除旅程',
|
||||
'journey.settings.deleteMessage': '删除"{title}"?所有条目和照片将丢失。',
|
||||
|
||||
@@ -251,6 +251,10 @@ const zhTw: Record<string, string> = {
|
||||
'admin.notifications.adminNtfyPanel.testFailed': '測試 Ntfy 失敗',
|
||||
'admin.notifications.adminNtfyPanel.alwaysOnHint': '設定主題後管理員 Ntfy 始終觸發',
|
||||
'admin.notifications.adminNotificationsHint': '配置哪些渠道傳遞僅管理員通知(例如版本提醒)。',
|
||||
'admin.notifications.tripReminders.title': '行程提醒',
|
||||
'admin.notifications.tripReminders.hint': '在行程開始前發送提醒通知(需要在行程中設定提醒天數)。',
|
||||
'admin.notifications.tripReminders.enabled': '行程提醒已啟用',
|
||||
'admin.notifications.tripReminders.disabled': '行程提醒已停用',
|
||||
'admin.smtp.title': '郵件與通知',
|
||||
'admin.smtp.hint': '用於傳送電子郵件通知的 SMTP 配置。',
|
||||
'admin.smtp.testButton': '傳送測試郵件',
|
||||
@@ -602,6 +606,12 @@ 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': '聊天',
|
||||
@@ -1846,6 +1856,8 @@ const zhTw: Record<string, string> = {
|
||||
'memories.saveRouteNotConfigured': '此提供商未設定儲存路由',
|
||||
'memories.testRouteNotConfigured': '此提供商未設定測試路由',
|
||||
'memories.fillRequiredFields': '請填寫所有必填欄位',
|
||||
'journey.search.placeholder': '搜尋旅程…',
|
||||
'journey.search.noResults': '沒有符合「{query}」的旅程',
|
||||
'journey.title': '旅程',
|
||||
'journey.subtitle': '即時記錄你的旅行',
|
||||
'journey.new': '新建旅程',
|
||||
@@ -1867,6 +1879,7 @@ const zhTw: Record<string, string> = {
|
||||
'journey.status.active': '進行中',
|
||||
'journey.status.completed': '已完成',
|
||||
'journey.status.upcoming': '即將開始',
|
||||
'journey.status.archived': '已封存',
|
||||
'journey.checkin.add': '打卡',
|
||||
'journey.checkin.namePlaceholder': '地點名稱',
|
||||
'journey.checkin.notesPlaceholder': '備註(可選)',
|
||||
@@ -2020,6 +2033,11 @@ const zhTw: Record<string, string> = {
|
||||
'journey.settings.name': '名稱',
|
||||
'journey.settings.subtitle': '副標題',
|
||||
'journey.settings.subtitlePlaceholder': '例如 泰國、越南和柬埔寨',
|
||||
'journey.settings.endJourney': '封存旅程',
|
||||
'journey.settings.reopenJourney': '還原旅程',
|
||||
'journey.settings.archived': '旅程已封存',
|
||||
'journey.settings.reopened': '旅程已重新開啟',
|
||||
'journey.settings.endDescription': '隱藏直播標記。您可以隨時重新開啟。',
|
||||
'journey.settings.delete': '刪除',
|
||||
'journey.settings.deleteJourney': '刪除旅程',
|
||||
'journey.settings.deleteMessage': '刪除「{title}」?所有條目和照片將遺失。',
|
||||
|
||||
@@ -194,6 +194,18 @@ 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(() => {}) }, [])
|
||||
@@ -242,7 +254,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, logout } = useAuthStore()
|
||||
const { user: currentUser, updateApiKeys, setAppRequireMfa, setTripRemindersEnabled, setPlacesPhotosEnabled, setPlacesAutocompleteEnabled, setPlacesDetailsEnabled, logout } = useAuthStore()
|
||||
const navigate = useNavigate()
|
||||
const toast = useToast()
|
||||
|
||||
@@ -1023,6 +1035,66 @@ 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">
|
||||
@@ -1180,6 +1252,7 @@ export default function AdminPage(): React.ReactElement {
|
||||
const emailActive = activeChans.includes('email')
|
||||
const webhookActive = activeChans.includes('webhook')
|
||||
const ntfyActive = activeChans.includes('ntfy')
|
||||
const tripRemindersActive = smtpValues.notify_trip_reminder !== 'false'
|
||||
|
||||
const setChannels = async (email: boolean, webhook: boolean, ntfy: boolean) => {
|
||||
const chans = [email && 'email', webhook && 'webhook', ntfy && 'ntfy'].filter(Boolean).join(',') || 'none'
|
||||
@@ -1338,6 +1411,37 @@ export default function AdminPage(): React.ReactElement {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Trip Reminders Toggle */}
|
||||
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
|
||||
<div className="px-6 py-4 flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="font-semibold text-slate-900">{t('admin.notifications.tripReminders.title')}</h2>
|
||||
<p className="text-xs text-slate-400 mt-1">{t('admin.notifications.tripReminders.hint')}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={async () => {
|
||||
const next = !tripRemindersActive
|
||||
setSmtpValues(prev => ({ ...prev, notify_trip_reminder: next ? 'true' : 'false' }))
|
||||
try {
|
||||
await authApi.updateAppSettings({ notify_trip_reminder: next ? 'true' : 'false' })
|
||||
toast.success(next ? t('admin.notifications.tripReminders.enabled') : t('admin.notifications.tripReminders.disabled'))
|
||||
authApi.getAppConfig().then((c: { trip_reminders_enabled?: boolean }) => {
|
||||
if (c?.trip_reminders_enabled !== undefined) setTripRemindersEnabled(c.trip_reminders_enabled)
|
||||
}).catch(() => {})
|
||||
} catch {
|
||||
setSmtpValues(prev => ({ ...prev, notify_trip_reminder: tripRemindersActive ? 'true' : 'false' }))
|
||||
toast.error(t('common.error'))
|
||||
}
|
||||
}}
|
||||
className="relative inline-flex h-6 w-11 items-center rounded-full transition-colors flex-shrink-0"
|
||||
style={{ background: tripRemindersActive ? '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: tripRemindersActive ? 'translateX(20px)' : 'translateX(0)' }} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Admin Webhook Panel */}
|
||||
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
|
||||
<div className="px-6 py-4 border-b border-slate-100">
|
||||
|
||||
@@ -176,7 +176,7 @@ const mockJourneyDetail = {
|
||||
avatar: null,
|
||||
},
|
||||
],
|
||||
stats: { entries: 2, photos: 1, cities: 2 },
|
||||
stats: { entries: 2, photos: 1, places: 2 },
|
||||
};
|
||||
|
||||
// ── MSW Handlers ─────────────────────────────────────────────────────────────
|
||||
@@ -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
|
||||
expect(screen.getByText('Arrived in Rome')).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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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.getByText('Arrived in Rome')).toBeInTheDocument();
|
||||
expect(screen.getByText('Florence Day')).toBeInTheDocument();
|
||||
expect(screen.getAllByText('Arrived in Rome').length).toBeGreaterThanOrEqual(1);
|
||||
expect(screen.getAllByText('Florence Day').length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -362,12 +362,12 @@ describe('JourneyDetailPage', () => {
|
||||
expect(screen.getAllByText('Days').length).toBeGreaterThanOrEqual(1);
|
||||
expect(screen.getAllByText('Entries').length).toBeGreaterThanOrEqual(1);
|
||||
expect(screen.getAllByText('Photos').length).toBeGreaterThanOrEqual(1);
|
||||
expect(screen.getAllByText('Cities').length).toBeGreaterThanOrEqual(1);
|
||||
expect(screen.getAllByText('Places').length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it('renders stat values', async () => {
|
||||
await renderAndWait();
|
||||
// stats.entries = 2, stats.photos = 1, stats.cities = 2
|
||||
// stats.entries = 2, stats.photos = 1, stats.places = 2
|
||||
// Entries count appears in hero and sidebar
|
||||
const twos = screen.getAllByText('2');
|
||||
expect(twos.length).toBeGreaterThanOrEqual(1);
|
||||
@@ -474,7 +474,7 @@ describe('JourneyDetailPage', () => {
|
||||
// ── FE-PAGE-JOURNEYDETAIL-018 ──────────────────────────────────────────
|
||||
describe('FE-PAGE-JOURNEYDETAIL-018: Empty state when no entries', () => {
|
||||
it('shows "No entries yet" when journey has no entries', async () => {
|
||||
setupDefaultHandlers({ entries: [], stats: { entries: 0, photos: 0, cities: 0 } });
|
||||
setupDefaultHandlers({ entries: [], stats: { entries: 0, photos: 0, places: 0 } });
|
||||
|
||||
render(<JourneyDetailPage />);
|
||||
|
||||
@@ -484,7 +484,7 @@ describe('JourneyDetailPage', () => {
|
||||
});
|
||||
|
||||
it('shows hint text to add a trip', async () => {
|
||||
setupDefaultHandlers({ entries: [], stats: { entries: 0, photos: 0, cities: 0 } });
|
||||
setupDefaultHandlers({ entries: [], stats: { entries: 0, photos: 0, places: 0 } });
|
||||
|
||||
render(<JourneyDetailPage />);
|
||||
|
||||
@@ -567,7 +567,7 @@ describe('JourneyDetailPage', () => {
|
||||
};
|
||||
setupDefaultHandlers({
|
||||
entries: [multiPhotoEntry, mockJourneyDetail.entries[1]],
|
||||
stats: { entries: 2, photos: 3, cities: 2 },
|
||||
stats: { entries: 2, photos: 3, places: 2 },
|
||||
});
|
||||
|
||||
render(<JourneyDetailPage />);
|
||||
@@ -610,12 +610,12 @@ describe('JourneyDetailPage', () => {
|
||||
};
|
||||
setupDefaultHandlers({
|
||||
entries: [...mockJourneyDetail.entries, skeletonEntry],
|
||||
stats: { entries: 3, photos: 1, cities: 3 },
|
||||
stats: { entries: 3, photos: 1, places: 3 },
|
||||
});
|
||||
|
||||
render(<JourneyDetailPage />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Venice Visit')).toBeInTheDocument();
|
||||
expect(screen.getAllByText('Venice Visit').length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
// Skeleton card shows "Add Entry" CTA
|
||||
@@ -650,15 +650,15 @@ describe('JourneyDetailPage', () => {
|
||||
};
|
||||
setupDefaultHandlers({
|
||||
entries: [...mockJourneyDetail.entries, checkinEntry],
|
||||
stats: { entries: 3, photos: 1, cities: 2 },
|
||||
stats: { entries: 3, photos: 1, places: 2 },
|
||||
});
|
||||
|
||||
render(<JourneyDetailPage />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Quick stop at cafe')).toBeInTheDocument();
|
||||
expect(screen.getAllByText('Quick stop at cafe').length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
expect(screen.getByText(/Cafe Roma/)).toBeInTheDocument();
|
||||
expect(screen.getAllByText(/Cafe Roma/).length).toBeGreaterThanOrEqual(1);
|
||||
expect(screen.getByText('Grabbed an espresso')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -707,15 +707,26 @@ describe('JourneyDetailPage', () => {
|
||||
|
||||
// ── FE-PAGE-JOURNEYDETAIL-030 ──────────────────────────────────────────
|
||||
describe('FE-PAGE-JOURNEYDETAIL-030: Active status badge shows Live indicator', () => {
|
||||
it('renders a "Live" badge for active journeys', async () => {
|
||||
it('renders a "Live" badge when linked trip spans today', async () => {
|
||||
setupDefaultHandlers({
|
||||
trips: [{ trip_id: 5, added_at: now, title: 'Current Trip', start_date: '2020-01-01', end_date: '2099-12-31', cover_image: null, currency: 'EUR', place_count: 8 }],
|
||||
});
|
||||
await renderAndWait();
|
||||
expect(screen.getByText('Live')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not render "Live" badge when linked trip is in the past', async () => {
|
||||
await renderAndWait();
|
||||
expect(screen.queryByText('Live')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ── FE-PAGE-JOURNEYDETAIL-031 ──────────────────────────────────────────
|
||||
describe('FE-PAGE-JOURNEYDETAIL-031: Synced with Trips badge renders', () => {
|
||||
it('renders the "Synced with Trips" text in the hero', async () => {
|
||||
it('renders the "Synced with Trips" text in the hero for live journeys', async () => {
|
||||
setupDefaultHandlers({
|
||||
trips: [{ trip_id: 5, added_at: now, title: 'Current Trip', start_date: '2020-01-01', end_date: '2099-12-31', cover_image: null, currency: 'EUR', place_count: 8 }],
|
||||
});
|
||||
await renderAndWait();
|
||||
expect(screen.getByText('Synced with Trips')).toBeInTheDocument();
|
||||
});
|
||||
@@ -741,7 +752,7 @@ describe('JourneyDetailPage', () => {
|
||||
it('shows the place count in the sidebar map', async () => {
|
||||
await renderAndWait();
|
||||
// The sidebar map shows "N Places" text
|
||||
expect(screen.getByText(/Places/)).toBeInTheDocument();
|
||||
expect(screen.getAllByText(/Places/).length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1106,8 +1117,9 @@ describe('JourneyDetailPage', () => {
|
||||
|
||||
// Map view renders a location list with entry titles/location names
|
||||
// The MapView component shows entry names in clickable location items
|
||||
expect(screen.getByText('Arrived in Rome')).toBeInTheDocument();
|
||||
expect(screen.getByText('Florence Day')).toBeInTheDocument();
|
||||
// (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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1166,8 +1178,8 @@ describe('JourneyDetailPage', () => {
|
||||
expect(dayBadges.length).toBeGreaterThanOrEqual(2);
|
||||
|
||||
// Each day group shows its entries
|
||||
expect(screen.getByText('Arrived in Rome')).toBeInTheDocument();
|
||||
expect(screen.getByText('Florence Day')).toBeInTheDocument();
|
||||
expect(screen.getAllByText('Arrived in Rome').length).toBeGreaterThanOrEqual(1);
|
||||
expect(screen.getAllByText('Florence Day').length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1717,7 +1729,7 @@ describe('JourneyDetailPage', () => {
|
||||
};
|
||||
setupDefaultHandlers({
|
||||
entries: [emptyEntry],
|
||||
stats: { entries: 1, photos: 0, cities: 1 },
|
||||
stats: { entries: 1, photos: 0, places: 1 },
|
||||
});
|
||||
|
||||
render(<JourneyDetailPage />);
|
||||
@@ -1867,8 +1879,10 @@ describe('JourneyDetailPage', () => {
|
||||
expect(screen.getAllByTestId('journey-map').length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
// Click the "Arrived in Rome" location item
|
||||
const romeItem = screen.getByText('Arrived in Rome');
|
||||
// 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];
|
||||
await user.click(romeItem);
|
||||
|
||||
// After clicking, the item should gain active styles (translate-x-0.5 on the container)
|
||||
@@ -1930,7 +1944,7 @@ describe('JourneyDetailPage', () => {
|
||||
{ ...mockJourneyDetail.entries[0], id: 10, entry_date: '2026-03-15' },
|
||||
{ ...mockJourneyDetail.entries[1], id: 11, entry_date: '2026-03-15', location_lat: 41.95, location_lng: 12.55 },
|
||||
];
|
||||
setupDefaultHandlers({ entries: twoOnSameDay, stats: { entries: 2, photos: 1, cities: 2 } });
|
||||
setupDefaultHandlers({ entries: twoOnSameDay, stats: { entries: 2, photos: 1, places: 2 } });
|
||||
|
||||
render(<JourneyDetailPage />);
|
||||
await waitFor(() => {
|
||||
@@ -2005,7 +2019,7 @@ describe('JourneyDetailPage', () => {
|
||||
};
|
||||
setupDefaultHandlers({
|
||||
entries: [immichEntry, mockJourneyDetail.entries[1]],
|
||||
stats: { entries: 2, photos: 1, cities: 2 },
|
||||
stats: { entries: 2, photos: 1, places: 2 },
|
||||
});
|
||||
|
||||
render(<JourneyDetailPage />);
|
||||
@@ -2039,7 +2053,7 @@ describe('JourneyDetailPage', () => {
|
||||
};
|
||||
setupDefaultHandlers({
|
||||
entries: [synologyEntry, mockJourneyDetail.entries[1]],
|
||||
stats: { entries: 2, photos: 1, cities: 2 },
|
||||
stats: { entries: 2, photos: 1, places: 2 },
|
||||
});
|
||||
|
||||
render(<JourneyDetailPage />);
|
||||
@@ -2636,7 +2650,7 @@ describe('JourneyDetailPage', () => {
|
||||
};
|
||||
setupDefaultHandlers({
|
||||
entries: [multiPhotoEntry, mockJourneyDetail.entries[1]],
|
||||
stats: { entries: 2, photos: 5, cities: 2 },
|
||||
stats: { entries: 2, photos: 5, places: 2 },
|
||||
});
|
||||
|
||||
render(<JourneyDetailPage />);
|
||||
@@ -2661,7 +2675,7 @@ describe('JourneyDetailPage', () => {
|
||||
};
|
||||
setupDefaultHandlers({
|
||||
entries: [twoPhotoEntry, mockJourneyDetail.entries[1]],
|
||||
stats: { entries: 2, photos: 2, cities: 2 },
|
||||
stats: { entries: 2, photos: 2, places: 2 },
|
||||
});
|
||||
|
||||
render(<JourneyDetailPage />);
|
||||
@@ -3045,7 +3059,7 @@ describe('JourneyDetailPage', () => {
|
||||
};
|
||||
setupDefaultHandlers({
|
||||
entries: [mockJourneyDetail.entries[0], noLocEntry],
|
||||
stats: { entries: 2, photos: 1, cities: 1 },
|
||||
stats: { entries: 2, photos: 1, places: 1 },
|
||||
});
|
||||
|
||||
render(<JourneyDetailPage />);
|
||||
@@ -3528,7 +3542,7 @@ describe('JourneyDetailPage', () => {
|
||||
};
|
||||
setupDefaultHandlers({
|
||||
entries: [entryWithMultiPhotos, mockJourneyDetail.entries[1]],
|
||||
stats: { entries: 2, photos: 2, cities: 2 },
|
||||
stats: { entries: 2, photos: 2, places: 2 },
|
||||
});
|
||||
|
||||
server.use(
|
||||
@@ -3620,7 +3634,7 @@ describe('JourneyDetailPage', () => {
|
||||
};
|
||||
setupDefaultHandlers({
|
||||
entries: [mockJourneyDetail.entries[0], noTitleEntry],
|
||||
stats: { entries: 2, photos: 1, cities: 2 },
|
||||
stats: { entries: 2, photos: 1, places: 2 },
|
||||
});
|
||||
|
||||
render(<JourneyDetailPage />);
|
||||
|
||||
@@ -24,6 +24,7 @@ import MobileMapTimeline from '../components/Journey/MobileMapTimeline'
|
||||
import MobileEntryView from '../components/Journey/MobileEntryView'
|
||||
import { useIsMobile } from '../hooks/useIsMobile'
|
||||
import type { JourneyEntry, JourneyPhoto, JourneyDetail } from '../store/journeyStore'
|
||||
import { computeJourneyLifecycle } from '../utils/journeyLifecycle'
|
||||
|
||||
const GRADIENTS = [
|
||||
'linear-gradient(135deg, #0F172A 0%, #6366F1 45%, #EC4899 100%)',
|
||||
@@ -163,6 +164,12 @@ 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]
|
||||
@@ -207,6 +214,14 @@ export default function JourneyDetailPage() {
|
||||
const dayGroups = groupByDate(timelineEntries)
|
||||
const sortedDates = [...dayGroups.keys()].sort()
|
||||
|
||||
const tripDateMin = current.trips.length
|
||||
? current.trips.reduce((min: string, t: any) => t.start_date && (!min || t.start_date < min) ? t.start_date : min, '')
|
||||
: null
|
||||
const tripDateMax = current.trips.length
|
||||
? current.trips.reduce((max: string, t: any) => t.end_date && (!max || t.end_date > max) ? t.end_date : max, '')
|
||||
: null
|
||||
const lifecycle = computeJourneyLifecycle(current.status, tripDateMin || null, tripDateMax || null)
|
||||
|
||||
const showMobileCombined = isMobile && view === 'timeline'
|
||||
|
||||
return (
|
||||
@@ -283,16 +298,28 @@ export default function JourneyDetailPage() {
|
||||
<div className="relative z-[3] flex items-center justify-between mb-5">
|
||||
{/* Desktop: badges */}
|
||||
<div className="hidden md:flex items-center gap-2">
|
||||
{current.status === 'active' && (
|
||||
{lifecycle === 'live' && (
|
||||
<div className="inline-flex items-center gap-2 px-2.5 py-1 bg-white/15 backdrop-blur rounded-full text-[10px] font-semibold uppercase">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-emerald-500 animate-pulse" />
|
||||
Live
|
||||
{t('journey.frontpage.live')}
|
||||
</div>
|
||||
)}
|
||||
{lifecycle !== 'archived' && current.trips.length > 0 && (
|
||||
<div className="inline-flex items-center gap-1.5 px-2.5 py-1 bg-white/[0.12] backdrop-blur border border-white/15 rounded-full text-[11px] font-medium">
|
||||
<RefreshCw size={11} />
|
||||
{t('journey.detail.syncedWithTrips')}
|
||||
</div>
|
||||
)}
|
||||
{lifecycle !== 'live' && lifecycle !== 'archived' && (
|
||||
<div className="inline-flex items-center gap-1.5 px-2.5 py-1 bg-white/[0.12] backdrop-blur border border-white/15 rounded-full text-[11px] font-medium">
|
||||
{t(`journey.status.${lifecycle === 'upcoming' ? 'upcoming' : lifecycle === 'draft' ? 'draft' : 'completed'}`)}
|
||||
</div>
|
||||
)}
|
||||
{lifecycle === 'archived' && (
|
||||
<div className="inline-flex items-center gap-1.5 px-2.5 py-1 bg-white/[0.12] backdrop-blur border border-white/15 rounded-full text-[11px] font-medium">
|
||||
{t('journey.status.archived')}
|
||||
</div>
|
||||
)}
|
||||
<div className="inline-flex items-center gap-1.5 px-2.5 py-1 bg-white/[0.12] backdrop-blur border border-white/15 rounded-full text-[11px] font-medium">
|
||||
<RefreshCw size={11} />
|
||||
{t('journey.detail.syncedWithTrips')}
|
||||
</div>
|
||||
</div>
|
||||
{/* Mobile: back button on the left */}
|
||||
<button
|
||||
@@ -331,7 +358,7 @@ export default function JourneyDetailPage() {
|
||||
<div className="flex gap-8">
|
||||
{[
|
||||
{ value: sortedDates.length, label: t('journey.stats.days') },
|
||||
{ value: current.stats.cities, label: t('journey.stats.cities') },
|
||||
{ value: current.stats.places, label: t('journey.stats.places') },
|
||||
{ value: current.stats.entries, label: t('journey.stats.entries') },
|
||||
{ value: current.stats.photos, label: t('journey.stats.photos') },
|
||||
].map(s => (
|
||||
@@ -392,8 +419,8 @@ export default function JourneyDetailPage() {
|
||||
</div>
|
||||
|
||||
{/* Timeline (desktop only — mobile uses fullscreen combined view above) */}
|
||||
{!isMobile && view === 'timeline' && (
|
||||
<div className="flex flex-col gap-6 pb-24 md:pb-6">
|
||||
{!isMobile && (
|
||||
<div className={`flex flex-col gap-6 pb-24 md:pb-6${view === 'timeline' ? '' : ' hidden'}`}>
|
||||
{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">
|
||||
@@ -448,7 +475,7 @@ export default function JourneyDetailPage() {
|
||||
)}
|
||||
|
||||
{/* Gallery View */}
|
||||
{view === 'gallery' && (
|
||||
<div className={view === 'gallery' ? '' : 'hidden'}>
|
||||
<GalleryView
|
||||
entries={current.entries}
|
||||
journeyId={current.id}
|
||||
@@ -457,17 +484,21 @@ 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 && view === 'map' && <div className="pb-24 md:pb-6"><MapView
|
||||
entries={current.entries}
|
||||
mapEntries={mapEntries}
|
||||
sortedDates={sortedDates}
|
||||
activeLocationId={activeLocationId}
|
||||
fullMapRef={fullMapRef}
|
||||
onLocationClick={handleLocationClick}
|
||||
/></div>}
|
||||
{!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>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right sidebar — hidden on mobile */}
|
||||
@@ -494,7 +525,7 @@ export default function JourneyDetailPage() {
|
||||
{ value: sortedDates.length, label: t('journey.stats.days') },
|
||||
{ value: current.stats.entries, label: t('journey.stats.entries') },
|
||||
{ value: current.stats.photos, label: t('journey.stats.photos') },
|
||||
{ value: current.stats.cities, label: t('journey.stats.cities') },
|
||||
{ value: current.stats.places, label: t('journey.stats.places') },
|
||||
].map(s => (
|
||||
<div key={s.label} className="rounded-lg bg-zinc-50 dark:bg-zinc-800/60 border border-zinc-100 dark:border-zinc-700/50 px-3 py-2.5">
|
||||
<div className="text-[18px] font-bold tracking-[-0.02em] text-zinc-900 dark:text-white leading-none mb-0.5">{s.value}</div>
|
||||
@@ -1021,7 +1052,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 (assetIds, entryId) => {
|
||||
onAdd={async (groups, entryId) => {
|
||||
let targetId = entryId
|
||||
if (!targetId) {
|
||||
try {
|
||||
@@ -1034,10 +1065,12 @@ function GalleryView({ entries, journeyId, userId, trips, onPhotoClick, onRefres
|
||||
} catch { return }
|
||||
}
|
||||
let added = 0
|
||||
try {
|
||||
const result = await journeyApi.addProviderPhotos(targetId, pickerProvider!, assetIds)
|
||||
added = result.added || 0
|
||||
} catch {}
|
||||
for (const group of groups) {
|
||||
try {
|
||||
const result = await journeyApi.addProviderPhotos(targetId, pickerProvider!, group.assetIds, undefined, group.passphrase)
|
||||
added += result.added || 0
|
||||
} catch {}
|
||||
}
|
||||
if (added > 0) {
|
||||
toast.success(t('journey.photosAdded', { count: added }))
|
||||
onRefresh()
|
||||
@@ -1511,7 +1544,7 @@ function ProviderPicker({ provider, userId, entries, trips, existingAssetIds, on
|
||||
trips: JourneyTrip[]
|
||||
existingAssetIds: Set<string>
|
||||
onClose: () => void
|
||||
onAdd: (assetIds: string[], entryId: number | null) => Promise<void>
|
||||
onAdd: (groups: Array<{ assetIds: string[]; passphrase?: string }>, entryId: number | null) => Promise<void>
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const [filter, setFilter] = useState<'trip' | 'custom' | 'all' | 'album'>('trip')
|
||||
@@ -1525,7 +1558,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<Set<string>>(new Set())
|
||||
const [selected, setSelected] = useState<Map<string, { albumId?: string; passphrase?: string }>>(new Map())
|
||||
const [customFrom, setCustomFrom] = useState('')
|
||||
const [customTo, setCustomTo] = useState('')
|
||||
const [targetEntryId, setTargetEntryId] = useState<number | null>(null)
|
||||
@@ -1617,8 +1650,12 @@ function ProviderPicker({ provider, userId, entries, trips, existingAssetIds, on
|
||||
|
||||
const toggleAsset = (id: string) => {
|
||||
setSelected(prev => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(id)) next.delete(id); else next.add(id)
|
||||
const next = new Map(prev)
|
||||
if (next.has(id)) {
|
||||
next.delete(id)
|
||||
} else {
|
||||
next.set(id, { albumId: selectedAlbum ?? undefined, passphrase: selectedAlbumPassphrase })
|
||||
}
|
||||
return next
|
||||
})
|
||||
}
|
||||
@@ -1780,9 +1817,9 @@ function ProviderPicker({ provider, userId, entries, trips, existingAssetIds, on
|
||||
<button
|
||||
onClick={() => {
|
||||
if (allSelected) {
|
||||
setSelected(new Set())
|
||||
setSelected(new Map())
|
||||
} else {
|
||||
setSelected(new Set(selectable.map((a: any) => a.id)))
|
||||
setSelected(new Map(selectable.map((a: any) => [a.id, { albumId: selectedAlbum ?? undefined, passphrase: selectedAlbumPassphrase }])))
|
||||
}
|
||||
}}
|
||||
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"
|
||||
@@ -1884,7 +1921,16 @@ function ProviderPicker({ provider, userId, entries, trips, existingAssetIds, on
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onAdd([...selected], targetEntryId)}
|
||||
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)
|
||||
}}
|
||||
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"
|
||||
>
|
||||
@@ -2091,7 +2137,7 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[9999] flex items-end sm:items-center sm:justify-center sm:p-5" style={{ background: 'rgba(9,9,11,0.75)' }}>
|
||||
<div className="bg-white dark:bg-zinc-900 sm:rounded-2xl shadow-[0_20px_40px_rgba(0,0,0,0.2)] sm:max-w-[640px] w-full flex flex-col overflow-hidden h-full sm:h-auto sm:max-h-[90vh]">
|
||||
<div className="bg-white dark:bg-zinc-900 sm:rounded-2xl shadow-[0_20px_40px_rgba(0,0,0,0.2)] sm:max-w-[640px] w-full flex flex-col overflow-hidden h-full sm:h-auto sm:max-h-[90vh]" style={{ paddingBottom: 'var(--bottom-nav-h)' }}>
|
||||
|
||||
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-zinc-200 dark:border-zinc-700">
|
||||
@@ -2820,6 +2866,21 @@ function JourneySettingsDialog({ journey, onClose, onSaved, onOpenInvite }: {
|
||||
}
|
||||
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
|
||||
const [archiving, setArchiving] = useState(false)
|
||||
|
||||
const handleArchiveToggle = async () => {
|
||||
setArchiving(true)
|
||||
try {
|
||||
const newStatus = journey.status === 'archived' ? 'active' : 'archived'
|
||||
await updateJourney(journey.id, { status: newStatus })
|
||||
toast.success(newStatus === 'archived' ? t('journey.settings.archived') : t('journey.settings.reopened'))
|
||||
onSaved()
|
||||
} catch {
|
||||
toast.error(t('journey.settings.saveFailed'))
|
||||
} finally {
|
||||
setArchiving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async () => {
|
||||
try {
|
||||
@@ -2947,11 +3008,19 @@ function JourneySettingsDialog({ journey, onClose, onSaved, onOpenInvite }: {
|
||||
<div className="flex flex-wrap items-center gap-2 px-6 py-4 pb-6 md:pb-4 border-t border-zinc-200 dark:border-zinc-700 bg-zinc-50 dark:bg-zinc-800/50">
|
||||
<button
|
||||
onClick={() => setShowDeleteConfirm(true)}
|
||||
className="flex items-center gap-1.5 text-[12px] font-medium text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-lg px-2.5 py-2 mr-auto"
|
||||
className="flex items-center gap-1.5 text-[12px] font-medium text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-lg px-2.5 py-2"
|
||||
>
|
||||
<Trash2 size={13} />
|
||||
{t('journey.settings.delete')}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleArchiveToggle}
|
||||
disabled={archiving}
|
||||
className="flex items-center gap-1.5 text-[12px] font-medium text-zinc-600 dark:text-zinc-400 hover:bg-zinc-100 dark:hover:bg-zinc-700 rounded-lg px-2.5 py-2 mr-auto disabled:opacity-40"
|
||||
title={t('journey.settings.endDescription')}
|
||||
>
|
||||
{journey.status === 'archived' ? t('journey.settings.reopenJourney') : t('journey.settings.endJourney')}
|
||||
</button>
|
||||
<button onClick={onClose} className="px-3.5 py-2 rounded-lg border border-zinc-200 dark:border-zinc-600 text-[13px] font-medium text-zinc-700 dark:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-zinc-700">{t('common.cancel')}</button>
|
||||
<button onClick={handleSave} disabled={saving || !title.trim()} 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">
|
||||
{saving ? t('common.saving') : t('common.save')}
|
||||
|
||||
@@ -43,7 +43,9 @@ function buildJourneyListItem(overrides: Record<string, unknown> = {}) {
|
||||
status: 'draft' as const,
|
||||
entry_count: 0,
|
||||
photo_count: 0,
|
||||
city_count: 0,
|
||||
place_count: 0,
|
||||
trip_date_min: null as string | null,
|
||||
trip_date_max: null as string | null,
|
||||
created_at: Date.now(),
|
||||
updated_at: Date.now(),
|
||||
...overrides,
|
||||
@@ -194,7 +196,7 @@ describe('JourneyPage', () => {
|
||||
|
||||
// FE-PAGE-JOURNEY-008
|
||||
it('FE-PAGE-JOURNEY-008: shows active journey hero when active journey exists', async () => {
|
||||
const active = buildJourneyListItem({ id: 10, title: 'Active Trip', status: 'active' });
|
||||
const active = buildJourneyListItem({ id: 10, title: 'Active Trip', status: 'active', trip_date_min: '2020-01-01', trip_date_max: '2099-12-31' });
|
||||
const other = buildJourneyListItem({ id: 11, title: 'Completed Trip', status: 'completed' });
|
||||
setupDefaultHandlers([active, other]);
|
||||
|
||||
@@ -320,13 +322,13 @@ describe('JourneyPage', () => {
|
||||
});
|
||||
|
||||
// FE-PAGE-JOURNEY-013
|
||||
it('FE-PAGE-JOURNEY-013: journey card shows entry/photo/city counts', async () => {
|
||||
it('FE-PAGE-JOURNEY-013: journey card shows entry/photo/place counts', async () => {
|
||||
const j1 = buildJourneyListItem({
|
||||
id: 20,
|
||||
title: 'Stats Journey',
|
||||
entry_count: 12,
|
||||
photo_count: 47,
|
||||
city_count: 5,
|
||||
place_count: 5,
|
||||
});
|
||||
setupDefaultHandlers([j1]);
|
||||
|
||||
@@ -335,7 +337,7 @@ describe('JourneyPage', () => {
|
||||
expect(screen.getByText('Stats Journey')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// The card renders entry_count, photo_count, city_count values
|
||||
// The card renders entry_count, photo_count, place_count values
|
||||
expect(screen.getByText('12')).toBeInTheDocument();
|
||||
expect(screen.getByText('47')).toBeInTheDocument();
|
||||
expect(screen.getByText('5')).toBeInTheDocument();
|
||||
@@ -361,6 +363,8 @@ describe('JourneyPage', () => {
|
||||
id: 40,
|
||||
title: 'Recent Active',
|
||||
status: 'active',
|
||||
trip_date_min: '2020-01-01',
|
||||
trip_date_max: '2099-12-31',
|
||||
updated_at: Date.now() - 60000, // 1 minute ago
|
||||
});
|
||||
setupDefaultHandlers([active]);
|
||||
@@ -380,6 +384,8 @@ describe('JourneyPage', () => {
|
||||
id: 41,
|
||||
title: 'Hours Active',
|
||||
status: 'active',
|
||||
trip_date_min: '2020-01-01',
|
||||
trip_date_max: '2099-12-31',
|
||||
updated_at: Date.now() - 3 * 3600000, // 3 hours ago
|
||||
});
|
||||
setupDefaultHandlers([active]);
|
||||
@@ -399,6 +405,8 @@ describe('JourneyPage', () => {
|
||||
id: 42,
|
||||
title: 'Days Active',
|
||||
status: 'active',
|
||||
trip_date_min: '2020-01-01',
|
||||
trip_date_max: '2099-12-31',
|
||||
updated_at: Date.now() - 5 * 24 * 3600000, // 5 days ago
|
||||
});
|
||||
setupDefaultHandlers([active]);
|
||||
@@ -414,7 +422,7 @@ describe('JourneyPage', () => {
|
||||
|
||||
// FE-PAGE-JOURNEY-018
|
||||
it('FE-PAGE-JOURNEY-018: active journey hero shows "Continue writing" button', async () => {
|
||||
const active = buildJourneyListItem({ id: 50, title: 'Writing Journey', status: 'active' });
|
||||
const active = buildJourneyListItem({ id: 50, title: 'Writing Journey', status: 'active', trip_date_min: '2020-01-01', trip_date_max: '2099-12-31' });
|
||||
setupDefaultHandlers([active]);
|
||||
|
||||
render(<JourneyPage />);
|
||||
@@ -427,7 +435,7 @@ describe('JourneyPage', () => {
|
||||
|
||||
// FE-PAGE-JOURNEY-019
|
||||
it('FE-PAGE-JOURNEY-019: active journey hero shows Live and Synced badges', async () => {
|
||||
const active = buildJourneyListItem({ id: 51, title: 'Live Journey', status: 'active' });
|
||||
const active = buildJourneyListItem({ id: 51, title: 'Live Journey', status: 'active', trip_date_min: '2020-01-01', trip_date_max: '2099-12-31' });
|
||||
setupDefaultHandlers([active]);
|
||||
|
||||
render(<JourneyPage />);
|
||||
@@ -442,7 +450,7 @@ describe('JourneyPage', () => {
|
||||
// FE-PAGE-JOURNEY-020
|
||||
it('FE-PAGE-JOURNEY-020: clicking active journey hero navigates to its detail page', async () => {
|
||||
const user = userEvent.setup();
|
||||
const active = buildJourneyListItem({ id: 60, title: 'Clickable Hero', status: 'active' });
|
||||
const active = buildJourneyListItem({ id: 60, title: 'Clickable Hero', status: 'active', trip_date_min: '2020-01-01', trip_date_max: '2099-12-31' });
|
||||
setupDefaultHandlers([active]);
|
||||
|
||||
render(<JourneyPage />);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState, useMemo } from 'react'
|
||||
import { useEffect, useState, useMemo, useRef } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useJourneyStore } from '../store/journeyStore'
|
||||
import { journeyApi } from '../api/client'
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
Check, X, ChevronRight, RefreshCw, Users,
|
||||
} from 'lucide-react'
|
||||
import type { Journey } from '../store/journeyStore'
|
||||
import { computeJourneyLifecycle } from '../utils/journeyLifecycle'
|
||||
|
||||
const GRADIENTS = [
|
||||
'linear-gradient(135deg, #0F172A 0%, #6366F1 45%, #EC4899 100%)',
|
||||
@@ -43,6 +44,9 @@ export default function JourneyPage() {
|
||||
const [newTitle, setNewTitle] = useState('')
|
||||
const [availableTrips, setAvailableTrips] = useState<any[]>([])
|
||||
const [selectedTripIds, setSelectedTripIds] = useState<Set<number>>(new Set())
|
||||
const [searchOpen, setSearchOpen] = useState(false)
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const searchInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
// suggestion
|
||||
const [suggestions, setSuggestions] = useState<any[]>([])
|
||||
@@ -56,12 +60,22 @@ export default function JourneyPage() {
|
||||
const activeSuggestion = suggestions.find(s => !dismissedSuggestions.has(s.id))
|
||||
|
||||
const activeJourney = useMemo(() => {
|
||||
return journeys.find(j => j.status === 'active') || null
|
||||
}, [journeys])
|
||||
if (searchQuery.trim()) return null
|
||||
return journeys.find(j => {
|
||||
const j2 = j as any
|
||||
return computeJourneyLifecycle(j.status, j2.trip_date_min, j2.trip_date_max) === 'live'
|
||||
}) || null
|
||||
}, [journeys, searchQuery])
|
||||
|
||||
const otherJourneys = useMemo(() => {
|
||||
return journeys.filter(j => j.id !== activeJourney?.id)
|
||||
}, [journeys, activeJourney])
|
||||
const filteredJourneys = useMemo(() => {
|
||||
const q = searchQuery.trim().toLowerCase()
|
||||
if (!q) return journeys.filter(j => j.id !== activeJourney?.id)
|
||||
return journeys.filter(j => {
|
||||
const inTitle = j.title.toLowerCase().includes(q)
|
||||
const inSubtitle = j.subtitle?.toLowerCase().includes(q) ?? false
|
||||
return inTitle || inSubtitle
|
||||
})
|
||||
}, [journeys, activeJourney, searchQuery])
|
||||
|
||||
const openCreateModal = async (preSelectedTripId?: number) => {
|
||||
setShowCreate(true)
|
||||
@@ -99,15 +113,41 @@ export default function JourneyPage() {
|
||||
<div style={{ paddingTop: 'var(--nav-h, 56px)' }}>
|
||||
<div className="max-w-[1440px] mx-auto">
|
||||
|
||||
{/* Header — mobile: just a create button */}
|
||||
<div className="md:hidden px-5 pt-5 pb-4">
|
||||
<button
|
||||
onClick={() => openCreateModal()}
|
||||
className="w-full flex items-center justify-center gap-2 py-3 rounded-xl bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 text-[14px] font-semibold active:scale-[0.98] transition-transform"
|
||||
>
|
||||
<Plus size={16} strokeWidth={2.5} />
|
||||
{t('journey.frontpage.createJourney')}
|
||||
</button>
|
||||
{/* Header — mobile */}
|
||||
<div className="md:hidden px-5 pt-5 pb-4 flex flex-col gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => {
|
||||
if (searchOpen) {
|
||||
setSearchOpen(false)
|
||||
setSearchQuery('')
|
||||
} else {
|
||||
setSearchOpen(true)
|
||||
setTimeout(() => searchInputRef.current?.focus(), 50)
|
||||
}
|
||||
}}
|
||||
className="w-10 h-10 rounded-xl border border-zinc-200 dark:border-zinc-700 bg-white dark:bg-zinc-800 flex items-center justify-center text-zinc-500 hover:bg-zinc-50 dark:hover:bg-zinc-700 flex-shrink-0"
|
||||
>
|
||||
{searchOpen ? <X size={15} /> : <Search size={15} />}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => openCreateModal()}
|
||||
className="flex-1 flex items-center justify-center gap-2 py-2.5 rounded-xl bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 text-[14px] font-semibold active:scale-[0.98] transition-transform"
|
||||
>
|
||||
<Plus size={16} strokeWidth={2.5} />
|
||||
{t('journey.frontpage.createJourney')}
|
||||
</button>
|
||||
</div>
|
||||
{searchOpen && (
|
||||
<input
|
||||
ref={searchInputRef}
|
||||
value={searchQuery}
|
||||
onChange={e => setSearchQuery(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Escape') { setSearchQuery(''); setSearchOpen(false) } }}
|
||||
placeholder={t('journey.search.placeholder')}
|
||||
className="w-full px-3.5 py-2.5 border border-zinc-200 dark:border-zinc-700 rounded-xl text-[14px] bg-white dark:bg-zinc-800 text-zinc-900 dark:text-white focus:border-zinc-400 focus:outline-none"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Header — desktop */}
|
||||
@@ -117,8 +157,24 @@ export default function JourneyPage() {
|
||||
<p className="text-[13px] text-zinc-500 mt-1.5">{t("journey.frontpage.subtitle")}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button className="w-9 h-9 rounded-[10px] border border-zinc-200 dark:border-zinc-700 bg-white dark:bg-zinc-800 flex items-center justify-center text-zinc-500 hover:bg-zinc-50 dark:hover:bg-zinc-700">
|
||||
<Search size={15} />
|
||||
{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()}
|
||||
@@ -226,7 +282,7 @@ export default function JourneyPage() {
|
||||
{[
|
||||
{ val: (activeJourney as any).entry_count ?? '--', label: t("journey.stats.entries") },
|
||||
{ val: (activeJourney as any).photo_count ?? '--', label: t("journey.stats.photos") },
|
||||
{ val: (activeJourney as any).city_count ?? '--', label: t("journey.stats.cities") },
|
||||
{ val: (activeJourney as any).place_count ?? '--', label: t("journey.stats.places") },
|
||||
].map(s => (
|
||||
<div key={s.label} className="flex flex-col gap-1">
|
||||
<span className="text-[28px] font-extrabold tracking-[-0.02em] leading-none">{s.val}</span>
|
||||
@@ -243,11 +299,24 @@ export default function JourneyPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Search results info */}
|
||||
{searchQuery.trim() && (
|
||||
<div className="mb-4 flex items-center gap-2">
|
||||
<span className="text-[13px] text-zinc-500">
|
||||
{filteredJourneys.length === 0
|
||||
? t('journey.search.noResults', { query: searchQuery.trim() })
|
||||
: `${filteredJourneys.length} ${t('journey.frontpage.journeys')}`}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* All Journeys */}
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<span className="text-[11px] font-bold tracking-[0.14em] uppercase text-zinc-500">{t("journey.frontpage.allJourneys")}</span>
|
||||
<span className="text-[11px] text-zinc-400">{journeys.length} {t('journey.frontpage.journeys')}</span>
|
||||
</div>
|
||||
{!searchQuery.trim() && (
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<span className="text-[11px] font-bold tracking-[0.14em] uppercase text-zinc-500">{t("journey.frontpage.allJourneys")}</span>
|
||||
<span className="text-[11px] text-zinc-400">{journeys.length} {t('journey.frontpage.journeys')}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading && journeys.length === 0 ? (
|
||||
<div className="flex justify-center py-16">
|
||||
@@ -255,7 +324,7 @@ export default function JourneyPage() {
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 md:gap-[18px]">
|
||||
{otherJourneys.map(j => (
|
||||
{filteredJourneys.map(j => (
|
||||
<JourneyCard key={j.id} journey={j} onClick={() => navigate(`/journey/${j.id}`)} />
|
||||
))}
|
||||
|
||||
@@ -279,7 +348,7 @@ export default function JourneyPage() {
|
||||
{/* Create Modal */}
|
||||
{showCreate && (
|
||||
<div className="fixed inset-0 z-[200] flex items-center justify-center p-5" style={{ background: 'rgba(9,9,11,0.6)', backdropFilter: 'blur(6px)' }}>
|
||||
<div className="bg-white dark:bg-zinc-900 rounded-2xl shadow-[0_20px_40px_rgba(0,0,0,0.2)] max-w-[640px] w-full max-h-[90vh] flex flex-col overflow-hidden">
|
||||
<div className="bg-white dark:bg-zinc-900 rounded-2xl shadow-[0_20px_40px_rgba(0,0,0,0.2)] max-w-[640px] w-full max-h-[90vh] flex flex-col overflow-hidden" style={{ paddingBottom: 'var(--bottom-nav-h)' }}>
|
||||
|
||||
{/* Header */}
|
||||
<div className="px-7 pt-6 pb-5 border-b border-zinc-200 dark:border-zinc-700">
|
||||
@@ -386,12 +455,13 @@ export default function JourneyPage() {
|
||||
)
|
||||
}
|
||||
|
||||
function JourneyCard({ journey, onClick }: { journey: Journey & { entry_count?: number; photo_count?: number; city_count?: number }; onClick: () => void }) {
|
||||
function JourneyCard({ journey, onClick }: { journey: Journey & { entry_count?: number; photo_count?: number; place_count?: number; trip_date_min?: string | null; trip_date_max?: string | null }; onClick: () => void }) {
|
||||
const { t } = useTranslation()
|
||||
const j = journey
|
||||
const entryCount = j.entry_count ?? 0
|
||||
const photoCount = j.photo_count ?? 0
|
||||
const cityCount = j.city_count ?? 0
|
||||
const placeCount = j.place_count ?? 0
|
||||
const lifecycle = computeJourneyLifecycle(j.status, j.trip_date_min, j.trip_date_max)
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -424,15 +494,22 @@ function JourneyCard({ journey, onClick }: { journey: Journey & { entry_count?:
|
||||
{j.subtitle && (
|
||||
<p className="text-[12px] text-zinc-500 mt-1">{j.subtitle}</p>
|
||||
)}
|
||||
{j.status === 'draft' && (
|
||||
<span className="inline-flex self-start mt-1.5 px-2 py-0.5 rounded-full bg-zinc-100 dark:bg-zinc-800 text-[10px] font-medium text-zinc-500 uppercase tracking-wide">{t('journey.status.draft')}</span>
|
||||
{lifecycle !== 'live' && (
|
||||
<span className={`inline-flex self-start mt-1.5 px-2 py-0.5 rounded-full text-[10px] font-medium uppercase tracking-wide ${
|
||||
lifecycle === 'archived' ? 'bg-zinc-100 dark:bg-zinc-800 text-zinc-500' :
|
||||
lifecycle === 'upcoming' ? 'bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-400' :
|
||||
lifecycle === 'completed' ? 'bg-emerald-100 dark:bg-emerald-900/30 text-emerald-700 dark:text-emerald-400' :
|
||||
'bg-zinc-100 dark:bg-zinc-800 text-zinc-500'
|
||||
}`}>
|
||||
{t(`journey.status.${lifecycle}`)}
|
||||
</span>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-3 gap-2.5 mt-auto pt-3.5 border-t border-zinc-100 dark:border-zinc-800" style={{ marginTop: j.subtitle ? 14 : 'auto' }}>
|
||||
{[
|
||||
{ val: entryCount, label: t('journey.stats.entries') },
|
||||
{ val: photoCount, label: t('journey.stats.photos') },
|
||||
{ val: cityCount, label: t('journey.stats.cities') },
|
||||
{ val: placeCount, label: t('journey.stats.places') },
|
||||
].map(s => (
|
||||
<div key={s.label} className="flex flex-col gap-1">
|
||||
<span className={`text-[16px] font-bold leading-none tracking-[-0.01em] ${s.val > 0 ? 'text-zinc-900 dark:text-white' : 'text-zinc-300 dark:text-zinc-600'}`}>
|
||||
|
||||
@@ -109,7 +109,7 @@ const mockJourneyData = {
|
||||
stats: {
|
||||
entries: 2,
|
||||
photos: 1,
|
||||
cities: 2,
|
||||
places: 2,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -354,7 +354,7 @@ describe('JourneyPublicPage', () => {
|
||||
],
|
||||
},
|
||||
],
|
||||
stats: { entries: 1, photos: 3, cities: 0 },
|
||||
stats: { entries: 1, photos: 3, places: 0 },
|
||||
};
|
||||
|
||||
server.use(
|
||||
@@ -383,7 +383,7 @@ describe('JourneyPublicPage', () => {
|
||||
it('FE-PAGE-PUBLICJOURNEY-015: stats display shows entries, photos, and cities counts', async () => {
|
||||
const customData = {
|
||||
...mockJourneyData,
|
||||
stats: { entries: 14, photos: 83, cities: 7 },
|
||||
stats: { entries: 14, photos: 83, places: 7 },
|
||||
};
|
||||
server.use(
|
||||
http.get('/api/public/journey/test-share-token', () => HttpResponse.json(customData)),
|
||||
|
||||
@@ -176,7 +176,7 @@ export default function JourneyPublicPage() {
|
||||
<span style={{ fontSize: 11, opacity: 0.4 }}>·</span>
|
||||
<span style={{ fontSize: 12, fontWeight: 500, opacity: 0.8, display: 'flex', alignItems: 'center', gap: 5 }}><Camera size={12} /> {stats.photos} {t('journey.stats.photos')}</span>
|
||||
<span style={{ fontSize: 11, opacity: 0.4 }}>·</span>
|
||||
<span style={{ fontSize: 12, fontWeight: 500, opacity: 0.8, display: 'flex', alignItems: 'center', gap: 5 }}><MapPin size={12} /> {stats.cities} {t('journey.stats.places')}</span>
|
||||
<span style={{ fontSize: 12, fontWeight: 500, opacity: 0.8, display: 'flex', alignItems: 'center', gap: 5 }}><MapPin size={12} /> {stats.places} {t('journey.stats.places')}</span>
|
||||
</div>
|
||||
|
||||
<div className="relative" style={{ marginTop: 12, fontSize: 9, fontWeight: 500, letterSpacing: 1.5, textTransform: 'uppercase', opacity: 0.25 }}>{t('journey.public.readOnly')}</div>
|
||||
|
||||
@@ -28,6 +28,7 @@ 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'
|
||||
@@ -141,6 +142,7 @@ 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)
|
||||
@@ -261,7 +263,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) return
|
||||
if (isLoading || !places || places.length === 0 || !placesPhotosEnabled) return
|
||||
for (const p of places) {
|
||||
if (p.image_url) continue
|
||||
const cacheKey = p.google_place_id || p.osm_id || `${p.lat},${p.lng}`
|
||||
@@ -932,7 +934,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
||||
)}
|
||||
|
||||
{selectedPlace && isMobile && ReactDOM.createPortal(
|
||||
<div style={{ position: 'fixed', inset: 0, zIndex: 9999, display: 'flex', alignItems: 'flex-end', justifyContent: 'center', background: 'rgba(0,0,0,0.3)' }} onClick={() => setSelectedPlaceId(null)}>
|
||||
<div style={{ position: 'fixed', inset: 0, zIndex: 9999, display: 'flex', alignItems: 'flex-end', justifyContent: 'center', background: 'rgba(0,0,0,0.3)', paddingBottom: 'var(--bottom-nav-h)' }} onClick={() => setSelectedPlaceId(null)}>
|
||||
<div style={{ width: '100%', maxHeight: '85vh' }} onClick={e => e.stopPropagation()}>
|
||||
<PlaceInspector
|
||||
place={selectedPlace}
|
||||
@@ -994,7 +996,7 @@ 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); setSelectedAssignmentId(null); setMobileSidebarOpen(null) }} accommodations={tripAccommodations} onNavigateToFiles={() => { setMobileSidebarOpen(null); handleTabChange('dateien') }} onExpandedDaysChange={setExpandedDayIds} pushUndo={pushUndo} canUndo={canUndo} lastActionLabel={lastActionLabel} onUndo={handleUndo} />
|
||||
? <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} />
|
||||
: <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>
|
||||
|
||||
@@ -85,6 +85,19 @@ 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)
|
||||
mapsApi.placePhoto(photoId, lat, lng, name)
|
||||
.then(async (data: { photoUrl?: string }) => {
|
||||
|
||||
@@ -33,6 +33,9 @@ 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>
|
||||
@@ -53,6 +56,9 @@ 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>
|
||||
}
|
||||
|
||||
@@ -74,6 +80,9 @@ 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++
|
||||
@@ -257,6 +266,9 @@ 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++
|
||||
|
||||
@@ -8,7 +8,7 @@ export interface Journey {
|
||||
subtitle?: string | null
|
||||
cover_gradient?: string | null
|
||||
cover_image?: string | null
|
||||
status: 'draft' | 'active' | 'completed'
|
||||
status: 'draft' | 'active' | 'completed' | 'archived'
|
||||
created_at: number
|
||||
updated_at: number
|
||||
}
|
||||
@@ -81,7 +81,7 @@ export interface JourneyDetail extends Journey {
|
||||
entries: JourneyEntry[]
|
||||
trips: JourneyTrip[]
|
||||
contributors: JourneyContributor[]
|
||||
stats: { entries: number; photos: number; cities: number }
|
||||
stats: { entries: number; photos: number; places: number }
|
||||
hide_skeletons?: boolean
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
export type JourneyLifecycle = 'archived' | 'live' | 'upcoming' | 'completed' | 'draft'
|
||||
|
||||
export function computeJourneyLifecycle(
|
||||
status: string,
|
||||
tripDateMin: string | null | undefined,
|
||||
tripDateMax: string | null | undefined,
|
||||
): JourneyLifecycle {
|
||||
if (status === 'archived') return 'archived'
|
||||
|
||||
if (tripDateMin && tripDateMax) {
|
||||
const today = new Date().toISOString().split('T')[0]
|
||||
if (tripDateMin <= today && today <= tripDateMax) return 'live'
|
||||
if (tripDateMin > today) return 'upcoming'
|
||||
return 'completed'
|
||||
}
|
||||
|
||||
if (!tripDateMin && !tripDateMax) {
|
||||
return 'draft'
|
||||
}
|
||||
|
||||
// Single boundary: only start or only end
|
||||
if (tripDateMin && !tripDateMax) {
|
||||
const today = new Date().toISOString().split('T')[0]
|
||||
return tripDateMin > today ? 'upcoming' : 'live'
|
||||
}
|
||||
if (!tripDateMin && tripDateMax) {
|
||||
const today = new Date().toISOString().split('T')[0]
|
||||
return tripDateMax < today ? 'completed' : 'live'
|
||||
}
|
||||
|
||||
return 'completed'
|
||||
}
|
||||
Reference in New Issue
Block a user