mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-22 23:01:48 +00:00
Compare commits
10 Commits
v3.0.9
...
73ce54eac5
| Author | SHA1 | Date | |
|---|---|---|---|
| 73ce54eac5 | |||
| 39f13881c5 | |||
| 3b94727c07 | |||
| 4a5a461d25 | |||
| 1963573db4 | |||
| 5046e1a2e0 | |||
| a1f3b4476e | |||
| b2a39a3071 | |||
| e078a9d9e1 | |||
| fef12b0e8b |
@@ -331,8 +331,8 @@ export const journeyApi = {
|
|||||||
|
|
||||||
// Photos
|
// Photos
|
||||||
uploadPhotos: (entryId: number, formData: FormData) => apiClient.post(`/journeys/entries/${entryId}/photos`, formData, { headers: { 'Content-Type': undefined as any } }).then(r => r.data),
|
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),
|
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) => apiClient.post(`/journeys/entries/${entryId}/provider-photos`, { provider, asset_ids: assetIds, caption }).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),
|
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),
|
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),
|
deletePhoto: (photoId: number) => apiClient.delete(`/journeys/photos/${photoId}`).then(r => r.data),
|
||||||
|
|||||||
@@ -94,7 +94,7 @@ function ImageLightbox({ files, initialIndex, onClose }: ImageLightboxProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<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}
|
onClick={onClose}
|
||||||
onTouchStart={e => setTouchStart(e.touches[0].clientX)}
|
onTouchStart={e => setTouchStart(e.touches[0].clientX)}
|
||||||
onTouchEnd={e => {
|
onTouchEnd={e => {
|
||||||
|
|||||||
@@ -69,6 +69,7 @@ export default function PhotoLightbox({ photos, startIndex = 0, onClose }: Props
|
|||||||
position: 'fixed', inset: 0, zIndex: 500,
|
position: 'fixed', inset: 0, zIndex: 500,
|
||||||
background: 'rgba(0,0,0,0.92)', backdropFilter: 'blur(20px)',
|
background: 'rgba(0,0,0,0.92)', backdropFilter: 'blur(20px)',
|
||||||
display: 'flex', flexDirection: 'column',
|
display: 'flex', flexDirection: 'column',
|
||||||
|
paddingBottom: 'var(--bottom-nav-h)',
|
||||||
}}
|
}}
|
||||||
onTouchStart={onTouchStart}
|
onTouchStart={onTouchStart}
|
||||||
onTouchEnd={onTouchEnd}
|
onTouchEnd={onTouchEnd}
|
||||||
|
|||||||
@@ -1268,7 +1268,7 @@ export default function PackingListPanel({ tripId, items }: PackingListPanelProp
|
|||||||
|
|
||||||
{/* ── Bag Modal (mobile + click) ── */}
|
{/* ── Bag Modal (mobile + click) ── */}
|
||||||
{showBagModal && bagTrackingEnabled && (
|
{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)}>
|
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 }}
|
<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()}>
|
onClick={e => e.stopPropagation()}>
|
||||||
|
|||||||
@@ -79,6 +79,7 @@ export function PhotoLightbox({ photos, initialIndex, onClose, onUpdate, onDelet
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="fixed inset-0 z-50 bg-black/95 flex items-center justify-center"
|
className="fixed inset-0 z-50 bg-black/95 flex items-center justify-center"
|
||||||
|
style={{ paddingBottom: 'var(--bottom-nav-h)' }}
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
>
|
>
|
||||||
{/* Main area */}
|
{/* Main area */}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import React, { useState, useEffect, useRef } from 'react';
|
import React, { useState, useEffect, useRef } from 'react';
|
||||||
|
import { flushSync } from 'react-dom';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { Info, AlertTriangle, AlertOctagon, X, ChevronLeft, ChevronRight } from 'lucide-react';
|
import { Info, AlertTriangle, AlertOctagon, X, ChevronLeft, ChevronRight } from 'lucide-react';
|
||||||
import * as LucideIcons from 'lucide-react';
|
import * as LucideIcons from 'lucide-react';
|
||||||
@@ -70,159 +71,168 @@ function NoticeContent({ notice, title, body, ctaLabel, titleId, bodyId, isDark,
|
|||||||
: DefaultIcon;
|
: DefaultIcon;
|
||||||
|
|
||||||
return (
|
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 */}
|
{/* Dismiss X button — only on last page so users read all notices */}
|
||||||
{notice.dismissible && isLastPage && (
|
{notice.dismissible && isLastPage && (
|
||||||
<button
|
<button
|
||||||
onClick={onDismissAll}
|
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"
|
aria-label="Dismiss"
|
||||||
>
|
>
|
||||||
<X size={18} />
|
<X size={18} />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Hero image (not inline) */}
|
{/* Scrollable content — vertically centered when shorter than available space */}
|
||||||
{notice.media && notice.media.placement !== 'inline' && (
|
<div className="flex flex-col justify-center" style={{ flex: '1 1 0' }}>
|
||||||
<div
|
{/* Hero image (not inline) */}
|
||||||
className="w-full overflow-hidden"
|
{notice.media && notice.media.placement !== 'inline' && (
|
||||||
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' && (
|
|
||||||
<div
|
<div
|
||||||
className="w-full overflow-hidden rounded-lg mb-4 mx-auto"
|
className="w-full overflow-hidden"
|
||||||
style={{ aspectRatio: notice.media.aspectRatio ?? '16/9' }}
|
style={{ aspectRatio: notice.media.aspectRatio ?? '16/9' }}
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
src={isDark && notice.media.srcDark ? notice.media.srcDark : notice.media.src}
|
src={isDark && notice.media.srcDark ? notice.media.srcDark : notice.media.src}
|
||||||
alt={t(notice.media.altKey)}
|
alt={t(notice.media.altKey)}
|
||||||
className="w-full h-full object-cover"
|
className="w-full h-full object-cover"
|
||||||
|
fetchPriority="high"
|
||||||
decoding="async"
|
decoding="async"
|
||||||
onError={e => { (e.target as HTMLImageElement).style.display = 'none'; }}
|
onError={e => { (e.target as HTMLImageElement).style.display = 'none'; }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Highlights */}
|
{/* Special warm header for Heart icon (thank-you notice) */}
|
||||||
{notice.highlights && notice.highlights.length > 0 && (
|
{notice.icon === 'Heart' && !notice.media && (
|
||||||
<ul className="mx-auto mb-4 space-y-2">
|
<div className="relative overflow-hidden bg-gradient-to-br from-rose-500 via-pink-500 to-indigo-500 px-8 py-5 text-center">
|
||||||
{notice.highlights.map((h, i) => {
|
<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' }} />
|
||||||
const HIcon: React.ElementType | null = h.iconName
|
<div className="relative flex items-center justify-center gap-3">
|
||||||
? ((LucideIcons as Record<string, unknown>)[h.iconName] as React.ElementType) ?? null
|
<div className="w-10 h-10 rounded-full bg-white/20 backdrop-blur-sm flex items-center justify-center ring-2 ring-white/10">
|
||||||
: null;
|
<LucideIcon size={20} className="text-white" />
|
||||||
return (
|
</div>
|
||||||
<li key={i} className="flex items-center gap-2 text-sm text-slate-700 dark:text-slate-300">
|
<div className="text-left">
|
||||||
{HIcon
|
<h2 id={titleId} className="text-lg font-bold text-white leading-tight">{title}</h2>
|
||||||
? <HIcon size={16} className="text-blue-500 shrink-0" />
|
<p className="text-xs text-white/60 font-medium">TREK 3.0</p>
|
||||||
: <span className="text-blue-500 shrink-0">✓</span>
|
</div>
|
||||||
}
|
</div>
|
||||||
{t(h.labelKey)}
|
</div>
|
||||||
</li>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</ul>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<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) */}
|
{/* Pager — dots, arrows, counter (only when multiple notices) */}
|
||||||
{total > 1 && (
|
{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">
|
<div className="flex items-center gap-2">
|
||||||
<button
|
<button
|
||||||
onClick={onPrev}
|
onClick={onPrev}
|
||||||
disabled={!canPage || currentPage === 0}
|
disabled={!canPage || currentPage === 0}
|
||||||
aria-label={t('system_notice.pager.prev')}
|
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} />
|
<ChevronLeft size={14} />
|
||||||
</button>
|
</button>
|
||||||
@@ -246,7 +256,7 @@ function NoticeContent({ notice, title, body, ctaLabel, titleId, bodyId, isDark,
|
|||||||
onClick={onNext}
|
onClick={onNext}
|
||||||
disabled={!canPage || currentPage === total - 1}
|
disabled={!canPage || currentPage === total - 1}
|
||||||
aria-label={t('system_notice.pager.next')}
|
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} />
|
<ChevronRight size={14} />
|
||||||
</button>
|
</button>
|
||||||
@@ -261,17 +271,8 @@ function NoticeContent({ notice, title, body, ctaLabel, titleId, bodyId, isDark,
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* CTA + dismiss link */}
|
{/* CTA + dismiss link */}
|
||||||
<div className="flex flex-col items-center gap-3 mt-2">
|
<div className="flex flex-col items-center gap-3">
|
||||||
{!isLastPage && total > 1 ? (
|
{ctaLabel && isLastPage ? (
|
||||||
/* Non-last page: "Next" button to advance through all notices */
|
|
||||||
<button
|
|
||||||
id={`notice-cta-${notice.id}`}
|
|
||||||
onClick={onNext}
|
|
||||||
className="w-full h-11 rounded-lg bg-blue-600 hover:bg-blue-700 text-white font-medium transition-colors flex items-center justify-center gap-2"
|
|
||||||
>
|
|
||||||
{t('system_notice.pager.next')} <ChevronRight size={16} />
|
|
||||||
</button>
|
|
||||||
) : ctaLabel ? (
|
|
||||||
<button
|
<button
|
||||||
id={`notice-cta-${notice.id}`}
|
id={`notice-cta-${notice.id}`}
|
||||||
onClick={onCTA}
|
onClick={onCTA}
|
||||||
@@ -279,10 +280,10 @@ function NoticeContent({ notice, title, body, ctaLabel, titleId, bodyId, isDark,
|
|||||||
>
|
>
|
||||||
{ctaLabel}
|
{ctaLabel}
|
||||||
</button>
|
</button>
|
||||||
) : (
|
) : (notice.dismissible || isLastPage) && (
|
||||||
<button
|
<button
|
||||||
id={`notice-cta-${notice.id}`}
|
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"
|
className="w-full h-11 rounded-lg bg-blue-600 hover:bg-blue-700 text-white font-medium transition-colors"
|
||||||
>
|
>
|
||||||
{t('common.ok')}
|
{t('common.ok')}
|
||||||
@@ -327,7 +328,13 @@ export function ModalRenderer({ notices }: Props) {
|
|||||||
// Non-dismissible notices lock the pager so users must act before advancing.
|
// Non-dismissible notices lock the pager so users must act before advancing.
|
||||||
const canPage = notice?.dismissible !== false;
|
const canPage = notice?.dismissible !== false;
|
||||||
|
|
||||||
|
const touchStartX = useRef<number | null>(null);
|
||||||
const touchStartY = 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
|
// Keep a ref to the current notice id so dismiss/CTA handlers see the latest value
|
||||||
const noticeIdRef = useRef<string | null>(null);
|
const noticeIdRef = useRef<string | null>(null);
|
||||||
noticeIdRef.current = notice?.id ?? 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.
|
// contentWrapperRef: the div wrapping NoticeContent — we animate its transform directly.
|
||||||
const isPageNavRef = useRef(false);
|
const isPageNavRef = useRef(false);
|
||||||
const slideDirRef = useRef<'left' | 'right'>('right');
|
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
|
// Mobile breakpoint
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -455,6 +470,12 @@ export function ModalRenderer({ notices }: Props) {
|
|||||||
return () => { document.body.style.overflow = ''; };
|
return () => { document.body.style.overflow = ''; };
|
||||||
}, [visible, notice]);
|
}, [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) {
|
function announceIndex(newIdx: number, total: number) {
|
||||||
setPageAnnouncement(
|
setPageAnnouncement(
|
||||||
t('system_notice.pager.position')
|
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
|
// 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
|
// re-renders with the new notice), then flags the grace-delay effect to slide
|
||||||
// rather than hide+show.
|
// rather than hide+show.
|
||||||
@@ -576,6 +608,38 @@ export function ModalRenderer({ notices }: Props) {
|
|||||||
? (visible ? 'opacity-100' : 'opacity-0')
|
? (visible ? 'opacity-100' : 'opacity-0')
|
||||||
: (visible ? 'opacity-100 translate-y-0' : 'opacity-100 translate-y-full');
|
: (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 (
|
return (
|
||||||
<div className="fixed inset-0 z-50" role="presentation">
|
<div className="fixed inset-0 z-50" role="presentation">
|
||||||
{/* Screen-reader page announcements */}
|
{/* Screen-reader page announcements */}
|
||||||
@@ -583,30 +647,150 @@ export function ModalRenderer({ notices }: Props) {
|
|||||||
{/* Backdrop */}
|
{/* Backdrop */}
|
||||||
<div
|
<div
|
||||||
className={`absolute inset-0 bg-slate-950/40 backdrop-blur-[2px] transition-opacity ${dur} ${ease} ${visible ? 'opacity-100' : 'opacity-0'}`}
|
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 */}
|
{/* Bottom sheet */}
|
||||||
<div
|
<div
|
||||||
|
ref={sheetRef}
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
aria-labelledby={titleId}
|
aria-labelledby={titleId}
|
||||||
aria-describedby={bodyId}
|
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}`}
|
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}`}
|
||||||
onTouchStart={e => { touchStartY.current = e.touches[0].clientY; }}
|
style={{ touchAction: 'pan-y' }}
|
||||||
onTouchEnd={e => {
|
onTouchStart={e => {
|
||||||
if (touchStartY.current !== null && notice.dismissible) {
|
touchStartX.current = e.touches[0].clientX;
|
||||||
const delta = e.changedTouches[0].clientY - touchStartY.current;
|
touchStartY.current = e.touches[0].clientY;
|
||||||
if (delta > 80) handleDismiss();
|
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;
|
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 */}
|
{/* Drag handle — fixed, does not scroll */}
|
||||||
<div className="pt-3 pb-1 flex justify-center">
|
<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 className="w-9 h-1 rounded-full bg-slate-300 dark:bg-slate-600" />
|
||||||
</div>
|
</div>
|
||||||
<div ref={contentWrapperRef}>
|
{/* Clip container — fills remaining sheet height, hides adjacent slots */}
|
||||||
<NoticeContent {...contentProps} />
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -394,7 +394,7 @@ export default function TodoListPanel({ tripId, items }: { tripId: number; items
|
|||||||
)}
|
)}
|
||||||
{selectedItem && !isAddingNew && isMobile && (
|
{selectedItem && !isAddingNew && isMobile && (
|
||||||
<div onClick={e => { if (e.target === e.currentTarget) setSelectedId(null) }}
|
<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' }}
|
<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' } } }}>
|
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
|
<DetailPane
|
||||||
@@ -419,7 +419,7 @@ export default function TodoListPanel({ tripId, items }: { tripId: number; items
|
|||||||
)}
|
)}
|
||||||
{isAddingNew && !selectedItem && isMobile && (
|
{isAddingNew && !selectedItem && isMobile && (
|
||||||
<div onClick={e => { if (e.target === e.currentTarget) setIsAddingNew(false) }}
|
<div onClick={e => { if (e.target === e.currentTarget) setIsAddingNew(false) }}
|
||||||
style={{ position: 'fixed', inset: 0, zIndex: 1000, background: 'rgba(0,0,0,0.4)', display: 'flex', justifyContent: 'center', alignItems: 'flex-end' }}>
|
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' }}
|
<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' } } }}>
|
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
|
<NewTaskPane
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ export default function Modal({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="fixed inset-0 z-[200] flex items-start sm:items-center justify-center px-4 modal-backdrop"
|
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 }}
|
onMouseDown={e => { mouseDownTarget.current = e.target }}
|
||||||
onClick={e => {
|
onClick={e => {
|
||||||
if (e.target === e.currentTarget && mouseDownTarget.current === e.currentTarget) onClose()
|
if (e.target === e.currentTarget && mouseDownTarget.current === e.currentTarget) onClose()
|
||||||
|
|||||||
@@ -1570,6 +1570,14 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'memories.confirmShareTitle': 'مشاركة مع أعضاء الرحلة؟',
|
'memories.confirmShareTitle': 'مشاركة مع أعضاء الرحلة؟',
|
||||||
'memories.confirmShareHint': '{count} صور ستكون مرئية لجميع أعضاء هذه الرحلة. يمكنك جعل الصور الفردية خاصة لاحقًا.',
|
'memories.confirmShareHint': '{count} صور ستكون مرئية لجميع أعضاء هذه الرحلة. يمكنك جعل الصور الفردية خاصة لاحقًا.',
|
||||||
'memories.confirmShareButton': 'مشاركة الصور',
|
'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.settings.failedToDelete': 'فشل في الحذف',
|
||||||
'journey.entries.deleteTitle': 'حذف الإدخال',
|
'journey.entries.deleteTitle': 'حذف الإدخال',
|
||||||
'journey.photosUploaded': 'تم رفع {count} صورة',
|
'journey.photosUploaded': 'تم رفع {count} صورة',
|
||||||
@@ -1876,6 +1884,10 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'admin.notifications.adminNtfyPanel.testFailed': 'فشل إرسال Ntfy التجريبي',
|
'admin.notifications.adminNtfyPanel.testFailed': 'فشل إرسال Ntfy التجريبي',
|
||||||
'admin.notifications.adminNtfyPanel.alwaysOnHint': 'يُرسل Ntfy للمسؤول دائمًا عند تهيئة موضوع',
|
'admin.notifications.adminNtfyPanel.alwaysOnHint': 'يُرسل Ntfy للمسؤول دائمًا عند تهيئة موضوع',
|
||||||
'admin.notifications.adminNotificationsHint': 'حدد القنوات التي تُسلّم إشعارات المسؤول (مثل تنبيهات الإصدارات). يُرسل الـ Webhook تلقائيًا عند تعيين رابط URL لـ Webhook المسؤول.',
|
'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': 'الإشعارات',
|
'admin.tabs.notifications': 'الإشعارات',
|
||||||
'notifications.versionAvailable.title': 'تحديث متاح',
|
'notifications.versionAvailable.title': 'تحديث متاح',
|
||||||
'notifications.versionAvailable.text': 'TREK {version} متاح الآن.',
|
'notifications.versionAvailable.text': 'TREK {version} متاح الآن.',
|
||||||
|
|||||||
@@ -1825,6 +1825,10 @@ const br: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'admin.notifications.adminNtfyPanel.testFailed': 'Falha ao enviar Ntfy de teste',
|
'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.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.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',
|
'admin.tabs.notifications': 'Notificações',
|
||||||
'notifications.versionAvailable.title': 'Atualização disponível',
|
'notifications.versionAvailable.title': 'Atualização disponível',
|
||||||
'notifications.versionAvailable.text': 'TREK {version} já está disponível.',
|
'notifications.versionAvailable.text': 'TREK {version} já está disponível.',
|
||||||
@@ -1872,6 +1876,8 @@ const br: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'memories.saveRouteNotConfigured': 'A rota de salvamento não está configurada para este provedor',
|
'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.testRouteNotConfigured': 'A rota de teste não está configurada para este provedor',
|
||||||
'memories.fillRequiredFields': 'Por favor preencha todos os campos obrigatórios',
|
'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.title': 'Jornada',
|
||||||
'journey.subtitle': 'Registre suas viagens em tempo real',
|
'journey.subtitle': 'Registre suas viagens em tempo real',
|
||||||
'journey.new': 'Nova jornada',
|
'journey.new': 'Nova jornada',
|
||||||
@@ -1893,6 +1899,7 @@ const br: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'journey.status.active': 'Ativa',
|
'journey.status.active': 'Ativa',
|
||||||
'journey.status.completed': 'Concluída',
|
'journey.status.completed': 'Concluída',
|
||||||
'journey.status.upcoming': 'Próxima',
|
'journey.status.upcoming': 'Próxima',
|
||||||
|
'journey.status.archived': 'Arquivado',
|
||||||
'journey.checkin.add': 'Fazer check-in',
|
'journey.checkin.add': 'Fazer check-in',
|
||||||
'journey.checkin.namePlaceholder': 'Nome do local',
|
'journey.checkin.namePlaceholder': 'Nome do local',
|
||||||
'journey.checkin.notesPlaceholder': 'Notas (opcional)',
|
'journey.checkin.notesPlaceholder': 'Notas (opcional)',
|
||||||
@@ -2046,6 +2053,11 @@ const br: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'journey.settings.name': 'Nome',
|
'journey.settings.name': 'Nome',
|
||||||
'journey.settings.subtitle': 'Subtítulo',
|
'journey.settings.subtitle': 'Subtítulo',
|
||||||
'journey.settings.subtitlePlaceholder': 'ex. Tailândia, Vietnã e Camboja',
|
'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.delete': 'Excluir',
|
||||||
'journey.settings.deleteJourney': 'Excluir jornada',
|
'journey.settings.deleteJourney': 'Excluir jornada',
|
||||||
'journey.settings.deleteMessage': 'Excluir "{title}"? Todas as entradas e fotos serão perdidas.',
|
'journey.settings.deleteMessage': 'Excluir "{title}"? Todas as entradas e fotos serão perdidas.',
|
||||||
|
|||||||
@@ -1830,6 +1830,10 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'admin.notifications.adminNtfyPanel.testFailed': 'Testovací Ntfy selhalo',
|
'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.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.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í',
|
'admin.tabs.notifications': 'Oznámení',
|
||||||
'notifications.versionAvailable.title': 'Dostupná aktualizace',
|
'notifications.versionAvailable.title': 'Dostupná aktualizace',
|
||||||
'notifications.versionAvailable.text': 'TREK {version} je nyní k dispozici.',
|
'notifications.versionAvailable.text': 'TREK {version} je nyní k dispozici.',
|
||||||
@@ -1877,6 +1881,8 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'memories.saveRouteNotConfigured': 'Trasa uložení není nakonfigurována pro tohoto poskytovatele',
|
'memories.saveRouteNotConfigured': 'Trasa uložení není nakonfigurována pro tohoto poskytovatele',
|
||||||
'memories.testRouteNotConfigured': 'Testovací trasa 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',
|
'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.title': 'Cestovní deník',
|
||||||
'journey.subtitle': 'Zaznamenávejte své cesty průběžně',
|
'journey.subtitle': 'Zaznamenávejte své cesty průběžně',
|
||||||
'journey.new': 'Nový cestovní deník',
|
'journey.new': 'Nový cestovní deník',
|
||||||
@@ -1898,6 +1904,7 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'journey.status.active': 'Aktivní',
|
'journey.status.active': 'Aktivní',
|
||||||
'journey.status.completed': 'Dokončeno',
|
'journey.status.completed': 'Dokončeno',
|
||||||
'journey.status.upcoming': 'Nadcházející',
|
'journey.status.upcoming': 'Nadcházející',
|
||||||
|
'journey.status.archived': 'Archivováno',
|
||||||
'journey.checkin.add': 'Odbavit se',
|
'journey.checkin.add': 'Odbavit se',
|
||||||
'journey.checkin.namePlaceholder': 'Název místa',
|
'journey.checkin.namePlaceholder': 'Název místa',
|
||||||
'journey.checkin.notesPlaceholder': 'Poznámky (volitelné)',
|
'journey.checkin.notesPlaceholder': 'Poznámky (volitelné)',
|
||||||
@@ -2051,6 +2058,11 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'journey.settings.name': 'Název',
|
'journey.settings.name': 'Název',
|
||||||
'journey.settings.subtitle': 'Podtitul',
|
'journey.settings.subtitle': 'Podtitul',
|
||||||
'journey.settings.subtitlePlaceholder': 'např. Thajsko, Vietnam a Kambodža',
|
'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.delete': 'Smazat',
|
||||||
'journey.settings.deleteJourney': 'Smazat cestovní deník',
|
'journey.settings.deleteJourney': 'Smazat cestovní deník',
|
||||||
'journey.settings.deleteMessage': 'Smazat „{title}"? Všechny záznamy a fotky budou ztraceny.',
|
'journey.settings.deleteMessage': 'Smazat „{title}"? Všechny záznamy a fotky budou ztraceny.',
|
||||||
|
|||||||
@@ -1842,6 +1842,10 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'admin.notifications.adminNtfyPanel.testFailed': 'Test-Ntfy fehlgeschlagen',
|
'admin.notifications.adminNtfyPanel.testFailed': 'Test-Ntfy fehlgeschlagen',
|
||||||
'admin.notifications.adminNtfyPanel.alwaysOnHint': 'Admin-Ntfy sendet immer, wenn ein Thema konfiguriert ist',
|
'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.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',
|
'admin.tabs.notifications': 'Benachrichtigungen',
|
||||||
'notifications.versionAvailable.title': 'Update verfügbar',
|
'notifications.versionAvailable.title': 'Update verfügbar',
|
||||||
'notifications.versionAvailable.text': 'TREK {version} ist jetzt verfügbar.',
|
'notifications.versionAvailable.text': 'TREK {version} ist jetzt verfügbar.',
|
||||||
@@ -1883,6 +1887,8 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'notif.dev.unknown_event.text': 'Ereignistyp "{event}" ist nicht in EVENT_NOTIFICATION_CONFIG registriert',
|
'notif.dev.unknown_event.text': 'Ereignistyp "{event}" ist nicht in EVENT_NOTIFICATION_CONFIG registriert',
|
||||||
|
|
||||||
// Journey Addon
|
// Journey Addon
|
||||||
|
'journey.search.placeholder': 'Reisen suchen…',
|
||||||
|
'journey.search.noResults': 'Keine Reisen passen zu „{query}"',
|
||||||
'journey.title': 'Journey',
|
'journey.title': 'Journey',
|
||||||
'journey.subtitle': 'Dokumentiere deine Reisen unterwegs',
|
'journey.subtitle': 'Dokumentiere deine Reisen unterwegs',
|
||||||
'journey.new': 'Neue Journey',
|
'journey.new': 'Neue Journey',
|
||||||
@@ -1904,6 +1910,7 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'journey.status.active': 'Aktiv',
|
'journey.status.active': 'Aktiv',
|
||||||
'journey.status.completed': 'Abgeschlossen',
|
'journey.status.completed': 'Abgeschlossen',
|
||||||
'journey.status.upcoming': 'Anstehend',
|
'journey.status.upcoming': 'Anstehend',
|
||||||
|
'journey.status.archived': 'Archiviert',
|
||||||
'journey.checkin.add': 'Einchecken',
|
'journey.checkin.add': 'Einchecken',
|
||||||
'journey.checkin.namePlaceholder': 'Ortsname',
|
'journey.checkin.namePlaceholder': 'Ortsname',
|
||||||
'journey.checkin.notesPlaceholder': 'Notizen (optional)',
|
'journey.checkin.notesPlaceholder': 'Notizen (optional)',
|
||||||
@@ -2061,6 +2068,11 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'journey.settings.name': 'Name',
|
'journey.settings.name': 'Name',
|
||||||
'journey.settings.subtitle': 'Untertitel',
|
'journey.settings.subtitle': 'Untertitel',
|
||||||
'journey.settings.subtitlePlaceholder': 'z.B. Thailand, Vietnam & Kambodscha',
|
'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.delete': 'Löschen',
|
||||||
'journey.settings.deleteJourney': 'Journey löschen',
|
'journey.settings.deleteJourney': 'Journey löschen',
|
||||||
'journey.settings.deleteMessage': '"{title}" löschen? Alle Einträge und Fotos gehen verloren.',
|
'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.testFailed': 'Test ntfy failed',
|
||||||
'admin.notifications.adminNtfyPanel.alwaysOnHint': 'Admin ntfy always fires when a topic is configured',
|
'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.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.title': 'Email & Notifications',
|
||||||
'admin.smtp.hint': 'SMTP configuration for sending email notifications.',
|
'admin.smtp.hint': 'SMTP configuration for sending email notifications.',
|
||||||
'admin.smtp.testButton': 'Send test email',
|
'admin.smtp.testButton': 'Send test email',
|
||||||
@@ -1886,6 +1890,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',
|
'notif.dev.unknown_event.text': 'Event type "{event}" is not registered in EVENT_NOTIFICATION_CONFIG',
|
||||||
|
|
||||||
// Journey addon
|
// Journey addon
|
||||||
|
'journey.search.placeholder': 'Search journeys…',
|
||||||
|
'journey.search.noResults': 'No journeys match "{query}"',
|
||||||
'journey.title': 'Journey',
|
'journey.title': 'Journey',
|
||||||
'journey.subtitle': 'Track your travels as they happen',
|
'journey.subtitle': 'Track your travels as they happen',
|
||||||
'journey.new': 'New Journey',
|
'journey.new': 'New Journey',
|
||||||
@@ -1907,6 +1913,7 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'journey.status.active': 'Active',
|
'journey.status.active': 'Active',
|
||||||
'journey.status.completed': 'Completed',
|
'journey.status.completed': 'Completed',
|
||||||
'journey.status.upcoming': 'Upcoming',
|
'journey.status.upcoming': 'Upcoming',
|
||||||
|
'journey.status.archived': 'Archived',
|
||||||
'journey.checkin.add': 'Check in',
|
'journey.checkin.add': 'Check in',
|
||||||
'journey.checkin.namePlaceholder': 'Location name',
|
'journey.checkin.namePlaceholder': 'Location name',
|
||||||
'journey.checkin.notesPlaceholder': 'Notes (optional)',
|
'journey.checkin.notesPlaceholder': 'Notes (optional)',
|
||||||
@@ -2085,6 +2092,11 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'journey.settings.name': 'Name',
|
'journey.settings.name': 'Name',
|
||||||
'journey.settings.subtitle': 'Subtitle',
|
'journey.settings.subtitle': 'Subtitle',
|
||||||
'journey.settings.subtitlePlaceholder': 'e.g. Thailand, Vietnam & Cambodia',
|
'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.delete': 'Delete',
|
||||||
'journey.settings.deleteJourney': 'Delete Journey',
|
'journey.settings.deleteJourney': 'Delete Journey',
|
||||||
'journey.settings.deleteMessage': 'Delete "{title}"? All entries and photos will be lost.',
|
'journey.settings.deleteMessage': 'Delete "{title}"? All entries and photos will be lost.',
|
||||||
|
|||||||
@@ -1835,6 +1835,10 @@ const es: Record<string, string> = {
|
|||||||
'admin.notifications.adminNtfyPanel.testFailed': 'Error al enviar el Ntfy de prueba',
|
'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.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.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',
|
'admin.tabs.notifications': 'Notificaciones',
|
||||||
'notifications.versionAvailable.title': 'Actualización disponible',
|
'notifications.versionAvailable.title': 'Actualización disponible',
|
||||||
'notifications.versionAvailable.text': 'TREK {version} ya está disponible.',
|
'notifications.versionAvailable.text': 'TREK {version} ya está disponible.',
|
||||||
@@ -1879,6 +1883,8 @@ const es: Record<string, string> = {
|
|||||||
'common.justNow': 'justo ahora',
|
'common.justNow': 'justo ahora',
|
||||||
'common.hoursAgo': 'hace {count}h',
|
'common.hoursAgo': 'hace {count}h',
|
||||||
'common.daysAgo': 'hace {count}d',
|
'common.daysAgo': 'hace {count}d',
|
||||||
|
'journey.search.placeholder': 'Buscar viajes…',
|
||||||
|
'journey.search.noResults': 'Ningún viaje coincide con "{query}"',
|
||||||
'journey.title': 'Travesía',
|
'journey.title': 'Travesía',
|
||||||
'journey.subtitle': 'Registra tus viajes en tiempo real',
|
'journey.subtitle': 'Registra tus viajes en tiempo real',
|
||||||
'journey.new': 'Nueva travesía',
|
'journey.new': 'Nueva travesía',
|
||||||
@@ -1900,6 +1906,7 @@ const es: Record<string, string> = {
|
|||||||
'journey.status.active': 'Activa',
|
'journey.status.active': 'Activa',
|
||||||
'journey.status.completed': 'Completada',
|
'journey.status.completed': 'Completada',
|
||||||
'journey.status.upcoming': 'Próxima',
|
'journey.status.upcoming': 'Próxima',
|
||||||
|
'journey.status.archived': 'Archivado',
|
||||||
'journey.checkin.add': 'Registrar ubicación',
|
'journey.checkin.add': 'Registrar ubicación',
|
||||||
'journey.checkin.namePlaceholder': 'Nombre del lugar',
|
'journey.checkin.namePlaceholder': 'Nombre del lugar',
|
||||||
'journey.checkin.notesPlaceholder': 'Notas (opcional)',
|
'journey.checkin.notesPlaceholder': 'Notas (opcional)',
|
||||||
@@ -2053,6 +2060,11 @@ const es: Record<string, string> = {
|
|||||||
'journey.settings.name': 'Nombre',
|
'journey.settings.name': 'Nombre',
|
||||||
'journey.settings.subtitle': 'Subtítulo',
|
'journey.settings.subtitle': 'Subtítulo',
|
||||||
'journey.settings.subtitlePlaceholder': 'p. ej. Tailandia, Vietnam y Camboya',
|
'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.delete': 'Eliminar',
|
||||||
'journey.settings.deleteJourney': 'Eliminar travesía',
|
'journey.settings.deleteJourney': 'Eliminar travesía',
|
||||||
'journey.settings.deleteMessage': '¿Eliminar "{title}"? Todas las entradas y fotos se perderán.',
|
'journey.settings.deleteMessage': '¿Eliminar "{title}"? Todas las entradas y fotos se perderán.',
|
||||||
|
|||||||
@@ -1829,6 +1829,10 @@ const fr: Record<string, string> = {
|
|||||||
'admin.notifications.adminNtfyPanel.testFailed': 'Échec de l\'envoi du Ntfy de test',
|
'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.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.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',
|
'admin.tabs.notifications': 'Notifications',
|
||||||
'notifications.versionAvailable.title': 'Mise à jour disponible',
|
'notifications.versionAvailable.title': 'Mise à jour disponible',
|
||||||
'notifications.versionAvailable.text': 'TREK {version} est maintenant disponible.',
|
'notifications.versionAvailable.text': 'TREK {version} est maintenant disponible.',
|
||||||
@@ -1873,6 +1877,8 @@ const fr: Record<string, string> = {
|
|||||||
'common.justNow': 'à l\'instant',
|
'common.justNow': 'à l\'instant',
|
||||||
'common.hoursAgo': 'il y a {count}h',
|
'common.hoursAgo': 'il y a {count}h',
|
||||||
'common.daysAgo': 'il y a {count}j',
|
'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.title': 'Journal de voyage',
|
||||||
'journey.subtitle': 'Suivez vos voyages en temps réel',
|
'journey.subtitle': 'Suivez vos voyages en temps réel',
|
||||||
'journey.new': 'Nouveau journal',
|
'journey.new': 'Nouveau journal',
|
||||||
@@ -1894,6 +1900,7 @@ const fr: Record<string, string> = {
|
|||||||
'journey.status.active': 'Actif',
|
'journey.status.active': 'Actif',
|
||||||
'journey.status.completed': 'Terminé',
|
'journey.status.completed': 'Terminé',
|
||||||
'journey.status.upcoming': 'À venir',
|
'journey.status.upcoming': 'À venir',
|
||||||
|
'journey.status.archived': 'Archivé',
|
||||||
'journey.checkin.add': 'Check-in',
|
'journey.checkin.add': 'Check-in',
|
||||||
'journey.checkin.namePlaceholder': 'Nom du lieu',
|
'journey.checkin.namePlaceholder': 'Nom du lieu',
|
||||||
'journey.checkin.notesPlaceholder': 'Notes (facultatif)',
|
'journey.checkin.notesPlaceholder': 'Notes (facultatif)',
|
||||||
@@ -2047,6 +2054,11 @@ const fr: Record<string, string> = {
|
|||||||
'journey.settings.name': 'Nom',
|
'journey.settings.name': 'Nom',
|
||||||
'journey.settings.subtitle': 'Sous-titre',
|
'journey.settings.subtitle': 'Sous-titre',
|
||||||
'journey.settings.subtitlePlaceholder': 'ex. Thaïlande, Vietnam et Cambodge',
|
'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.delete': 'Supprimer',
|
||||||
'journey.settings.deleteJourney': 'Supprimer le journal',
|
'journey.settings.deleteJourney': 'Supprimer le journal',
|
||||||
'journey.settings.deleteMessage': 'Supprimer « {title} » ? Toutes les entrées et photos seront perdues.',
|
'journey.settings.deleteMessage': 'Supprimer « {title} » ? Toutes les entrées et photos seront perdues.',
|
||||||
|
|||||||
@@ -1827,6 +1827,10 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'admin.notifications.adminNtfyPanel.testFailed': 'Teszt Ntfy sikertelen',
|
'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.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.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',
|
'admin.tabs.notifications': 'Értesítések',
|
||||||
'notifications.versionAvailable.title': 'Elérhető frissítés',
|
'notifications.versionAvailable.title': 'Elérhető frissítés',
|
||||||
'notifications.versionAvailable.text': 'A TREK {version} már elérhető.',
|
'notifications.versionAvailable.text': 'A TREK {version} már elérhető.',
|
||||||
@@ -1874,6 +1878,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.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.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',
|
'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.title': 'Útinaplók',
|
||||||
'journey.subtitle': 'Kövesse nyomon utazásait valós időben',
|
'journey.subtitle': 'Kövesse nyomon utazásait valós időben',
|
||||||
'journey.new': 'Új útinapló',
|
'journey.new': 'Új útinapló',
|
||||||
@@ -1895,6 +1901,7 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'journey.status.active': 'Aktív',
|
'journey.status.active': 'Aktív',
|
||||||
'journey.status.completed': 'Befejezett',
|
'journey.status.completed': 'Befejezett',
|
||||||
'journey.status.upcoming': 'Közelgő',
|
'journey.status.upcoming': 'Közelgő',
|
||||||
|
'journey.status.archived': 'Archivált',
|
||||||
'journey.checkin.add': 'Bejelentkezés',
|
'journey.checkin.add': 'Bejelentkezés',
|
||||||
'journey.checkin.namePlaceholder': 'Helyszín neve',
|
'journey.checkin.namePlaceholder': 'Helyszín neve',
|
||||||
'journey.checkin.notesPlaceholder': 'Jegyzetek (opcionális)',
|
'journey.checkin.notesPlaceholder': 'Jegyzetek (opcionális)',
|
||||||
@@ -2048,6 +2055,11 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'journey.settings.name': 'Név',
|
'journey.settings.name': 'Név',
|
||||||
'journey.settings.subtitle': 'Alcím',
|
'journey.settings.subtitle': 'Alcím',
|
||||||
'journey.settings.subtitlePlaceholder': 'pl. Thaiföld, Vietnam és Kambodzsa',
|
'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.delete': 'Törlés',
|
||||||
'journey.settings.deleteJourney': 'Útinapló törlése',
|
'journey.settings.deleteJourney': 'Útinapló törlése',
|
||||||
'journey.settings.deleteMessage': 'Törlöd a(z) „{title}" útinaplót? Minden bejegyzés és fotó elveszik.',
|
'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.testFailed': 'Uji Ntfy gagal',
|
||||||
'admin.notifications.adminNtfyPanel.alwaysOnHint': 'Admin Ntfy selalu berjalan jika topik dikonfigurasi',
|
'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.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.title': 'Email & Notifikasi',
|
||||||
'admin.smtp.hint': 'Konfigurasi SMTP untuk pengiriman notifikasi email.',
|
'admin.smtp.hint': 'Konfigurasi SMTP untuk pengiriman notifikasi email.',
|
||||||
'admin.smtp.testButton': 'Kirim email uji',
|
'admin.smtp.testButton': 'Kirim email uji',
|
||||||
@@ -1877,6 +1881,8 @@ const id: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'notif.dev.unknown_event.text': 'Tipe event "{event}" tidak terdaftar di EVENT_NOTIFICATION_CONFIG',
|
'notif.dev.unknown_event.text': 'Tipe event "{event}" tidak terdaftar di EVENT_NOTIFICATION_CONFIG',
|
||||||
|
|
||||||
// Journey addon
|
// Journey addon
|
||||||
|
'journey.search.placeholder': 'Cari perjalanan…',
|
||||||
|
'journey.search.noResults': 'Tidak ada perjalanan yang cocok dengan "{query}"',
|
||||||
'journey.title': 'Journey',
|
'journey.title': 'Journey',
|
||||||
'journey.subtitle': 'Lacak perjalananmu saat terjadi',
|
'journey.subtitle': 'Lacak perjalananmu saat terjadi',
|
||||||
'journey.new': 'Journey Baru',
|
'journey.new': 'Journey Baru',
|
||||||
@@ -1898,6 +1904,7 @@ const id: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'journey.status.active': 'Aktif',
|
'journey.status.active': 'Aktif',
|
||||||
'journey.status.completed': 'Selesai',
|
'journey.status.completed': 'Selesai',
|
||||||
'journey.status.upcoming': 'Mendatang',
|
'journey.status.upcoming': 'Mendatang',
|
||||||
|
'journey.status.archived': 'Diarsipkan',
|
||||||
'journey.checkin.add': 'Check in',
|
'journey.checkin.add': 'Check in',
|
||||||
'journey.checkin.namePlaceholder': 'Nama lokasi',
|
'journey.checkin.namePlaceholder': 'Nama lokasi',
|
||||||
'journey.checkin.notesPlaceholder': 'Catatan (opsional)',
|
'journey.checkin.notesPlaceholder': 'Catatan (opsional)',
|
||||||
@@ -2075,6 +2082,11 @@ const id: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'journey.settings.name': 'Nama',
|
'journey.settings.name': 'Nama',
|
||||||
'journey.settings.subtitle': 'Subjudul',
|
'journey.settings.subtitle': 'Subjudul',
|
||||||
'journey.settings.subtitlePlaceholder': 'mis. Thailand, Vietnam & Kamboja',
|
'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.delete': 'Hapus',
|
||||||
'journey.settings.deleteJourney': 'Hapus Journey',
|
'journey.settings.deleteJourney': 'Hapus Journey',
|
||||||
'journey.settings.deleteMessage': 'Hapus "{title}"? Semua entri dan foto akan hilang.',
|
'journey.settings.deleteMessage': 'Hapus "{title}"? Semua entri dan foto akan hilang.',
|
||||||
|
|||||||
@@ -1830,6 +1830,10 @@ const it: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'admin.notifications.adminNtfyPanel.testFailed': 'Invio Ntfy di test fallito',
|
'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.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.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',
|
'admin.tabs.notifications': 'Notifiche',
|
||||||
'notifications.versionAvailable.title': 'Aggiornamento disponibile',
|
'notifications.versionAvailable.title': 'Aggiornamento disponibile',
|
||||||
'notifications.versionAvailable.text': 'TREK {version} è ora disponibile.',
|
'notifications.versionAvailable.text': 'TREK {version} è ora disponibile.',
|
||||||
@@ -1874,6 +1878,8 @@ const it: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'common.justNow': 'proprio ora',
|
'common.justNow': 'proprio ora',
|
||||||
'common.hoursAgo': '{count}h fa',
|
'common.hoursAgo': '{count}h fa',
|
||||||
'common.daysAgo': '{count}g 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.title': 'Diario di viaggio',
|
||||||
'journey.subtitle': 'Segui i tuoi viaggi in tempo reale',
|
'journey.subtitle': 'Segui i tuoi viaggi in tempo reale',
|
||||||
'journey.new': 'Nuovo diario',
|
'journey.new': 'Nuovo diario',
|
||||||
@@ -1895,6 +1901,7 @@ const it: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'journey.status.active': 'Attivo',
|
'journey.status.active': 'Attivo',
|
||||||
'journey.status.completed': 'Completato',
|
'journey.status.completed': 'Completato',
|
||||||
'journey.status.upcoming': 'In arrivo',
|
'journey.status.upcoming': 'In arrivo',
|
||||||
|
'journey.status.archived': 'Archiviato',
|
||||||
'journey.checkin.add': 'Check-in',
|
'journey.checkin.add': 'Check-in',
|
||||||
'journey.checkin.namePlaceholder': 'Nome del luogo',
|
'journey.checkin.namePlaceholder': 'Nome del luogo',
|
||||||
'journey.checkin.notesPlaceholder': 'Note (facoltativo)',
|
'journey.checkin.notesPlaceholder': 'Note (facoltativo)',
|
||||||
@@ -2048,6 +2055,11 @@ const it: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'journey.settings.name': 'Nome',
|
'journey.settings.name': 'Nome',
|
||||||
'journey.settings.subtitle': 'Sottotitolo',
|
'journey.settings.subtitle': 'Sottotitolo',
|
||||||
'journey.settings.subtitlePlaceholder': 'es. Thailandia, Vietnam e Cambogia',
|
'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.delete': 'Elimina',
|
||||||
'journey.settings.deleteJourney': 'Elimina diario',
|
'journey.settings.deleteJourney': 'Elimina diario',
|
||||||
'journey.settings.deleteMessage': 'Eliminare "{title}"? Tutte le voci e le foto andranno perse.',
|
'journey.settings.deleteMessage': 'Eliminare "{title}"? Tutte le voci e le foto andranno perse.',
|
||||||
|
|||||||
@@ -1829,6 +1829,10 @@ const nl: Record<string, string> = {
|
|||||||
'admin.notifications.adminNtfyPanel.testFailed': 'Test-Ntfy mislukt',
|
'admin.notifications.adminNtfyPanel.testFailed': 'Test-Ntfy mislukt',
|
||||||
'admin.notifications.adminNtfyPanel.alwaysOnHint': 'Admin-Ntfy verstuurt altijd wanneer een onderwerp is geconfigureerd',
|
'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.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',
|
'admin.tabs.notifications': 'Meldingen',
|
||||||
'notifications.versionAvailable.title': 'Update beschikbaar',
|
'notifications.versionAvailable.title': 'Update beschikbaar',
|
||||||
'notifications.versionAvailable.text': 'TREK {version} is nu beschikbaar.',
|
'notifications.versionAvailable.text': 'TREK {version} is nu beschikbaar.',
|
||||||
@@ -1873,6 +1877,8 @@ const nl: Record<string, string> = {
|
|||||||
'common.justNow': 'zojuist',
|
'common.justNow': 'zojuist',
|
||||||
'common.hoursAgo': '{count}u geleden',
|
'common.hoursAgo': '{count}u geleden',
|
||||||
'common.daysAgo': '{count}d geleden',
|
'common.daysAgo': '{count}d geleden',
|
||||||
|
'journey.search.placeholder': 'Reizen zoeken…',
|
||||||
|
'journey.search.noResults': 'Geen reizen komen overeen met "{query}"',
|
||||||
'journey.title': 'Reisverslag',
|
'journey.title': 'Reisverslag',
|
||||||
'journey.subtitle': 'Leg je reizen vast terwijl je onderweg bent',
|
'journey.subtitle': 'Leg je reizen vast terwijl je onderweg bent',
|
||||||
'journey.new': 'Nieuw reisverslag',
|
'journey.new': 'Nieuw reisverslag',
|
||||||
@@ -1894,6 +1900,7 @@ const nl: Record<string, string> = {
|
|||||||
'journey.status.active': 'Actief',
|
'journey.status.active': 'Actief',
|
||||||
'journey.status.completed': 'Voltooid',
|
'journey.status.completed': 'Voltooid',
|
||||||
'journey.status.upcoming': 'Gepland',
|
'journey.status.upcoming': 'Gepland',
|
||||||
|
'journey.status.archived': 'Gearchiveerd',
|
||||||
'journey.checkin.add': 'Inchecken',
|
'journey.checkin.add': 'Inchecken',
|
||||||
'journey.checkin.namePlaceholder': 'Locatienaam',
|
'journey.checkin.namePlaceholder': 'Locatienaam',
|
||||||
'journey.checkin.notesPlaceholder': 'Notities (optioneel)',
|
'journey.checkin.notesPlaceholder': 'Notities (optioneel)',
|
||||||
@@ -2047,6 +2054,11 @@ const nl: Record<string, string> = {
|
|||||||
'journey.settings.name': 'Naam',
|
'journey.settings.name': 'Naam',
|
||||||
'journey.settings.subtitle': 'Ondertitel',
|
'journey.settings.subtitle': 'Ondertitel',
|
||||||
'journey.settings.subtitlePlaceholder': 'bijv. Thailand, Vietnam & Cambodja',
|
'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.delete': 'Verwijderen',
|
||||||
'journey.settings.deleteJourney': 'Reisverslag verwijderen',
|
'journey.settings.deleteJourney': 'Reisverslag verwijderen',
|
||||||
'journey.settings.deleteMessage': '"{title}" verwijderen? Alle vermeldingen en foto\'s gaan verloren.',
|
'journey.settings.deleteMessage': '"{title}" verwijderen? Alle vermeldingen en foto\'s gaan verloren.',
|
||||||
|
|||||||
@@ -1633,6 +1633,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.testFailed': 'Wysyłanie testowego Ntfy nie powiodło się',
|
||||||
'admin.notifications.adminNtfyPanel.alwaysOnHint': 'Admin Ntfy zawsze wysyła po skonfigurowaniu tematu',
|
'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.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.).',
|
'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.notificationsDisabled': 'Powiadomienia nie są skonfigurowane.',
|
||||||
'settings.notificationPreferences.noChannels': 'Brak skonfigurowanych kanałów powiadomień. Poproś administratora o skonfigurowanie powiadomień e-mail lub webhook.',
|
'settings.notificationPreferences.noChannels': 'Brak skonfigurowanych kanałów powiadomień. Poproś administratora o skonfigurowanie powiadomień e-mail lub webhook.',
|
||||||
@@ -1866,6 +1870,8 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'memories.saveRouteNotConfigured': 'Trasa zapisu nie jest skonfigurowana dla tego dostawcy',
|
'memories.saveRouteNotConfigured': 'Trasa zapisu nie jest skonfigurowana dla tego dostawcy',
|
||||||
'memories.testRouteNotConfigured': 'Trasa testowa nie jest skonfigurowana dla tego dostawcy',
|
'memories.testRouteNotConfigured': 'Trasa testowa nie jest skonfigurowana dla tego dostawcy',
|
||||||
'memories.fillRequiredFields': 'Proszę wypełnić wszystkie wymagane pola',
|
'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.title': 'Dziennik podróży',
|
||||||
'journey.subtitle': 'Dokumentuj swoje podróże na bieżąco',
|
'journey.subtitle': 'Dokumentuj swoje podróże na bieżąco',
|
||||||
'journey.new': 'Nowy dziennik podróży',
|
'journey.new': 'Nowy dziennik podróży',
|
||||||
@@ -1887,6 +1893,7 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'journey.status.active': 'Aktywny',
|
'journey.status.active': 'Aktywny',
|
||||||
'journey.status.completed': 'Zakończony',
|
'journey.status.completed': 'Zakończony',
|
||||||
'journey.status.upcoming': 'Nadchodzący',
|
'journey.status.upcoming': 'Nadchodzący',
|
||||||
|
'journey.status.archived': 'Zarchiwizowano',
|
||||||
'journey.checkin.add': 'Zamelduj się',
|
'journey.checkin.add': 'Zamelduj się',
|
||||||
'journey.checkin.namePlaceholder': 'Nazwa miejsca',
|
'journey.checkin.namePlaceholder': 'Nazwa miejsca',
|
||||||
'journey.checkin.notesPlaceholder': 'Notatki (opcjonalnie)',
|
'journey.checkin.notesPlaceholder': 'Notatki (opcjonalnie)',
|
||||||
@@ -2040,6 +2047,11 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'journey.settings.name': 'Nazwa',
|
'journey.settings.name': 'Nazwa',
|
||||||
'journey.settings.subtitle': 'Podtytuł',
|
'journey.settings.subtitle': 'Podtytuł',
|
||||||
'journey.settings.subtitlePlaceholder': 'np. Tajlandia, Wietnam i Kambodża',
|
'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.delete': 'Usuń',
|
||||||
'journey.settings.deleteJourney': 'Usuń dziennik podróży',
|
'journey.settings.deleteJourney': 'Usuń dziennik podróży',
|
||||||
'journey.settings.deleteMessage': 'Usunąć „{title}"? Wszystkie wpisy i zdjęcia zostaną utracone.',
|
'journey.settings.deleteMessage': 'Usunąć „{title}"? Wszystkie wpisy i zdjęcia zostaną utracone.',
|
||||||
|
|||||||
@@ -1826,6 +1826,10 @@ const ru: Record<string, string> = {
|
|||||||
'admin.notifications.adminNtfyPanel.testFailed': 'Ошибка отправки тестового Ntfy',
|
'admin.notifications.adminNtfyPanel.testFailed': 'Ошибка отправки тестового Ntfy',
|
||||||
'admin.notifications.adminNtfyPanel.alwaysOnHint': 'Ntfy администратора всегда отправляется при наличии настроенной темы',
|
'admin.notifications.adminNtfyPanel.alwaysOnHint': 'Ntfy администратора всегда отправляется при наличии настроенной темы',
|
||||||
'admin.notifications.adminNotificationsHint': 'Настройте, какие каналы доставляют уведомления администратора (например, оповещения о версиях). Вебхук отправляется автоматически, если задан URL вебхука администратора.',
|
'admin.notifications.adminNotificationsHint': 'Настройте, какие каналы доставляют уведомления администратора (например, оповещения о версиях). Вебхук отправляется автоматически, если задан URL вебхука администратора.',
|
||||||
|
'admin.notifications.tripReminders.title': 'Напоминания о поездках',
|
||||||
|
'admin.notifications.tripReminders.hint': 'Отправляет напоминание перед началом поездки (необходимо указать дни напоминания в параметрах поездки).',
|
||||||
|
'admin.notifications.tripReminders.enabled': 'Напоминания о поездках включены',
|
||||||
|
'admin.notifications.tripReminders.disabled': 'Напоминания о поездках отключены',
|
||||||
'admin.tabs.notifications': 'Уведомления',
|
'admin.tabs.notifications': 'Уведомления',
|
||||||
'notifications.versionAvailable.title': 'Доступно обновление',
|
'notifications.versionAvailable.title': 'Доступно обновление',
|
||||||
'notifications.versionAvailable.text': 'TREK {version} теперь доступен.',
|
'notifications.versionAvailable.text': 'TREK {version} теперь доступен.',
|
||||||
@@ -1873,6 +1877,8 @@ const ru: Record<string, string> = {
|
|||||||
'memories.saveRouteNotConfigured': 'Маршрут сохранения не настроен для этого провайдера',
|
'memories.saveRouteNotConfigured': 'Маршрут сохранения не настроен для этого провайдера',
|
||||||
'memories.testRouteNotConfigured': 'Маршрут тестирования не настроен для этого провайдера',
|
'memories.testRouteNotConfigured': 'Маршрут тестирования не настроен для этого провайдера',
|
||||||
'memories.fillRequiredFields': 'Пожалуйста, заполните все обязательные поля',
|
'memories.fillRequiredFields': 'Пожалуйста, заполните все обязательные поля',
|
||||||
|
'journey.search.placeholder': 'Поиск путешествий…',
|
||||||
|
'journey.search.noResults': 'Путешествий по запросу «{query}» не найдено',
|
||||||
'journey.title': 'Путешествие',
|
'journey.title': 'Путешествие',
|
||||||
'journey.subtitle': 'Отслеживайте свои путешествия в реальном времени',
|
'journey.subtitle': 'Отслеживайте свои путешествия в реальном времени',
|
||||||
'journey.new': 'Новое путешествие',
|
'journey.new': 'Новое путешествие',
|
||||||
@@ -1894,6 +1900,7 @@ const ru: Record<string, string> = {
|
|||||||
'journey.status.active': 'Активно',
|
'journey.status.active': 'Активно',
|
||||||
'journey.status.completed': 'Завершено',
|
'journey.status.completed': 'Завершено',
|
||||||
'journey.status.upcoming': 'Предстоящее',
|
'journey.status.upcoming': 'Предстоящее',
|
||||||
|
'journey.status.archived': 'В архиве',
|
||||||
'journey.checkin.add': 'Отметиться',
|
'journey.checkin.add': 'Отметиться',
|
||||||
'journey.checkin.namePlaceholder': 'Название места',
|
'journey.checkin.namePlaceholder': 'Название места',
|
||||||
'journey.checkin.notesPlaceholder': 'Заметки (необязательно)',
|
'journey.checkin.notesPlaceholder': 'Заметки (необязательно)',
|
||||||
@@ -2047,6 +2054,11 @@ const ru: Record<string, string> = {
|
|||||||
'journey.settings.name': 'Название',
|
'journey.settings.name': 'Название',
|
||||||
'journey.settings.subtitle': 'Подзаголовок',
|
'journey.settings.subtitle': 'Подзаголовок',
|
||||||
'journey.settings.subtitlePlaceholder': 'напр. Таиланд, Вьетнам и Камбоджа',
|
'journey.settings.subtitlePlaceholder': 'напр. Таиланд, Вьетнам и Камбоджа',
|
||||||
|
'journey.settings.endJourney': 'Архивировать путешествие',
|
||||||
|
'journey.settings.reopenJourney': 'Восстановить путешествие',
|
||||||
|
'journey.settings.archived': 'Путешествие архивировано',
|
||||||
|
'journey.settings.reopened': 'Путешествие возобновлено',
|
||||||
|
'journey.settings.endDescription': 'Скрывает значок «В эфире». Вы можете возобновить в любое время.',
|
||||||
'journey.settings.delete': 'Удалить',
|
'journey.settings.delete': 'Удалить',
|
||||||
'journey.settings.deleteJourney': 'Удалить путешествие',
|
'journey.settings.deleteJourney': 'Удалить путешествие',
|
||||||
'journey.settings.deleteMessage': 'Удалить «{title}»? Все записи и фото будут потеряны.',
|
'journey.settings.deleteMessage': 'Удалить «{title}»? Все записи и фото будут потеряны.',
|
||||||
|
|||||||
@@ -1826,6 +1826,10 @@ const zh: Record<string, string> = {
|
|||||||
'admin.notifications.adminNtfyPanel.testFailed': '测试 Ntfy 失败',
|
'admin.notifications.adminNtfyPanel.testFailed': '测试 Ntfy 失败',
|
||||||
'admin.notifications.adminNtfyPanel.alwaysOnHint': '配置主题后管理员 Ntfy 始终触发',
|
'admin.notifications.adminNtfyPanel.alwaysOnHint': '配置主题后管理员 Ntfy 始终触发',
|
||||||
'admin.notifications.adminNotificationsHint': '配置哪些渠道发送管理员通知(如版本更新提醒)。设置管理员 Webhook URL 后,Webhook 将自动触发。',
|
'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': '通知',
|
'admin.tabs.notifications': '通知',
|
||||||
'notifications.versionAvailable.title': '有可用更新',
|
'notifications.versionAvailable.title': '有可用更新',
|
||||||
'notifications.versionAvailable.text': 'TREK {version} 现已可用。',
|
'notifications.versionAvailable.text': 'TREK {version} 现已可用。',
|
||||||
@@ -1873,6 +1877,8 @@ const zh: Record<string, string> = {
|
|||||||
'memories.saveRouteNotConfigured': '此提供商未配置保存路由',
|
'memories.saveRouteNotConfigured': '此提供商未配置保存路由',
|
||||||
'memories.testRouteNotConfigured': '此提供商未配置测试路由',
|
'memories.testRouteNotConfigured': '此提供商未配置测试路由',
|
||||||
'memories.fillRequiredFields': '请填写所有必填字段',
|
'memories.fillRequiredFields': '请填写所有必填字段',
|
||||||
|
'journey.search.placeholder': '搜索旅程…',
|
||||||
|
'journey.search.noResults': '没有与"{query}"匹配的旅程',
|
||||||
'journey.title': '旅程',
|
'journey.title': '旅程',
|
||||||
'journey.subtitle': '实时记录你的旅行',
|
'journey.subtitle': '实时记录你的旅行',
|
||||||
'journey.new': '新建旅程',
|
'journey.new': '新建旅程',
|
||||||
@@ -1894,6 +1900,7 @@ const zh: Record<string, string> = {
|
|||||||
'journey.status.active': '进行中',
|
'journey.status.active': '进行中',
|
||||||
'journey.status.completed': '已完成',
|
'journey.status.completed': '已完成',
|
||||||
'journey.status.upcoming': '即将开始',
|
'journey.status.upcoming': '即将开始',
|
||||||
|
'journey.status.archived': '已归档',
|
||||||
'journey.checkin.add': '签到',
|
'journey.checkin.add': '签到',
|
||||||
'journey.checkin.namePlaceholder': '地点名称',
|
'journey.checkin.namePlaceholder': '地点名称',
|
||||||
'journey.checkin.notesPlaceholder': '备注(可选)',
|
'journey.checkin.notesPlaceholder': '备注(可选)',
|
||||||
@@ -2047,6 +2054,11 @@ const zh: Record<string, string> = {
|
|||||||
'journey.settings.name': '名称',
|
'journey.settings.name': '名称',
|
||||||
'journey.settings.subtitle': '副标题',
|
'journey.settings.subtitle': '副标题',
|
||||||
'journey.settings.subtitlePlaceholder': '例如 泰国、越南和柬埔寨',
|
'journey.settings.subtitlePlaceholder': '例如 泰国、越南和柬埔寨',
|
||||||
|
'journey.settings.endJourney': '归档旅程',
|
||||||
|
'journey.settings.reopenJourney': '恢复旅程',
|
||||||
|
'journey.settings.archived': '旅程已归档',
|
||||||
|
'journey.settings.reopened': '旅程已重新开启',
|
||||||
|
'journey.settings.endDescription': '隐藏直播标记。您可以随时重新开启。',
|
||||||
'journey.settings.delete': '删除',
|
'journey.settings.delete': '删除',
|
||||||
'journey.settings.deleteJourney': '删除旅程',
|
'journey.settings.deleteJourney': '删除旅程',
|
||||||
'journey.settings.deleteMessage': '删除"{title}"?所有条目和照片将丢失。',
|
'journey.settings.deleteMessage': '删除"{title}"?所有条目和照片将丢失。',
|
||||||
|
|||||||
@@ -251,6 +251,10 @@ const zhTw: Record<string, string> = {
|
|||||||
'admin.notifications.adminNtfyPanel.testFailed': '測試 Ntfy 失敗',
|
'admin.notifications.adminNtfyPanel.testFailed': '測試 Ntfy 失敗',
|
||||||
'admin.notifications.adminNtfyPanel.alwaysOnHint': '設定主題後管理員 Ntfy 始終觸發',
|
'admin.notifications.adminNtfyPanel.alwaysOnHint': '設定主題後管理員 Ntfy 始終觸發',
|
||||||
'admin.notifications.adminNotificationsHint': '配置哪些渠道傳遞僅管理員通知(例如版本提醒)。',
|
'admin.notifications.adminNotificationsHint': '配置哪些渠道傳遞僅管理員通知(例如版本提醒)。',
|
||||||
|
'admin.notifications.tripReminders.title': '行程提醒',
|
||||||
|
'admin.notifications.tripReminders.hint': '在行程開始前發送提醒通知(需要在行程中設定提醒天數)。',
|
||||||
|
'admin.notifications.tripReminders.enabled': '行程提醒已啟用',
|
||||||
|
'admin.notifications.tripReminders.disabled': '行程提醒已停用',
|
||||||
'admin.smtp.title': '郵件與通知',
|
'admin.smtp.title': '郵件與通知',
|
||||||
'admin.smtp.hint': '用於傳送電子郵件通知的 SMTP 配置。',
|
'admin.smtp.hint': '用於傳送電子郵件通知的 SMTP 配置。',
|
||||||
'admin.smtp.testButton': '傳送測試郵件',
|
'admin.smtp.testButton': '傳送測試郵件',
|
||||||
@@ -1833,6 +1837,8 @@ const zhTw: Record<string, string> = {
|
|||||||
'memories.saveRouteNotConfigured': '此提供商未設定儲存路由',
|
'memories.saveRouteNotConfigured': '此提供商未設定儲存路由',
|
||||||
'memories.testRouteNotConfigured': '此提供商未設定測試路由',
|
'memories.testRouteNotConfigured': '此提供商未設定測試路由',
|
||||||
'memories.fillRequiredFields': '請填寫所有必填欄位',
|
'memories.fillRequiredFields': '請填寫所有必填欄位',
|
||||||
|
'journey.search.placeholder': '搜尋旅程…',
|
||||||
|
'journey.search.noResults': '沒有符合「{query}」的旅程',
|
||||||
'journey.title': '旅程',
|
'journey.title': '旅程',
|
||||||
'journey.subtitle': '即時記錄你的旅行',
|
'journey.subtitle': '即時記錄你的旅行',
|
||||||
'journey.new': '新建旅程',
|
'journey.new': '新建旅程',
|
||||||
@@ -1854,6 +1860,7 @@ const zhTw: Record<string, string> = {
|
|||||||
'journey.status.active': '進行中',
|
'journey.status.active': '進行中',
|
||||||
'journey.status.completed': '已完成',
|
'journey.status.completed': '已完成',
|
||||||
'journey.status.upcoming': '即將開始',
|
'journey.status.upcoming': '即將開始',
|
||||||
|
'journey.status.archived': '已封存',
|
||||||
'journey.checkin.add': '打卡',
|
'journey.checkin.add': '打卡',
|
||||||
'journey.checkin.namePlaceholder': '地點名稱',
|
'journey.checkin.namePlaceholder': '地點名稱',
|
||||||
'journey.checkin.notesPlaceholder': '備註(可選)',
|
'journey.checkin.notesPlaceholder': '備註(可選)',
|
||||||
@@ -2007,6 +2014,11 @@ const zhTw: Record<string, string> = {
|
|||||||
'journey.settings.name': '名稱',
|
'journey.settings.name': '名稱',
|
||||||
'journey.settings.subtitle': '副標題',
|
'journey.settings.subtitle': '副標題',
|
||||||
'journey.settings.subtitlePlaceholder': '例如 泰國、越南和柬埔寨',
|
'journey.settings.subtitlePlaceholder': '例如 泰國、越南和柬埔寨',
|
||||||
|
'journey.settings.endJourney': '封存旅程',
|
||||||
|
'journey.settings.reopenJourney': '還原旅程',
|
||||||
|
'journey.settings.archived': '旅程已封存',
|
||||||
|
'journey.settings.reopened': '旅程已重新開啟',
|
||||||
|
'journey.settings.endDescription': '隱藏直播標記。您可以隨時重新開啟。',
|
||||||
'journey.settings.delete': '刪除',
|
'journey.settings.delete': '刪除',
|
||||||
'journey.settings.deleteJourney': '刪除旅程',
|
'journey.settings.deleteJourney': '刪除旅程',
|
||||||
'journey.settings.deleteMessage': '刪除「{title}」?所有條目和照片將遺失。',
|
'journey.settings.deleteMessage': '刪除「{title}」?所有條目和照片將遺失。',
|
||||||
|
|||||||
@@ -1180,6 +1180,7 @@ export default function AdminPage(): React.ReactElement {
|
|||||||
const emailActive = activeChans.includes('email')
|
const emailActive = activeChans.includes('email')
|
||||||
const webhookActive = activeChans.includes('webhook')
|
const webhookActive = activeChans.includes('webhook')
|
||||||
const ntfyActive = activeChans.includes('ntfy')
|
const ntfyActive = activeChans.includes('ntfy')
|
||||||
|
const tripRemindersActive = smtpValues.notify_trip_reminder !== 'false'
|
||||||
|
|
||||||
const setChannels = async (email: boolean, webhook: boolean, ntfy: boolean) => {
|
const setChannels = async (email: boolean, webhook: boolean, ntfy: boolean) => {
|
||||||
const chans = [email && 'email', webhook && 'webhook', ntfy && 'ntfy'].filter(Boolean).join(',') || 'none'
|
const chans = [email && 'email', webhook && 'webhook', ntfy && 'ntfy'].filter(Boolean).join(',') || 'none'
|
||||||
@@ -1338,6 +1339,37 @@ export default function AdminPage(): React.ReactElement {
|
|||||||
</div>
|
</div>
|
||||||
</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 */}
|
{/* Admin Webhook Panel */}
|
||||||
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
|
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
|
||||||
<div className="px-6 py-4 border-b border-slate-100">
|
<div className="px-6 py-4 border-b border-slate-100">
|
||||||
|
|||||||
@@ -176,7 +176,7 @@ const mockJourneyDetail = {
|
|||||||
avatar: null,
|
avatar: null,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
stats: { entries: 2, photos: 1, cities: 2 },
|
stats: { entries: 2, photos: 1, places: 2 },
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── MSW Handlers ─────────────────────────────────────────────────────────────
|
// ── MSW Handlers ─────────────────────────────────────────────────────────────
|
||||||
@@ -362,12 +362,12 @@ describe('JourneyDetailPage', () => {
|
|||||||
expect(screen.getAllByText('Days').length).toBeGreaterThanOrEqual(1);
|
expect(screen.getAllByText('Days').length).toBeGreaterThanOrEqual(1);
|
||||||
expect(screen.getAllByText('Entries').length).toBeGreaterThanOrEqual(1);
|
expect(screen.getAllByText('Entries').length).toBeGreaterThanOrEqual(1);
|
||||||
expect(screen.getAllByText('Photos').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 () => {
|
it('renders stat values', async () => {
|
||||||
await renderAndWait();
|
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
|
// Entries count appears in hero and sidebar
|
||||||
const twos = screen.getAllByText('2');
|
const twos = screen.getAllByText('2');
|
||||||
expect(twos.length).toBeGreaterThanOrEqual(1);
|
expect(twos.length).toBeGreaterThanOrEqual(1);
|
||||||
@@ -474,7 +474,7 @@ describe('JourneyDetailPage', () => {
|
|||||||
// ── FE-PAGE-JOURNEYDETAIL-018 ──────────────────────────────────────────
|
// ── FE-PAGE-JOURNEYDETAIL-018 ──────────────────────────────────────────
|
||||||
describe('FE-PAGE-JOURNEYDETAIL-018: Empty state when no entries', () => {
|
describe('FE-PAGE-JOURNEYDETAIL-018: Empty state when no entries', () => {
|
||||||
it('shows "No entries yet" when journey has no entries', async () => {
|
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 />);
|
render(<JourneyDetailPage />);
|
||||||
|
|
||||||
@@ -484,7 +484,7 @@ describe('JourneyDetailPage', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('shows hint text to add a trip', async () => {
|
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 />);
|
render(<JourneyDetailPage />);
|
||||||
|
|
||||||
@@ -567,7 +567,7 @@ describe('JourneyDetailPage', () => {
|
|||||||
};
|
};
|
||||||
setupDefaultHandlers({
|
setupDefaultHandlers({
|
||||||
entries: [multiPhotoEntry, mockJourneyDetail.entries[1]],
|
entries: [multiPhotoEntry, mockJourneyDetail.entries[1]],
|
||||||
stats: { entries: 2, photos: 3, cities: 2 },
|
stats: { entries: 2, photos: 3, places: 2 },
|
||||||
});
|
});
|
||||||
|
|
||||||
render(<JourneyDetailPage />);
|
render(<JourneyDetailPage />);
|
||||||
@@ -610,7 +610,7 @@ describe('JourneyDetailPage', () => {
|
|||||||
};
|
};
|
||||||
setupDefaultHandlers({
|
setupDefaultHandlers({
|
||||||
entries: [...mockJourneyDetail.entries, skeletonEntry],
|
entries: [...mockJourneyDetail.entries, skeletonEntry],
|
||||||
stats: { entries: 3, photos: 1, cities: 3 },
|
stats: { entries: 3, photos: 1, places: 3 },
|
||||||
});
|
});
|
||||||
|
|
||||||
render(<JourneyDetailPage />);
|
render(<JourneyDetailPage />);
|
||||||
@@ -650,7 +650,7 @@ describe('JourneyDetailPage', () => {
|
|||||||
};
|
};
|
||||||
setupDefaultHandlers({
|
setupDefaultHandlers({
|
||||||
entries: [...mockJourneyDetail.entries, checkinEntry],
|
entries: [...mockJourneyDetail.entries, checkinEntry],
|
||||||
stats: { entries: 3, photos: 1, cities: 2 },
|
stats: { entries: 3, photos: 1, places: 2 },
|
||||||
});
|
});
|
||||||
|
|
||||||
render(<JourneyDetailPage />);
|
render(<JourneyDetailPage />);
|
||||||
@@ -707,15 +707,26 @@ describe('JourneyDetailPage', () => {
|
|||||||
|
|
||||||
// ── FE-PAGE-JOURNEYDETAIL-030 ──────────────────────────────────────────
|
// ── FE-PAGE-JOURNEYDETAIL-030 ──────────────────────────────────────────
|
||||||
describe('FE-PAGE-JOURNEYDETAIL-030: Active status badge shows Live indicator', () => {
|
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();
|
await renderAndWait();
|
||||||
expect(screen.getByText('Live')).toBeInTheDocument();
|
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 ──────────────────────────────────────────
|
// ── FE-PAGE-JOURNEYDETAIL-031 ──────────────────────────────────────────
|
||||||
describe('FE-PAGE-JOURNEYDETAIL-031: Synced with Trips badge renders', () => {
|
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();
|
await renderAndWait();
|
||||||
expect(screen.getByText('Synced with Trips')).toBeInTheDocument();
|
expect(screen.getByText('Synced with Trips')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
@@ -741,7 +752,7 @@ describe('JourneyDetailPage', () => {
|
|||||||
it('shows the place count in the sidebar map', async () => {
|
it('shows the place count in the sidebar map', async () => {
|
||||||
await renderAndWait();
|
await renderAndWait();
|
||||||
// The sidebar map shows "N Places" text
|
// The sidebar map shows "N Places" text
|
||||||
expect(screen.getByText(/Places/)).toBeInTheDocument();
|
expect(screen.getAllByText(/Places/).length).toBeGreaterThanOrEqual(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1717,7 +1728,7 @@ describe('JourneyDetailPage', () => {
|
|||||||
};
|
};
|
||||||
setupDefaultHandlers({
|
setupDefaultHandlers({
|
||||||
entries: [emptyEntry],
|
entries: [emptyEntry],
|
||||||
stats: { entries: 1, photos: 0, cities: 1 },
|
stats: { entries: 1, photos: 0, places: 1 },
|
||||||
});
|
});
|
||||||
|
|
||||||
render(<JourneyDetailPage />);
|
render(<JourneyDetailPage />);
|
||||||
@@ -1930,7 +1941,7 @@ describe('JourneyDetailPage', () => {
|
|||||||
{ ...mockJourneyDetail.entries[0], id: 10, entry_date: '2026-03-15' },
|
{ ...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 },
|
{ ...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 />);
|
render(<JourneyDetailPage />);
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
@@ -2005,7 +2016,7 @@ describe('JourneyDetailPage', () => {
|
|||||||
};
|
};
|
||||||
setupDefaultHandlers({
|
setupDefaultHandlers({
|
||||||
entries: [immichEntry, mockJourneyDetail.entries[1]],
|
entries: [immichEntry, mockJourneyDetail.entries[1]],
|
||||||
stats: { entries: 2, photos: 1, cities: 2 },
|
stats: { entries: 2, photos: 1, places: 2 },
|
||||||
});
|
});
|
||||||
|
|
||||||
render(<JourneyDetailPage />);
|
render(<JourneyDetailPage />);
|
||||||
@@ -2039,7 +2050,7 @@ describe('JourneyDetailPage', () => {
|
|||||||
};
|
};
|
||||||
setupDefaultHandlers({
|
setupDefaultHandlers({
|
||||||
entries: [synologyEntry, mockJourneyDetail.entries[1]],
|
entries: [synologyEntry, mockJourneyDetail.entries[1]],
|
||||||
stats: { entries: 2, photos: 1, cities: 2 },
|
stats: { entries: 2, photos: 1, places: 2 },
|
||||||
});
|
});
|
||||||
|
|
||||||
render(<JourneyDetailPage />);
|
render(<JourneyDetailPage />);
|
||||||
@@ -2636,7 +2647,7 @@ describe('JourneyDetailPage', () => {
|
|||||||
};
|
};
|
||||||
setupDefaultHandlers({
|
setupDefaultHandlers({
|
||||||
entries: [multiPhotoEntry, mockJourneyDetail.entries[1]],
|
entries: [multiPhotoEntry, mockJourneyDetail.entries[1]],
|
||||||
stats: { entries: 2, photos: 5, cities: 2 },
|
stats: { entries: 2, photos: 5, places: 2 },
|
||||||
});
|
});
|
||||||
|
|
||||||
render(<JourneyDetailPage />);
|
render(<JourneyDetailPage />);
|
||||||
@@ -2661,7 +2672,7 @@ describe('JourneyDetailPage', () => {
|
|||||||
};
|
};
|
||||||
setupDefaultHandlers({
|
setupDefaultHandlers({
|
||||||
entries: [twoPhotoEntry, mockJourneyDetail.entries[1]],
|
entries: [twoPhotoEntry, mockJourneyDetail.entries[1]],
|
||||||
stats: { entries: 2, photos: 2, cities: 2 },
|
stats: { entries: 2, photos: 2, places: 2 },
|
||||||
});
|
});
|
||||||
|
|
||||||
render(<JourneyDetailPage />);
|
render(<JourneyDetailPage />);
|
||||||
@@ -3045,7 +3056,7 @@ describe('JourneyDetailPage', () => {
|
|||||||
};
|
};
|
||||||
setupDefaultHandlers({
|
setupDefaultHandlers({
|
||||||
entries: [mockJourneyDetail.entries[0], noLocEntry],
|
entries: [mockJourneyDetail.entries[0], noLocEntry],
|
||||||
stats: { entries: 2, photos: 1, cities: 1 },
|
stats: { entries: 2, photos: 1, places: 1 },
|
||||||
});
|
});
|
||||||
|
|
||||||
render(<JourneyDetailPage />);
|
render(<JourneyDetailPage />);
|
||||||
@@ -3528,7 +3539,7 @@ describe('JourneyDetailPage', () => {
|
|||||||
};
|
};
|
||||||
setupDefaultHandlers({
|
setupDefaultHandlers({
|
||||||
entries: [entryWithMultiPhotos, mockJourneyDetail.entries[1]],
|
entries: [entryWithMultiPhotos, mockJourneyDetail.entries[1]],
|
||||||
stats: { entries: 2, photos: 2, cities: 2 },
|
stats: { entries: 2, photos: 2, places: 2 },
|
||||||
});
|
});
|
||||||
|
|
||||||
server.use(
|
server.use(
|
||||||
@@ -3620,7 +3631,7 @@ describe('JourneyDetailPage', () => {
|
|||||||
};
|
};
|
||||||
setupDefaultHandlers({
|
setupDefaultHandlers({
|
||||||
entries: [mockJourneyDetail.entries[0], noTitleEntry],
|
entries: [mockJourneyDetail.entries[0], noTitleEntry],
|
||||||
stats: { entries: 2, photos: 1, cities: 2 },
|
stats: { entries: 2, photos: 1, places: 2 },
|
||||||
});
|
});
|
||||||
|
|
||||||
render(<JourneyDetailPage />);
|
render(<JourneyDetailPage />);
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import MobileMapTimeline from '../components/Journey/MobileMapTimeline'
|
|||||||
import MobileEntryView from '../components/Journey/MobileEntryView'
|
import MobileEntryView from '../components/Journey/MobileEntryView'
|
||||||
import { useIsMobile } from '../hooks/useIsMobile'
|
import { useIsMobile } from '../hooks/useIsMobile'
|
||||||
import type { JourneyEntry, JourneyPhoto, JourneyDetail } from '../store/journeyStore'
|
import type { JourneyEntry, JourneyPhoto, JourneyDetail } from '../store/journeyStore'
|
||||||
|
import { computeJourneyLifecycle } from '../utils/journeyLifecycle'
|
||||||
|
|
||||||
const GRADIENTS = [
|
const GRADIENTS = [
|
||||||
'linear-gradient(135deg, #0F172A 0%, #6366F1 45%, #EC4899 100%)',
|
'linear-gradient(135deg, #0F172A 0%, #6366F1 45%, #EC4899 100%)',
|
||||||
@@ -207,6 +208,14 @@ export default function JourneyDetailPage() {
|
|||||||
const dayGroups = groupByDate(timelineEntries)
|
const dayGroups = groupByDate(timelineEntries)
|
||||||
const sortedDates = [...dayGroups.keys()].sort()
|
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'
|
const showMobileCombined = isMobile && view === 'timeline'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -283,16 +292,28 @@ export default function JourneyDetailPage() {
|
|||||||
<div className="relative z-[3] flex items-center justify-between mb-5">
|
<div className="relative z-[3] flex items-center justify-between mb-5">
|
||||||
{/* Desktop: badges */}
|
{/* Desktop: badges */}
|
||||||
<div className="hidden md:flex items-center gap-2">
|
<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">
|
<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" />
|
<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>
|
||||||
)}
|
)}
|
||||||
<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>
|
</div>
|
||||||
{/* Mobile: back button on the left */}
|
{/* Mobile: back button on the left */}
|
||||||
<button
|
<button
|
||||||
@@ -331,7 +352,7 @@ export default function JourneyDetailPage() {
|
|||||||
<div className="flex gap-8">
|
<div className="flex gap-8">
|
||||||
{[
|
{[
|
||||||
{ value: sortedDates.length, label: t('journey.stats.days') },
|
{ 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.entries, label: t('journey.stats.entries') },
|
||||||
{ value: current.stats.photos, label: t('journey.stats.photos') },
|
{ value: current.stats.photos, label: t('journey.stats.photos') },
|
||||||
].map(s => (
|
].map(s => (
|
||||||
@@ -494,7 +515,7 @@ export default function JourneyDetailPage() {
|
|||||||
{ value: sortedDates.length, label: t('journey.stats.days') },
|
{ value: sortedDates.length, label: t('journey.stats.days') },
|
||||||
{ value: current.stats.entries, label: t('journey.stats.entries') },
|
{ value: current.stats.entries, label: t('journey.stats.entries') },
|
||||||
{ value: current.stats.photos, label: t('journey.stats.photos') },
|
{ 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 => (
|
].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 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>
|
<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 +1042,7 @@ function GalleryView({ entries, journeyId, userId, trips, onPhotoClick, onRefres
|
|||||||
trips={trips}
|
trips={trips}
|
||||||
existingAssetIds={new Set(entries.flatMap(e => (e.photos || []).filter(p => p.asset_id).map(p => p.asset_id!)))}
|
existingAssetIds={new Set(entries.flatMap(e => (e.photos || []).filter(p => p.asset_id).map(p => p.asset_id!)))}
|
||||||
onClose={() => setShowPicker(false)}
|
onClose={() => setShowPicker(false)}
|
||||||
onAdd={async (assetIds, entryId) => {
|
onAdd={async (assetIds, entryId, passphrase) => {
|
||||||
let targetId = entryId
|
let targetId = entryId
|
||||||
if (!targetId) {
|
if (!targetId) {
|
||||||
try {
|
try {
|
||||||
@@ -1035,7 +1056,7 @@ function GalleryView({ entries, journeyId, userId, trips, onPhotoClick, onRefres
|
|||||||
}
|
}
|
||||||
let added = 0
|
let added = 0
|
||||||
try {
|
try {
|
||||||
const result = await journeyApi.addProviderPhotos(targetId, pickerProvider!, assetIds)
|
const result = await journeyApi.addProviderPhotos(targetId, pickerProvider!, assetIds, undefined, passphrase)
|
||||||
added = result.added || 0
|
added = result.added || 0
|
||||||
} catch {}
|
} catch {}
|
||||||
if (added > 0) {
|
if (added > 0) {
|
||||||
@@ -1511,7 +1532,7 @@ function ProviderPicker({ provider, userId, entries, trips, existingAssetIds, on
|
|||||||
trips: JourneyTrip[]
|
trips: JourneyTrip[]
|
||||||
existingAssetIds: Set<string>
|
existingAssetIds: Set<string>
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
onAdd: (assetIds: string[], entryId: number | null) => Promise<void>
|
onAdd: (assetIds: string[], entryId: number | null, passphrase?: string) => Promise<void>
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const [filter, setFilter] = useState<'trip' | 'custom' | 'all' | 'album'>('trip')
|
const [filter, setFilter] = useState<'trip' | 'custom' | 'all' | 'album'>('trip')
|
||||||
@@ -1884,7 +1905,7 @@ function ProviderPicker({ provider, userId, entries, trips, existingAssetIds, on
|
|||||||
{t('common.cancel')}
|
{t('common.cancel')}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => onAdd([...selected], targetEntryId)}
|
onClick={() => onAdd([...selected], targetEntryId, selectedAlbumPassphrase)}
|
||||||
disabled={selected.size === 0}
|
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"
|
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 +2112,7 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa
|
|||||||
|
|
||||||
return (
|
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="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">
|
<div className="flex items-center justify-between px-6 py-4 border-b border-zinc-200 dark:border-zinc-700">
|
||||||
@@ -2820,6 +2841,21 @@ function JourneySettingsDialog({ journey, onClose, onSaved, onOpenInvite }: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
|
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 () => {
|
const handleDelete = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -2947,11 +2983,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">
|
<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
|
<button
|
||||||
onClick={() => setShowDeleteConfirm(true)}
|
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} />
|
<Trash2 size={13} />
|
||||||
{t('journey.settings.delete')}
|
{t('journey.settings.delete')}
|
||||||
</button>
|
</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={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">
|
<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')}
|
{saving ? t('common.saving') : t('common.save')}
|
||||||
|
|||||||
@@ -43,7 +43,9 @@ function buildJourneyListItem(overrides: Record<string, unknown> = {}) {
|
|||||||
status: 'draft' as const,
|
status: 'draft' as const,
|
||||||
entry_count: 0,
|
entry_count: 0,
|
||||||
photo_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(),
|
created_at: Date.now(),
|
||||||
updated_at: Date.now(),
|
updated_at: Date.now(),
|
||||||
...overrides,
|
...overrides,
|
||||||
@@ -194,7 +196,7 @@ describe('JourneyPage', () => {
|
|||||||
|
|
||||||
// FE-PAGE-JOURNEY-008
|
// FE-PAGE-JOURNEY-008
|
||||||
it('FE-PAGE-JOURNEY-008: shows active journey hero when active journey exists', async () => {
|
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' });
|
const other = buildJourneyListItem({ id: 11, title: 'Completed Trip', status: 'completed' });
|
||||||
setupDefaultHandlers([active, other]);
|
setupDefaultHandlers([active, other]);
|
||||||
|
|
||||||
@@ -320,13 +322,13 @@ describe('JourneyPage', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// FE-PAGE-JOURNEY-013
|
// 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({
|
const j1 = buildJourneyListItem({
|
||||||
id: 20,
|
id: 20,
|
||||||
title: 'Stats Journey',
|
title: 'Stats Journey',
|
||||||
entry_count: 12,
|
entry_count: 12,
|
||||||
photo_count: 47,
|
photo_count: 47,
|
||||||
city_count: 5,
|
place_count: 5,
|
||||||
});
|
});
|
||||||
setupDefaultHandlers([j1]);
|
setupDefaultHandlers([j1]);
|
||||||
|
|
||||||
@@ -335,7 +337,7 @@ describe('JourneyPage', () => {
|
|||||||
expect(screen.getByText('Stats Journey')).toBeInTheDocument();
|
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('12')).toBeInTheDocument();
|
||||||
expect(screen.getByText('47')).toBeInTheDocument();
|
expect(screen.getByText('47')).toBeInTheDocument();
|
||||||
expect(screen.getByText('5')).toBeInTheDocument();
|
expect(screen.getByText('5')).toBeInTheDocument();
|
||||||
@@ -361,6 +363,8 @@ describe('JourneyPage', () => {
|
|||||||
id: 40,
|
id: 40,
|
||||||
title: 'Recent Active',
|
title: 'Recent Active',
|
||||||
status: 'active',
|
status: 'active',
|
||||||
|
trip_date_min: '2020-01-01',
|
||||||
|
trip_date_max: '2099-12-31',
|
||||||
updated_at: Date.now() - 60000, // 1 minute ago
|
updated_at: Date.now() - 60000, // 1 minute ago
|
||||||
});
|
});
|
||||||
setupDefaultHandlers([active]);
|
setupDefaultHandlers([active]);
|
||||||
@@ -380,6 +384,8 @@ describe('JourneyPage', () => {
|
|||||||
id: 41,
|
id: 41,
|
||||||
title: 'Hours Active',
|
title: 'Hours Active',
|
||||||
status: 'active',
|
status: 'active',
|
||||||
|
trip_date_min: '2020-01-01',
|
||||||
|
trip_date_max: '2099-12-31',
|
||||||
updated_at: Date.now() - 3 * 3600000, // 3 hours ago
|
updated_at: Date.now() - 3 * 3600000, // 3 hours ago
|
||||||
});
|
});
|
||||||
setupDefaultHandlers([active]);
|
setupDefaultHandlers([active]);
|
||||||
@@ -399,6 +405,8 @@ describe('JourneyPage', () => {
|
|||||||
id: 42,
|
id: 42,
|
||||||
title: 'Days Active',
|
title: 'Days Active',
|
||||||
status: '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
|
updated_at: Date.now() - 5 * 24 * 3600000, // 5 days ago
|
||||||
});
|
});
|
||||||
setupDefaultHandlers([active]);
|
setupDefaultHandlers([active]);
|
||||||
@@ -414,7 +422,7 @@ describe('JourneyPage', () => {
|
|||||||
|
|
||||||
// FE-PAGE-JOURNEY-018
|
// FE-PAGE-JOURNEY-018
|
||||||
it('FE-PAGE-JOURNEY-018: active journey hero shows "Continue writing" button', async () => {
|
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]);
|
setupDefaultHandlers([active]);
|
||||||
|
|
||||||
render(<JourneyPage />);
|
render(<JourneyPage />);
|
||||||
@@ -427,7 +435,7 @@ describe('JourneyPage', () => {
|
|||||||
|
|
||||||
// FE-PAGE-JOURNEY-019
|
// FE-PAGE-JOURNEY-019
|
||||||
it('FE-PAGE-JOURNEY-019: active journey hero shows Live and Synced badges', async () => {
|
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]);
|
setupDefaultHandlers([active]);
|
||||||
|
|
||||||
render(<JourneyPage />);
|
render(<JourneyPage />);
|
||||||
@@ -442,7 +450,7 @@ describe('JourneyPage', () => {
|
|||||||
// FE-PAGE-JOURNEY-020
|
// FE-PAGE-JOURNEY-020
|
||||||
it('FE-PAGE-JOURNEY-020: clicking active journey hero navigates to its detail page', async () => {
|
it('FE-PAGE-JOURNEY-020: clicking active journey hero navigates to its detail page', async () => {
|
||||||
const user = userEvent.setup();
|
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]);
|
setupDefaultHandlers([active]);
|
||||||
|
|
||||||
render(<JourneyPage />);
|
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 { useNavigate } from 'react-router-dom'
|
||||||
import { useJourneyStore } from '../store/journeyStore'
|
import { useJourneyStore } from '../store/journeyStore'
|
||||||
import { journeyApi } from '../api/client'
|
import { journeyApi } from '../api/client'
|
||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
Check, X, ChevronRight, RefreshCw, Users,
|
Check, X, ChevronRight, RefreshCw, Users,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import type { Journey } from '../store/journeyStore'
|
import type { Journey } from '../store/journeyStore'
|
||||||
|
import { computeJourneyLifecycle } from '../utils/journeyLifecycle'
|
||||||
|
|
||||||
const GRADIENTS = [
|
const GRADIENTS = [
|
||||||
'linear-gradient(135deg, #0F172A 0%, #6366F1 45%, #EC4899 100%)',
|
'linear-gradient(135deg, #0F172A 0%, #6366F1 45%, #EC4899 100%)',
|
||||||
@@ -43,6 +44,9 @@ export default function JourneyPage() {
|
|||||||
const [newTitle, setNewTitle] = useState('')
|
const [newTitle, setNewTitle] = useState('')
|
||||||
const [availableTrips, setAvailableTrips] = useState<any[]>([])
|
const [availableTrips, setAvailableTrips] = useState<any[]>([])
|
||||||
const [selectedTripIds, setSelectedTripIds] = useState<Set<number>>(new Set())
|
const [selectedTripIds, setSelectedTripIds] = useState<Set<number>>(new Set())
|
||||||
|
const [searchOpen, setSearchOpen] = useState(false)
|
||||||
|
const [searchQuery, setSearchQuery] = useState('')
|
||||||
|
const searchInputRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
// suggestion
|
// suggestion
|
||||||
const [suggestions, setSuggestions] = useState<any[]>([])
|
const [suggestions, setSuggestions] = useState<any[]>([])
|
||||||
@@ -56,12 +60,22 @@ export default function JourneyPage() {
|
|||||||
const activeSuggestion = suggestions.find(s => !dismissedSuggestions.has(s.id))
|
const activeSuggestion = suggestions.find(s => !dismissedSuggestions.has(s.id))
|
||||||
|
|
||||||
const activeJourney = useMemo(() => {
|
const activeJourney = useMemo(() => {
|
||||||
return journeys.find(j => j.status === 'active') || null
|
if (searchQuery.trim()) return null
|
||||||
}, [journeys])
|
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(() => {
|
const filteredJourneys = useMemo(() => {
|
||||||
return journeys.filter(j => j.id !== activeJourney?.id)
|
const q = searchQuery.trim().toLowerCase()
|
||||||
}, [journeys, activeJourney])
|
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) => {
|
const openCreateModal = async (preSelectedTripId?: number) => {
|
||||||
setShowCreate(true)
|
setShowCreate(true)
|
||||||
@@ -99,15 +113,41 @@ export default function JourneyPage() {
|
|||||||
<div style={{ paddingTop: 'var(--nav-h, 56px)' }}>
|
<div style={{ paddingTop: 'var(--nav-h, 56px)' }}>
|
||||||
<div className="max-w-[1440px] mx-auto">
|
<div className="max-w-[1440px] mx-auto">
|
||||||
|
|
||||||
{/* Header — mobile: just a create button */}
|
{/* Header — mobile */}
|
||||||
<div className="md:hidden px-5 pt-5 pb-4">
|
<div className="md:hidden px-5 pt-5 pb-4 flex flex-col gap-2">
|
||||||
<button
|
<div className="flex items-center gap-2">
|
||||||
onClick={() => openCreateModal()}
|
<button
|
||||||
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"
|
onClick={() => {
|
||||||
>
|
if (searchOpen) {
|
||||||
<Plus size={16} strokeWidth={2.5} />
|
setSearchOpen(false)
|
||||||
{t('journey.frontpage.createJourney')}
|
setSearchQuery('')
|
||||||
</button>
|
} 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>
|
</div>
|
||||||
|
|
||||||
{/* Header — desktop */}
|
{/* 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>
|
<p className="text-[13px] text-zinc-500 mt-1.5">{t("journey.frontpage.subtitle")}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<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">
|
{searchOpen && (
|
||||||
<Search size={15} />
|
<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>
|
||||||
<button
|
<button
|
||||||
onClick={() => openCreateModal()}
|
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).entry_count ?? '--', label: t("journey.stats.entries") },
|
||||||
{ val: (activeJourney as any).photo_count ?? '--', label: t("journey.stats.photos") },
|
{ 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 => (
|
].map(s => (
|
||||||
<div key={s.label} className="flex flex-col gap-1">
|
<div key={s.label} className="flex flex-col gap-1">
|
||||||
<span className="text-[28px] font-extrabold tracking-[-0.02em] leading-none">{s.val}</span>
|
<span className="text-[28px] font-extrabold tracking-[-0.02em] leading-none">{s.val}</span>
|
||||||
@@ -243,11 +299,24 @@ export default function JourneyPage() {
|
|||||||
</div>
|
</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 */}
|
{/* All Journeys */}
|
||||||
<div className="mb-4 flex items-center justify-between">
|
{!searchQuery.trim() && (
|
||||||
<span className="text-[11px] font-bold tracking-[0.14em] uppercase text-zinc-500">{t("journey.frontpage.allJourneys")}</span>
|
<div className="mb-4 flex items-center justify-between">
|
||||||
<span className="text-[11px] text-zinc-400">{journeys.length} {t('journey.frontpage.journeys')}</span>
|
<span className="text-[11px] font-bold tracking-[0.14em] uppercase text-zinc-500">{t("journey.frontpage.allJourneys")}</span>
|
||||||
</div>
|
<span className="text-[11px] text-zinc-400">{journeys.length} {t('journey.frontpage.journeys')}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{loading && journeys.length === 0 ? (
|
{loading && journeys.length === 0 ? (
|
||||||
<div className="flex justify-center py-16">
|
<div className="flex justify-center py-16">
|
||||||
@@ -255,7 +324,7 @@ export default function JourneyPage() {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 md:gap-[18px]">
|
<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}`)} />
|
<JourneyCard key={j.id} journey={j} onClick={() => navigate(`/journey/${j.id}`)} />
|
||||||
))}
|
))}
|
||||||
|
|
||||||
@@ -279,7 +348,7 @@ export default function JourneyPage() {
|
|||||||
{/* Create Modal */}
|
{/* Create Modal */}
|
||||||
{showCreate && (
|
{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="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 */}
|
{/* Header */}
|
||||||
<div className="px-7 pt-6 pb-5 border-b border-zinc-200 dark:border-zinc-700">
|
<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 { t } = useTranslation()
|
||||||
const j = journey
|
const j = journey
|
||||||
const entryCount = j.entry_count ?? 0
|
const entryCount = j.entry_count ?? 0
|
||||||
const photoCount = j.photo_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 (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -424,15 +494,22 @@ function JourneyCard({ journey, onClick }: { journey: Journey & { entry_count?:
|
|||||||
{j.subtitle && (
|
{j.subtitle && (
|
||||||
<p className="text-[12px] text-zinc-500 mt-1">{j.subtitle}</p>
|
<p className="text-[12px] text-zinc-500 mt-1">{j.subtitle}</p>
|
||||||
)}
|
)}
|
||||||
{j.status === 'draft' && (
|
{lifecycle !== 'live' && (
|
||||||
<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>
|
<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' }}>
|
<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: entryCount, label: t('journey.stats.entries') },
|
||||||
{ val: photoCount, label: t('journey.stats.photos') },
|
{ val: photoCount, label: t('journey.stats.photos') },
|
||||||
{ val: cityCount, label: t('journey.stats.cities') },
|
{ val: placeCount, label: t('journey.stats.places') },
|
||||||
].map(s => (
|
].map(s => (
|
||||||
<div key={s.label} className="flex flex-col gap-1">
|
<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'}`}>
|
<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: {
|
stats: {
|
||||||
entries: 2,
|
entries: 2,
|
||||||
photos: 1,
|
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(
|
server.use(
|
||||||
@@ -383,7 +383,7 @@ describe('JourneyPublicPage', () => {
|
|||||||
it('FE-PAGE-PUBLICJOURNEY-015: stats display shows entries, photos, and cities counts', async () => {
|
it('FE-PAGE-PUBLICJOURNEY-015: stats display shows entries, photos, and cities counts', async () => {
|
||||||
const customData = {
|
const customData = {
|
||||||
...mockJourneyData,
|
...mockJourneyData,
|
||||||
stats: { entries: 14, photos: 83, cities: 7 },
|
stats: { entries: 14, photos: 83, places: 7 },
|
||||||
};
|
};
|
||||||
server.use(
|
server.use(
|
||||||
http.get('/api/public/journey/test-share-token', () => HttpResponse.json(customData)),
|
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: 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: 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: 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>
|
||||||
|
|
||||||
<div className="relative" style={{ marginTop: 12, fontSize: 9, fontWeight: 500, letterSpacing: 1.5, textTransform: 'uppercase', opacity: 0.25 }}>{t('journey.public.readOnly')}</div>
|
<div className="relative" style={{ marginTop: 12, fontSize: 9, fontWeight: 500, letterSpacing: 1.5, textTransform: 'uppercase', opacity: 0.25 }}>{t('journey.public.readOnly')}</div>
|
||||||
|
|||||||
@@ -866,7 +866,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{selectedPlace && isMobile && ReactDOM.createPortal(
|
{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()}>
|
<div style={{ width: '100%', maxHeight: '85vh' }} onClick={e => e.stopPropagation()}>
|
||||||
<PlaceInspector
|
<PlaceInspector
|
||||||
place={selectedPlace}
|
place={selectedPlace}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ export interface Journey {
|
|||||||
subtitle?: string | null
|
subtitle?: string | null
|
||||||
cover_gradient?: string | null
|
cover_gradient?: string | null
|
||||||
cover_image?: string | null
|
cover_image?: string | null
|
||||||
status: 'draft' | 'active' | 'completed'
|
status: 'draft' | 'active' | 'completed' | 'archived'
|
||||||
created_at: number
|
created_at: number
|
||||||
updated_at: number
|
updated_at: number
|
||||||
}
|
}
|
||||||
@@ -81,7 +81,7 @@ export interface JourneyDetail extends Journey {
|
|||||||
entries: JourneyEntry[]
|
entries: JourneyEntry[]
|
||||||
trips: JourneyTrip[]
|
trips: JourneyTrip[]
|
||||||
contributors: JourneyContributor[]
|
contributors: JourneyContributor[]
|
||||||
stats: { entries: number; photos: number; cities: number }
|
stats: { entries: number; photos: number; places: number }
|
||||||
hide_skeletons?: boolean
|
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'
|
||||||
|
}
|
||||||
@@ -115,13 +115,14 @@ router.post('/entries/:entryId/photos', authenticate, upload.array('photos', 10)
|
|||||||
|
|
||||||
router.post('/entries/:entryId/provider-photos', authenticate, (req: Request, res: Response) => {
|
router.post('/entries/:entryId/provider-photos', authenticate, (req: Request, res: Response) => {
|
||||||
const authReq = req as AuthRequest;
|
const authReq = req as AuthRequest;
|
||||||
const { provider, asset_id, asset_ids, caption } = req.body || {};
|
const { provider, asset_id, asset_ids, caption, passphrase } = req.body || {};
|
||||||
|
const pp = passphrase && typeof passphrase === 'string' ? passphrase : undefined;
|
||||||
|
|
||||||
// Batch mode: { provider, asset_ids: string[] }
|
// Batch mode: { provider, asset_ids: string[] }
|
||||||
if (Array.isArray(asset_ids) && provider) {
|
if (Array.isArray(asset_ids) && provider) {
|
||||||
const added: any[] = [];
|
const added: any[] = [];
|
||||||
for (const id of asset_ids) {
|
for (const id of asset_ids) {
|
||||||
const photo = svc.addProviderPhoto(Number(req.params.entryId), authReq.user.id, provider, String(id), caption);
|
const photo = svc.addProviderPhoto(Number(req.params.entryId), authReq.user.id, provider, String(id), caption, pp);
|
||||||
if (photo) added.push(photo);
|
if (photo) added.push(photo);
|
||||||
}
|
}
|
||||||
return res.status(201).json({ photos: added, added: added.length });
|
return res.status(201).json({ photos: added, added: added.length });
|
||||||
@@ -129,7 +130,7 @@ router.post('/entries/:entryId/provider-photos', authenticate, (req: Request, re
|
|||||||
|
|
||||||
// Single mode (backward compat)
|
// Single mode (backward compat)
|
||||||
if (!provider || !asset_id) return res.status(400).json({ error: 'provider and asset_id required' });
|
if (!provider || !asset_id) return res.status(400).json({ error: 'provider and asset_id required' });
|
||||||
const photo = svc.addProviderPhoto(Number(req.params.entryId), authReq.user.id, provider, asset_id, caption);
|
const photo = svc.addProviderPhoto(Number(req.params.entryId), authReq.user.id, provider, asset_id, caption, pp);
|
||||||
if (!photo) return res.status(403).json({ error: 'Not allowed or duplicate' });
|
if (!photo) return res.status(403).json({ error: 'Not allowed or duplicate' });
|
||||||
res.status(201).json(photo);
|
res.status(201).json(photo);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -166,14 +166,9 @@ function startTripReminders(): void {
|
|||||||
const reminderEnabled = getSetting('notify_trip_reminder') !== 'false';
|
const reminderEnabled = getSetting('notify_trip_reminder') !== 'false';
|
||||||
const channelsRaw = getSetting('notification_channels') || getSetting('notification_channel') || 'none';
|
const channelsRaw = getSetting('notification_channels') || getSetting('notification_channel') || 'none';
|
||||||
const activeChannels = channelsRaw === 'none' ? [] : channelsRaw.split(',').map((c: string) => c.trim());
|
const activeChannels = channelsRaw === 'none' ? [] : channelsRaw.split(',').map((c: string) => c.trim());
|
||||||
const hasEmail = activeChannels.includes('email') && !!(getSetting('smtp_host') || '').trim();
|
if (!reminderEnabled) {
|
||||||
const hasWebhook = activeChannels.includes('webhook');
|
|
||||||
const channelReady = hasEmail || hasWebhook;
|
|
||||||
|
|
||||||
if (!channelReady || !reminderEnabled) {
|
|
||||||
const { logInfo: li } = require('./services/auditLog');
|
const { logInfo: li } = require('./services/auditLog');
|
||||||
const reason = !channelReady ? 'no notification channels configured' : 'trip reminders disabled in settings';
|
li('Trip reminders: disabled in settings');
|
||||||
li(`Trip reminders: disabled (${reason})`);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ const ADMIN_SETTINGS_KEYS = [
|
|||||||
'allow_registration', 'allowed_file_types', 'require_mfa',
|
'allow_registration', 'allowed_file_types', 'require_mfa',
|
||||||
'smtp_host', 'smtp_port', 'smtp_user', 'smtp_pass', 'smtp_from', 'smtp_skip_tls_verify',
|
'smtp_host', 'smtp_port', 'smtp_user', 'smtp_pass', 'smtp_from', 'smtp_skip_tls_verify',
|
||||||
'notification_channels', 'admin_webhook_url', 'admin_ntfy_server', 'admin_ntfy_topic', 'admin_ntfy_token',
|
'notification_channels', 'admin_webhook_url', 'admin_ntfy_server', 'admin_ntfy_topic', 'admin_ntfy_token',
|
||||||
|
'notify_trip_reminder',
|
||||||
'password_login', 'password_registration', 'oidc_login', 'oidc_registration',
|
'password_login', 'password_registration', 'oidc_login', 'oidc_registration',
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -227,8 +228,7 @@ export function getAppConfig(authenticatedUser: { id: number } | null) {
|
|||||||
const notifChannelsRaw = (db.prepare("SELECT value FROM app_settings WHERE key = 'notification_channels'").get() as { value: string } | undefined)?.value || notifChannel;
|
const notifChannelsRaw = (db.prepare("SELECT value FROM app_settings WHERE key = 'notification_channels'").get() as { value: string } | undefined)?.value || notifChannel;
|
||||||
const activeChannels = notifChannelsRaw === 'none' ? [] : notifChannelsRaw.split(',').map((c: string) => c.trim()).filter(Boolean);
|
const activeChannels = notifChannelsRaw === 'none' ? [] : notifChannelsRaw.split(',').map((c: string) => c.trim()).filter(Boolean);
|
||||||
const hasWebhookEnabled = activeChannels.includes('webhook');
|
const hasWebhookEnabled = activeChannels.includes('webhook');
|
||||||
const channelConfigured = (activeChannels.includes('email') && hasSmtpHost) || hasWebhookEnabled;
|
const tripRemindersEnabled = tripReminderSetting !== 'false';
|
||||||
const tripRemindersEnabled = channelConfigured && tripReminderSetting !== 'false';
|
|
||||||
const setupComplete = userCount > 0 && !(db.prepare("SELECT id FROM users WHERE role = 'admin' AND must_change_password = 1 LIMIT 1").get());
|
const setupComplete = userCount > 0 && !(db.prepare("SELECT id FROM users WHERE role = 'admin' AND must_change_password = 1 LIMIT 1").get());
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -59,12 +59,14 @@ export function listJourneys(userId: number) {
|
|||||||
SELECT DISTINCT j.*,
|
SELECT DISTINCT j.*,
|
||||||
(SELECT COUNT(*) FROM journey_entries je WHERE je.journey_id = j.id AND je.type != 'skeleton') as entry_count,
|
(SELECT COUNT(*) FROM journey_entries je WHERE je.journey_id = j.id AND je.type != 'skeleton') as entry_count,
|
||||||
(SELECT COUNT(*) FROM journey_photos jp JOIN journey_entries je2 ON jp.entry_id = je2.id WHERE je2.journey_id = j.id) as photo_count,
|
(SELECT COUNT(*) FROM journey_photos jp JOIN journey_entries je2 ON jp.entry_id = je2.id WHERE je2.journey_id = j.id) as photo_count,
|
||||||
(SELECT COUNT(DISTINCT je3.location_name) FROM journey_entries je3 WHERE je3.journey_id = j.id AND je3.location_name IS NOT NULL AND je3.location_name != '') as city_count
|
(SELECT COUNT(DISTINCT je3.location_name) FROM journey_entries je3 WHERE je3.journey_id = j.id AND je3.location_name IS NOT NULL AND je3.location_name != '') as place_count,
|
||||||
|
(SELECT MIN(t.start_date) FROM journey_trips jt JOIN trips t ON jt.trip_id = t.id WHERE jt.journey_id = j.id) as trip_date_min,
|
||||||
|
(SELECT MAX(t.end_date) FROM journey_trips jt JOIN trips t ON jt.trip_id = t.id WHERE jt.journey_id = j.id) as trip_date_max
|
||||||
FROM journeys j
|
FROM journeys j
|
||||||
LEFT JOIN journey_contributors jc ON j.id = jc.journey_id AND jc.user_id = ?
|
LEFT JOIN journey_contributors jc ON j.id = jc.journey_id AND jc.user_id = ?
|
||||||
WHERE j.user_id = ? OR jc.user_id = ?
|
WHERE j.user_id = ? OR jc.user_id = ?
|
||||||
ORDER BY j.updated_at DESC
|
ORDER BY j.updated_at DESC
|
||||||
`).all(userId, userId, userId) as (Journey & { entry_count: number; photo_count: number; city_count: number })[];
|
`).all(userId, userId, userId) as (Journey & { entry_count: number; photo_count: number; place_count: number; trip_date_min: string | null; trip_date_max: string | null })[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createJourney(userId: number, data: {
|
export function createJourney(userId: number, data: {
|
||||||
@@ -159,7 +161,7 @@ export function getJourneyFull(journeyId: number, userId: number) {
|
|||||||
// stats
|
// stats
|
||||||
const entryCount = entries.filter(e => e.type === 'entry').length;
|
const entryCount = entries.filter(e => e.type === 'entry').length;
|
||||||
const photoCount = photos.length;
|
const photoCount = photos.length;
|
||||||
const cities = [...new Set(entries.map(e => e.location_name).filter(Boolean))];
|
const places = [...new Set(entries.map(e => e.location_name).filter(Boolean))];
|
||||||
|
|
||||||
const userPrefs = db.prepare(
|
const userPrefs = db.prepare(
|
||||||
'SELECT hide_skeletons FROM journey_contributors WHERE journey_id = ? AND user_id = ?'
|
'SELECT hide_skeletons FROM journey_contributors WHERE journey_id = ? AND user_id = ?'
|
||||||
@@ -170,7 +172,7 @@ export function getJourneyFull(journeyId: number, userId: number) {
|
|||||||
entries: enrichedEntries,
|
entries: enrichedEntries,
|
||||||
trips,
|
trips,
|
||||||
contributors,
|
contributors,
|
||||||
stats: { entries: entryCount, photos: photoCount, cities: cities.length },
|
stats: { entries: entryCount, photos: photoCount, places: places.length },
|
||||||
hide_skeletons: !!(userPrefs?.hide_skeletons),
|
hide_skeletons: !!(userPrefs?.hide_skeletons),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -184,11 +186,13 @@ export function updateJourney(journeyId: number, userId: number, data: Partial<{
|
|||||||
}>): Journey | null {
|
}>): Journey | null {
|
||||||
if (!canEdit(journeyId, userId)) return null;
|
if (!canEdit(journeyId, userId)) return null;
|
||||||
|
|
||||||
|
const ALLOWED_STATUSES = ['draft', 'active', 'completed', 'archived'];
|
||||||
const allowed = ['title', 'subtitle', 'cover_gradient', 'cover_image', 'status'];
|
const allowed = ['title', 'subtitle', 'cover_gradient', 'cover_image', 'status'];
|
||||||
const fields: string[] = [];
|
const fields: string[] = [];
|
||||||
const values: unknown[] = [];
|
const values: unknown[] = [];
|
||||||
for (const [key, val] of Object.entries(data)) {
|
for (const [key, val] of Object.entries(data)) {
|
||||||
if (val !== undefined && allowed.includes(key)) {
|
if (val !== undefined && allowed.includes(key)) {
|
||||||
|
if (key === 'status' && !ALLOWED_STATUSES.includes(val as string)) continue;
|
||||||
fields.push(`${key} = ?`);
|
fields.push(`${key} = ?`);
|
||||||
values.push(val);
|
values.push(val);
|
||||||
}
|
}
|
||||||
@@ -628,12 +632,12 @@ export function addPhoto(entryId: number, userId: number, filePath: string, thum
|
|||||||
return db.prepare(`SELECT ${JP_SELECT} FROM ${JP_JOIN} WHERE jp.id = ?`).get(Number(res.lastInsertRowid)) as JourneyPhoto;
|
return db.prepare(`SELECT ${JP_SELECT} FROM ${JP_JOIN} WHERE jp.id = ?`).get(Number(res.lastInsertRowid)) as JourneyPhoto;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function addProviderPhoto(entryId: number, userId: number, provider: string, assetId: string, caption?: string): JourneyPhoto | null {
|
export function addProviderPhoto(entryId: number, userId: number, provider: string, assetId: string, caption?: string, passphrase?: string): JourneyPhoto | null {
|
||||||
const entry = db.prepare('SELECT * FROM journey_entries WHERE id = ?').get(entryId) as JourneyEntry | undefined;
|
const entry = db.prepare('SELECT * FROM journey_entries WHERE id = ?').get(entryId) as JourneyEntry | undefined;
|
||||||
if (!entry) return null;
|
if (!entry) return null;
|
||||||
if (!canEdit(entry.journey_id, userId)) return null;
|
if (!canEdit(entry.journey_id, userId)) return null;
|
||||||
|
|
||||||
const trekPhotoId = getOrCreateTrekPhoto(provider, assetId, userId);
|
const trekPhotoId = getOrCreateTrekPhoto(provider, assetId, userId, passphrase);
|
||||||
|
|
||||||
// skip if already added
|
// skip if already added
|
||||||
const exists = db.prepare('SELECT 1 FROM journey_photos WHERE entry_id = ? AND photo_id = ?').get(entryId, trekPhotoId);
|
const exists = db.prepare('SELECT 1 FROM journey_photos WHERE entry_id = ? AND photo_id = ?').get(entryId, trekPhotoId);
|
||||||
|
|||||||
@@ -132,7 +132,7 @@ export function getPublicJourney(token: string) {
|
|||||||
const stats = {
|
const stats = {
|
||||||
entries: entries.length,
|
entries: entries.length,
|
||||||
photos: photos.length,
|
photos: photos.length,
|
||||||
cities: new Set(entries.filter(e => e.location_name).map(e => e.location_name)).size,
|
places: new Set(entries.filter(e => e.location_name).map(e => e.location_name)).size,
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -637,27 +637,20 @@ export async function streamSynologyAsset(
|
|||||||
|
|
||||||
|
|
||||||
//size: 'sm' 240px| 'm' 320px| 'xl' 1280px| 'preview' ?
|
//size: 'sm' 240px| 'm' 320px| 'xl' 1280px| 'preview' ?
|
||||||
const resolvedSize = size || 'sm';
|
// Use Thumbnail API for both thumbnail and original — avoids serving raw HEIC files
|
||||||
const params = kind === 'thumbnail'
|
// (original uses xl size to get a full-resolution JPEG-compatible render)
|
||||||
? new URLSearchParams({
|
const resolvedSize = kind === 'original' ? 'xl' : (size || 'sm');
|
||||||
api: 'SYNO.Foto.Thumbnail',
|
const params = new URLSearchParams({
|
||||||
method: 'get',
|
api: 'SYNO.Foto.Thumbnail',
|
||||||
version: '2',
|
method: 'get',
|
||||||
mode: 'download',
|
version: '2',
|
||||||
id: parsedId.id,
|
mode: 'download',
|
||||||
type: 'unit',
|
id: parsedId.id,
|
||||||
size: resolvedSize,
|
type: 'unit',
|
||||||
cache_key: parsedId.cacheKey,
|
size: resolvedSize,
|
||||||
_sid: sid.data,
|
cache_key: parsedId.cacheKey,
|
||||||
})
|
_sid: sid.data,
|
||||||
: new URLSearchParams({
|
});
|
||||||
api: 'SYNO.Foto.Download',
|
|
||||||
method: 'download',
|
|
||||||
version: '2',
|
|
||||||
cache_key: parsedId.cacheKey,
|
|
||||||
unit_id: `[${parsedId.id}]`,
|
|
||||||
_sid: sid.data,
|
|
||||||
});
|
|
||||||
if (passphrase) params.append('passphrase', passphrase);
|
if (passphrase) params.append('passphrase', passphrase);
|
||||||
|
|
||||||
const url = _buildSynologyEndpoint(synology_credentials.data.synology_url, params.toString());
|
const url = _buildSynologyEndpoint(synology_credentials.data.synology_url, params.toString());
|
||||||
|
|||||||
@@ -1,10 +1,19 @@
|
|||||||
|
import { createRequire } from 'module';
|
||||||
|
import semver from 'semver';
|
||||||
import { db } from '../db/database.js';
|
import { db } from '../db/database.js';
|
||||||
import { SYSTEM_NOTICES } from './registry.js';
|
import { SYSTEM_NOTICES } from './registry.js';
|
||||||
import { evaluate } from './conditions.js';
|
import { evaluate } from './conditions.js';
|
||||||
import type { SystemNoticeDTO } from './types.js';
|
import type { SystemNoticeDTO } from './types.js';
|
||||||
|
|
||||||
function getCurrentAppVersion(): string {
|
function getCurrentAppVersion(): string {
|
||||||
return process.env.APP_VERSION || '0.0.0';
|
const fromEnv = semver.valid(process.env.APP_VERSION ?? '');
|
||||||
|
if (fromEnv) return fromEnv;
|
||||||
|
try {
|
||||||
|
const pkg = require('../../package.json') as { version?: string };
|
||||||
|
return semver.valid(pkg.version ?? '') ?? '0.0.0';
|
||||||
|
} catch {
|
||||||
|
return '0.0.0';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function severityWeight(s: string): number {
|
function severityWeight(s: string): number {
|
||||||
|
|||||||
+1
-1
@@ -329,7 +329,7 @@ export interface Journey {
|
|||||||
subtitle?: string | null;
|
subtitle?: string | null;
|
||||||
cover_gradient?: string | null;
|
cover_gradient?: string | null;
|
||||||
cover_image?: string | null;
|
cover_image?: string | null;
|
||||||
status: 'draft' | 'active' | 'completed';
|
status: 'draft' | 'active' | 'completed' | 'archived';
|
||||||
created_at: number;
|
created_at: number;
|
||||||
updated_at: number;
|
updated_at: number;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -936,6 +936,50 @@ describe('Share link update', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Provider photos passphrase (JOURNEY-INT-046, 047)
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('Provider photos — passphrase persistence', () => {
|
||||||
|
it('JOURNEY-INT-046 — single mode with passphrase persists encrypted passphrase on trek_photos', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
const journey = createJourney(testDb, user.id);
|
||||||
|
const entry = createJourneyEntry(testDb, journey.id, user.id, { entry_date: '2026-04-01' });
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post(`/api/journeys/entries/${entry.id}/provider-photos`)
|
||||||
|
.set('Cookie', authCookie(user.id))
|
||||||
|
.send({ provider: 'synologyphotos', asset_id: 'shared-asset-1', passphrase: 'pp-test' });
|
||||||
|
|
||||||
|
expect(res.status).toBe(201);
|
||||||
|
|
||||||
|
const row = testDb.prepare('SELECT passphrase FROM trek_photos WHERE provider = ? AND asset_id = ? AND owner_id = ?')
|
||||||
|
.get('synologyphotos', 'shared-asset-1', user.id) as { passphrase: string | null } | undefined;
|
||||||
|
expect(row?.passphrase).not.toBeNull();
|
||||||
|
expect(typeof row?.passphrase).toBe('string');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('JOURNEY-INT-047 — batch mode with passphrase persists encrypted passphrase on all trek_photos rows', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
const journey = createJourney(testDb, user.id);
|
||||||
|
const entry = createJourneyEntry(testDb, journey.id, user.id, { entry_date: '2026-04-02' });
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post(`/api/journeys/entries/${entry.id}/provider-photos`)
|
||||||
|
.set('Cookie', authCookie(user.id))
|
||||||
|
.send({ provider: 'synologyphotos', asset_ids: ['batch-asset-1', 'batch-asset-2'], passphrase: 'pp-batch' });
|
||||||
|
|
||||||
|
expect(res.status).toBe(201);
|
||||||
|
expect(res.body.added).toBe(2);
|
||||||
|
|
||||||
|
for (const assetId of ['batch-asset-1', 'batch-asset-2']) {
|
||||||
|
const row = testDb.prepare('SELECT passphrase FROM trek_photos WHERE provider = ? AND asset_id = ? AND owner_id = ?')
|
||||||
|
.get('synologyphotos', assetId, user.id) as { passphrase: string | null } | undefined;
|
||||||
|
expect(row?.passphrase).not.toBeNull();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// Photo upload without files (JOURNEY-INT-045)
|
// Photo upload without files (JOURNEY-INT-045)
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|||||||
@@ -166,16 +166,6 @@ vi.mock('../../src/utils/ssrfGuard', async () => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Original download
|
|
||||||
if (apiName === 'SYNO.Foto.Download') {
|
|
||||||
const imageBytes = Buffer.from('fake-synology-original');
|
|
||||||
return Promise.resolve({
|
|
||||||
ok: true, status: 200,
|
|
||||||
headers: { get: (h: string) => h === 'content-type' ? 'image/jpeg' : null },
|
|
||||||
body: new ReadableStream({ start(c) { c.enqueue(imageBytes); c.close(); } }),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return Promise.reject(new Error(`Unexpected safeFetch call to Synology: ${u}, api=${apiName}`));
|
return Promise.reject(new Error(`Unexpected safeFetch call to Synology: ${u}, api=${apiName}`));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -202,7 +202,7 @@ describe('listJourneys', () => {
|
|||||||
expect(result).toHaveLength(1);
|
expect(result).toHaveLength(1);
|
||||||
expect(result[0].title).toBe('Road Trip');
|
expect(result[0].title).toBe('Road Trip');
|
||||||
expect(result[0].entry_count).toBe(2);
|
expect(result[0].entry_count).toBe(2);
|
||||||
expect(result[0].city_count).toBe(2);
|
expect(result[0].place_count).toBe(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('JOURNEY-SVC-012: includes journeys where user is contributor', () => {
|
it('JOURNEY-SVC-012: includes journeys where user is contributor', () => {
|
||||||
@@ -226,6 +226,21 @@ describe('listJourneys', () => {
|
|||||||
|
|
||||||
expect(result).toHaveLength(0);
|
expect(result).toHaveLength(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('JOURNEY-SVC-013b: returns trip_date_min/max aggregated from linked trips', () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
const journey = createJourney(testDb, user.id, { title: 'Multi Trip' });
|
||||||
|
const trip1 = createTrip(testDb, user.id, { title: 'Trip A', start_date: '2025-06-01', end_date: '2025-06-10' });
|
||||||
|
const trip2 = createTrip(testDb, user.id, { title: 'Trip B', start_date: '2026-03-15', end_date: '2026-03-20' });
|
||||||
|
addTripToJourney(journey.id, trip1.id, user.id);
|
||||||
|
addTripToJourney(journey.id, trip2.id, user.id);
|
||||||
|
|
||||||
|
const result = listJourneys(user.id);
|
||||||
|
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
expect(result[0].trip_date_min).toBe('2025-06-01');
|
||||||
|
expect(result[0].trip_date_max).toBe('2026-03-20');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('createJourney (service)', () => {
|
describe('createJourney (service)', () => {
|
||||||
@@ -335,6 +350,26 @@ describe('updateJourney', () => {
|
|||||||
expect(result).not.toBeNull();
|
expect(result).not.toBeNull();
|
||||||
expect(result!.title).toBe('Same');
|
expect(result!.title).toBe('Same');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('JOURNEY-SVC-021b: accepts archived status', () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
const journey = createJourney(testDb, user.id, { title: 'To Archive' });
|
||||||
|
|
||||||
|
const result = updateJourney(journey.id, user.id, { status: 'archived' });
|
||||||
|
|
||||||
|
expect(result).not.toBeNull();
|
||||||
|
expect(result!.status).toBe('archived');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('JOURNEY-SVC-021c: ignores invalid status value', () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
const journey = createJourney(testDb, user.id, { title: 'Stay Active' });
|
||||||
|
|
||||||
|
const result = updateJourney(journey.id, user.id, { status: 'bogus' });
|
||||||
|
|
||||||
|
expect(result).not.toBeNull();
|
||||||
|
expect(result!.status).toBe('active');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('deleteJourney', () => {
|
describe('deleteJourney', () => {
|
||||||
@@ -1412,3 +1447,24 @@ describe('Edge cases', () => {
|
|||||||
expect(filledRow.source_place_id).toBeNull();
|
expect(filledRow.source_place_id).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// -- Passphrase on addProviderPhoto -------------------------------------------
|
||||||
|
|
||||||
|
describe('addProviderPhoto — passphrase', () => {
|
||||||
|
it('JOURNEY-SVC-088: addProviderPhoto with passphrase stores encrypted value on trek_photos', () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
const journey = createJourney(testDb, user.id);
|
||||||
|
const entry = createJourneyEntry(testDb, journey.id, user.id, { entry_date: '2026-03-15' });
|
||||||
|
|
||||||
|
const photo = addProviderPhoto(entry.id, user.id, 'synologyphotos', 'pp-asset-1', undefined, 'secret-pp');
|
||||||
|
|
||||||
|
expect(photo).not.toBeNull();
|
||||||
|
|
||||||
|
const row = testDb.prepare('SELECT passphrase FROM trek_photos WHERE provider = ? AND asset_id = ? AND owner_id = ?')
|
||||||
|
.get('synologyphotos', 'pp-asset-1', user.id) as { passphrase: string | null } | undefined;
|
||||||
|
expect(row?.passphrase).not.toBeNull();
|
||||||
|
expect(typeof row?.passphrase).toBe('string');
|
||||||
|
// stored value must be encrypted (not plaintext)
|
||||||
|
expect(row?.passphrase).not.toBe('secret-pp');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -336,7 +336,7 @@ describe('getPublicJourney', () => {
|
|||||||
expect(result!.entries).toHaveLength(2);
|
expect(result!.entries).toHaveLength(2);
|
||||||
expect(result!.stats.entries).toBe(2);
|
expect(result!.stats.entries).toBe(2);
|
||||||
expect(result!.stats.photos).toBe(1);
|
expect(result!.stats.photos).toBe(1);
|
||||||
expect(result!.stats.cities).toBe(2);
|
expect(result!.stats.places).toBe(2);
|
||||||
expect(result!.permissions.share_timeline).toBe(true);
|
expect(result!.permissions.share_timeline).toBe(true);
|
||||||
expect(result!.permissions.share_gallery).toBe(true);
|
expect(result!.permissions.share_gallery).toBe(true);
|
||||||
expect(result!.permissions.share_map).toBe(false);
|
expect(result!.permissions.share_map).toBe(false);
|
||||||
@@ -397,6 +397,6 @@ describe('getPublicJourney', () => {
|
|||||||
expect(result!.entries).toEqual([]);
|
expect(result!.entries).toEqual([]);
|
||||||
expect(result!.stats.entries).toBe(0);
|
expect(result!.stats.entries).toBe(0);
|
||||||
expect(result!.stats.photos).toBe(0);
|
expect(result!.stats.photos).toBe(0);
|
||||||
expect(result!.stats.cities).toBe(0);
|
expect(result!.stats.places).toBe(0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user