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-markdown": "^10.1.0",
"react-router-dom": "^6.22.2", "react-router-dom": "^6.22.2",
"react-window": "^2.2.7", "react-window": "^2.2.7",
"remark-breaks": "^4.0.0",
"remark-gfm": "^4.0.1", "remark-gfm": "^4.0.1",
"topojson-client": "^3.1.0", "topojson-client": "^3.1.0",
"zustand": "^4.5.2" "zustand": "^4.5.2"
@@ -7585,6 +7586,20 @@
"url": "https://opencollective.com/unified" "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": { "node_modules/mdast-util-phrasing": {
"version": "4.1.0", "version": "4.1.0",
"resolved": "https://registry.npmjs.org/mdast-util-phrasing/-/mdast-util-phrasing-4.1.0.tgz", "resolved": "https://registry.npmjs.org/mdast-util-phrasing/-/mdast-util-phrasing-4.1.0.tgz",
@@ -9272,6 +9287,21 @@
"regjsparser": "bin/parser" "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": { "node_modules/remark-gfm": {
"version": "4.0.1", "version": "4.0.1",
"resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz", "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-markdown": "^10.1.0",
"react-router-dom": "^6.22.2", "react-router-dom": "^6.22.2",
"react-window": "^2.2.7", "react-window": "^2.2.7",
"remark-breaks": "^4.0.0",
"remark-gfm": "^4.0.1", "remark-gfm": "^4.0.1",
"topojson-client": "^3.1.0", "topojson-client": "^3.1.0",
"zustand": "^4.5.2" "zustand": "^4.5.2"
+4
View File
@@ -309,6 +309,7 @@ export const journeyApi = {
// Photos // Photos
uploadPhotos: (entryId: number, formData: FormData) => apiClient.post(`/journeys/entries/${entryId}/photos`, formData, { headers: { 'Content-Type': undefined as any } }).then(r => r.data), uploadPhotos: (entryId: number, formData: FormData) => apiClient.post(`/journeys/entries/${entryId}/photos`, formData, { headers: { 'Content-Type': undefined as any } }).then(r => r.data),
addProviderPhoto: (entryId: number, provider: string, assetId: string, caption?: string) => apiClient.post(`/journeys/entries/${entryId}/provider-photos`, { provider, asset_id: assetId, caption }).then(r => r.data), addProviderPhoto: (entryId: number, provider: string, assetId: string, caption?: string) => 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), linkPhoto: (entryId: number, photoId: number) => apiClient.post(`/journeys/entries/${entryId}/link-photo`, { photo_id: photoId }).then(r => r.data),
updatePhoto: (photoId: number, data: Record<string, unknown>) => apiClient.patch(`/journeys/photos/${photoId}`, data).then(r => r.data), updatePhoto: (photoId: number, data: Record<string, unknown>) => apiClient.patch(`/journeys/photos/${photoId}`, data).then(r => r.data),
deletePhoto: (photoId: number) => apiClient.delete(`/journeys/photos/${photoId}`).then(r => r.data), deletePhoto: (photoId: number) => apiClient.delete(`/journeys/photos/${photoId}`).then(r => r.data),
@@ -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), 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), 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 // Share
getShareLink: (id: number) => apiClient.get(`/journeys/${id}/share-link`).then(r => r.data), 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), 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(); 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( server.use(
http.get('/api/admin/addons', () => http.get('/api/admin/addons', () =>
HttpResponse.json({ HttpResponse.json({
addons: [ 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: 'photos', name: 'Memories', type: 'trip', icon: 'Image', enabled: false }),
buildAddon({ id: 'unsplash', name: 'Unsplash', type: 'photo_provider', enabled: true }), buildAddon({ id: 'unsplash', name: 'Unsplash', type: 'photo_provider', enabled: true }),
buildAddon({ id: 'pexels', name: 'Pexels', type: 'photo_provider', enabled: false }), buildAddon({ id: 'pexels', name: 'Pexels', type: 'photo_provider', enabled: false }),
@@ -204,18 +205,16 @@ describe('AddonManager', () => {
); );
render(<AddonManager />); render(<AddonManager />);
// Provider sub-rows are visible // Provider sub-rows are visible under Journey addon
await screen.findByText('Unsplash'); await screen.findByText('Unsplash');
expect(screen.getByText('Pexels')).toBeInTheDocument(); expect(screen.getByText('Pexels')).toBeInTheDocument();
// Memories row shows name override // Journey addon is rendered
expect(screen.getByText('Memories providers')).toBeInTheDocument(); expect(screen.getByText('Journey')).toBeInTheDocument();
// The photos addon row itself has no top-level toggle (hideToggle = true) // Toggle buttons: journey toggle + 2 provider toggles
// The toggle buttons are only for the providers
const toggleBtns = screen.getAllByRole('button').filter(b => b.classList.contains('rounded-full')); 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(3);
expect(toggleBtns.length).toBe(2);
}); });
it('FE-ADMIN-ADDON-011: icon falls back to Puzzle when icon name unknown', async () => { 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 { useSettingsStore } from '../../store/settingsStore'
import { useAddonStore } from '../../store/addonStore' import { useAddonStore } from '../../store/addonStore'
import { useToast } from '../shared/Toast' 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 = { 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 { 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 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 integrationAddons = addons.filter(a => a.type === 'integration')
const photosAddon = tripAddons.find(isPhotosAddon)
const providerOptions: ProviderOption[] = photoProviderAddons.map((provider) => ({ const providerOptions: ProviderOption[] = photoProviderAddons.map((provider) => ({
key: provider.id, key: provider.id,
label: provider.name, label: provider.name,
@@ -153,42 +153,7 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking }
</div> </div>
{tripAddons.map(addon => ( {tripAddons.map(addon => (
<div key={addon.id}> <div key={addon.id}>
<AddonRow <AddonRow addon={addon} onToggle={handleToggle} t={t} />
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>
)}
{addon.id === 'packing' && addon.enabled && onToggleBagTracking && ( {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 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 }}> <div style={{ flex: 1, minWidth: 0 }}>
@@ -223,7 +188,37 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking }
</span> </span>
</div> </div>
{globalAddons.map(addon => ( {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> </div>
)} )}
+3 -2
View File
@@ -3,6 +3,7 @@ import { useState, useEffect, useCallback, useRef, useMemo } from 'react'
import DOM from 'react-dom' import DOM from 'react-dom'
import Markdown from 'react-markdown' import Markdown from 'react-markdown'
import remarkGfm from 'remark-gfm' 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 { Plus, Trash2, Pin, PinOff, Pencil, X, Check, StickyNote, Settings, ExternalLink, Maximize2, Loader2 } from 'lucide-react'
import { collabApi } from '../../api/client' import { collabApi } from '../../api/client'
import { getAuthUrl } from '../../api/authUrl' import { getAuthUrl } from '../../api/authUrl'
@@ -845,7 +846,7 @@ function NoteCard({ note, currentUser, canEdit, onUpdate, onDelete, onEdit, onVi
maxHeight: '4.5em', overflow: 'hidden', maxHeight: '4.5em', overflow: 'hidden',
wordBreak: 'break-word', fontFamily: FONT, wordBreak: 'break-word', fontFamily: FONT,
}}> }}>
<Markdown remarkPlugins={[remarkGfm]}>{note.content}</Markdown> <Markdown remarkPlugins={[remarkGfm, remarkBreaks]}>{note.content}</Markdown>
</div> </div>
)} )}
</div> </div>
@@ -1352,7 +1353,7 @@ export default function CollabNotes({ tripId, currentUser }: CollabNotesProps) {
</div> </div>
</div> </div>
<div className="collab-note-md-full" style={{ padding: '16px 20px', overflowY: 'auto', fontSize: 14, color: 'var(--text-primary)', lineHeight: 1.7 }}> <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 && ( {(viewingNote.attachments || []).length > 0 && (
<div style={{ marginTop: 16, paddingTop: 16, borderTop: '1px solid var(--border-primary)' }}> <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> <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', () => { it('FE-COMP-JOURNALBODY-004: renders headings with proper elements', () => {
const { container } = render(<JournalBody text="## Section Title" />); const { container } = render(<JournalBody text="## Section Title" />);
const h2 = container.querySelector('h2'); const p = container.querySelector('p');
expect(h2).toBeInTheDocument(); expect(p).toBeInTheDocument();
expect(h2!.textContent).toBe('Section Title'); expect(p!.textContent).toBe('Section Title');
}); });
it('FE-COMP-JOURNALBODY-005: handles empty text without crashing', () => { it('FE-COMP-JOURNALBODY-005: handles empty text without crashing', () => {
@@ -1,5 +1,6 @@
import ReactMarkdown from 'react-markdown' import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm' import remarkGfm from 'remark-gfm'
import remarkBreaks from 'remark-breaks'
interface Props { interface Props {
text: string text: string
@@ -15,11 +16,11 @@ export default function JournalBody({ text, dark }: Props) {
color: 'inherit', color: 'inherit',
}}> }}>
<ReactMarkdown <ReactMarkdown
remarkPlugins={[remarkGfm]} remarkPlugins={[remarkGfm, remarkBreaks]}
components={{ components={{
h1: ({ children }) => <h1 style={{ fontFamily: 'inherit', fontSize: '1.3em', fontWeight: 700, margin: '16px 0 6px', lineHeight: 1.3 }}>{children}</h1>, h1: ({ children }) => <p style={{ margin: '0 0 6px' }}>{children}</p>,
h2: ({ children }) => <h2 style={{ fontFamily: 'inherit', fontSize: '1.15em', fontWeight: 600, margin: '14px 0 4px', lineHeight: 1.3 }}>{children}</h2>, h2: ({ children }) => <p style={{ margin: '0 0 6px' }}>{children}</p>,
h3: ({ children }) => <h3 style={{ fontFamily: 'inherit', fontSize: '1.05em', fontWeight: 600, margin: '12px 0 4px', lineHeight: 1.4 }}>{children}</h3>, h3: ({ children }) => <p style={{ margin: '0 0 6px' }}>{children}</p>,
p: ({ children }) => <p style={{ margin: '0 0 6px' }}>{children}</p>, p: ({ children }) => <p style={{ margin: '0 0 6px' }}>{children}</p>,
blockquote: ({ children }) => ( blockquote: ({ children }) => (
<blockquote style={{ <blockquote style={{
@@ -62,7 +63,7 @@ export default function JournalBody({ text, dark }: Props) {
}, },
}} }}
> >
{text} {text.replace(/^(.+)\n([-=]{3,})$/gm, '$1\n\n$2')}
</ReactMarkdown> </ReactMarkdown>
</div> </div>
) )
+6 -2
View File
@@ -155,7 +155,7 @@ const JourneyMap = forwardRef<JourneyMapHandle, Props>(function JourneyMap(
const map = L.map(containerRef.current, { const map = L.map(containerRef.current, {
zoomControl: false, zoomControl: false,
attributionControl: false, attributionControl: true,
scrollWheelZoom: false, scrollWheelZoom: false,
dragging: true, dragging: true,
touchZoom: true, touchZoom: true,
@@ -165,7 +165,11 @@ const JourneyMap = forwardRef<JourneyMapHandle, Props>(function JourneyMap(
const defaultTile = dark const defaultTile = dark
? 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png' ? 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png'
: 'https://{s}.basemaps.cartocdn.com/rastertiles/voyager/{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) const items = buildMarkerItems(entries)
itemsRef.current = items itemsRef.current = items
@@ -6,7 +6,7 @@ interface Props {
dark?: boolean 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 }> = [ const ACTIONS: Array<{ icon: typeof Bold; label: string; action: FormatAction }> = [
{ icon: Bold, label: 'Bold', action: { type: 'wrap', before: '**', after: '**' } }, { 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: Link, label: 'Link', action: { type: 'wrap', before: '[', after: '](url)' } },
{ icon: List, label: 'List', action: { type: 'line', prefix: '- ' } }, { icon: List, label: 'List', action: { type: 'line', prefix: '- ' } },
{ icon: ListOrdered, label: 'Ordered', action: { type: 'line', prefix: '1. ' } }, { 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) { export default function MarkdownToolbar({ textareaRef, onUpdate, dark }: Props) {
@@ -35,6 +35,9 @@ export default function MarkdownToolbar({ textareaRef, onUpdate, dark }: Props)
if (action.type === 'wrap') { if (action.type === 'wrap') {
result = text.slice(0, start) + action.before + selected + action.after + text.slice(end) 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 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 { } else {
// line prefix — find start of current line // line prefix — find start of current line
const lineStart = text.lastIndexOf('\n', start - 1) + 1 const lineStart = text.lastIndexOf('\n', start - 1) + 1
@@ -233,8 +233,8 @@ describe('MemoriesPanel', () => {
http.get('/api/integrations/memories/unified/trips/:tripId/photos', () => http.get('/api/integrations/memories/unified/trips/:tripId/photos', () =>
HttpResponse.json({ HttpResponse.json({
photos: [ photos: [
{ asset_id: 'photo1', provider: 'immich', user_id: 1, username: 'me', shared: 1, added_at: '2025-03-01T10:00:00Z' }, { photo_id: 1, 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: 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', () => http.get('/api/integrations/memories/unified/trips/:tripId/photos', () =>
HttpResponse.json({ HttpResponse.json({
photos: [ photos: [
{ asset_id: 'p1', provider: 'immich', user_id: 1, username: 'me', shared: 1, added_at: '2025-03-01T00:00:00Z', city: 'Paris' }, { photo_id: 10, 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: 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', () => http.get('/api/integrations/memories/unified/trips/:tripId/photos', () =>
HttpResponse.json({ HttpResponse.json({
photos: [ photos: [
{ asset_id: 'p1', provider: 'immich', user_id: 1, username: 'me', shared: 1, added_at: '2025-03-01T00:00:00Z', city: 'Paris' }, { photo_id: 10, 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: 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 ─────────────────────────────────────────────────────────────────── // ── Types ───────────────────────────────────────────────────────────────────
interface TripPhoto { interface TripPhoto {
photo_id: number
asset_id: string asset_id: string
provider: string provider: string
user_id: number user_id: number
@@ -105,19 +106,12 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
} }
function buildProviderAssetUrl(photo: TripPhoto, what: string): string { 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 { function buildProviderAssetUrlFromAsset(asset: Asset, what: string, userId: number): string {
const photo: TripPhoto = { // Picker photos are not yet saved — use provider-specific URL
asset_id: asset.id, return `${ADDON_PREFIX}/${asset.provider}/assets/${tripId}/${asset.id}/${userId}/${what}`
provider: asset.provider,
user_id: userId,
username: '',
shared: 0,
added_at: null
}
return buildProviderAssetUrl(photo, what)
} }
@@ -189,7 +183,7 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
} }
// Lightbox // Lightbox
const [lightboxId, setLightboxId] = useState<string | null>(null) const [lightboxId, setLightboxId] = useState<number | null>(null)
const [lightboxUserId, setLightboxUserId] = useState<number | null>(null) const [lightboxUserId, setLightboxUserId] = useState<number | null>(null)
const [lightboxInfo, setLightboxInfo] = useState<any>(null) const [lightboxInfo, setLightboxInfo] = useState<any>(null)
const [lightboxInfoLoading, setLightboxInfoLoading] = useState(false) const [lightboxInfoLoading, setLightboxInfoLoading] = useState(false)
@@ -357,11 +351,10 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
try { try {
await apiClient.delete(buildUnifiedUrl('photos'), { await apiClient.delete(buildUnifiedUrl('photos'), {
data: { data: {
asset_id: photo.asset_id, photo_id: photo.photo_id,
provider: photo.provider,
}, },
}) })
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')) } } catch { toast.error(t('memories.error.removePhoto')) }
} }
@@ -371,11 +364,10 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
try { try {
await apiClient.put(buildUnifiedUrl('photos', 'sharing'), { await apiClient.put(buildUnifiedUrl('photos', 'sharing'), {
shared, shared,
asset_id: photo.asset_id, photo_id: photo.photo_id,
provider: photo.provider,
}) })
setTripPhotos(prev => prev.map(p => 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')) } } catch { toast.error(t('memories.error.toggleSharing')) }
} }
@@ -839,10 +831,10 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
{allVisible.map(photo => { {allVisible.map(photo => {
const isOwn = photo.user_id === currentUser?.id const isOwn = photo.user_id === currentUser?.id
return ( 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' }} style={{ position: 'relative', aspectRatio: '1', borderRadius: 10, overflow: 'visible', cursor: 'pointer' }}
onClick={() => { 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) if (lightboxOriginalSrc) URL.revokeObjectURL(lightboxOriginalSrc)
setLightboxOriginalSrc('') setLightboxOriginalSrc('')
fetchImageAsBlob('/api' + buildProviderAssetUrl(photo, 'original')).then(setLightboxOriginalSrc) fetchImageAsBlob('/api' + buildProviderAssetUrl(photo, 'original')).then(setLightboxOriginalSrc)
@@ -961,7 +953,7 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
setShowMobileInfo(false) 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 hasPrev = currentIdx > 0
const hasNext = currentIdx < allVisible.length - 1 const hasNext = currentIdx < allVisible.length - 1
const navigateTo = (idx: number) => { const navigateTo = (idx: number) => {
@@ -969,7 +961,7 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
if (!photo) return if (!photo) return
if (lightboxOriginalSrc) URL.revokeObjectURL(lightboxOriginalSrc) if (lightboxOriginalSrc) URL.revokeObjectURL(lightboxOriginalSrc)
setLightboxOriginalSrc('') setLightboxOriginalSrc('')
setLightboxId(photo.asset_id) setLightboxId(photo.photo_id)
setLightboxUserId(photo.user_id) setLightboxUserId(photo.user_id)
setLightboxInfo(null) setLightboxInfo(null)
fetchImageAsBlob('/api' + buildProviderAssetUrl(photo, 'original')).then(setLightboxOriginalSrc) 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 { function pSrc(p: JourneyPhoto): string {
if (p.provider === 'local') return abs(`/uploads/${p.file_path}`) return abs(`/api/photos/${p.photo_id}/original`)
return abs(`/api/integrations/memories/${p.provider}/assets/0/${p.asset_id}/${p.owner_id}/original`)
} }
function fmtDate(d: string): string { function fmtDate(d: string): string {
+22 -15
View File
@@ -1,7 +1,7 @@
// Trip PDF via browser print window // Trip PDF via browser print window
import { createElement } from 'react' import { createElement } from 'react'
import { getCategoryIcon } from '../shared/categoryIcons' 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 { accommodationsApi, mapsApi } from '../../api/client'
import type { Trip, Day, Place, Category, AssignmentsMap, DayNotesMap } from '../../types' 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' }) return renderLucideIcon(Icon, { size: 14, strokeWidth: 1.8, color: '#94a3b8' })
} }
const TRANSPORT_ICON_MAP = { flight: Plane, train: Train, bus: Bus, car: Car, cruise: Ship } const RESERVATION_ICON_MAP = { flight: Plane, train: Train, bus: Bus, car: Car, cruise: Ship, restaurant: Utensils, event: Ticket, tour: Users, other: FileText }
function transportIconSvg(type) { const RESERVATION_COLOR_MAP = { flight: '#3b82f6', train: '#06b6d4', bus: '#6b7280', car: '#6b7280', cruise: '#0ea5e9', restaurant: '#ef4444', event: '#f59e0b', tour: '#10b981', other: '#6b7280' }
const Icon = TRANSPORT_ICON_MAP[type] || Ticket function reservationIconSvg(type) {
return renderLucideIcon(Icon, { size: 14, strokeWidth: 1.8, color: '#3b82f6' }) 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 } 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 notes = (dayNotes || []).filter(n => n.day_id === day.id)
const cost = dayCost(assignments, day.id, loc) const cost = dayCost(assignments, day.id, loc)
// Transport bookings for this day // Reservations for this day (hotel rendered via accommodations block)
const TRANSPORT_TYPES = new Set(['flight', 'train', 'bus', 'car', 'cruise']) const dayReservations = (reservations || []).filter(r => {
const dayTransport = (reservations || []).filter(r => { if (!r.reservation_time || r.type === 'hotel') return false
if (!r.reservation_time || !TRANSPORT_TYPES.has(r.type)) return false
return day.date && r.reservation_time.split('T')[0] === day.date return day.date && r.reservation_time.split('T')[0] === day.date
}) })
const merged = [] const merged = []
assigned.forEach(a => merged.push({ type: 'place', k: a.order_index ?? a.sort_order ?? 0, data: a })) 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 })) 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) 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) 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 const itemsHtml = merged.length === 0
? `<div class="empty-day">${escHtml(tr('dayplan.emptyDay'))}</div>` ? `<div class="empty-day">${escHtml(tr('dayplan.emptyDay'))}</div>`
: merged.map(item => { : merged.map(item => {
if (item.type === 'transport') { if (item.type === 'reservation') {
const r = item.data const r = item.data
const meta = typeof r.metadata === 'string' ? JSON.parse(r.metadata || '{}') : (r.metadata || {}) 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 = '' 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(' · ') 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 === '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) : '' const time = r.reservation_time?.includes('T') ? r.reservation_time.split('T')[1]?.substring(0, 5) : ''
return ` return `
<div class="note-card" style="border-left: 3px solid #3b82f6;"> <div class="note-card" style="border-left: 3px solid ${color};">
<div class="note-line" style="background: #3b82f6;"></div> <div class="note-line" style="background: ${color};"></div>
<span class="note-icon">${icon}</span> <span class="note-icon">${icon}</span>
<div class="note-body"> <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> <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>` : ''} ${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>` : ''} ${r.confirmation_number ? `<div class="note-time" style="font-size:9px;">Code: ${escHtml(r.confirmation_number)}</div>` : ''}
</div> </div>
</div>` </div>`
@@ -286,7 +286,20 @@ export default function PlaceFormModal({
onChange={e => handleChange('description', e.target.value)} onChange={e => handleChange('description', e.target.value)}
rows={2} rows={2}
placeholder={t('places.formDescriptionPlaceholder')} 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> </div>
@@ -341,9 +341,16 @@ export default function PlaceInspector({
)} )}
{/* Description / Summary */} {/* 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' }}> <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> </div>
)} )}
@@ -28,7 +28,7 @@ interface PlacesSidebarProps {
onDeletePlace: (placeId: number) => void onDeletePlace: (placeId: number) => void
days: Day[] days: Day[]
isMobile: boolean isMobile: boolean
onCategoryFilterChange?: (categoryId: string) => void onCategoryFilterChange?: (categoryIds: Set<string>) => void
onPlacesFilterChange?: (filter: string) => void onPlacesFilterChange?: (filter: string) => void
pushUndo?: (label: string, undoFn: () => Promise<void> | void) => void pushUndo?: (label: string, undoFn: () => Promise<void> | void) => void
} }
@@ -105,8 +105,7 @@ const PlacesSidebar = React.memo(function PlacesSidebar({
setCategoryFiltersLocal(prev => { setCategoryFiltersLocal(prev => {
const next = new Set(prev) const next = new Set(prev)
if (next.has(catId)) next.delete(catId); else next.add(catId) if (next.has(catId)) next.delete(catId); else next.add(catId)
// Notify parent with first selected or empty onCategoryFilterChange?.(next)
onCategoryFilterChange?.(next.size === 1 ? [...next][0] : '')
return next return next
}) })
} }
@@ -257,7 +256,7 @@ const PlacesSidebar = React.memo(function PlacesSidebar({
) )
})} })}
{categoryFilters.size > 0 && ( {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, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 4,
width: '100%', padding: '6px 10px', borderRadius: 6, border: 'none', cursor: 'pointer', width: '100%', padding: '6px 10px', borderRadius: 6, border: 'none', cursor: 'pointer',
background: 'transparent', fontFamily: 'inherit', fontSize: 11, color: 'var(--text-faint)', 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
'common.save': 'حفظ', 'common.save': 'حفظ',
'common.showMore': 'عرض المزيد',
'common.showLess': 'عرض أقل',
'common.cancel': 'إلغاء', 'common.cancel': 'إلغاء',
'common.delete': 'حذف', 'common.delete': 'حذف',
'common.edit': 'تعديل', 'common.edit': 'تعديل',
@@ -927,6 +929,7 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
'places.endTimeBeforeStart': 'وقت النهاية قبل وقت البداية', 'places.endTimeBeforeStart': 'وقت النهاية قبل وقت البداية',
'places.timeCollision': 'تداخل في الوقت مع:', 'places.timeCollision': 'تداخل في الوقت مع:',
'places.formWebsite': 'الموقع الإلكتروني', 'places.formWebsite': 'الموقع الإلكتروني',
'places.formNotes': 'ملاحظات',
'places.formNotesPlaceholder': 'ملاحظات شخصية...', 'places.formNotesPlaceholder': 'ملاحظات شخصية...',
'places.formReservation': 'حجز', 'places.formReservation': 'حجز',
'places.reservationNotesPlaceholder': 'ملاحظات الحجز، رقم التأكيد...', 'places.reservationNotesPlaceholder': 'ملاحظات الحجز، رقم التأكيد...',
@@ -1097,7 +1100,6 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
'budget.settlement': 'التسوية', 'budget.settlement': 'التسوية',
'budget.settlementInfo': 'انقر على صورة العضو في بند الميزانية لتحديده باللون الأخضر — وهذا يعني أنه دفع. ثم تُظهر التسوية من يدين لمن وبكم.', 'budget.settlementInfo': 'انقر على صورة العضو في بند الميزانية لتحديده باللون الأخضر — وهذا يعني أنه دفع. ثم تُظهر التسوية من يدين لمن وبكم.',
'budget.netBalances': 'الأرصدة الصافية', 'budget.netBalances': 'الأرصدة الصافية',
'budget.linkedToReservation': 'مرتبط بحجز — قم بتحرير الاسم هناك',
// Files // Files
'files.title': 'الملفات', 'files.title': 'الملفات',
@@ -1539,6 +1541,45 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
'journey.entries.deleteTitle': 'حذف الإدخال', 'journey.entries.deleteTitle': 'حذف الإدخال',
'journey.photosUploaded': 'تم رفع {count} صورة', 'journey.photosUploaded': 'تم رفع {count} صورة',
'journey.photosAdded': 'تمت إضافة {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 Addon
'collab.tabs.chat': 'الدردشة', 'collab.tabs.chat': 'الدردشة',
+29
View File
@@ -1,6 +1,8 @@
const br: Record<string, string | { name: string; category: string }[]> = { const br: Record<string, string | { name: string; category: string }[]> = {
// Common // Common
'common.save': 'Salvar', 'common.save': 'Salvar',
'common.showMore': 'Mostrar mais',
'common.showLess': 'Mostrar menos',
'common.cancel': 'Cancelar', 'common.cancel': 'Cancelar',
'common.delete': 'Excluir', 'common.delete': 'Excluir',
'common.edit': 'Editar', '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.endTimeBeforeStart': 'O horário de fim é antes do início',
'places.timeCollision': 'Sobreposição de horário com:', 'places.timeCollision': 'Sobreposição de horário com:',
'places.formWebsite': 'Site', 'places.formWebsite': 'Site',
'places.formNotes': 'Notas',
'places.formNotesPlaceholder': 'Notas pessoais...', 'places.formNotesPlaceholder': 'Notas pessoais...',
'places.formReservation': 'Reserva', 'places.formReservation': 'Reserva',
'places.reservationNotesPlaceholder': 'Notas da reserva, código de confirmação...', '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.contributors': 'Colaboradores',
'journey.detail.readMore': 'Ler mais', 'journey.detail.readMore': 'Ler mais',
'journey.detail.prosCons': 'Prós e contras', '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.days': 'Dias',
'journey.stats.cities': 'Cidades', 'journey.stats.cities': 'Cidades',
'journey.stats.entries': 'Entradas', 'journey.stats.entries': 'Entradas',
'journey.stats.photos': 'Fotos', 'journey.stats.photos': 'Fotos',
'journey.stats.places': 'Lugares', 'journey.stats.places': 'Lugares',
'journey.skeletons.show': 'Mostrar sugestões',
'journey.skeletons.hide': 'Ocultar sugestões',
'journey.verdict.lovedIt': 'Adorei', 'journey.verdict.lovedIt': 'Adorei',
'journey.verdict.couldBeBetter': 'Poderia ser melhor', 'journey.verdict.couldBeBetter': 'Poderia ser melhor',
'journey.synced.places': 'lugares', 'journey.synced.places': 'lugares',
'journey.synced.synced': 'sincronizado', 'journey.synced.synced': 'sincronizado',
'journey.editor.uploadPhotos': 'Enviar fotos', 'journey.editor.uploadPhotos': 'Enviar fotos',
'journey.editor.uploading': 'Enviando...',
'journey.editor.fromGallery': 'Da galeria', 'journey.editor.fromGallery': 'Da galeria',
'journey.editor.allPhotosAdded': 'Todas as fotos já foram adicionadas', 'journey.editor.allPhotosAdded': 'Todas as fotos já foram adicionadas',
'journey.editor.writeStory': 'Escreva sua história...', '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.weather': 'Clima',
'journey.editor.photoFirst': '1º', 'journey.editor.photoFirst': '1º',
'journey.editor.makeFirst': 'Tornar 1º', 'journey.editor.makeFirst': 'Tornar 1º',
'journey.editor.searching': 'Pesquisando...',
'journey.mood.amazing': 'Incrível', 'journey.mood.amazing': 'Incrível',
'journey.mood.good': 'Bom', 'journey.mood.good': 'Bom',
'journey.mood.neutral': 'Neutro', '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.linkDeleted': 'Link de compartilhamento removido',
'journey.share.deleteFailed': 'Não foi possível excluir', 'journey.share.deleteFailed': 'Não foi possível excluir',
'journey.share.updateFailed': 'Não foi possível atualizar', '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.title': 'Configurações da jornada',
'journey.settings.coverImage': 'Imagem de capa', 'journey.settings.coverImage': 'Imagem de capa',
'journey.settings.changeCover': 'Alterar 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.theEnd': 'Fim',
'journey.pdf.saveAsPdf': 'Salvar como PDF', 'journey.pdf.saveAsPdf': 'Salvar como PDF',
'journey.pdf.pages': 'páginas', '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.morning': 'Bom dia,',
'dashboard.greeting.afternoon': 'Boa tarde,', 'dashboard.greeting.afternoon': 'Boa tarde,',
'dashboard.greeting.evening': 'Boa noite,', 'dashboard.greeting.evening': 'Boa noite,',
+29
View File
@@ -1,6 +1,8 @@
const cs: Record<string, string | { name: string; category: string }[]> = { const cs: Record<string, string | { name: string; category: string }[]> = {
// Společné (Common) // Společné (Common)
'common.save': 'Uložit', 'common.save': 'Uložit',
'common.showMore': 'Zobrazit více',
'common.showLess': 'Zobrazit méně',
'common.cancel': 'Zrušit', 'common.cancel': 'Zrušit',
'common.delete': 'Smazat', 'common.delete': 'Smazat',
'common.edit': 'Upravit', '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.endTimeBeforeStart': 'Čas konce je před časem začátku',
'places.timeCollision': 'Časový překryv s:', 'places.timeCollision': 'Časový překryv s:',
'places.formWebsite': 'Webové stránky', 'places.formWebsite': 'Webové stránky',
'places.formNotes': 'Poznámky',
'places.formNotesPlaceholder': 'Osobní poznámky...', 'places.formNotesPlaceholder': 'Osobní poznámky...',
'places.formReservation': 'Rezervace', 'places.formReservation': 'Rezervace',
'places.reservationNotesPlaceholder': 'Poznámky k rezervaci, potvrzovací kód...', '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.contributors': 'Přispěvatelé',
'journey.detail.readMore': 'Číst dále', 'journey.detail.readMore': 'Číst dále',
'journey.detail.prosCons': 'Klady a zápory', '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.days': 'Dny',
'journey.stats.cities': 'Města', 'journey.stats.cities': 'Města',
'journey.stats.entries': 'Záznamy', 'journey.stats.entries': 'Záznamy',
'journey.stats.photos': 'Fotky', 'journey.stats.photos': 'Fotky',
'journey.stats.places': 'Místa', '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.lovedIt': 'Skvělé',
'journey.verdict.couldBeBetter': 'Mohlo by být lepší', 'journey.verdict.couldBeBetter': 'Mohlo by být lepší',
'journey.synced.places': 'místa', 'journey.synced.places': 'místa',
'journey.synced.synced': 'synchronizováno', 'journey.synced.synced': 'synchronizováno',
'journey.editor.uploadPhotos': 'Nahrát fotky', 'journey.editor.uploadPhotos': 'Nahrát fotky',
'journey.editor.uploading': 'Nahrávání...',
'journey.editor.fromGallery': 'Z galerie', 'journey.editor.fromGallery': 'Z galerie',
'journey.editor.allPhotosAdded': 'Všechny fotky již přidány', 'journey.editor.allPhotosAdded': 'Všechny fotky již přidány',
'journey.editor.writeStory': 'Napište svůj příběh...', '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.weather': 'Počasí',
'journey.editor.photoFirst': '1.', 'journey.editor.photoFirst': '1.',
'journey.editor.makeFirst': 'Nastavit jako 1.', 'journey.editor.makeFirst': 'Nastavit jako 1.',
'journey.editor.searching': 'Hledání...',
'journey.mood.amazing': 'Úžasný', 'journey.mood.amazing': 'Úžasný',
'journey.mood.good': 'Dobrý', 'journey.mood.good': 'Dobrý',
'journey.mood.neutral': 'Neutrální', '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.linkDeleted': 'Odkaz ke sdílení smazán',
'journey.share.deleteFailed': 'Smazání selhalo', 'journey.share.deleteFailed': 'Smazání selhalo',
'journey.share.updateFailed': 'Aktualizace selhala', '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.title': 'Nastavení cestovního deníku',
'journey.settings.coverImage': 'Titulní obrázek', 'journey.settings.coverImage': 'Titulní obrázek',
'journey.settings.changeCover': 'Změnit obal', '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.theEnd': 'Konec',
'journey.pdf.saveAsPdf': 'Uložit jako PDF', 'journey.pdf.saveAsPdf': 'Uložit jako PDF',
'journey.pdf.pages': 'stran', '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.morning': 'Dobré ráno,',
'dashboard.greeting.afternoon': 'Dobré odpoledne,', 'dashboard.greeting.afternoon': 'Dobré odpoledne,',
'dashboard.greeting.evening': 'Dobrý večer,', 'dashboard.greeting.evening': 'Dobrý večer,',
+29 -8
View File
@@ -1,6 +1,8 @@
const de: Record<string, string | { name: string; category: string }[]> = { const de: Record<string, string | { name: string; category: string }[]> = {
// Allgemein // Allgemein
'common.save': 'Speichern', 'common.save': 'Speichern',
'common.showMore': 'Mehr anzeigen',
'common.showLess': 'Weniger anzeigen',
'common.cancel': 'Abbrechen', 'common.cancel': 'Abbrechen',
'common.delete': 'Löschen', 'common.delete': 'Löschen',
'common.edit': 'Bearbeiten', 'common.edit': 'Bearbeiten',
@@ -928,6 +930,7 @@ const de: Record<string, string | { name: string; category: string }[]> = {
'places.endTimeBeforeStart': 'Endzeit liegt vor der Startzeit', 'places.endTimeBeforeStart': 'Endzeit liegt vor der Startzeit',
'places.timeCollision': 'Zeitliche Überschneidung mit:', 'places.timeCollision': 'Zeitliche Überschneidung mit:',
'places.formWebsite': 'Website', 'places.formWebsite': 'Website',
'places.formNotes': 'Notizen',
'places.formNotesPlaceholder': 'Persönliche Notizen...', 'places.formNotesPlaceholder': 'Persönliche Notizen...',
'places.formReservation': 'Reservierung', 'places.formReservation': 'Reservierung',
'places.reservationNotesPlaceholder': 'Reservierungsnotizen, Bestätigungsnummer...', 'places.reservationNotesPlaceholder': 'Reservierungsnotizen, Bestätigungsnummer...',
@@ -1892,16 +1895,22 @@ const de: Record<string, string | { name: string; category: string }[]> = {
'journey.detail.contributors': 'Mitwirkende', 'journey.detail.contributors': 'Mitwirkende',
'journey.detail.readMore': 'Mehr lesen', 'journey.detail.readMore': 'Mehr lesen',
'journey.detail.prosCons': 'Pro & Contra', 'journey.detail.prosCons': 'Pro & Contra',
'journey.detail.photos': 'Fotos',
'journey.detail.day': 'Tag {number}',
'journey.detail.places': 'Orte',
'journey.stats.days': 'Tage', 'journey.stats.days': 'Tage',
'journey.stats.cities': 'Städte', 'journey.stats.cities': 'Städte',
'journey.stats.entries': 'Einträge', 'journey.stats.entries': 'Einträge',
'journey.stats.photos': 'Fotos', 'journey.stats.photos': 'Fotos',
'journey.stats.places': 'Orte', 'journey.stats.places': 'Orte',
'journey.skeletons.show': 'Vorschläge anzeigen',
'journey.skeletons.hide': 'Vorschläge ausblenden',
'journey.verdict.lovedIt': 'Toll', 'journey.verdict.lovedIt': 'Toll',
'journey.verdict.couldBeBetter': 'Verbesserungswürdig', 'journey.verdict.couldBeBetter': 'Verbesserungswürdig',
'journey.synced.places': 'Orte', 'journey.synced.places': 'Orte',
'journey.synced.synced': 'synchronisiert', 'journey.synced.synced': 'synchronisiert',
'journey.editor.uploadPhotos': 'Fotos hochladen', 'journey.editor.uploadPhotos': 'Fotos hochladen',
'journey.editor.uploading': 'Hochladen...',
'journey.editor.fromGallery': 'Aus Galerie', 'journey.editor.fromGallery': 'Aus Galerie',
'journey.editor.allPhotosAdded': 'Alle Fotos bereits hinzugefügt', 'journey.editor.allPhotosAdded': 'Alle Fotos bereits hinzugefügt',
'journey.editor.writeStory': 'Erzähle deine Geschichte...', '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.weather': 'Wetter',
'journey.editor.photoFirst': '1.', 'journey.editor.photoFirst': '1.',
'journey.editor.makeFirst': 'Als 1. setzen', 'journey.editor.makeFirst': 'Als 1. setzen',
'journey.editor.searching': 'Suche...',
'journey.mood.amazing': 'Großartig', 'journey.mood.amazing': 'Großartig',
'journey.mood.good': 'Gut', 'journey.mood.good': 'Gut',
'journey.mood.neutral': 'Neutral', 'journey.mood.neutral': 'Neutral',
@@ -1962,6 +1972,13 @@ const de: Record<string, string | { name: string; category: string }[]> = {
'journey.share.linkDeleted': 'Link entfernt', 'journey.share.linkDeleted': 'Link entfernt',
'journey.share.deleteFailed': 'Entfernen fehlgeschlagen', 'journey.share.deleteFailed': 'Entfernen fehlgeschlagen',
'journey.share.updateFailed': 'Aktualisierung 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.title': 'Journey-Einstellungen',
'journey.settings.coverImage': 'Titelbild', 'journey.settings.coverImage': 'Titelbild',
'journey.settings.changeCover': 'Titelbild ändern', 'journey.settings.changeCover': 'Titelbild ändern',
@@ -1992,6 +2009,18 @@ const de: Record<string, string | { name: string; category: string }[]> = {
'journey.pdf.theEnd': 'Ende', 'journey.pdf.theEnd': 'Ende',
'journey.pdf.saveAsPdf': 'Als PDF speichern', 'journey.pdf.saveAsPdf': 'Als PDF speichern',
'journey.pdf.pages': 'Seiten', '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.morning': 'Guten Morgen,',
'dashboard.greeting.afternoon': 'Guten Tag,', 'dashboard.greeting.afternoon': 'Guten Tag,',
'dashboard.greeting.evening': 'Guten Abend,', '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.allAssigned': 'Alle Orte zugeordnet',
'dayplan.mobile.noMatch': 'Kein Treffer', 'dayplan.mobile.noMatch': 'Kein Treffer',
'dayplan.mobile.createNew': 'Neuen Ort erstellen', '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 groups
'oauth.scope.group.trips': 'Reisen', 'oauth.scope.group.trips': 'Reisen',
+29
View File
@@ -1,6 +1,8 @@
const en: Record<string, string | { name: string; category: string }[]> = { const en: Record<string, string | { name: string; category: string }[]> = {
// Common // Common
'common.save': 'Save', 'common.save': 'Save',
'common.showMore': 'Show more',
'common.showLess': 'Show less',
'common.cancel': 'Cancel', 'common.cancel': 'Cancel',
'common.delete': 'Delete', 'common.delete': 'Delete',
'common.edit': 'Edit', '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.endTimeBeforeStart': 'End time is before start time',
'places.timeCollision': 'Time overlap with:', 'places.timeCollision': 'Time overlap with:',
'places.formWebsite': 'Website', 'places.formWebsite': 'Website',
'places.formNotes': 'Notes',
'places.formNotesPlaceholder': 'Personal notes...', 'places.formNotesPlaceholder': 'Personal notes...',
'places.formReservation': 'Reservation', 'places.formReservation': 'Reservation',
'places.reservationNotesPlaceholder': 'Reservation notes, confirmation number...', '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.contributors': 'Contributors',
'journey.detail.readMore': 'Read more', 'journey.detail.readMore': 'Read more',
'journey.detail.prosCons': 'Pros & Cons', 'journey.detail.prosCons': 'Pros & Cons',
'journey.detail.photos': 'photos',
'journey.detail.day': 'Day {number}',
'journey.detail.places': 'places',
// Journey Detail — Stats // Journey Detail — Stats
'journey.stats.days': 'Days', 'journey.stats.days': 'Days',
@@ -1902,6 +1908,8 @@ const en: Record<string, string | { name: string; category: string }[]> = {
'journey.stats.entries': 'Entries', 'journey.stats.entries': 'Entries',
'journey.stats.photos': 'Photos', 'journey.stats.photos': 'Photos',
'journey.stats.places': 'Places', 'journey.stats.places': 'Places',
'journey.skeletons.show': 'Show suggestions',
'journey.skeletons.hide': 'Hide suggestions',
// Journey Detail — Verdict // Journey Detail — Verdict
'journey.verdict.lovedIt': 'Loved it', 'journey.verdict.lovedIt': 'Loved it',
@@ -1913,6 +1921,7 @@ const en: Record<string, string | { name: string; category: string }[]> = {
// Journey Entry Editor // Journey Entry Editor
'journey.editor.uploadPhotos': 'Upload photos', 'journey.editor.uploadPhotos': 'Upload photos',
'journey.editor.uploading': 'Uploading...',
'journey.editor.fromGallery': 'From Gallery', 'journey.editor.fromGallery': 'From Gallery',
'journey.editor.allPhotosAdded': 'All photos already added', 'journey.editor.allPhotosAdded': 'All photos already added',
'journey.editor.writeStory': 'Write your story...', '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.weather': 'Weather',
'journey.editor.photoFirst': '1st', 'journey.editor.photoFirst': '1st',
'journey.editor.makeFirst': 'Make 1st', 'journey.editor.makeFirst': 'Make 1st',
'journey.editor.searching': 'Searching...',
// Journey Entry — Moods // Journey Entry — Moods
'journey.mood.amazing': 'Amazing', '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.deleteFailed': 'Failed to delete',
'journey.share.updateFailed': 'Failed to update', '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 Dialog
'journey.settings.title': 'Journey Settings', 'journey.settings.title': 'Journey Settings',
'journey.settings.coverImage': 'Cover Image', '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.theEnd': 'The End',
'journey.pdf.saveAsPdf': 'Save as PDF', 'journey.pdf.saveAsPdf': 'Save as PDF',
'journey.pdf.pages': 'pages', '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 Mobile
'dashboard.greeting.morning': 'Good morning,', 'dashboard.greeting.morning': 'Good morning,',
+29
View File
@@ -1,6 +1,8 @@
const es: Record<string, string> = { const es: Record<string, string> = {
// Common // Common
'common.save': 'Guardar', 'common.save': 'Guardar',
'common.showMore': 'Ver más',
'common.showLess': 'Ver menos',
'common.cancel': 'Cancelar', 'common.cancel': 'Cancelar',
'common.delete': 'Eliminar', 'common.delete': 'Eliminar',
'common.edit': 'Editar', '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.endTimeBeforeStart': 'La hora de fin es anterior a la de inicio',
'places.timeCollision': 'Solapamiento horario con:', 'places.timeCollision': 'Solapamiento horario con:',
'places.formWebsite': 'Página web', 'places.formWebsite': 'Página web',
'places.formNotes': 'Notas',
'places.formNotesPlaceholder': 'Notas personales...', 'places.formNotesPlaceholder': 'Notas personales...',
'places.formReservation': 'Reserva', 'places.formReservation': 'Reserva',
'places.reservationNotesPlaceholder': 'Notas de reserva, número de confirmación...', '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.contributors': 'Colaboradores',
'journey.detail.readMore': 'Leer más', 'journey.detail.readMore': 'Leer más',
'journey.detail.prosCons': 'Pros y contras', '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.days': 'Días',
'journey.stats.cities': 'Ciudades', 'journey.stats.cities': 'Ciudades',
'journey.stats.entries': 'Entradas', 'journey.stats.entries': 'Entradas',
'journey.stats.photos': 'Fotos', 'journey.stats.photos': 'Fotos',
'journey.stats.places': 'Lugares', 'journey.stats.places': 'Lugares',
'journey.skeletons.show': 'Mostrar sugerencias',
'journey.skeletons.hide': 'Ocultar sugerencias',
'journey.verdict.lovedIt': 'Me encantó', 'journey.verdict.lovedIt': 'Me encantó',
'journey.verdict.couldBeBetter': 'Podría mejorar', 'journey.verdict.couldBeBetter': 'Podría mejorar',
'journey.synced.places': 'lugares', 'journey.synced.places': 'lugares',
'journey.synced.synced': 'sincronizado', 'journey.synced.synced': 'sincronizado',
'journey.editor.uploadPhotos': 'Subir fotos', 'journey.editor.uploadPhotos': 'Subir fotos',
'journey.editor.uploading': 'Subiendo...',
'journey.editor.fromGallery': 'Desde galería', 'journey.editor.fromGallery': 'Desde galería',
'journey.editor.allPhotosAdded': 'Todas las fotos ya fueron añadidas', 'journey.editor.allPhotosAdded': 'Todas las fotos ya fueron añadidas',
'journey.editor.writeStory': 'Escribe tu historia...', 'journey.editor.writeStory': 'Escribe tu historia...',
@@ -1919,6 +1928,7 @@ const es: Record<string, string> = {
'journey.editor.weather': 'Clima', 'journey.editor.weather': 'Clima',
'journey.editor.photoFirst': '1º', 'journey.editor.photoFirst': '1º',
'journey.editor.makeFirst': 'Hacer 1º', 'journey.editor.makeFirst': 'Hacer 1º',
'journey.editor.searching': 'Buscando...',
'journey.mood.amazing': 'Increíble', 'journey.mood.amazing': 'Increíble',
'journey.mood.good': 'Bien', 'journey.mood.good': 'Bien',
'journey.mood.neutral': 'Neutral', 'journey.mood.neutral': 'Neutral',
@@ -1963,6 +1973,13 @@ const es: Record<string, string> = {
'journey.share.linkDeleted': 'Enlace para compartir eliminado', 'journey.share.linkDeleted': 'Enlace para compartir eliminado',
'journey.share.deleteFailed': 'No se pudo eliminar', 'journey.share.deleteFailed': 'No se pudo eliminar',
'journey.share.updateFailed': 'No se pudo actualizar', '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.title': 'Ajustes de la travesía',
'journey.settings.coverImage': 'Imagen de portada', 'journey.settings.coverImage': 'Imagen de portada',
'journey.settings.changeCover': 'Cambiar portada', 'journey.settings.changeCover': 'Cambiar portada',
@@ -1993,6 +2010,18 @@ const es: Record<string, string> = {
'journey.pdf.theEnd': 'Fin', 'journey.pdf.theEnd': 'Fin',
'journey.pdf.saveAsPdf': 'Guardar como PDF', 'journey.pdf.saveAsPdf': 'Guardar como PDF',
'journey.pdf.pages': 'páginas', '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.morning': 'Buenos días,',
'dashboard.greeting.afternoon': 'Buenas tardes,', 'dashboard.greeting.afternoon': 'Buenas tardes,',
'dashboard.greeting.evening': 'Buenas noches,', 'dashboard.greeting.evening': 'Buenas noches,',
+29
View File
@@ -1,6 +1,8 @@
const fr: Record<string, string> = { const fr: Record<string, string> = {
// Common // Common
'common.save': 'Enregistrer', 'common.save': 'Enregistrer',
'common.showMore': 'Voir plus',
'common.showLess': 'Voir moins',
'common.cancel': 'Annuler', 'common.cancel': 'Annuler',
'common.delete': 'Supprimer', 'common.delete': 'Supprimer',
'common.edit': 'Modifier', '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.endTimeBeforeStart': 'L\'heure de fin est antérieure à l\'heure de début',
'places.timeCollision': 'Chevauchement horaire avec :', 'places.timeCollision': 'Chevauchement horaire avec :',
'places.formWebsite': 'Site web', 'places.formWebsite': 'Site web',
'places.formNotes': 'Notes',
'places.formNotesPlaceholder': 'Notes personnelles…', 'places.formNotesPlaceholder': 'Notes personnelles…',
'places.formReservation': 'Réservation', 'places.formReservation': 'Réservation',
'places.reservationNotesPlaceholder': 'Notes de réservation, numéro de confirmation…', '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.contributors': 'Contributeurs',
'journey.detail.readMore': 'Lire la suite', 'journey.detail.readMore': 'Lire la suite',
'journey.detail.prosCons': 'Pour et contre', '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.days': 'Jours',
'journey.stats.cities': 'Villes', 'journey.stats.cities': 'Villes',
'journey.stats.entries': 'Entrées', 'journey.stats.entries': 'Entrées',
'journey.stats.photos': 'Photos', 'journey.stats.photos': 'Photos',
'journey.stats.places': 'Lieux', 'journey.stats.places': 'Lieux',
'journey.skeletons.show': 'Afficher les suggestions',
'journey.skeletons.hide': 'Masquer les suggestions',
'journey.verdict.lovedIt': 'Adoré', 'journey.verdict.lovedIt': 'Adoré',
'journey.verdict.couldBeBetter': 'Pourrait être mieux', 'journey.verdict.couldBeBetter': 'Pourrait être mieux',
'journey.synced.places': 'lieux', 'journey.synced.places': 'lieux',
'journey.synced.synced': 'synchronisé', 'journey.synced.synced': 'synchronisé',
'journey.editor.uploadPhotos': 'Téléverser des photos', 'journey.editor.uploadPhotos': 'Téléverser des photos',
'journey.editor.uploading': 'Envoi...',
'journey.editor.fromGallery': 'Depuis la galerie', 'journey.editor.fromGallery': 'Depuis la galerie',
'journey.editor.allPhotosAdded': 'Toutes les photos ont déjà été ajoutées', 'journey.editor.allPhotosAdded': 'Toutes les photos ont déjà été ajoutées',
'journey.editor.writeStory': 'Écrivez votre histoire...', 'journey.editor.writeStory': 'Écrivez votre histoire...',
@@ -1913,6 +1922,7 @@ const fr: Record<string, string> = {
'journey.editor.weather': 'Météo', 'journey.editor.weather': 'Météo',
'journey.editor.photoFirst': '1er', 'journey.editor.photoFirst': '1er',
'journey.editor.makeFirst': 'Mettre en 1er', 'journey.editor.makeFirst': 'Mettre en 1er',
'journey.editor.searching': 'Recherche...',
'journey.mood.amazing': 'Incroyable', 'journey.mood.amazing': 'Incroyable',
'journey.mood.good': 'Bien', 'journey.mood.good': 'Bien',
'journey.mood.neutral': 'Neutre', 'journey.mood.neutral': 'Neutre',
@@ -1957,6 +1967,13 @@ const fr: Record<string, string> = {
'journey.share.linkDeleted': 'Lien de partage supprimé', 'journey.share.linkDeleted': 'Lien de partage supprimé',
'journey.share.deleteFailed': 'Échec de la suppression', 'journey.share.deleteFailed': 'Échec de la suppression',
'journey.share.updateFailed': 'Échec de la mise à jour', '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.title': 'Paramètres du journal',
'journey.settings.coverImage': 'Image de couverture', 'journey.settings.coverImage': 'Image de couverture',
'journey.settings.changeCover': 'Changer la couverture', 'journey.settings.changeCover': 'Changer la couverture',
@@ -1987,6 +2004,18 @@ const fr: Record<string, string> = {
'journey.pdf.theEnd': 'Fin', 'journey.pdf.theEnd': 'Fin',
'journey.pdf.saveAsPdf': 'Enregistrer en PDF', 'journey.pdf.saveAsPdf': 'Enregistrer en PDF',
'journey.pdf.pages': 'pages', '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.morning': 'Bonjour,',
'dashboard.greeting.afternoon': 'Bon après-midi,', 'dashboard.greeting.afternoon': 'Bon après-midi,',
'dashboard.greeting.evening': 'Bonsoir,', 'dashboard.greeting.evening': 'Bonsoir,',
+29
View File
@@ -1,6 +1,8 @@
const hu: Record<string, string | { name: string; category: string }[]> = { const hu: Record<string, string | { name: string; category: string }[]> = {
// Általános // Általános
'common.save': 'Mentés', 'common.save': 'Mentés',
'common.showMore': 'Továbbiak',
'common.showLess': 'Kevesebb',
'common.cancel': 'Mégse', 'common.cancel': 'Mégse',
'common.delete': 'Törlés', 'common.delete': 'Törlés',
'common.edit': 'Szerkeszté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.endTimeBeforeStart': 'A befejezési idő a kezdési idő előtt van',
'places.timeCollision': 'Időbeli átfedés:', 'places.timeCollision': 'Időbeli átfedés:',
'places.formWebsite': 'Weboldal', 'places.formWebsite': 'Weboldal',
'places.formNotes': 'Jegyzetek',
'places.formNotesPlaceholder': 'Személyes jegyzetek...', 'places.formNotesPlaceholder': 'Személyes jegyzetek...',
'places.formReservation': 'Foglalás', 'places.formReservation': 'Foglalás',
'places.reservationNotesPlaceholder': 'Foglalási jegyzetek, visszaigazolási szám...', '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.contributors': 'Közreműködők',
'journey.detail.readMore': 'Tovább olvasás', 'journey.detail.readMore': 'Tovább olvasás',
'journey.detail.prosCons': 'Előnyök és hátrányok', '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.days': 'Napok',
'journey.stats.cities': 'Városok', 'journey.stats.cities': 'Városok',
'journey.stats.entries': 'Bejegyzések', 'journey.stats.entries': 'Bejegyzések',
'journey.stats.photos': 'Fotók', 'journey.stats.photos': 'Fotók',
'journey.stats.places': 'Helyszínek', '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.lovedIt': 'Imádtam',
'journey.verdict.couldBeBetter': 'Lehetne jobb', 'journey.verdict.couldBeBetter': 'Lehetne jobb',
'journey.synced.places': 'helyszín', 'journey.synced.places': 'helyszín',
'journey.synced.synced': 'szinkronizálva', 'journey.synced.synced': 'szinkronizálva',
'journey.editor.uploadPhotos': 'Fotók feltöltése', 'journey.editor.uploadPhotos': 'Fotók feltöltése',
'journey.editor.uploading': 'Feltöltés...',
'journey.editor.fromGallery': 'Galériából', 'journey.editor.fromGallery': 'Galériából',
'journey.editor.allPhotosAdded': 'Minden fotó már hozzáadva', 'journey.editor.allPhotosAdded': 'Minden fotó már hozzáadva',
'journey.editor.writeStory': 'Írd meg a történeted...', '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.weather': 'Időjárás',
'journey.editor.photoFirst': '1.', 'journey.editor.photoFirst': '1.',
'journey.editor.makeFirst': 'Legyen az 1.', 'journey.editor.makeFirst': 'Legyen az 1.',
'journey.editor.searching': 'Keresés...',
'journey.mood.amazing': 'Fantasztikus', 'journey.mood.amazing': 'Fantasztikus',
'journey.mood.good': 'Jó', 'journey.mood.good': 'Jó',
'journey.mood.neutral': 'Semleges', '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.linkDeleted': 'Megosztó link törölve',
'journey.share.deleteFailed': 'Nem sikerült törölni', 'journey.share.deleteFailed': 'Nem sikerült törölni',
'journey.share.updateFailed': 'Nem sikerült frissíteni', '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.title': 'Útinapló beállításai',
'journey.settings.coverImage': 'Borítókép', 'journey.settings.coverImage': 'Borítókép',
'journey.settings.changeCover': 'Borító módosítása', '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.theEnd': 'Vége',
'journey.pdf.saveAsPdf': 'Mentés PDF-ként', 'journey.pdf.saveAsPdf': 'Mentés PDF-ként',
'journey.pdf.pages': 'oldal', '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.morning': 'Jó reggelt,',
'dashboard.greeting.afternoon': 'Jó napot,', 'dashboard.greeting.afternoon': 'Jó napot,',
'dashboard.greeting.evening': 'Jó estét,', 'dashboard.greeting.evening': 'Jó estét,',
+29
View File
@@ -1,6 +1,8 @@
const it: Record<string, string | { name: string; category: string }[]> = { const it: Record<string, string | { name: string; category: string }[]> = {
// Common // Common
'common.save': 'Salva', 'common.save': 'Salva',
'common.showMore': 'Mostra di più',
'common.showLess': 'Mostra meno',
'common.cancel': 'Annulla', 'common.cancel': 'Annulla',
'common.delete': 'Elimina', 'common.delete': 'Elimina',
'common.edit': 'Modifica', '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.endTimeBeforeStart': 'L\'ora di fine è precedente all\'ora di inizio',
'places.timeCollision': 'Sovrapposizione di orario con:', 'places.timeCollision': 'Sovrapposizione di orario con:',
'places.formWebsite': 'Sito web', 'places.formWebsite': 'Sito web',
'places.formNotes': 'Note',
'places.formNotesPlaceholder': 'Note personali...', 'places.formNotesPlaceholder': 'Note personali...',
'places.formReservation': 'Prenotazione', 'places.formReservation': 'Prenotazione',
'places.reservationNotesPlaceholder': 'Note della prenotazione, numero di conferma...', '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.contributors': 'Contributori',
'journey.detail.readMore': 'Leggi di più', 'journey.detail.readMore': 'Leggi di più',
'journey.detail.prosCons': 'Pro e contro', '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.days': 'Giorni',
'journey.stats.cities': 'Città', 'journey.stats.cities': 'Città',
'journey.stats.entries': 'Voci', 'journey.stats.entries': 'Voci',
'journey.stats.photos': 'Foto', 'journey.stats.photos': 'Foto',
'journey.stats.places': 'Luoghi', 'journey.stats.places': 'Luoghi',
'journey.skeletons.show': 'Mostra suggerimenti',
'journey.skeletons.hide': 'Nascondi suggerimenti',
'journey.verdict.lovedIt': 'Adorato', 'journey.verdict.lovedIt': 'Adorato',
'journey.verdict.couldBeBetter': 'Potrebbe essere meglio', 'journey.verdict.couldBeBetter': 'Potrebbe essere meglio',
'journey.synced.places': 'luoghi', 'journey.synced.places': 'luoghi',
'journey.synced.synced': 'sincronizzato', 'journey.synced.synced': 'sincronizzato',
'journey.editor.uploadPhotos': 'Carica foto', 'journey.editor.uploadPhotos': 'Carica foto',
'journey.editor.uploading': 'Caricamento...',
'journey.editor.fromGallery': 'Dalla galleria', 'journey.editor.fromGallery': 'Dalla galleria',
'journey.editor.allPhotosAdded': 'Tutte le foto sono già state aggiunte', 'journey.editor.allPhotosAdded': 'Tutte le foto sono già state aggiunte',
'journey.editor.writeStory': 'Scrivi la tua storia...', '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.weather': 'Meteo',
'journey.editor.photoFirst': '1°', 'journey.editor.photoFirst': '1°',
'journey.editor.makeFirst': 'Metti 1°', 'journey.editor.makeFirst': 'Metti 1°',
'journey.editor.searching': 'Ricerca...',
'journey.mood.amazing': 'Fantastico', 'journey.mood.amazing': 'Fantastico',
'journey.mood.good': 'Buono', 'journey.mood.good': 'Buono',
'journey.mood.neutral': 'Neutro', '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.linkDeleted': 'Link di condivisione eliminato',
'journey.share.deleteFailed': 'Eliminazione fallita', 'journey.share.deleteFailed': 'Eliminazione fallita',
'journey.share.updateFailed': 'Aggiornamento fallito', '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.title': 'Impostazioni del diario',
'journey.settings.coverImage': 'Immagine di copertina', 'journey.settings.coverImage': 'Immagine di copertina',
'journey.settings.changeCover': 'Cambia 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.theEnd': 'Fine',
'journey.pdf.saveAsPdf': 'Salva come PDF', 'journey.pdf.saveAsPdf': 'Salva come PDF',
'journey.pdf.pages': 'pagine', '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.morning': 'Buongiorno,',
'dashboard.greeting.afternoon': 'Buon pomeriggio,', 'dashboard.greeting.afternoon': 'Buon pomeriggio,',
'dashboard.greeting.evening': 'Buonasera,', 'dashboard.greeting.evening': 'Buonasera,',
+29
View File
@@ -1,6 +1,8 @@
const nl: Record<string, string> = { const nl: Record<string, string> = {
// Common // Common
'common.save': 'Opslaan', 'common.save': 'Opslaan',
'common.showMore': 'Meer tonen',
'common.showLess': 'Minder tonen',
'common.cancel': 'Annuleren', 'common.cancel': 'Annuleren',
'common.delete': 'Verwijderen', 'common.delete': 'Verwijderen',
'common.edit': 'Bewerken', 'common.edit': 'Bewerken',
@@ -924,6 +926,7 @@ const nl: Record<string, string> = {
'places.endTimeBeforeStart': 'Eindtijd is vóór de starttijd', 'places.endTimeBeforeStart': 'Eindtijd is vóór de starttijd',
'places.timeCollision': 'Tijdoverlap met:', 'places.timeCollision': 'Tijdoverlap met:',
'places.formWebsite': 'Website', 'places.formWebsite': 'Website',
'places.formNotes': 'Notities',
'places.formNotesPlaceholder': 'Persoonlijke notities...', 'places.formNotesPlaceholder': 'Persoonlijke notities...',
'places.formReservation': 'Reservering', 'places.formReservation': 'Reservering',
'places.reservationNotesPlaceholder': 'Reserveringsnotities, bevestigingsnummer...', 'places.reservationNotesPlaceholder': 'Reserveringsnotities, bevestigingsnummer...',
@@ -1887,16 +1890,22 @@ const nl: Record<string, string> = {
'journey.detail.contributors': 'Bijdragers', 'journey.detail.contributors': 'Bijdragers',
'journey.detail.readMore': 'Lees meer', 'journey.detail.readMore': 'Lees meer',
'journey.detail.prosCons': 'Voor- & nadelen', '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.days': 'Dagen',
'journey.stats.cities': 'Steden', 'journey.stats.cities': 'Steden',
'journey.stats.entries': 'Vermeldingen', 'journey.stats.entries': 'Vermeldingen',
'journey.stats.photos': 'Foto\'s', 'journey.stats.photos': 'Foto\'s',
'journey.stats.places': 'Plaatsen', 'journey.stats.places': 'Plaatsen',
'journey.skeletons.show': 'Suggesties tonen',
'journey.skeletons.hide': 'Suggesties verbergen',
'journey.verdict.lovedIt': 'Geweldig', 'journey.verdict.lovedIt': 'Geweldig',
'journey.verdict.couldBeBetter': 'Kan beter', 'journey.verdict.couldBeBetter': 'Kan beter',
'journey.synced.places': 'plaatsen', 'journey.synced.places': 'plaatsen',
'journey.synced.synced': 'gesynchroniseerd', 'journey.synced.synced': 'gesynchroniseerd',
'journey.editor.uploadPhotos': 'Foto\'s uploaden', 'journey.editor.uploadPhotos': 'Foto\'s uploaden',
'journey.editor.uploading': 'Uploaden...',
'journey.editor.fromGallery': 'Uit galerij', 'journey.editor.fromGallery': 'Uit galerij',
'journey.editor.allPhotosAdded': 'Alle foto\'s al toegevoegd', 'journey.editor.allPhotosAdded': 'Alle foto\'s al toegevoegd',
'journey.editor.writeStory': 'Schrijf je verhaal...', 'journey.editor.writeStory': 'Schrijf je verhaal...',
@@ -1913,6 +1922,7 @@ const nl: Record<string, string> = {
'journey.editor.weather': 'Weer', 'journey.editor.weather': 'Weer',
'journey.editor.photoFirst': '1e', 'journey.editor.photoFirst': '1e',
'journey.editor.makeFirst': 'Maak 1e', 'journey.editor.makeFirst': 'Maak 1e',
'journey.editor.searching': 'Zoeken...',
'journey.mood.amazing': 'Fantastisch', 'journey.mood.amazing': 'Fantastisch',
'journey.mood.good': 'Goed', 'journey.mood.good': 'Goed',
'journey.mood.neutral': 'Neutraal', 'journey.mood.neutral': 'Neutraal',
@@ -1957,6 +1967,13 @@ const nl: Record<string, string> = {
'journey.share.linkDeleted': 'Deellink verwijderd', 'journey.share.linkDeleted': 'Deellink verwijderd',
'journey.share.deleteFailed': 'Verwijderen mislukt', 'journey.share.deleteFailed': 'Verwijderen mislukt',
'journey.share.updateFailed': 'Bijwerken 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.title': 'Reisverslaginstellingen',
'journey.settings.coverImage': 'Omslagfoto', 'journey.settings.coverImage': 'Omslagfoto',
'journey.settings.changeCover': 'Omslag wijzigen', 'journey.settings.changeCover': 'Omslag wijzigen',
@@ -1987,6 +2004,18 @@ const nl: Record<string, string> = {
'journey.pdf.theEnd': 'Einde', 'journey.pdf.theEnd': 'Einde',
'journey.pdf.saveAsPdf': 'Opslaan als PDF', 'journey.pdf.saveAsPdf': 'Opslaan als PDF',
'journey.pdf.pages': 'pagina\'s', '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.morning': 'Goedemorgen,',
'dashboard.greeting.afternoon': 'Goedemiddag,', 'dashboard.greeting.afternoon': 'Goedemiddag,',
'dashboard.greeting.evening': 'Goedenavond,', 'dashboard.greeting.evening': 'Goedenavond,',
+29
View File
@@ -1,6 +1,8 @@
const pl: Record<string, string | { name: string; category: string }[]> = { const pl: Record<string, string | { name: string; category: string }[]> = {
// Common // Common
'common.save': 'Zapisz', 'common.save': 'Zapisz',
'common.showMore': 'Pokaż więcej',
'common.showLess': 'Pokaż mniej',
'common.cancel': 'Anuluj', 'common.cancel': 'Anuluj',
'common.delete': 'Usuń', 'common.delete': 'Usuń',
'common.edit': 'Edytuj', '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.endTimeBeforeStart': 'Godzina zakończenia jest przed godziną rozpoczęcia',
'places.timeCollision': 'Nakładanie się godzin z:', 'places.timeCollision': 'Nakładanie się godzin z:',
'places.formWebsite': 'Strona internetowa', 'places.formWebsite': 'Strona internetowa',
'places.formNotes': 'Notatki',
'places.formNotesPlaceholder': 'Osobiste notatki...', 'places.formNotesPlaceholder': 'Osobiste notatki...',
'places.formReservation': 'Rezerwacja', 'places.formReservation': 'Rezerwacja',
'places.reservationNotesPlaceholder': 'Notatki z rezerwacji, numer potwierdzenia...', '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.contributors': 'Współtwórcy',
'journey.detail.readMore': 'Czytaj dalej', 'journey.detail.readMore': 'Czytaj dalej',
'journey.detail.prosCons': 'Zalety i wady', '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.days': 'Dni',
'journey.stats.cities': 'Miasta', 'journey.stats.cities': 'Miasta',
'journey.stats.entries': 'Wpisy', 'journey.stats.entries': 'Wpisy',
'journey.stats.photos': 'Zdjęcia', 'journey.stats.photos': 'Zdjęcia',
'journey.stats.places': 'Miejsca', 'journey.stats.places': 'Miejsca',
'journey.skeletons.show': 'Pokaż sugestie',
'journey.skeletons.hide': 'Ukryj sugestie',
'journey.verdict.lovedIt': 'Świetne', 'journey.verdict.lovedIt': 'Świetne',
'journey.verdict.couldBeBetter': 'Mogłoby być lepiej', 'journey.verdict.couldBeBetter': 'Mogłoby być lepiej',
'journey.synced.places': 'miejsca', 'journey.synced.places': 'miejsca',
'journey.synced.synced': 'zsynchronizowane', 'journey.synced.synced': 'zsynchronizowane',
'journey.editor.uploadPhotos': 'Prześlij zdjęcia', 'journey.editor.uploadPhotos': 'Prześlij zdjęcia',
'journey.editor.uploading': 'Przesyłanie...',
'journey.editor.fromGallery': 'Z galerii', 'journey.editor.fromGallery': 'Z galerii',
'journey.editor.allPhotosAdded': 'Wszystkie zdjęcia już dodane', 'journey.editor.allPhotosAdded': 'Wszystkie zdjęcia już dodane',
'journey.editor.writeStory': 'Napisz swoją historię...', '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.weather': 'Pogoda',
'journey.editor.photoFirst': '1.', 'journey.editor.photoFirst': '1.',
'journey.editor.makeFirst': 'Ustaw jako 1.', 'journey.editor.makeFirst': 'Ustaw jako 1.',
'journey.editor.searching': 'Szukanie...',
'journey.mood.amazing': 'Niesamowity', 'journey.mood.amazing': 'Niesamowity',
'journey.mood.good': 'Dobry', 'journey.mood.good': 'Dobry',
'journey.mood.neutral': 'Neutralny', '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.linkDeleted': 'Link udostępniania usunięty',
'journey.share.deleteFailed': 'Usunięcie nie powiodło się', 'journey.share.deleteFailed': 'Usunięcie nie powiodło się',
'journey.share.updateFailed': 'Aktualizacja nie powiodła 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.title': 'Ustawienia dziennika podróży',
'journey.settings.coverImage': 'Zdjęcie okładkowe', 'journey.settings.coverImage': 'Zdjęcie okładkowe',
'journey.settings.changeCover': 'Zmień okładkę', '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.theEnd': 'Koniec',
'journey.pdf.saveAsPdf': 'Zapisz jako PDF', 'journey.pdf.saveAsPdf': 'Zapisz jako PDF',
'journey.pdf.pages': 'stron', '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.morning': 'Dzień dobry,',
'dashboard.greeting.afternoon': 'Dzień dobry,', 'dashboard.greeting.afternoon': 'Dzień dobry,',
'dashboard.greeting.evening': 'Dobry wieczór,', 'dashboard.greeting.evening': 'Dobry wieczór,',
+29 -13
View File
@@ -1,6 +1,8 @@
const ru: Record<string, string> = { const ru: Record<string, string> = {
// Common // Common
'common.save': 'Сохранить', 'common.save': 'Сохранить',
'common.showMore': 'Показать больше',
'common.showLess': 'Показать меньше',
'common.cancel': 'Отмена', 'common.cancel': 'Отмена',
'common.delete': 'Удалить', 'common.delete': 'Удалить',
'common.edit': 'Редактировать', 'common.edit': 'Редактировать',
@@ -924,6 +926,7 @@ const ru: Record<string, string> = {
'places.endTimeBeforeStart': 'Время окончания раньше времени начала', 'places.endTimeBeforeStart': 'Время окончания раньше времени начала',
'places.timeCollision': 'Пересечение по времени с:', 'places.timeCollision': 'Пересечение по времени с:',
'places.formWebsite': 'Сайт', 'places.formWebsite': 'Сайт',
'places.formNotes': 'Заметки',
'places.formNotesPlaceholder': 'Личные заметки...', 'places.formNotesPlaceholder': 'Личные заметки...',
'places.formReservation': 'Бронирование', 'places.formReservation': 'Бронирование',
'places.reservationNotesPlaceholder': 'Заметки о бронировании, номер подтверждения...', 'places.reservationNotesPlaceholder': 'Заметки о бронировании, номер подтверждения...',
@@ -1093,7 +1096,6 @@ const ru: Record<string, string> = {
'budget.settlement': 'Взаиморасчёт', 'budget.settlement': 'Взаиморасчёт',
'budget.settlementInfo': 'Нажмите на аватар участника в строке бюджета, чтобы отметить его зелёным — это значит, что он заплатил. Взаиморасчёт покажет, кто кому и сколько должен.', 'budget.settlementInfo': 'Нажмите на аватар участника в строке бюджета, чтобы отметить его зелёным — это значит, что он заплатил. Взаиморасчёт покажет, кто кому и сколько должен.',
'budget.netBalances': 'Чистые балансы', 'budget.netBalances': 'Чистые балансы',
'budget.linkedToReservation': 'Привязано к бронированию — измените название там',
// Files // Files
'files.title': 'Файлы', 'files.title': 'Файлы',
@@ -1802,21 +1804,9 @@ const ru: Record<string, string> = {
'common.justNow': 'только что', 'common.justNow': 'только что',
'common.hoursAgo': '{count} ч назад', 'common.hoursAgo': '{count} ч назад',
'common.daysAgo': '{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.saveRouteNotConfigured': 'Маршрут сохранения не настроен для этого провайдера',
'memories.testRouteNotConfigured': 'Маршрут тестирования не настроен для этого провайдера', 'memories.testRouteNotConfigured': 'Маршрут тестирования не настроен для этого провайдера',
'memories.fillRequiredFields': 'Пожалуйста, заполните все обязательные поля', 'memories.fillRequiredFields': 'Пожалуйста, заполните все обязательные поля',
'memories.selectAlbumMultiple': 'Выбрать альбом',
'memories.selectPhotosMultiple': 'Выбрать фото',
'journey.title': 'Путешествие', 'journey.title': 'Путешествие',
'journey.subtitle': 'Отслеживайте свои путешествия в реальном времени', 'journey.subtitle': 'Отслеживайте свои путешествия в реальном времени',
'journey.new': 'Новое путешествие', 'journey.new': 'Новое путешествие',
@@ -1900,16 +1890,22 @@ const ru: Record<string, string> = {
'journey.detail.contributors': 'Участники', 'journey.detail.contributors': 'Участники',
'journey.detail.readMore': 'Читать далее', 'journey.detail.readMore': 'Читать далее',
'journey.detail.prosCons': 'Плюсы и минусы', 'journey.detail.prosCons': 'Плюсы и минусы',
'journey.detail.photos': 'фото',
'journey.detail.day': 'День {number}',
'journey.detail.places': 'мест',
'journey.stats.days': 'Дней', 'journey.stats.days': 'Дней',
'journey.stats.cities': 'Городов', 'journey.stats.cities': 'Городов',
'journey.stats.entries': 'Записей', 'journey.stats.entries': 'Записей',
'journey.stats.photos': 'Фото', 'journey.stats.photos': 'Фото',
'journey.stats.places': 'Мест', 'journey.stats.places': 'Мест',
'journey.skeletons.show': 'Показать предложения',
'journey.skeletons.hide': 'Скрыть предложения',
'journey.verdict.lovedIt': 'Понравилось', 'journey.verdict.lovedIt': 'Понравилось',
'journey.verdict.couldBeBetter': 'Могло быть лучше', 'journey.verdict.couldBeBetter': 'Могло быть лучше',
'journey.synced.places': 'мест', 'journey.synced.places': 'мест',
'journey.synced.synced': 'синхронизировано', 'journey.synced.synced': 'синхронизировано',
'journey.editor.uploadPhotos': 'Загрузить фото', 'journey.editor.uploadPhotos': 'Загрузить фото',
'journey.editor.uploading': 'Загрузка...',
'journey.editor.fromGallery': 'Из галереи', 'journey.editor.fromGallery': 'Из галереи',
'journey.editor.allPhotosAdded': 'Все фото уже добавлены', 'journey.editor.allPhotosAdded': 'Все фото уже добавлены',
'journey.editor.writeStory': 'Напишите свою историю...', 'journey.editor.writeStory': 'Напишите свою историю...',
@@ -1926,6 +1922,7 @@ const ru: Record<string, string> = {
'journey.editor.weather': 'Погода', 'journey.editor.weather': 'Погода',
'journey.editor.photoFirst': '1-е', 'journey.editor.photoFirst': '1-е',
'journey.editor.makeFirst': 'Сделать 1-м', 'journey.editor.makeFirst': 'Сделать 1-м',
'journey.editor.searching': 'Поиск...',
'journey.mood.amazing': 'Потрясающе', 'journey.mood.amazing': 'Потрясающе',
'journey.mood.good': 'Хорошо', 'journey.mood.good': 'Хорошо',
'journey.mood.neutral': 'Нейтрально', 'journey.mood.neutral': 'Нейтрально',
@@ -1970,6 +1967,13 @@ const ru: Record<string, string> = {
'journey.share.linkDeleted': 'Ссылка удалена', 'journey.share.linkDeleted': 'Ссылка удалена',
'journey.share.deleteFailed': 'Не удалось удалить', 'journey.share.deleteFailed': 'Не удалось удалить',
'journey.share.updateFailed': 'Не удалось обновить', '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.title': 'Настройки путешествия',
'journey.settings.coverImage': 'Обложка', 'journey.settings.coverImage': 'Обложка',
'journey.settings.changeCover': 'Сменить обложку', 'journey.settings.changeCover': 'Сменить обложку',
@@ -2000,6 +2004,18 @@ const ru: Record<string, string> = {
'journey.pdf.theEnd': 'Конец', 'journey.pdf.theEnd': 'Конец',
'journey.pdf.saveAsPdf': 'Сохранить как PDF', 'journey.pdf.saveAsPdf': 'Сохранить как PDF',
'journey.pdf.pages': 'страниц', '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.morning': 'Доброе утро,',
'dashboard.greeting.afternoon': 'Добрый день,', 'dashboard.greeting.afternoon': 'Добрый день,',
'dashboard.greeting.evening': 'Добрый вечер,', 'dashboard.greeting.evening': 'Добрый вечер,',
+30 -14
View File
@@ -1,6 +1,8 @@
const zh: Record<string, string> = { const zh: Record<string, string> = {
// Common // Common
'common.save': '保存', 'common.save': '保存',
'common.showMore': '显示更多',
'common.showLess': '收起',
'common.cancel': '取消', 'common.cancel': '取消',
'common.delete': '删除', 'common.delete': '删除',
'common.edit': '编辑', 'common.edit': '编辑',
@@ -924,6 +926,7 @@ const zh: Record<string, string> = {
'places.endTimeBeforeStart': '结束时间早于开始时间', 'places.endTimeBeforeStart': '结束时间早于开始时间',
'places.timeCollision': '时间冲突:', 'places.timeCollision': '时间冲突:',
'places.formWebsite': '网站', 'places.formWebsite': '网站',
'places.formNotes': '备注',
'places.formNotesPlaceholder': '个人备注...', 'places.formNotesPlaceholder': '个人备注...',
'places.formReservation': '预订', 'places.formReservation': '预订',
'places.reservationNotesPlaceholder': '预订备注、确认号...', 'places.reservationNotesPlaceholder': '预订备注、确认号...',
@@ -1093,7 +1096,6 @@ const zh: Record<string, string> = {
'budget.settlement': '结算', 'budget.settlement': '结算',
'budget.settlementInfo': '点击预算项目上的成员头像将其标记为绿色——表示该成员已付款。结算会显示谁欠谁多少。', 'budget.settlementInfo': '点击预算项目上的成员头像将其标记为绿色——表示该成员已付款。结算会显示谁欠谁多少。',
'budget.netBalances': '净余额', 'budget.netBalances': '净余额',
'budget.linkedToReservation': '已链接到预订——在那里编辑名称',
// Files // Files
'files.title': '文件', 'files.title': '文件',
@@ -1802,21 +1804,9 @@ const zh: Record<string, string> = {
'common.justNow': '刚刚', 'common.justNow': '刚刚',
'common.hoursAgo': '{count}小时前', 'common.hoursAgo': '{count}小时前',
'common.daysAgo': '{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.saveRouteNotConfigured': '此提供商未配置保存路由',
'memories.testRouteNotConfigured': '此提供商未配置测试路由', 'memories.testRouteNotConfigured': '此提供商未配置测试路由',
'memories.fillRequiredFields': '请填写所有必填字段', 'memories.fillRequiredFields': '请填写所有必填字段',
'memories.selectAlbumMultiple': '选择相册',
'memories.selectPhotosMultiple': '选择照片',
'journey.title': '旅程', 'journey.title': '旅程',
'journey.subtitle': '实时记录你的旅行', 'journey.subtitle': '实时记录你的旅行',
'journey.new': '新建旅程', 'journey.new': '新建旅程',
@@ -1900,17 +1890,23 @@ const zh: Record<string, string> = {
'journey.detail.contributors': '贡献者', 'journey.detail.contributors': '贡献者',
'journey.detail.readMore': '阅读更多', 'journey.detail.readMore': '阅读更多',
'journey.detail.prosCons': '优缺点', 'journey.detail.prosCons': '优缺点',
'journey.detail.photos': '照片',
'journey.detail.day': '第{number}天',
'journey.detail.places': '个地点',
'journey.stats.days': '天', 'journey.stats.days': '天',
'journey.stats.cities': '城市', 'journey.stats.cities': '城市',
'journey.stats.entries': '条目', 'journey.stats.entries': '条目',
'journey.stats.photos': '照片', 'journey.stats.photos': '照片',
'journey.stats.places': '地点', 'journey.stats.places': '地点',
'journey.skeletons.show': '显示建议',
'journey.skeletons.hide': '隐藏建议',
'journey.verdict.lovedIt': '非常喜欢', 'journey.verdict.lovedIt': '非常喜欢',
'journey.verdict.couldBeBetter': '有待改进', 'journey.verdict.couldBeBetter': '有待改进',
'journey.synced.places': '个地点', 'journey.synced.places': '个地点',
'journey.synced.synced': '已同步', 'journey.synced.synced': '已同步',
'journey.editor.uploadPhotos': '上传照片', 'journey.editor.uploadPhotos': '上传照片',
'journey.editor.fromGallery': '从相册选择', 'journey.editor.uploading': '上传中...',
'journey.editor.fromGallery': '从相册',
'journey.editor.allPhotosAdded': '所有照片已添加', 'journey.editor.allPhotosAdded': '所有照片已添加',
'journey.editor.writeStory': '写下你的故事...', 'journey.editor.writeStory': '写下你的故事...',
'journey.editor.prosCons': '优缺点', 'journey.editor.prosCons': '优缺点',
@@ -1926,6 +1922,7 @@ const zh: Record<string, string> = {
'journey.editor.weather': '天气', 'journey.editor.weather': '天气',
'journey.editor.photoFirst': '第1张', 'journey.editor.photoFirst': '第1张',
'journey.editor.makeFirst': '设为第1张', 'journey.editor.makeFirst': '设为第1张',
'journey.editor.searching': '搜索中...',
'journey.mood.amazing': '太棒了', 'journey.mood.amazing': '太棒了',
'journey.mood.good': '不错', 'journey.mood.good': '不错',
'journey.mood.neutral': '一般', 'journey.mood.neutral': '一般',
@@ -1970,6 +1967,13 @@ const zh: Record<string, string> = {
'journey.share.linkDeleted': '分享链接已删除', 'journey.share.linkDeleted': '分享链接已删除',
'journey.share.deleteFailed': '删除失败', 'journey.share.deleteFailed': '删除失败',
'journey.share.updateFailed': '更新失败', '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.title': '旅程设置',
'journey.settings.coverImage': '封面图片', 'journey.settings.coverImage': '封面图片',
'journey.settings.changeCover': '更换封面', 'journey.settings.changeCover': '更换封面',
@@ -2000,6 +2004,18 @@ const zh: Record<string, string> = {
'journey.pdf.theEnd': '终', 'journey.pdf.theEnd': '终',
'journey.pdf.saveAsPdf': '保存为 PDF', 'journey.pdf.saveAsPdf': '保存为 PDF',
'journey.pdf.pages': '页', '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.morning': '早上好,',
'dashboard.greeting.afternoon': '下午好,', 'dashboard.greeting.afternoon': '下午好,',
'dashboard.greeting.evening': '晚上好,', 'dashboard.greeting.evening': '晚上好,',
+30 -147
View File
@@ -1,6 +1,8 @@
const zhTw: Record<string, string> = { const zhTw: Record<string, string> = {
// Common // Common
'common.save': '儲存', 'common.save': '儲存',
'common.showMore': '顯示更多',
'common.showLess': '收起',
'common.cancel': '取消', 'common.cancel': '取消',
'common.delete': '刪除', 'common.delete': '刪除',
'common.edit': '編輯', 'common.edit': '編輯',
@@ -133,8 +135,6 @@ const zhTw: Record<string, string> = {
'dashboard.coverRemoveError': '移除失敗', 'dashboard.coverRemoveError': '移除失敗',
'dashboard.titleRequired': '標題為必填項', 'dashboard.titleRequired': '標題為必填項',
'dashboard.endDateError': '結束日期必須晚於開始日期', 'dashboard.endDateError': '結束日期必須晚於開始日期',
'dashboard.dayCount': '天數',
'dashboard.dayCountHint': '未設定旅行日期時的規劃天數。',
// Settings // Settings
'settings.title': '設定', 'settings.title': '設定',
@@ -951,6 +951,7 @@ const zhTw: Record<string, string> = {
'places.endTimeBeforeStart': '結束時間早於開始時間', 'places.endTimeBeforeStart': '結束時間早於開始時間',
'places.timeCollision': '時間衝突:', 'places.timeCollision': '時間衝突:',
'places.formWebsite': '網站', 'places.formWebsite': '網站',
'places.formNotes': '備註',
'places.formNotesPlaceholder': '個人備註...', 'places.formNotesPlaceholder': '個人備註...',
'places.formReservation': '預訂', 'places.formReservation': '預訂',
'places.reservationNotesPlaceholder': '預訂備註、確認號...', 'places.reservationNotesPlaceholder': '預訂備註、確認號...',
@@ -1763,21 +1764,9 @@ const zhTw: Record<string, string> = {
'common.justNow': '剛剛', 'common.justNow': '剛剛',
'common.hoursAgo': '{count}小時前', 'common.hoursAgo': '{count}小時前',
'common.daysAgo': '{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.saveRouteNotConfigured': '此提供商未設定儲存路由',
'memories.testRouteNotConfigured': '此提供商未設定測試路由', 'memories.testRouteNotConfigured': '此提供商未設定測試路由',
'memories.fillRequiredFields': '請填寫所有必填欄位', 'memories.fillRequiredFields': '請填寫所有必填欄位',
'memories.selectAlbumMultiple': '選擇相簿',
'memories.selectPhotosMultiple': '選擇照片',
'journey.title': '旅程', 'journey.title': '旅程',
'journey.subtitle': '即時記錄你的旅行', 'journey.subtitle': '即時記錄你的旅行',
'journey.new': '新建旅程', 'journey.new': '新建旅程',
@@ -1861,17 +1850,23 @@ const zhTw: Record<string, string> = {
'journey.detail.contributors': '貢獻者', 'journey.detail.contributors': '貢獻者',
'journey.detail.readMore': '閱讀更多', 'journey.detail.readMore': '閱讀更多',
'journey.detail.prosCons': '優缺點', 'journey.detail.prosCons': '優缺點',
'journey.detail.photos': '照片',
'journey.detail.day': '第{number}天',
'journey.detail.places': '個地點',
'journey.stats.days': '天', 'journey.stats.days': '天',
'journey.stats.cities': '城市', 'journey.stats.cities': '城市',
'journey.stats.entries': '條目', 'journey.stats.entries': '條目',
'journey.stats.photos': '照片', 'journey.stats.photos': '照片',
'journey.stats.places': '地點', 'journey.stats.places': '地點',
'journey.skeletons.show': '顯示建議',
'journey.skeletons.hide': '隱藏建議',
'journey.verdict.lovedIt': '非常喜歡', 'journey.verdict.lovedIt': '非常喜歡',
'journey.verdict.couldBeBetter': '有待改進', 'journey.verdict.couldBeBetter': '有待改進',
'journey.synced.places': '個地點', 'journey.synced.places': '個地點',
'journey.synced.synced': '已同步', 'journey.synced.synced': '已同步',
'journey.editor.uploadPhotos': '上傳照片', 'journey.editor.uploadPhotos': '上傳照片',
'journey.editor.fromGallery': '從相簿選擇', 'journey.editor.uploading': '上傳中...',
'journey.editor.fromGallery': '從相簿',
'journey.editor.allPhotosAdded': '所有照片已新增', 'journey.editor.allPhotosAdded': '所有照片已新增',
'journey.editor.writeStory': '寫下你的故事...', 'journey.editor.writeStory': '寫下你的故事...',
'journey.editor.prosCons': '優缺點', 'journey.editor.prosCons': '優缺點',
@@ -1887,6 +1882,7 @@ const zhTw: Record<string, string> = {
'journey.editor.weather': '天氣', 'journey.editor.weather': '天氣',
'journey.editor.photoFirst': '第1張', 'journey.editor.photoFirst': '第1張',
'journey.editor.makeFirst': '設為第1張', 'journey.editor.makeFirst': '設為第1張',
'journey.editor.searching': '搜尋中...',
'journey.mood.amazing': '太棒了', 'journey.mood.amazing': '太棒了',
'journey.mood.good': '不錯', 'journey.mood.good': '不錯',
'journey.mood.neutral': '一般', 'journey.mood.neutral': '一般',
@@ -1931,6 +1927,13 @@ const zhTw: Record<string, string> = {
'journey.share.linkDeleted': '分享連結已刪除', 'journey.share.linkDeleted': '分享連結已刪除',
'journey.share.deleteFailed': '刪除失敗', 'journey.share.deleteFailed': '刪除失敗',
'journey.share.updateFailed': '更新失敗', '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.title': '旅程設定',
'journey.settings.coverImage': '封面圖片', 'journey.settings.coverImage': '封面圖片',
'journey.settings.changeCover': '更換封面', 'journey.settings.changeCover': '更換封面',
@@ -1961,6 +1964,18 @@ const zhTw: Record<string, string> = {
'journey.pdf.theEnd': '終', 'journey.pdf.theEnd': '終',
'journey.pdf.saveAsPdf': '儲存為 PDF', 'journey.pdf.saveAsPdf': '儲存為 PDF',
'journey.pdf.pages': '頁', '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.morning': '早安,',
'dashboard.greeting.afternoon': '午安,', 'dashboard.greeting.afternoon': '午安,',
'dashboard.greeting.evening': '晚安,', 'dashboard.greeting.evening': '晚安,',
@@ -1998,112 +2013,9 @@ const zhTw: Record<string, string> = {
'dayplan.mobile.createNew': '建立新地點', 'dayplan.mobile.createNew': '建立新地點',
'admin.addons.catalog.journey.name': '旅程', 'admin.addons.catalog.journey.name': '旅程',
'admin.addons.catalog.journey.description': '旅行追蹤與旅行日誌,包含打卡、照片和每日故事', '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.title': '有可用更新',
'notifications.versionAvailable.text': 'TREK {version} 現已推出。', 'notifications.versionAvailable.text': 'TREK {version} 現已推出。',
'notifications.versionAvailable.button': '查看詳情', '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.title': '[測試] 通知',
'notif.test.simple.text': '這是一則簡單的測試通知。', 'notif.test.simple.text': '這是一則簡單的測試通知。',
'notif.test.boolean.text': '你是否接受這則測試通知?', 'notif.test.boolean.text': '你是否接受這則測試通知?',
@@ -2130,39 +2042,10 @@ const zhTw: Record<string, string> = {
'notif.action.view_photos': '查看照片', 'notif.action.view_photos': '查看照片',
'notif.action.view_vacay': '查看 Vacay', 'notif.action.view_vacay': '查看 Vacay',
'notif.action.view_admin': '前往管理', 'notif.action.view_admin': '前往管理',
'notifications.versionAvailable.title': '有可用更新',
'notifications.versionAvailable.text': 'TREK {version} 現已推出。',
'notifications.versionAvailable.button': '查看詳情',
// Notifications — dev test events // Notifications — dev test events
'notif.test.title': '[測試] 通知',
'notif.test.simple.text': '這是一條簡單的測試通知。',
'notif.test.boolean.text': '您接受此測試通知嗎?',
'notif.test.navigate.text': '點選下方前往儀表板。',
// Notifications // 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.view': '查看',
'notif.action.accept': '接受', 'notif.action.accept': '接受',
'notif.action.decline': '拒絕', 'notif.action.decline': '拒絕',
+1 -1
View File
@@ -1353,7 +1353,7 @@ export default function AdminPage(): React.ReactElement {
disabled={!smtpValues.admin_webhook_url?.trim()} 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" 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> </button>
</div> </div>
</div> </div>
+4 -2
View File
@@ -296,8 +296,9 @@ export default function AtlasPage(): React.ReactElement {
updateWhenIdle: false, updateWhenIdle: false,
tileSize: 256, tileSize: 256,
zoomOffset: 0, zoomOffset: 0,
crossOrigin: true crossOrigin: true,
}).addTo(map) referrerPolicy: 'strict-origin-when-cross-origin',
} as any).addTo(map)
// Preload adjacent zoom level tiles // Preload adjacent zoom level tiles
L.tileLayer(tileUrl, { L.tileLayer(tileUrl, {
@@ -306,6 +307,7 @@ export default function AtlasPage(): React.ReactElement {
opacity: 0, opacity: 0,
tileSize: 256, tileSize: 256,
crossOrigin: true, crossOrigin: true,
referrerPolicy: 'strict-origin-when-cross-origin',
}).addTo(map) }).addTo(map)
// Custom pane for region layer — above overlay (z-index 400) // 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); expect(screen.getAllByText('2').length).toBeGreaterThan(0);
}); });
// Duration stat label // Days stat label
expect(screen.getAllByText(/duration/i).length).toBeGreaterThan(0); expect(screen.getAllByText(/days/i).length).toBeGreaterThan(0);
// Places stat label // Places stat label
expect(screen.getAllByText(/places/i).length).toBeGreaterThan(0); expect(screen.getAllByText(/places/i).length).toBeGreaterThan(0);
}); });
+54 -46
View File
@@ -113,6 +113,7 @@ const mockJourneyDetail = {
{ {
id: 100, id: 100,
entry_id: 10, entry_id: 10,
photo_id: 100,
provider: 'local', provider: 'local',
file_path: 'photos/test.jpg', file_path: 'photos/test.jpg',
asset_id: null, asset_id: null,
@@ -300,7 +301,7 @@ describe('JourneyDetailPage', () => {
// img with alt="" is presentational (no 'img' role), so query the DOM directly // img with alt="" is presentational (no 'img' role), so query the DOM directly
const images = document.querySelectorAll('img'); const images = document.querySelectorAll('img');
const srcs = Array.from(images).map((img) => img.getAttribute('src')); 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(); await renderAndWait();
const imgs = document.querySelectorAll('img'); const imgs = document.querySelectorAll('img');
const photoSrcs = Array.from(imgs).map((img) => img.getAttribute('src')); 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], ...mockJourneyDetail.entries[0],
photos: [ 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, asset_id: null, owner_id: null, thumbnail_path: null,
caption: null, sort_order: 0, width: 800, height: 600, shared: 1, created_at: now, 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, asset_id: null, owner_id: null, thumbnail_path: null,
caption: null, sort_order: 1, width: 800, height: 600, shared: 1, created_at: now, 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, asset_id: null, owner_id: null, thumbnail_path: null,
caption: null, sort_order: 2, width: 800, height: 600, shared: 1, created_at: now, 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 imgs = document.querySelectorAll('img');
const photoSrcs = Array.from(imgs).map((img) => img.getAttribute('src')); const photoSrcs = Array.from(imgs).map((img) => img.getAttribute('src'));
expect(photoSrcs).toContain('/uploads/photos/a.jpg'); expect(photoSrcs).toContain('/api/photos/100/thumbnail');
expect(photoSrcs).toContain('/uploads/photos/b.jpg'); expect(photoSrcs).toContain('/api/photos/101/thumbnail');
expect(photoSrcs).toContain('/uploads/photos/c.jpg'); expect(photoSrcs).toContain('/api/photos/102/thumbnail');
}); });
}); });
@@ -1064,7 +1065,7 @@ describe('JourneyDetailPage', () => {
// Gallery renders photos as images // Gallery renders photos as images
const imgs = document.querySelectorAll('img'); const imgs = document.querySelectorAll('img');
const srcs = Array.from(imgs).map((img) => img.getAttribute('src')); 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 // 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); expect(galleryImgs.length).toBeGreaterThanOrEqual(1);
await user.click(galleryImgs[0] as HTMLElement); await user.click(galleryImgs[0] as HTMLElement);
@@ -1960,8 +1961,10 @@ describe('JourneyDetailPage', () => {
expect(screen.getByText(/1 photos/i)).toBeInTheDocument(); expect(screen.getByText(/1 photos/i)).toBeInTheDocument();
}); });
// The entry date '2026-03-15' is shown as an overlay on each gallery photo // The entry date '2026-03-15' is shown as a formatted overlay on each gallery photo
expect(screen.getByText('2026-03-15')).toBeInTheDocument(); // 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 = { const immichEntry = {
...mockJourneyDetail.entries[0], ...mockJourneyDetail.entries[0],
photos: [{ 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, asset_id: 'asset-123', owner_id: 1, thumbnail_path: null,
caption: null, sort_order: 0, width: 800, height: 600, shared: 1, created_at: now, caption: null, sort_order: 0, width: 800, height: 600, shared: 1, created_at: now,
}], }],
@@ -2025,7 +2028,7 @@ describe('JourneyDetailPage', () => {
const synologyEntry = { const synologyEntry = {
...mockJourneyDetail.entries[0], ...mockJourneyDetail.entries[0],
photos: [{ 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, asset_id: 'syn-456', owner_id: 1, thumbnail_path: null,
caption: null, sort_order: 0, width: 800, height: 600, shared: 1, created_at: now, 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 }); const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
await openGalleryWithProvider(user); await openGalleryWithProvider(user);
// Filter tabs use i18n keys: journey.trips.link = "Link", common.edit = "Edit", journey.share.gallery = "Gallery" // Filter tabs use i18n keys: journey.picker.tripPeriod, dateRange, allPhotos, albums
// "Link" may appear in multiple places, so check the picker has all three tabs
const pickerModal = screen.getByText('Add to').closest('[class*="fixed"]')!; const pickerModal = screen.getByText('Add to').closest('[class*="fixed"]')!;
expect(pickerModal).toBeTruthy(); expect(pickerModal).toBeTruthy();
// The filter bar inside picker has 3 tab buttons (Link, Edit, Gallery) // The filter bar inside picker has 4 tab buttons
expect(screen.getByText('Edit')).toBeInTheDocument(); expect(screen.getByText('Trip Period')).toBeInTheDocument();
expect(screen.getByText('Albums')).toBeInTheDocument();
expect(screen.getByText('Add to')).toBeInTheDocument(); expect(screen.getByText('Add to')).toBeInTheDocument();
}); });
}); });
@@ -2124,6 +2127,9 @@ describe('JourneyDetailPage', () => {
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
await openGalleryWithProvider(user); 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 // Photos should load via the search endpoint, rendered as thumbnail images
await waitFor(() => { await waitFor(() => {
const imgs = document.querySelectorAll('img[src*="/api/integrations/memories/"]'); const imgs = document.querySelectorAll('img[src*="/api/integrations/memories/"]');
@@ -2293,8 +2299,8 @@ describe('JourneyDetailPage', () => {
// The gallery picker shows thumbnail images from existing photos // The gallery picker shows thumbnail images from existing photos
await waitFor(() => { await waitFor(() => {
// The gallery picker grid renders gallery photos as clickable thumbnails // The gallery picker grid renders gallery photos as clickable thumbnails via /api/photos/{id}/thumbnail
const pickerImgs = document.querySelectorAll('img[src="/uploads/photos/test.jpg"]'); const pickerImgs = document.querySelectorAll('img[src="/api/photos/100/thumbnail"]');
expect(pickerImgs.length).toBeGreaterThanOrEqual(1); expect(pickerImgs.length).toBeGreaterThanOrEqual(1);
}); });
}); });
@@ -2471,9 +2477,9 @@ describe('JourneyDetailPage', () => {
expect(screen.getByText('Invite Contributor')).toBeInTheDocument(); expect(screen.getByText('Invite Contributor')).toBeInTheDocument();
}); });
// Role selector shows viewer and editor buttons // Role selector shows Viewer and Editor buttons (from journey.invite.viewer / journey.invite.editor)
expect(screen.getByText('viewer')).toBeInTheDocument(); expect(screen.getByText('Viewer')).toBeInTheDocument();
expect(screen.getByText('editor')).toBeInTheDocument(); expect(screen.getByText('Editor')).toBeInTheDocument();
}); });
}); });
@@ -2501,11 +2507,11 @@ describe('JourneyDetailPage', () => {
await user.click(inviteBtns[0] as HTMLElement); await user.click(inviteBtns[0] as HTMLElement);
await waitFor(() => { await waitFor(() => {
expect(screen.getByText('viewer')).toBeInTheDocument(); expect(screen.getByText('Viewer')).toBeInTheDocument();
}); });
// Default is viewer - click editor to switch // Default is Viewer - click Editor to switch
const editorBtn = screen.getByText('editor'); const editorBtn = screen.getByText('Editor');
await user.click(editorBtn); await user.click(editorBtn);
// Editor button should now be active (bg-zinc-900 class) // Editor button should now be active (bg-zinc-900 class)
@@ -2617,11 +2623,11 @@ describe('JourneyDetailPage', () => {
const multiPhotoEntry = { const multiPhotoEntry = {
...mockJourneyDetail.entries[0], ...mockJourneyDetail.entries[0],
photos: [ 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: 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, 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: 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, 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: 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, 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: 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, 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: 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({ setupDefaultHandlers({
@@ -2645,8 +2651,8 @@ describe('JourneyDetailPage', () => {
const twoPhotoEntry = { const twoPhotoEntry = {
...mockJourneyDetail.entries[0], ...mockJourneyDetail.entries[0],
photos: [ 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: 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, 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: 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({ setupDefaultHandlers({
@@ -2662,8 +2668,8 @@ describe('JourneyDetailPage', () => {
// Both photos render in the grid // Both photos render in the grid
const imgs = document.querySelectorAll('img'); const imgs = document.querySelectorAll('img');
const srcs = Array.from(imgs).map(img => img.getAttribute('src')); const srcs = Array.from(imgs).map(img => img.getAttribute('src'));
expect(srcs).toContain('/uploads/photos/a.jpg'); expect(srcs).toContain('/api/photos/100/thumbnail');
expect(srcs).toContain('/uploads/photos/b.jpg'); expect(srcs).toContain('/api/photos/101/thumbnail');
}); });
}); });
@@ -2673,6 +2679,9 @@ describe('JourneyDetailPage', () => {
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
await openGalleryWithProvider(user); await openGalleryWithProvider(user);
// Flush pending timers/microtasks so the search fetch resolves
await vi.runAllTimersAsync();
// Wait for photos to load // Wait for photos to load
await waitFor(() => { await waitFor(() => {
const imgs = document.querySelectorAll('img[src*="/api/integrations/memories/"]'); const imgs = document.querySelectorAll('img[src*="/api/integrations/memories/"]');
@@ -2725,13 +2734,12 @@ describe('JourneyDetailPage', () => {
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
await openGalleryWithProvider(user); await openGalleryWithProvider(user);
// The picker modal has 3 filter tabs: Link, Edit, Gallery // The picker modal has 4 filter tabs: Trip Period, Date Range, All Photos, Albums
// Find the "Gallery" tab button inside the picker modal (not the main view)
const pickerModal = screen.getByText('Add to').closest('[class*="fixed"]')!; const pickerModal = screen.getByText('Add to').closest('[class*="fixed"]')!;
const filterButtons = pickerModal.querySelectorAll('[class*="px-3"][class*="py-1\\.5"][class*="rounded-lg"]'); 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 // Find the Albums tab button
const albumTab = Array.from(filterButtons).find(btn => btn.textContent === 'Gallery'); const albumTab = Array.from(filterButtons).find(btn => btn.textContent === 'Albums');
expect(albumTab).toBeTruthy(); expect(albumTab).toBeTruthy();
await user.click(albumTab as HTMLElement); await user.click(albumTab as HTMLElement);
@@ -2845,7 +2853,7 @@ describe('JourneyDetailPage', () => {
const editorModal = screen.getByText('Edit Entry').closest('[class*="fixed"]')!; const editorModal = screen.getByText('Edit Entry').closest('[class*="fixed"]')!;
const editorImgs = editorModal.querySelectorAll('img'); const editorImgs = editorModal.querySelectorAll('img');
const editorSrcs = Array.from(editorImgs).map(img => img.getAttribute('src')); 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', () => { http.post('/api/journeys/entries/88/photos', () => {
uploadCalled = true; 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(); 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 pickerModal = screen.getByText('Add to').closest('[class*="fixed"]')!;
const editTab = Array.from(pickerModal.querySelectorAll('button')).find( const editTab = Array.from(pickerModal.querySelectorAll('button')).find(
b => b.textContent === 'Edit', b => b.textContent === 'Date Range',
); );
expect(editTab).toBeTruthy(); expect(editTab).toBeTruthy();
await user.click(editTab as HTMLElement); await user.click(editTab as HTMLElement);
@@ -3510,8 +3518,8 @@ describe('JourneyDetailPage', () => {
const entryWithMultiPhotos = { const entryWithMultiPhotos = {
...mockJourneyDetail.entries[0], ...mockJourneyDetail.entries[0],
photos: [ 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: 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, 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: 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({ setupDefaultHandlers({
@@ -3564,7 +3572,7 @@ describe('JourneyDetailPage', () => {
}), }),
http.post('/api/journeys/entries/11/photos', () => { http.post('/api/journeys/entries/11/photos', () => {
uploadCalled = true; 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, Clock, Package, Image, ChevronRight,
UserPlus, Plus, Minus, Calendar, Camera, BookOpen, X, Check, ImagePlus, Trash2, Pencil, UserPlus, Plus, Minus, Calendar, Camera, BookOpen, X, Check, ImagePlus, Trash2, Pencil,
Laugh, Smile, Meh, Annoyed, Frown, Laugh, Smile, Meh, Annoyed, Frown,
Sun, CloudSun, Cloud, CloudRain, CloudLightning, Snowflake, ChevronDown, Sun, CloudSun, Cloud, CloudRain, CloudLightning, Snowflake, ChevronDown, Eye, EyeOff,
} from 'lucide-react' } from 'lucide-react'
import type { JourneyEntry, JourneyPhoto, JourneyDetail } from '../store/journeyStore' 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 } { function formatDate(d: string): { weekday: string; month: string; day: number } {
const date = new Date(d + 'T00:00:00') const date = new Date(d + 'T00:00:00')
return { return {
weekday: date.toLocaleDateString('en', { weekday: 'long' }), weekday: date.toLocaleDateString(undefined, { weekday: 'long' }),
month: date.toLocaleDateString('en', { month: 'long' }), month: date.toLocaleDateString(undefined, { month: 'long' }),
day: date.getDate(), day: date.getDate(),
} }
} }
function photoUrl(p: JourneyPhoto, size: 'thumbnail' | 'original' = 'thumbnail'): string { function photoUrl(p: JourneyPhoto, size: 'thumbnail' | 'original' = 'thumbnail'): string {
if (p.provider === 'local') { return `/api/photos/${p.photo_id}/${size}`
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}`
} }
export default function JourneyDetailPage() { export default function JourneyDetailPage() {
@@ -85,7 +79,7 @@ export default function JourneyDetailPage() {
const navigate = useNavigate() const navigate = useNavigate()
const toast = useToast() const toast = useToast()
const { t } = useTranslation() 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 mapRef = useRef<JourneyMapHandle>(null)
const fullMapRef = useRef<JourneyMapHandle>(null) const fullMapRef = useRef<JourneyMapHandle>(null)
const [activeLocationId, setActiveLocationId] = useState<string | null>(null) const [activeLocationId, setActiveLocationId] = useState<string | null>(null)
@@ -98,11 +92,23 @@ export default function JourneyDetailPage() {
const [showAddTrip, setShowAddTrip] = useState(false) const [showAddTrip, setShowAddTrip] = useState(false)
const [unlinkTrip, setUnlinkTrip] = useState<{ trip_id: number; title: string } | null>(null) const [unlinkTrip, setUnlinkTrip] = useState<{ trip_id: number; title: string } | null>(null)
const [showSettings, setShowSettings] = useState(false) const [showSettings, setShowSettings] = useState(false)
const [hideSkeletons, setHideSkeletons] = useState(false)
useEffect(() => { useEffect(() => {
if (id) loadJourney(Number(id)) if (id) loadJourney(Number(id)).catch(() => {})
}, [id]) }, [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 // WebSocket real-time updates
useEffect(() => { useEffect(() => {
if (!id) return if (!id) return
@@ -157,6 +163,16 @@ export default function JourneyDetailPage() {
[current?.entries] [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 tripDates = useMemo(() => {
const dates = new Set<string>() const dates = new Set<string>()
if (!current?.trips) return dates 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 dayGroups = groupByDate(timelineEntries)
const sortedDates = [...dayGroups.keys()].sort() const sortedDates = [...dayGroups.keys()].sort()
@@ -195,7 +211,7 @@ export default function JourneyDetailPage() {
{/* Back link — desktop */} {/* 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"> <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} /> <ArrowLeft size={14} />
Back to Journey {t('journey.detail.backToJourney')}
</button> </button>
{/* Hero card — full width */} {/* 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"> <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} /> <RefreshCw size={11} />
Synced with Trips {t('journey.detail.syncedWithTrips')}
</div> </div>
</div> </div>
{/* Mobile: back button on the left */} {/* Mobile: back button on the left */}
@@ -232,7 +248,21 @@ export default function JourneyDetailPage() {
</button> </button>
<div className="flex items-center gap-1.5"> <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={() => { 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> <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>
</div> </div>
@@ -326,7 +356,7 @@ export default function JourneyDetailPage() {
{dayIdx + 1} {dayIdx + 1}
</div> </div>
<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> </div>
<div className="flex items-center gap-3 text-[11px] text-zinc-500"> <div className="flex items-center gap-3 text-[11px] text-zinc-500">
@@ -386,17 +416,9 @@ export default function JourneyDetailPage() {
<JourneyMap <JourneyMap
ref={mapRef} ref={mapRef}
checkins={[]} checkins={[]}
entries={mapEntries.map(e => ({ entries={sidebarMapItems as any}
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}
height={240} 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"> <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> <span>{mapEntries.length} {t('journey.stats.places')}</span>
@@ -405,17 +427,17 @@ export default function JourneyDetailPage() {
{/* Stats panel */} {/* Stats panel */}
<div className="bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-700 rounded-xl p-4"> <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="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-3"> <div className="grid grid-cols-2 gap-2">
{[ {[
{ value: `${sortedDates.length}`, label: t('journey.stats.days') }, { value: sortedDates.length, label: t('journey.stats.days') },
{ value: `${current.stats.entries}`, label: t('journey.stats.entries') }, { value: current.stats.entries, label: t('journey.stats.entries') },
{ value: `${current.stats.photos}`, label: t('journey.stats.photos') }, { value: current.stats.photos, label: t('journey.stats.photos') },
{ value: `${current.stats.cities}`, label: t('journey.stats.cities') }, { value: current.stats.cities, label: t('journey.stats.cities') },
].map(s => ( ].map(s => (
<div key={s.label}> <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-[20px] font-bold tracking-[-0.02em] text-zinc-900 dark:text-white">{s.value}</div> <div className="text-[18px] font-bold tracking-[-0.02em] text-zinc-900 dark:text-white leading-none mb-0.5">{s.value}</div>
<div className="text-[10px] uppercase tracking-[0.08em] text-zinc-500 font-medium">{s.label}</div> <div className="text-[9px] uppercase tracking-[0.1em] text-zinc-400 dark:text-zinc-500 font-semibold">{s.label}</div>
</div> </div>
))} ))}
</div> </div>
@@ -440,7 +462,7 @@ export default function JourneyDetailPage() {
<div className="flex-1 min-w-0"> <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-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"> <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> <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>
</div> </div>
@@ -674,7 +696,7 @@ function MapView({ entries, mapEntries, sortedDates, activeLocationId, fullMapRe
<div key={date}> <div key={date}>
{/* Day separator */} {/* Day separator */}
<div className="flex items-center gap-2.5 py-3"> <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> <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 className="flex-1 h-px bg-zinc-200 dark:bg-zinc-700" />
</div> </div>
@@ -750,6 +772,7 @@ function GalleryView({ entries, journeyId, userId, trips, onPhotoClick, onRefres
const [showPicker, setShowPicker] = useState(false) const [showPicker, setShowPicker] = useState(false)
const [pickerProvider, setPickerProvider] = useState<string | null>(null) const [pickerProvider, setPickerProvider] = useState<string | null>(null)
const [availableProviders, setAvailableProviders] = useState<{ id: string; name: string }[]>([]) const [availableProviders, setAvailableProviders] = useState<{ id: string; name: string }[]>([])
const [galleryUploading, setGalleryUploading] = useState(false)
const toast = useToast() const toast = useToast()
// check which providers are enabled AND connected for the current user // 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 handleGalleryUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files const files = e.target.files
if (!files?.length) return if (!files?.length) return
// find existing "Gallery" entry or create one setGalleryUploading(true)
let galleryEntry = entries.find(e => e.title === 'Gallery' && e.type === 'entry') try {
let entryId = galleryEntry?.id // find existing "Gallery" entry or create one
if (!entryId) { let galleryEntry = entries.find(e => e.title === 'Gallery' && e.type === 'entry')
try { let entryId = galleryEntry?.id
if (!entryId) {
const entry = await journeyApi.createEntry(journeyId, { const entry = await journeyApi.createEntry(journeyId, {
title: t('journey.share.gallery'), title: t('journey.share.gallery'),
entry_date: new Date().toISOString().split('T')[0], entry_date: new Date().toISOString().split('T')[0],
type: 'entry', type: 'entry',
}) })
entryId = entry.id entryId = entry.id
} catch { return } }
} const formData = new FormData()
const formData = new FormData() for (const f of files) formData.append('photos', f)
for (const f of files) formData.append('photos', f)
try {
await journeyApi.uploadPhotos(entryId, formData) await journeyApi.uploadPhotos(entryId, formData)
toast.success(t('journey.photosUploaded', { count: files.length })) toast.success(t('journey.photosUploaded', { count: files.length }))
onRefresh() onRefresh()
} catch { } catch {
toast.error(t('journey.settings.coverFailed')) toast.error(t('journey.settings.coverFailed'))
} finally {
setGalleryUploading(false)
} }
e.target.value = '' e.target.value = ''
} }
const handleDeletePhoto = async (photoId: number) => { 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 { try {
await journeyApi.deletePhoto(photoId) await journeyApi.deletePhoto(photoId)
onRefresh()
} catch { } catch {
toast.error(t('common.error')) toast.error(t('common.error'))
onRefresh() // Revert on error
} }
} }
@@ -834,14 +870,20 @@ function GalleryView({ entries, journeyId, userId, trips, onPhotoClick, onRefres
{/* Header */} {/* Header */}
<div className="flex items-center justify-between mb-4 flex-wrap gap-2"> <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"> <div className="flex items-center gap-2">
<button <button
onClick={() => galleryFileRef.current?.click()} 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} /> {galleryUploading ? (
Upload <><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> </button>
{availableProviders.map(p => ( {availableProviders.map(p => (
<button <button
@@ -873,7 +915,7 @@ function GalleryView({ entries, journeyId, userId, trips, onPhotoClick, onRefres
onClick={() => onPhotoClick(entry.photos, entry.photos.indexOf(photo))} onClick={() => onPhotoClick(entry.photos, entry.photos.indexOf(photo))}
> >
<img <img
src={photo.provider !== 'local' ? photoUrl(photo, 'original') : photoUrl(photo)} src={photoUrl(photo, 'thumbnail')}
alt={photo.caption || ''} alt={photo.caption || ''}
className="w-full h-full object-cover transition-transform group-hover:scale-105" className="w-full h-full object-cover transition-transform group-hover:scale-105"
loading="lazy" loading="lazy"
@@ -886,11 +928,11 @@ function GalleryView({ entries, journeyId, userId, trips, onPhotoClick, onRefres
> >
<X size={12} /> <X size={12} />
</button> </button>
{photo.provider !== 'local' && ( {photo.provider && photo.provider !== 'local' && (
<div className="absolute top-1.5 left-1.5"> <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"> <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} /> <RefreshCw size={7} />
{photo.provider === 'immich' ? 'Immich' : 'Synology'} {photo.provider === 'immich' ? 'Immich' : photo.provider === 'synology' ? 'Synology' : photo.provider}
</span> </span>
</div> </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"> <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"> <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> </span>
</div> </div>
</div> </div>
@@ -931,12 +973,10 @@ function GalleryView({ entries, journeyId, userId, trips, onPhotoClick, onRefres
} catch { return } } catch { return }
} }
let added = 0 let added = 0
for (const assetId of assetIds) { try {
try { const result = await journeyApi.addProviderPhotos(targetId, pickerProvider!, assetIds)
await journeyApi.addProviderPhoto(targetId, pickerProvider!, assetId) added = result.added || 0
added++ } catch {}
} catch {}
}
if (added > 0) { if (added > 0) {
toast.success(t('journey.photosAdded', { count: added })) toast.success(t('journey.photosAdded', { count: added }))
onRefresh() onRefresh()
@@ -952,6 +992,7 @@ function GalleryView({ entries, journeyId, userId, trips, onPhotoClick, onRefres
// ── Expandable Story ───────────────────────────────────────────────────── // ── Expandable Story ─────────────────────────────────────────────────────
function ExpandableStory({ story }: { story: string }) { function ExpandableStory({ story }: { story: string }) {
const { t } = useTranslation()
const [expanded, setExpanded] = useState(false) const [expanded, setExpanded] = useState(false)
const [clamped, setClamped] = useState(false) const [clamped, setClamped] = useState(false)
const ref = useRef<HTMLDivElement>(null) const ref = useRef<HTMLDivElement>(null)
@@ -977,16 +1018,24 @@ function ExpandableStory({ story }: { story: string }) {
onClick={() => { if (clamped || expanded) setExpanded(e => !e) }} onClick={() => { if (clamped || expanded) setExpanded(e => !e) }}
className={`text-[13px] text-zinc-700 dark:text-zinc-300 leading-relaxed ${ className={`text-[13px] text-zinc-700 dark:text-zinc-300 leading-relaxed ${
expanded ? '' : 'line-clamp-3 md:line-clamp-[9]' expanded ? '' : 'line-clamp-3 md:line-clamp-[9]'
} ${clamped || expanded ? 'cursor-pointer md:cursor-auto' : ''}`} } ${clamped || expanded ? 'cursor-pointer' : ''}`}
> >
<JournalBody text={story} /> <JournalBody text={story} />
</div> </div>
{clamped && !expanded && ( {clamped && !expanded && (
<button <button
onClick={() => setExpanded(true)} 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> </button>
)} )}
</div> </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" /> <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"> <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 <ChevronDown
size={12} size={12}
className={`md:hidden text-zinc-400 transition-transform duration-300 ${open ? 'rotate-180' : ''}`} 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 && ( {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="flex items-center gap-2 mb-3">
<div className="w-6 h-6 rounded-lg bg-green-500 flex items-center justify-center"> <div className="w-6 h-6 rounded-lg bg-green-500 flex items-center justify-center">
<Check size={14} className="text-white" strokeWidth={3} /> <Check size={14} className="text-white" strokeWidth={3} />
</div> </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> <span className="ml-auto text-[11px] font-semibold text-green-600">{pros.length}</span>
</div> </div>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
@@ -1065,12 +1114,12 @@ function VerdictSection({ pros, cons }: { pros: string[]; cons: string[] }) {
</div> </div>
)} )}
{cons.length > 0 && ( {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="flex items-center gap-2 mb-3">
<div className="w-6 h-6 rounded-lg bg-red-500 flex items-center justify-center"> <div className="w-6 h-6 rounded-lg bg-red-500 flex items-center justify-center">
<Minus size={14} className="text-white" strokeWidth={3} /> <Minus size={14} className="text-white" strokeWidth={3} />
</div> </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> <span className="ml-auto text-[11px] font-semibold text-red-600">{cons.length}</span>
</div> </div>
<div className="flex flex-col gap-2"> <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 }) { 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 ( return (
<img <img
src={src} 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 ─────────────────────────────────────────────────────── // ── Provider Picker ───────────────────────────────────────────────────────
function ProviderPicker({ provider, userId, entries, trips, existingAssetIds, onClose, onAdd }: { 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> onAdd: (assetIds: string[], entryId: number | null) => Promise<void>
}) { }) {
const { t } = useTranslation() 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 [photos, setPhotos] = useState<any[]>([])
const [albums, setAlbums] = useState<any[]>([]) const [albums, setAlbums] = useState<any[]>([])
const [selectedAlbum, setSelectedAlbum] = useState<string | null>(null) const [selectedAlbum, setSelectedAlbum] = useState<string | null>(null)
const [loading, setLoading] = useState(false) 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 [selected, setSelected] = useState<Set<string>>(new Set())
const [customFrom, setCustomFrom] = useState('') const [customFrom, setCustomFrom] = useState('')
const [customTo, setCustomTo] = useState('') const [customTo, setCustomTo] = useState('')
const [targetEntryId, setTargetEntryId] = useState<number | null>(null) const [targetEntryId, setTargetEntryId] = useState<number | null>(null)
const [addToOpen, setAddToOpen] = useState(false) const [addToOpen, setAddToOpen] = useState(false)
const abortRef = useRef<AbortController | null>(null)
const gridRef = useRef<HTMLDivElement>(null)
// compute trip range // compute trip range
const tripRange = useMemo(() => { const tripRange = useMemo(() => {
@@ -1389,17 +1463,53 @@ function ProviderPicker({ provider, userId, entries, trips, existingAssetIds, on
return { from, to } return { from, to }
}, [trips]) }, [trips])
const searchPhotos = async (from: string, to: string) => { const cancelPending = () => {
setLoading(true) 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 { try {
const res = await fetch(`/api/integrations/memories/${provider}/search`, { const res = await fetch(`/api/integrations/memories/${provider}/search`, {
method: 'POST', credentials: 'include', method: 'POST', credentials: 'include', signal,
headers: { 'Content-Type': 'application/json' }, 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 || []) if (res.ok) setPhotos((await res.json()).assets || [])
} catch {} } catch (e: any) { if (e.name !== 'AbortError') {} }
setLoading(false) if (!signal.aborted) setLoading(false)
} }
const loadAlbums = async () => { const loadAlbums = async () => {
@@ -1413,6 +1523,8 @@ function ProviderPicker({ provider, userId, entries, trips, existingAssetIds, on
useEffect(() => { useEffect(() => {
if (filter === 'trip' && tripRange.from && tripRange.to) { if (filter === 'trip' && tripRange.from && tripRange.to) {
searchPhotos(tripRange.from, tripRange.to) searchPhotos(tripRange.from, tripRange.to)
} else if (filter === 'all') {
searchPhotos('', '')
} else if (filter === 'album' && albums.length === 0) { } else if (filter === 'album' && albums.length === 0) {
loadAlbums() loadAlbums()
} }
@@ -1432,7 +1544,7 @@ function ProviderPicker({ provider, userId, entries, trips, existingAssetIds, on
const targetLabel = targetEntryId const targetLabel = targetEntryId
? entries.find(e => e.id === targetEntryId)?.title || entries.find(e => e.id === targetEntryId)?.entry_date || t('journey.stats.entries') ? 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 ( 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="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 */} {/* Tabs */}
<div className="flex gap-1.5 mb-3"> <div className="flex gap-1.5 mb-3">
{[ {[
{ id: 'trip' as const, label: t('journey.trips.link') }, { id: 'trip' as const, label: t('journey.picker.tripPeriod') },
{ id: 'custom' as const, label: t('common.edit') }, { id: 'custom' as const, label: t('journey.picker.dateRange') },
{ id: 'album' as const, label: t('journey.share.gallery') }, { id: 'all' as const, label: t('journey.picker.allPhotos') },
{ id: 'album' as const, label: t('journey.picker.albums') },
].map(f => ( ].map(f => (
<button <button
key={f.id} key={f.id}
@@ -1479,11 +1592,11 @@ function ProviderPicker({ provider, userId, entries, trips, existingAssetIds, on
<> <>
<Calendar size={13} className="text-zinc-400" /> <Calendar size={13} className="text-zinc-400" />
<span className="font-medium text-zinc-900 dark:text-white"> <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>
<span className="text-zinc-400">&mdash;</span> <span className="text-zinc-400">&mdash;</span>
<span className="font-medium text-zinc-900 dark:text-white"> <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>
<span className="ml-1 text-zinc-400"> <span className="ml-1 text-zinc-400">
({Math.ceil((new Date(tripRange.to).getTime() - new Date(tripRange.from).getTime()) / 86400000) + 1} days) ({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> <div className="flex-1"><DatePicker value={customTo} onChange={setCustomTo} /></div>
<button onClick={handleCustomSearch} <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"> 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> </button>
</div> </div>
)} )}
@@ -1512,7 +1625,7 @@ function ProviderPicker({ provider, userId, entries, trips, existingAssetIds, on
{albums.map((a: any) => ( {albums.map((a: any) => (
<button <button
key={a.id} 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 ${ className={`px-2.5 py-1 rounded-lg text-[11px] font-medium whitespace-nowrap flex-shrink-0 border ${
selectedAlbum === a.id selectedAlbum === a.id
? 'bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 border-zinc-900 dark:border-white' ? '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})` : ''} {a.albumName || a.name || 'Album'}{a.assetCount != null ? ` (${a.assetCount})` : ''}
</button> </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> </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="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"> <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 <button
onClick={() => setAddToOpen(!addToOpen)} 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" 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} /> <Camera size={12} />
Gallery {t('journey.picker.newGallery')}
</button> </button>
<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]').length > 0 && (
{entries.map(e => ( <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 <button
key={e.id} key={e.id}
onClick={() => { setTargetEntryId(e.id); setAddToOpen(false) }} 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' : '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> </button>
))} ))}
</div> </div>
@@ -1574,6 +1689,36 @@ function ProviderPicker({ provider, userId, entries, trips, existingAssetIds, on
</div> </div>
</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 */} {/* Photo grid */}
<div className="flex-1 overflow-y-auto p-4"> <div className="flex-1 overflow-y-auto p-4">
{loading ? ( {loading ? (
@@ -1632,25 +1777,28 @@ function ProviderPicker({ provider, userId, entries, trips, existingAssetIds, on
</div> </div>
) )
})} })}
{/* Infinite scroll trigger */}
{hasMore && !selectedAlbum && <ScrollTrigger onVisible={loadMorePhotos} loading={loadingMore} />}
</div> </div>
)} )}
</div> </div>
{/* Footer */} {/* 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"> <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"> <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">
<strong className="text-zinc-900 dark:text-white">{selected.size}</strong> selected <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> </span>
<div className="flex items-center gap-2"> <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"> <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>
<button <button
onClick={() => onAdd([...selected], targetEntryId)} onClick={() => onAdd([...selected], targetEntryId)}
disabled={selected.size === 0} disabled={selected.size === 0}
className="px-3.5 py-2 rounded-lg bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 text-[13px] font-medium hover:bg-zinc-800 dark:hover:bg-zinc-100 disabled:opacity-40 disabled:cursor-not-allowed" className="px-3.5 py-2 rounded-lg bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 text-[13px] font-medium hover:bg-zinc-800 dark:hover:bg-zinc-100 disabled:opacity-40 disabled:cursor-not-allowed"
> >
Add {selected.size > 0 ? `(${selected.size})` : ''} {t('common.add')} {selected.size > 0 ? `(${selected.size})` : ''}
</button> </button>
</div> </div>
</div> </div>
@@ -1666,6 +1814,7 @@ function DatePicker({ value, onChange, tripDates }: {
onChange: (date: string) => void onChange: (date: string) => void
tripDates?: Set<string> tripDates?: Set<string>
}) { }) {
const { t } = useTranslation()
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const [viewMonth, setViewMonth] = useState(() => { const [viewMonth, setViewMonth] = useState(() => {
const d = value ? new Date(value + 'T00:00:00') : new Date() 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 daysInMonth = new Date(viewMonth.year, viewMonth.month + 1, 0).getDate()
const firstDow = new Date(viewMonth.year, viewMonth.month, 1).getDay() 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 = () => { const prevMonth = () => {
setViewMonth(p => p.month === 0 ? { year: p.year - 1, month: 11 } : { ...p, month: p.month - 1 }) 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 i = 0; i < firstDow; i++) cells.push(null)
for (let d = 1; d <= daysInMonth; d++) cells.push(d) 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 ( return (
<div className="relative"> <div className="relative">
@@ -1719,8 +1868,8 @@ function DatePicker({ value, onChange, tripDates }: {
{/* Weekday headers */} {/* Weekday headers */}
<div className="grid grid-cols-7 mb-1"> <div className="grid grid-cols-7 mb-1">
{['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa'].map(d => ( {['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa'].map((d, i) => (
<div key={d} className="text-center text-[10px] font-medium text-zinc-400 py-1">{d}</div> <div key={i} className="text-center text-[10px] font-medium text-zinc-400 py-1">{d}</div>
))} ))}
</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 [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 [cons, setCons] = useState<string[]>(entry.pros_cons?.cons?.length ? entry.pros_cons.cons : [''])
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
const [uploading, setUploading] = useState(false)
const [photos, setPhotos] = useState<JourneyPhoto[]>(entry.photos || []) const [photos, setPhotos] = useState<JourneyPhoto[]>(entry.photos || [])
const [pendingFiles, setPendingFiles] = useState<File[]>([]) const [pendingFiles, setPendingFiles] = useState<File[]>([])
const [pendingLinkIds, setPendingLinkIds] = useState<number[]>([]) const [pendingLinkIds, setPendingLinkIds] = useState<number[]>([])
@@ -1837,10 +1987,15 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa
// queue files for upload after save // queue files for upload after save
setPendingFiles(prev => [...prev, ...Array.from(files)]) setPendingFiles(prev => [...prev, ...Array.from(files)])
} else { } else {
const formData = new FormData() setUploading(true)
for (const f of files) formData.append('photos', f) try {
const newPhotos = await onUploadPhotos(entry.id, formData) const formData = new FormData()
if (newPhotos?.length) setPhotos(prev => [...prev, ...newPhotos]) 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"> <div className="flex gap-2">
<button <button
onClick={() => fileRef.current?.click()} 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> </button>
{galleryPhotos.length > 0 && ( {galleryPhotos.length > 0 && (
<button <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' : '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> </button>
)} )}
</div> </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" 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> </div>
))} ))}
{galleryPhotos.filter(gp => !photos.some(p => p.id === gp.id)).length === 0 && ( {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"> <div className="flex flex-wrap gap-2">
{photos.map((p, idx) => ( {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' : ''}`}> <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 && ( {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> <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" 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>
)} )}
<button <button
@@ -2017,7 +2177,7 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa
onClick={() => setPros([...pros, ''])} 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" 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> </button>
</div> </div>
</div> </div>
@@ -2051,7 +2211,7 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa
onClick={() => setCons([...cons, ''])} 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" 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> </button>
</div> </div>
</div> </div>
@@ -2129,7 +2289,7 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa
)} )}
{locationSearching && ( {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"> <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>
)} )}
</div> </div>
@@ -2203,11 +2363,11 @@ function AddTripDialog({ journeyId, existingTripIds, onClose, onAdded }: {
journeyApi.availableTrips().then(d => setTrips(d.trips || [])).catch(() => {}) journeyApi.availableTrips().then(d => setTrips(d.trips || [])).catch(() => {})
}, []) }, [])
const filtered = trips.filter(t => { const filtered = trips.filter(trip => {
if (existingTripIds.includes(t.id)) return false if (existingTripIds.includes(trip.id)) return false
if (!search) return true if (!search) return true
const q = search.toLowerCase() 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) => { const handleAdd = async (tripId: number) => {
@@ -2249,26 +2409,26 @@ function AddTripDialog({ journeyId, existingTripIds, onClose, onAdded }: {
{filtered.length === 0 && ( {filtered.length === 0 && (
<p className="text-[12px] text-zinc-400 text-center py-4">{t('journey.trips.noTripsAvailable')}</p> <p className="text-[12px] text-zinc-400 text-center py-4">{t('journey.trips.noTripsAvailable')}</p>
)} )}
{filtered.map(t => ( {filtered.map(trip => (
<div <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" 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="flex-1 min-w-0">
<div className="text-[13px] font-medium text-zinc-900 dark:text-white truncate">{t.title}</div> <div className="text-[13px] font-medium text-zinc-900 dark:text-white truncate">{trip.title}</div>
{(t.destination || t.start_date) && ( {(trip.destination || trip.start_date) && (
<div className="text-[11px] text-zinc-500 truncate"> <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>
)} )}
</div> </div>
<button <button
onClick={() => handleAdd(t.id)} onClick={() => handleAdd(trip.id)}
disabled={adding === t.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" 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> </button>
</div> </div>
))} ))}
@@ -2376,19 +2536,19 @@ function ContributorInviteDialog({ journeyId, existingUserIds, onClose, onInvite
{/* Role selector */} {/* Role selector */}
<div> <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"> <div className="flex gap-2">
{(['viewer', 'editor'] as const).map(r => ( {(['viewer', 'editor'] as const).map(r => (
<button <button
key={r} key={r}
onClick={() => setRole(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 role === r
? 'bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 border-zinc-900 dark:border-white' ? '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' : 'border-zinc-200 dark:border-zinc-700 text-zinc-500 hover:border-zinc-400'
}`} }`}
> >
{r} {t(`journey.invite.${r}`)}
</button> </button>
))} ))}
</div> </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"> <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"> <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>
<button <button
onClick={handleInvite} onClick={handleInvite}
disabled={!selectedUserId || sending} 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" 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> </button>
</div> </div>
</div> </div>
@@ -2471,7 +2631,7 @@ function JourneyShareSection({ journeyId }: { journeyId: number }) {
onClick={createLink} 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" 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> </button>
) : ( ) : (
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
@@ -2483,7 +2643,7 @@ function JourneyShareSection({ journeyId }: { journeyId: number }) {
onClick={copyLink} 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" 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> </button>
</div> </div>
@@ -2578,8 +2738,8 @@ function JourneySettingsDialog({ journey, onClose, onSaved, onOpenInvite }: {
} }
return ( 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="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-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="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"> <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> <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> </button>
</div> </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 */} {/* Cover Image */}
<div> <div>
<label className="text-[10px] font-semibold tracking-[0.12em] uppercase text-zinc-500 block mb-2">{t('journey.settings.coverImage')}</label> <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> </div>
{/* Footer */} {/* 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 <button
onClick={() => setShowDeleteConfirm(true)} onClick={() => setShowDeleteConfirm(true)}
className="flex items-center gap-1.5 text-[12px] font-medium text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-lg px-2.5 py-2 mr-auto" className="flex items-center gap-1.5 text-[12px] font-medium text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-lg px-2.5 py-2 mr-auto"
+4 -4
View File
@@ -97,7 +97,7 @@ const mockJourneyData = {
weather: 'cloudy', weather: 'cloudy',
pros_cons: null, pros_cons: null,
photos: [ 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, entry_time: null, location_name: null, location_lat: null, location_lng: null,
mood: null, weather: null, pros_cons: null, mood: null, weather: null, pros_cons: null,
photos: [ photos: [
{ id: 200, entry_id: 20, provider: 'local', asset_id: null, owner_id: null, file_path: 'journey/a.jpg', caption: 'Photo A' }, { 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, provider: 'local', asset_id: null, owner_id: null, file_path: 'journey/b.jpg', caption: 'Photo B' }, { 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, provider: 'local', asset_id: null, owner_id: null, file_path: 'journey/c.jpg', caption: 'Photo C' }, { 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 { interface PublicPhoto {
id: number id: number
entry_id: number entry_id: number
provider: string photo_id: number
provider?: string
asset_id?: string | null asset_id?: string | null
owner_id?: number | null owner_id?: number | null
file_path?: string | null file_path?: string | null
@@ -34,8 +35,7 @@ interface PublicPhoto {
} }
function photoUrl(p: PublicPhoto, shareToken: string): string { 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}/photos/${p.photo_id}/original`
return `/api/public/journey/${shareToken}/photo/${p.provider}/${p.asset_id}/${p.owner_id}/original`
} }
function formatDate(d: string): { weekday: string; month: string; day: number } { 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) useTripWebSocket(tripId)
const [mapCategoryFilter, setMapCategoryFilter] = useState<string>('') const [mapCategoryFilter, setMapCategoryFilter] = useState<Set<string>>(new Set())
const [mapPlacesFilter, setMapPlacesFilter] = useState<string>('all') const [mapPlacesFilter, setMapPlacesFilter] = useState<string>('all')
const [expandedDayIds, setExpandedDayIds] = useState<Set<number> | null>(null) const [expandedDayIds, setExpandedDayIds] = useState<Set<number> | null>(null)
@@ -239,7 +239,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
return places.filter(p => { return places.filter(p => {
if (!p.lat || !p.lng) return false 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 (hiddenPlaceIds.has(p.id)) return false
if (plannedIds && plannedIds.has(p.id)) return false if (plannedIds && plannedIds.has(p.id)) return false
return true return true
+1
View File
@@ -148,6 +148,7 @@ describe('journeyStore', () => {
); );
await expect(useJourneyStore.getState().loadJourney(999)).rejects.toThrow(); await expect(useJourneyStore.getState().loadJourney(999)).rejects.toThrow();
expect(useJourneyStore.getState().loading).toBe(false); expect(useJourneyStore.getState().loading).toBe(false);
expect(useJourneyStore.getState().notFound).toBe(true);
}); });
// ── createJourney ──────────────────────────────────────────────────────── // ── createJourney ────────────────────────────────────────────────────────
+16 -6
View File
@@ -42,17 +42,19 @@ export interface JourneyEntry {
export interface JourneyPhoto { export interface JourneyPhoto {
id: number id: number
entry_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 asset_id?: string | null
owner_id?: number | null owner_id?: number | null
file_path?: string | null file_path?: string | null
thumbnail_path?: string | null thumbnail_path?: string | null
caption?: string | null
sort_order: number
width?: number | null width?: number | null
height?: number | null height?: number | null
shared: number
created_at: number
} }
export interface JourneyTrip { export interface JourneyTrip {
@@ -80,12 +82,14 @@ export interface JourneyDetail extends Journey {
trips: JourneyTrip[] trips: JourneyTrip[]
contributors: JourneyContributor[] contributors: JourneyContributor[]
stats: { entries: number; photos: number; cities: number } stats: { entries: number; photos: number; cities: number }
hide_skeletons?: boolean
} }
interface JourneyState { interface JourneyState {
journeys: Journey[] journeys: Journey[]
current: JourneyDetail | null current: JourneyDetail | null
loading: boolean loading: boolean
notFound: boolean
loadJourneys: () => Promise<void> loadJourneys: () => Promise<void>
loadJourney: (id: number) => Promise<void> loadJourney: (id: number) => Promise<void>
@@ -107,6 +111,7 @@ export const useJourneyStore = create<JourneyState>((set, get) => ({
journeys: [], journeys: [],
current: null, current: null,
loading: false, loading: false,
notFound: false,
loadJourneys: async () => { loadJourneys: async () => {
set({ loading: true }) set({ loading: true })
@@ -119,10 +124,15 @@ export const useJourneyStore = create<JourneyState>((set, get) => ({
}, },
loadJourney: async (id) => { loadJourney: async (id) => {
set({ loading: true }) set({ loading: true, notFound: false })
try { try {
const data = await journeyApi.get(id) const data = await journeyApi.get(id)
set({ current: data }) set({ current: data })
} catch (err: any) {
if (err?.response?.status === 404) {
set({ current: null, notFound: true })
}
throw err
} finally { } finally {
set({ loading: false }) set({ loading: false })
} }
+6
View File
@@ -43,18 +43,24 @@ export interface Place {
trip_id: number trip_id: number
name: string name: string
description: string | null description: string | null
notes: string | null
lat: number | null lat: number | null
lng: number | null lng: number | null
address: string | null address: string | null
category_id: number | null category_id: number | null
icon: string | null icon: string | null
price: string | null price: string | null
currency: string | null
image_url: string | null image_url: string | null
google_place_id: string | null google_place_id: string | null
osm_id: string | null osm_id: string | null
route_geometry: string | null route_geometry: string | null
place_time: string | null place_time: string | null
end_time: string | null end_time: string | null
duration_minutes: number | null
transport_mode: string | null
website: string | null
phone: string | null
created_at: string 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: { test: {
root: '.', root: '.',
globals: true, globals: true,
environment: 'jsdom', environment: './tests/environment/jsdom-native-abort.ts',
include: [ include: [
'tests/**/*.test.{ts,tsx}', 'tests/**/*.test.{ts,tsx}',
'src/**/*.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 vacayRoutes from './routes/vacay';
import atlasRoutes from './routes/atlas'; import atlasRoutes from './routes/atlas';
import memoriesRoutes from './routes/memories/unified'; import memoriesRoutes from './routes/memories/unified';
import photoRoutes from './routes/photos';
import notificationRoutes from './routes/notifications'; import notificationRoutes from './routes/notifications';
import shareRoutes from './routes/share'; import shareRoutes from './routes/share';
import journeyRoutes from './routes/journey'; import journeyRoutes from './routes/journey';
@@ -266,6 +267,7 @@ export function createApp(): express.Application {
app.use('/api/journeys', journeyRoutes); app.use('/api/journeys', journeyRoutes);
app.use('/api/public/journey', journeyPublicRoutes); app.use('/api/public/journey', journeyPublicRoutes);
app.use('/api/integrations/memories', memoriesRoutes); app.use('/api/integrations/memories', memoriesRoutes);
app.use('/api/photos', photoRoutes);
app.use('/api/maps', mapsRoutes); app.use('/api/maps', mapsRoutes);
app.use('/api/weather', weatherRoutes); app.use('/api/weather', weatherRoutes);
app.use('/api/settings', settingsRoutes); 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 {} 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) { 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) => { router.patch('/entries/:entryId', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest; 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' }); if (!result) return res.status(404).json({ error: 'Entry not found' });
res.json(result); res.json(result);
}); });
router.delete('/entries/:entryId', authenticate, (req: Request, res: Response) => { router.delete('/entries/:entryId', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest; 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' }); return res.status(404).json({ error: 'Entry not found' });
} }
res.json({ success: true }); 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) => { router.post('/entries/:entryId/provider-photos', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest; const authReq = req as AuthRequest;
const { provider, asset_id, 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' }); if (!provider || !asset_id) return res.status(400).json({ error: 'provider and asset_id required' });
const photo = svc.addProviderPhoto(Number(req.params.entryId), authReq.user.id, provider, asset_id, caption); const photo = svc.addProviderPhoto(Number(req.params.entryId), authReq.user.id, provider, asset_id, caption);
if (!photo) return res.status(403).json({ error: 'Not allowed or duplicate' }); 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 authReq = req as AuthRequest;
const { entry_date } = req.body || {}; const { entry_date } = req.body || {};
if (!entry_date) return res.status(400).json({ error: 'entry_date is required' }); 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' }); if (!entry) return res.status(404).json({ error: 'Journey not found' });
res.status(201).json(entry); res.status(201).json(entry);
}); });
@@ -267,6 +279,15 @@ router.delete('/:id/contributors/:userId', authenticate, (req: Request, res: Res
res.json({ success: true }); 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 ──────────────────────────────────────────────────────────── // ── Share Link ────────────────────────────────────────────────────────────
router.get('/:id/share-link', authenticate, (req: Request, res: Response) => { 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 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 { streamImmichAsset } from '../services/memories/immichService';
import path from 'node:path'; import path from 'node:path';
import fs from 'node:fs'; import fs from 'node:fs';
@@ -12,16 +13,23 @@ router.get('/:token', (req: Request, res: Response) => {
res.json(data); 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) => { router.get('/:token/photo/:provider/:assetId/:ownerId/:kind', async (req: Request, res: Response) => {
const { token, provider, assetId, ownerId, kind } = req.params; const { token, provider, assetId, ownerId, kind } = req.params;
// Validate token and that this asset belongs to the shared journey
const valid = validateShareTokenForAsset(token, assetId); const valid = validateShareTokenForAsset(token, assetId);
if (!valid) return res.status(404).json({ error: 'Not found' }); if (!valid) return res.status(404).json({ error: 'Not found' });
if (provider === 'local') { if (provider === 'local') {
// Local file — assetId is the file_path
const filePath = path.join(__dirname, '../../uploads/journey', assetId); const filePath = path.join(__dirname, '../../uploads/journey', assetId);
const resolved = path.resolve(filePath); const resolved = path.resolve(filePath);
const uploadsDir = path.resolve(__dirname, '../../uploads'); 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); return res.sendFile(resolved);
} }
// Immich/Synology — proxy through
const effectiveOwnerId = valid.ownerId || Number(ownerId); const effectiveOwnerId = valid.ownerId || Number(ownerId);
if (provider === 'immich') { if (provider === 'immich') {
await streamImmichAsset(res, effectiveOwnerId, assetId, kind === 'thumbnail' ? 'thumbnail' : 'original', effectiveOwnerId); await streamImmichAsset(res, effectiveOwnerId, assetId, kind === 'thumbnail' ? 'thumbnail' : 'original', effectiveOwnerId);
} else { } else {
// Synology or other providers — try dynamic import
try { try {
const { streamSynologyAsset } = await import('../services/memories/synologyService'); const { streamSynologyAsset } = await import('../services/memories/synologyService');
await streamSynologyAsset(res, effectiveOwnerId, effectiveOwnerId, assetId, kind === 'thumbnail' ? 'thumbnail' : 'original'); await streamSynologyAsset(res, effectiveOwnerId, effectiveOwnerId, assetId, kind === 'thumbnail' ? 'thumbnail' : 'original');
+18 -4
View File
@@ -13,6 +13,7 @@ import {
searchPhotos, searchPhotos,
streamImmichAsset, streamImmichAsset,
listAlbums, listAlbums,
getAlbumPhotos,
syncAlbumAssets, syncAlbumAssets,
getAssetInfo, getAssetInfo,
isValidAssetId, isValidAssetId,
@@ -59,10 +60,16 @@ router.get('/browse', authenticate, async (req: Request, res: Response) => {
router.post('/search', authenticate, async (req: Request, res: Response) => { router.post('/search', authenticate, async (req: Request, res: Response) => {
const authReq = req as AuthRequest; const authReq = req as AuthRequest;
const { from, to } = req.body; const { from, to, size } = req.body;
const result = await searchPhotos(authReq.user.id, from, to); const pageSize = Math.min(Number(size) || 50, 200);
if (result.error) return res.status(result.status!).json({ error: result.error }); const allAssets: any[] = [];
res.json({ assets: result.assets }); 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 ────────────────────────────────────────────────────────── // ── Asset Details ──────────────────────────────────────────────────────────
@@ -113,6 +120,13 @@ router.get('/albums', authenticate, async (req: Request, res: Response) => {
res.json({ albums: result.albums }); 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) => { router.post('/trips/:tripId/album-links/:linkId/sync', authenticate, async (req: Request, res: Response) => {
const authReq = req as AuthRequest; const authReq = req as AuthRequest;
const { tripId, linkId } = req.params; const { tripId, linkId } = req.params;
+12 -2
View File
@@ -7,6 +7,7 @@ import {
getSynologyStatus, getSynologyStatus,
testSynologyConnection, testSynologyConnection,
listSynologyAlbums, listSynologyAlbums,
getSynologyAlbumPhotos,
syncSynologyAlbumLink, syncSynologyAlbumLink,
searchSynologyPhotos, searchSynologyPhotos,
getSynologyAssetInfo, getSynologyAssetInfo,
@@ -77,6 +78,11 @@ router.get('/albums', authenticate, async (req: Request, res: Response) => {
handleServiceResult(res, await listSynologyAlbums(authReq.user.id)); 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) => { router.post('/trips/:tripId/album-links/:linkId/sync', authenticate, async (req: Request, res: Response) => {
const authReq = req as AuthRequest; const authReq = req as AuthRequest;
const { tripId, linkId } = req.params; 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 body = req.body as Record<string, unknown>;
const from = _parseStringBodyField(body.from); const from = _parseStringBodyField(body.from);
const to = _parseStringBodyField(body.to); const to = _parseStringBodyField(body.to);
const offset = _parseNumberBodyField(body.offset, 0); let offset = _parseNumberBodyField(body.offset, 0);
const limit = _parseNumberBodyField(body.limit, 100); 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( handleServiceResult(res, await searchSynologyPhotos(
authReq.user.id, 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( const result = await setTripPhotoSharing(
tripId, tripId,
authReq.user.id, authReq.user.id,
req.body?.provider, Number(req.body?.photo_id),
req.body?.asset_id,
req.body?.shared, req.body?.shared,
); );
if ('error' in result) return res.status(result.error.status).json({ error: result.error.message }); 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) => { router.delete('/unified/trips/:tripId/photos', authenticate, async (req: Request, res: Response) => {
const authReq = req as AuthRequest; const authReq = req as AuthRequest;
const { tripId } = req.params; 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 }); if ('error' in result) return res.status(result.error.status).json({ error: result.error.message });
res.json({ success: true }); 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 ─────────────────────────────────────────────────────── // ── 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> { export async function reverseGeocodeCountry(lat: number, lng: number): Promise<string | null> {
const key = roundKey(lat, lng); const key = roundKey(lat, lng);
if (geocodeCache.has(key)) return geocodeCache.get(key)!; if (geocodeCache.has(key)) return geocodeCache.get(key)!;
await throttleNominatim();
try { try {
const res = await fetch(`https://nominatim.openstreetmap.org/reverse?lat=${lat}&lon=${lng}&format=json&zoom=3&accept-language=en`, { 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; if (!res.ok) return null;
const data = await res.json() as { address?: { country_code?: string } }; const data = await res.json() as { address?: { country_code?: string } };
@@ -215,15 +226,15 @@ export function getCountryFromAddress(address: string | null): string | null {
return 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> { async function resolveCountryCode(place: Place): Promise<string | null> {
let code = getCountryFromAddress(place.address); let code = getCountryFromAddress(place.address);
if (!code && place.lat && place.lng) { 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) { if (!code && place.lat && place.lng) {
code = getCountryFromCoords(place.lat, place.lng); code = await reverseGeocodeCountry(place.lat, place.lng);
} }
return code; 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 } 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>(); const regionCache = new Map<string, RegionInfo | null>();
async function reverseGeocodeRegion(lat: number, lng: number): Promise<RegionInfo | null> { async function reverseGeocodeRegion(lat: number, lng: number): Promise<RegionInfo | null> {
const key = roundKey(lat, lng); const key = roundKey(lat, lng);
if (regionCache.has(key)) return regionCache.get(key)!; if (regionCache.has(key)) return regionCache.get(key)!;
await throttleNominatim();
try { try {
const res = await fetch( const res = await fetch(
`https://nominatim.openstreetmap.org/reverse?lat=${lat}&lon=${lng}&format=json&zoom=8&accept-language=en`, `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; if (!res.ok) return null;
const data = await res.json() as { address?: Record<string, string> }; 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])); const cachedMap = new Map(cached.map(c => [c.place_id, c]));
// Resolve uncached places (rate-limited to avoid hammering Nominatim) // Kick off background geocoding for uncached places; return cached data immediately.
const uncached = places.filter(p => p.lat && p.lng && !cachedMap.has(p.id)); const uncached = places.filter(p => p.lat && p.lng && !cachedMap.has(p.id) && !geocodingInFlight.has(p.id));
const insertStmt = db.prepare('INSERT OR REPLACE INTO place_regions (place_id, country_code, region_code, region_name) VALUES (?, ?, ?, ?)'); 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 place of uncached) { for (const p of uncached) geocodingInFlight.add(p.id);
const info = await reverseGeocodeRegion(place.lat!, place.lng!); void (async () => {
if (info) { try {
insertStmt.run(place.id, info.country_code, info.region_code, info.region_name); for (const place of uncached) {
cachedMap.set(place.id, { place_id: place.id, ...info }); try {
} const info = await reverseGeocodeRegion(place.lat!, place.lng!);
// Nominatim rate limit: 1 req/sec if (info) insertStmt.run(place.id, info.country_code, info.region_code, info.region_name);
if (uncached.indexOf(place) < uncached.length - 1) { } catch {
await new Promise(r => setTimeout(r, 1100)); // 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 // Group by country → regions with place counts
+1 -1
View File
@@ -117,7 +117,7 @@ export function listBackups(): BackupInfo[] {
filename, filename,
size: stat.size, size: stat.size,
sizeText: formatSize(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()); .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 { db } from '../db/database';
import { broadcastToUser } from '../websocket'; import { broadcastToUser } from '../websocket';
import type { Journey, JourneyEntry, JourneyPhoto, JourneyContributor } from '../types'; import type { Journey, JourneyEntry, JourneyPhoto, JourneyContributor } from '../types';
import { getOrCreateTrekPhoto, getOrCreateLocalTrekPhoto, setTrekPhotoProvider } from './memories/photoResolverService';
function ts(): number { function ts(): number {
return Date.now(); 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( const contributors = db.prepare(
'SELECT user_id FROM journey_contributors WHERE journey_id = ?' 'SELECT user_id FROM journey_contributors WHERE journey_id = ?'
).all(journeyId) as { user_id: number }[]; ).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); if (owner) userIds.add(owner.user_id);
for (const uid of userIds) { for (const uid of userIds) {
if (uid === excludeUserId) continue; broadcastToUser(uid, { type: event, journeyId, ...data }, excludeSocketId);
broadcastToUser(uid, { type: event, journeyId, ...data });
} }
} }
@@ -105,7 +112,7 @@ export function getJourneyFull(journeyId: number, userId: number) {
).all(journeyId) as JourneyEntry[]; ).all(journeyId) as JourneyEntry[];
const photos = db.prepare( 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[]; ).all(journeyId) as JourneyPhoto[];
// group photos by entry // group photos by entry
@@ -154,12 +161,17 @@ export function getJourneyFull(journeyId: number, userId: number) {
const photoCount = photos.length; const photoCount = photos.length;
const cities = [...new Set(entries.map(e => e.location_name).filter(Boolean))]; 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 { return {
...journey, ...journey,
entries: enrichedEntries, entries: enrichedEntries,
trips, trips,
contributors, contributors,
stats: { entries: entryCount, photos: photoCount, cities: cities.length }, 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; 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 { export function deleteJourney(journeyId: number, userId: number): boolean {
if (!isOwner(journeyId, userId)) return false; if (!isOwner(journeyId, userId)) return false;
db.prepare('DELETE FROM journeys WHERE id = ?').run(journeyId); 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); syncTripPlaces(journeyId, tripId, userId);
// import existing trip photos (Immich/Synology) with sharing settings // import existing trip photos (Immich/Synology) with sharing settings
syncTripPhotos(journeyId, tripId); syncTripPhotos(journeyId, tripId);
broadcastJourneyEvent(journeyId, 'journey:trip:synced', { tripId }, userId); broadcastJourneyEvent(journeyId, 'journey:trip:synced', { tripId });
return true; return true;
} }
@@ -253,6 +278,7 @@ export function syncTripPlaces(journeyId: number, tripId: number, authorId: numb
for (const place of places) { for (const place of places) {
if (existingPlaceIds.has(place.id)) continue; if (existingPlaceIds.has(place.id)) continue;
existingPlaceIds.add(place.id);
const entryDate = place.day_date || new Date().toISOString().split('T')[0]; const entryDate = place.day_date || new Date().toISOString().split('T')[0];
const entryTime = place.assignment_time || place.place_time || null; 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 // import trip_photos into journey when a trip is linked
function syncTripPhotos(journeyId: number, tripId: number) { function syncTripPhotos(journeyId: number, tripId: number) {
const tripPhotos = db.prepare( const tripPhotos = db.prepare(
'SELECT * FROM trip_photos WHERE trip_id = ?' 'SELECT tp.photo_id, tp.user_id, tp.shared FROM trip_photos tp WHERE tp.trip_id = ?'
).all(tripId) as { id: number; trip_id: number; user_id: number; asset_id: string; provider: string; shared: number }[]; ).all(tripId) as { photo_id: number; user_id: number; shared: number }[];
if (!tripPhotos.length) return; if (!tripPhotos.length) return;
const now = ts(); const now = ts();
@@ -285,7 +311,6 @@ function syncTripPhotos(journeyId: number, tripId: number) {
`).get(journeyId, tripId) as { id: number } | undefined; `).get(journeyId, tripId) as { id: number } | undefined;
if (!photoEntry) { 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 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 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 }; 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) }; 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) { for (const tp of tripPhotos) {
const exists = db.prepare( const exists = db.prepare(
'SELECT 1 FROM journey_photos WHERE entry_id = ? AND provider = ? AND asset_id = ?' 'SELECT 1 FROM journey_photos WHERE entry_id = ? AND photo_id = ?'
).get(photoEntry.id, tp.provider, tp.asset_id); ).get(photoEntry.id, tp.photo_id);
if (exists) continue; 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 }; 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(` db.prepare(`
INSERT INTO journey_photos (entry_id, provider, asset_id, owner_id, shared, sort_order, created_at) INSERT INTO journey_photos (entry_id, photo_id, shared, sort_order, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?)
`).run(photoEntry.id, tp.provider, tp.asset_id, tp.user_id, tp.shared, (maxOrder?.m ?? -1) + 1, now); `).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[]; ).all(journeyId) as JourneyEntry[];
const photos = db.prepare( 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[]; ).all(journeyId) as JourneyPhoto[];
const photosByEntry: Record<number, JourneyPhoto[]> = {}; const photosByEntry: Record<number, JourneyPhoto[]> = {};
@@ -457,7 +482,7 @@ export function createEntry(journeyId: number, userId: number, data: {
tags?: string[]; tags?: string[];
pros_cons?: { pros: string[]; cons: string[] }; pros_cons?: { pros: string[]; cons: string[] };
visibility?: string; visibility?: string;
}): JourneyEntry | null { }, sid?: string): JourneyEntry | null {
if (!canEdit(journeyId, userId)) return null; if (!canEdit(journeyId, userId)) return null;
const now = ts(); 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; 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; return created;
} }
@@ -510,7 +535,7 @@ export function updateEntry(entryId: number, userId: number, data: Partial<{
pros_cons: { pros: string[]; cons: string[] }; pros_cons: { pros: string[]; cons: string[] };
visibility: string; visibility: string;
sort_order: number; sort_order: number;
}>): JourneyEntry | null { }>, sid?: string): JourneyEntry | null {
const entry = db.prepare('SELECT * FROM journey_entries WHERE id = ?').get(entryId) as JourneyEntry | undefined; const entry = db.prepare('SELECT * FROM journey_entries WHERE id = ?').get(entryId) as JourneyEntry | undefined;
if (!entry) return null; if (!entry) return null;
if (!canEdit(entry.journey_id, userId)) return null; if (!canEdit(entry.journey_id, userId)) return null;
@@ -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); 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; 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; 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; const entry = db.prepare('SELECT * FROM journey_entries WHERE id = ?').get(entryId) as JourneyEntry | undefined;
if (!entry) return false; if (!entry) return false;
if (!canEdit(entry.journey_id, userId)) return false; if (!canEdit(entry.journey_id, userId)) return false;
// delete photos along with the entry — no more orphan Gallery entries // 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_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 // clean up any empty Gallery entries in this journey
db.prepare(` 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) AND id NOT IN (SELECT DISTINCT entry_id FROM journey_photos)
`).run(entry.journey_id); `).run(entry.journey_id);
broadcastJourneyEvent(entry.journey_id, 'journey:entry:deleted', { entryId }, userId);
return true; return true;
} }
@@ -579,15 +616,16 @@ export function addPhoto(entryId: number, userId: number, filePath: string, thum
if (!entry) return null; if (!entry) return null;
if (!canEdit(entry.journey_id, userId)) return null; if (!canEdit(entry.journey_id, userId)) return null;
const trekPhotoId = 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 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 now = ts();
const res = db.prepare(` const res = db.prepare(`
INSERT INTO journey_photos (entry_id, provider, file_path, thumbnail_path, caption, sort_order, created_at) INSERT INTO journey_photos (entry_id, photo_id, caption, sort_order, created_at)
VALUES (?, 'local', ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?)
`).run(entryId, filePath, thumbnailPath || null, caption || null, (maxOrder?.m ?? -1) + 1, now); `).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 { 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 (!entry) return null;
if (!canEdit(entry.journey_id, userId)) return null; if (!canEdit(entry.journey_id, userId)) return null;
const trekPhotoId = getOrCreateTrekPhoto(provider, assetId, userId);
// skip if already added // 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; 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 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 now = ts();
const res = db.prepare(` const res = db.prepare(`
INSERT INTO journey_photos (entry_id, provider, asset_id, owner_id, caption, sort_order, created_at) INSERT INTO journey_photos (entry_id, photo_id, caption, sort_order, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?)
`).run(entryId, provider, assetId, userId, caption || null, (maxOrder?.m ?? -1) + 1, now); `).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 { 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 (!entry) return null;
if (!canEdit(entry.journey_id, userId)) 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) return null;
if (source.entry_id === entryId) return source; 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) { 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 { export function updatePhoto(photoId: number, userId: number, data: { caption?: string; sort_order?: number }): JourneyPhoto | null {
const photo = db.prepare(` 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 JOIN journey_entries je ON jp.entry_id = je.id
WHERE jp.id = ? WHERE jp.id = ?
`).get(photoId) as (JourneyPhoto & { journey_id: number }) | undefined; `).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); values.push(photoId);
db.prepare(`UPDATE journey_photos SET ${fields.join(', ')} WHERE id = ?`).run(...values); 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 { export function deletePhoto(photoId: number, userId: number): (JourneyPhoto & { journey_id: number }) | null {
const photo = db.prepare(` 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 JOIN journey_entries je ON jp.entry_id = je.id
WHERE jp.id = ? WHERE jp.id = ?
`).get(photoId) as (JourneyPhoto & { journey_id: number }) | undefined; `).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; const row = db.prepare('SELECT journey_id FROM journey_share_tokens WHERE token = ?').get(token) as any;
if (!row) return null; if (!row) return null;
const photo = db.prepare(` 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 JOIN journey_entries je ON jp.entry_id = je.id
WHERE jp.id = ? AND je.journey_id = ? WHERE jp.id = ? AND je.journey_id = ?
`).get(photoId, row.journey_id) as any; `).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 { 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; const row = db.prepare('SELECT journey_id FROM journey_share_tokens WHERE token = ?').get(token) as any;
if (!row) return null; if (!row) return null;
// Check if this asset belongs to any photo in the shared journey
const photo = db.prepare(` 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 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; `).get(assetId, row.journey_id) as any;
if (!photo) { if (!photo) {
// Fallback: get journey owner
const journey = db.prepare('SELECT user_id FROM journeys WHERE id = ?').get(row.journey_id) as any; const journey = db.prepare('SELECT user_id FROM journeys WHERE id = ?').get(row.journey_id) as any;
return journey ? { ownerId: journey.user_id } : null; return journey ? { ownerId: journey.user_id } : null;
} }
@@ -100,7 +101,10 @@ export function getPublicJourney(token: string) {
`).all(row.journey_id) as any[]; `).all(row.journey_id) as any[];
const photos = db.prepare(` 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 JOIN journey_entries je ON jp.entry_id = je.id
WHERE je.journey_id = ? WHERE je.journey_id = ?
ORDER BY jp.sort_order ORDER BY jp.sort_order
+58 -11
View File
@@ -129,15 +129,15 @@ export function canAccessUserPhoto(requestingUserId: number, ownerUserId: number
const journeyPhoto = db.prepare(` const journeyPhoto = db.prepare(`
SELECT jp.entry_id, je.journey_id SELECT jp.entry_id, je.journey_id
FROM journey_photos jp FROM journey_photos jp
JOIN trek_photos tkp ON tkp.id = jp.photo_id
JOIN journey_entries je ON je.id = jp.entry_id JOIN journey_entries je ON je.id = jp.entry_id
WHERE jp.asset_id = ? WHERE tkp.asset_id = ?
AND jp.provider = ? AND tkp.provider = ?
AND jp.owner_id = ? AND tkp.owner_id = ?
LIMIT 1 LIMIT 1
`).get(assetId, provider, ownerUserId) as { entry_id: number; journey_id: number } | undefined; `).get(assetId, provider, ownerUserId) as { entry_id: number; journey_id: number } | undefined;
if (!journeyPhoto) return false; if (!journeyPhoto) return false;
// Check if requesting user is the journey owner or a contributor
const access = db.prepare(` const access = db.prepare(`
SELECT 1 FROM journeys WHERE id = ? AND user_id = ? SELECT 1 FROM journeys WHERE id = ? AND user_id = ?
UNION ALL UNION ALL
@@ -147,15 +147,16 @@ export function canAccessUserPhoto(requestingUserId: number, ownerUserId: number
return !!access; return !!access;
} }
// Regular trip photos // Regular trip photos — join through trek_photos
const sharedAsset = db.prepare(` const sharedAsset = db.prepare(`
SELECT 1 SELECT 1
FROM trip_photos FROM trip_photos tp
WHERE user_id = ? JOIN trek_photos tkp ON tkp.id = tp.photo_id
AND asset_id = ? WHERE tp.user_id = ?
AND provider = ? AND tkp.asset_id = ?
AND trip_id = ? AND tkp.provider = ?
AND shared = 1 AND tp.trip_id = ?
AND tp.shared = 1
LIMIT 1 LIMIT 1
`).get(ownerUserId, assetId, provider, tripId); `).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 //helpers for album link syncing
+68 -34
View File
@@ -149,44 +149,36 @@ export async function browseTimeline(
export async function searchPhotos( export async function searchPhotos(
userId: number, userId: number,
from?: string, from?: string,
to?: string to?: string,
): Promise<{ assets?: any[]; error?: string; status?: number }> { page: number = 1,
size: number = 50,
): Promise<{ assets?: any[]; hasMore?: boolean; error?: string; status?: number }> {
const creds = getImmichCredentials(userId); const creds = getImmichCredentials(userId);
if (!creds) return { error: 'Immich not configured', status: 400 }; if (!creds) return { error: 'Immich not configured', status: 400 };
try { try {
// Paginate through all results (Immich limits per-page to 1000) const resp = await safeFetch(`${creds.immich_url}/api/search/metadata`, {
const allAssets: any[] = []; method: 'POST',
let page = 1; headers: { 'x-api-key': creds.immich_api_key, 'Content-Type': 'application/json' },
const pageSize = 1000; body: JSON.stringify({
while (true) { takenAfter: from ? `${from}T00:00:00.000Z` : undefined,
const resp = await safeFetch(`${creds.immich_url}/api/search/metadata`, { takenBefore: to ? `${to}T23:59:59.999Z` : undefined,
method: 'POST', type: 'IMAGE',
headers: { 'x-api-key': creds.immich_api_key, 'Content-Type': 'application/json' }, size,
body: JSON.stringify({ page,
takenAfter: from ? `${from}T00:00:00.000Z` : undefined, }),
takenBefore: to ? `${to}T23:59:59.999Z` : undefined, signal: AbortSignal.timeout(15000) as any,
type: 'IMAGE', });
size: pageSize, if (!resp.ok) return { error: 'Search failed', status: resp.status };
page, const data = await resp.json() as { assets?: { items?: any[] } };
}), const items = data.assets?.items || [];
signal: AbortSignal.timeout(15000) as any, const assets = items.map((a: 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) => ({
id: a.id, id: a.id,
takenAt: a.fileCreatedAt || a.createdAt, takenAt: a.fileCreatedAt || a.createdAt,
city: a.exifInfo?.city || null, city: a.exifInfo?.city || null,
country: a.exifInfo?.country || null, country: a.exifInfo?.country || null,
})); }));
return { assets }; return { assets, hasMore: items.length >= size };
} catch { } catch {
return { error: 'Could not reach Immich', status: 502 }; 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 }; if (!creds) return { error: 'Immich not configured', status: 400 };
try { try {
const resp = await safeFetch(`${creds.immich_url}/api/albums`, { // Fetch both owned and shared albums
headers: { 'x-api-key': creds.immich_api_key, 'Accept': 'application/json' }, const [ownResp, sharedResp] = await Promise.all([
signal: AbortSignal.timeout(10000) as any, 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 = allAlbums.map((a: any) => ({
const albums = (await resp.json() as any[]).map((a: any) => ({
id: a.id, id: a.id,
albumName: a.albumName, albumName: a.albumName,
assetCount: a.assetCount || 0, assetCount: a.assetCount || 0,
startDate: a.startDate, startDate: a.startDate,
endDate: a.endDate, endDate: a.endDate,
albumThumbnailAssetId: a.albumThumbnailAssetId, albumThumbnailAssetId: a.albumThumbnailAssetId,
shared: a.shared || a.sharedUsers?.length > 0,
})); }));
return { albums }; return { albums };
} catch { } 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) { export function listAlbumLinks(tripId: string) {
return db.prepare(` return db.prepare(`
SELECT tal.*, u.username 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>> { export async function syncSynologyAlbumLink(userId: number, tripId: string, linkId: string, sid: string): Promise<ServiceResult<SyncAlbumResult>> {
const response = getAlbumIdFromLink(tripId, linkId, userId); const response = getAlbumIdFromLink(tripId, linkId, userId);
if (!response.success) return response as ServiceResult<SyncAlbumResult>; if (!response.success) return response as ServiceResult<SyncAlbumResult>;
@@ -555,7 +585,6 @@ export async function streamSynologyAsset(
targetUserId: number, targetUserId: number,
photoId: string, photoId: string,
kind: 'thumbnail' | 'original', kind: 'thumbnail' | 'original',
size?: string,
): Promise<void> { ): Promise<void> {
const parsedId = _splitPackedSynologyId(photoId); const parsedId = _splitPackedSynologyId(photoId);
if (!parsedId) { if (!parsedId) {
@@ -579,6 +608,8 @@ export async function streamSynologyAsset(
return; return;
} }
//size: 'sm' 240px| 'm' 320px| 'xl' 1280px| 'preview' ?
const params = kind === 'thumbnail' const params = kind === 'thumbnail'
? new URLSearchParams({ ? new URLSearchParams({
api: 'SYNO.Foto.Thumbnail', api: 'SYNO.Foto.Thumbnail',
@@ -587,7 +618,7 @@ export async function streamSynologyAsset(
mode: 'download', mode: 'download',
id: parsedId.id, id: parsedId.id,
type: 'unit', type: 'unit',
size: size, size: 'sm',
cache_key: parsedId.cacheKey, cache_key: parsedId.cacheKey,
_sid: sid.data, _sid: sid.data,
}) })
+13 -14
View File
@@ -8,6 +8,7 @@ import {
mapDbError, mapDbError,
Selection, Selection,
} from './helpersService'; } from './helpersService';
import { getOrCreateTrekPhoto } from './photoResolverService';
function _providers(): Array<{id: string; enabled: boolean}> { function _providers(): Array<{id: string; enabled: boolean}> {
@@ -45,13 +46,14 @@ export function listTripPhotos(tripId: string, userId: number): ServiceResult<an
} }
const photos = db.prepare(` 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 u.username, u.avatar
FROM trip_photos tp FROM trip_photos tp
JOIN trek_photos tkp ON tkp.id = tp.photo_id
JOIN users u ON tp.user_id = u.id JOIN users u ON tp.user_id = u.id
WHERE tp.trip_id = ? WHERE tp.trip_id = ?
AND (tp.user_id = ? OR tp.shared = 1) 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 ORDER BY tp.added_at ASC
`).all(tripId, userId, ...enabledProviders); `).all(tripId, userId, ...enabledProviders);
@@ -108,9 +110,10 @@ function _addTripPhoto(tripId: string, userId: number, provider: string, assetId
return providerResult as ServiceResult<boolean>; return providerResult as ServiceResult<boolean>;
} }
try { try {
const photoId = getOrCreateTrekPhoto(provider, assetId, userId);
const result = db.prepare( const result = db.prepare(
'INSERT OR IGNORE INTO trip_photos (trip_id, user_id, asset_id, provider, shared, album_link_id) VALUES (?, ?, ?, ?, ?, ?)' 'INSERT OR IGNORE INTO trip_photos (trip_id, user_id, photo_id, shared, album_link_id) VALUES (?, ?, ?, ?, ?)'
).run(tripId, userId, assetId, provider, shared ? 1 : 0, albumLinkId || null); ).run(tripId, userId, photoId, shared ? 1 : 0, albumLinkId || null);
return success(result.changes > 0); return success(result.changes > 0);
} }
catch (error) { catch (error) {
@@ -163,8 +166,7 @@ export async function addTripPhotos(
export async function setTripPhotoSharing( export async function setTripPhotoSharing(
tripId: string, tripId: string,
userId: number, userId: number,
provider: string, photoId: number,
assetId: string,
shared: boolean, shared: boolean,
sid?: string, sid?: string,
): Promise<ServiceResult<true>> { ): Promise<ServiceResult<true>> {
@@ -179,9 +181,8 @@ export async function setTripPhotoSharing(
SET shared = ? SET shared = ?
WHERE trip_id = ? WHERE trip_id = ?
AND user_id = ? AND user_id = ?
AND asset_id = ? AND photo_id = ?
AND provider = ? `).run(shared ? 1 : 0, tripId, userId, photoId);
`).run(shared ? 1 : 0, tripId, userId, assetId, provider);
await _notifySharedTripPhotos(tripId, userId, 1); await _notifySharedTripPhotos(tripId, userId, 1);
broadcast(tripId, 'memories:updated', { userId }, sid); broadcast(tripId, 'memories:updated', { userId }, sid);
@@ -194,8 +195,7 @@ export async function setTripPhotoSharing(
export function removeTripPhoto( export function removeTripPhoto(
tripId: string, tripId: string,
userId: number, userId: number,
provider: string, photoId: number,
assetId: string,
sid?: string, sid?: string,
): ServiceResult<true> { ): ServiceResult<true> {
const access = canAccessTrip(tripId, userId); const access = canAccessTrip(tripId, userId);
@@ -208,9 +208,8 @@ export function removeTripPhoto(
DELETE FROM trip_photos DELETE FROM trip_photos
WHERE trip_id = ? WHERE trip_id = ?
AND user_id = ? AND user_id = ?
AND asset_id = ? AND photo_id = ?
AND provider = ? `).run(tripId, userId, photoId);
`).run(tripId, userId, assetId, provider);
broadcast(tripId, 'memories:updated', { userId }, sid); 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 }> { export async function testSmtp(to: string): Promise<{ success: boolean; error?: string }> {
if (!getSmtpConfig()) return { success: false, error: 'SMTP not configured' };
try { 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.'); const config = getSmtpConfig()!;
return sent ? { success: true } : { success: false, error: 'SMTP not configured' }; 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) { } catch (err) {
return { success: false, error: err instanceof Error ? err.message : 'Unknown error' }; 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) { if (diffDays > -1) {
const month = targetDate.getMonth() + 1; const month = targetDate.getMonth() + 1;
const day = targetDate.getDate(); 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 startDate = new Date(refYear, month - 1, day - 2);
const endDate = new Date(refYear, month - 1, day + 2); const endDate = new Date(refYear, month - 1, day + 2);
const startStr = startDate.toISOString().slice(0, 10); const startStr = startDate.toISOString().slice(0, 10);
@@ -299,7 +302,10 @@ export async function getDetailedWeather(
// Climate / archive path (> 16 days out) // Climate / archive path (> 16 days out)
if (diffDays > 16) { 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 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}` 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; updated_at: number;
} }
export interface JourneyPhoto { export interface TrekPhoto {
id: number; id: number;
entry_id: number; provider: string;
provider: 'local' | 'immich' | 'synologyphotos';
asset_id?: string | null; asset_id?: string | null;
owner_id?: number | null; owner_id?: number | null;
file_path?: string | null; file_path?: string | null;
thumbnail_path?: string | null; thumbnail_path?: string | null;
caption?: string | null;
sort_order: number;
width?: number | null; width?: number | null;
height?: 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; shared: number;
created_at: 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 { export interface JourneyTrip {
+16 -3
View File
@@ -558,10 +558,23 @@ export function addTripPhoto(
provider: string, provider: string,
opts: { shared?: boolean; albumLinkId?: number } = {} opts: { shared?: boolean; albumLinkId?: number } = {}
): TestTripPhoto { ): 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( const result = db.prepare(
'INSERT OR IGNORE INTO trip_photos (trip_id, user_id, asset_id, provider, shared, album_link_id) VALUES (?, ?, ?, ?, ?, ?)' 'INSERT OR IGNORE INTO trip_photos (trip_id, user_id, photo_id, shared, album_link_id) VALUES (?, ?, ?, ?, ?)'
).run(tripId, userId, assetId, provider, opts.shared ? 1 : 0, opts.albumLinkId ?? null); ).run(tripId, userId, trekPhoto.id, opts.shared ? 1 : 0, opts.albumLinkId ?? null);
return db.prepare('SELECT * FROM trip_photos WHERE id = ?').get(result.lastInsertRowid) as TestTripPhoto; 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 { 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; .get(trip.id, user.id, 'album-xyz', 'Album XYZ', 'immich') as any;
// Insert photos synced from the album // 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); for (const assetId of ['asset-001', 'asset-002']) {
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); 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) // 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) const res = await request(app)
.delete(`/api/integrations/memories/unified/trips/${trip.id}/album-links/${linkResult.id}`) .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); expect(res.body.success).toBe(true);
// Album-linked photos should be gone // 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.length).toBe(1);
expect(remainingPhotos[0].asset_id).toBe('asset-manual'); 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 *') 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; .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 // Non-member tries to delete owner's album link — should be denied
const res = await request(app) const res = await request(app)
@@ -232,7 +243,11 @@ describe('Immich album links', () => {
// Link and photos should still exist // Link and photos should still exist
const link = testDb.prepare('SELECT * FROM trip_album_links WHERE id = ?').get(linkResult.id); const link = testDb.prepare('SELECT * FROM trip_album_links WHERE id = ?').get(linkResult.id);
expect(link).toBeDefined(); 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(); expect(photo).toBeDefined();
}); });
@@ -119,8 +119,8 @@ vi.mock('../../src/utils/ssrfGuard', async () => {
body: null, body: null,
}); });
} }
// /api/albums — list albums // /api/albums — list albums (owned and shared?=true variant)
if (/\/api\/albums$/.test(u)) { if (/\/api\/albums(\?.*)?$/.test(u)) {
return Promise.resolve({ return Promise.resolve({
ok: true, status: 200, ok: true, status: 200,
headers: { get: () => null }, headers: { get: () => null },
@@ -415,9 +415,11 @@ describe('Immich asset proxy', () => {
const { user: member } = createUser(testDb); const { user: member } = createUser(testDb);
// Insert a shared photo referencing a trip that doesn't exist (FK disabled temporarily) // Insert a shared photo referencing a trip that doesn't exist (FK disabled temporarily)
testDb.exec('PRAGMA foreign_keys = OFF'); 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( testDb.prepare(
'INSERT INTO trip_photos (trip_id, user_id, asset_id, provider, shared) VALUES (?, ?, ?, ?, ?)' 'INSERT INTO trip_photos (trip_id, user_id, photo_id, shared) VALUES (?, ?, ?, ?)'
).run(9999, owner.id, 'asset-notrip', 'immich', 1); ).run(9999, owner.id, tkpNotrip.id, 1);
testDb.exec('PRAGMA foreign_keys = ON'); testDb.exec('PRAGMA foreign_keys = ON');
const res = await request(app) const res = await request(app)
@@ -531,7 +533,11 @@ describe('Immich syncAlbumAssets', () => {
expect(typeof res.body.added).toBe('number'); expect(typeof res.body.added).toBe('number');
// Verify photos were inserted into the DB // 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.length).toBeGreaterThan(0);
expect(photos[0].provider).toBe('immich'); 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) // Determine which API was called from the URL query param (e.g. ?api=SYNO.API.Auth)
// or from the body for POST requests. // or from the body for POST requests.
let apiName = ''; let apiName = '';
let params = new URLSearchParams();
try { try {
apiName = new URL(u).searchParams.get('api') || ''; params = new URL(u).searchParams;
apiName = params.get('api') || '';
} catch {} } catch {}
if (!apiName && init?.body) { if (!apiName && init?.body) {
const body = init.body instanceof URLSearchParams params = init.body instanceof URLSearchParams
? init.body ? init.body
: new URLSearchParams(String(init.body)); : new URLSearchParams(String(init.body));
apiName = body.get('api') || ''; apiName = params.get('api') || '';
} }
// Auth login — used by settings save, status, test-connection // Auth login — used by settings save, status, test-connection
@@ -154,6 +156,8 @@ vi.mock('../../src/utils/ssrfGuard', async () => {
// Thumbnail stream // Thumbnail stream
if (apiName === 'SYNO.Foto.Thumbnail') { 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'); const imageBytes = Buffer.from('fake-synology-thumbnail');
return Promise.resolve({ return Promise.resolve({
ok: true, status: 200, ok: true, status: 200,
@@ -437,6 +441,24 @@ describe('Synology asset access', () => {
expect(res.headers['content-type']).toContain('image/jpeg'); 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 () => { it('SYNO-033 — GET /assets/original streams image data for shared photo', async () => {
const { user: owner } = createUser(testDb); const { user: owner } = createUser(testDb);
const { user: member } = createUser(testDb); const { user: member } = createUser(testDb);
@@ -470,9 +492,11 @@ describe('Synology asset access', () => {
const { user: member } = createUser(testDb); const { user: member } = createUser(testDb);
// Insert a shared photo referencing a trip that doesn't exist (FK disabled temporarily) // Insert a shared photo referencing a trip that doesn't exist (FK disabled temporarily)
testDb.exec('PRAGMA foreign_keys = OFF'); 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( testDb.prepare(
'INSERT INTO trip_photos (trip_id, user_id, asset_id, provider, shared) VALUES (?, ?, ?, ?, ?)' 'INSERT INTO trip_photos (trip_id, user_id, photo_id, shared) VALUES (?, ?, ?, ?)'
).run(9999, owner.id, '101_cachekey', 'synologyphotos', 1); ).run(9999, owner.id, tkpSyno35.id, 1);
testDb.exec('PRAGMA foreign_keys = ON'); testDb.exec('PRAGMA foreign_keys = ON');
const res = await request(app) const res = await request(app)
@@ -568,7 +592,11 @@ describe('Synology syncSynologyAlbumLink', () => {
expect(typeof res.body.total).toBe('number'); expect(typeof res.body.total).toBe('number');
// Verify photos were inserted into the DB // 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.length).toBeGreaterThan(0);
expect(photos[0].provider).toBe('synologyphotos'); expect(photos[0].provider).toBe('synologyphotos');
}); });
@@ -146,7 +146,11 @@ describe('Unified photo management', () => {
expect(res.status).toBe(200); expect(res.status).toBe(200);
expect(res.body.added).toBe(2); 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'])); 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 { user } = createUser(testDb);
const trip = createTrip(testDb, user.id); const trip = createTrip(testDb, user.id);
addTripPhoto(testDb, trip.id, user.id, 'asset-tog', 'immich', { shared: false }); 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) const res = await request(app)
.put(`${photosUrl(trip.id)}/sharing`) .put(`${photosUrl(trip.id)}/sharing`)
.set('Cookie', authCookie(user.id)) .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); 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); expect(row.shared).toBe(1);
}); });
@@ -206,14 +219,23 @@ describe('Unified photo management', () => {
const { user } = createUser(testDb); const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id); const trip = createTrip(testDb, user.id);
addTripPhoto(testDb, trip.id, user.id, 'asset-del', 'immich'); 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) const res = await request(app)
.delete(photosUrl(trip.id)) .delete(photosUrl(trip.id))
.set('Cookie', authCookie(user.id)) .set('Cookie', authCookie(user.id))
.send({ provider: 'immich', asset_id: 'asset-del' }); .send({ photo_id: trekRef.photo_id });
expect(res.status).toBe(200); 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(); expect(row).toBeUndefined();
}); });
@@ -473,10 +473,12 @@ describe('getVisitedRegions', () => {
const trip = createTrip(testDb, user.id, { title: 'Paris Trip' }); const trip = createTrip(testDb, user.id, { title: 'Paris Trip' });
insertPlaceWithCoords(testDb, trip.id, 'Paris Hotel', 48.85, 2.35); 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) // Advance all pending timers (including the 1100ms Nominatim rate-limit delay)
await vi.runAllTimersAsync(); await vi.runAllTimersAsync();
const result = await resultPromise; // Second call returns now-cached data
const result = await getVisitedRegions(user.id);
expect(result.regions['FR']).toBeDefined(); expect(result.regions['FR']).toBeDefined();
@@ -580,7 +580,7 @@ describe('BACKUP-041 listBackups', () => {
fsMock.readdirSync.mockReturnValue(['backup-2026-01-01T00-00-00.zip']); fsMock.readdirSync.mockReturnValue(['backup-2026-01-01T00-00-00.zip']);
fsMock.statSync.mockReturnValue({ fsMock.statSync.mockReturnValue({
size: 1024, size: 1024,
birthtime: new Date('2026-01-01T00:00:00Z'), mtime: new Date('2026-01-01T00:00:00Z'),
}); });
const result = listBackups(); const result = listBackups();
@@ -599,9 +599,9 @@ describe('BACKUP-041 listBackups', () => {
]); ]);
fsMock.statSync.mockImplementation((p: string) => { fsMock.statSync.mockImplementation((p: string) => {
if (String(p).includes('2026-01-01')) { 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(); const result = listBackups();
@@ -619,7 +619,7 @@ describe('BACKUP-041 listBackups', () => {
]); ]);
fsMock.statSync.mockReturnValue({ fsMock.statSync.mockReturnValue({
size: 1024, size: 1024,
birthtime: new Date('2026-01-01T00:00:00Z'), mtime: new Date('2026-01-01T00:00:00Z'),
}); });
const result = listBackups(); const result = listBackups();
@@ -389,6 +389,8 @@ describe('addTripToJourney / removeTripFromJourney', () => {
end_date: '2026-03-03', end_date: '2026-03-03',
}); });
const place = createPlace(testDb, trip.id, { name: 'Eiffel Tower' }); 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); addTripToJourney(journey.id, trip.id, user.id);
@@ -563,6 +565,46 @@ describe('deleteEntry', () => {
expect(deleteEntry(entry.id, viewer.id)).toBe(false); 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 ------------------------------------------------------------------- // -- Photos -------------------------------------------------------------------
@@ -809,6 +851,9 @@ describe('syncTripPlaces', () => {
}); });
const place1 = createPlace(testDb, trip.id, { name: 'Eiffel Tower' }); const place1 = createPlace(testDb, trip.id, { name: 'Eiffel Tower' });
const place2 = createPlace(testDb, trip.id, { name: 'Louvre' }); 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); syncTripPlaces(journey.id, trip.id, user.id);
@@ -828,7 +873,9 @@ describe('syncTripPlaces', () => {
start_date: '2026-05-01', start_date: '2026-05-01',
end_date: '2026-05-02', 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);
syncTripPlaces(journey.id, trip.id, user.id); // second call syncTripPlaces(journey.id, trip.id, user.id); // second call
@@ -879,6 +926,8 @@ describe('onPlaceCreated', () => {
// Create a new place after trip is linked // Create a new place after trip is linked
const place = createPlace(testDb, trip.id, { name: 'Sagrada Familia' }); 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); onPlaceCreated(trip.id, place.id);
const skeleton = testDb.prepare( const skeleton = testDb.prepare(
@@ -911,6 +960,8 @@ describe('onPlaceCreated', () => {
addTripToJourney(journey.id, trip.id, user.id); addTripToJourney(journey.id, trip.id, user.id);
const place = createPlace(testDb, trip.id, { name: 'Arc de Triomphe' }); 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);
onPlaceCreated(trip.id, place.id); // second call onPlaceCreated(trip.id, place.id); // second call
@@ -931,6 +982,8 @@ describe('onPlaceUpdated', () => {
end_date: '2026-08-03', end_date: '2026-08-03',
}); });
const place = createPlace(testDb, trip.id, { name: 'Old Name' }); 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); addTripToJourney(journey.id, trip.id, user.id);
// Update the place name directly in DB // Update the place name directly in DB
@@ -954,6 +1007,8 @@ describe('onPlaceUpdated', () => {
end_date: '2026-08-02', end_date: '2026-08-02',
}); });
const place = createPlace(testDb, trip.id, { name: 'Original Place' }); 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); addTripToJourney(journey.id, trip.id, user.id);
// Promote the skeleton to a full entry // Promote the skeleton to a full entry
@@ -1017,6 +1072,8 @@ describe('onPlaceDeleted', () => {
end_date: '2026-09-02', end_date: '2026-09-02',
}); });
const place = createPlace(testDb, trip.id, { name: 'Detach Place' }); 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); addTripToJourney(journey.id, trip.id, user.id);
// Promote the skeleton to a filled entry // Promote the skeleton to a filled entry
@@ -1115,7 +1172,11 @@ describe('setPhotoProvider', () => {
setPhotoProvider(photo!.id, 'immich', 'immich-asset-789', user.id); 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.provider).toBe('immich');
expect(updated.asset_id).toBe('immich-asset-789'); expect(updated.asset_id).toBe('immich-asset-789');
expect(updated.owner_id).toBe(user.id); expect(updated.owner_id).toBe(user.id);
@@ -1219,7 +1280,7 @@ describe('listUserTrips', () => {
// -- Edge cases ---------------------------------------------------------------- // -- Edge cases ----------------------------------------------------------------
describe('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 { user } = createUser(testDb);
const journey = createJourney(testDb, user.id); const journey = createJourney(testDb, user.id);
const entry = createJourneyEntry(testDb, journey.id, user.id, { entry_date: '2026-03-01' }); 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); const result = deleteEntry(entry.id, user.id);
expect(result).toBe(true); expect(result).toBe(true);
// Photo should still exist, moved to a Gallery entry // Photo should be deleted with the entry
const movedPhoto = testDb.prepare('SELECT * FROM journey_photos WHERE id = ?').get(photo!.id) as any; const deletedPhoto = testDb.prepare('SELECT * FROM journey_photos WHERE id = ?').get(photo!.id) as any;
expect(movedPhoto).toBeDefined(); expect(deletedPhoto).toBeUndefined();
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');
}); });
it('JOURNEY-SVC-082: updateJourney can set cover_gradient', () => { it('JOURNEY-SVC-082: updateJourney can set cover_gradient', () => {
@@ -1308,9 +1365,11 @@ describe('Edge cases', () => {
).get(journey.id) as any; ).get(journey.id) as any;
expect(photoEntry).toBeDefined(); expect(photoEntry).toBeDefined();
const photos = testDb.prepare( const photos = testDb.prepare(`
'SELECT * FROM journey_photos WHERE entry_id = ?' SELECT jp.*, tkp.asset_id FROM journey_photos jp
).all(photoEntry.id); JOIN trek_photos tkp ON tkp.id = jp.photo_id
WHERE jp.entry_id = ?
`).all(photoEntry.id);
expect(photos.length).toBe(1); expect(photos.length).toBe(1);
expect((photos[0] as any).asset_id).toBe('immich-photo-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 place1 = createPlace(testDb, trip.id, { name: 'Skeleton Place' });
const place2 = createPlace(testDb, trip.id, { name: 'Filled 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); addTripToJourney(journey.id, trip.id, user.id);
// Promote one skeleton to a filled entry // Promote one skeleton to a filled entry
@@ -63,10 +63,17 @@ function insertJourneyPhoto(
entryId: number, entryId: number,
opts: { filePath?: string; assetId?: string; ownerId?: number } = {} opts: { filePath?: string; assetId?: string; ownerId?: number } = {}
): 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(` const result = testDb.prepare(`
INSERT INTO journey_photos (entry_id, file_path, caption, sort_order, created_at, asset_id, owner_id) INSERT INTO journey_photos (entry_id, photo_id, caption, sort_order, created_at)
VALUES (?, ?, NULL, 0, ?, ?, ?) VALUES (?, ?, NULL, 0, ?)
`).run(entryId, opts.filePath ?? '/photos/test.jpg', Date.now(), opts.assetId ?? null, opts.ownerId ?? null); `).run(entryId, trekId, Date.now());
return result.lastInsertRowid as number; return result.lastInsertRowid as number;
} }
@@ -384,7 +384,12 @@ describe('searchNominatim (fetch stubbed)', () => {
}); });
it('MAPS-030b: throws when nominatim response is not ok', async () => { 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'); const { searchNominatim } = await import('../../../src/services/mapsService');
await expect(searchNominatim('fail')).rejects.toThrow('Nominatim API error'); await expect(searchNominatim('fail')).rejects.toThrow('Nominatim API error');
}); });