Merge branch 'dev' into feat/login-language-detection-dropdown

This commit is contained in:
Isaias Tavares
2026-04-14 17:07:18 -03:00
committed by GitHub
74 changed files with 1982 additions and 703 deletions
+30
View File
@@ -21,6 +21,7 @@
"react-markdown": "^10.1.0",
"react-router-dom": "^6.22.2",
"react-window": "^2.2.7",
"remark-breaks": "^4.0.0",
"remark-gfm": "^4.0.1",
"topojson-client": "^3.1.0",
"zustand": "^4.5.2"
@@ -7585,6 +7586,20 @@
"url": "https://opencollective.com/unified"
}
},
"node_modules/mdast-util-newline-to-break": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/mdast-util-newline-to-break/-/mdast-util-newline-to-break-2.0.0.tgz",
"integrity": "sha512-MbgeFca0hLYIEx/2zGsszCSEJJ1JSCdiY5xQxRcLDDGa8EPvlLPupJ4DSajbMPAnC0je8jfb9TiUATnxxrHUog==",
"license": "MIT",
"dependencies": {
"@types/mdast": "^4.0.0",
"mdast-util-find-and-replace": "^3.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/mdast-util-phrasing": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/mdast-util-phrasing/-/mdast-util-phrasing-4.1.0.tgz",
@@ -9272,6 +9287,21 @@
"regjsparser": "bin/parser"
}
},
"node_modules/remark-breaks": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/remark-breaks/-/remark-breaks-4.0.0.tgz",
"integrity": "sha512-IjEjJOkH4FuJvHZVIW0QCDWxcG96kCq7An/KVH2NfJe6rKZU2AsHeB3OEjPNRxi4QC34Xdx7I2KGYn6IpT7gxQ==",
"license": "MIT",
"dependencies": {
"@types/mdast": "^4.0.0",
"mdast-util-newline-to-break": "^2.0.0",
"unified": "^11.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/remark-gfm": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz",
+1
View File
@@ -28,6 +28,7 @@
"react-markdown": "^10.1.0",
"react-router-dom": "^6.22.2",
"react-window": "^2.2.7",
"remark-breaks": "^4.0.0",
"remark-gfm": "^4.0.1",
"topojson-client": "^3.1.0",
"zustand": "^4.5.2"
+4
View File
@@ -309,6 +309,7 @@ export const journeyApi = {
// Photos
uploadPhotos: (entryId: number, formData: FormData) => apiClient.post(`/journeys/entries/${entryId}/photos`, formData, { headers: { 'Content-Type': undefined as any } }).then(r => r.data),
addProviderPhoto: (entryId: number, provider: string, assetId: string, caption?: string) => apiClient.post(`/journeys/entries/${entryId}/provider-photos`, { provider, asset_id: assetId, caption }).then(r => r.data),
addProviderPhotos: (entryId: number, provider: string, assetIds: string[], caption?: string) => apiClient.post(`/journeys/entries/${entryId}/provider-photos`, { provider, asset_ids: assetIds, caption }).then(r => r.data),
linkPhoto: (entryId: number, photoId: number) => apiClient.post(`/journeys/entries/${entryId}/link-photo`, { photo_id: photoId }).then(r => r.data),
updatePhoto: (photoId: number, data: Record<string, unknown>) => apiClient.patch(`/journeys/photos/${photoId}`, data).then(r => r.data),
deletePhoto: (photoId: number) => apiClient.delete(`/journeys/photos/${photoId}`).then(r => r.data),
@@ -321,6 +322,9 @@ export const journeyApi = {
updateContributor: (id: number, userId: number, role: string) => apiClient.patch(`/journeys/${id}/contributors/${userId}`, { role }).then(r => r.data),
removeContributor: (id: number, userId: number) => apiClient.delete(`/journeys/${id}/contributors/${userId}`).then(r => r.data),
// Preferences
updatePreferences: (id: number, data: { hide_skeletons?: boolean }) => apiClient.patch(`/journeys/${id}/preferences`, data).then(r => r.data),
// Share
getShareLink: (id: number) => apiClient.get(`/journeys/${id}/share-link`).then(r => r.data),
createShareLink: (id: number, perms: { share_timeline?: boolean; share_gallery?: boolean; share_map?: boolean }) => apiClient.post(`/journeys/${id}/share-link`, perms).then(r => r.data),
@@ -190,11 +190,12 @@ describe('AddonManager', () => {
expect(screen.queryByText('Bag Tracking')).not.toBeInTheDocument();
});
it('FE-ADMIN-ADDON-010: photo provider sub-toggles shown for Memories addon', async () => {
it('FE-ADMIN-ADDON-010: photo provider sub-toggles shown under Journey addon', async () => {
server.use(
http.get('/api/admin/addons', () =>
HttpResponse.json({
addons: [
buildAddon({ id: 'journey', name: 'Journey', type: 'global', icon: 'Compass', enabled: true }),
buildAddon({ id: 'photos', name: 'Memories', type: 'trip', icon: 'Image', enabled: false }),
buildAddon({ id: 'unsplash', name: 'Unsplash', type: 'photo_provider', enabled: true }),
buildAddon({ id: 'pexels', name: 'Pexels', type: 'photo_provider', enabled: false }),
@@ -204,18 +205,16 @@ describe('AddonManager', () => {
);
render(<AddonManager />);
// Provider sub-rows are visible
// Provider sub-rows are visible under Journey addon
await screen.findByText('Unsplash');
expect(screen.getByText('Pexels')).toBeInTheDocument();
// Memories row shows name override
expect(screen.getByText('Memories providers')).toBeInTheDocument();
// Journey addon is rendered
expect(screen.getByText('Journey')).toBeInTheDocument();
// The photos addon row itself has no top-level toggle (hideToggle = true)
// The toggle buttons are only for the providers
// Toggle buttons: journey toggle + 2 provider toggles
const toggleBtns = screen.getAllByRole('button').filter(b => b.classList.contains('rounded-full'));
// Should be 2 provider toggles (no main toggle for the photos addon)
expect(toggleBtns.length).toBe(2);
expect(toggleBtns.length).toBe(3);
});
it('FE-ADMIN-ADDON-011: icon falls back to Puzzle when icon name unknown', async () => {
+37 -42
View File
@@ -4,10 +4,10 @@ import { useTranslation } from '../../i18n'
import { useSettingsStore } from '../../store/settingsStore'
import { useAddonStore } from '../../store/addonStore'
import { useToast } from '../shared/Toast'
import { Puzzle, ListChecks, Wallet, FileText, CalendarDays, Globe, Briefcase, Image, Terminal, Link2, Compass } from 'lucide-react'
import { Puzzle, ListChecks, Wallet, FileText, CalendarDays, Globe, Briefcase, Image, Terminal, Link2, Compass, BookOpen } from 'lucide-react'
const ICON_MAP = {
ListChecks, Wallet, FileText, CalendarDays, Puzzle, Globe, Briefcase, Image, Terminal, Link2, Compass,
ListChecks, Wallet, FileText, CalendarDays, Puzzle, Globe, Briefcase, Image, Terminal, Link2, Compass, BookOpen,
}
interface Addon {
@@ -103,11 +103,11 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking }
}
}
const tripAddons = addons.filter(a => a.type === 'trip')
const globalAddons = addons.filter(a => a.type === 'global')
const photoProviderAddons = addons.filter(isPhotoProviderAddon)
const photosAddon = addons.filter(a => a.type === 'trip').find(isPhotosAddon)
const tripAddons = addons.filter(a => a.type === 'trip' && !isPhotosAddon(a))
const globalAddons = addons.filter(a => a.type === 'global')
const integrationAddons = addons.filter(a => a.type === 'integration')
const photosAddon = tripAddons.find(isPhotosAddon)
const providerOptions: ProviderOption[] = photoProviderAddons.map((provider) => ({
key: provider.id,
label: provider.name,
@@ -153,42 +153,7 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking }
</div>
{tripAddons.map(addon => (
<div key={addon.id}>
<AddonRow
addon={addon}
onToggle={handleToggle}
t={t}
nameOverride={photosAddon && addon.id === photosAddon.id ? 'Memories providers' : undefined}
descriptionOverride={photosAddon && addon.id === photosAddon.id ? 'Enable or disable each photo provider.' : undefined}
statusOverride={photosAddon && addon.id === photosAddon.id ? photosDerivedEnabled : undefined}
hideToggle={photosAddon && addon.id === photosAddon.id}
/>
{photosAddon && addon.id === photosAddon.id && providerOptions.length > 0 && (
<div className="px-6 py-3 border-b" style={{ borderColor: 'var(--border-secondary)', background: 'var(--bg-secondary)', paddingLeft: 70 }}>
<div className="space-y-2">
{providerOptions.map(provider => (
<div key={provider.key} className="flex items-center gap-4" style={{ minHeight: 32 }}>
<div style={{ flex: 1, minWidth: 0 }}>
<div className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>{provider.label}</div>
<div className="text-xs mt-0.5" style={{ color: 'var(--text-faint)' }}>{provider.description}</div>
</div>
<div className="flex items-center gap-2 shrink-0">
<span className="hidden sm:inline text-xs font-medium" style={{ color: provider.enabled ? 'var(--text-primary)' : 'var(--text-faint)' }}>
{provider.enabled ? t('admin.addons.enabled') : t('admin.addons.disabled')}
</span>
<button
onClick={provider.toggle}
className="relative inline-flex h-6 w-11 items-center rounded-full transition-colors"
style={{ background: provider.enabled ? '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: provider.enabled ? 'translateX(20px)' : 'translateX(0)' }} />
</button>
</div>
</div>
))}
</div>
</div>
)}
<AddonRow addon={addon} onToggle={handleToggle} t={t} />
{addon.id === 'packing' && addon.enabled && onToggleBagTracking && (
<div className="flex items-center gap-4 px-6 py-3 border-b" style={{ borderColor: 'var(--border-secondary)', background: 'var(--bg-secondary)', paddingLeft: 70 }}>
<div style={{ flex: 1, minWidth: 0 }}>
@@ -223,7 +188,37 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking }
</span>
</div>
{globalAddons.map(addon => (
<AddonRow key={addon.id} addon={addon} onToggle={handleToggle} t={t} />
<div key={addon.id}>
<AddonRow addon={addon} onToggle={handleToggle} t={t} />
{/* Memories providers as sub-items under Journey addon */}
{addon.id === 'journey' && providerOptions.length > 0 && (
<div className="px-6 py-3 border-b" style={{ borderColor: 'var(--border-secondary)', background: 'var(--bg-secondary)', paddingLeft: 70 }}>
<div className="space-y-2">
{providerOptions.map(provider => (
<div key={provider.key} className="flex items-center gap-4" style={{ minHeight: 32 }}>
<div style={{ flex: 1, minWidth: 0 }}>
<div className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>{provider.label}</div>
<div className="text-xs mt-0.5" style={{ color: 'var(--text-faint)' }}>{provider.description}</div>
</div>
<div className="flex items-center gap-2 shrink-0">
<span className="hidden sm:inline text-xs font-medium" style={{ color: provider.enabled ? 'var(--text-primary)' : 'var(--text-faint)' }}>
{provider.enabled ? t('admin.addons.enabled') : t('admin.addons.disabled')}
</span>
<button
onClick={provider.toggle}
className="relative inline-flex h-6 w-11 items-center rounded-full transition-colors"
style={{ background: provider.enabled ? '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: provider.enabled ? 'translateX(20px)' : 'translateX(0)' }} />
</button>
</div>
</div>
))}
</div>
</div>
)}
</div>
))}
</div>
)}
+3 -2
View File
@@ -3,6 +3,7 @@ import { useState, useEffect, useCallback, useRef, useMemo } from 'react'
import DOM from 'react-dom'
import Markdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import remarkBreaks from 'remark-breaks'
import { Plus, Trash2, Pin, PinOff, Pencil, X, Check, StickyNote, Settings, ExternalLink, Maximize2, Loader2 } from 'lucide-react'
import { collabApi } from '../../api/client'
import { getAuthUrl } from '../../api/authUrl'
@@ -845,7 +846,7 @@ function NoteCard({ note, currentUser, canEdit, onUpdate, onDelete, onEdit, onVi
maxHeight: '4.5em', overflow: 'hidden',
wordBreak: 'break-word', fontFamily: FONT,
}}>
<Markdown remarkPlugins={[remarkGfm]}>{note.content}</Markdown>
<Markdown remarkPlugins={[remarkGfm, remarkBreaks]}>{note.content}</Markdown>
</div>
)}
</div>
@@ -1352,7 +1353,7 @@ export default function CollabNotes({ tripId, currentUser }: CollabNotesProps) {
</div>
</div>
<div className="collab-note-md-full" style={{ padding: '16px 20px', overflowY: 'auto', fontSize: 14, color: 'var(--text-primary)', lineHeight: 1.7 }}>
<Markdown remarkPlugins={[remarkGfm]}>{viewingNote.content || ''}</Markdown>
<Markdown remarkPlugins={[remarkGfm, remarkBreaks]}>{viewingNote.content || ''}</Markdown>
{(viewingNote.attachments || []).length > 0 && (
<div style={{ marginTop: 16, paddingTop: 16, borderTop: '1px solid var(--border-primary)' }}>
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: 0.5, marginBottom: 10 }}>{t('files.title')}</div>
@@ -27,9 +27,9 @@ describe('JournalBody', () => {
it('FE-COMP-JOURNALBODY-004: renders headings with proper elements', () => {
const { container } = render(<JournalBody text="## Section Title" />);
const h2 = container.querySelector('h2');
expect(h2).toBeInTheDocument();
expect(h2!.textContent).toBe('Section Title');
const p = container.querySelector('p');
expect(p).toBeInTheDocument();
expect(p!.textContent).toBe('Section Title');
});
it('FE-COMP-JOURNALBODY-005: handles empty text without crashing', () => {
@@ -1,5 +1,6 @@
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import remarkBreaks from 'remark-breaks'
interface Props {
text: string
@@ -15,11 +16,11 @@ export default function JournalBody({ text, dark }: Props) {
color: 'inherit',
}}>
<ReactMarkdown
remarkPlugins={[remarkGfm]}
remarkPlugins={[remarkGfm, remarkBreaks]}
components={{
h1: ({ children }) => <h1 style={{ fontFamily: 'inherit', fontSize: '1.3em', fontWeight: 700, margin: '16px 0 6px', lineHeight: 1.3 }}>{children}</h1>,
h2: ({ children }) => <h2 style={{ fontFamily: 'inherit', fontSize: '1.15em', fontWeight: 600, margin: '14px 0 4px', lineHeight: 1.3 }}>{children}</h2>,
h3: ({ children }) => <h3 style={{ fontFamily: 'inherit', fontSize: '1.05em', fontWeight: 600, margin: '12px 0 4px', lineHeight: 1.4 }}>{children}</h3>,
h1: ({ children }) => <p style={{ margin: '0 0 6px' }}>{children}</p>,
h2: ({ children }) => <p style={{ margin: '0 0 6px' }}>{children}</p>,
h3: ({ children }) => <p style={{ margin: '0 0 6px' }}>{children}</p>,
p: ({ children }) => <p style={{ margin: '0 0 6px' }}>{children}</p>,
blockquote: ({ children }) => (
<blockquote style={{
@@ -62,7 +63,7 @@ export default function JournalBody({ text, dark }: Props) {
},
}}
>
{text}
{text.replace(/^(.+)\n([-=]{3,})$/gm, '$1\n\n$2')}
</ReactMarkdown>
</div>
)
+6 -2
View File
@@ -155,7 +155,7 @@ const JourneyMap = forwardRef<JourneyMapHandle, Props>(function JourneyMap(
const map = L.map(containerRef.current, {
zoomControl: false,
attributionControl: false,
attributionControl: true,
scrollWheelZoom: false,
dragging: true,
touchZoom: true,
@@ -165,7 +165,11 @@ const JourneyMap = forwardRef<JourneyMapHandle, Props>(function JourneyMap(
const defaultTile = dark
? 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png'
: 'https://{s}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}{r}.png'
L.tileLayer(mapTileUrl || defaultTile, { maxZoom: 18 }).addTo(map)
L.tileLayer(mapTileUrl || defaultTile, {
maxZoom: 18,
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>',
referrerPolicy: 'strict-origin-when-cross-origin',
} as any).addTo(map)
const items = buildMarkerItems(entries)
itemsRef.current = items
@@ -6,7 +6,7 @@ interface Props {
dark?: boolean
}
type FormatAction = { type: 'wrap'; before: string; after: string } | { type: 'line'; prefix: string }
type FormatAction = { type: 'wrap'; before: string; after: string } | { type: 'line'; prefix: string } | { type: 'insert'; text: string }
const ACTIONS: Array<{ icon: typeof Bold; label: string; action: FormatAction }> = [
{ icon: Bold, label: 'Bold', action: { type: 'wrap', before: '**', after: '**' } },
@@ -16,7 +16,7 @@ const ACTIONS: Array<{ icon: typeof Bold; label: string; action: FormatAction }>
{ icon: Link, label: 'Link', action: { type: 'wrap', before: '[', after: '](url)' } },
{ icon: List, label: 'List', action: { type: 'line', prefix: '- ' } },
{ icon: ListOrdered, label: 'Ordered', action: { type: 'line', prefix: '1. ' } },
{ icon: Minus, label: 'Divider', action: { type: 'line', prefix: '\n---\n' } },
{ icon: Minus, label: 'Divider', action: { type: 'insert', text: '\n\n---\n\n' } },
]
export default function MarkdownToolbar({ textareaRef, onUpdate, dark }: Props) {
@@ -35,6 +35,9 @@ export default function MarkdownToolbar({ textareaRef, onUpdate, dark }: Props)
if (action.type === 'wrap') {
result = text.slice(0, start) + action.before + selected + action.after + text.slice(end)
cursorPos = selected ? end + action.before.length + action.after.length : start + action.before.length
} else if (action.type === 'insert') {
result = text.slice(0, start) + action.text + text.slice(end)
cursorPos = start + action.text.length
} else {
// line prefix — find start of current line
const lineStart = text.lastIndexOf('\n', start - 1) + 1
@@ -233,8 +233,8 @@ describe('MemoriesPanel', () => {
http.get('/api/integrations/memories/unified/trips/:tripId/photos', () =>
HttpResponse.json({
photos: [
{ asset_id: 'photo1', provider: 'immich', user_id: 1, username: 'me', shared: 1, added_at: '2025-03-01T10:00:00Z' },
{ asset_id: 'photo2', provider: 'immich', user_id: 1, username: 'me', shared: 1, added_at: '2025-03-10T10:00:00Z' },
{ photo_id: 1, asset_id: 'photo1', provider: 'immich', user_id: 1, username: 'me', shared: 1, added_at: '2025-03-01T10:00:00Z' },
{ photo_id: 2, asset_id: 'photo2', provider: 'immich', user_id: 1, username: 'me', shared: 1, added_at: '2025-03-10T10:00:00Z' },
],
})
),
@@ -501,8 +501,8 @@ describe('MemoriesPanel', () => {
http.get('/api/integrations/memories/unified/trips/:tripId/photos', () =>
HttpResponse.json({
photos: [
{ asset_id: 'p1', provider: 'immich', user_id: 1, username: 'me', shared: 1, added_at: '2025-03-01T00:00:00Z', city: 'Paris' },
{ asset_id: 'p2', provider: 'immich', user_id: 1, username: 'me', shared: 1, added_at: '2025-03-05T00:00:00Z', city: 'Lyon' },
{ photo_id: 10, asset_id: 'p1', provider: 'immich', user_id: 1, username: 'me', shared: 1, added_at: '2025-03-01T00:00:00Z', city: 'Paris' },
{ photo_id: 11, asset_id: 'p2', provider: 'immich', user_id: 1, username: 'me', shared: 1, added_at: '2025-03-05T00:00:00Z', city: 'Lyon' },
],
})
),
@@ -676,8 +676,8 @@ describe('MemoriesPanel', () => {
http.get('/api/integrations/memories/unified/trips/:tripId/photos', () =>
HttpResponse.json({
photos: [
{ asset_id: 'p1', provider: 'immich', user_id: 1, username: 'me', shared: 1, added_at: '2025-03-01T00:00:00Z', city: 'Paris' },
{ asset_id: 'p2', provider: 'immich', user_id: 1, username: 'me', shared: 1, added_at: '2025-03-05T00:00:00Z', city: 'Lyon' },
{ photo_id: 10, asset_id: 'p1', provider: 'immich', user_id: 1, username: 'me', shared: 1, added_at: '2025-03-01T00:00:00Z', city: 'Paris' },
{ photo_id: 11, asset_id: 'p2', provider: 'immich', user_id: 1, username: 'me', shared: 1, added_at: '2025-03-05T00:00:00Z', city: 'Lyon' },
],
})
),
@@ -30,6 +30,7 @@ function ProviderImg({ baseUrl, provider, style, loading }: { baseUrl: string; p
// ── Types ───────────────────────────────────────────────────────────────────
interface TripPhoto {
photo_id: number
asset_id: string
provider: string
user_id: number
@@ -105,19 +106,12 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
}
function buildProviderAssetUrl(photo: TripPhoto, what: string): string {
return `${ADDON_PREFIX}/${photo.provider}/assets/${tripId}/${photo.asset_id}/${photo.user_id}/${what}`
return `/photos/${photo.photo_id}/${what}`
}
function buildProviderAssetUrlFromAsset(asset: Asset, what: string, userId: number): string {
const photo: TripPhoto = {
asset_id: asset.id,
provider: asset.provider,
user_id: userId,
username: '',
shared: 0,
added_at: null
}
return buildProviderAssetUrl(photo, what)
// Picker photos are not yet saved — use provider-specific URL
return `${ADDON_PREFIX}/${asset.provider}/assets/${tripId}/${asset.id}/${userId}/${what}`
}
@@ -189,7 +183,7 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
}
// Lightbox
const [lightboxId, setLightboxId] = useState<string | null>(null)
const [lightboxId, setLightboxId] = useState<number | null>(null)
const [lightboxUserId, setLightboxUserId] = useState<number | null>(null)
const [lightboxInfo, setLightboxInfo] = useState<any>(null)
const [lightboxInfoLoading, setLightboxInfoLoading] = useState(false)
@@ -357,11 +351,10 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
try {
await apiClient.delete(buildUnifiedUrl('photos'), {
data: {
asset_id: photo.asset_id,
provider: photo.provider,
photo_id: photo.photo_id,
},
})
setTripPhotos(prev => prev.filter(p => !(p.provider === photo.provider && p.asset_id === photo.asset_id)))
setTripPhotos(prev => prev.filter(p => p.photo_id !== photo.photo_id))
} catch { toast.error(t('memories.error.removePhoto')) }
}
@@ -371,11 +364,10 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
try {
await apiClient.put(buildUnifiedUrl('photos', 'sharing'), {
shared,
asset_id: photo.asset_id,
provider: photo.provider,
photo_id: photo.photo_id,
})
setTripPhotos(prev => prev.map(p =>
p.provider === photo.provider && p.asset_id === photo.asset_id ? { ...p, shared: shared ? 1 : 0 } : p
p.photo_id === photo.photo_id ? { ...p, shared: shared ? 1 : 0 } : p
))
} catch { toast.error(t('memories.error.toggleSharing')) }
}
@@ -839,10 +831,10 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
{allVisible.map(photo => {
const isOwn = photo.user_id === currentUser?.id
return (
<div key={`${photo.provider}:${photo.asset_id}`} className="group"
<div key={photo.photo_id} className="group"
style={{ position: 'relative', aspectRatio: '1', borderRadius: 10, overflow: 'visible', cursor: 'pointer' }}
onClick={() => {
setLightboxId(photo.asset_id); setLightboxUserId(photo.user_id); setLightboxInfo(null)
setLightboxId(photo.photo_id); setLightboxUserId(photo.user_id); setLightboxInfo(null)
if (lightboxOriginalSrc) URL.revokeObjectURL(lightboxOriginalSrc)
setLightboxOriginalSrc('')
fetchImageAsBlob('/api' + buildProviderAssetUrl(photo, 'original')).then(setLightboxOriginalSrc)
@@ -961,7 +953,7 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
setShowMobileInfo(false)
}
const currentIdx = allVisible.findIndex(p => p.asset_id === lightboxId)
const currentIdx = allVisible.findIndex(p => p.photo_id === lightboxId)
const hasPrev = currentIdx > 0
const hasNext = currentIdx < allVisible.length - 1
const navigateTo = (idx: number) => {
@@ -969,7 +961,7 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
if (!photo) return
if (lightboxOriginalSrc) URL.revokeObjectURL(lightboxOriginalSrc)
setLightboxOriginalSrc('')
setLightboxId(photo.asset_id)
setLightboxId(photo.photo_id)
setLightboxUserId(photo.user_id)
setLightboxInfo(null)
fetchImageAsBlob('/api' + buildProviderAssetUrl(photo, 'original')).then(setLightboxOriginalSrc)
+1 -2
View File
@@ -19,8 +19,7 @@ function abs(url: string | null | undefined): string {
}
function pSrc(p: JourneyPhoto): string {
if (p.provider === 'local') return abs(`/uploads/${p.file_path}`)
return abs(`/api/integrations/memories/${p.provider}/assets/0/${p.asset_id}/${p.owner_id}/original`)
return abs(`/api/photos/${p.photo_id}/original`)
}
function fmtDate(d: string): string {
+22 -15
View File
@@ -1,7 +1,7 @@
// Trip PDF via browser print window
import { createElement } from 'react'
import { getCategoryIcon } from '../shared/categoryIcons'
import { FileText, Info, Clock, MapPin, Navigation, Train, Plane, Bus, Car, Ship, Coffee, Ticket, Star, Heart, Camera, Flag, Lightbulb, AlertTriangle, ShoppingBag, Bookmark, Hotel, LogIn, LogOut, KeyRound, BedDouble, LucideIcon } from 'lucide-react'
import { FileText, Info, Clock, MapPin, Navigation, Train, Plane, Bus, Car, Ship, Coffee, Ticket, Star, Heart, Camera, Flag, Lightbulb, AlertTriangle, ShoppingBag, Bookmark, Hotel, LogIn, LogOut, KeyRound, BedDouble, Utensils, Users, LucideIcon } from 'lucide-react'
import { accommodationsApi, mapsApi } from '../../api/client'
import type { Trip, Day, Place, Category, AssignmentsMap, DayNotesMap } from '../../types'
@@ -18,10 +18,12 @@ function noteIconSvg(iconId) {
return renderLucideIcon(Icon, { size: 14, strokeWidth: 1.8, color: '#94a3b8' })
}
const TRANSPORT_ICON_MAP = { flight: Plane, train: Train, bus: Bus, car: Car, cruise: Ship }
function transportIconSvg(type) {
const Icon = TRANSPORT_ICON_MAP[type] || Ticket
return renderLucideIcon(Icon, { size: 14, strokeWidth: 1.8, color: '#3b82f6' })
const RESERVATION_ICON_MAP = { flight: Plane, train: Train, bus: Bus, car: Car, cruise: Ship, restaurant: Utensils, event: Ticket, tour: Users, other: FileText }
const RESERVATION_COLOR_MAP = { flight: '#3b82f6', train: '#06b6d4', bus: '#6b7280', car: '#6b7280', cruise: '#0ea5e9', restaurant: '#ef4444', event: '#f59e0b', tour: '#10b981', other: '#6b7280' }
function reservationIconSvg(type) {
const Icon = RESERVATION_ICON_MAP[type] || Ticket
const color = RESERVATION_COLOR_MAP[type] || '#3b82f6'
return renderLucideIcon(Icon, { size: 14, strokeWidth: 1.8, color })
}
const ACCOMMODATION_ICON_MAP = { accommodation: Hotel, checkin: LogIn, checkout: LogOut, location: MapPin, note: FileText, confirmation: KeyRound }
@@ -144,19 +146,18 @@ export async function downloadTripPDF({ trip, days, places, assignments, categor
const notes = (dayNotes || []).filter(n => n.day_id === day.id)
const cost = dayCost(assignments, day.id, loc)
// Transport bookings for this day
const TRANSPORT_TYPES = new Set(['flight', 'train', 'bus', 'car', 'cruise'])
const dayTransport = (reservations || []).filter(r => {
if (!r.reservation_time || !TRANSPORT_TYPES.has(r.type)) return false
// Reservations for this day (hotel rendered via accommodations block)
const dayReservations = (reservations || []).filter(r => {
if (!r.reservation_time || r.type === 'hotel') return false
return day.date && r.reservation_time.split('T')[0] === day.date
})
const merged = []
assigned.forEach(a => merged.push({ type: 'place', k: a.order_index ?? a.sort_order ?? 0, data: a }))
notes.forEach(n => merged.push({ type: 'note', k: n.sort_order ?? 0, data: n }))
dayTransport.forEach(r => {
dayReservations.forEach(r => {
const pos = r.day_plan_position ?? (merged.length > 0 ? Math.max(...merged.map(m => m.k)) + 0.5 : 0.5)
merged.push({ type: 'transport', k: pos, data: r })
merged.push({ type: 'reservation', k: pos, data: r })
})
merged.sort((a, b) => a.k - b.k)
@@ -164,21 +165,27 @@ export async function downloadTripPDF({ trip, days, places, assignments, categor
const itemsHtml = merged.length === 0
? `<div class="empty-day">${escHtml(tr('dayplan.emptyDay'))}</div>`
: merged.map(item => {
if (item.type === 'transport') {
if (item.type === 'reservation') {
const r = item.data
const meta = typeof r.metadata === 'string' ? JSON.parse(r.metadata || '{}') : (r.metadata || {})
const icon = transportIconSvg(r.type)
const icon = reservationIconSvg(r.type)
const color = RESERVATION_COLOR_MAP[r.type] || '#3b82f6'
let subtitle = ''
if (r.type === 'flight') subtitle = [meta.airline, meta.flight_number, meta.departure_airport && meta.arrival_airport ? `${meta.departure_airport}${meta.arrival_airport}` : ''].filter(Boolean).join(' · ')
else if (r.type === 'train') subtitle = [meta.train_number, meta.platform ? `Gl. ${meta.platform}` : '', meta.seat ? `Seat ${meta.seat}` : ''].filter(Boolean).join(' · ')
else if (r.type === 'restaurant') subtitle = [meta.party_size ? `${meta.party_size} guests` : ''].filter(Boolean).join(' · ')
else if (r.type === 'event') subtitle = [meta.venue].filter(Boolean).join(' · ')
else if (r.type === 'tour') subtitle = [meta.operator].filter(Boolean).join(' · ')
const locationLine = r.location || meta.location || ''
const time = r.reservation_time?.includes('T') ? r.reservation_time.split('T')[1]?.substring(0, 5) : ''
return `
<div class="note-card" style="border-left: 3px solid #3b82f6;">
<div class="note-line" style="background: #3b82f6;"></div>
<div class="note-card" style="border-left: 3px solid ${color};">
<div class="note-line" style="background: ${color};"></div>
<span class="note-icon">${icon}</span>
<div class="note-body">
<div class="note-text" style="font-weight: 600;">${escHtml(r.title)}${time ? ` <span style="color:#6b7280;font-weight:400;font-size:10px;">${time}</span>` : ''}</div>
${subtitle ? `<div class="note-time">${escHtml(subtitle)}</div>` : ''}
${locationLine ? `<div class="note-time">${escHtml(locationLine)}</div>` : ''}
${r.confirmation_number ? `<div class="note-time" style="font-size:9px;">Code: ${escHtml(r.confirmation_number)}</div>` : ''}
</div>
</div>`
@@ -286,7 +286,20 @@ export default function PlaceFormModal({
onChange={e => handleChange('description', e.target.value)}
rows={2}
placeholder={t('places.formDescriptionPlaceholder')}
className="form-input" style={{ resize: 'none' }}
className="form-input" style={{ resize: 'vertical' }}
/>
</div>
{/* Notes */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">{t('places.formNotes')}</label>
<textarea
value={form.notes}
onChange={e => handleChange('notes', e.target.value)}
rows={3}
maxLength={2000}
placeholder={t('places.formNotesPlaceholder')}
className="form-input" style={{ resize: 'vertical' }}
/>
</div>
@@ -341,9 +341,16 @@ export default function PlaceInspector({
)}
{/* Description / Summary */}
{(place.description || place.notes || googleDetails?.summary) && (
{(place.description || googleDetails?.summary) && (
<div className="collab-note-md" style={{ background: 'var(--bg-hover)', borderRadius: 10, overflow: 'hidden', fontSize: 12, color: 'var(--text-muted)', lineHeight: '1.5', padding: '8px 12px' }}>
<Markdown remarkPlugins={[remarkGfm]}>{place.description || place.notes || googleDetails?.summary || ''}</Markdown>
<Markdown remarkPlugins={[remarkGfm]}>{place.description || googleDetails?.summary || ''}</Markdown>
</div>
)}
{/* Notes */}
{place.notes && (
<div className="collab-note-md" style={{ background: 'var(--bg-hover)', borderRadius: 10, overflow: 'hidden', fontSize: 12, color: 'var(--text-muted)', lineHeight: '1.5', padding: '8px 12px' }}>
<Markdown remarkPlugins={[remarkGfm]}>{place.notes}</Markdown>
</div>
)}
@@ -28,7 +28,7 @@ interface PlacesSidebarProps {
onDeletePlace: (placeId: number) => void
days: Day[]
isMobile: boolean
onCategoryFilterChange?: (categoryId: string) => void
onCategoryFilterChange?: (categoryIds: Set<string>) => void
onPlacesFilterChange?: (filter: string) => void
pushUndo?: (label: string, undoFn: () => Promise<void> | void) => void
}
@@ -105,8 +105,7 @@ const PlacesSidebar = React.memo(function PlacesSidebar({
setCategoryFiltersLocal(prev => {
const next = new Set(prev)
if (next.has(catId)) next.delete(catId); else next.add(catId)
// Notify parent with first selected or empty
onCategoryFilterChange?.(next.size === 1 ? [...next][0] : '')
onCategoryFilterChange?.(next)
return next
})
}
@@ -257,7 +256,7 @@ const PlacesSidebar = React.memo(function PlacesSidebar({
)
})}
{categoryFilters.size > 0 && (
<button onClick={() => { setCategoryFiltersLocal(new Set()); onCategoryFilterChange?.('') }} style={{
<button onClick={() => { setCategoryFiltersLocal(new Set()); onCategoryFilterChange?.(new Set()) }} style={{
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 4,
width: '100%', padding: '6px 10px', borderRadius: 6, border: 'none', cursor: 'pointer',
background: 'transparent', fontFamily: 'inherit', fontSize: 11, color: 'var(--text-faint)',
+42 -1
View File
@@ -5,6 +5,8 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
// Common
'common.save': 'حفظ',
'common.showMore': 'عرض المزيد',
'common.showLess': 'عرض أقل',
'common.cancel': 'إلغاء',
'common.delete': 'حذف',
'common.edit': 'تعديل',
@@ -927,6 +929,7 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
'places.endTimeBeforeStart': 'وقت النهاية قبل وقت البداية',
'places.timeCollision': 'تداخل في الوقت مع:',
'places.formWebsite': 'الموقع الإلكتروني',
'places.formNotes': 'ملاحظات',
'places.formNotesPlaceholder': 'ملاحظات شخصية...',
'places.formReservation': 'حجز',
'places.reservationNotesPlaceholder': 'ملاحظات الحجز، رقم التأكيد...',
@@ -1097,7 +1100,6 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
'budget.settlement': 'التسوية',
'budget.settlementInfo': 'انقر على صورة العضو في بند الميزانية لتحديده باللون الأخضر — وهذا يعني أنه دفع. ثم تُظهر التسوية من يدين لمن وبكم.',
'budget.netBalances': 'الأرصدة الصافية',
'budget.linkedToReservation': 'مرتبط بحجز — قم بتحرير الاسم هناك',
// Files
'files.title': 'الملفات',
@@ -1539,6 +1541,45 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
'journey.entries.deleteTitle': 'حذف الإدخال',
'journey.photosUploaded': 'تم رفع {count} صورة',
'journey.photosAdded': 'تمت إضافة {count} صورة',
'journey.picker.tripPeriod': 'فترة الرحلة',
'journey.picker.dateRange': 'نطاق التاريخ',
'journey.picker.allPhotos': 'كل الصور',
'journey.picker.albums': 'ألبومات',
'journey.picker.selected': 'محدد',
'journey.picker.addTo': 'إضافة إلى',
'journey.picker.newGallery': 'معرض جديد',
'journey.picker.selectAll': 'تحديد الكل',
'journey.picker.deselectAll': 'إلغاء تحديد الكل',
'journey.picker.noAlbums': 'لم يتم العثور على ألبومات',
'journey.picker.selectDate': 'اختر تاريخ',
'journey.picker.search': 'بحث',
// Journey Detail
'journey.detail.photos': 'صور',
'journey.detail.backToJourney': 'العودة للمجلة',
'journey.detail.day': 'اليوم {number}',
'journey.detail.places': 'أماكن',
'journey.skeletons.show': 'إظهار الاقتراحات',
'journey.skeletons.hide': 'إخفاء الاقتراحات',
// Journey — Invite
'journey.invite.role': 'الدور',
'journey.invite.viewer': 'مشاهد',
'journey.invite.editor': 'محرر',
'journey.invite.invite': 'دعوة',
'journey.invite.inviting': 'جارٍ الدعوة...',
// Journey Entry Editor
'journey.editor.uploadPhotos': 'رفع صور',
'journey.editor.uploading': '...جارٍ الرفع',
'journey.editor.fromGallery': 'من المعرض',
'journey.editor.addAnother': 'إضافة آخر',
'journey.editor.makeFirst': 'جعله الأول',
'journey.editor.searching': 'جارٍ البحث...',
// Journey — Share
'journey.share.copy': 'نسخ',
'journey.share.copied': 'تم النسخ!',
// Collab Addon
'collab.tabs.chat': 'الدردشة',
+29
View File
@@ -1,6 +1,8 @@
const br: Record<string, string | { name: string; category: string }[]> = {
// Common
'common.save': 'Salvar',
'common.showMore': 'Mostrar mais',
'common.showLess': 'Mostrar menos',
'common.cancel': 'Cancelar',
'common.delete': 'Excluir',
'common.edit': 'Editar',
@@ -897,6 +899,7 @@ const br: Record<string, string | { name: string; category: string }[]> = {
'places.endTimeBeforeStart': 'O horário de fim é antes do início',
'places.timeCollision': 'Sobreposição de horário com:',
'places.formWebsite': 'Site',
'places.formNotes': 'Notas',
'places.formNotesPlaceholder': 'Notas pessoais...',
'places.formReservation': 'Reserva',
'places.reservationNotesPlaceholder': 'Notas da reserva, código de confirmação...',
@@ -1886,16 +1889,22 @@ const br: Record<string, string | { name: string; category: string }[]> = {
'journey.detail.contributors': 'Colaboradores',
'journey.detail.readMore': 'Ler mais',
'journey.detail.prosCons': 'Prós e contras',
'journey.detail.photos': 'fotos',
'journey.detail.day': 'Dia {number}',
'journey.detail.places': 'lugares',
'journey.stats.days': 'Dias',
'journey.stats.cities': 'Cidades',
'journey.stats.entries': 'Entradas',
'journey.stats.photos': 'Fotos',
'journey.stats.places': 'Lugares',
'journey.skeletons.show': 'Mostrar sugestões',
'journey.skeletons.hide': 'Ocultar sugestões',
'journey.verdict.lovedIt': 'Adorei',
'journey.verdict.couldBeBetter': 'Poderia ser melhor',
'journey.synced.places': 'lugares',
'journey.synced.synced': 'sincronizado',
'journey.editor.uploadPhotos': 'Enviar fotos',
'journey.editor.uploading': 'Enviando...',
'journey.editor.fromGallery': 'Da galeria',
'journey.editor.allPhotosAdded': 'Todas as fotos já foram adicionadas',
'journey.editor.writeStory': 'Escreva sua história...',
@@ -1912,6 +1921,7 @@ const br: Record<string, string | { name: string; category: string }[]> = {
'journey.editor.weather': 'Clima',
'journey.editor.photoFirst': '1º',
'journey.editor.makeFirst': 'Tornar 1º',
'journey.editor.searching': 'Pesquisando...',
'journey.mood.amazing': 'Incrível',
'journey.mood.good': 'Bom',
'journey.mood.neutral': 'Neutro',
@@ -1956,6 +1966,13 @@ const br: Record<string, string | { name: string; category: string }[]> = {
'journey.share.linkDeleted': 'Link de compartilhamento removido',
'journey.share.deleteFailed': 'Não foi possível excluir',
'journey.share.updateFailed': 'Não foi possível atualizar',
// Journey — Invite
'journey.invite.role': 'Função',
'journey.invite.viewer': 'Visualizador',
'journey.invite.editor': 'Editor',
'journey.invite.invite': 'Convidar',
'journey.invite.inviting': 'Convidando...',
'journey.settings.title': 'Configurações da jornada',
'journey.settings.coverImage': 'Imagem de capa',
'journey.settings.changeCover': 'Alterar capa',
@@ -1986,6 +2003,18 @@ const br: Record<string, string | { name: string; category: string }[]> = {
'journey.pdf.theEnd': 'Fim',
'journey.pdf.saveAsPdf': 'Salvar como PDF',
'journey.pdf.pages': 'páginas',
'journey.picker.tripPeriod': 'Período da viagem',
'journey.picker.dateRange': 'Período',
'journey.picker.allPhotos': 'Todas as fotos',
'journey.picker.albums': 'Álbuns',
'journey.picker.selected': 'selecionados',
'journey.picker.addTo': 'Adicionar a',
'journey.picker.newGallery': 'Nova galeria',
'journey.picker.selectAll': 'Selecionar tudo',
'journey.picker.deselectAll': 'Desmarcar tudo',
'journey.picker.noAlbums': 'Nenhum álbum encontrado',
'journey.picker.selectDate': 'Selecionar data',
'journey.picker.search': 'Pesquisar',
'dashboard.greeting.morning': 'Bom dia,',
'dashboard.greeting.afternoon': 'Boa tarde,',
'dashboard.greeting.evening': 'Boa noite,',
+29
View File
@@ -1,6 +1,8 @@
const cs: Record<string, string | { name: string; category: string }[]> = {
// Společné (Common)
'common.save': 'Uložit',
'common.showMore': 'Zobrazit více',
'common.showLess': 'Zobrazit méně',
'common.cancel': 'Zrušit',
'common.delete': 'Smazat',
'common.edit': 'Upravit',
@@ -925,6 +927,7 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
'places.endTimeBeforeStart': 'Čas konce je před časem začátku',
'places.timeCollision': 'Časový překryv s:',
'places.formWebsite': 'Webové stránky',
'places.formNotes': 'Poznámky',
'places.formNotesPlaceholder': 'Osobní poznámky...',
'places.formReservation': 'Rezervace',
'places.reservationNotesPlaceholder': 'Poznámky k rezervaci, potvrzovací kód...',
@@ -1891,16 +1894,22 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
'journey.detail.contributors': 'Přispěvatelé',
'journey.detail.readMore': 'Číst dále',
'journey.detail.prosCons': 'Klady a zápory',
'journey.detail.photos': 'fotky',
'journey.detail.day': 'Den {number}',
'journey.detail.places': 'míst',
'journey.stats.days': 'Dny',
'journey.stats.cities': 'Města',
'journey.stats.entries': 'Záznamy',
'journey.stats.photos': 'Fotky',
'journey.stats.places': 'Místa',
'journey.skeletons.show': 'Zobrazit návrhy',
'journey.skeletons.hide': 'Skrýt návrhy',
'journey.verdict.lovedIt': 'Skvělé',
'journey.verdict.couldBeBetter': 'Mohlo by být lepší',
'journey.synced.places': 'místa',
'journey.synced.synced': 'synchronizováno',
'journey.editor.uploadPhotos': 'Nahrát fotky',
'journey.editor.uploading': 'Nahrávání...',
'journey.editor.fromGallery': 'Z galerie',
'journey.editor.allPhotosAdded': 'Všechny fotky již přidány',
'journey.editor.writeStory': 'Napište svůj příběh...',
@@ -1917,6 +1926,7 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
'journey.editor.weather': 'Počasí',
'journey.editor.photoFirst': '1.',
'journey.editor.makeFirst': 'Nastavit jako 1.',
'journey.editor.searching': 'Hledání...',
'journey.mood.amazing': 'Úžasný',
'journey.mood.good': 'Dobrý',
'journey.mood.neutral': 'Neutrální',
@@ -1961,6 +1971,13 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
'journey.share.linkDeleted': 'Odkaz ke sdílení smazán',
'journey.share.deleteFailed': 'Smazání selhalo',
'journey.share.updateFailed': 'Aktualizace selhala',
// Journey — Invite
'journey.invite.role': 'Role',
'journey.invite.viewer': 'Čtenář',
'journey.invite.editor': 'Editor',
'journey.invite.invite': 'Pozvat',
'journey.invite.inviting': 'Zveme...',
'journey.settings.title': 'Nastavení cestovního deníku',
'journey.settings.coverImage': 'Titulní obrázek',
'journey.settings.changeCover': 'Změnit obal',
@@ -1991,6 +2008,18 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
'journey.pdf.theEnd': 'Konec',
'journey.pdf.saveAsPdf': 'Uložit jako PDF',
'journey.pdf.pages': 'stran',
'journey.picker.tripPeriod': 'Období cesty',
'journey.picker.dateRange': 'Časové období',
'journey.picker.allPhotos': 'Všechny fotky',
'journey.picker.albums': 'Alba',
'journey.picker.selected': 'vybráno',
'journey.picker.addTo': 'Přidat do',
'journey.picker.newGallery': 'Nová galerie',
'journey.picker.selectAll': 'Vybrat vše',
'journey.picker.deselectAll': 'Zrušit výběr',
'journey.picker.noAlbums': 'Žádná alba nenalezena',
'journey.picker.selectDate': 'Vyberte datum',
'journey.picker.search': 'Hledat',
'dashboard.greeting.morning': 'Dobré ráno,',
'dashboard.greeting.afternoon': 'Dobré odpoledne,',
'dashboard.greeting.evening': 'Dobrý večer,',
+29 -8
View File
@@ -1,6 +1,8 @@
const de: Record<string, string | { name: string; category: string }[]> = {
// Allgemein
'common.save': 'Speichern',
'common.showMore': 'Mehr anzeigen',
'common.showLess': 'Weniger anzeigen',
'common.cancel': 'Abbrechen',
'common.delete': 'Löschen',
'common.edit': 'Bearbeiten',
@@ -928,6 +930,7 @@ const de: Record<string, string | { name: string; category: string }[]> = {
'places.endTimeBeforeStart': 'Endzeit liegt vor der Startzeit',
'places.timeCollision': 'Zeitliche Überschneidung mit:',
'places.formWebsite': 'Website',
'places.formNotes': 'Notizen',
'places.formNotesPlaceholder': 'Persönliche Notizen...',
'places.formReservation': 'Reservierung',
'places.reservationNotesPlaceholder': 'Reservierungsnotizen, Bestätigungsnummer...',
@@ -1892,16 +1895,22 @@ const de: Record<string, string | { name: string; category: string }[]> = {
'journey.detail.contributors': 'Mitwirkende',
'journey.detail.readMore': 'Mehr lesen',
'journey.detail.prosCons': 'Pro & Contra',
'journey.detail.photos': 'Fotos',
'journey.detail.day': 'Tag {number}',
'journey.detail.places': 'Orte',
'journey.stats.days': 'Tage',
'journey.stats.cities': 'Städte',
'journey.stats.entries': 'Einträge',
'journey.stats.photos': 'Fotos',
'journey.stats.places': 'Orte',
'journey.skeletons.show': 'Vorschläge anzeigen',
'journey.skeletons.hide': 'Vorschläge ausblenden',
'journey.verdict.lovedIt': 'Toll',
'journey.verdict.couldBeBetter': 'Verbesserungswürdig',
'journey.synced.places': 'Orte',
'journey.synced.synced': 'synchronisiert',
'journey.editor.uploadPhotos': 'Fotos hochladen',
'journey.editor.uploading': 'Hochladen...',
'journey.editor.fromGallery': 'Aus Galerie',
'journey.editor.allPhotosAdded': 'Alle Fotos bereits hinzugefügt',
'journey.editor.writeStory': 'Erzähle deine Geschichte...',
@@ -1918,6 +1927,7 @@ const de: Record<string, string | { name: string; category: string }[]> = {
'journey.editor.weather': 'Wetter',
'journey.editor.photoFirst': '1.',
'journey.editor.makeFirst': 'Als 1. setzen',
'journey.editor.searching': 'Suche...',
'journey.mood.amazing': 'Großartig',
'journey.mood.good': 'Gut',
'journey.mood.neutral': 'Neutral',
@@ -1962,6 +1972,13 @@ const de: Record<string, string | { name: string; category: string }[]> = {
'journey.share.linkDeleted': 'Link entfernt',
'journey.share.deleteFailed': 'Entfernen fehlgeschlagen',
'journey.share.updateFailed': 'Aktualisierung fehlgeschlagen',
// Journey — Invite
'journey.invite.role': 'Rolle',
'journey.invite.viewer': 'Betrachter',
'journey.invite.editor': 'Bearbeiter',
'journey.invite.invite': 'Einladen',
'journey.invite.inviting': 'Wird eingeladen...',
'journey.settings.title': 'Journey-Einstellungen',
'journey.settings.coverImage': 'Titelbild',
'journey.settings.changeCover': 'Titelbild ändern',
@@ -1992,6 +2009,18 @@ const de: Record<string, string | { name: string; category: string }[]> = {
'journey.pdf.theEnd': 'Ende',
'journey.pdf.saveAsPdf': 'Als PDF speichern',
'journey.pdf.pages': 'Seiten',
'journey.picker.tripPeriod': 'Reisezeitraum',
'journey.picker.dateRange': 'Zeitraum',
'journey.picker.allPhotos': 'Alle Fotos',
'journey.picker.albums': 'Alben',
'journey.picker.selected': 'ausgewählt',
'journey.picker.addTo': 'Hinzufügen zu',
'journey.picker.newGallery': 'Neue Galerie',
'journey.picker.selectAll': 'Alle auswählen',
'journey.picker.deselectAll': 'Alle abwählen',
'journey.picker.noAlbums': 'Keine Alben gefunden',
'journey.picker.selectDate': 'Datum wählen',
'journey.picker.search': 'Suchen',
'dashboard.greeting.morning': 'Guten Morgen,',
'dashboard.greeting.afternoon': 'Guten Tag,',
'dashboard.greeting.evening': 'Guten Abend,',
@@ -2027,14 +2056,6 @@ const de: Record<string, string | { name: string; category: string }[]> = {
'dayplan.mobile.allAssigned': 'Alle Orte zugeordnet',
'dayplan.mobile.noMatch': 'Kein Treffer',
'dayplan.mobile.createNew': 'Neuen Ort erstellen',
'memories.notConnectedMultipleHint': 'Connect any of these photo providers: {provider_names} in Settings to be able add photos to this trip.',
'memories.providerUrl': 'Server URL',
'memories.providerApiKey': 'API Key',
'memories.providerUsername': 'Username',
'memories.providerPassword': 'Password',
'memories.saveError': 'Could not save {provider_name} settings',
'memories.selectAlbumMultiple': 'Select Album',
'memories.selectPhotosMultiple': 'Select Photos',
// OAuth scope groups
'oauth.scope.group.trips': 'Reisen',
+29
View File
@@ -1,6 +1,8 @@
const en: Record<string, string | { name: string; category: string }[]> = {
// Common
'common.save': 'Save',
'common.showMore': 'Show more',
'common.showLess': 'Show less',
'common.cancel': 'Cancel',
'common.delete': 'Delete',
'common.edit': 'Edit',
@@ -950,6 +952,7 @@ const en: Record<string, string | { name: string; category: string }[]> = {
'places.endTimeBeforeStart': 'End time is before start time',
'places.timeCollision': 'Time overlap with:',
'places.formWebsite': 'Website',
'places.formNotes': 'Notes',
'places.formNotesPlaceholder': 'Personal notes...',
'places.formReservation': 'Reservation',
'places.reservationNotesPlaceholder': 'Reservation notes, confirmation number...',
@@ -1895,6 +1898,9 @@ const en: Record<string, string | { name: string; category: string }[]> = {
'journey.detail.contributors': 'Contributors',
'journey.detail.readMore': 'Read more',
'journey.detail.prosCons': 'Pros & Cons',
'journey.detail.photos': 'photos',
'journey.detail.day': 'Day {number}',
'journey.detail.places': 'places',
// Journey Detail — Stats
'journey.stats.days': 'Days',
@@ -1902,6 +1908,8 @@ const en: Record<string, string | { name: string; category: string }[]> = {
'journey.stats.entries': 'Entries',
'journey.stats.photos': 'Photos',
'journey.stats.places': 'Places',
'journey.skeletons.show': 'Show suggestions',
'journey.skeletons.hide': 'Hide suggestions',
// Journey Detail — Verdict
'journey.verdict.lovedIt': 'Loved it',
@@ -1913,6 +1921,7 @@ const en: Record<string, string | { name: string; category: string }[]> = {
// Journey Entry Editor
'journey.editor.uploadPhotos': 'Upload photos',
'journey.editor.uploading': 'Uploading...',
'journey.editor.fromGallery': 'From Gallery',
'journey.editor.allPhotosAdded': 'All photos already added',
'journey.editor.writeStory': 'Write your story...',
@@ -1929,6 +1938,7 @@ const en: Record<string, string | { name: string; category: string }[]> = {
'journey.editor.weather': 'Weather',
'journey.editor.photoFirst': '1st',
'journey.editor.makeFirst': 'Make 1st',
'journey.editor.searching': 'Searching...',
// Journey Entry — Moods
'journey.mood.amazing': 'Amazing',
@@ -1984,6 +1994,13 @@ const en: Record<string, string | { name: string; category: string }[]> = {
'journey.share.deleteFailed': 'Failed to delete',
'journey.share.updateFailed': 'Failed to update',
// Journey — Invite
'journey.invite.role': 'Role',
'journey.invite.viewer': 'Viewer',
'journey.invite.editor': 'Editor',
'journey.invite.invite': 'Invite',
'journey.invite.inviting': 'Inviting...',
// Journey — Settings Dialog
'journey.settings.title': 'Journey Settings',
'journey.settings.coverImage': 'Cover Image',
@@ -2019,6 +2036,18 @@ const en: Record<string, string | { name: string; category: string }[]> = {
'journey.pdf.theEnd': 'The End',
'journey.pdf.saveAsPdf': 'Save as PDF',
'journey.pdf.pages': 'pages',
'journey.picker.tripPeriod': 'Trip Period',
'journey.picker.dateRange': 'Date Range',
'journey.picker.allPhotos': 'All Photos',
'journey.picker.albums': 'Albums',
'journey.picker.selected': 'selected',
'journey.picker.addTo': 'Add to',
'journey.picker.newGallery': 'New Gallery',
'journey.picker.selectAll': 'Select all',
'journey.picker.deselectAll': 'Deselect all',
'journey.picker.noAlbums': 'No albums found',
'journey.picker.selectDate': 'Select date',
'journey.picker.search': 'Search',
// Dashboard Mobile
'dashboard.greeting.morning': 'Good morning,',
+29
View File
@@ -1,6 +1,8 @@
const es: Record<string, string> = {
// Common
'common.save': 'Guardar',
'common.showMore': 'Ver más',
'common.showLess': 'Ver menos',
'common.cancel': 'Cancelar',
'common.delete': 'Eliminar',
'common.edit': 'Editar',
@@ -900,6 +902,7 @@ const es: Record<string, string> = {
'places.endTimeBeforeStart': 'La hora de fin es anterior a la de inicio',
'places.timeCollision': 'Solapamiento horario con:',
'places.formWebsite': 'Página web',
'places.formNotes': 'Notas',
'places.formNotesPlaceholder': 'Notas personales...',
'places.formReservation': 'Reserva',
'places.reservationNotesPlaceholder': 'Notas de reserva, número de confirmación...',
@@ -1893,16 +1896,22 @@ const es: Record<string, string> = {
'journey.detail.contributors': 'Colaboradores',
'journey.detail.readMore': 'Leer más',
'journey.detail.prosCons': 'Pros y contras',
'journey.detail.photos': 'fotos',
'journey.detail.day': 'Día {number}',
'journey.detail.places': 'lugares',
'journey.stats.days': 'Días',
'journey.stats.cities': 'Ciudades',
'journey.stats.entries': 'Entradas',
'journey.stats.photos': 'Fotos',
'journey.stats.places': 'Lugares',
'journey.skeletons.show': 'Mostrar sugerencias',
'journey.skeletons.hide': 'Ocultar sugerencias',
'journey.verdict.lovedIt': 'Me encantó',
'journey.verdict.couldBeBetter': 'Podría mejorar',
'journey.synced.places': 'lugares',
'journey.synced.synced': 'sincronizado',
'journey.editor.uploadPhotos': 'Subir fotos',
'journey.editor.uploading': 'Subiendo...',
'journey.editor.fromGallery': 'Desde galería',
'journey.editor.allPhotosAdded': 'Todas las fotos ya fueron añadidas',
'journey.editor.writeStory': 'Escribe tu historia...',
@@ -1919,6 +1928,7 @@ const es: Record<string, string> = {
'journey.editor.weather': 'Clima',
'journey.editor.photoFirst': '1º',
'journey.editor.makeFirst': 'Hacer 1º',
'journey.editor.searching': 'Buscando...',
'journey.mood.amazing': 'Increíble',
'journey.mood.good': 'Bien',
'journey.mood.neutral': 'Neutral',
@@ -1963,6 +1973,13 @@ const es: Record<string, string> = {
'journey.share.linkDeleted': 'Enlace para compartir eliminado',
'journey.share.deleteFailed': 'No se pudo eliminar',
'journey.share.updateFailed': 'No se pudo actualizar',
// Journey — Invite
'journey.invite.role': 'Rol',
'journey.invite.viewer': 'Lector',
'journey.invite.editor': 'Editor',
'journey.invite.invite': 'Invitar',
'journey.invite.inviting': 'Invitando...',
'journey.settings.title': 'Ajustes de la travesía',
'journey.settings.coverImage': 'Imagen de portada',
'journey.settings.changeCover': 'Cambiar portada',
@@ -1993,6 +2010,18 @@ const es: Record<string, string> = {
'journey.pdf.theEnd': 'Fin',
'journey.pdf.saveAsPdf': 'Guardar como PDF',
'journey.pdf.pages': 'páginas',
'journey.picker.tripPeriod': 'Período del viaje',
'journey.picker.dateRange': 'Rango de fechas',
'journey.picker.allPhotos': 'Todas las fotos',
'journey.picker.albums': 'Álbumes',
'journey.picker.selected': 'seleccionados',
'journey.picker.addTo': 'Añadir a',
'journey.picker.newGallery': 'Nueva galería',
'journey.picker.selectAll': 'Seleccionar todo',
'journey.picker.deselectAll': 'Deseleccionar todo',
'journey.picker.noAlbums': 'No se encontraron álbumes',
'journey.picker.selectDate': 'Seleccionar fecha',
'journey.picker.search': 'Buscar',
'dashboard.greeting.morning': 'Buenos días,',
'dashboard.greeting.afternoon': 'Buenas tardes,',
'dashboard.greeting.evening': 'Buenas noches,',
+29
View File
@@ -1,6 +1,8 @@
const fr: Record<string, string> = {
// Common
'common.save': 'Enregistrer',
'common.showMore': 'Voir plus',
'common.showLess': 'Voir moins',
'common.cancel': 'Annuler',
'common.delete': 'Supprimer',
'common.edit': 'Modifier',
@@ -924,6 +926,7 @@ const fr: Record<string, string> = {
'places.endTimeBeforeStart': 'L\'heure de fin est antérieure à l\'heure de début',
'places.timeCollision': 'Chevauchement horaire avec :',
'places.formWebsite': 'Site web',
'places.formNotes': 'Notes',
'places.formNotesPlaceholder': 'Notes personnelles…',
'places.formReservation': 'Réservation',
'places.reservationNotesPlaceholder': 'Notes de réservation, numéro de confirmation…',
@@ -1887,16 +1890,22 @@ const fr: Record<string, string> = {
'journey.detail.contributors': 'Contributeurs',
'journey.detail.readMore': 'Lire la suite',
'journey.detail.prosCons': 'Pour et contre',
'journey.detail.photos': 'photos',
'journey.detail.day': 'Jour {number}',
'journey.detail.places': 'lieux',
'journey.stats.days': 'Jours',
'journey.stats.cities': 'Villes',
'journey.stats.entries': 'Entrées',
'journey.stats.photos': 'Photos',
'journey.stats.places': 'Lieux',
'journey.skeletons.show': 'Afficher les suggestions',
'journey.skeletons.hide': 'Masquer les suggestions',
'journey.verdict.lovedIt': 'Adoré',
'journey.verdict.couldBeBetter': 'Pourrait être mieux',
'journey.synced.places': 'lieux',
'journey.synced.synced': 'synchronisé',
'journey.editor.uploadPhotos': 'Téléverser des photos',
'journey.editor.uploading': 'Envoi...',
'journey.editor.fromGallery': 'Depuis la galerie',
'journey.editor.allPhotosAdded': 'Toutes les photos ont déjà été ajoutées',
'journey.editor.writeStory': 'Écrivez votre histoire...',
@@ -1913,6 +1922,7 @@ const fr: Record<string, string> = {
'journey.editor.weather': 'Météo',
'journey.editor.photoFirst': '1er',
'journey.editor.makeFirst': 'Mettre en 1er',
'journey.editor.searching': 'Recherche...',
'journey.mood.amazing': 'Incroyable',
'journey.mood.good': 'Bien',
'journey.mood.neutral': 'Neutre',
@@ -1957,6 +1967,13 @@ const fr: Record<string, string> = {
'journey.share.linkDeleted': 'Lien de partage supprimé',
'journey.share.deleteFailed': 'Échec de la suppression',
'journey.share.updateFailed': 'Échec de la mise à jour',
// Journey — Invite
'journey.invite.role': 'Rôle',
'journey.invite.viewer': 'Lecteur',
'journey.invite.editor': 'Éditeur',
'journey.invite.invite': 'Inviter',
'journey.invite.inviting': 'Invitation...',
'journey.settings.title': 'Paramètres du journal',
'journey.settings.coverImage': 'Image de couverture',
'journey.settings.changeCover': 'Changer la couverture',
@@ -1987,6 +2004,18 @@ const fr: Record<string, string> = {
'journey.pdf.theEnd': 'Fin',
'journey.pdf.saveAsPdf': 'Enregistrer en PDF',
'journey.pdf.pages': 'pages',
'journey.picker.tripPeriod': 'Période du voyage',
'journey.picker.dateRange': 'Plage de dates',
'journey.picker.allPhotos': 'Toutes les photos',
'journey.picker.albums': 'Albums',
'journey.picker.selected': 'sélectionnés',
'journey.picker.addTo': 'Ajouter à',
'journey.picker.newGallery': 'Nouvelle galerie',
'journey.picker.selectAll': 'Tout sélectionner',
'journey.picker.deselectAll': 'Tout désélectionner',
'journey.picker.noAlbums': 'Aucun album trouvé',
'journey.picker.selectDate': 'Sélectionner une date',
'journey.picker.search': 'Rechercher',
'dashboard.greeting.morning': 'Bonjour,',
'dashboard.greeting.afternoon': 'Bon après-midi,',
'dashboard.greeting.evening': 'Bonsoir,',
+29
View File
@@ -1,6 +1,8 @@
const hu: Record<string, string | { name: string; category: string }[]> = {
// Általános
'common.save': 'Mentés',
'common.showMore': 'Továbbiak',
'common.showLess': 'Kevesebb',
'common.cancel': 'Mégse',
'common.delete': 'Törlés',
'common.edit': 'Szerkesztés',
@@ -925,6 +927,7 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
'places.endTimeBeforeStart': 'A befejezési idő a kezdési idő előtt van',
'places.timeCollision': 'Időbeli átfedés:',
'places.formWebsite': 'Weboldal',
'places.formNotes': 'Jegyzetek',
'places.formNotesPlaceholder': 'Személyes jegyzetek...',
'places.formReservation': 'Foglalás',
'places.reservationNotesPlaceholder': 'Foglalási jegyzetek, visszaigazolási szám...',
@@ -1888,16 +1891,22 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
'journey.detail.contributors': 'Közreműködők',
'journey.detail.readMore': 'Tovább olvasás',
'journey.detail.prosCons': 'Előnyök és hátrányok',
'journey.detail.photos': 'fotók',
'journey.detail.day': '{number}. nap',
'journey.detail.places': 'helyek',
'journey.stats.days': 'Napok',
'journey.stats.cities': 'Városok',
'journey.stats.entries': 'Bejegyzések',
'journey.stats.photos': 'Fotók',
'journey.stats.places': 'Helyszínek',
'journey.skeletons.show': 'Javaslatok megjelenítése',
'journey.skeletons.hide': 'Javaslatok elrejtése',
'journey.verdict.lovedIt': 'Imádtam',
'journey.verdict.couldBeBetter': 'Lehetne jobb',
'journey.synced.places': 'helyszín',
'journey.synced.synced': 'szinkronizálva',
'journey.editor.uploadPhotos': 'Fotók feltöltése',
'journey.editor.uploading': 'Feltöltés...',
'journey.editor.fromGallery': 'Galériából',
'journey.editor.allPhotosAdded': 'Minden fotó már hozzáadva',
'journey.editor.writeStory': 'Írd meg a történeted...',
@@ -1914,6 +1923,7 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
'journey.editor.weather': 'Időjárás',
'journey.editor.photoFirst': '1.',
'journey.editor.makeFirst': 'Legyen az 1.',
'journey.editor.searching': 'Keresés...',
'journey.mood.amazing': 'Fantasztikus',
'journey.mood.good': 'Jó',
'journey.mood.neutral': 'Semleges',
@@ -1958,6 +1968,13 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
'journey.share.linkDeleted': 'Megosztó link törölve',
'journey.share.deleteFailed': 'Nem sikerült törölni',
'journey.share.updateFailed': 'Nem sikerült frissíteni',
// Journey — Invite
'journey.invite.role': 'Szerepkör',
'journey.invite.viewer': 'Megtekintő',
'journey.invite.editor': 'Szerkesztő',
'journey.invite.invite': 'Meghívás',
'journey.invite.inviting': 'Meghívás...',
'journey.settings.title': 'Útinapló beállításai',
'journey.settings.coverImage': 'Borítókép',
'journey.settings.changeCover': 'Borító módosítása',
@@ -1988,6 +2005,18 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
'journey.pdf.theEnd': 'Vége',
'journey.pdf.saveAsPdf': 'Mentés PDF-ként',
'journey.pdf.pages': 'oldal',
'journey.picker.tripPeriod': 'Utazási időszak',
'journey.picker.dateRange': 'Időszak',
'journey.picker.allPhotos': 'Összes fotó',
'journey.picker.albums': 'Albumok',
'journey.picker.selected': 'kiválasztva',
'journey.picker.addTo': 'Hozzáadás',
'journey.picker.newGallery': 'Új galéria',
'journey.picker.selectAll': 'Összes kijelölése',
'journey.picker.deselectAll': 'Összes kijelölés törlése',
'journey.picker.noAlbums': 'Nem található album',
'journey.picker.selectDate': 'Dátum választása',
'journey.picker.search': 'Keresés',
'dashboard.greeting.morning': 'Jó reggelt,',
'dashboard.greeting.afternoon': 'Jó napot,',
'dashboard.greeting.evening': 'Jó estét,',
+29
View File
@@ -1,6 +1,8 @@
const it: Record<string, string | { name: string; category: string }[]> = {
// Common
'common.save': 'Salva',
'common.showMore': 'Mostra di più',
'common.showLess': 'Mostra meno',
'common.cancel': 'Annulla',
'common.delete': 'Elimina',
'common.edit': 'Modifica',
@@ -925,6 +927,7 @@ const it: Record<string, string | { name: string; category: string }[]> = {
'places.endTimeBeforeStart': 'L\'ora di fine è precedente all\'ora di inizio',
'places.timeCollision': 'Sovrapposizione di orario con:',
'places.formWebsite': 'Sito web',
'places.formNotes': 'Note',
'places.formNotesPlaceholder': 'Note personali...',
'places.formReservation': 'Prenotazione',
'places.reservationNotesPlaceholder': 'Note della prenotazione, numero di conferma...',
@@ -1888,16 +1891,22 @@ const it: Record<string, string | { name: string; category: string }[]> = {
'journey.detail.contributors': 'Contributori',
'journey.detail.readMore': 'Leggi di più',
'journey.detail.prosCons': 'Pro e contro',
'journey.detail.photos': 'foto',
'journey.detail.day': 'Giorno {number}',
'journey.detail.places': 'luoghi',
'journey.stats.days': 'Giorni',
'journey.stats.cities': 'Città',
'journey.stats.entries': 'Voci',
'journey.stats.photos': 'Foto',
'journey.stats.places': 'Luoghi',
'journey.skeletons.show': 'Mostra suggerimenti',
'journey.skeletons.hide': 'Nascondi suggerimenti',
'journey.verdict.lovedIt': 'Adorato',
'journey.verdict.couldBeBetter': 'Potrebbe essere meglio',
'journey.synced.places': 'luoghi',
'journey.synced.synced': 'sincronizzato',
'journey.editor.uploadPhotos': 'Carica foto',
'journey.editor.uploading': 'Caricamento...',
'journey.editor.fromGallery': 'Dalla galleria',
'journey.editor.allPhotosAdded': 'Tutte le foto sono già state aggiunte',
'journey.editor.writeStory': 'Scrivi la tua storia...',
@@ -1914,6 +1923,7 @@ const it: Record<string, string | { name: string; category: string }[]> = {
'journey.editor.weather': 'Meteo',
'journey.editor.photoFirst': '1°',
'journey.editor.makeFirst': 'Metti 1°',
'journey.editor.searching': 'Ricerca...',
'journey.mood.amazing': 'Fantastico',
'journey.mood.good': 'Buono',
'journey.mood.neutral': 'Neutro',
@@ -1958,6 +1968,13 @@ const it: Record<string, string | { name: string; category: string }[]> = {
'journey.share.linkDeleted': 'Link di condivisione eliminato',
'journey.share.deleteFailed': 'Eliminazione fallita',
'journey.share.updateFailed': 'Aggiornamento fallito',
// Journey — Invite
'journey.invite.role': 'Ruolo',
'journey.invite.viewer': 'Visualizzatore',
'journey.invite.editor': 'Editore',
'journey.invite.invite': 'Invita',
'journey.invite.inviting': 'Invito in corso...',
'journey.settings.title': 'Impostazioni del diario',
'journey.settings.coverImage': 'Immagine di copertina',
'journey.settings.changeCover': 'Cambia copertina',
@@ -1988,6 +2005,18 @@ const it: Record<string, string | { name: string; category: string }[]> = {
'journey.pdf.theEnd': 'Fine',
'journey.pdf.saveAsPdf': 'Salva come PDF',
'journey.pdf.pages': 'pagine',
'journey.picker.tripPeriod': 'Periodo del viaggio',
'journey.picker.dateRange': 'Intervallo di date',
'journey.picker.allPhotos': 'Tutte le foto',
'journey.picker.albums': 'Album',
'journey.picker.selected': 'selezionati',
'journey.picker.addTo': 'Aggiungi a',
'journey.picker.newGallery': 'Nuova galleria',
'journey.picker.selectAll': 'Seleziona tutto',
'journey.picker.deselectAll': 'Deseleziona tutto',
'journey.picker.noAlbums': 'Nessun album trovato',
'journey.picker.selectDate': 'Seleziona data',
'journey.picker.search': 'Cerca',
'dashboard.greeting.morning': 'Buongiorno,',
'dashboard.greeting.afternoon': 'Buon pomeriggio,',
'dashboard.greeting.evening': 'Buonasera,',
+29
View File
@@ -1,6 +1,8 @@
const nl: Record<string, string> = {
// Common
'common.save': 'Opslaan',
'common.showMore': 'Meer tonen',
'common.showLess': 'Minder tonen',
'common.cancel': 'Annuleren',
'common.delete': 'Verwijderen',
'common.edit': 'Bewerken',
@@ -924,6 +926,7 @@ const nl: Record<string, string> = {
'places.endTimeBeforeStart': 'Eindtijd is vóór de starttijd',
'places.timeCollision': 'Tijdoverlap met:',
'places.formWebsite': 'Website',
'places.formNotes': 'Notities',
'places.formNotesPlaceholder': 'Persoonlijke notities...',
'places.formReservation': 'Reservering',
'places.reservationNotesPlaceholder': 'Reserveringsnotities, bevestigingsnummer...',
@@ -1887,16 +1890,22 @@ const nl: Record<string, string> = {
'journey.detail.contributors': 'Bijdragers',
'journey.detail.readMore': 'Lees meer',
'journey.detail.prosCons': 'Voor- & nadelen',
'journey.detail.photos': 'foto\'s',
'journey.detail.day': 'Dag {number}',
'journey.detail.places': 'plaatsen',
'journey.stats.days': 'Dagen',
'journey.stats.cities': 'Steden',
'journey.stats.entries': 'Vermeldingen',
'journey.stats.photos': 'Foto\'s',
'journey.stats.places': 'Plaatsen',
'journey.skeletons.show': 'Suggesties tonen',
'journey.skeletons.hide': 'Suggesties verbergen',
'journey.verdict.lovedIt': 'Geweldig',
'journey.verdict.couldBeBetter': 'Kan beter',
'journey.synced.places': 'plaatsen',
'journey.synced.synced': 'gesynchroniseerd',
'journey.editor.uploadPhotos': 'Foto\'s uploaden',
'journey.editor.uploading': 'Uploaden...',
'journey.editor.fromGallery': 'Uit galerij',
'journey.editor.allPhotosAdded': 'Alle foto\'s al toegevoegd',
'journey.editor.writeStory': 'Schrijf je verhaal...',
@@ -1913,6 +1922,7 @@ const nl: Record<string, string> = {
'journey.editor.weather': 'Weer',
'journey.editor.photoFirst': '1e',
'journey.editor.makeFirst': 'Maak 1e',
'journey.editor.searching': 'Zoeken...',
'journey.mood.amazing': 'Fantastisch',
'journey.mood.good': 'Goed',
'journey.mood.neutral': 'Neutraal',
@@ -1957,6 +1967,13 @@ const nl: Record<string, string> = {
'journey.share.linkDeleted': 'Deellink verwijderd',
'journey.share.deleteFailed': 'Verwijderen mislukt',
'journey.share.updateFailed': 'Bijwerken mislukt',
// Journey — Invite
'journey.invite.role': 'Rol',
'journey.invite.viewer': 'Kijker',
'journey.invite.editor': 'Bewerker',
'journey.invite.invite': 'Uitnodigen',
'journey.invite.inviting': 'Uitnodigen...',
'journey.settings.title': 'Reisverslaginstellingen',
'journey.settings.coverImage': 'Omslagfoto',
'journey.settings.changeCover': 'Omslag wijzigen',
@@ -1987,6 +2004,18 @@ const nl: Record<string, string> = {
'journey.pdf.theEnd': 'Einde',
'journey.pdf.saveAsPdf': 'Opslaan als PDF',
'journey.pdf.pages': 'pagina\'s',
'journey.picker.tripPeriod': 'Reisperiode',
'journey.picker.dateRange': 'Datumbereik',
'journey.picker.allPhotos': 'Alle foto\'s',
'journey.picker.albums': 'Albums',
'journey.picker.selected': 'geselecteerd',
'journey.picker.addTo': 'Toevoegen aan',
'journey.picker.newGallery': 'Nieuwe galerij',
'journey.picker.selectAll': 'Alles selecteren',
'journey.picker.deselectAll': 'Alles deselecteren',
'journey.picker.noAlbums': 'Geen albums gevonden',
'journey.picker.selectDate': 'Selecteer datum',
'journey.picker.search': 'Zoeken',
'dashboard.greeting.morning': 'Goedemorgen,',
'dashboard.greeting.afternoon': 'Goedemiddag,',
'dashboard.greeting.evening': 'Goedenavond,',
+29
View File
@@ -1,6 +1,8 @@
const pl: Record<string, string | { name: string; category: string }[]> = {
// Common
'common.save': 'Zapisz',
'common.showMore': 'Pokaż więcej',
'common.showLess': 'Pokaż mniej',
'common.cancel': 'Anuluj',
'common.delete': 'Usuń',
'common.edit': 'Edytuj',
@@ -886,6 +888,7 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
'places.endTimeBeforeStart': 'Godzina zakończenia jest przed godziną rozpoczęcia',
'places.timeCollision': 'Nakładanie się godzin z:',
'places.formWebsite': 'Strona internetowa',
'places.formNotes': 'Notatki',
'places.formNotesPlaceholder': 'Osobiste notatki...',
'places.formReservation': 'Rezerwacja',
'places.reservationNotesPlaceholder': 'Notatki z rezerwacji, numer potwierdzenia...',
@@ -1880,16 +1883,22 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
'journey.detail.contributors': 'Współtwórcy',
'journey.detail.readMore': 'Czytaj dalej',
'journey.detail.prosCons': 'Zalety i wady',
'journey.detail.photos': 'zdjęć',
'journey.detail.day': 'Dzień {number}',
'journey.detail.places': 'miejsc',
'journey.stats.days': 'Dni',
'journey.stats.cities': 'Miasta',
'journey.stats.entries': 'Wpisy',
'journey.stats.photos': 'Zdjęcia',
'journey.stats.places': 'Miejsca',
'journey.skeletons.show': 'Pokaż sugestie',
'journey.skeletons.hide': 'Ukryj sugestie',
'journey.verdict.lovedIt': 'Świetne',
'journey.verdict.couldBeBetter': 'Mogłoby być lepiej',
'journey.synced.places': 'miejsca',
'journey.synced.synced': 'zsynchronizowane',
'journey.editor.uploadPhotos': 'Prześlij zdjęcia',
'journey.editor.uploading': 'Przesyłanie...',
'journey.editor.fromGallery': 'Z galerii',
'journey.editor.allPhotosAdded': 'Wszystkie zdjęcia już dodane',
'journey.editor.writeStory': 'Napisz swoją historię...',
@@ -1906,6 +1915,7 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
'journey.editor.weather': 'Pogoda',
'journey.editor.photoFirst': '1.',
'journey.editor.makeFirst': 'Ustaw jako 1.',
'journey.editor.searching': 'Szukanie...',
'journey.mood.amazing': 'Niesamowity',
'journey.mood.good': 'Dobry',
'journey.mood.neutral': 'Neutralny',
@@ -1950,6 +1960,13 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
'journey.share.linkDeleted': 'Link udostępniania usunięty',
'journey.share.deleteFailed': 'Usunięcie nie powiodło się',
'journey.share.updateFailed': 'Aktualizacja nie powiodła się',
// Journey — Invite
'journey.invite.role': 'Rola',
'journey.invite.viewer': 'Obserwator',
'journey.invite.editor': 'Redaktor',
'journey.invite.invite': 'Zaproś',
'journey.invite.inviting': 'Zapraszanie...',
'journey.settings.title': 'Ustawienia dziennika podróży',
'journey.settings.coverImage': 'Zdjęcie okładkowe',
'journey.settings.changeCover': 'Zmień okładkę',
@@ -1980,6 +1997,18 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
'journey.pdf.theEnd': 'Koniec',
'journey.pdf.saveAsPdf': 'Zapisz jako PDF',
'journey.pdf.pages': 'stron',
'journey.picker.tripPeriod': 'Okres podróży',
'journey.picker.dateRange': 'Zakres dat',
'journey.picker.allPhotos': 'Wszystkie zdjęcia',
'journey.picker.albums': 'Albumy',
'journey.picker.selected': 'wybranych',
'journey.picker.addTo': 'Dodaj do',
'journey.picker.newGallery': 'Nowa galeria',
'journey.picker.selectAll': 'Zaznacz wszystko',
'journey.picker.deselectAll': 'Odznacz wszystko',
'journey.picker.noAlbums': 'Nie znaleziono albumów',
'journey.picker.selectDate': 'Wybierz datę',
'journey.picker.search': 'Szukaj',
'dashboard.greeting.morning': 'Dzień dobry,',
'dashboard.greeting.afternoon': 'Dzień dobry,',
'dashboard.greeting.evening': 'Dobry wieczór,',
+29 -13
View File
@@ -1,6 +1,8 @@
const ru: Record<string, string> = {
// Common
'common.save': 'Сохранить',
'common.showMore': 'Показать больше',
'common.showLess': 'Показать меньше',
'common.cancel': 'Отмена',
'common.delete': 'Удалить',
'common.edit': 'Редактировать',
@@ -924,6 +926,7 @@ const ru: Record<string, string> = {
'places.endTimeBeforeStart': 'Время окончания раньше времени начала',
'places.timeCollision': 'Пересечение по времени с:',
'places.formWebsite': 'Сайт',
'places.formNotes': 'Заметки',
'places.formNotesPlaceholder': 'Личные заметки...',
'places.formReservation': 'Бронирование',
'places.reservationNotesPlaceholder': 'Заметки о бронировании, номер подтверждения...',
@@ -1093,7 +1096,6 @@ const ru: Record<string, string> = {
'budget.settlement': 'Взаиморасчёт',
'budget.settlementInfo': 'Нажмите на аватар участника в строке бюджета, чтобы отметить его зелёным — это значит, что он заплатил. Взаиморасчёт покажет, кто кому и сколько должен.',
'budget.netBalances': 'Чистые балансы',
'budget.linkedToReservation': 'Привязано к бронированию — измените название там',
// Files
'files.title': 'Файлы',
@@ -1802,21 +1804,9 @@ const ru: Record<string, string> = {
'common.justNow': 'только что',
'common.hoursAgo': '{count} ч назад',
'common.daysAgo': '{count} д назад',
'budget.linkedToReservation': 'Привязано к бронированию — измените название там',
'packing.saveAsTemplate': 'Сохранить как шаблон',
'packing.templateName': 'Название шаблона',
'packing.templateSaved': 'Список вещей сохранён как шаблон',
'memories.notConnectedMultipleHint': 'Подключите любого из этих фото-провайдеров: {provider_names} в Настройках, чтобы добавлять фото к этой поездке.',
'memories.providerUrl': 'URL сервера',
'memories.providerApiKey': 'API-ключ',
'memories.providerUsername': 'Имя пользователя',
'memories.providerPassword': 'Пароль',
'memories.saveError': 'Не удалось сохранить настройки {provider_name}',
'memories.saveRouteNotConfigured': 'Маршрут сохранения не настроен для этого провайдера',
'memories.testRouteNotConfigured': 'Маршрут тестирования не настроен для этого провайдера',
'memories.fillRequiredFields': 'Пожалуйста, заполните все обязательные поля',
'memories.selectAlbumMultiple': 'Выбрать альбом',
'memories.selectPhotosMultiple': 'Выбрать фото',
'journey.title': 'Путешествие',
'journey.subtitle': 'Отслеживайте свои путешествия в реальном времени',
'journey.new': 'Новое путешествие',
@@ -1900,16 +1890,22 @@ const ru: Record<string, string> = {
'journey.detail.contributors': 'Участники',
'journey.detail.readMore': 'Читать далее',
'journey.detail.prosCons': 'Плюсы и минусы',
'journey.detail.photos': 'фото',
'journey.detail.day': 'День {number}',
'journey.detail.places': 'мест',
'journey.stats.days': 'Дней',
'journey.stats.cities': 'Городов',
'journey.stats.entries': 'Записей',
'journey.stats.photos': 'Фото',
'journey.stats.places': 'Мест',
'journey.skeletons.show': 'Показать предложения',
'journey.skeletons.hide': 'Скрыть предложения',
'journey.verdict.lovedIt': 'Понравилось',
'journey.verdict.couldBeBetter': 'Могло быть лучше',
'journey.synced.places': 'мест',
'journey.synced.synced': 'синхронизировано',
'journey.editor.uploadPhotos': 'Загрузить фото',
'journey.editor.uploading': 'Загрузка...',
'journey.editor.fromGallery': 'Из галереи',
'journey.editor.allPhotosAdded': 'Все фото уже добавлены',
'journey.editor.writeStory': 'Напишите свою историю...',
@@ -1926,6 +1922,7 @@ const ru: Record<string, string> = {
'journey.editor.weather': 'Погода',
'journey.editor.photoFirst': '1-е',
'journey.editor.makeFirst': 'Сделать 1-м',
'journey.editor.searching': 'Поиск...',
'journey.mood.amazing': 'Потрясающе',
'journey.mood.good': 'Хорошо',
'journey.mood.neutral': 'Нейтрально',
@@ -1970,6 +1967,13 @@ const ru: Record<string, string> = {
'journey.share.linkDeleted': 'Ссылка удалена',
'journey.share.deleteFailed': 'Не удалось удалить',
'journey.share.updateFailed': 'Не удалось обновить',
// Journey — Invite
'journey.invite.role': 'Роль',
'journey.invite.viewer': 'Наблюдатель',
'journey.invite.editor': 'Редактор',
'journey.invite.invite': 'Пригласить',
'journey.invite.inviting': 'Приглашаем...',
'journey.settings.title': 'Настройки путешествия',
'journey.settings.coverImage': 'Обложка',
'journey.settings.changeCover': 'Сменить обложку',
@@ -2000,6 +2004,18 @@ const ru: Record<string, string> = {
'journey.pdf.theEnd': 'Конец',
'journey.pdf.saveAsPdf': 'Сохранить как PDF',
'journey.pdf.pages': 'страниц',
'journey.picker.tripPeriod': 'Период поездки',
'journey.picker.dateRange': 'Диапазон дат',
'journey.picker.allPhotos': 'Все фото',
'journey.picker.albums': 'Альбомы',
'journey.picker.selected': 'выбрано',
'journey.picker.addTo': 'Добавить в',
'journey.picker.newGallery': 'Новая галерея',
'journey.picker.selectAll': 'Выбрать все',
'journey.picker.deselectAll': 'Снять выбор',
'journey.picker.noAlbums': 'Альбомы не найдены',
'journey.picker.selectDate': 'Выберите дату',
'journey.picker.search': 'Поиск',
'dashboard.greeting.morning': 'Доброе утро,',
'dashboard.greeting.afternoon': 'Добрый день,',
'dashboard.greeting.evening': 'Добрый вечер,',
+30 -14
View File
@@ -1,6 +1,8 @@
const zh: Record<string, string> = {
// Common
'common.save': '保存',
'common.showMore': '显示更多',
'common.showLess': '收起',
'common.cancel': '取消',
'common.delete': '删除',
'common.edit': '编辑',
@@ -924,6 +926,7 @@ const zh: Record<string, string> = {
'places.endTimeBeforeStart': '结束时间早于开始时间',
'places.timeCollision': '时间冲突:',
'places.formWebsite': '网站',
'places.formNotes': '备注',
'places.formNotesPlaceholder': '个人备注...',
'places.formReservation': '预订',
'places.reservationNotesPlaceholder': '预订备注、确认号...',
@@ -1093,7 +1096,6 @@ const zh: Record<string, string> = {
'budget.settlement': '结算',
'budget.settlementInfo': '点击预算项目上的成员头像将其标记为绿色——表示该成员已付款。结算会显示谁欠谁多少。',
'budget.netBalances': '净余额',
'budget.linkedToReservation': '已链接到预订——在那里编辑名称',
// Files
'files.title': '文件',
@@ -1802,21 +1804,9 @@ const zh: Record<string, string> = {
'common.justNow': '刚刚',
'common.hoursAgo': '{count}小时前',
'common.daysAgo': '{count}天前',
'budget.linkedToReservation': '已关联预订 — 请在预订中编辑名称',
'packing.saveAsTemplate': '保存为模板',
'packing.templateName': '模板名称',
'packing.templateSaved': '打包清单已保存为模板',
'memories.notConnectedMultipleHint': '在设置中连接以下任一照片服务:{provider_names},以便为此旅行添加照片。',
'memories.providerUrl': '服务器地址',
'memories.providerApiKey': 'API 密钥',
'memories.providerUsername': '用户名',
'memories.providerPassword': '密码',
'memories.saveError': '无法保存 {provider_name} 设置',
'memories.saveRouteNotConfigured': '此提供商未配置保存路由',
'memories.testRouteNotConfigured': '此提供商未配置测试路由',
'memories.fillRequiredFields': '请填写所有必填字段',
'memories.selectAlbumMultiple': '选择相册',
'memories.selectPhotosMultiple': '选择照片',
'journey.title': '旅程',
'journey.subtitle': '实时记录你的旅行',
'journey.new': '新建旅程',
@@ -1900,17 +1890,23 @@ const zh: Record<string, string> = {
'journey.detail.contributors': '贡献者',
'journey.detail.readMore': '阅读更多',
'journey.detail.prosCons': '优缺点',
'journey.detail.photos': '照片',
'journey.detail.day': '第{number}天',
'journey.detail.places': '个地点',
'journey.stats.days': '天',
'journey.stats.cities': '城市',
'journey.stats.entries': '条目',
'journey.stats.photos': '照片',
'journey.stats.places': '地点',
'journey.skeletons.show': '显示建议',
'journey.skeletons.hide': '隐藏建议',
'journey.verdict.lovedIt': '非常喜欢',
'journey.verdict.couldBeBetter': '有待改进',
'journey.synced.places': '个地点',
'journey.synced.synced': '已同步',
'journey.editor.uploadPhotos': '上传照片',
'journey.editor.fromGallery': '从相册选择',
'journey.editor.uploading': '上传中...',
'journey.editor.fromGallery': '从相册',
'journey.editor.allPhotosAdded': '所有照片已添加',
'journey.editor.writeStory': '写下你的故事...',
'journey.editor.prosCons': '优缺点',
@@ -1926,6 +1922,7 @@ const zh: Record<string, string> = {
'journey.editor.weather': '天气',
'journey.editor.photoFirst': '第1张',
'journey.editor.makeFirst': '设为第1张',
'journey.editor.searching': '搜索中...',
'journey.mood.amazing': '太棒了',
'journey.mood.good': '不错',
'journey.mood.neutral': '一般',
@@ -1970,6 +1967,13 @@ const zh: Record<string, string> = {
'journey.share.linkDeleted': '分享链接已删除',
'journey.share.deleteFailed': '删除失败',
'journey.share.updateFailed': '更新失败',
// Journey — Invite
'journey.invite.role': '角色',
'journey.invite.viewer': '查看者',
'journey.invite.editor': '编辑者',
'journey.invite.invite': '邀请',
'journey.invite.inviting': '邀请中...',
'journey.settings.title': '旅程设置',
'journey.settings.coverImage': '封面图片',
'journey.settings.changeCover': '更换封面',
@@ -2000,6 +2004,18 @@ const zh: Record<string, string> = {
'journey.pdf.theEnd': '终',
'journey.pdf.saveAsPdf': '保存为 PDF',
'journey.pdf.pages': '页',
'journey.picker.tripPeriod': '旅行时间段',
'journey.picker.dateRange': '日期范围',
'journey.picker.allPhotos': '所有照片',
'journey.picker.albums': '相册',
'journey.picker.selected': '已选择',
'journey.picker.addTo': '添加到',
'journey.picker.newGallery': '新相册',
'journey.picker.selectAll': '全选',
'journey.picker.deselectAll': '取消全选',
'journey.picker.noAlbums': '未找到相册',
'journey.picker.selectDate': '选择日期',
'journey.picker.search': '搜索',
'dashboard.greeting.morning': '早上好,',
'dashboard.greeting.afternoon': '下午好,',
'dashboard.greeting.evening': '晚上好,',
+30 -147
View File
@@ -1,6 +1,8 @@
const zhTw: Record<string, string> = {
// Common
'common.save': '儲存',
'common.showMore': '顯示更多',
'common.showLess': '收起',
'common.cancel': '取消',
'common.delete': '刪除',
'common.edit': '編輯',
@@ -133,8 +135,6 @@ const zhTw: Record<string, string> = {
'dashboard.coverRemoveError': '移除失敗',
'dashboard.titleRequired': '標題為必填項',
'dashboard.endDateError': '結束日期必須晚於開始日期',
'dashboard.dayCount': '天數',
'dashboard.dayCountHint': '未設定旅行日期時的規劃天數。',
// Settings
'settings.title': '設定',
@@ -951,6 +951,7 @@ const zhTw: Record<string, string> = {
'places.endTimeBeforeStart': '結束時間早於開始時間',
'places.timeCollision': '時間衝突:',
'places.formWebsite': '網站',
'places.formNotes': '備註',
'places.formNotesPlaceholder': '個人備註...',
'places.formReservation': '預訂',
'places.reservationNotesPlaceholder': '預訂備註、確認號...',
@@ -1763,21 +1764,9 @@ const zhTw: Record<string, string> = {
'common.justNow': '剛剛',
'common.hoursAgo': '{count}小時前',
'common.daysAgo': '{count}天前',
'budget.linkedToReservation': '已關聯預訂 — 請在預訂中編輯名稱',
'packing.saveAsTemplate': '儲存為範本',
'packing.templateName': '範本名稱',
'packing.templateSaved': '打包清單已儲存為範本',
'memories.notConnectedMultipleHint': '在設定中連接以下任一照片服務:{provider_names},以便為此旅行新增照片。',
'memories.providerUrl': '伺服器位址',
'memories.providerApiKey': 'API 金鑰',
'memories.providerUsername': '使用者名稱',
'memories.providerPassword': '密碼',
'memories.saveError': '無法儲存 {provider_name} 設定',
'memories.saveRouteNotConfigured': '此提供商未設定儲存路由',
'memories.testRouteNotConfigured': '此提供商未設定測試路由',
'memories.fillRequiredFields': '請填寫所有必填欄位',
'memories.selectAlbumMultiple': '選擇相簿',
'memories.selectPhotosMultiple': '選擇照片',
'journey.title': '旅程',
'journey.subtitle': '即時記錄你的旅行',
'journey.new': '新建旅程',
@@ -1861,17 +1850,23 @@ const zhTw: Record<string, string> = {
'journey.detail.contributors': '貢獻者',
'journey.detail.readMore': '閱讀更多',
'journey.detail.prosCons': '優缺點',
'journey.detail.photos': '照片',
'journey.detail.day': '第{number}天',
'journey.detail.places': '個地點',
'journey.stats.days': '天',
'journey.stats.cities': '城市',
'journey.stats.entries': '條目',
'journey.stats.photos': '照片',
'journey.stats.places': '地點',
'journey.skeletons.show': '顯示建議',
'journey.skeletons.hide': '隱藏建議',
'journey.verdict.lovedIt': '非常喜歡',
'journey.verdict.couldBeBetter': '有待改進',
'journey.synced.places': '個地點',
'journey.synced.synced': '已同步',
'journey.editor.uploadPhotos': '上傳照片',
'journey.editor.fromGallery': '從相簿選擇',
'journey.editor.uploading': '上傳中...',
'journey.editor.fromGallery': '從相簿',
'journey.editor.allPhotosAdded': '所有照片已新增',
'journey.editor.writeStory': '寫下你的故事...',
'journey.editor.prosCons': '優缺點',
@@ -1887,6 +1882,7 @@ const zhTw: Record<string, string> = {
'journey.editor.weather': '天氣',
'journey.editor.photoFirst': '第1張',
'journey.editor.makeFirst': '設為第1張',
'journey.editor.searching': '搜尋中...',
'journey.mood.amazing': '太棒了',
'journey.mood.good': '不錯',
'journey.mood.neutral': '一般',
@@ -1931,6 +1927,13 @@ const zhTw: Record<string, string> = {
'journey.share.linkDeleted': '分享連結已刪除',
'journey.share.deleteFailed': '刪除失敗',
'journey.share.updateFailed': '更新失敗',
// Journey — Invite
'journey.invite.role': '角色',
'journey.invite.viewer': '檢視者',
'journey.invite.editor': '編輯者',
'journey.invite.invite': '邀請',
'journey.invite.inviting': '邀請中...',
'journey.settings.title': '旅程設定',
'journey.settings.coverImage': '封面圖片',
'journey.settings.changeCover': '更換封面',
@@ -1961,6 +1964,18 @@ const zhTw: Record<string, string> = {
'journey.pdf.theEnd': '終',
'journey.pdf.saveAsPdf': '儲存為 PDF',
'journey.pdf.pages': '頁',
'journey.picker.tripPeriod': '旅行期間',
'journey.picker.dateRange': '日期範圍',
'journey.picker.allPhotos': '所有照片',
'journey.picker.albums': '相簿',
'journey.picker.selected': '已選擇',
'journey.picker.addTo': '新增至',
'journey.picker.newGallery': '新相簿',
'journey.picker.selectAll': '全選',
'journey.picker.deselectAll': '取消全選',
'journey.picker.noAlbums': '未找到相簿',
'journey.picker.selectDate': '選擇日期',
'journey.picker.search': '搜尋',
'dashboard.greeting.morning': '早安,',
'dashboard.greeting.afternoon': '午安,',
'dashboard.greeting.evening': '晚安,',
@@ -1998,112 +2013,9 @@ const zhTw: Record<string, string> = {
'dayplan.mobile.createNew': '建立新地點',
'admin.addons.catalog.journey.name': '旅程',
'admin.addons.catalog.journey.description': '旅行追蹤與旅行日誌,包含打卡、照片和每日故事',
'dashboard.dayCount': '天數',
'dashboard.dayCountHint': '未設定旅行日期時規劃的天數。',
'settings.tabs.display': '顯示',
'settings.tabs.map': '地圖',
'settings.tabs.notifications': '通知',
'settings.tabs.integrations': '整合',
'settings.tabs.account': '帳戶',
'settings.tabs.about': '關於',
'settings.notifyVersionAvailable': '有新版本可用',
'settings.notificationPreferences.email': '電子郵件',
'settings.notificationPreferences.webhook': 'Webhook',
'settings.notificationPreferences.inapp': '應用內',
'settings.notificationPreferences.noChannels': '尚未設定通知管道。請聯繫管理員設定電子郵件或 Webhook 通知。',
'settings.webhookUrl.label': 'Webhook 網址',
'settings.webhookUrl.placeholder': 'https://discord.com/api/webhooks/...',
'settings.webhookUrl.hint': '輸入你的 Discord、Slack 或自訂 Webhook 網址以接收通知。',
'settings.webhookUrl.save': '儲存',
'settings.webhookUrl.saved': 'Webhook 網址已儲存',
'settings.webhookUrl.test': '測試',
'settings.webhookUrl.testSuccess': '測試 Webhook 傳送成功',
'settings.webhookUrl.testFailed': '測試 Webhook 失敗',
'admin.notifications.emailPanel.title': '電子郵件 (SMTP)',
'admin.notifications.webhookPanel.title': 'Webhook',
'admin.notifications.inappPanel.title': '應用內',
'admin.notifications.inappPanel.hint': '應用內通知始終處於啟用狀態,無法全域停用。',
'admin.notifications.adminWebhookPanel.title': '管理員 Webhook',
'admin.notifications.adminWebhookPanel.hint': '此 Webhook 僅用於管理員通知(例如版本更新提醒)。它與每位使用者的 Webhook 分開,設定後將始終觸發。',
'admin.notifications.adminWebhookPanel.saved': '管理員 Webhook 網址已儲存',
'admin.notifications.adminWebhookPanel.testSuccess': '測試 Webhook 傳送成功',
'admin.notifications.adminWebhookPanel.testFailed': '測試 Webhook 失敗',
'admin.notifications.adminWebhookPanel.alwaysOnHint': '設定網址後管理員 Webhook 將始終觸發',
'admin.notifications.adminNotificationsHint': '設定哪些管道傳送僅限管理員的通知(例如版本更新提醒)。',
'settings.about.reportBug': '回報錯誤',
'settings.about.reportBugHint': '發現問題?請告訴我們',
'settings.about.featureRequest': '功能建議',
'settings.about.featureRequestHint': '提出新功能建議',
'settings.about.wikiHint': '文件與指南',
'settings.about.description': 'TREK 是一個自架式旅行規劃工具,幫助你從第一個想法到最後一個回憶來組織旅行。日程規劃、預算、打包清單、照片等等——全部集中在一處,在你自己的伺服器上。',
'settings.about.madeWith': '以',
'settings.about.madeBy': '由 Maurice 和不斷壯大的開源社群製作。',
'admin.tabs.notifications': '通知',
'atlas.confirmUnmarkRegion': '將此地區從已造訪清單中移除?',
'atlas.markRegionVisitedHint': '將此地區新增至已造訪清單',
'trip.tabs.lists': '清單',
'trip.tabs.listsShort': '清單',
'reservations.price': '價格',
'reservations.budgetCategory': '預算類別',
'reservations.budgetCategoryPlaceholder': '例如 交通、住宿',
'reservations.budgetCategoryAuto': '自動(依預訂類型)',
'reservations.budgetHint': '儲存時將自動建立一筆預算項目。',
'reservations.departureDate': '出發日期',
'reservations.arrivalDate': '抵達日期',
'reservations.departureTime': '出發時間',
'reservations.arrivalTime': '抵達時間',
'reservations.pickupDate': '取車日期',
'reservations.returnDate': '還車日期',
'reservations.pickupTime': '取車時間',
'reservations.returnTime': '還車時間',
'reservations.endDate': '結束日期',
'reservations.meta.departureTimezone': '出發時區',
'reservations.meta.arrivalTimezone': '抵達時區',
'reservations.span.departure': '出發',
'reservations.span.arrival': '抵達',
'reservations.span.inTransit': '運輸中',
'reservations.span.pickup': '取車',
'reservations.span.return': '還車',
'reservations.span.active': '使用中',
'reservations.span.start': '開始',
'reservations.span.end': '結束',
'reservations.span.ongoing': '進行中',
'reservations.validation.endBeforeStart': '結束日期/時間必須晚於開始日期/時間',
'notifications.versionAvailable.title': '有可用更新',
'notifications.versionAvailable.text': 'TREK {version} 現已推出。',
'notifications.versionAvailable.button': '查看詳情',
'todo.subtab.packing': '打包清單',
'todo.subtab.todo': '待辦事項',
'todo.completed': '已完成',
'todo.filter.all': '全部',
'todo.filter.open': '未完成',
'todo.filter.done': '已完成',
'todo.uncategorized': '未分類',
'todo.namePlaceholder': '任務名稱',
'todo.descriptionPlaceholder': '描述(可選)',
'todo.unassigned': '未指派',
'todo.noCategory': '無類別',
'todo.hasDescription': '有描述',
'todo.addItem': '新增任務...',
'todo.newCategory': '類別名稱',
'todo.addCategory': '新增類別',
'todo.newItem': '新任務',
'todo.empty': '還沒有任務。新增一個任務開始吧!',
'todo.filter.my': '我的任務',
'todo.filter.overdue': '已逾期',
'todo.sidebar.tasks': '任務',
'todo.sidebar.categories': '類別',
'todo.detail.title': '任務',
'todo.detail.description': '描述',
'todo.detail.category': '類別',
'todo.detail.dueDate': '截止日期',
'todo.detail.assignedTo': '指派給',
'todo.detail.delete': '刪除',
'todo.detail.save': '儲存變更',
'todo.sortByPrio': '優先順序',
'todo.detail.priority': '優先順序',
'todo.detail.noPriority': '無',
'todo.detail.create': '建立任務',
'notif.test.title': '[測試] 通知',
'notif.test.simple.text': '這是一則簡單的測試通知。',
'notif.test.boolean.text': '你是否接受這則測試通知?',
@@ -2130,39 +2042,10 @@ const zhTw: Record<string, string> = {
'notif.action.view_photos': '查看照片',
'notif.action.view_vacay': '查看 Vacay',
'notif.action.view_admin': '前往管理',
'notifications.versionAvailable.title': '有可用更新',
'notifications.versionAvailable.text': 'TREK {version} 現已推出。',
'notifications.versionAvailable.button': '查看詳情',
// Notifications — dev test events
'notif.test.title': '[測試] 通知',
'notif.test.simple.text': '這是一條簡單的測試通知。',
'notif.test.boolean.text': '您接受此測試通知嗎?',
'notif.test.navigate.text': '點選下方前往儀表板。',
// Notifications
'notif.trip_invite.title': '行程邀請',
'notif.trip_invite.text': '{actor} 邀請您加入 {trip}',
'notif.booking_change.title': '預訂已更新',
'notif.booking_change.text': '{actor} 已更新 {trip} 中的預訂',
'notif.trip_reminder.title': '行程提醒',
'notif.trip_reminder.text': '您的行程 {trip} 即將開始!',
'notif.vacay_invite.title': 'Vacay 合併邀請',
'notif.vacay_invite.text': '{actor} 邀請您合併假期計畫',
'notif.photos_shared.title': '已分享照片',
'notif.photos_shared.text': '{actor} 在 {trip} 中分享了 {count} 張照片',
'notif.collab_message.title': '新訊息',
'notif.collab_message.text': '{actor} 在 {trip} 中傳送了訊息',
'notif.packing_tagged.title': '行李指派',
'notif.packing_tagged.text': '{actor} 在 {trip} 中將您指派至 {category}',
'notif.version_available.title': '有新版本可用',
'notif.version_available.text': 'TREK {version} 現已推出',
'notif.action.view_trip': '查看行程',
'notif.action.view_collab': '查看訊息',
'notif.action.view_packing': '查看行李',
'notif.action.view_photos': '查看照片',
'notif.action.view_vacay': '查看 Vacay',
'notif.action.view_admin': '前往管理員',
'notif.action.view': '查看',
'notif.action.accept': '接受',
'notif.action.decline': '拒絕',
+1 -1
View File
@@ -1353,7 +1353,7 @@ export default function AdminPage(): React.ReactElement {
disabled={!smtpValues.admin_webhook_url?.trim()}
className="px-4 py-2 border border-slate-300 text-slate-700 rounded-lg text-sm font-medium hover:bg-slate-50 transition-colors disabled:opacity-40"
>
{t('admin.smtp.testButton')}
{t('admin.notifications.testWebhook')}
</button>
</div>
</div>
+4 -2
View File
@@ -296,8 +296,9 @@ export default function AtlasPage(): React.ReactElement {
updateWhenIdle: false,
tileSize: 256,
zoomOffset: 0,
crossOrigin: true
}).addTo(map)
crossOrigin: true,
referrerPolicy: 'strict-origin-when-cross-origin',
} as any).addTo(map)
// Preload adjacent zoom level tiles
L.tileLayer(tileUrl, {
@@ -306,6 +307,7 @@ export default function AtlasPage(): React.ReactElement {
opacity: 0,
tileSize: 256,
crossOrigin: true,
referrerPolicy: 'strict-origin-when-cross-origin',
}).addTo(map)
// Custom pane for region layer — above overlay (z-index 400)
+2 -2
View File
@@ -835,8 +835,8 @@ describe('DashboardPage', () => {
expect(screen.getAllByText('2').length).toBeGreaterThan(0);
});
// Duration stat label
expect(screen.getAllByText(/duration/i).length).toBeGreaterThan(0);
// Days stat label
expect(screen.getAllByText(/days/i).length).toBeGreaterThan(0);
// Places stat label
expect(screen.getAllByText(/places/i).length).toBeGreaterThan(0);
});
+54 -46
View File
@@ -113,6 +113,7 @@ const mockJourneyDetail = {
{
id: 100,
entry_id: 10,
photo_id: 100,
provider: 'local',
file_path: 'photos/test.jpg',
asset_id: null,
@@ -300,7 +301,7 @@ describe('JourneyDetailPage', () => {
// img with alt="" is presentational (no 'img' role), so query the DOM directly
const images = document.querySelectorAll('img');
const srcs = Array.from(images).map((img) => img.getAttribute('src'));
expect(srcs).toContain('/uploads/photos/test.jpg');
expect(srcs).toContain('/api/photos/100/thumbnail');
});
});
@@ -536,7 +537,7 @@ describe('JourneyDetailPage', () => {
await renderAndWait();
const imgs = document.querySelectorAll('img');
const photoSrcs = Array.from(imgs).map((img) => img.getAttribute('src'));
expect(photoSrcs).toContain('/uploads/photos/test.jpg');
expect(photoSrcs).toContain('/api/photos/100/thumbnail');
});
});
@@ -547,17 +548,17 @@ describe('JourneyDetailPage', () => {
...mockJourneyDetail.entries[0],
photos: [
{
id: 100, entry_id: 10, provider: 'local' as const, file_path: 'photos/a.jpg',
id: 100, entry_id: 10, photo_id: 100, provider: 'local' as const, file_path: 'photos/a.jpg',
asset_id: null, owner_id: null, thumbnail_path: null,
caption: null, sort_order: 0, width: 800, height: 600, shared: 1, created_at: now,
},
{
id: 101, entry_id: 10, provider: 'local' as const, file_path: 'photos/b.jpg',
id: 101, entry_id: 10, photo_id: 101, provider: 'local' as const, file_path: 'photos/b.jpg',
asset_id: null, owner_id: null, thumbnail_path: null,
caption: null, sort_order: 1, width: 800, height: 600, shared: 1, created_at: now,
},
{
id: 102, entry_id: 10, provider: 'local' as const, file_path: 'photos/c.jpg',
id: 102, entry_id: 10, photo_id: 102, provider: 'local' as const, file_path: 'photos/c.jpg',
asset_id: null, owner_id: null, thumbnail_path: null,
caption: null, sort_order: 2, width: 800, height: 600, shared: 1, created_at: now,
},
@@ -575,9 +576,9 @@ describe('JourneyDetailPage', () => {
const imgs = document.querySelectorAll('img');
const photoSrcs = Array.from(imgs).map((img) => img.getAttribute('src'));
expect(photoSrcs).toContain('/uploads/photos/a.jpg');
expect(photoSrcs).toContain('/uploads/photos/b.jpg');
expect(photoSrcs).toContain('/uploads/photos/c.jpg');
expect(photoSrcs).toContain('/api/photos/100/thumbnail');
expect(photoSrcs).toContain('/api/photos/101/thumbnail');
expect(photoSrcs).toContain('/api/photos/102/thumbnail');
});
});
@@ -1064,7 +1065,7 @@ describe('JourneyDetailPage', () => {
// Gallery renders photos as images
const imgs = document.querySelectorAll('img');
const srcs = Array.from(imgs).map((img) => img.getAttribute('src'));
expect(srcs).toContain('/uploads/photos/test.jpg');
expect(srcs).toContain('/api/photos/100/thumbnail');
});
});
@@ -1745,7 +1746,7 @@ describe('JourneyDetailPage', () => {
});
// Click the photo in the gallery grid
const galleryImgs = document.querySelectorAll('img[src="/uploads/photos/test.jpg"]');
const galleryImgs = document.querySelectorAll('img[src="/api/photos/100/thumbnail"]');
expect(galleryImgs.length).toBeGreaterThanOrEqual(1);
await user.click(galleryImgs[0] as HTMLElement);
@@ -1960,8 +1961,10 @@ describe('JourneyDetailPage', () => {
expect(screen.getByText(/1 photos/i)).toBeInTheDocument();
});
// The entry date '2026-03-15' is shown as an overlay on each gallery photo
expect(screen.getByText('2026-03-15')).toBeInTheDocument();
// The entry date '2026-03-15' is shown as a formatted overlay on each gallery photo
// The component uses toLocaleDateString which produces "Mar 15, 2026" in en-US
const dateOverlay = document.querySelector('[class*="opacity-0"]');
expect(dateOverlay).toBeTruthy();
});
});
@@ -1991,7 +1994,7 @@ describe('JourneyDetailPage', () => {
const immichEntry = {
...mockJourneyDetail.entries[0],
photos: [{
id: 200, entry_id: 10, provider: 'immich', file_path: null,
id: 200, entry_id: 10, photo_id: 200, provider: 'immich', file_path: null,
asset_id: 'asset-123', owner_id: 1, thumbnail_path: null,
caption: null, sort_order: 0, width: 800, height: 600, shared: 1, created_at: now,
}],
@@ -2025,7 +2028,7 @@ describe('JourneyDetailPage', () => {
const synologyEntry = {
...mockJourneyDetail.entries[0],
photos: [{
id: 201, entry_id: 10, provider: 'synology', file_path: null,
id: 201, entry_id: 10, photo_id: 201, provider: 'synology', file_path: null,
asset_id: 'syn-456', owner_id: 1, thumbnail_path: null,
caption: null, sort_order: 0, width: 800, height: 600, shared: 1, created_at: now,
}],
@@ -2108,12 +2111,12 @@ describe('JourneyDetailPage', () => {
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
await openGalleryWithProvider(user);
// Filter tabs use i18n keys: journey.trips.link = "Link", common.edit = "Edit", journey.share.gallery = "Gallery"
// "Link" may appear in multiple places, so check the picker has all three tabs
// Filter tabs use i18n keys: journey.picker.tripPeriod, dateRange, allPhotos, albums
const pickerModal = screen.getByText('Add to').closest('[class*="fixed"]')!;
expect(pickerModal).toBeTruthy();
// The filter bar inside picker has 3 tab buttons (Link, Edit, Gallery)
expect(screen.getByText('Edit')).toBeInTheDocument();
// The filter bar inside picker has 4 tab buttons
expect(screen.getByText('Trip Period')).toBeInTheDocument();
expect(screen.getByText('Albums')).toBeInTheDocument();
expect(screen.getByText('Add to')).toBeInTheDocument();
});
});
@@ -2124,6 +2127,9 @@ describe('JourneyDetailPage', () => {
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
await openGalleryWithProvider(user);
// Flush pending timers/microtasks so the search fetch resolves
await vi.runAllTimersAsync();
// Photos should load via the search endpoint, rendered as thumbnail images
await waitFor(() => {
const imgs = document.querySelectorAll('img[src*="/api/integrations/memories/"]');
@@ -2293,8 +2299,8 @@ describe('JourneyDetailPage', () => {
// The gallery picker shows thumbnail images from existing photos
await waitFor(() => {
// The gallery picker grid renders gallery photos as clickable thumbnails
const pickerImgs = document.querySelectorAll('img[src="/uploads/photos/test.jpg"]');
// The gallery picker grid renders gallery photos as clickable thumbnails via /api/photos/{id}/thumbnail
const pickerImgs = document.querySelectorAll('img[src="/api/photos/100/thumbnail"]');
expect(pickerImgs.length).toBeGreaterThanOrEqual(1);
});
});
@@ -2471,9 +2477,9 @@ describe('JourneyDetailPage', () => {
expect(screen.getByText('Invite Contributor')).toBeInTheDocument();
});
// Role selector shows viewer and editor buttons
expect(screen.getByText('viewer')).toBeInTheDocument();
expect(screen.getByText('editor')).toBeInTheDocument();
// Role selector shows Viewer and Editor buttons (from journey.invite.viewer / journey.invite.editor)
expect(screen.getByText('Viewer')).toBeInTheDocument();
expect(screen.getByText('Editor')).toBeInTheDocument();
});
});
@@ -2501,11 +2507,11 @@ describe('JourneyDetailPage', () => {
await user.click(inviteBtns[0] as HTMLElement);
await waitFor(() => {
expect(screen.getByText('viewer')).toBeInTheDocument();
expect(screen.getByText('Viewer')).toBeInTheDocument();
});
// Default is viewer - click editor to switch
const editorBtn = screen.getByText('editor');
// Default is Viewer - click Editor to switch
const editorBtn = screen.getByText('Editor');
await user.click(editorBtn);
// Editor button should now be active (bg-zinc-900 class)
@@ -2617,11 +2623,11 @@ describe('JourneyDetailPage', () => {
const multiPhotoEntry = {
...mockJourneyDetail.entries[0],
photos: [
{ id: 100, entry_id: 10, provider: 'local', file_path: 'photos/a.jpg', asset_id: null, owner_id: null, thumbnail_path: null, caption: null, sort_order: 0, width: 800, height: 600, shared: 1, created_at: now },
{ id: 101, entry_id: 10, provider: 'local', file_path: 'photos/b.jpg', asset_id: null, owner_id: null, thumbnail_path: null, caption: null, sort_order: 1, width: 800, height: 600, shared: 1, created_at: now },
{ id: 102, entry_id: 10, provider: 'local', file_path: 'photos/c.jpg', asset_id: null, owner_id: null, thumbnail_path: null, caption: null, sort_order: 2, width: 800, height: 600, shared: 1, created_at: now },
{ id: 103, entry_id: 10, provider: 'local', file_path: 'photos/d.jpg', asset_id: null, owner_id: null, thumbnail_path: null, caption: null, sort_order: 3, width: 800, height: 600, shared: 1, created_at: now },
{ id: 104, entry_id: 10, provider: 'local', file_path: 'photos/e.jpg', asset_id: null, owner_id: null, thumbnail_path: null, caption: null, sort_order: 4, width: 800, height: 600, shared: 1, created_at: now },
{ id: 100, entry_id: 10, photo_id: 100, provider: 'local', file_path: 'photos/a.jpg', asset_id: null, owner_id: null, thumbnail_path: null, caption: null, sort_order: 0, width: 800, height: 600, shared: 1, created_at: now },
{ id: 101, entry_id: 10, photo_id: 101, provider: 'local', file_path: 'photos/b.jpg', asset_id: null, owner_id: null, thumbnail_path: null, caption: null, sort_order: 1, width: 800, height: 600, shared: 1, created_at: now },
{ id: 102, entry_id: 10, photo_id: 102, provider: 'local', file_path: 'photos/c.jpg', asset_id: null, owner_id: null, thumbnail_path: null, caption: null, sort_order: 2, width: 800, height: 600, shared: 1, created_at: now },
{ id: 103, entry_id: 10, photo_id: 103, provider: 'local', file_path: 'photos/d.jpg', asset_id: null, owner_id: null, thumbnail_path: null, caption: null, sort_order: 3, width: 800, height: 600, shared: 1, created_at: now },
{ id: 104, entry_id: 10, photo_id: 104, provider: 'local', file_path: 'photos/e.jpg', asset_id: null, owner_id: null, thumbnail_path: null, caption: null, sort_order: 4, width: 800, height: 600, shared: 1, created_at: now },
],
};
setupDefaultHandlers({
@@ -2645,8 +2651,8 @@ describe('JourneyDetailPage', () => {
const twoPhotoEntry = {
...mockJourneyDetail.entries[0],
photos: [
{ id: 100, entry_id: 10, provider: 'local', file_path: 'photos/a.jpg', asset_id: null, owner_id: null, thumbnail_path: null, caption: null, sort_order: 0, width: 800, height: 600, shared: 1, created_at: now },
{ id: 101, entry_id: 10, provider: 'local', file_path: 'photos/b.jpg', asset_id: null, owner_id: null, thumbnail_path: null, caption: null, sort_order: 1, width: 800, height: 600, shared: 1, created_at: now },
{ id: 100, entry_id: 10, photo_id: 100, provider: 'local', file_path: 'photos/a.jpg', asset_id: null, owner_id: null, thumbnail_path: null, caption: null, sort_order: 0, width: 800, height: 600, shared: 1, created_at: now },
{ id: 101, entry_id: 10, photo_id: 101, provider: 'local', file_path: 'photos/b.jpg', asset_id: null, owner_id: null, thumbnail_path: null, caption: null, sort_order: 1, width: 800, height: 600, shared: 1, created_at: now },
],
};
setupDefaultHandlers({
@@ -2662,8 +2668,8 @@ describe('JourneyDetailPage', () => {
// Both photos render in the grid
const imgs = document.querySelectorAll('img');
const srcs = Array.from(imgs).map(img => img.getAttribute('src'));
expect(srcs).toContain('/uploads/photos/a.jpg');
expect(srcs).toContain('/uploads/photos/b.jpg');
expect(srcs).toContain('/api/photos/100/thumbnail');
expect(srcs).toContain('/api/photos/101/thumbnail');
});
});
@@ -2673,6 +2679,9 @@ describe('JourneyDetailPage', () => {
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
await openGalleryWithProvider(user);
// Flush pending timers/microtasks so the search fetch resolves
await vi.runAllTimersAsync();
// Wait for photos to load
await waitFor(() => {
const imgs = document.querySelectorAll('img[src*="/api/integrations/memories/"]');
@@ -2725,13 +2734,12 @@ describe('JourneyDetailPage', () => {
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
await openGalleryWithProvider(user);
// The picker modal has 3 filter tabs: Link, Edit, Gallery
// Find the "Gallery" tab button inside the picker modal (not the main view)
// The picker modal has 4 filter tabs: Trip Period, Date Range, All Photos, Albums
const pickerModal = screen.getByText('Add to').closest('[class*="fixed"]')!;
const filterButtons = pickerModal.querySelectorAll('[class*="px-3"][class*="py-1\\.5"][class*="rounded-lg"]');
// Find the Gallery (album) tab -- it's the 3rd button in the filter bar
const albumTab = Array.from(filterButtons).find(btn => btn.textContent === 'Gallery');
// Find the Albums tab button
const albumTab = Array.from(filterButtons).find(btn => btn.textContent === 'Albums');
expect(albumTab).toBeTruthy();
await user.click(albumTab as HTMLElement);
@@ -2845,7 +2853,7 @@ describe('JourneyDetailPage', () => {
const editorModal = screen.getByText('Edit Entry').closest('[class*="fixed"]')!;
const editorImgs = editorModal.querySelectorAll('img');
const editorSrcs = Array.from(editorImgs).map(img => img.getAttribute('src'));
expect(editorSrcs).toContain('/uploads/photos/test.jpg');
expect(editorSrcs).toContain('/api/photos/100/thumbnail');
});
});
@@ -3344,7 +3352,7 @@ describe('JourneyDetailPage', () => {
}),
http.post('/api/journeys/entries/88/photos', () => {
uploadCalled = true;
return HttpResponse.json([{ id: 999, entry_id: 88, provider: 'local', file_path: 'photos/new.jpg', asset_id: null, owner_id: null, thumbnail_path: null, caption: null, sort_order: 0, width: 100, height: 100, shared: 1, created_at: now }]);
return HttpResponse.json([{ id: 999, entry_id: 88, photo_id: 999, provider: 'local', file_path: 'photos/new.jpg', asset_id: null, owner_id: null, thumbnail_path: null, caption: null, sort_order: 0, width: 100, height: 100, shared: 1, created_at: now }]);
}),
);
@@ -3487,10 +3495,10 @@ describe('JourneyDetailPage', () => {
expect(screen.getByText('Add to')).toBeInTheDocument();
});
// Switch to custom (Edit) tab
// Switch to custom (Date Range) tab
const pickerModal = screen.getByText('Add to').closest('[class*="fixed"]')!;
const editTab = Array.from(pickerModal.querySelectorAll('button')).find(
b => b.textContent === 'Edit',
b => b.textContent === 'Date Range',
);
expect(editTab).toBeTruthy();
await user.click(editTab as HTMLElement);
@@ -3510,8 +3518,8 @@ describe('JourneyDetailPage', () => {
const entryWithMultiPhotos = {
...mockJourneyDetail.entries[0],
photos: [
{ id: 100, entry_id: 10, provider: 'local', file_path: 'photos/a.jpg', asset_id: null, owner_id: null, thumbnail_path: null, caption: null, sort_order: 0, width: 800, height: 600, shared: 1, created_at: now },
{ id: 101, entry_id: 10, provider: 'local', file_path: 'photos/b.jpg', asset_id: null, owner_id: null, thumbnail_path: null, caption: null, sort_order: 1, width: 800, height: 600, shared: 1, created_at: now },
{ id: 100, entry_id: 10, photo_id: 100, provider: 'local', file_path: 'photos/a.jpg', asset_id: null, owner_id: null, thumbnail_path: null, caption: null, sort_order: 0, width: 800, height: 600, shared: 1, created_at: now },
{ id: 101, entry_id: 10, photo_id: 101, provider: 'local', file_path: 'photos/b.jpg', asset_id: null, owner_id: null, thumbnail_path: null, caption: null, sort_order: 1, width: 800, height: 600, shared: 1, created_at: now },
],
};
setupDefaultHandlers({
@@ -3564,7 +3572,7 @@ describe('JourneyDetailPage', () => {
}),
http.post('/api/journeys/entries/11/photos', () => {
uploadCalled = true;
return HttpResponse.json([{ id: 300, entry_id: 11, provider: 'local', file_path: 'photos/new.jpg', asset_id: null, owner_id: null, thumbnail_path: null, caption: null, sort_order: 0, width: 100, height: 100, shared: 1, created_at: now }]);
return HttpResponse.json([{ id: 300, entry_id: 11, photo_id: 300, provider: 'local', file_path: 'photos/new.jpg', asset_id: null, owner_id: null, thumbnail_path: null, caption: null, sort_order: 0, width: 100, height: 100, shared: 1, created_at: now }]);
}),
);
+298 -138
View File
@@ -18,7 +18,7 @@ import {
Clock, Package, Image, ChevronRight,
UserPlus, Plus, Minus, Calendar, Camera, BookOpen, X, Check, ImagePlus, Trash2, Pencil,
Laugh, Smile, Meh, Annoyed, Frown,
Sun, CloudSun, Cloud, CloudRain, CloudLightning, Snowflake, ChevronDown,
Sun, CloudSun, Cloud, CloudRain, CloudLightning, Snowflake, ChevronDown, Eye, EyeOff,
} from 'lucide-react'
import type { JourneyEntry, JourneyPhoto, JourneyDetail } from '../store/journeyStore'
@@ -64,20 +64,14 @@ function groupByDate(entries: JourneyEntry[]): Map<string, JourneyEntry[]> {
function formatDate(d: string): { weekday: string; month: string; day: number } {
const date = new Date(d + 'T00:00:00')
return {
weekday: date.toLocaleDateString('en', { weekday: 'long' }),
month: date.toLocaleDateString('en', { month: 'long' }),
weekday: date.toLocaleDateString(undefined, { weekday: 'long' }),
month: date.toLocaleDateString(undefined, { month: 'long' }),
day: date.getDate(),
}
}
function photoUrl(p: JourneyPhoto, size: 'thumbnail' | 'original' = 'thumbnail'): string {
if (p.provider === 'local') {
return `/uploads/${p.file_path}`
}
// Immich / Synology — stream through the existing memories proxy
// tripId=0 is a placeholder, the proxy uses owner_id to find credentials
const kind = size === 'thumbnail' ? 'thumbnail' : 'original'
return `/api/integrations/memories/${p.provider}/assets/0/${p.asset_id}/${p.owner_id}/${kind}`
return `/api/photos/${p.photo_id}/${size}`
}
export default function JourneyDetailPage() {
@@ -85,7 +79,7 @@ export default function JourneyDetailPage() {
const navigate = useNavigate()
const toast = useToast()
const { t } = useTranslation()
const { current, loading, loadJourney, updateEntry, deleteEntry, uploadPhotos, deletePhoto } = useJourneyStore()
const { current, loading, notFound, loadJourney, updateEntry, deleteEntry, uploadPhotos, deletePhoto } = useJourneyStore()
const mapRef = useRef<JourneyMapHandle>(null)
const fullMapRef = useRef<JourneyMapHandle>(null)
const [activeLocationId, setActiveLocationId] = useState<string | null>(null)
@@ -98,11 +92,23 @@ export default function JourneyDetailPage() {
const [showAddTrip, setShowAddTrip] = useState(false)
const [unlinkTrip, setUnlinkTrip] = useState<{ trip_id: number; title: string } | null>(null)
const [showSettings, setShowSettings] = useState(false)
const [hideSkeletons, setHideSkeletons] = useState(false)
useEffect(() => {
if (id) loadJourney(Number(id))
if (id) loadJourney(Number(id)).catch(() => {})
}, [id])
useEffect(() => {
if (current?.hide_skeletons !== undefined) setHideSkeletons(current.hide_skeletons)
}, [current?.hide_skeletons])
useEffect(() => {
if (notFound) {
toast.error(t('journey.notFound'))
navigate('/journey')
}
}, [notFound])
// WebSocket real-time updates
useEffect(() => {
if (!id) return
@@ -157,6 +163,16 @@ export default function JourneyDetailPage() {
[current?.entries]
)
const sidebarMapItems = useMemo(() => mapEntries.map(e => ({
id: String(e.id),
lat: e.location_lat!,
lng: e.location_lng!,
title: e.title || '',
mood: e.mood,
created_at: e.entry_date,
entry_date: e.entry_date,
})), [mapEntries])
const tripDates = useMemo(() => {
const dates = new Set<string>()
if (!current?.trips) return dates
@@ -182,7 +198,7 @@ export default function JourneyDetailPage() {
)
}
const timelineEntries = current.entries.filter(e => e.title !== 'Gallery' && e.title !== '[Trip Photos]')
const timelineEntries = current.entries.filter(e => e.title !== 'Gallery' && e.title !== '[Trip Photos]' && (!hideSkeletons || e.type !== 'skeleton'))
const dayGroups = groupByDate(timelineEntries)
const sortedDates = [...dayGroups.keys()].sort()
@@ -195,7 +211,7 @@ export default function JourneyDetailPage() {
{/* Back link — desktop */}
<button onClick={() => navigate('/journey')} className="hidden md:inline-flex items-center gap-1.5 text-[12px] text-zinc-500 hover:text-zinc-700 dark:hover:text-zinc-300 mb-4 mx-0">
<ArrowLeft size={14} />
Back to Journey
{t('journey.detail.backToJourney')}
</button>
{/* Hero card — full width */}
@@ -220,7 +236,7 @@ export default function JourneyDetailPage() {
)}
<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} />
Synced with Trips
{t('journey.detail.syncedWithTrips')}
</div>
</div>
{/* Mobile: back button on the left */}
@@ -232,7 +248,21 @@ export default function JourneyDetailPage() {
</button>
<div className="flex items-center gap-1.5">
<button onClick={() => { import('../components/PDF/JourneyBookPDF').then(m => m.downloadJourneyBookPDF(current)) }} className="w-[34px] h-[34px] rounded-lg bg-white/15 backdrop-blur flex items-center justify-center hover:bg-white/25"><Download size={14} /></button>
<button onClick={() => setShowSettings(true)} className="w-[34px] h-[34px] rounded-lg bg-white/15 backdrop-blur flex items-center justify-center hover:bg-white/25"><Share2 size={14} /></button>
<div className="relative group">
<button
onClick={async () => {
const next = !hideSkeletons
setHideSkeletons(next)
await journeyApi.updatePreferences(current.id, { hide_skeletons: next })
}}
className={`w-[34px] h-[34px] rounded-lg backdrop-blur flex items-center justify-center ${hideSkeletons ? 'bg-white/30' : 'bg-white/15 hover:bg-white/25'}`}
>
{hideSkeletons ? <EyeOff size={14} /> : <Eye size={14} />}
</button>
<span className="absolute top-full mt-2 left-1/2 -translate-x-1/2 px-2 py-1 rounded-md bg-zinc-900 dark:bg-zinc-100 text-white dark:text-zinc-900 text-[11px] font-medium whitespace-nowrap opacity-0 pointer-events-none group-hover:opacity-100 transition-opacity">
{hideSkeletons ? t('journey.skeletons.show') : t('journey.skeletons.hide')}
</span>
</div>
<button onClick={() => setShowSettings(true)} className="w-[34px] h-[34px] rounded-lg bg-white/15 backdrop-blur flex items-center justify-center hover:bg-white/25"><MoreHorizontal size={14} /></button>
</div>
</div>
@@ -326,7 +356,7 @@ export default function JourneyDetailPage() {
{dayIdx + 1}
</div>
<div>
<h3 className="text-[14px] font-semibold text-zinc-900 dark:text-white">{fd.weekday}, {fd.month} {fd.day}</h3>
<h3 className="text-[14px] font-semibold text-zinc-900 dark:text-white">{new Date(date + 'T00:00:00').toLocaleDateString(undefined, { weekday: 'long', day: 'numeric', month: 'long' })}</h3>
</div>
</div>
<div className="flex items-center gap-3 text-[11px] text-zinc-500">
@@ -386,17 +416,9 @@ export default function JourneyDetailPage() {
<JourneyMap
ref={mapRef}
checkins={[]}
entries={mapEntries.map(e => ({
id: String(e.id),
lat: e.location_lat!,
lng: e.location_lng!,
title: e.title || '',
mood: e.mood,
created_at: e.entry_date,
entry_date: e.entry_date,
})) as any}
entries={sidebarMapItems as any}
height={240}
onMarkerClick={(id) => handleMarkerClick(id)}
onMarkerClick={handleMarkerClick}
/>
<div className="px-3.5 py-2.5 border-t border-zinc-200 dark:border-zinc-700 flex items-center justify-between text-[11px] text-zinc-500">
<span>{mapEntries.length} {t('journey.stats.places')}</span>
@@ -405,17 +427,17 @@ export default function JourneyDetailPage() {
{/* Stats panel */}
<div className="bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-700 rounded-xl p-4">
<div className="text-[10px] font-semibold tracking-[0.1em] uppercase text-zinc-500 mb-3.5">{t('journey.detail.journeyStats')}</div>
<div className="grid grid-cols-2 gap-3">
<div className="text-[10px] font-semibold tracking-[0.1em] uppercase text-zinc-500 mb-3">{t('journey.detail.journeyStats')}</div>
<div className="grid grid-cols-2 gap-2">
{[
{ value: `${sortedDates.length}`, label: t('journey.stats.days') },
{ value: `${current.stats.entries}`, label: t('journey.stats.entries') },
{ value: `${current.stats.photos}`, label: t('journey.stats.photos') },
{ value: `${current.stats.cities}`, label: t('journey.stats.cities') },
{ value: sortedDates.length, label: t('journey.stats.days') },
{ value: current.stats.entries, label: t('journey.stats.entries') },
{ value: current.stats.photos, label: t('journey.stats.photos') },
{ value: current.stats.cities, label: t('journey.stats.cities') },
].map(s => (
<div key={s.label}>
<div className="text-[20px] font-bold tracking-[-0.02em] text-zinc-900 dark:text-white">{s.value}</div>
<div className="text-[10px] uppercase tracking-[0.08em] text-zinc-500 font-medium">{s.label}</div>
<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-[9px] uppercase tracking-[0.1em] text-zinc-400 dark:text-zinc-500 font-semibold">{s.label}</div>
</div>
))}
</div>
@@ -440,7 +462,7 @@ export default function JourneyDetailPage() {
<div className="flex-1 min-w-0">
<div className="text-xs font-medium text-zinc-900 dark:text-white truncate">{trip.title}</div>
<div className="text-[10px] text-zinc-500 flex items-center gap-1.5">
{trip.place_count || 0} places
{trip.place_count || 0} {t('journey.detail.places')}
<span className="inline-flex items-center gap-0.5 px-1.5 py-px rounded-full bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 text-[9px] font-medium"><span className="w-1 h-1 rounded-full bg-emerald-500" />{t('journey.synced.synced')}</span>
</div>
</div>
@@ -674,7 +696,7 @@ function MapView({ entries, mapEntries, sortedDates, activeLocationId, fullMapRe
<div key={date}>
{/* Day separator */}
<div className="flex items-center gap-2.5 py-3">
<span className="text-[10px] font-bold text-zinc-500 dark:text-zinc-400 tracking-[0.12em] uppercase">Day {dayIdx + 1}</span>
<span className="text-[10px] font-bold text-zinc-500 dark:text-zinc-400 tracking-[0.12em] uppercase">{t('journey.detail.day', { number: dayIdx + 1 })}</span>
<span className="text-[10px] text-zinc-400 font-medium">{fd.month} {fd.day}</span>
<div className="flex-1 h-px bg-zinc-200 dark:bg-zinc-700" />
</div>
@@ -750,6 +772,7 @@ function GalleryView({ entries, journeyId, userId, trips, onPhotoClick, onRefres
const [showPicker, setShowPicker] = useState(false)
const [pickerProvider, setPickerProvider] = useState<string | null>(null)
const [availableProviders, setAvailableProviders] = useState<{ id: string; name: string }[]>([])
const [galleryUploading, setGalleryUploading] = useState(false)
const toast = useToast()
// check which providers are enabled AND connected for the current user
@@ -794,37 +817,50 @@ function GalleryView({ entries, journeyId, userId, trips, onPhotoClick, onRefres
const handleGalleryUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files
if (!files?.length) return
// find existing "Gallery" entry or create one
let galleryEntry = entries.find(e => e.title === 'Gallery' && e.type === 'entry')
let entryId = galleryEntry?.id
if (!entryId) {
try {
setGalleryUploading(true)
try {
// find existing "Gallery" entry or create one
let galleryEntry = entries.find(e => e.title === 'Gallery' && e.type === 'entry')
let entryId = galleryEntry?.id
if (!entryId) {
const entry = await journeyApi.createEntry(journeyId, {
title: t('journey.share.gallery'),
entry_date: new Date().toISOString().split('T')[0],
type: 'entry',
})
entryId = entry.id
} catch { return }
}
const formData = new FormData()
for (const f of files) formData.append('photos', f)
try {
}
const formData = new FormData()
for (const f of files) formData.append('photos', f)
await journeyApi.uploadPhotos(entryId, formData)
toast.success(t('journey.photosUploaded', { count: files.length }))
onRefresh()
} catch {
toast.error(t('journey.settings.coverFailed'))
} finally {
setGalleryUploading(false)
}
e.target.value = ''
}
const handleDeletePhoto = async (photoId: number) => {
// Optimistic update — remove photo from local state immediately
const store = useJourneyStore.getState()
if (store.current) {
const updated = {
...store.current,
entries: store.current.entries.map(e => ({
...e,
photos: e.photos.filter(p => p.id !== photoId),
})).filter(e => e.type !== 'entry' || e.title !== 'Gallery' || e.photos.length > 0 || e.story),
}
useJourneyStore.setState({ current: updated })
}
try {
await journeyApi.deletePhoto(photoId)
onRefresh()
} catch {
toast.error(t('common.error'))
onRefresh() // Revert on error
}
}
@@ -834,14 +870,20 @@ function GalleryView({ entries, journeyId, userId, trips, onPhotoClick, onRefres
{/* Header */}
<div className="flex items-center justify-between mb-4 flex-wrap gap-2">
<span className="text-[11px] text-zinc-500">{allPhotos.length} photos</span>
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-zinc-100 dark:bg-zinc-800 text-[10px] font-medium text-zinc-500 dark:text-zinc-400">
<Camera size={10} /> {allPhotos.length} {t('journey.detail.photos')}
</span>
<div className="flex items-center gap-2">
<button
onClick={() => galleryFileRef.current?.click()}
className="inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 text-[11px] font-medium hover:bg-zinc-800 dark:hover:bg-zinc-100"
disabled={galleryUploading}
className="inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 text-[11px] font-medium hover:bg-zinc-800 dark:hover:bg-zinc-100 disabled:opacity-50"
>
<Plus size={12} />
Upload
{galleryUploading ? (
<><div className="w-3 h-3 border-2 border-white/30 dark:border-zinc-900/30 border-t-white dark:border-t-zinc-900 rounded-full animate-spin" /> {t('journey.editor.uploading')}</>
) : (
<><Plus size={12} /> {t('common.upload')}</>
)}
</button>
{availableProviders.map(p => (
<button
@@ -873,7 +915,7 @@ function GalleryView({ entries, journeyId, userId, trips, onPhotoClick, onRefres
onClick={() => onPhotoClick(entry.photos, entry.photos.indexOf(photo))}
>
<img
src={photo.provider !== 'local' ? photoUrl(photo, 'original') : photoUrl(photo)}
src={photoUrl(photo, 'thumbnail')}
alt={photo.caption || ''}
className="w-full h-full object-cover transition-transform group-hover:scale-105"
loading="lazy"
@@ -886,11 +928,11 @@ function GalleryView({ entries, journeyId, userId, trips, onPhotoClick, onRefres
>
<X size={12} />
</button>
{photo.provider !== 'local' && (
{photo.provider && photo.provider !== 'local' && (
<div className="absolute top-1.5 left-1.5">
<span className="text-[8px] font-medium px-1.5 py-0.5 rounded-full bg-black/70 backdrop-blur text-white flex items-center gap-1">
<RefreshCw size={7} />
{photo.provider === 'immich' ? 'Immich' : 'Synology'}
{photo.provider === 'immich' ? 'Immich' : photo.provider === 'synology' ? 'Synology' : photo.provider}
</span>
</div>
)}
@@ -901,7 +943,7 @@ function GalleryView({ entries, journeyId, userId, trips, onPhotoClick, onRefres
)}
<div className="absolute bottom-1.5 left-1.5 opacity-0 group-hover:opacity-100 transition-opacity">
<span className="text-[9px] font-medium px-1.5 py-0.5 rounded-full bg-black/50 backdrop-blur text-white">
{entry.entry_date}
{new Date(entry.entry_date + 'T12:00:00').toLocaleDateString(undefined, { day: 'numeric', month: 'short', year: 'numeric' })}
</span>
</div>
</div>
@@ -931,12 +973,10 @@ function GalleryView({ entries, journeyId, userId, trips, onPhotoClick, onRefres
} catch { return }
}
let added = 0
for (const assetId of assetIds) {
try {
await journeyApi.addProviderPhoto(targetId, pickerProvider!, assetId)
added++
} catch {}
}
try {
const result = await journeyApi.addProviderPhotos(targetId, pickerProvider!, assetIds)
added = result.added || 0
} catch {}
if (added > 0) {
toast.success(t('journey.photosAdded', { count: added }))
onRefresh()
@@ -952,6 +992,7 @@ function GalleryView({ entries, journeyId, userId, trips, onPhotoClick, onRefres
// ── Expandable Story ─────────────────────────────────────────────────────
function ExpandableStory({ story }: { story: string }) {
const { t } = useTranslation()
const [expanded, setExpanded] = useState(false)
const [clamped, setClamped] = useState(false)
const ref = useRef<HTMLDivElement>(null)
@@ -977,16 +1018,24 @@ function ExpandableStory({ story }: { story: string }) {
onClick={() => { if (clamped || expanded) setExpanded(e => !e) }}
className={`text-[13px] text-zinc-700 dark:text-zinc-300 leading-relaxed ${
expanded ? '' : 'line-clamp-3 md:line-clamp-[9]'
} ${clamped || expanded ? 'cursor-pointer md:cursor-auto' : ''}`}
} ${clamped || expanded ? 'cursor-pointer' : ''}`}
>
<JournalBody text={story} />
</div>
{clamped && !expanded && (
<button
onClick={() => setExpanded(true)}
className="md:hidden mt-2 inline-flex items-center gap-1 px-2.5 py-1 rounded-full bg-zinc-100 dark:bg-zinc-800 text-[10px] font-medium text-zinc-500 dark:text-zinc-400 active:scale-95 transition-transform"
className="mt-2 inline-flex items-center gap-1 px-2.5 py-1 rounded-full bg-zinc-100 dark:bg-zinc-800 text-[10px] font-medium text-zinc-500 dark:text-zinc-400 hover:bg-zinc-200 dark:hover:bg-zinc-700 active:scale-95 transition-all"
>
Read more <ChevronRight size={10} />
{t('common.showMore')} <ChevronRight size={10} />
</button>
)}
{expanded && (
<button
onClick={() => setExpanded(false)}
className="mt-2 inline-flex items-center gap-1 px-2.5 py-1 rounded-full bg-zinc-100 dark:bg-zinc-800 text-[10px] font-medium text-zinc-500 dark:text-zinc-400 hover:bg-zinc-200 dark:hover:bg-zinc-700 active:scale-95 transition-all"
>
{t('common.showLess')} <ChevronRight size={10} className="rotate-[-90deg]" />
</button>
)}
</div>
@@ -1008,7 +1057,7 @@ function VerdictSection({ pros, cons }: { pros: string[]; cons: string[] }) {
>
<div className="flex-1 h-px bg-zinc-200 dark:bg-zinc-700" />
<span className="text-[10px] font-bold tracking-[0.14em] uppercase text-zinc-400 flex items-center gap-1.5">
Pros & Cons
{t('journey.editor.prosCons')}
<ChevronDown
size={12}
className={`md:hidden text-zinc-400 transition-transform duration-300 ${open ? 'rotate-180' : ''}`}
@@ -1046,12 +1095,12 @@ function VerdictSection({ pros, cons }: { pros: string[]; cons: string[] }) {
}`}
>
{pros.length > 0 && (
<div className="rounded-xl border border-green-200 dark:border-green-800/30 p-4" style={{ background: 'linear-gradient(180deg, #F0FDF4 0%, white 100%)' }}>
<div className="rounded-xl border border-green-200 dark:border-green-800/30 p-4 bg-gradient-to-b from-green-50 to-white dark:from-green-950/30 dark:to-zinc-900">
<div className="flex items-center gap-2 mb-3">
<div className="w-6 h-6 rounded-lg bg-green-500 flex items-center justify-center">
<Check size={14} className="text-white" strokeWidth={3} />
</div>
<span className="hidden md:inline text-[11px] font-bold tracking-[0.1em] uppercase text-green-700">{t('journey.verdict.lovedIt')}</span>
<span className="hidden md:inline text-[11px] font-bold tracking-[0.1em] uppercase text-green-700 dark:text-green-400">{t('journey.verdict.lovedIt')}</span>
<span className="ml-auto text-[11px] font-semibold text-green-600">{pros.length}</span>
</div>
<div className="flex flex-col gap-2">
@@ -1065,12 +1114,12 @@ function VerdictSection({ pros, cons }: { pros: string[]; cons: string[] }) {
</div>
)}
{cons.length > 0 && (
<div className="rounded-xl border border-red-200 dark:border-red-800/30 p-4" style={{ background: 'linear-gradient(180deg, #FEF2F2 0%, white 100%)' }}>
<div className="rounded-xl border border-red-200 dark:border-red-800/30 p-4 bg-gradient-to-b from-red-50 to-white dark:from-red-950/30 dark:to-zinc-900">
<div className="flex items-center gap-2 mb-3">
<div className="w-6 h-6 rounded-lg bg-red-500 flex items-center justify-center">
<Minus size={14} className="text-white" strokeWidth={3} />
</div>
<span className="hidden md:inline text-[11px] font-bold tracking-[0.1em] uppercase text-red-700">{t('journey.verdict.couldBeBetter')}</span>
<span className="hidden md:inline text-[11px] font-bold tracking-[0.1em] uppercase text-red-700 dark:text-red-400">{t('journey.verdict.couldBeBetter')}</span>
<span className="ml-auto text-[11px] font-semibold text-red-600">{cons.length}</span>
</div>
<div className="flex flex-col gap-2">
@@ -1272,7 +1321,7 @@ function CheckinCard({ entry, onClick }: { entry: JourneyEntry; onClick: () => v
}
function PhotoImg({ photo, className, style, onClick }: { photo: JourneyPhoto; className?: string; style?: React.CSSProperties; onClick?: () => void }) {
const src = photo.provider !== 'local' ? photoUrl(photo, 'original') : photoUrl(photo)
const src = photoUrl(photo, 'thumbnail')
return (
<img
src={src}
@@ -1356,6 +1405,24 @@ function WeatherChip({ weather }: { weather: string }) {
)
}
// ── Scroll Trigger ───────────────────────────────────────────────────────
function ScrollTrigger({ onVisible, loading }: { onVisible: () => void; loading: boolean }) {
const ref = useRef<HTMLDivElement>(null)
useEffect(() => {
const el = ref.current
if (!el) return
const obs = new IntersectionObserver(([entry]) => { if (entry.isIntersecting && !loading) onVisible() }, { rootMargin: '200px' })
obs.observe(el)
return () => obs.disconnect()
}, [onVisible, loading])
return (
<div ref={ref} className="flex justify-center py-4 mt-2">
<div className="w-5 h-5 border-2 border-zinc-300 border-t-zinc-900 dark:border-zinc-600 dark:border-t-white rounded-full animate-spin" />
</div>
)
}
// ── Provider Picker ───────────────────────────────────────────────────────
function ProviderPicker({ provider, userId, entries, trips, existingAssetIds, onClose, onAdd }: {
@@ -1368,16 +1435,23 @@ function ProviderPicker({ provider, userId, entries, trips, existingAssetIds, on
onAdd: (assetIds: string[], entryId: number | null) => Promise<void>
}) {
const { t } = useTranslation()
const [filter, setFilter] = useState<'trip' | 'custom' | 'album'>('trip')
const [filter, setFilter] = useState<'trip' | 'custom' | 'all' | 'album'>('trip')
const [photos, setPhotos] = useState<any[]>([])
const [albums, setAlbums] = useState<any[]>([])
const [selectedAlbum, setSelectedAlbum] = useState<string | null>(null)
const [loading, setLoading] = useState(false)
const [loadingMore, setLoadingMore] = useState(false)
const [hasMore, setHasMore] = useState(false)
const [searchPage, setSearchPage] = useState(1)
const [searchFrom, setSearchFrom] = useState('')
const [searchTo, setSearchTo] = useState('')
const [selected, setSelected] = useState<Set<string>>(new Set())
const [customFrom, setCustomFrom] = useState('')
const [customTo, setCustomTo] = useState('')
const [targetEntryId, setTargetEntryId] = useState<number | null>(null)
const [addToOpen, setAddToOpen] = useState(false)
const abortRef = useRef<AbortController | null>(null)
const gridRef = useRef<HTMLDivElement>(null)
// compute trip range
const tripRange = useMemo(() => {
@@ -1389,17 +1463,53 @@ function ProviderPicker({ provider, userId, entries, trips, existingAssetIds, on
return { from, to }
}, [trips])
const searchPhotos = async (from: string, to: string) => {
setLoading(true)
const cancelPending = () => {
if (abortRef.current) { abortRef.current.abort() }
abortRef.current = new AbortController()
return abortRef.current.signal
}
const searchPhotos = async (from: string, to: string, page: number = 1, append: boolean = false) => {
const signal = cancelPending()
if (page === 1) { setLoading(true); setPhotos([]) } else { setLoadingMore(true) }
setSearchFrom(from)
setSearchTo(to)
setSearchPage(page)
try {
const res = await fetch(`/api/integrations/memories/${provider}/search`, {
method: 'POST', credentials: 'include',
method: 'POST', credentials: 'include', signal,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ from, to }),
body: JSON.stringify({ from, to, page, size: 50 }),
})
if (res.ok) {
const data = await res.json()
const assets = data.assets || []
setPhotos(prev => append ? [...prev, ...assets] : assets)
setHasMore(!!data.hasMore)
} else {
setHasMore(false)
}
} catch (e: any) {
if (e.name !== 'AbortError') setHasMore(false)
}
if (!signal.aborted) { setLoading(false); setLoadingMore(false) }
}
const loadMorePhotos = () => {
if (loadingMore || !hasMore) return
searchPhotos(searchFrom, searchTo, searchPage + 1, true)
}
const loadAlbumPhotos = async (albumId: string) => {
const signal = cancelPending()
setLoading(true)
setPhotos([])
setHasMore(false)
try {
const res = await fetch(`/api/integrations/memories/${provider}/albums/${albumId}/photos`, { credentials: 'include', signal })
if (res.ok) setPhotos((await res.json()).assets || [])
} catch {}
setLoading(false)
} catch (e: any) { if (e.name !== 'AbortError') {} }
if (!signal.aborted) setLoading(false)
}
const loadAlbums = async () => {
@@ -1413,6 +1523,8 @@ function ProviderPicker({ provider, userId, entries, trips, existingAssetIds, on
useEffect(() => {
if (filter === 'trip' && tripRange.from && tripRange.to) {
searchPhotos(tripRange.from, tripRange.to)
} else if (filter === 'all') {
searchPhotos('', '')
} else if (filter === 'album' && albums.length === 0) {
loadAlbums()
}
@@ -1432,7 +1544,7 @@ function ProviderPicker({ provider, userId, entries, trips, existingAssetIds, on
const targetLabel = targetEntryId
? entries.find(e => e.id === targetEntryId)?.title || entries.find(e => e.id === targetEntryId)?.entry_date || t('journey.stats.entries')
: 'Gallery'
: t('journey.picker.newGallery')
return (
<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)' }}>
@@ -1453,9 +1565,10 @@ function ProviderPicker({ provider, userId, entries, trips, existingAssetIds, on
{/* Tabs */}
<div className="flex gap-1.5 mb-3">
{[
{ id: 'trip' as const, label: t('journey.trips.link') },
{ id: 'custom' as const, label: t('common.edit') },
{ id: 'album' as const, label: t('journey.share.gallery') },
{ id: 'trip' as const, label: t('journey.picker.tripPeriod') },
{ id: 'custom' as const, label: t('journey.picker.dateRange') },
{ id: 'all' as const, label: t('journey.picker.allPhotos') },
{ id: 'album' as const, label: t('journey.picker.albums') },
].map(f => (
<button
key={f.id}
@@ -1479,11 +1592,11 @@ function ProviderPicker({ provider, userId, entries, trips, existingAssetIds, on
<>
<Calendar size={13} className="text-zinc-400" />
<span className="font-medium text-zinc-900 dark:text-white">
{new Date(tripRange.from + 'T00:00:00').toLocaleDateString('en', { month: 'short', day: 'numeric' })}
{new Date(tripRange.from + 'T00:00:00').toLocaleDateString(undefined, { month: 'short', day: 'numeric' })}
</span>
<span className="text-zinc-400">&mdash;</span>
<span className="font-medium text-zinc-900 dark:text-white">
{new Date(tripRange.to + 'T00:00:00').toLocaleDateString('en', { month: 'short', day: 'numeric', year: 'numeric' })}
{new Date(tripRange.to + 'T00:00:00').toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })}
</span>
<span className="ml-1 text-zinc-400">
({Math.ceil((new Date(tripRange.to).getTime() - new Date(tripRange.from).getTime()) / 86400000) + 1} days)
@@ -1502,7 +1615,7 @@ function ProviderPicker({ provider, userId, entries, trips, existingAssetIds, on
<div className="flex-1"><DatePicker value={customTo} onChange={setCustomTo} /></div>
<button onClick={handleCustomSearch}
className="px-3 py-1.5 rounded-lg bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 text-[12px] font-medium hover:bg-zinc-800 dark:hover:bg-zinc-100 flex-shrink-0">
Search
{t('journey.picker.search')}
</button>
</div>
)}
@@ -1512,7 +1625,7 @@ function ProviderPicker({ provider, userId, entries, trips, existingAssetIds, on
{albums.map((a: any) => (
<button
key={a.id}
onClick={() => { setSelectedAlbum(a.id); searchPhotos(a.startDate || '2000-01-01', a.endDate || '2099-01-01') }}
onClick={() => { setSelectedAlbum(a.id); loadAlbumPhotos(a.id) }}
className={`px-2.5 py-1 rounded-lg text-[11px] font-medium whitespace-nowrap flex-shrink-0 border ${
selectedAlbum === a.id
? 'bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 border-zinc-900 dark:border-white'
@@ -1522,16 +1635,16 @@ function ProviderPicker({ provider, userId, entries, trips, existingAssetIds, on
{a.albumName || a.name || 'Album'}{a.assetCount != null ? ` (${a.assetCount})` : ''}
</button>
))}
{albums.length === 0 && !loading && <span className="text-[12px] text-zinc-400">No albums found</span>}
{albums.length === 0 && !loading && <span className="text-[12px] text-zinc-400">{t('journey.picker.noAlbums')}</span>}
</div>
)}
</div>
</div>
{/* Add-to */}
{/* Add-to entry selector */}
<div className="px-6 py-2.5 border-b border-zinc-200 dark:border-zinc-700 bg-zinc-50 dark:bg-zinc-800/50">
<div className="relative flex items-center gap-2">
<span className="text-[10px] font-semibold tracking-[0.12em] uppercase text-zinc-500">Add to</span>
<span className="text-[10px] font-semibold tracking-[0.12em] uppercase text-zinc-500">{t('journey.picker.addTo')}</span>
<button
onClick={() => setAddToOpen(!addToOpen)}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg border border-zinc-200 dark:border-zinc-700 text-[12px] font-medium text-zinc-700 dark:text-zinc-300 hover:bg-zinc-50 dark:hover:bg-zinc-800"
@@ -1552,10 +1665,12 @@ function ProviderPicker({ provider, userId, entries, trips, existingAssetIds, on
}`}
>
<Camera size={12} />
Gallery
{t('journey.picker.newGallery')}
</button>
<div className="h-px bg-zinc-200 dark:bg-zinc-700 my-1" />
{entries.map(e => (
{entries.filter(e => e.type !== 'skeleton' && e.title !== 'Gallery' && e.title !== '[Trip Photos]').length > 0 && (
<div className="h-px bg-zinc-200 dark:bg-zinc-700 my-1" />
)}
{entries.filter(e => e.type !== 'skeleton' && e.title !== 'Gallery' && e.title !== '[Trip Photos]').map(e => (
<button
key={e.id}
onClick={() => { setTargetEntryId(e.id); setAddToOpen(false) }}
@@ -1565,7 +1680,7 @@ function ProviderPicker({ provider, userId, entries, trips, existingAssetIds, on
: 'text-zinc-700 dark:text-zinc-300 hover:bg-zinc-50 dark:hover:bg-zinc-700'
}`}
>
{e.title || e.location_name || e.entry_date}
{e.title || e.location_name || new Date(e.entry_date + 'T12:00:00').toLocaleDateString(undefined, { day: 'numeric', month: 'short' })}
</button>
))}
</div>
@@ -1574,6 +1689,36 @@ function ProviderPicker({ provider, userId, entries, trips, existingAssetIds, on
</div>
</div>
{/* Select all bar — sticky above grid */}
{!loading && photos.length > 0 && (() => {
const selectable = photos.filter((a: any) => !existingAssetIds.has(a.id))
const allSelected = selectable.length > 0 && selectable.every((a: any) => selected.has(a.id))
if (selectable.length === 0) return null
return (
<div className="px-4 py-2 border-b border-zinc-200 dark:border-zinc-700 bg-white dark:bg-zinc-900">
<button
onClick={() => {
if (allSelected) {
setSelected(new Set())
} else {
setSelected(new Set(selectable.map((a: any) => a.id)))
}
}}
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg text-[11px] font-medium border border-zinc-200 dark:border-zinc-700 text-zinc-500 dark:text-zinc-400 hover:bg-zinc-50 dark:hover:bg-zinc-800"
>
<div className={`w-3.5 h-3.5 rounded border flex items-center justify-center ${
allSelected
? 'bg-zinc-900 dark:bg-white border-zinc-900 dark:border-white'
: 'border-zinc-300 dark:border-zinc-600'
}`}>
{allSelected && <Check size={9} className="text-white dark:text-zinc-900" strokeWidth={3} />}
</div>
{allSelected ? t('journey.picker.deselectAll') : t('journey.picker.selectAll')} ({selectable.length})
</button>
</div>
)
})()}
{/* Photo grid */}
<div className="flex-1 overflow-y-auto p-4">
{loading ? (
@@ -1632,25 +1777,28 @@ function ProviderPicker({ provider, userId, entries, trips, existingAssetIds, on
</div>
)
})}
{/* Infinite scroll trigger */}
{hasMore && !selectedAlbum && <ScrollTrigger onVisible={loadMorePhotos} loading={loadingMore} />}
</div>
)}
</div>
{/* Footer */}
<div className="flex items-center justify-between px-6 py-4 border-t border-zinc-200 dark:border-zinc-700 bg-zinc-50 dark:bg-zinc-800/50">
<span className="text-[12px] text-zinc-500">
<strong className="text-zinc-900 dark:text-white">{selected.size}</strong> selected
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full bg-zinc-200/60 dark:bg-zinc-700/60 text-[11px] leading-none text-zinc-500 dark:text-zinc-400">
<span className="inline-flex items-center justify-center min-w-[18px] h-[18px] px-1 rounded-full bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 text-[10px] leading-none font-bold">{selected.size}</span>
<span className="leading-[18px]">{t('journey.picker.selected')}</span>
</span>
<div className="flex items-center gap-2">
<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">
Cancel
{t('common.cancel')}
</button>
<button
onClick={() => onAdd([...selected], targetEntryId)}
disabled={selected.size === 0}
className="px-3.5 py-2 rounded-lg bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 text-[13px] font-medium hover:bg-zinc-800 dark:hover:bg-zinc-100 disabled:opacity-40 disabled:cursor-not-allowed"
>
Add {selected.size > 0 ? `(${selected.size})` : ''}
{t('common.add')} {selected.size > 0 ? `(${selected.size})` : ''}
</button>
</div>
</div>
@@ -1666,6 +1814,7 @@ function DatePicker({ value, onChange, tripDates }: {
onChange: (date: string) => void
tripDates?: Set<string>
}) {
const { t } = useTranslation()
const [open, setOpen] = useState(false)
const [viewMonth, setViewMonth] = useState(() => {
const d = value ? new Date(value + 'T00:00:00') : new Date()
@@ -1674,7 +1823,7 @@ function DatePicker({ value, onChange, tripDates }: {
const daysInMonth = new Date(viewMonth.year, viewMonth.month + 1, 0).getDate()
const firstDow = new Date(viewMonth.year, viewMonth.month, 1).getDay()
const monthName = new Date(viewMonth.year, viewMonth.month).toLocaleDateString('en', { month: 'long', year: 'numeric' })
const monthName = new Date(viewMonth.year, viewMonth.month).toLocaleDateString(undefined, { month: 'long', year: 'numeric' })
const prevMonth = () => {
setViewMonth(p => p.month === 0 ? { year: p.year - 1, month: 11 } : { ...p, month: p.month - 1 })
@@ -1689,7 +1838,7 @@ function DatePicker({ value, onChange, tripDates }: {
for (let i = 0; i < firstDow; i++) cells.push(null)
for (let d = 1; d <= daysInMonth; d++) cells.push(d)
const formatted = value ? new Date(value + 'T00:00:00').toLocaleDateString('en', { month: 'short', day: 'numeric', year: 'numeric' }) : 'Select date'
const formatted = value ? new Date(value + 'T00:00:00').toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }) : t('journey.picker.selectDate')
return (
<div className="relative">
@@ -1719,8 +1868,8 @@ function DatePicker({ value, onChange, tripDates }: {
{/* Weekday headers */}
<div className="grid grid-cols-7 mb-1">
{['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa'].map(d => (
<div key={d} className="text-center text-[10px] font-medium text-zinc-400 py-1">{d}</div>
{['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa'].map((d, i) => (
<div key={i} className="text-center text-[10px] font-medium text-zinc-400 py-1">{d}</div>
))}
</div>
@@ -1789,6 +1938,7 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa
const [pros, setPros] = useState<string[]>(entry.pros_cons?.pros?.length ? entry.pros_cons.pros : [''])
const [cons, setCons] = useState<string[]>(entry.pros_cons?.cons?.length ? entry.pros_cons.cons : [''])
const [saving, setSaving] = useState(false)
const [uploading, setUploading] = useState(false)
const [photos, setPhotos] = useState<JourneyPhoto[]>(entry.photos || [])
const [pendingFiles, setPendingFiles] = useState<File[]>([])
const [pendingLinkIds, setPendingLinkIds] = useState<number[]>([])
@@ -1837,10 +1987,15 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa
// queue files for upload after save
setPendingFiles(prev => [...prev, ...Array.from(files)])
} else {
const formData = new FormData()
for (const f of files) formData.append('photos', f)
const newPhotos = await onUploadPhotos(entry.id, formData)
if (newPhotos?.length) setPhotos(prev => [...prev, ...newPhotos])
setUploading(true)
try {
const formData = new FormData()
for (const f of files) formData.append('photos', f)
const newPhotos = await onUploadPhotos(entry.id, formData)
if (newPhotos?.length) setPhotos(prev => [...prev, ...newPhotos])
} finally {
setUploading(false)
}
}
}
@@ -1868,9 +2023,14 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa
<div className="flex gap-2">
<button
onClick={() => fileRef.current?.click()}
className="flex-1 border border-dashed border-zinc-200 dark:border-zinc-700 rounded-lg py-4 text-[12px] text-zinc-500 hover:border-zinc-400 dark:hover:border-zinc-500 hover:bg-zinc-50 dark:hover:bg-zinc-800 flex items-center justify-center gap-1.5"
disabled={uploading}
className="flex-1 border border-dashed border-zinc-200 dark:border-zinc-700 rounded-lg py-4 text-[12px] text-zinc-500 hover:border-zinc-400 dark:hover:border-zinc-500 hover:bg-zinc-50 dark:hover:bg-zinc-800 flex items-center justify-center gap-1.5 disabled:opacity-50"
>
<Plus size={13} /> Upload photos
{uploading ? (
<><div className="w-3.5 h-3.5 border-2 border-zinc-300 border-t-zinc-600 rounded-full animate-spin" /> {t('journey.editor.uploading')}</>
) : (
<><Plus size={13} /> {t('journey.editor.uploadPhotos')}</>
)}
</button>
{galleryPhotos.length > 0 && (
<button
@@ -1881,7 +2041,7 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa
: 'border-dashed border-zinc-200 dark:border-zinc-700 hover:border-zinc-400 dark:hover:border-zinc-500 hover:bg-zinc-50 dark:hover:bg-zinc-800'
}`}
>
<Image size={13} /> From Gallery
<Image size={13} /> {t('journey.editor.fromGallery')}
</button>
)}
</div>
@@ -1906,7 +2066,7 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa
}}
className="aspect-square rounded-lg overflow-hidden cursor-pointer hover:ring-2 hover:ring-zinc-900 dark:hover:ring-white hover:ring-offset-1 dark:hover:ring-offset-zinc-900 transition-all"
>
<img src={photoUrl(gp)} alt="" className="w-full h-full object-cover" loading="lazy" onError={e => { if (gp.provider !== 'local') { const img = e.currentTarget; const orig = photoUrl(gp, 'original'); if (!img.src.includes('/original')) img.src = orig } }} />
<img src={photoUrl(gp)} alt="" className="w-full h-full object-cover" loading="lazy" onError={e => { const img = e.currentTarget; const orig = photoUrl(gp, 'original'); if (!img.src.includes('/original')) img.src = orig }} />
</div>
))}
{galleryPhotos.filter(gp => !photos.some(p => p.id === gp.id)).length === 0 && (
@@ -1920,7 +2080,7 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa
<div className="flex flex-wrap gap-2">
{photos.map((p, idx) => (
<div key={p.id} className={`w-20 h-20 rounded-lg overflow-hidden relative group ${idx === 0 && photos.length > 1 ? 'ring-2 ring-zinc-900 dark:ring-white ring-offset-1 dark:ring-offset-zinc-900' : ''}`}>
<img src={photoUrl(p)} className="w-full h-full object-cover" alt="" onError={e => { if (p.provider !== 'local') { const img = e.currentTarget; const orig = photoUrl(p, 'original'); if (!img.src.includes('/original')) img.src = orig } }} />
<img src={photoUrl(p)} className="w-full h-full object-cover" alt="" onError={e => { const img = e.currentTarget; const orig = photoUrl(p, 'original'); if (!img.src.includes('/original')) img.src = orig }} />
{idx === 0 && photos.length > 1 && (
<span className="absolute bottom-0.5 left-0.5 px-1 py-px rounded text-[8px] font-bold bg-zinc-900/70 text-white">{t('journey.editor.photoFirst')}</span>
)}
@@ -1938,7 +2098,7 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa
}}
className="absolute bottom-0.5 left-0.5 px-1.5 py-0.5 rounded bg-black/60 text-white text-[8px] font-semibold opacity-0 group-hover:opacity-100 transition-opacity"
>
Make 1st
{t('journey.editor.makeFirst')}
</button>
)}
<button
@@ -2017,7 +2177,7 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa
onClick={() => setPros([...pros, ''])}
className="flex items-center justify-center gap-1.5 h-9 w-full border border-dashed border-green-200 dark:border-green-800/40 rounded-[10px] text-[12px] font-medium text-green-700 dark:text-green-400 hover:border-green-300 dark:hover:border-green-700 transition-colors"
>
<Plus size={13} strokeWidth={2.5} /> Add another
<Plus size={13} strokeWidth={2.5} /> {t('journey.editor.addAnother')}
</button>
</div>
</div>
@@ -2051,7 +2211,7 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa
onClick={() => setCons([...cons, ''])}
className="flex items-center justify-center gap-1.5 h-9 w-full border border-dashed border-red-200 dark:border-red-800/40 rounded-[10px] text-[12px] font-medium text-red-700 dark:text-red-400 hover:border-red-300 dark:hover:border-red-700 transition-colors"
>
<Plus size={13} strokeWidth={2.5} /> Add another
<Plus size={13} strokeWidth={2.5} /> {t('journey.editor.addAnother')}
</button>
</div>
</div>
@@ -2129,7 +2289,7 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa
)}
{locationSearching && (
<div className="absolute left-0 right-0 top-full mt-1 z-[100] bg-white dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-xl shadow-lg px-3 py-3 text-center text-[12px] text-zinc-400">
Searching...
{t('journey.editor.searching')}
</div>
)}
</div>
@@ -2203,11 +2363,11 @@ function AddTripDialog({ journeyId, existingTripIds, onClose, onAdded }: {
journeyApi.availableTrips().then(d => setTrips(d.trips || [])).catch(() => {})
}, [])
const filtered = trips.filter(t => {
if (existingTripIds.includes(t.id)) return false
const filtered = trips.filter(trip => {
if (existingTripIds.includes(trip.id)) return false
if (!search) return true
const q = search.toLowerCase()
return t.title.toLowerCase().includes(q) || (t.destination || '').toLowerCase().includes(q)
return trip.title.toLowerCase().includes(q) || (trip.destination || '').toLowerCase().includes(q)
})
const handleAdd = async (tripId: number) => {
@@ -2249,26 +2409,26 @@ function AddTripDialog({ journeyId, existingTripIds, onClose, onAdded }: {
{filtered.length === 0 && (
<p className="text-[12px] text-zinc-400 text-center py-4">{t('journey.trips.noTripsAvailable')}</p>
)}
{filtered.map(t => (
{filtered.map(trip => (
<div
key={t.id}
key={trip.id}
className="flex items-center gap-2.5 p-2.5 rounded-lg hover:bg-zinc-50 dark:hover:bg-zinc-800 border border-transparent"
>
<div className="w-9 h-9 rounded-md flex-shrink-0" style={{ background: pickGradient(t.id) }} />
<div className="w-9 h-9 rounded-md flex-shrink-0" style={{ background: pickGradient(trip.id) }} />
<div className="flex-1 min-w-0">
<div className="text-[13px] font-medium text-zinc-900 dark:text-white truncate">{t.title}</div>
{(t.destination || t.start_date) && (
<div className="text-[13px] font-medium text-zinc-900 dark:text-white truncate">{trip.title}</div>
{(trip.destination || trip.start_date) && (
<div className="text-[11px] text-zinc-500 truncate">
{t.destination}{t.destination && t.start_date ? ' · ' : ''}{t.start_date}
{trip.destination}{trip.destination && trip.start_date ? ' · ' : ''}{trip.start_date}
</div>
)}
</div>
<button
onClick={() => handleAdd(t.id)}
disabled={adding === t.id}
onClick={() => handleAdd(trip.id)}
disabled={adding === trip.id}
className="px-3 py-1.5 rounded-lg text-[11px] font-semibold bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 hover:bg-zinc-700 dark:hover:bg-zinc-200 disabled:opacity-50"
>
{adding === t.id ? '...' : 'Link'}
{adding === trip.id ? '...' : t('journey.trips.link')}
</button>
</div>
))}
@@ -2376,19 +2536,19 @@ function ContributorInviteDialog({ journeyId, existingUserIds, onClose, onInvite
{/* Role selector */}
<div>
<label className="text-[10px] font-semibold tracking-[0.12em] uppercase text-zinc-500 block mb-2">Role</label>
<label className="text-[10px] font-semibold tracking-[0.12em] uppercase text-zinc-500 block mb-2">{t('journey.invite.role')}</label>
<div className="flex gap-2">
{(['viewer', 'editor'] as const).map(r => (
<button
key={r}
onClick={() => setRole(r)}
className={`flex-1 py-2 rounded-lg text-[12px] font-medium border transition-all capitalize ${
className={`flex-1 py-2 rounded-lg text-[12px] font-medium border transition-all ${
role === r
? 'bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 border-zinc-900 dark:border-white'
: 'border-zinc-200 dark:border-zinc-700 text-zinc-500 hover:border-zinc-400'
}`}
>
{r}
{t(`journey.invite.${r}`)}
</button>
))}
</div>
@@ -2397,14 +2557,14 @@ function ContributorInviteDialog({ journeyId, existingUserIds, onClose, onInvite
<div className="flex items-center justify-end gap-2 px-6 py-4 border-t border-zinc-200 dark:border-zinc-700 bg-zinc-50 dark:bg-zinc-800/50">
<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">
Cancel
{t('common.cancel')}
</button>
<button
onClick={handleInvite}
disabled={!selectedUserId || sending}
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"
>
{sending ? 'Inviting...' : 'Invite'}
{sending ? t('journey.invite.inviting') : t('journey.invite.invite')}
</button>
</div>
</div>
@@ -2471,7 +2631,7 @@ function JourneyShareSection({ journeyId }: { journeyId: number }) {
onClick={createLink}
className="w-full flex items-center justify-center gap-1.5 py-2.5 rounded-lg border border-dashed border-zinc-300 dark:border-zinc-600 text-[12px] font-medium text-zinc-500 hover:border-zinc-400 hover:text-zinc-700 dark:hover:border-zinc-500 dark:hover:text-zinc-300 transition-colors"
>
<Link size={14} /> Create share link
<Link size={14} /> {t('journey.share.createLink')}
</button>
) : (
<div className="flex flex-col gap-3">
@@ -2483,7 +2643,7 @@ function JourneyShareSection({ journeyId }: { journeyId: number }) {
onClick={copyLink}
className="flex-shrink-0 px-2.5 py-1 rounded-md bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 text-[11px] font-medium hover:bg-zinc-700 dark:hover:bg-zinc-200"
>
{copied ? 'Copied!' : 'Copy'}
{copied ? t('journey.share.copied') : t('journey.share.copy')}
</button>
</div>
@@ -2578,8 +2738,8 @@ function JourneySettingsDialog({ journey, onClose, onSaved, onOpenInvite }: {
}
return (
<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-[480px] w-full max-h-[90vh] flex flex-col overflow-hidden">
<div className="fixed inset-0 z-[200] flex items-end md:items-center justify-center md:p-5 overscroll-none" style={{ background: 'rgba(9,9,11,0.6)', backdropFilter: 'blur(6px)' }} onClick={onClose} onTouchMove={e => { if (e.target === e.currentTarget) e.preventDefault() }}>
<div className="bg-white dark:bg-zinc-900 rounded-t-2xl md:rounded-2xl shadow-[0_20px_40px_rgba(0,0,0,0.2)] max-w-[480px] w-full max-h-[85vh] md:max-h-[90vh] flex flex-col overflow-hidden pb-safe" onClick={e => e.stopPropagation()}>
<div className="flex items-center justify-between px-6 py-4 border-b border-zinc-200 dark:border-zinc-700">
<h2 className="text-[16px] font-bold text-zinc-900 dark:text-white">{t('journey.settings.title')}</h2>
@@ -2588,7 +2748,7 @@ function JourneySettingsDialog({ journey, onClose, onSaved, onOpenInvite }: {
</button>
</div>
<div className="flex-1 overflow-y-auto px-6 py-5 flex flex-col gap-5">
<div className="flex-1 overflow-y-auto overscroll-contain px-6 py-5 flex flex-col gap-5">
{/* Cover Image */}
<div>
<label className="text-[10px] font-semibold tracking-[0.12em] uppercase text-zinc-500 block mb-2">{t('journey.settings.coverImage')}</label>
@@ -2691,7 +2851,7 @@ function JourneySettingsDialog({ journey, onClose, onSaved, onOpenInvite }: {
</div>
{/* Footer */}
<div className="flex items-center gap-2 px-6 py-4 border-t border-zinc-200 dark:border-zinc-700 bg-zinc-50 dark:bg-zinc-800/50">
<div className="flex items-center gap-2 px-6 py-4 pb-6 md:pb-4 border-t border-zinc-200 dark:border-zinc-700 bg-zinc-50 dark:bg-zinc-800/50">
<button
onClick={() => setShowDeleteConfirm(true)}
className="flex items-center gap-1.5 text-[12px] font-medium text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-lg px-2.5 py-2 mr-auto"
+4 -4
View File
@@ -97,7 +97,7 @@ const mockJourneyData = {
weather: 'cloudy',
pros_cons: null,
photos: [
{ id: 100, entry_id: 11, provider: 'local', asset_id: null, owner_id: null, file_path: 'journey/temple.jpg', caption: 'Temple entrance' },
{ id: 100, entry_id: 11, photo_id: 100, provider: 'local', asset_id: null, owner_id: null, file_path: 'journey/temple.jpg', caption: 'Temple entrance' },
],
},
],
@@ -348,9 +348,9 @@ describe('JourneyPublicPage', () => {
entry_time: null, location_name: null, location_lat: null, location_lng: null,
mood: null, weather: null, pros_cons: null,
photos: [
{ id: 200, entry_id: 20, provider: 'local', asset_id: null, owner_id: null, file_path: 'journey/a.jpg', caption: 'Photo A' },
{ id: 201, entry_id: 20, provider: 'local', asset_id: null, owner_id: null, file_path: 'journey/b.jpg', caption: 'Photo B' },
{ id: 202, entry_id: 20, provider: 'local', asset_id: null, owner_id: null, file_path: 'journey/c.jpg', caption: 'Photo C' },
{ id: 200, entry_id: 20, photo_id: 200, provider: 'local', asset_id: null, owner_id: null, file_path: 'journey/a.jpg', caption: 'Photo A' },
{ id: 201, entry_id: 20, photo_id: 201, provider: 'local', asset_id: null, owner_id: null, file_path: 'journey/b.jpg', caption: 'Photo B' },
{ id: 202, entry_id: 20, photo_id: 202, provider: 'local', asset_id: null, owner_id: null, file_path: 'journey/c.jpg', caption: 'Photo C' },
],
},
],
+3 -3
View File
@@ -26,7 +26,8 @@ interface PublicEntry {
interface PublicPhoto {
id: number
entry_id: number
provider: string
photo_id: number
provider?: string
asset_id?: string | null
owner_id?: number | null
file_path?: string | null
@@ -34,8 +35,7 @@ interface PublicPhoto {
}
function photoUrl(p: PublicPhoto, shareToken: string): string {
if (p.provider === 'local') return `/api/public/journey/${shareToken}/photo/local/${encodeURIComponent(p.file_path || '')}/0/original`
return `/api/public/journey/${shareToken}/photo/${p.provider}/${p.asset_id}/${p.owner_id}/original`
return `/api/public/journey/${shareToken}/photos/${p.photo_id}/original`
}
function formatDate(d: string): { weekday: string; month: string; day: number } {
+2 -2
View File
@@ -206,7 +206,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
useTripWebSocket(tripId)
const [mapCategoryFilter, setMapCategoryFilter] = useState<string>('')
const [mapCategoryFilter, setMapCategoryFilter] = useState<Set<string>>(new Set())
const [mapPlacesFilter, setMapPlacesFilter] = useState<string>('all')
const [expandedDayIds, setExpandedDayIds] = useState<Set<number> | null>(null)
@@ -239,7 +239,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
return places.filter(p => {
if (!p.lat || !p.lng) return false
if (mapCategoryFilter && String(p.category_id) !== String(mapCategoryFilter)) return false
if (mapCategoryFilter.size > 0 && !mapCategoryFilter.has(String(p.category_id))) return false
if (hiddenPlaceIds.has(p.id)) return false
if (plannedIds && plannedIds.has(p.id)) return false
return true
+1
View File
@@ -148,6 +148,7 @@ describe('journeyStore', () => {
);
await expect(useJourneyStore.getState().loadJourney(999)).rejects.toThrow();
expect(useJourneyStore.getState().loading).toBe(false);
expect(useJourneyStore.getState().notFound).toBe(true);
});
// ── createJourney ────────────────────────────────────────────────────────
+16 -6
View File
@@ -42,17 +42,19 @@ export interface JourneyEntry {
export interface JourneyPhoto {
id: number
entry_id: number
provider: 'local' | 'immich' | 'synologyphotos'
photo_id: number
caption?: string | null
sort_order: number
shared: number
created_at: number
// Joined from trek_photos for display
provider?: string
asset_id?: string | null
owner_id?: number | null
file_path?: string | null
thumbnail_path?: string | null
caption?: string | null
sort_order: number
width?: number | null
height?: number | null
shared: number
created_at: number
}
export interface JourneyTrip {
@@ -80,12 +82,14 @@ export interface JourneyDetail extends Journey {
trips: JourneyTrip[]
contributors: JourneyContributor[]
stats: { entries: number; photos: number; cities: number }
hide_skeletons?: boolean
}
interface JourneyState {
journeys: Journey[]
current: JourneyDetail | null
loading: boolean
notFound: boolean
loadJourneys: () => Promise<void>
loadJourney: (id: number) => Promise<void>
@@ -107,6 +111,7 @@ export const useJourneyStore = create<JourneyState>((set, get) => ({
journeys: [],
current: null,
loading: false,
notFound: false,
loadJourneys: async () => {
set({ loading: true })
@@ -119,10 +124,15 @@ export const useJourneyStore = create<JourneyState>((set, get) => ({
},
loadJourney: async (id) => {
set({ loading: true })
set({ loading: true, notFound: false })
try {
const data = await journeyApi.get(id)
set({ current: data })
} catch (err: any) {
if (err?.response?.status === 404) {
set({ current: null, notFound: true })
}
throw err
} finally {
set({ loading: false })
}
+6
View File
@@ -43,18 +43,24 @@ export interface Place {
trip_id: number
name: string
description: string | null
notes: string | null
lat: number | null
lng: number | null
address: string | null
category_id: number | null
icon: string | null
price: string | null
currency: string | null
image_url: string | null
google_place_id: string | null
osm_id: string | null
route_geometry: string | null
place_time: string | null
end_time: string | null
duration_minutes: number | null
transport_mode: string | null
website: string | null
phone: string | null
created_at: string
}
@@ -0,0 +1,38 @@
/**
* Custom Vitest environment that extends jsdom but preserves the native
* Node.js AbortController and AbortSignal.
*
* Problem: jsdom replaces globalThis.AbortController and AbortSignal with its
* own implementations. Node.js's undici-based fetch validates signals via
* `signal instanceof AbortSignal` against its own native class reference.
* jsdom's AbortSignal instances fail this check, causing fetch to throw:
* TypeError: RequestInit: Expected signal ("AbortSignal {}") to be an
* instance of AbortSignal.
*
* Fix: after jsdom installs its globals, restore the native AbortController
* and AbortSignal so fetch works correctly in tests.
*/
import { builtinEnvironments } from 'vitest/environments';
const jsdomEnv = builtinEnvironments.jsdom;
export default {
name: 'jsdom-native-abort',
transformMode: 'web' as const,
async setup(global: typeof globalThis, options: Record<string, unknown>) {
// Capture native AbortController/AbortSignal BEFORE jsdom patches them
const NativeAbortController = global.AbortController;
const NativeAbortSignal = global.AbortSignal;
// Run standard jsdom setup (installs jsdom globals, including its own AbortController)
const env = await jsdomEnv.setup(global, options as Parameters<typeof jsdomEnv.setup>[1]);
// Restore native AbortController so Node.js fetch (undici) accepts the signals
global.AbortController = NativeAbortController;
global.AbortSignal = NativeAbortSignal;
return env;
},
};
+1 -1
View File
@@ -6,7 +6,7 @@ export default defineConfig({
test: {
root: '.',
globals: true,
environment: 'jsdom',
environment: './tests/environment/jsdom-native-abort.ts',
include: [
'tests/**/*.test.{ts,tsx}',
'src/**/*.test.{ts,tsx}',
+2
View File
@@ -36,6 +36,7 @@ import { oauthPublicRouter, oauthApiRouter } from './routes/oauth';
import vacayRoutes from './routes/vacay';
import atlasRoutes from './routes/atlas';
import memoriesRoutes from './routes/memories/unified';
import photoRoutes from './routes/photos';
import notificationRoutes from './routes/notifications';
import shareRoutes from './routes/share';
import journeyRoutes from './routes/journey';
@@ -266,6 +267,7 @@ export function createApp(): express.Application {
app.use('/api/journeys', journeyRoutes);
app.use('/api/public/journey', journeyPublicRoutes);
app.use('/api/integrations/memories', memoriesRoutes);
app.use('/api/photos', photoRoutes);
app.use('/api/maps', mapsRoutes);
app.use('/api/weather', weatherRoutes);
app.use('/api/settings', settingsRoutes);
+143
View File
@@ -1435,6 +1435,149 @@ function runMigrations(db: Database.Database): void {
() => {
try { db.exec("ALTER TABLE vacay_plans ADD COLUMN week_start INTEGER NOT NULL DEFAULT 1"); } catch {}
},
// Migration: Unified Photo Provider Abstraction Layer (#584)
// Central trek_photos registry; trip_photos + journey_photos reference via photo_id
() => {
// 1. Create the central photo registry
db.exec(`
CREATE TABLE IF NOT EXISTS trek_photos (
id INTEGER PRIMARY KEY AUTOINCREMENT,
provider TEXT NOT NULL,
asset_id TEXT,
owner_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
file_path TEXT,
thumbnail_path TEXT,
width INTEGER,
height INTEGER,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
`);
db.exec('CREATE UNIQUE INDEX IF NOT EXISTS idx_trek_photos_provider_asset ON trek_photos(provider, asset_id, owner_id) WHERE asset_id IS NOT NULL');
db.exec('CREATE INDEX IF NOT EXISTS idx_trek_photos_owner ON trek_photos(owner_id)');
// 2. Migrate trip_photos → trek_photos + photo_id FK
const tripPhotosExists = db.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'trip_photos'").get();
if (tripPhotosExists) {
// Detect schema variant: old (immich_asset_id) vs new (asset_id + provider)
const tpCols = db.prepare("PRAGMA table_info('trip_photos')").all() as Array<{ name: string }>;
const tpColNames = new Set(tpCols.map(c => c.name));
const hasProvider = tpColNames.has('provider');
const assetCol = tpColNames.has('asset_id') ? 'asset_id' : (tpColNames.has('immich_asset_id') ? 'immich_asset_id' : null);
const hasAlbumLink = tpColNames.has('album_link_id');
if (assetCol) {
const providerExpr = hasProvider ? 'provider' : "'immich'";
// Qualified alias needed in JOIN context where both trip_photos and trek_photos have provider
const providerJoinExpr = hasProvider ? 'tp.provider' : "'immich'";
const sharedExpr = tpColNames.has('shared') ? 'shared' : '1';
const addedAtExpr = tpColNames.has('added_at') ? 'COALESCE(added_at, CURRENT_TIMESTAMP)' : 'CURRENT_TIMESTAMP';
const albumLinkExpr = hasAlbumLink ? 'album_link_id' : 'NULL';
// Insert existing trip photo references into trek_photos
db.exec(`
INSERT OR IGNORE INTO trek_photos (provider, asset_id, owner_id, created_at)
SELECT DISTINCT ${providerExpr}, ${assetCol}, user_id, ${addedAtExpr}
FROM trip_photos
WHERE ${assetCol} IS NOT NULL AND TRIM(${assetCol}) != ''
`);
// Recreate trip_photos with photo_id FK
db.exec(`
CREATE TABLE trip_photos_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
photo_id INTEGER NOT NULL REFERENCES trek_photos(id) ON DELETE CASCADE,
shared INTEGER NOT NULL DEFAULT 1,
album_link_id INTEGER REFERENCES trip_album_links(id) ON DELETE SET NULL,
added_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE(trip_id, user_id, photo_id)
)
`);
db.exec(`
INSERT OR IGNORE INTO trip_photos_new (trip_id, user_id, photo_id, shared, album_link_id, added_at)
SELECT tp.trip_id, tp.user_id, tkp.id, ${sharedExpr}, ${albumLinkExpr}, ${addedAtExpr}
FROM trip_photos tp
JOIN trek_photos tkp ON tkp.provider = ${providerJoinExpr} AND tkp.asset_id = tp.${assetCol} AND tkp.owner_id = tp.user_id
`);
} else {
// No asset column at all — just recreate empty
db.exec(`
CREATE TABLE trip_photos_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
photo_id INTEGER NOT NULL REFERENCES trek_photos(id) ON DELETE CASCADE,
shared INTEGER NOT NULL DEFAULT 1,
album_link_id INTEGER REFERENCES trip_album_links(id) ON DELETE SET NULL,
added_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE(trip_id, user_id, photo_id)
)
`);
}
db.exec('DROP TABLE trip_photos');
db.exec('ALTER TABLE trip_photos_new RENAME TO trip_photos');
db.exec('CREATE INDEX IF NOT EXISTS idx_trip_photos_trip ON trip_photos(trip_id)');
db.exec('CREATE INDEX IF NOT EXISTS idx_trip_photos_photo ON trip_photos(photo_id)');
}
// 3. Migrate journey_photos → trek_photos + photo_id FK
const journeyPhotosExists = db.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'journey_photos'").get();
if (journeyPhotosExists) {
// Insert provider-based journey photos into trek_photos
db.exec(`
INSERT OR IGNORE INTO trek_photos (provider, asset_id, owner_id, width, height, created_at)
SELECT DISTINCT provider, asset_id, owner_id, width, height, created_at
FROM journey_photos
WHERE provider != 'local' AND asset_id IS NOT NULL AND TRIM(asset_id) != ''
`);
// Insert local journey photos into trek_photos (each is unique)
db.exec(`
INSERT INTO trek_photos (provider, file_path, thumbnail_path, width, height, created_at)
SELECT 'local', file_path, thumbnail_path, width, height, created_at
FROM journey_photos
WHERE provider = 'local' AND file_path IS NOT NULL
`);
// Recreate journey_photos with photo_id FK
db.exec(`
CREATE TABLE journey_photos_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
entry_id INTEGER NOT NULL,
photo_id INTEGER NOT NULL REFERENCES trek_photos(id) ON DELETE CASCADE,
caption TEXT,
sort_order INTEGER DEFAULT 0,
shared INTEGER NOT NULL DEFAULT 1,
created_at INTEGER NOT NULL,
FOREIGN KEY (entry_id) REFERENCES journey_entries(id) ON DELETE CASCADE
)
`);
// Migrate provider photos
db.exec(`
INSERT INTO journey_photos_new (entry_id, photo_id, caption, sort_order, shared, created_at)
SELECT jp.entry_id, tkp.id, jp.caption, jp.sort_order, jp.shared, jp.created_at
FROM journey_photos jp
JOIN trek_photos tkp ON tkp.provider = jp.provider AND tkp.asset_id = jp.asset_id AND tkp.owner_id = jp.owner_id
WHERE jp.provider != 'local' AND jp.asset_id IS NOT NULL
`);
// Migrate local photos (match by file_path)
db.exec(`
INSERT INTO journey_photos_new (entry_id, photo_id, caption, sort_order, shared, created_at)
SELECT jp.entry_id, tkp.id, jp.caption, jp.sort_order, jp.shared, jp.created_at
FROM journey_photos jp
JOIN trek_photos tkp ON tkp.provider = 'local' AND tkp.file_path = jp.file_path
WHERE jp.provider = 'local' AND jp.file_path IS NOT NULL
`);
db.exec('DROP TABLE journey_photos');
db.exec('ALTER TABLE journey_photos_new RENAME TO journey_photos');
db.exec('CREATE INDEX IF NOT EXISTS idx_journey_photos_entry ON journey_photos(entry_id)');
db.exec('CREATE INDEX IF NOT EXISTS idx_journey_photos_photo ON journey_photos(photo_id)');
}
},
// Migration 99: hide_skeletons per-user setting on journey_contributors
() => {
try { db.exec('ALTER TABLE journey_contributors ADD COLUMN hide_skeletons INTEGER NOT NULL DEFAULT 0'); } catch {}
},
];
if (currentVersion < migrations.length) {
+25 -4
View File
@@ -64,14 +64,14 @@ router.get('/available-trips', authenticate, (req: Request, res: Response) => {
router.patch('/entries/:entryId', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const result = svc.updateEntry(Number(req.params.entryId), authReq.user.id, req.body || {});
const result = svc.updateEntry(Number(req.params.entryId), authReq.user.id, req.body || {}, req.headers['x-socket-id'] as string);
if (!result) return res.status(404).json({ error: 'Entry not found' });
res.json(result);
});
router.delete('/entries/:entryId', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
if (!svc.deleteEntry(Number(req.params.entryId), authReq.user.id)) {
if (!svc.deleteEntry(Number(req.params.entryId), authReq.user.id, req.headers['x-socket-id'] as string)) {
return res.status(404).json({ error: 'Entry not found' });
}
res.json({ success: true });
@@ -115,7 +115,19 @@ router.post('/entries/:entryId/photos', authenticate, upload.array('photos', 10)
router.post('/entries/:entryId/provider-photos', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { provider, asset_id, caption } = req.body || {};
const { provider, asset_id, asset_ids, caption } = req.body || {};
// Batch mode: { provider, asset_ids: string[] }
if (Array.isArray(asset_ids) && provider) {
const added: any[] = [];
for (const id of asset_ids) {
const photo = svc.addProviderPhoto(Number(req.params.entryId), authReq.user.id, provider, String(id), caption);
if (photo) added.push(photo);
}
return res.status(201).json({ photos: added, added: added.length });
}
// Single mode (backward compat)
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);
if (!photo) return res.status(403).json({ error: 'Not allowed or duplicate' });
@@ -233,7 +245,7 @@ router.post('/:id/entries', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { entry_date } = req.body || {};
if (!entry_date) return res.status(400).json({ error: 'entry_date is required' });
const entry = svc.createEntry(Number(req.params.id), authReq.user.id, req.body);
const entry = svc.createEntry(Number(req.params.id), authReq.user.id, req.body, req.headers['x-socket-id'] as string);
if (!entry) return res.status(404).json({ error: 'Journey not found' });
res.status(201).json(entry);
});
@@ -267,6 +279,15 @@ router.delete('/:id/contributors/:userId', authenticate, (req: Request, res: Res
res.json({ success: true });
});
// ── User Preferences ─────────────────────────────────────────────────────
router.patch('/:id/preferences', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const result = svc.updateJourneyPreferences(Number(req.params.id), authReq.user.id, req.body);
if (!result) return res.status(403).json({ error: 'Not allowed' });
res.json(result);
});
// ── Share Link ────────────────────────────────────────────────────────────
router.get('/:id/share-link', authenticate, (req: Request, res: Response) => {
+12 -6
View File
@@ -1,5 +1,6 @@
import express, { Request, Response } from 'express';
import { getPublicJourney, validateShareTokenForAsset } from '../services/journeyShareService';
import { getPublicJourney, validateShareTokenForAsset, validateShareTokenForPhoto } from '../services/journeyShareService';
import { streamPhoto } from '../services/memories/photoResolverService';
import { streamImmichAsset } from '../services/memories/immichService';
import path from 'node:path';
import fs from 'node:fs';
@@ -12,16 +13,23 @@ router.get('/:token', (req: Request, res: Response) => {
res.json(data);
});
// Public photo proxy — validates share token instead of auth
// Unified public photo proxy — uses trek_photo_id
router.get('/:token/photos/:photoId/:kind', async (req: Request, res: Response) => {
const { token, photoId, kind } = req.params;
const valid = validateShareTokenForPhoto(token, Number(photoId));
if (!valid) return res.status(404).json({ error: 'Not found' });
await streamPhoto(res, valid.ownerId, Number(photoId), kind === 'thumbnail' ? 'thumbnail' : 'original');
});
// Legacy public photo proxy — validates share token instead of auth
router.get('/:token/photo/:provider/:assetId/:ownerId/:kind', async (req: Request, res: Response) => {
const { token, provider, assetId, ownerId, kind } = req.params;
// Validate token and that this asset belongs to the shared journey
const valid = validateShareTokenForAsset(token, assetId);
if (!valid) return res.status(404).json({ error: 'Not found' });
if (provider === 'local') {
// Local file — assetId is the file_path
const filePath = path.join(__dirname, '../../uploads/journey', assetId);
const resolved = path.resolve(filePath);
const uploadsDir = path.resolve(__dirname, '../../uploads');
@@ -32,12 +40,10 @@ router.get('/:token/photo/:provider/:assetId/:ownerId/:kind', async (req: Reques
return res.sendFile(resolved);
}
// Immich/Synology — proxy through
const effectiveOwnerId = valid.ownerId || Number(ownerId);
if (provider === 'immich') {
await streamImmichAsset(res, effectiveOwnerId, assetId, kind === 'thumbnail' ? 'thumbnail' : 'original', effectiveOwnerId);
} else {
// Synology or other providers — try dynamic import
try {
const { streamSynologyAsset } = await import('../services/memories/synologyService');
await streamSynologyAsset(res, effectiveOwnerId, effectiveOwnerId, assetId, kind === 'thumbnail' ? 'thumbnail' : 'original');
+18 -4
View File
@@ -13,6 +13,7 @@ import {
searchPhotos,
streamImmichAsset,
listAlbums,
getAlbumPhotos,
syncAlbumAssets,
getAssetInfo,
isValidAssetId,
@@ -59,10 +60,16 @@ router.get('/browse', authenticate, async (req: Request, res: Response) => {
router.post('/search', authenticate, async (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { from, to } = req.body;
const result = await searchPhotos(authReq.user.id, from, to);
if (result.error) return res.status(result.status!).json({ error: result.error });
res.json({ assets: result.assets });
const { from, to, size } = req.body;
const pageSize = Math.min(Number(size) || 50, 200);
const allAssets: any[] = [];
for (let page = 1; page <= 20; page++) {
const result = await searchPhotos(authReq.user.id, from, to, page, pageSize);
if (result.error) return res.status(result.status!).json({ error: result.error });
if (result.assets) allAssets.push(...result.assets);
if (!result.hasMore) break;
}
res.json({ assets: allAssets });
});
// ── Asset Details ──────────────────────────────────────────────────────────
@@ -113,6 +120,13 @@ router.get('/albums', authenticate, async (req: Request, res: Response) => {
res.json({ albums: result.albums });
});
router.get('/albums/:albumId/photos', authenticate, async (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const result = await getAlbumPhotos(authReq.user.id, req.params.albumId);
if (result.error) return res.status(result.status!).json({ error: result.error });
res.json({ assets: result.assets });
});
router.post('/trips/:tripId/album-links/:linkId/sync', authenticate, async (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { tripId, linkId } = req.params;
+12 -2
View File
@@ -7,6 +7,7 @@ import {
getSynologyStatus,
testSynologyConnection,
listSynologyAlbums,
getSynologyAlbumPhotos,
syncSynologyAlbumLink,
searchSynologyPhotos,
getSynologyAssetInfo,
@@ -77,6 +78,11 @@ router.get('/albums', authenticate, async (req: Request, res: Response) => {
handleServiceResult(res, await listSynologyAlbums(authReq.user.id));
});
router.get('/albums/:albumId/photos', authenticate, async (req: Request, res: Response) => {
const authReq = req as AuthRequest;
handleServiceResult(res, await getSynologyAlbumPhotos(authReq.user.id, req.params.albumId));
});
router.post('/trips/:tripId/album-links/:linkId/sync', authenticate, async (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { tripId, linkId } = req.params;
@@ -90,8 +96,12 @@ router.post('/search', authenticate, async (req: Request, res: Response) => {
const body = req.body as Record<string, unknown>;
const from = _parseStringBodyField(body.from);
const to = _parseStringBodyField(body.to);
const offset = _parseNumberBodyField(body.offset, 0);
const limit = _parseNumberBodyField(body.limit, 100);
let offset = _parseNumberBodyField(body.offset, 0);
const page = _parseNumberBodyField(body.page, 1) - 1;
let limit = _parseNumberBodyField(body.limit, 100);
const size = _parseNumberBodyField(body.size, 0);
if(page > 0) offset = page*limit;
if(size > 0) limit = size;
handleServiceResult(res, await searchSynologyPhotos(
authReq.user.id,
+2 -3
View File
@@ -55,8 +55,7 @@ router.put('/unified/trips/:tripId/photos/sharing', authenticate, async (req: Re
const result = await setTripPhotoSharing(
tripId,
authReq.user.id,
req.body?.provider,
req.body?.asset_id,
Number(req.body?.photo_id),
req.body?.shared,
);
if ('error' in result) return res.status(result.error.status).json({ error: result.error.message });
@@ -66,7 +65,7 @@ router.put('/unified/trips/:tripId/photos/sharing', authenticate, async (req: Re
router.delete('/unified/trips/:tripId/photos', authenticate, async (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { tripId } = req.params;
const result = await removeTripPhoto(tripId, authReq.user.id, req.body?.provider, req.body?.asset_id);
const result = removeTripPhoto(tripId, authReq.user.id, Number(req.body?.photo_id));
if ('error' in result) return res.status(result.error.status).json({ error: result.error.message });
res.json({ success: true });
});
+47
View File
@@ -0,0 +1,47 @@
import express, { Request, Response } from 'express';
import { authenticate } from '../middleware/auth';
import { AuthRequest } from '../types';
import { streamPhoto, getPhotoInfo, resolveTrekPhoto } from '../services/memories/photoResolverService';
import { canAccessTrekPhoto } from '../services/memories/helpersService';
const router = express.Router();
router.get('/:id/thumbnail', authenticate, async (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const photoId = Number(req.params.id);
if (!Number.isFinite(photoId)) return res.status(400).json({ error: 'Invalid photo ID' });
if (!canAccessTrekPhoto(authReq.user.id, photoId)) {
return res.status(403).json({ error: 'Forbidden' });
}
await streamPhoto(res, authReq.user.id, photoId, 'thumbnail');
});
router.get('/:id/original', authenticate, async (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const photoId = Number(req.params.id);
if (!Number.isFinite(photoId)) return res.status(400).json({ error: 'Invalid photo ID' });
if (!canAccessTrekPhoto(authReq.user.id, photoId)) {
return res.status(403).json({ error: 'Forbidden' });
}
await streamPhoto(res, authReq.user.id, photoId, 'original');
});
router.get('/:id/info', authenticate, async (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const photoId = Number(req.params.id);
if (!Number.isFinite(photoId)) return res.status(400).json({ error: 'Invalid photo ID' });
if (!canAccessTrekPhoto(authReq.user.id, photoId)) {
return res.status(403).json({ error: 'Forbidden' });
}
const result = await getPhotoInfo(authReq.user.id, photoId);
if ('error' in result) return res.status(result.error.status).json({ error: result.error.message });
res.json(result.data);
});
export default router;
+44 -19
View File
@@ -171,12 +171,23 @@ export const CONTINENT_MAP: Record<string, string> = {
// ── Geocoding helpers ───────────────────────────────────────────────────────
let lastNominatimCall = 0;
// Shared throttle: enforces ≥1.1s between any Nominatim request, across all callers.
async function throttleNominatim() {
const elapsed = Date.now() - lastNominatimCall;
if (elapsed < 1100) await new Promise(r => setTimeout(r, 1100 - elapsed));
lastNominatimCall = Date.now();
}
export async function reverseGeocodeCountry(lat: number, lng: number): Promise<string | null> {
const key = roundKey(lat, lng);
if (geocodeCache.has(key)) return geocodeCache.get(key)!;
await throttleNominatim();
try {
const res = await fetch(`https://nominatim.openstreetmap.org/reverse?lat=${lat}&lon=${lng}&format=json&zoom=3&accept-language=en`, {
headers: { 'User-Agent': 'TREK Travel Planner' },
headers: { 'User-Agent': 'TREK Travel Planner (https://github.com/mauriceboe/TREK)' },
signal: AbortSignal.timeout(10_000),
});
if (!res.ok) return null;
const data = await res.json() as { address?: { country_code?: string } };
@@ -215,15 +226,15 @@ export function getCountryFromAddress(address: string | null): string | null {
return null;
}
// ── Resolve a place to a country code (address -> geocode -> bbox) ──────────
// ── Resolve a place to a country code (address -> bbox -> geocode) ──────────
async function resolveCountryCode(place: Place): Promise<string | null> {
let code = getCountryFromAddress(place.address);
if (!code && place.lat && place.lng) {
code = await reverseGeocodeCountry(place.lat, place.lng);
code = getCountryFromCoords(place.lat, place.lng);
}
if (!code && place.lat && place.lng) {
code = getCountryFromCoords(place.lat, place.lng);
code = await reverseGeocodeCountry(place.lat, place.lng);
}
return code;
}
@@ -453,15 +464,22 @@ export function unmarkRegionVisited(userId: number, regionCode: string): void {
interface RegionInfo { country_code: string; region_code: string; region_name: string }
// Tracks place IDs currently being geocoded in the background to prevent duplicate enqueuing.
const geocodingInFlight = new Set<number>();
const regionCache = new Map<string, RegionInfo | null>();
async function reverseGeocodeRegion(lat: number, lng: number): Promise<RegionInfo | null> {
const key = roundKey(lat, lng);
if (regionCache.has(key)) return regionCache.get(key)!;
await throttleNominatim();
try {
const res = await fetch(
`https://nominatim.openstreetmap.org/reverse?lat=${lat}&lon=${lng}&format=json&zoom=8&accept-language=en`,
{ headers: { 'User-Agent': 'TREK Travel Planner' } }
{
headers: { 'User-Agent': 'TREK Travel Planner (https://github.com/mauriceboe/TREK)' },
signal: AbortSignal.timeout(10_000),
}
);
if (!res.ok) return null;
const data = await res.json() as { address?: Record<string, string> };
@@ -498,20 +516,27 @@ export async function getVisitedRegions(userId: number): Promise<{ regions: Reco
: [];
const cachedMap = new Map(cached.map(c => [c.place_id, c]));
// Resolve uncached places (rate-limited to avoid hammering Nominatim)
const uncached = places.filter(p => p.lat && p.lng && !cachedMap.has(p.id));
const insertStmt = db.prepare('INSERT OR REPLACE INTO place_regions (place_id, country_code, region_code, region_name) VALUES (?, ?, ?, ?)');
for (const place of uncached) {
const info = await reverseGeocodeRegion(place.lat!, place.lng!);
if (info) {
insertStmt.run(place.id, info.country_code, info.region_code, info.region_name);
cachedMap.set(place.id, { place_id: place.id, ...info });
}
// Nominatim rate limit: 1 req/sec
if (uncached.indexOf(place) < uncached.length - 1) {
await new Promise(r => setTimeout(r, 1100));
}
// Kick off background geocoding for uncached places; return cached data immediately.
const uncached = places.filter(p => p.lat && p.lng && !cachedMap.has(p.id) && !geocodingInFlight.has(p.id));
if (uncached.length > 0) {
const insertStmt = db.prepare('INSERT OR REPLACE INTO place_regions (place_id, country_code, region_code, region_name) VALUES (?, ?, ?, ?)');
for (const p of uncached) geocodingInFlight.add(p.id);
void (async () => {
try {
for (const place of uncached) {
try {
const info = await reverseGeocodeRegion(place.lat!, place.lng!);
if (info) insertStmt.run(place.id, info.country_code, info.region_code, info.region_name);
} catch {
// individual failure — continue with remaining places
} finally {
geocodingInFlight.delete(place.id);
}
}
} catch {
for (const p of uncached) geocodingInFlight.delete(p.id);
}
})();
}
// Group by country → regions with place counts
+1 -1
View File
@@ -117,7 +117,7 @@ export function listBackups(): BackupInfo[] {
filename,
size: stat.size,
sizeText: formatSize(stat.size),
created_at: stat.birthtime.toISOString(),
created_at: stat.mtime.toISOString(),
};
})
.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime());
+80 -37
View File
@@ -1,12 +1,20 @@
import { db } from '../db/database';
import { broadcastToUser } from '../websocket';
import type { Journey, JourneyEntry, JourneyPhoto, JourneyContributor } from '../types';
import { getOrCreateTrekPhoto, getOrCreateLocalTrekPhoto, setTrekPhotoProvider } from './memories/photoResolverService';
function ts(): number {
return Date.now();
}
function broadcastJourneyEvent(journeyId: number, event: string, data: Record<string, unknown>, excludeUserId?: number) {
// Joined SELECT for journey_photos + trek_photos — returns fields matching JourneyPhoto interface
const JP_SELECT = `
jp.id, jp.entry_id, jp.photo_id, jp.caption, jp.sort_order, jp.shared, jp.created_at,
tkp.provider, tkp.asset_id, tkp.owner_id, tkp.file_path, tkp.thumbnail_path, tkp.width, tkp.height
`;
const JP_JOIN = 'journey_photos jp JOIN trek_photos tkp ON tkp.id = jp.photo_id';
function broadcastJourneyEvent(journeyId: number, event: string, data: Record<string, unknown>, excludeSocketId?: string | number) {
const contributors = db.prepare(
'SELECT user_id FROM journey_contributors WHERE journey_id = ?'
).all(journeyId) as { user_id: number }[];
@@ -16,8 +24,7 @@ function broadcastJourneyEvent(journeyId: number, event: string, data: Record<st
if (owner) userIds.add(owner.user_id);
for (const uid of userIds) {
if (uid === excludeUserId) continue;
broadcastToUser(uid, { type: event, journeyId, ...data });
broadcastToUser(uid, { type: event, journeyId, ...data }, excludeSocketId);
}
}
@@ -105,7 +112,7 @@ export function getJourneyFull(journeyId: number, userId: number) {
).all(journeyId) as JourneyEntry[];
const photos = db.prepare(
'SELECT * FROM journey_photos WHERE entry_id IN (SELECT id FROM journey_entries WHERE journey_id = ?) ORDER BY sort_order ASC'
`SELECT ${JP_SELECT} FROM ${JP_JOIN} WHERE jp.entry_id IN (SELECT id FROM journey_entries WHERE journey_id = ?) ORDER BY jp.sort_order ASC`
).all(journeyId) as JourneyPhoto[];
// group photos by entry
@@ -154,12 +161,17 @@ export function getJourneyFull(journeyId: number, userId: number) {
const photoCount = photos.length;
const cities = [...new Set(entries.map(e => e.location_name).filter(Boolean))];
const userPrefs = db.prepare(
'SELECT hide_skeletons FROM journey_contributors WHERE journey_id = ? AND user_id = ?'
).get(journeyId, userId) as { hide_skeletons: number } | undefined;
return {
...journey,
entries: enrichedEntries,
trips,
contributors,
stats: { entries: entryCount, photos: photoCount, cities: cities.length },
hide_skeletons: !!(userPrefs?.hide_skeletons),
};
}
@@ -190,6 +202,19 @@ export function updateJourney(journeyId: number, userId: number, data: Partial<{
return db.prepare('SELECT * FROM journeys WHERE id = ?').get(journeyId) as Journey;
}
export function updateJourneyPreferences(journeyId: number, userId: number, data: { hide_skeletons?: boolean }) {
if (!canAccessJourney(journeyId, userId)) return null;
if (data.hide_skeletons !== undefined) {
db.prepare(
'UPDATE journey_contributors SET hide_skeletons = ? WHERE journey_id = ? AND user_id = ?'
).run(data.hide_skeletons ? 1 : 0, journeyId, userId);
}
const row = db.prepare(
'SELECT hide_skeletons FROM journey_contributors WHERE journey_id = ? AND user_id = ?'
).get(journeyId, userId) as { hide_skeletons: number };
return { hide_skeletons: !!row.hide_skeletons };
}
export function deleteJourney(journeyId: number, userId: number): boolean {
if (!isOwner(journeyId, userId)) return false;
db.prepare('DELETE FROM journeys WHERE id = ?').run(journeyId);
@@ -210,7 +235,7 @@ export function addTripToJourney(journeyId: number, tripId: number, userId: numb
syncTripPlaces(journeyId, tripId, userId);
// import existing trip photos (Immich/Synology) with sharing settings
syncTripPhotos(journeyId, tripId);
broadcastJourneyEvent(journeyId, 'journey:trip:synced', { tripId }, userId);
broadcastJourneyEvent(journeyId, 'journey:trip:synced', { tripId });
return true;
}
@@ -253,6 +278,7 @@ export function syncTripPlaces(journeyId: number, tripId: number, authorId: numb
for (const place of places) {
if (existingPlaceIds.has(place.id)) continue;
existingPlaceIds.add(place.id);
const entryDate = place.day_date || new Date().toISOString().split('T')[0];
const entryTime = place.assignment_time || place.place_time || null;
@@ -272,8 +298,8 @@ export function syncTripPlaces(journeyId: number, tripId: number, authorId: numb
// import trip_photos into journey when a trip is linked
function syncTripPhotos(journeyId: number, tripId: number) {
const tripPhotos = db.prepare(
'SELECT * FROM trip_photos WHERE trip_id = ?'
).all(tripId) as { id: number; trip_id: number; user_id: number; asset_id: string; provider: string; shared: number }[];
'SELECT tp.photo_id, tp.user_id, tp.shared FROM trip_photos tp WHERE tp.trip_id = ?'
).all(tripId) as { photo_id: number; user_id: number; shared: number }[];
if (!tripPhotos.length) return;
const now = ts();
@@ -285,7 +311,6 @@ function syncTripPhotos(journeyId: number, tripId: number) {
`).get(journeyId, tripId) as { id: number } | undefined;
if (!photoEntry) {
// get trip date for the entry
const trip = db.prepare('SELECT start_date FROM trips WHERE id = ?').get(tripId) as { start_date: string } | undefined;
const entryDate = trip?.start_date || new Date().toISOString().split('T')[0];
const owner = db.prepare('SELECT user_id FROM journeys WHERE id = ?').get(journeyId) as { user_id: number };
@@ -297,19 +322,19 @@ function syncTripPhotos(journeyId: number, tripId: number) {
photoEntry = { id: Number(res.lastInsertRowid) };
}
// import each trip photo, skip duplicates
// import each trip photo, skip duplicates (by photo_id)
for (const tp of tripPhotos) {
const exists = db.prepare(
'SELECT 1 FROM journey_photos WHERE entry_id = ? AND provider = ? AND asset_id = ?'
).get(photoEntry.id, tp.provider, tp.asset_id);
'SELECT 1 FROM journey_photos WHERE entry_id = ? AND photo_id = ?'
).get(photoEntry.id, tp.photo_id);
if (exists) continue;
const maxOrder = db.prepare('SELECT MAX(sort_order) as m FROM journey_photos WHERE entry_id = ?').get(photoEntry.id) as { m: number | null };
db.prepare(`
INSERT INTO journey_photos (entry_id, provider, asset_id, owner_id, shared, sort_order, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
`).run(photoEntry.id, tp.provider, tp.asset_id, tp.user_id, tp.shared, (maxOrder?.m ?? -1) + 1, now);
INSERT INTO journey_photos (entry_id, photo_id, shared, sort_order, created_at)
VALUES (?, ?, ?, ?, ?)
`).run(photoEntry.id, tp.photo_id, tp.shared, (maxOrder?.m ?? -1) + 1, now);
}
}
@@ -424,7 +449,7 @@ export function listEntries(journeyId: number, userId: number) {
).all(journeyId) as JourneyEntry[];
const photos = db.prepare(
'SELECT * FROM journey_photos WHERE entry_id IN (SELECT id FROM journey_entries WHERE journey_id = ?) ORDER BY sort_order ASC'
`SELECT ${JP_SELECT} FROM ${JP_JOIN} WHERE jp.entry_id IN (SELECT id FROM journey_entries WHERE journey_id = ?) ORDER BY jp.sort_order ASC`
).all(journeyId) as JourneyPhoto[];
const photosByEntry: Record<number, JourneyPhoto[]> = {};
@@ -457,7 +482,7 @@ export function createEntry(journeyId: number, userId: number, data: {
tags?: string[];
pros_cons?: { pros: string[]; cons: string[] };
visibility?: string;
}): JourneyEntry | null {
}, sid?: string): JourneyEntry | null {
if (!canEdit(journeyId, userId)) return null;
const now = ts();
@@ -491,7 +516,7 @@ export function createEntry(journeyId: number, userId: number, data: {
);
const created = db.prepare('SELECT * FROM journey_entries WHERE id = ?').get(Number(res.lastInsertRowid)) as JourneyEntry;
broadcastJourneyEvent(journeyId, 'journey:entry:created', { entry: created }, userId);
broadcastJourneyEvent(journeyId, 'journey:entry:created', { entry: created }, sid);
return created;
}
@@ -510,7 +535,7 @@ export function updateEntry(entryId: number, userId: number, data: Partial<{
pros_cons: { pros: string[]; cons: string[] };
visibility: string;
sort_order: number;
}>): JourneyEntry | null {
}>, sid?: string): JourneyEntry | null {
const entry = db.prepare('SELECT * FROM journey_entries WHERE id = ?').get(entryId) as JourneyEntry | undefined;
if (!entry) return null;
if (!canEdit(entry.journey_id, userId)) return null;
@@ -549,18 +574,31 @@ export function updateEntry(entryId: number, userId: number, data: Partial<{
db.prepare('UPDATE journeys SET updated_at = ? WHERE id = ?').run(ts(), entry.journey_id);
const updated = db.prepare('SELECT * FROM journey_entries WHERE id = ?').get(entryId) as JourneyEntry;
broadcastJourneyEvent(entry.journey_id, 'journey:entry:updated', { entry: updated }, userId);
broadcastJourneyEvent(entry.journey_id, 'journey:entry:updated', { entry: updated }, sid);
return updated;
}
export function deleteEntry(entryId: number, userId: number): boolean {
export function deleteEntry(entryId: number, userId: number, sid?: string): boolean {
const entry = db.prepare('SELECT * FROM journey_entries WHERE id = ?').get(entryId) as JourneyEntry | undefined;
if (!entry) return false;
if (!canEdit(entry.journey_id, userId)) return false;
// delete photos along with the entry — no more orphan Gallery entries
db.prepare('DELETE FROM journey_photos WHERE entry_id = ?').run(entryId);
db.prepare('DELETE FROM journey_entries WHERE id = ?').run(entryId);
if (entry.source_trip_id && entry.source_place_id && entry.type !== 'skeleton') {
// Revert filled entry back to skeleton instead of deleting
db.prepare(`
UPDATE journey_entries
SET type = 'skeleton', story = NULL, mood = NULL, weather = NULL, pros_cons = NULL,
visibility = 'private', updated_at = ?
WHERE id = ?
`).run(ts(), entryId);
broadcastJourneyEvent(entry.journey_id, 'journey:entry:updated', { entryId }, sid);
} else {
db.prepare('DELETE FROM journey_entries WHERE id = ?').run(entryId);
broadcastJourneyEvent(entry.journey_id, 'journey:entry:deleted', { entryId }, sid);
}
// clean up any empty Gallery entries in this journey
db.prepare(`
@@ -568,7 +606,6 @@ export function deleteEntry(entryId: number, userId: number): boolean {
AND id NOT IN (SELECT DISTINCT entry_id FROM journey_photos)
`).run(entry.journey_id);
broadcastJourneyEvent(entry.journey_id, 'journey:entry:deleted', { entryId }, userId);
return true;
}
@@ -579,15 +616,16 @@ export function addPhoto(entryId: number, userId: number, filePath: string, thum
if (!entry) return null;
if (!canEdit(entry.journey_id, userId)) return null;
const trekPhotoId = getOrCreateLocalTrekPhoto(filePath, thumbnailPath);
const maxOrder = db.prepare('SELECT MAX(sort_order) as m FROM journey_photos WHERE entry_id = ?').get(entryId) as { m: number | null };
const now = ts();
const res = db.prepare(`
INSERT INTO journey_photos (entry_id, provider, file_path, thumbnail_path, caption, sort_order, created_at)
VALUES (?, 'local', ?, ?, ?, ?, ?)
`).run(entryId, filePath, thumbnailPath || null, caption || null, (maxOrder?.m ?? -1) + 1, now);
INSERT INTO journey_photos (entry_id, photo_id, caption, sort_order, created_at)
VALUES (?, ?, ?, ?, ?)
`).run(entryId, trekPhotoId, caption || null, (maxOrder?.m ?? -1) + 1, now);
return db.prepare('SELECT * FROM journey_photos WHERE 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 {
@@ -595,19 +633,21 @@ export function addProviderPhoto(entryId: number, userId: number, provider: stri
if (!entry) return null;
if (!canEdit(entry.journey_id, userId)) return null;
const trekPhotoId = getOrCreateTrekPhoto(provider, assetId, userId);
// skip if already added
const exists = db.prepare('SELECT 1 FROM journey_photos WHERE entry_id = ? AND provider = ? AND asset_id = ?').get(entryId, provider, assetId);
const exists = db.prepare('SELECT 1 FROM journey_photos WHERE entry_id = ? AND photo_id = ?').get(entryId, trekPhotoId);
if (exists) return null;
const maxOrder = db.prepare('SELECT MAX(sort_order) as m FROM journey_photos WHERE entry_id = ?').get(entryId) as { m: number | null };
const now = ts();
const res = db.prepare(`
INSERT INTO journey_photos (entry_id, provider, asset_id, owner_id, caption, sort_order, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
`).run(entryId, provider, assetId, userId, caption || null, (maxOrder?.m ?? -1) + 1, now);
INSERT INTO journey_photos (entry_id, photo_id, caption, sort_order, created_at)
VALUES (?, ?, ?, ?, ?)
`).run(entryId, trekPhotoId, caption || null, (maxOrder?.m ?? -1) + 1, now);
return db.prepare('SELECT * FROM journey_photos WHERE 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 linkPhotoToEntry(entryId: number, photoId: number, userId: number): JourneyPhoto | null {
@@ -615,7 +655,7 @@ export function linkPhotoToEntry(entryId: number, photoId: number, userId: numbe
if (!entry) return null;
if (!canEdit(entry.journey_id, userId)) return null;
const source = db.prepare('SELECT * FROM journey_photos WHERE id = ?').get(photoId) as JourneyPhoto | undefined;
const source = db.prepare(`SELECT ${JP_SELECT} FROM ${JP_JOIN} WHERE jp.id = ?`).get(photoId) as JourneyPhoto | undefined;
if (!source) return null;
if (source.entry_id === entryId) return source;
@@ -634,16 +674,19 @@ export function linkPhotoToEntry(entryId: number, photoId: number, userId: numbe
}
}
return db.prepare('SELECT * FROM journey_photos WHERE id = ?').get(photoId) as JourneyPhoto;
return db.prepare(`SELECT ${JP_SELECT} FROM ${JP_JOIN} WHERE jp.id = ?`).get(photoId) as JourneyPhoto;
}
export function setPhotoProvider(photoId: number, provider: string, assetId: string, ownerId: number) {
db.prepare('UPDATE journey_photos SET provider = ?, asset_id = ?, owner_id = ? WHERE id = ?').run(provider, assetId, ownerId, photoId);
// Get the trek_photo_id from the journey_photo, then update the central registry
const jp = db.prepare('SELECT photo_id FROM journey_photos WHERE id = ?').get(photoId) as { photo_id: number } | undefined;
if (!jp) return;
setTrekPhotoProvider(jp.photo_id, provider, assetId, ownerId);
}
export function updatePhoto(photoId: number, userId: number, data: { caption?: string; sort_order?: number }): JourneyPhoto | null {
const photo = db.prepare(`
SELECT jp.*, je.journey_id FROM journey_photos jp
SELECT ${JP_SELECT}, je.journey_id FROM ${JP_JOIN}
JOIN journey_entries je ON jp.entry_id = je.id
WHERE jp.id = ?
`).get(photoId) as (JourneyPhoto & { journey_id: number }) | undefined;
@@ -658,12 +701,12 @@ export function updatePhoto(photoId: number, userId: number, data: { caption?: s
values.push(photoId);
db.prepare(`UPDATE journey_photos SET ${fields.join(', ')} WHERE id = ?`).run(...values);
return db.prepare('SELECT * FROM journey_photos WHERE id = ?').get(photoId) as JourneyPhoto;
return db.prepare(`SELECT ${JP_SELECT} FROM ${JP_JOIN} WHERE jp.id = ?`).get(photoId) as JourneyPhoto;
}
export function deletePhoto(photoId: number, userId: number): (JourneyPhoto & { journey_id: number }) | null {
const photo = db.prepare(`
SELECT jp.*, je.journey_id FROM journey_photos jp
SELECT ${JP_SELECT}, je.journey_id FROM ${JP_JOIN}
JOIN journey_entries je ON jp.entry_id = je.id
WHERE jp.id = ?
`).get(photoId) as (JourneyPhoto & { journey_id: number }) | undefined;
+10 -6
View File
@@ -59,7 +59,9 @@ export function validateShareTokenForPhoto(token: string, photoId: number): { jo
const row = db.prepare('SELECT journey_id FROM journey_share_tokens WHERE token = ?').get(token) as any;
if (!row) return null;
const photo = db.prepare(`
SELECT jp.*, je.journey_id FROM journey_photos jp
SELECT jp.photo_id, tkp.owner_id, je.journey_id
FROM journey_photos jp
JOIN trek_photos tkp ON tkp.id = jp.photo_id
JOIN journey_entries je ON jp.entry_id = je.id
WHERE jp.id = ? AND je.journey_id = ?
`).get(photoId, row.journey_id) as any;
@@ -71,14 +73,13 @@ export function validateShareTokenForPhoto(token: string, photoId: number): { jo
export function validateShareTokenForAsset(token: string, assetId: string): { ownerId: number } | null {
const row = db.prepare('SELECT journey_id FROM journey_share_tokens WHERE token = ?').get(token) as any;
if (!row) return null;
// Check if this asset belongs to any photo in the shared journey
const photo = db.prepare(`
SELECT jp.owner_id FROM journey_photos jp
SELECT tkp.owner_id FROM journey_photos jp
JOIN trek_photos tkp ON tkp.id = jp.photo_id
JOIN journey_entries je ON jp.entry_id = je.id
WHERE jp.asset_id = ? AND je.journey_id = ?
WHERE tkp.asset_id = ? AND je.journey_id = ?
`).get(assetId, row.journey_id) as any;
if (!photo) {
// Fallback: get journey owner
const journey = db.prepare('SELECT user_id FROM journeys WHERE id = ?').get(row.journey_id) as any;
return journey ? { ownerId: journey.user_id } : null;
}
@@ -100,7 +101,10 @@ export function getPublicJourney(token: string) {
`).all(row.journey_id) as any[];
const photos = db.prepare(`
SELECT jp.* FROM journey_photos jp
SELECT jp.id, jp.entry_id, jp.photo_id, jp.caption, jp.sort_order, jp.shared, jp.created_at,
tkp.provider, tkp.asset_id, tkp.owner_id, tkp.file_path, tkp.thumbnail_path, tkp.width, tkp.height
FROM journey_photos jp
JOIN trek_photos tkp ON tkp.id = jp.photo_id
JOIN journey_entries je ON jp.entry_id = je.id
WHERE je.journey_id = ?
ORDER BY jp.sort_order
+58 -11
View File
@@ -129,15 +129,15 @@ export function canAccessUserPhoto(requestingUserId: number, ownerUserId: number
const journeyPhoto = db.prepare(`
SELECT jp.entry_id, je.journey_id
FROM journey_photos jp
JOIN trek_photos tkp ON tkp.id = jp.photo_id
JOIN journey_entries je ON je.id = jp.entry_id
WHERE jp.asset_id = ?
AND jp.provider = ?
AND jp.owner_id = ?
WHERE tkp.asset_id = ?
AND tkp.provider = ?
AND tkp.owner_id = ?
LIMIT 1
`).get(assetId, provider, ownerUserId) as { entry_id: number; journey_id: number } | undefined;
if (!journeyPhoto) return false;
// Check if requesting user is the journey owner or a contributor
const access = db.prepare(`
SELECT 1 FROM journeys WHERE id = ? AND user_id = ?
UNION ALL
@@ -147,15 +147,16 @@ export function canAccessUserPhoto(requestingUserId: number, ownerUserId: number
return !!access;
}
// Regular trip photos
// Regular trip photos — join through trek_photos
const sharedAsset = db.prepare(`
SELECT 1
FROM trip_photos
WHERE user_id = ?
AND asset_id = ?
AND provider = ?
AND trip_id = ?
AND shared = 1
FROM trip_photos tp
JOIN trek_photos tkp ON tkp.id = tp.photo_id
WHERE tp.user_id = ?
AND tkp.asset_id = ?
AND tkp.provider = ?
AND tp.trip_id = ?
AND tp.shared = 1
LIMIT 1
`).get(ownerUserId, assetId, provider, tripId);
@@ -166,6 +167,52 @@ export function canAccessUserPhoto(requestingUserId: number, ownerUserId: number
}
// ── Unified photo access check (trek_photos based) ──────────────────────
export function canAccessTrekPhoto(requestingUserId: number, trekPhotoId: number): boolean {
const photo = db.prepare('SELECT * FROM trek_photos WHERE id = ?').get(trekPhotoId) as { id: number; provider: string; owner_id: number | null } | undefined;
if (!photo) return false;
// Owner always has access
if (photo.owner_id === requestingUserId) return true;
// Check trip_photos — is this photo shared in a trip the user has access to?
const tripAccess = db.prepare(`
SELECT 1 FROM trip_photos tp
WHERE tp.photo_id = ?
AND tp.shared = 1
AND EXISTS (
SELECT 1 FROM trip_members tm WHERE tm.trip_id = tp.trip_id AND tm.user_id = ?
UNION ALL
SELECT 1 FROM trips t WHERE t.id = tp.trip_id AND t.user_id = ?
)
LIMIT 1
`).get(trekPhotoId, requestingUserId, requestingUserId);
if (tripAccess) return true;
// Check journey_photos — is this photo in a journey the user can access?
const journeyAccess = db.prepare(`
SELECT 1 FROM journey_photos jp
JOIN journey_entries je ON je.id = jp.entry_id
WHERE jp.photo_id = ?
AND EXISTS (
SELECT 1 FROM journeys j WHERE j.id = je.journey_id AND j.user_id = ?
UNION ALL
SELECT 1 FROM journey_contributors jc WHERE jc.journey_id = je.journey_id AND jc.user_id = ?
)
LIMIT 1
`).get(trekPhotoId, requestingUserId, requestingUserId);
if (journeyAccess) return true;
// Local photos without owner (uploaded files) — check if user has journey access
if (photo.provider === 'local' && !photo.owner_id) {
return !!journeyAccess;
}
return false;
}
// ----------------------------------------------
//helpers for album link syncing
+68 -34
View File
@@ -149,44 +149,36 @@ export async function browseTimeline(
export async function searchPhotos(
userId: number,
from?: string,
to?: string
): Promise<{ assets?: any[]; error?: string; status?: number }> {
to?: string,
page: number = 1,
size: number = 50,
): Promise<{ assets?: any[]; hasMore?: boolean; error?: string; status?: number }> {
const creds = getImmichCredentials(userId);
if (!creds) return { error: 'Immich not configured', status: 400 };
try {
// Paginate through all results (Immich limits per-page to 1000)
const allAssets: any[] = [];
let page = 1;
const pageSize = 1000;
while (true) {
const resp = await safeFetch(`${creds.immich_url}/api/search/metadata`, {
method: 'POST',
headers: { 'x-api-key': creds.immich_api_key, 'Content-Type': 'application/json' },
body: JSON.stringify({
takenAfter: from ? `${from}T00:00:00.000Z` : undefined,
takenBefore: to ? `${to}T23:59:59.999Z` : undefined,
type: 'IMAGE',
size: pageSize,
page,
}),
signal: AbortSignal.timeout(15000) as any,
});
if (!resp.ok) return { error: 'Search failed', status: resp.status };
const data = await resp.json() as { assets?: { items?: any[] } };
const items = data.assets?.items || [];
allAssets.push(...items);
if (items.length < pageSize) break; // Last page
page++;
if (page > 20) break; // Safety limit (20k photos max)
}
const assets = allAssets.map((a: any) => ({
const resp = await safeFetch(`${creds.immich_url}/api/search/metadata`, {
method: 'POST',
headers: { 'x-api-key': creds.immich_api_key, 'Content-Type': 'application/json' },
body: JSON.stringify({
takenAfter: from ? `${from}T00:00:00.000Z` : undefined,
takenBefore: to ? `${to}T23:59:59.999Z` : undefined,
type: 'IMAGE',
size,
page,
}),
signal: AbortSignal.timeout(15000) as any,
});
if (!resp.ok) return { error: 'Search failed', status: resp.status };
const data = await resp.json() as { assets?: { items?: any[] } };
const items = data.assets?.items || [];
const assets = items.map((a: any) => ({
id: a.id,
takenAt: a.fileCreatedAt || a.createdAt,
city: a.exifInfo?.city || null,
country: a.exifInfo?.country || null,
}));
return { assets };
return { assets, hasMore: items.length >= size };
} catch {
return { error: 'Could not reach Immich', status: 502 };
}
@@ -266,18 +258,34 @@ export async function listAlbums(
if (!creds) return { error: 'Immich not configured', status: 400 };
try {
const resp = await safeFetch(`${creds.immich_url}/api/albums`, {
headers: { 'x-api-key': creds.immich_api_key, 'Accept': 'application/json' },
signal: AbortSignal.timeout(10000) as any,
// Fetch both owned and shared albums
const [ownResp, sharedResp] = await Promise.all([
safeFetch(`${creds.immich_url}/api/albums`, {
headers: { 'x-api-key': creds.immich_api_key, 'Accept': 'application/json' },
signal: AbortSignal.timeout(10000) as any,
}),
safeFetch(`${creds.immich_url}/api/albums?shared=true`, {
headers: { 'x-api-key': creds.immich_api_key, 'Accept': 'application/json' },
signal: AbortSignal.timeout(10000) as any,
}),
]);
if (!ownResp.ok) return { error: 'Failed to fetch albums', status: ownResp.status };
const ownAlbums = await ownResp.json() as any[];
const sharedAlbums = sharedResp.ok ? await sharedResp.json() as any[] : [];
const seenIds = new Set<string>();
const allAlbums = [...ownAlbums, ...sharedAlbums].filter((a: any) => {
if (seenIds.has(a.id)) return false;
seenIds.add(a.id);
return true;
});
if (!resp.ok) return { error: 'Failed to fetch albums', status: resp.status };
const albums = (await resp.json() as any[]).map((a: any) => ({
const albums = allAlbums.map((a: any) => ({
id: a.id,
albumName: a.albumName,
assetCount: a.assetCount || 0,
startDate: a.startDate,
endDate: a.endDate,
albumThumbnailAssetId: a.albumThumbnailAssetId,
shared: a.shared || a.sharedUsers?.length > 0,
}));
return { albums };
} catch {
@@ -285,6 +293,32 @@ export async function listAlbums(
}
}
export async function getAlbumPhotos(
userId: number,
albumId: string,
): Promise<{ assets?: any[]; error?: string; status?: number }> {
const creds = getImmichCredentials(userId);
if (!creds) return { error: 'Immich not configured', status: 400 };
try {
const resp = await safeFetch(`${creds.immich_url}/api/albums/${albumId}`, {
headers: { 'x-api-key': creds.immich_api_key, 'Accept': 'application/json' },
signal: AbortSignal.timeout(15000) as any,
});
if (!resp.ok) return { error: 'Failed to fetch album', status: resp.status };
const albumData = await resp.json() as { assets?: any[] };
const assets = (albumData.assets || []).filter((a: any) => a.type === 'IMAGE').map((a: any) => ({
id: a.id,
takenAt: a.fileCreatedAt || a.createdAt,
city: a.exifInfo?.city || null,
country: a.exifInfo?.country || null,
}));
return { assets };
} catch {
return { error: 'Could not reach Immich', status: 502 };
}
}
export function listAlbumLinks(tripId: string) {
return db.prepare(`
SELECT tal.*, u.username
@@ -0,0 +1,141 @@
import { Response } from 'express';
import path from 'path';
import fs from 'fs';
import { db } from '../../db/database';
import type { TrekPhoto } from '../../types';
import { streamImmichAsset, getAssetInfo as getImmichAssetInfo } from './immichService';
import { streamSynologyAsset, getSynologyAssetInfo } from './synologyService';
import type { ServiceResult, AssetInfo } from './helpersService';
import { fail, success } from './helpersService';
// ── Lookup / Register ────────────────────────────────────────────────────
export function getOrCreateTrekPhoto(
provider: string,
assetId: string,
ownerId: number,
): number {
const existing = db.prepare(
'SELECT id FROM trek_photos WHERE provider = ? AND asset_id = ? AND owner_id = ?'
).get(provider, assetId, ownerId) as { id: number } | undefined;
if (existing) return existing.id;
const res = db.prepare(
'INSERT INTO trek_photos (provider, asset_id, owner_id) VALUES (?, ?, ?)'
).run(provider, assetId, ownerId);
return Number(res.lastInsertRowid);
}
export function getOrCreateLocalTrekPhoto(
filePath: string,
thumbnailPath?: string | null,
width?: number | null,
height?: number | null,
): number {
const existing = db.prepare(
"SELECT id FROM trek_photos WHERE provider = 'local' AND file_path = ?"
).get(filePath) as { id: number } | undefined;
if (existing) return existing.id;
const res = db.prepare(
'INSERT INTO trek_photos (provider, file_path, thumbnail_path, width, height) VALUES (?, ?, ?, ?, ?)'
).run('local', filePath, thumbnailPath || null, width || null, height || null);
return Number(res.lastInsertRowid);
}
export function resolveTrekPhoto(photoId: number): TrekPhoto | null {
return db.prepare('SELECT * FROM trek_photos WHERE id = ?').get(photoId) as TrekPhoto | undefined || null;
}
// ── Streaming ────────────────────────────────────────────────────────────
export async function streamPhoto(
res: Response,
userId: number,
photoId: number,
kind: 'thumbnail' | 'original',
): Promise<void> {
const photo = resolveTrekPhoto(photoId);
if (!photo) {
res.status(404).json({ error: 'Photo not found' });
return;
}
switch (photo.provider) {
case 'local': {
const filePath = path.join(__dirname, '../../../uploads', photo.file_path!);
if (!fs.existsSync(filePath)) {
res.status(404).json({ error: 'File not found' });
return;
}
res.set('Cache-Control', 'public, max-age=86400');
res.sendFile(filePath);
return;
}
case 'immich': {
await streamImmichAsset(res, userId, photo.asset_id!, kind, photo.owner_id!);
return;
}
case 'synologyphotos': {
await streamSynologyAsset(res, userId, photo.owner_id!, photo.asset_id!, kind);
return;
}
default:
res.status(400).json({ error: `Unknown provider: ${photo.provider}` });
}
}
// ── Asset Info ────────────────────────────────────────────────────────────
export async function getPhotoInfo(
userId: number,
photoId: number,
): Promise<ServiceResult<AssetInfo>> {
const photo = resolveTrekPhoto(photoId);
if (!photo) return fail('Photo not found', 404);
switch (photo.provider) {
case 'local': {
return success({
id: String(photo.id),
takenAt: photo.created_at,
city: null,
country: null,
width: photo.width,
height: photo.height,
fileName: photo.file_path?.split('/').pop() || null,
} as AssetInfo);
}
case 'immich': {
const result = await getImmichAssetInfo(userId, photo.asset_id!, photo.owner_id!);
if (result.error) return fail(result.error, result.status || 500);
return success(result.data as AssetInfo);
}
case 'synologyphotos': {
return getSynologyAssetInfo(userId, photo.asset_id!, photo.owner_id!);
}
default:
return fail(`Unknown provider: ${photo.provider}`, 400);
}
}
// ── Update provider on existing trek_photo (for Immich upload sync) ─────
export function setTrekPhotoProvider(
trekPhotoId: number,
provider: string,
assetId: string,
ownerId: number,
): void {
db.prepare(
'UPDATE trek_photos SET provider = ?, asset_id = ?, owner_id = ? WHERE id = ?'
).run(provider, assetId, ownerId, trekPhotoId);
}
// ── Delete local file for a trek_photo ──────────────────────────────────
export function getTrekPhotoFilePath(photoId: number): string | null {
const photo = resolveTrekPhoto(photoId);
if (!photo || photo.provider !== 'local' || !photo.file_path) return null;
return path.join(__dirname, '../../../uploads', photo.file_path);
}
@@ -452,6 +452,36 @@ export async function listSynologyAlbums(userId: number): Promise<ServiceResult<
}
export async function getSynologyAlbumPhotos(userId: number, albumId: string): Promise<ServiceResult<AssetsList>> {
const allItems: SynologyPhotoItem[] = [];
const pageSize = 1000;
let offset = 0;
while (true) {
const result = await _requestSynologyApi<{ list: SynologyPhotoItem[] }>(userId, {
api: 'SYNO.Foto.Browse.Item',
method: 'list',
version: 1,
album_id: Number(albumId),
offset,
limit: pageSize,
additional: ['thumbnail'],
});
if (!result.success) return result as ServiceResult<AssetsList>;
const items = result.data.list || [];
allItems.push(...items);
if (items.length < pageSize) break;
offset += pageSize;
}
const assets = allItems.map(item => ({
id: String(item.additional?.thumbnail?.cache_key || item.id || ''),
takenAt: item.time ? new Date(item.time * 1000).toISOString() : '',
})).filter(a => a.id);
return success({ assets, total: assets.length, hasMore: false });
}
export async function syncSynologyAlbumLink(userId: number, tripId: string, linkId: string, sid: string): Promise<ServiceResult<SyncAlbumResult>> {
const response = getAlbumIdFromLink(tripId, linkId, userId);
if (!response.success) return response as ServiceResult<SyncAlbumResult>;
@@ -555,7 +585,6 @@ export async function streamSynologyAsset(
targetUserId: number,
photoId: string,
kind: 'thumbnail' | 'original',
size?: string,
): Promise<void> {
const parsedId = _splitPackedSynologyId(photoId);
if (!parsedId) {
@@ -579,6 +608,8 @@ export async function streamSynologyAsset(
return;
}
//size: 'sm' 240px| 'm' 320px| 'xl' 1280px| 'preview' ?
const params = kind === 'thumbnail'
? new URLSearchParams({
api: 'SYNO.Foto.Thumbnail',
@@ -587,7 +618,7 @@ export async function streamSynologyAsset(
mode: 'download',
id: parsedId.id,
type: 'unit',
size: size,
size: 'sm',
cache_key: parsedId.cacheKey,
_sid: sid.data,
})
+13 -14
View File
@@ -8,6 +8,7 @@ import {
mapDbError,
Selection,
} from './helpersService';
import { getOrCreateTrekPhoto } from './photoResolverService';
function _providers(): Array<{id: string; enabled: boolean}> {
@@ -45,13 +46,14 @@ export function listTripPhotos(tripId: string, userId: number): ServiceResult<an
}
const photos = db.prepare(`
SELECT tp.asset_id, tp.provider, tp.user_id, tp.shared, tp.added_at,
SELECT tp.photo_id, tkp.asset_id, tkp.provider, tp.user_id, tp.shared, tp.added_at,
u.username, u.avatar
FROM trip_photos tp
JOIN trek_photos tkp ON tkp.id = tp.photo_id
JOIN users u ON tp.user_id = u.id
WHERE tp.trip_id = ?
AND (tp.user_id = ? OR tp.shared = 1)
AND tp.provider IN (${enabledProviders.map(() => '?').join(',')})
AND tkp.provider IN (${enabledProviders.map(() => '?').join(',')})
ORDER BY tp.added_at ASC
`).all(tripId, userId, ...enabledProviders);
@@ -108,9 +110,10 @@ function _addTripPhoto(tripId: string, userId: number, provider: string, assetId
return providerResult as ServiceResult<boolean>;
}
try {
const photoId = getOrCreateTrekPhoto(provider, assetId, userId);
const result = db.prepare(
'INSERT OR IGNORE INTO trip_photos (trip_id, user_id, asset_id, provider, shared, album_link_id) VALUES (?, ?, ?, ?, ?, ?)'
).run(tripId, userId, assetId, provider, shared ? 1 : 0, albumLinkId || null);
'INSERT OR IGNORE INTO trip_photos (trip_id, user_id, photo_id, shared, album_link_id) VALUES (?, ?, ?, ?, ?)'
).run(tripId, userId, photoId, shared ? 1 : 0, albumLinkId || null);
return success(result.changes > 0);
}
catch (error) {
@@ -163,8 +166,7 @@ export async function addTripPhotos(
export async function setTripPhotoSharing(
tripId: string,
userId: number,
provider: string,
assetId: string,
photoId: number,
shared: boolean,
sid?: string,
): Promise<ServiceResult<true>> {
@@ -179,9 +181,8 @@ export async function setTripPhotoSharing(
SET shared = ?
WHERE trip_id = ?
AND user_id = ?
AND asset_id = ?
AND provider = ?
`).run(shared ? 1 : 0, tripId, userId, assetId, provider);
AND photo_id = ?
`).run(shared ? 1 : 0, tripId, userId, photoId);
await _notifySharedTripPhotos(tripId, userId, 1);
broadcast(tripId, 'memories:updated', { userId }, sid);
@@ -194,8 +195,7 @@ export async function setTripPhotoSharing(
export function removeTripPhoto(
tripId: string,
userId: number,
provider: string,
assetId: string,
photoId: number,
sid?: string,
): ServiceResult<true> {
const access = canAccessTrip(tripId, userId);
@@ -208,9 +208,8 @@ export function removeTripPhoto(
DELETE FROM trip_photos
WHERE trip_id = ?
AND user_id = ?
AND asset_id = ?
AND provider = ?
`).run(tripId, userId, assetId, provider);
AND photo_id = ?
`).run(tripId, userId, photoId);
broadcast(tripId, 'memories:updated', { userId }, sid);
+17 -2
View File
@@ -399,9 +399,24 @@ export async function sendWebhook(url: string, payload: { event: string; title:
}
export async function testSmtp(to: string): Promise<{ success: boolean; error?: string }> {
if (!getSmtpConfig()) return { success: false, error: 'SMTP not configured' };
try {
const sent = await sendEmail(to, 'Test Notification', 'This is a test email from TREK. If you received this, your SMTP configuration is working correctly.');
return sent ? { success: true } : { success: false, error: 'SMTP not configured' };
const config = getSmtpConfig()!;
const skipTls = process.env.SMTP_SKIP_TLS_VERIFY === 'true' || getAppSetting('smtp_skip_tls_verify') === 'true';
const transporter = nodemailer.createTransport({
host: config.host,
port: config.port,
secure: config.secure,
auth: config.user ? { user: config.user, pass: config.pass } : undefined,
...(skipTls ? { tls: { rejectUnauthorized: false } } : {}),
});
await transporter.sendMail({
from: config.from,
to,
subject: 'TREK — Test Notification',
text: 'This is a test email from TREK. If you received this, your SMTP configuration is working correctly.',
});
return { success: true };
} catch (err) {
return { success: false, error: err instanceof Error ? err.message : 'Unknown error' };
}
+8 -2
View File
@@ -197,7 +197,10 @@ export async function getWeather(
if (diffDays > -1) {
const month = targetDate.getMonth() + 1;
const day = targetDate.getDate();
const refYear = targetDate.getFullYear() - 1;
let refYear = targetDate.getFullYear() - 1;
// Archive API only has data up to yesterday — go back further if needed
const yesterday = new Date(now.getTime() - 86400000);
if (new Date(refYear, month - 1, day + 2) > yesterday) refYear--;
const startDate = new Date(refYear, month - 1, day - 2);
const endDate = new Date(refYear, month - 1, day + 2);
const startStr = startDate.toISOString().slice(0, 10);
@@ -299,7 +302,10 @@ export async function getDetailedWeather(
// Climate / archive path (> 16 days out)
if (diffDays > 16) {
const refYear = targetDate.getFullYear() - 1;
let refYear = targetDate.getFullYear() - 1;
// Archive API only has data up to yesterday — go back further if needed
const yesterday = new Date(now.getTime() - 86400000);
if (new Date(refYear, targetDate.getMonth(), targetDate.getDate()) > yesterday) refYear--;
const refDateStr = `${refYear}-${String(targetDate.getMonth() + 1).padStart(2, '0')}-${String(targetDate.getDate()).padStart(2, '0')}`;
const url = `https://archive-api.open-meteo.com/v1/archive?latitude=${lat}&longitude=${lng}`
+19 -5
View File
@@ -339,20 +339,34 @@ export interface JourneyEntry {
updated_at: number;
}
export interface JourneyPhoto {
export interface TrekPhoto {
id: number;
entry_id: number;
provider: 'local' | 'immich' | 'synologyphotos';
provider: string;
asset_id?: string | null;
owner_id?: number | null;
file_path?: string | null;
thumbnail_path?: string | null;
caption?: string | null;
sort_order: number;
width?: number | null;
height?: number | null;
created_at: string;
}
export interface JourneyPhoto {
id: number;
entry_id: number;
photo_id: number;
caption?: string | null;
sort_order: number;
shared: number;
created_at: number;
// Joined from trek_photos for API responses
provider?: string;
asset_id?: string | null;
owner_id?: number | null;
file_path?: string | null;
thumbnail_path?: string | null;
width?: number | null;
height?: number | null;
}
export interface JourneyTrip {
+16 -3
View File
@@ -558,10 +558,23 @@ export function addTripPhoto(
provider: string,
opts: { shared?: boolean; albumLinkId?: number } = {}
): TestTripPhoto {
// Insert into trek_photos first (central registry)
db.prepare(
'INSERT OR IGNORE INTO trek_photos (provider, asset_id, owner_id) VALUES (?, ?, ?)'
).run(provider, assetId, userId);
const trekPhoto = db.prepare(
'SELECT id FROM trek_photos WHERE provider = ? AND asset_id = ? AND owner_id = ?'
).get(provider, assetId, userId) as { id: number };
const result = db.prepare(
'INSERT OR IGNORE INTO trip_photos (trip_id, user_id, asset_id, provider, shared, album_link_id) VALUES (?, ?, ?, ?, ?, ?)'
).run(tripId, userId, assetId, provider, opts.shared ? 1 : 0, opts.albumLinkId ?? null);
return db.prepare('SELECT * FROM trip_photos WHERE id = ?').get(result.lastInsertRowid) as TestTripPhoto;
'INSERT OR IGNORE INTO trip_photos (trip_id, user_id, photo_id, shared, album_link_id) VALUES (?, ?, ?, ?, ?)'
).run(tripId, userId, trekPhoto.id, opts.shared ? 1 : 0, opts.albumLinkId ?? null);
return db.prepare(`
SELECT tp.id, tp.trip_id, tp.user_id, tkp.asset_id, tkp.provider, tp.shared, tp.album_link_id
FROM trip_photos tp
JOIN trek_photos tkp ON tkp.id = tp.photo_id
WHERE tp.id = ?
`).get(result.lastInsertRowid) as TestTripPhoto;
}
export interface TestAlbumLink {
+21 -6
View File
@@ -190,11 +190,16 @@ describe('Immich album links', () => {
.get(trip.id, user.id, 'album-xyz', 'Album XYZ', 'immich') as any;
// Insert photos synced from the album
testDb.prepare('INSERT INTO trip_photos (trip_id, user_id, asset_id, provider, shared, album_link_id) VALUES (?, ?, ?, ?, 1, ?)').run(trip.id, user.id, 'asset-001', 'immich', linkResult.id);
testDb.prepare('INSERT INTO trip_photos (trip_id, user_id, asset_id, provider, shared, album_link_id) VALUES (?, ?, ?, ?, 1, ?)').run(trip.id, user.id, 'asset-002', 'immich', linkResult.id);
for (const assetId of ['asset-001', 'asset-002']) {
testDb.prepare('INSERT OR IGNORE INTO trek_photos (provider, asset_id, owner_id) VALUES (?, ?, ?)').run('immich', assetId, user.id);
const tkp = testDb.prepare('SELECT id FROM trek_photos WHERE provider = ? AND asset_id = ? AND owner_id = ?').get('immich', assetId, user.id) as any;
testDb.prepare('INSERT INTO trip_photos (trip_id, user_id, photo_id, shared, album_link_id) VALUES (?, ?, ?, 1, ?)').run(trip.id, user.id, tkp.id, linkResult.id);
}
// Insert an individually-added photo (no album_link_id)
testDb.prepare('INSERT INTO trip_photos (trip_id, user_id, asset_id, provider, shared) VALUES (?, ?, ?, ?, 1)').run(trip.id, user.id, 'asset-manual', 'immich');
testDb.prepare('INSERT OR IGNORE INTO trek_photos (provider, asset_id, owner_id) VALUES (?, ?, ?)').run('immich', 'asset-manual', user.id);
const tkpManual = testDb.prepare('SELECT id FROM trek_photos WHERE provider = ? AND asset_id = ? AND owner_id = ?').get('immich', 'asset-manual', user.id) as any;
testDb.prepare('INSERT INTO trip_photos (trip_id, user_id, photo_id, shared) VALUES (?, ?, ?, 1)').run(trip.id, user.id, tkpManual.id);
const res = await request(app)
.delete(`/api/integrations/memories/unified/trips/${trip.id}/album-links/${linkResult.id}`)
@@ -204,7 +209,11 @@ describe('Immich album links', () => {
expect(res.body.success).toBe(true);
// Album-linked photos should be gone
const remainingPhotos = testDb.prepare('SELECT * FROM trip_photos WHERE trip_id = ?').all(trip.id) as any[];
const remainingPhotos = testDb.prepare(`
SELECT tp.*, tkp.asset_id FROM trip_photos tp
JOIN trek_photos tkp ON tkp.id = tp.photo_id
WHERE tp.trip_id = ?
`).all(trip.id) as any[];
expect(remainingPhotos.length).toBe(1);
expect(remainingPhotos[0].asset_id).toBe('asset-manual');
@@ -220,7 +229,9 @@ describe('Immich album links', () => {
const linkResult = testDb.prepare('INSERT INTO trip_album_links (trip_id, user_id, album_id, album_name, provider) VALUES (?, ?, ?, ?, ?) RETURNING *')
.get(trip.id, owner.id, 'album-secret', 'Secret Album', 'immich') as any;
testDb.prepare('INSERT INTO trip_photos (trip_id, user_id, asset_id, provider, shared, album_link_id) VALUES (?, ?, ?, ?, 1, ?)').run(trip.id, owner.id, 'asset-owned', 'immich', linkResult.id);
testDb.prepare('INSERT OR IGNORE INTO trek_photos (provider, asset_id, owner_id) VALUES (?, ?, ?)').run('immich', 'asset-owned', owner.id);
const tkpOwned = testDb.prepare('SELECT id FROM trek_photos WHERE provider = ? AND asset_id = ? AND owner_id = ?').get('immich', 'asset-owned', owner.id) as any;
testDb.prepare('INSERT INTO trip_photos (trip_id, user_id, photo_id, shared, album_link_id) VALUES (?, ?, ?, 1, ?)').run(trip.id, owner.id, tkpOwned.id, linkResult.id);
// Non-member tries to delete owner's album link — should be denied
const res = await request(app)
@@ -232,7 +243,11 @@ describe('Immich album links', () => {
// Link and photos should still exist
const link = testDb.prepare('SELECT * FROM trip_album_links WHERE id = ?').get(linkResult.id);
expect(link).toBeDefined();
const photo = testDb.prepare('SELECT * FROM trip_photos WHERE asset_id = ?').get('asset-owned');
const photo = testDb.prepare(`
SELECT tp.* FROM trip_photos tp
JOIN trek_photos tkp ON tkp.id = tp.photo_id
WHERE tkp.asset_id = ?
`).get('asset-owned');
expect(photo).toBeDefined();
});
@@ -119,8 +119,8 @@ vi.mock('../../src/utils/ssrfGuard', async () => {
body: null,
});
}
// /api/albums — list albums
if (/\/api\/albums$/.test(u)) {
// /api/albums — list albums (owned and shared?=true variant)
if (/\/api\/albums(\?.*)?$/.test(u)) {
return Promise.resolve({
ok: true, status: 200,
headers: { get: () => null },
@@ -415,9 +415,11 @@ describe('Immich asset proxy', () => {
const { user: member } = createUser(testDb);
// Insert a shared photo referencing a trip that doesn't exist (FK disabled temporarily)
testDb.exec('PRAGMA foreign_keys = OFF');
testDb.prepare('INSERT OR IGNORE INTO trek_photos (provider, asset_id, owner_id) VALUES (?, ?, ?)').run('immich', 'asset-notrip', owner.id);
const tkpNotrip = testDb.prepare('SELECT id FROM trek_photos WHERE provider = ? AND asset_id = ? AND owner_id = ?').get('immich', 'asset-notrip', owner.id) as any;
testDb.prepare(
'INSERT INTO trip_photos (trip_id, user_id, asset_id, provider, shared) VALUES (?, ?, ?, ?, ?)'
).run(9999, owner.id, 'asset-notrip', 'immich', 1);
'INSERT INTO trip_photos (trip_id, user_id, photo_id, shared) VALUES (?, ?, ?, ?)'
).run(9999, owner.id, tkpNotrip.id, 1);
testDb.exec('PRAGMA foreign_keys = ON');
const res = await request(app)
@@ -531,7 +533,11 @@ describe('Immich syncAlbumAssets', () => {
expect(typeof res.body.added).toBe('number');
// Verify photos were inserted into the DB
const photos = testDb.prepare('SELECT * FROM trip_photos WHERE trip_id = ? AND user_id = ?').all(trip.id, user.id) as any[];
const photos = testDb.prepare(`
SELECT tp.*, tkp.provider FROM trip_photos tp
JOIN trek_photos tkp ON tkp.id = tp.photo_id
WHERE tp.trip_id = ? AND tp.user_id = ?
`).all(trip.id, user.id) as any[];
expect(photos.length).toBeGreaterThan(0);
expect(photos[0].provider).toBe('immich');
});
@@ -51,14 +51,16 @@ vi.mock('../../src/utils/ssrfGuard', async () => {
// Determine which API was called from the URL query param (e.g. ?api=SYNO.API.Auth)
// or from the body for POST requests.
let apiName = '';
let params = new URLSearchParams();
try {
apiName = new URL(u).searchParams.get('api') || '';
params = new URL(u).searchParams;
apiName = params.get('api') || '';
} catch {}
if (!apiName && init?.body) {
const body = init.body instanceof URLSearchParams
params = init.body instanceof URLSearchParams
? init.body
: new URLSearchParams(String(init.body));
apiName = body.get('api') || '';
apiName = params.get('api') || '';
}
// Auth login — used by settings save, status, test-connection
@@ -154,6 +156,8 @@ vi.mock('../../src/utils/ssrfGuard', async () => {
// Thumbnail stream
if (apiName === 'SYNO.Foto.Thumbnail') {
if (!(['sm', 'm', 'xl', 'preview'].includes(params.get('size') || '')))
return Promise.reject(new Error(`Unexpected thumbnail size: ${params.get('size')}`));
const imageBytes = Buffer.from('fake-synology-thumbnail');
return Promise.resolve({
ok: true, status: 200,
@@ -437,6 +441,24 @@ describe('Synology asset access', () => {
expect(res.headers['content-type']).toContain('image/jpeg');
});
it('SYNO-032b — GET /api/photos/:id/thumbnail uses an allowed Synology thumbnail size', async () => {
const { user } = createUser(testDb);
setSynologyCredentials(testDb, user.id, 'https://synology.example.com', 'admin', 'pass');
const insert = testDb.prepare(
'INSERT INTO trek_photos (provider, asset_id, owner_id) VALUES (?, ?, ?)'
).run('synologyphotos', '101_cachekey', user.id);
const trekPhotoId = Number(insert.lastInsertRowid);
vi.mocked(safeFetch).mockClear();
const res = await request(app)
.get(`/api/photos/${trekPhotoId}/thumbnail`)
.set('Cookie', authCookie(user.id));
expect(res.status).toBe(200);
});
it('SYNO-033 — GET /assets/original streams image data for shared photo', async () => {
const { user: owner } = createUser(testDb);
const { user: member } = createUser(testDb);
@@ -470,9 +492,11 @@ describe('Synology asset access', () => {
const { user: member } = createUser(testDb);
// Insert a shared photo referencing a trip that doesn't exist (FK disabled temporarily)
testDb.exec('PRAGMA foreign_keys = OFF');
testDb.prepare('INSERT OR IGNORE INTO trek_photos (provider, asset_id, owner_id) VALUES (?, ?, ?)').run('synologyphotos', '101_cachekey', owner.id);
const tkpSyno35 = testDb.prepare('SELECT id FROM trek_photos WHERE provider = ? AND asset_id = ? AND owner_id = ?').get('synologyphotos', '101_cachekey', owner.id) as any;
testDb.prepare(
'INSERT INTO trip_photos (trip_id, user_id, asset_id, provider, shared) VALUES (?, ?, ?, ?, ?)'
).run(9999, owner.id, '101_cachekey', 'synologyphotos', 1);
'INSERT INTO trip_photos (trip_id, user_id, photo_id, shared) VALUES (?, ?, ?, ?)'
).run(9999, owner.id, tkpSyno35.id, 1);
testDb.exec('PRAGMA foreign_keys = ON');
const res = await request(app)
@@ -568,7 +592,11 @@ describe('Synology syncSynologyAlbumLink', () => {
expect(typeof res.body.total).toBe('number');
// Verify photos were inserted into the DB
const photos = testDb.prepare('SELECT * FROM trip_photos WHERE trip_id = ? AND user_id = ?').all(trip.id, user.id) as any[];
const photos = testDb.prepare(`
SELECT tp.*, tkp.provider FROM trip_photos tp
JOIN trek_photos tkp ON tkp.id = tp.photo_id
WHERE tp.trip_id = ? AND tp.user_id = ?
`).all(trip.id, user.id) as any[];
expect(photos.length).toBeGreaterThan(0);
expect(photos[0].provider).toBe('synologyphotos');
});
@@ -146,7 +146,11 @@ describe('Unified photo management', () => {
expect(res.status).toBe(200);
expect(res.body.added).toBe(2);
const rows = testDb.prepare('SELECT asset_id FROM trip_photos WHERE trip_id = ?').all(trip.id) as any[];
const rows = testDb.prepare(`
SELECT tkp.asset_id FROM trip_photos tp
JOIN trek_photos tkp ON tkp.id = tp.photo_id
WHERE tp.trip_id = ?
`).all(trip.id) as any[];
expect(rows.map((r: any) => r.asset_id)).toEqual(expect.arrayContaining(['asset-a', 'asset-b']));
});
@@ -178,14 +182,23 @@ describe('Unified photo management', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
addTripPhoto(testDb, trip.id, user.id, 'asset-tog', 'immich', { shared: false });
const trekRef = testDb.prepare(`
SELECT tp.photo_id FROM trip_photos tp
JOIN trek_photos tkp ON tkp.id = tp.photo_id
WHERE tp.trip_id = ? AND tkp.asset_id = ?
`).get(trip.id, 'asset-tog') as any;
const res = await request(app)
.put(`${photosUrl(trip.id)}/sharing`)
.set('Cookie', authCookie(user.id))
.send({ provider: 'immich', asset_id: 'asset-tog', shared: true });
.send({ photo_id: trekRef.photo_id, shared: true });
expect(res.status).toBe(200);
const row = testDb.prepare('SELECT shared FROM trip_photos WHERE asset_id = ?').get('asset-tog') as any;
const row = testDb.prepare(`
SELECT tp.shared FROM trip_photos tp
JOIN trek_photos tkp ON tkp.id = tp.photo_id
WHERE tkp.asset_id = ?
`).get('asset-tog') as any;
expect(row.shared).toBe(1);
});
@@ -206,14 +219,23 @@ describe('Unified photo management', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
addTripPhoto(testDb, trip.id, user.id, 'asset-del', 'immich');
const trekRef = testDb.prepare(`
SELECT tp.photo_id FROM trip_photos tp
JOIN trek_photos tkp ON tkp.id = tp.photo_id
WHERE tp.trip_id = ? AND tkp.asset_id = ?
`).get(trip.id, 'asset-del') as any;
const res = await request(app)
.delete(photosUrl(trip.id))
.set('Cookie', authCookie(user.id))
.send({ provider: 'immich', asset_id: 'asset-del' });
.send({ photo_id: trekRef.photo_id });
expect(res.status).toBe(200);
const row = testDb.prepare('SELECT * FROM trip_photos WHERE asset_id = ?').get('asset-del');
const row = testDb.prepare(`
SELECT tp.* FROM trip_photos tp
JOIN trek_photos tkp ON tkp.id = tp.photo_id
WHERE tkp.asset_id = ?
`).get('asset-del');
expect(row).toBeUndefined();
});
@@ -473,10 +473,12 @@ describe('getVisitedRegions', () => {
const trip = createTrip(testDb, user.id, { title: 'Paris Trip' });
insertPlaceWithCoords(testDb, trip.id, 'Paris Hotel', 48.85, 2.35);
const resultPromise = getVisitedRegions(user.id);
// First call triggers the background geocoding fire-and-forget
await getVisitedRegions(user.id);
// Advance all pending timers (including the 1100ms Nominatim rate-limit delay)
await vi.runAllTimersAsync();
const result = await resultPromise;
// Second call returns now-cached data
const result = await getVisitedRegions(user.id);
expect(result.regions['FR']).toBeDefined();
@@ -580,7 +580,7 @@ describe('BACKUP-041 listBackups', () => {
fsMock.readdirSync.mockReturnValue(['backup-2026-01-01T00-00-00.zip']);
fsMock.statSync.mockReturnValue({
size: 1024,
birthtime: new Date('2026-01-01T00:00:00Z'),
mtime: new Date('2026-01-01T00:00:00Z'),
});
const result = listBackups();
@@ -599,9 +599,9 @@ describe('BACKUP-041 listBackups', () => {
]);
fsMock.statSync.mockImplementation((p: string) => {
if (String(p).includes('2026-01-01')) {
return { size: 512, birthtime: new Date('2026-01-01T00:00:00Z') };
return { size: 512, mtime: new Date('2026-01-01T00:00:00Z') };
}
return { size: 2048, birthtime: new Date('2026-06-01T00:00:00Z') };
return { size: 2048, mtime: new Date('2026-06-01T00:00:00Z') };
});
const result = listBackups();
@@ -619,7 +619,7 @@ describe('BACKUP-041 listBackups', () => {
]);
fsMock.statSync.mockReturnValue({
size: 1024,
birthtime: new Date('2026-01-01T00:00:00Z'),
mtime: new Date('2026-01-01T00:00:00Z'),
});
const result = listBackups();
@@ -389,6 +389,8 @@ describe('addTripToJourney / removeTripFromJourney', () => {
end_date: '2026-03-03',
});
const place = createPlace(testDb, trip.id, { name: 'Eiffel Tower' });
const day025 = testDb.prepare('SELECT id FROM days WHERE trip_id = ? ORDER BY date ASC LIMIT 1').get(trip.id) as { id: number };
createDayAssignment(testDb, day025.id, place.id);
addTripToJourney(journey.id, trip.id, user.id);
@@ -563,6 +565,46 @@ describe('deleteEntry', () => {
expect(deleteEntry(entry.id, viewer.id)).toBe(false);
});
it('JOURNEY-SVC-037b: deleting a filled skeleton reverts it back to skeleton', () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
const trip = createTrip(testDb, user.id);
const place = createPlace(testDb, trip.id, { name: 'Tokyo Tower' });
// Create a filled entry that originated from a trip skeleton
const now = Date.now();
testDb.prepare(`
INSERT INTO journey_entries (journey_id, source_trip_id, source_place_id, author_id, type, title, story, mood, entry_date, location_name, visibility, sort_order, created_at, updated_at)
VALUES (?, ?, ?, ?, 'entry', 'Tokyo Tower', 'Amazing view!', 'amazing', '2026-03-01', 'Tokyo', 'private', 0, ?, ?)
`).run(journey.id, trip.id, place.id, user.id, now, now);
const entry = testDb.prepare('SELECT * FROM journey_entries WHERE journey_id = ? AND source_place_id = ?').get(journey.id, place.id) as any;
const result = deleteEntry(entry.id, user.id);
expect(result).toBe(true);
// Entry should still exist but reverted to skeleton
const reverted = testDb.prepare('SELECT * FROM journey_entries WHERE id = ?').get(entry.id) as any;
expect(reverted).toBeDefined();
expect(reverted.type).toBe('skeleton');
expect(reverted.story).toBeNull();
expect(reverted.mood).toBeNull();
expect(reverted.source_trip_id).toBe(trip.id);
expect(reverted.source_place_id).toBe(place.id);
expect(reverted.title).toBe('Tokyo Tower');
});
it('JOURNEY-SVC-037c: deleting an independent entry permanently removes it', () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
const entry = createJourneyEntry(testDb, journey.id, user.id, { entry_date: '2026-03-01', story: 'Manual entry' });
const result = deleteEntry(entry.id, user.id);
expect(result).toBe(true);
const row = testDb.prepare('SELECT * FROM journey_entries WHERE id = ?').get(entry.id);
expect(row).toBeUndefined();
});
});
// -- Photos -------------------------------------------------------------------
@@ -809,6 +851,9 @@ describe('syncTripPlaces', () => {
});
const place1 = createPlace(testDb, trip.id, { name: 'Eiffel Tower' });
const place2 = createPlace(testDb, trip.id, { name: 'Louvre' });
const days055 = testDb.prepare('SELECT id FROM days WHERE trip_id = ? ORDER BY date ASC LIMIT 2').all(trip.id) as { id: number }[];
createDayAssignment(testDb, days055[0].id, place1.id);
createDayAssignment(testDb, days055[1].id, place2.id);
syncTripPlaces(journey.id, trip.id, user.id);
@@ -828,7 +873,9 @@ describe('syncTripPlaces', () => {
start_date: '2026-05-01',
end_date: '2026-05-02',
});
createPlace(testDb, trip.id, { name: 'Notre Dame' });
const place056 = createPlace(testDb, trip.id, { name: 'Notre Dame' });
const day056 = testDb.prepare('SELECT id FROM days WHERE trip_id = ? ORDER BY date ASC LIMIT 1').get(trip.id) as { id: number };
createDayAssignment(testDb, day056.id, place056.id);
syncTripPlaces(journey.id, trip.id, user.id);
syncTripPlaces(journey.id, trip.id, user.id); // second call
@@ -879,6 +926,8 @@ describe('onPlaceCreated', () => {
// Create a new place after trip is linked
const place = createPlace(testDb, trip.id, { name: 'Sagrada Familia' });
const day058 = testDb.prepare('SELECT id FROM days WHERE trip_id = ? ORDER BY date ASC LIMIT 1').get(trip.id) as { id: number };
createDayAssignment(testDb, day058.id, place.id);
onPlaceCreated(trip.id, place.id);
const skeleton = testDb.prepare(
@@ -911,6 +960,8 @@ describe('onPlaceCreated', () => {
addTripToJourney(journey.id, trip.id, user.id);
const place = createPlace(testDb, trip.id, { name: 'Arc de Triomphe' });
const day060 = testDb.prepare('SELECT id FROM days WHERE trip_id = ? ORDER BY date ASC LIMIT 1').get(trip.id) as { id: number };
createDayAssignment(testDb, day060.id, place.id);
onPlaceCreated(trip.id, place.id);
onPlaceCreated(trip.id, place.id); // second call
@@ -931,6 +982,8 @@ describe('onPlaceUpdated', () => {
end_date: '2026-08-03',
});
const place = createPlace(testDb, trip.id, { name: 'Old Name' });
const day061 = testDb.prepare('SELECT id FROM days WHERE trip_id = ? ORDER BY date ASC LIMIT 1').get(trip.id) as { id: number };
createDayAssignment(testDb, day061.id, place.id);
addTripToJourney(journey.id, trip.id, user.id);
// Update the place name directly in DB
@@ -954,6 +1007,8 @@ describe('onPlaceUpdated', () => {
end_date: '2026-08-02',
});
const place = createPlace(testDb, trip.id, { name: 'Original Place' });
const day062 = testDb.prepare('SELECT id FROM days WHERE trip_id = ? ORDER BY date ASC LIMIT 1').get(trip.id) as { id: number };
createDayAssignment(testDb, day062.id, place.id);
addTripToJourney(journey.id, trip.id, user.id);
// Promote the skeleton to a full entry
@@ -1017,6 +1072,8 @@ describe('onPlaceDeleted', () => {
end_date: '2026-09-02',
});
const place = createPlace(testDb, trip.id, { name: 'Detach Place' });
const day065 = testDb.prepare('SELECT id FROM days WHERE trip_id = ? ORDER BY date ASC LIMIT 1').get(trip.id) as { id: number };
createDayAssignment(testDb, day065.id, place.id);
addTripToJourney(journey.id, trip.id, user.id);
// Promote the skeleton to a filled entry
@@ -1115,7 +1172,11 @@ describe('setPhotoProvider', () => {
setPhotoProvider(photo!.id, 'immich', 'immich-asset-789', user.id);
const updated = testDb.prepare('SELECT * FROM journey_photos WHERE id = ?').get(photo!.id) as any;
const updated = testDb.prepare(`
SELECT jp.*, tkp.provider, tkp.asset_id, tkp.owner_id
FROM journey_photos jp JOIN trek_photos tkp ON tkp.id = jp.photo_id
WHERE jp.id = ?
`).get(photo!.id) as any;
expect(updated.provider).toBe('immich');
expect(updated.asset_id).toBe('immich-asset-789');
expect(updated.owner_id).toBe(user.id);
@@ -1219,7 +1280,7 @@ describe('listUserTrips', () => {
// -- Edge cases ----------------------------------------------------------------
describe('Edge cases', () => {
it('JOURNEY-SVC-081: deleteEntry moves photos to Gallery entry instead of deleting them', () => {
it('JOURNEY-SVC-081: deleteEntry deletes photos along with the entry', () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
const entry = createJourneyEntry(testDb, journey.id, user.id, { entry_date: '2026-03-01' });
@@ -1228,13 +1289,9 @@ describe('Edge cases', () => {
const result = deleteEntry(entry.id, user.id);
expect(result).toBe(true);
// Photo should still exist, moved to a Gallery entry
const movedPhoto = testDb.prepare('SELECT * FROM journey_photos WHERE id = ?').get(photo!.id) as any;
expect(movedPhoto).toBeDefined();
expect(movedPhoto.entry_id).not.toBe(entry.id);
const galleryEntry = testDb.prepare('SELECT * FROM journey_entries WHERE id = ?').get(movedPhoto.entry_id) as any;
expect(galleryEntry.title).toBe('Gallery');
// Photo should be deleted with the entry
const deletedPhoto = testDb.prepare('SELECT * FROM journey_photos WHERE id = ?').get(photo!.id) as any;
expect(deletedPhoto).toBeUndefined();
});
it('JOURNEY-SVC-082: updateJourney can set cover_gradient', () => {
@@ -1308,9 +1365,11 @@ describe('Edge cases', () => {
).get(journey.id) as any;
expect(photoEntry).toBeDefined();
const photos = testDb.prepare(
'SELECT * FROM journey_photos WHERE entry_id = ?'
).all(photoEntry.id);
const photos = testDb.prepare(`
SELECT jp.*, tkp.asset_id FROM journey_photos jp
JOIN trek_photos tkp ON tkp.id = jp.photo_id
WHERE jp.entry_id = ?
`).all(photoEntry.id);
expect(photos.length).toBe(1);
expect((photos[0] as any).asset_id).toBe('immich-photo-1');
});
@@ -1325,6 +1384,9 @@ describe('Edge cases', () => {
});
const place1 = createPlace(testDb, trip.id, { name: 'Skeleton Place' });
const place2 = createPlace(testDb, trip.id, { name: 'Filled Place' });
const days087 = testDb.prepare('SELECT id FROM days WHERE trip_id = ? ORDER BY date ASC LIMIT 2').all(trip.id) as { id: number }[];
createDayAssignment(testDb, days087[0].id, place1.id);
createDayAssignment(testDb, days087[1].id, place2.id);
addTripToJourney(journey.id, trip.id, user.id);
// Promote one skeleton to a filled entry
@@ -63,10 +63,17 @@ function insertJourneyPhoto(
entryId: number,
opts: { filePath?: string; assetId?: string; ownerId?: number } = {}
): number {
const provider = opts.assetId ? 'immich' : 'local';
const filePath = !opts.assetId ? (opts.filePath ?? '/photos/test.jpg') : null;
const trekResult = testDb.prepare(`
INSERT INTO trek_photos (provider, asset_id, file_path, owner_id, created_at)
VALUES (?, ?, ?, ?, ?)
`).run(provider, opts.assetId ?? null, filePath, opts.ownerId ?? null, Date.now());
const trekId = trekResult.lastInsertRowid as number;
const result = testDb.prepare(`
INSERT INTO journey_photos (entry_id, file_path, caption, sort_order, created_at, asset_id, owner_id)
VALUES (?, ?, NULL, 0, ?, ?, ?)
`).run(entryId, opts.filePath ?? '/photos/test.jpg', Date.now(), opts.assetId ?? null, opts.ownerId ?? null);
INSERT INTO journey_photos (entry_id, photo_id, caption, sort_order, created_at)
VALUES (?, ?, NULL, 0, ?)
`).run(entryId, trekId, Date.now());
return result.lastInsertRowid as number;
}
@@ -384,7 +384,12 @@ describe('searchNominatim (fetch stubbed)', () => {
});
it('MAPS-030b: throws when nominatim response is not ok', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false }));
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: false,
status: 500,
statusText: 'Internal Server Error',
text: async () => '',
}));
const { searchNominatim } = await import('../../../src/services/mapsService');
await expect(searchNominatim('fail')).rejects.toThrow('Nominatim API error');
});