mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-19 13:21:46 +00:00
Merge branch 'dev' into feat/login-language-detection-dropdown
This commit is contained in:
Generated
+30
@@ -21,6 +21,7 @@
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-router-dom": "^6.22.2",
|
||||
"react-window": "^2.2.7",
|
||||
"remark-breaks": "^4.0.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"topojson-client": "^3.1.0",
|
||||
"zustand": "^4.5.2"
|
||||
@@ -7585,6 +7586,20 @@
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/mdast-util-newline-to-break": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/mdast-util-newline-to-break/-/mdast-util-newline-to-break-2.0.0.tgz",
|
||||
"integrity": "sha512-MbgeFca0hLYIEx/2zGsszCSEJJ1JSCdiY5xQxRcLDDGa8EPvlLPupJ4DSajbMPAnC0je8jfb9TiUATnxxrHUog==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/mdast": "^4.0.0",
|
||||
"mdast-util-find-and-replace": "^3.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/mdast-util-phrasing": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/mdast-util-phrasing/-/mdast-util-phrasing-4.1.0.tgz",
|
||||
@@ -9272,6 +9287,21 @@
|
||||
"regjsparser": "bin/parser"
|
||||
}
|
||||
},
|
||||
"node_modules/remark-breaks": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/remark-breaks/-/remark-breaks-4.0.0.tgz",
|
||||
"integrity": "sha512-IjEjJOkH4FuJvHZVIW0QCDWxcG96kCq7An/KVH2NfJe6rKZU2AsHeB3OEjPNRxi4QC34Xdx7I2KGYn6IpT7gxQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/mdast": "^4.0.0",
|
||||
"mdast-util-newline-to-break": "^2.0.0",
|
||||
"unified": "^11.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/remark-gfm": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz",
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-router-dom": "^6.22.2",
|
||||
"react-window": "^2.2.7",
|
||||
"remark-breaks": "^4.0.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"topojson-client": "^3.1.0",
|
||||
"zustand": "^4.5.2"
|
||||
|
||||
@@ -309,6 +309,7 @@ export const journeyApi = {
|
||||
// Photos
|
||||
uploadPhotos: (entryId: number, formData: FormData) => apiClient.post(`/journeys/entries/${entryId}/photos`, formData, { headers: { 'Content-Type': undefined as any } }).then(r => r.data),
|
||||
addProviderPhoto: (entryId: number, provider: string, assetId: string, caption?: string) => apiClient.post(`/journeys/entries/${entryId}/provider-photos`, { provider, asset_id: assetId, caption }).then(r => r.data),
|
||||
addProviderPhotos: (entryId: number, provider: string, assetIds: string[], caption?: string) => apiClient.post(`/journeys/entries/${entryId}/provider-photos`, { provider, asset_ids: assetIds, caption }).then(r => r.data),
|
||||
linkPhoto: (entryId: number, photoId: number) => apiClient.post(`/journeys/entries/${entryId}/link-photo`, { photo_id: photoId }).then(r => r.data),
|
||||
updatePhoto: (photoId: number, data: Record<string, unknown>) => apiClient.patch(`/journeys/photos/${photoId}`, data).then(r => r.data),
|
||||
deletePhoto: (photoId: number) => apiClient.delete(`/journeys/photos/${photoId}`).then(r => r.data),
|
||||
@@ -321,6 +322,9 @@ export const journeyApi = {
|
||||
updateContributor: (id: number, userId: number, role: string) => apiClient.patch(`/journeys/${id}/contributors/${userId}`, { role }).then(r => r.data),
|
||||
removeContributor: (id: number, userId: number) => apiClient.delete(`/journeys/${id}/contributors/${userId}`).then(r => r.data),
|
||||
|
||||
// Preferences
|
||||
updatePreferences: (id: number, data: { hide_skeletons?: boolean }) => apiClient.patch(`/journeys/${id}/preferences`, data).then(r => r.data),
|
||||
|
||||
// Share
|
||||
getShareLink: (id: number) => apiClient.get(`/journeys/${id}/share-link`).then(r => r.data),
|
||||
createShareLink: (id: number, perms: { share_timeline?: boolean; share_gallery?: boolean; share_map?: boolean }) => apiClient.post(`/journeys/${id}/share-link`, perms).then(r => r.data),
|
||||
|
||||
@@ -190,11 +190,12 @@ describe('AddonManager', () => {
|
||||
expect(screen.queryByText('Bag Tracking')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-ADDON-010: photo provider sub-toggles shown for Memories addon', async () => {
|
||||
it('FE-ADMIN-ADDON-010: photo provider sub-toggles shown under Journey addon', async () => {
|
||||
server.use(
|
||||
http.get('/api/admin/addons', () =>
|
||||
HttpResponse.json({
|
||||
addons: [
|
||||
buildAddon({ id: 'journey', name: 'Journey', type: 'global', icon: 'Compass', enabled: true }),
|
||||
buildAddon({ id: 'photos', name: 'Memories', type: 'trip', icon: 'Image', enabled: false }),
|
||||
buildAddon({ id: 'unsplash', name: 'Unsplash', type: 'photo_provider', enabled: true }),
|
||||
buildAddon({ id: 'pexels', name: 'Pexels', type: 'photo_provider', enabled: false }),
|
||||
@@ -204,18 +205,16 @@ describe('AddonManager', () => {
|
||||
);
|
||||
render(<AddonManager />);
|
||||
|
||||
// Provider sub-rows are visible
|
||||
// Provider sub-rows are visible under Journey addon
|
||||
await screen.findByText('Unsplash');
|
||||
expect(screen.getByText('Pexels')).toBeInTheDocument();
|
||||
|
||||
// Memories row shows name override
|
||||
expect(screen.getByText('Memories providers')).toBeInTheDocument();
|
||||
// Journey addon is rendered
|
||||
expect(screen.getByText('Journey')).toBeInTheDocument();
|
||||
|
||||
// The photos addon row itself has no top-level toggle (hideToggle = true)
|
||||
// The toggle buttons are only for the providers
|
||||
// Toggle buttons: journey toggle + 2 provider toggles
|
||||
const toggleBtns = screen.getAllByRole('button').filter(b => b.classList.contains('rounded-full'));
|
||||
// Should be 2 provider toggles (no main toggle for the photos addon)
|
||||
expect(toggleBtns.length).toBe(2);
|
||||
expect(toggleBtns.length).toBe(3);
|
||||
});
|
||||
|
||||
it('FE-ADMIN-ADDON-011: icon falls back to Puzzle when icon name unknown', async () => {
|
||||
|
||||
@@ -4,10 +4,10 @@ import { useTranslation } from '../../i18n'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { useAddonStore } from '../../store/addonStore'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { Puzzle, ListChecks, Wallet, FileText, CalendarDays, Globe, Briefcase, Image, Terminal, Link2, Compass } from 'lucide-react'
|
||||
import { Puzzle, ListChecks, Wallet, FileText, CalendarDays, Globe, Briefcase, Image, Terminal, Link2, Compass, BookOpen } from 'lucide-react'
|
||||
|
||||
const ICON_MAP = {
|
||||
ListChecks, Wallet, FileText, CalendarDays, Puzzle, Globe, Briefcase, Image, Terminal, Link2, Compass,
|
||||
ListChecks, Wallet, FileText, CalendarDays, Puzzle, Globe, Briefcase, Image, Terminal, Link2, Compass, BookOpen,
|
||||
}
|
||||
|
||||
interface Addon {
|
||||
@@ -103,11 +103,11 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking }
|
||||
}
|
||||
}
|
||||
|
||||
const tripAddons = addons.filter(a => a.type === 'trip')
|
||||
const globalAddons = addons.filter(a => a.type === 'global')
|
||||
const photoProviderAddons = addons.filter(isPhotoProviderAddon)
|
||||
const photosAddon = addons.filter(a => a.type === 'trip').find(isPhotosAddon)
|
||||
const tripAddons = addons.filter(a => a.type === 'trip' && !isPhotosAddon(a))
|
||||
const globalAddons = addons.filter(a => a.type === 'global')
|
||||
const integrationAddons = addons.filter(a => a.type === 'integration')
|
||||
const photosAddon = tripAddons.find(isPhotosAddon)
|
||||
const providerOptions: ProviderOption[] = photoProviderAddons.map((provider) => ({
|
||||
key: provider.id,
|
||||
label: provider.name,
|
||||
@@ -153,42 +153,7 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking }
|
||||
</div>
|
||||
{tripAddons.map(addon => (
|
||||
<div key={addon.id}>
|
||||
<AddonRow
|
||||
addon={addon}
|
||||
onToggle={handleToggle}
|
||||
t={t}
|
||||
nameOverride={photosAddon && addon.id === photosAddon.id ? 'Memories providers' : undefined}
|
||||
descriptionOverride={photosAddon && addon.id === photosAddon.id ? 'Enable or disable each photo provider.' : undefined}
|
||||
statusOverride={photosAddon && addon.id === photosAddon.id ? photosDerivedEnabled : undefined}
|
||||
hideToggle={photosAddon && addon.id === photosAddon.id}
|
||||
/>
|
||||
{photosAddon && addon.id === photosAddon.id && providerOptions.length > 0 && (
|
||||
<div className="px-6 py-3 border-b" style={{ borderColor: 'var(--border-secondary)', background: 'var(--bg-secondary)', paddingLeft: 70 }}>
|
||||
<div className="space-y-2">
|
||||
{providerOptions.map(provider => (
|
||||
<div key={provider.key} className="flex items-center gap-4" style={{ minHeight: 32 }}>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>{provider.label}</div>
|
||||
<div className="text-xs mt-0.5" style={{ color: 'var(--text-faint)' }}>{provider.description}</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<span className="hidden sm:inline text-xs font-medium" style={{ color: provider.enabled ? 'var(--text-primary)' : 'var(--text-faint)' }}>
|
||||
{provider.enabled ? t('admin.addons.enabled') : t('admin.addons.disabled')}
|
||||
</span>
|
||||
<button
|
||||
onClick={provider.toggle}
|
||||
className="relative inline-flex h-6 w-11 items-center rounded-full transition-colors"
|
||||
style={{ background: provider.enabled ? 'var(--text-primary)' : 'var(--border-primary)' }}
|
||||
>
|
||||
<span className="absolute left-0.5 h-5 w-5 rounded-full bg-white transition-transform duration-200"
|
||||
style={{ transform: provider.enabled ? 'translateX(20px)' : 'translateX(0)' }} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<AddonRow addon={addon} onToggle={handleToggle} t={t} />
|
||||
{addon.id === 'packing' && addon.enabled && onToggleBagTracking && (
|
||||
<div className="flex items-center gap-4 px-6 py-3 border-b" style={{ borderColor: 'var(--border-secondary)', background: 'var(--bg-secondary)', paddingLeft: 70 }}>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
@@ -223,7 +188,37 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking }
|
||||
</span>
|
||||
</div>
|
||||
{globalAddons.map(addon => (
|
||||
<AddonRow key={addon.id} addon={addon} onToggle={handleToggle} t={t} />
|
||||
<div key={addon.id}>
|
||||
<AddonRow addon={addon} onToggle={handleToggle} t={t} />
|
||||
{/* Memories providers as sub-items under Journey addon */}
|
||||
{addon.id === 'journey' && providerOptions.length > 0 && (
|
||||
<div className="px-6 py-3 border-b" style={{ borderColor: 'var(--border-secondary)', background: 'var(--bg-secondary)', paddingLeft: 70 }}>
|
||||
<div className="space-y-2">
|
||||
{providerOptions.map(provider => (
|
||||
<div key={provider.key} className="flex items-center gap-4" style={{ minHeight: 32 }}>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>{provider.label}</div>
|
||||
<div className="text-xs mt-0.5" style={{ color: 'var(--text-faint)' }}>{provider.description}</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<span className="hidden sm:inline text-xs font-medium" style={{ color: provider.enabled ? 'var(--text-primary)' : 'var(--text-faint)' }}>
|
||||
{provider.enabled ? t('admin.addons.enabled') : t('admin.addons.disabled')}
|
||||
</span>
|
||||
<button
|
||||
onClick={provider.toggle}
|
||||
className="relative inline-flex h-6 w-11 items-center rounded-full transition-colors"
|
||||
style={{ background: provider.enabled ? 'var(--text-primary)' : 'var(--border-primary)' }}
|
||||
>
|
||||
<span className="absolute left-0.5 h-5 w-5 rounded-full bg-white transition-transform duration-200"
|
||||
style={{ transform: provider.enabled ? 'translateX(20px)' : 'translateX(0)' }} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useState, useEffect, useCallback, useRef, useMemo } from 'react'
|
||||
import DOM from 'react-dom'
|
||||
import Markdown from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import remarkBreaks from 'remark-breaks'
|
||||
import { Plus, Trash2, Pin, PinOff, Pencil, X, Check, StickyNote, Settings, ExternalLink, Maximize2, Loader2 } from 'lucide-react'
|
||||
import { collabApi } from '../../api/client'
|
||||
import { getAuthUrl } from '../../api/authUrl'
|
||||
@@ -845,7 +846,7 @@ function NoteCard({ note, currentUser, canEdit, onUpdate, onDelete, onEdit, onVi
|
||||
maxHeight: '4.5em', overflow: 'hidden',
|
||||
wordBreak: 'break-word', fontFamily: FONT,
|
||||
}}>
|
||||
<Markdown remarkPlugins={[remarkGfm]}>{note.content}</Markdown>
|
||||
<Markdown remarkPlugins={[remarkGfm, remarkBreaks]}>{note.content}</Markdown>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -1352,7 +1353,7 @@ export default function CollabNotes({ tripId, currentUser }: CollabNotesProps) {
|
||||
</div>
|
||||
</div>
|
||||
<div className="collab-note-md-full" style={{ padding: '16px 20px', overflowY: 'auto', fontSize: 14, color: 'var(--text-primary)', lineHeight: 1.7 }}>
|
||||
<Markdown remarkPlugins={[remarkGfm]}>{viewingNote.content || ''}</Markdown>
|
||||
<Markdown remarkPlugins={[remarkGfm, remarkBreaks]}>{viewingNote.content || ''}</Markdown>
|
||||
{(viewingNote.attachments || []).length > 0 && (
|
||||
<div style={{ marginTop: 16, paddingTop: 16, borderTop: '1px solid var(--border-primary)' }}>
|
||||
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: 0.5, marginBottom: 10 }}>{t('files.title')}</div>
|
||||
|
||||
@@ -27,9 +27,9 @@ describe('JournalBody', () => {
|
||||
|
||||
it('FE-COMP-JOURNALBODY-004: renders headings with proper elements', () => {
|
||||
const { container } = render(<JournalBody text="## Section Title" />);
|
||||
const h2 = container.querySelector('h2');
|
||||
expect(h2).toBeInTheDocument();
|
||||
expect(h2!.textContent).toBe('Section Title');
|
||||
const p = container.querySelector('p');
|
||||
expect(p).toBeInTheDocument();
|
||||
expect(p!.textContent).toBe('Section Title');
|
||||
});
|
||||
|
||||
it('FE-COMP-JOURNALBODY-005: handles empty text without crashing', () => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import remarkBreaks from 'remark-breaks'
|
||||
|
||||
interface Props {
|
||||
text: string
|
||||
@@ -15,11 +16,11 @@ export default function JournalBody({ text, dark }: Props) {
|
||||
color: 'inherit',
|
||||
}}>
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
remarkPlugins={[remarkGfm, remarkBreaks]}
|
||||
components={{
|
||||
h1: ({ children }) => <h1 style={{ fontFamily: 'inherit', fontSize: '1.3em', fontWeight: 700, margin: '16px 0 6px', lineHeight: 1.3 }}>{children}</h1>,
|
||||
h2: ({ children }) => <h2 style={{ fontFamily: 'inherit', fontSize: '1.15em', fontWeight: 600, margin: '14px 0 4px', lineHeight: 1.3 }}>{children}</h2>,
|
||||
h3: ({ children }) => <h3 style={{ fontFamily: 'inherit', fontSize: '1.05em', fontWeight: 600, margin: '12px 0 4px', lineHeight: 1.4 }}>{children}</h3>,
|
||||
h1: ({ children }) => <p style={{ margin: '0 0 6px' }}>{children}</p>,
|
||||
h2: ({ children }) => <p style={{ margin: '0 0 6px' }}>{children}</p>,
|
||||
h3: ({ children }) => <p style={{ margin: '0 0 6px' }}>{children}</p>,
|
||||
p: ({ children }) => <p style={{ margin: '0 0 6px' }}>{children}</p>,
|
||||
blockquote: ({ children }) => (
|
||||
<blockquote style={{
|
||||
@@ -62,7 +63,7 @@ export default function JournalBody({ text, dark }: Props) {
|
||||
},
|
||||
}}
|
||||
>
|
||||
{text}
|
||||
{text.replace(/^(.+)\n([-=]{3,})$/gm, '$1\n\n$2')}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -155,7 +155,7 @@ const JourneyMap = forwardRef<JourneyMapHandle, Props>(function JourneyMap(
|
||||
|
||||
const map = L.map(containerRef.current, {
|
||||
zoomControl: false,
|
||||
attributionControl: false,
|
||||
attributionControl: true,
|
||||
scrollWheelZoom: false,
|
||||
dragging: true,
|
||||
touchZoom: true,
|
||||
@@ -165,7 +165,11 @@ const JourneyMap = forwardRef<JourneyMapHandle, Props>(function JourneyMap(
|
||||
const defaultTile = dark
|
||||
? 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png'
|
||||
: 'https://{s}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}{r}.png'
|
||||
L.tileLayer(mapTileUrl || defaultTile, { maxZoom: 18 }).addTo(map)
|
||||
L.tileLayer(mapTileUrl || defaultTile, {
|
||||
maxZoom: 18,
|
||||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>',
|
||||
referrerPolicy: 'strict-origin-when-cross-origin',
|
||||
} as any).addTo(map)
|
||||
|
||||
const items = buildMarkerItems(entries)
|
||||
itemsRef.current = items
|
||||
|
||||
@@ -6,7 +6,7 @@ interface Props {
|
||||
dark?: boolean
|
||||
}
|
||||
|
||||
type FormatAction = { type: 'wrap'; before: string; after: string } | { type: 'line'; prefix: string }
|
||||
type FormatAction = { type: 'wrap'; before: string; after: string } | { type: 'line'; prefix: string } | { type: 'insert'; text: string }
|
||||
|
||||
const ACTIONS: Array<{ icon: typeof Bold; label: string; action: FormatAction }> = [
|
||||
{ icon: Bold, label: 'Bold', action: { type: 'wrap', before: '**', after: '**' } },
|
||||
@@ -16,7 +16,7 @@ const ACTIONS: Array<{ icon: typeof Bold; label: string; action: FormatAction }>
|
||||
{ icon: Link, label: 'Link', action: { type: 'wrap', before: '[', after: '](url)' } },
|
||||
{ icon: List, label: 'List', action: { type: 'line', prefix: '- ' } },
|
||||
{ icon: ListOrdered, label: 'Ordered', action: { type: 'line', prefix: '1. ' } },
|
||||
{ icon: Minus, label: 'Divider', action: { type: 'line', prefix: '\n---\n' } },
|
||||
{ icon: Minus, label: 'Divider', action: { type: 'insert', text: '\n\n---\n\n' } },
|
||||
]
|
||||
|
||||
export default function MarkdownToolbar({ textareaRef, onUpdate, dark }: Props) {
|
||||
@@ -35,6 +35,9 @@ export default function MarkdownToolbar({ textareaRef, onUpdate, dark }: Props)
|
||||
if (action.type === 'wrap') {
|
||||
result = text.slice(0, start) + action.before + selected + action.after + text.slice(end)
|
||||
cursorPos = selected ? end + action.before.length + action.after.length : start + action.before.length
|
||||
} else if (action.type === 'insert') {
|
||||
result = text.slice(0, start) + action.text + text.slice(end)
|
||||
cursorPos = start + action.text.length
|
||||
} else {
|
||||
// line prefix — find start of current line
|
||||
const lineStart = text.lastIndexOf('\n', start - 1) + 1
|
||||
|
||||
@@ -233,8 +233,8 @@ describe('MemoriesPanel', () => {
|
||||
http.get('/api/integrations/memories/unified/trips/:tripId/photos', () =>
|
||||
HttpResponse.json({
|
||||
photos: [
|
||||
{ asset_id: 'photo1', provider: 'immich', user_id: 1, username: 'me', shared: 1, added_at: '2025-03-01T10:00:00Z' },
|
||||
{ asset_id: 'photo2', provider: 'immich', user_id: 1, username: 'me', shared: 1, added_at: '2025-03-10T10:00:00Z' },
|
||||
{ photo_id: 1, asset_id: 'photo1', provider: 'immich', user_id: 1, username: 'me', shared: 1, added_at: '2025-03-01T10:00:00Z' },
|
||||
{ photo_id: 2, asset_id: 'photo2', provider: 'immich', user_id: 1, username: 'me', shared: 1, added_at: '2025-03-10T10:00:00Z' },
|
||||
],
|
||||
})
|
||||
),
|
||||
@@ -501,8 +501,8 @@ describe('MemoriesPanel', () => {
|
||||
http.get('/api/integrations/memories/unified/trips/:tripId/photos', () =>
|
||||
HttpResponse.json({
|
||||
photos: [
|
||||
{ asset_id: 'p1', provider: 'immich', user_id: 1, username: 'me', shared: 1, added_at: '2025-03-01T00:00:00Z', city: 'Paris' },
|
||||
{ asset_id: 'p2', provider: 'immich', user_id: 1, username: 'me', shared: 1, added_at: '2025-03-05T00:00:00Z', city: 'Lyon' },
|
||||
{ photo_id: 10, asset_id: 'p1', provider: 'immich', user_id: 1, username: 'me', shared: 1, added_at: '2025-03-01T00:00:00Z', city: 'Paris' },
|
||||
{ photo_id: 11, asset_id: 'p2', provider: 'immich', user_id: 1, username: 'me', shared: 1, added_at: '2025-03-05T00:00:00Z', city: 'Lyon' },
|
||||
],
|
||||
})
|
||||
),
|
||||
@@ -676,8 +676,8 @@ describe('MemoriesPanel', () => {
|
||||
http.get('/api/integrations/memories/unified/trips/:tripId/photos', () =>
|
||||
HttpResponse.json({
|
||||
photos: [
|
||||
{ asset_id: 'p1', provider: 'immich', user_id: 1, username: 'me', shared: 1, added_at: '2025-03-01T00:00:00Z', city: 'Paris' },
|
||||
{ asset_id: 'p2', provider: 'immich', user_id: 1, username: 'me', shared: 1, added_at: '2025-03-05T00:00:00Z', city: 'Lyon' },
|
||||
{ photo_id: 10, asset_id: 'p1', provider: 'immich', user_id: 1, username: 'me', shared: 1, added_at: '2025-03-01T00:00:00Z', city: 'Paris' },
|
||||
{ photo_id: 11, asset_id: 'p2', provider: 'immich', user_id: 1, username: 'me', shared: 1, added_at: '2025-03-05T00:00:00Z', city: 'Lyon' },
|
||||
],
|
||||
})
|
||||
),
|
||||
|
||||
@@ -30,6 +30,7 @@ function ProviderImg({ baseUrl, provider, style, loading }: { baseUrl: string; p
|
||||
// ── Types ───────────────────────────────────────────────────────────────────
|
||||
|
||||
interface TripPhoto {
|
||||
photo_id: number
|
||||
asset_id: string
|
||||
provider: string
|
||||
user_id: number
|
||||
@@ -105,19 +106,12 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
|
||||
}
|
||||
|
||||
function buildProviderAssetUrl(photo: TripPhoto, what: string): string {
|
||||
return `${ADDON_PREFIX}/${photo.provider}/assets/${tripId}/${photo.asset_id}/${photo.user_id}/${what}`
|
||||
return `/photos/${photo.photo_id}/${what}`
|
||||
}
|
||||
|
||||
function buildProviderAssetUrlFromAsset(asset: Asset, what: string, userId: number): string {
|
||||
const photo: TripPhoto = {
|
||||
asset_id: asset.id,
|
||||
provider: asset.provider,
|
||||
user_id: userId,
|
||||
username: '',
|
||||
shared: 0,
|
||||
added_at: null
|
||||
}
|
||||
return buildProviderAssetUrl(photo, what)
|
||||
// Picker photos are not yet saved — use provider-specific URL
|
||||
return `${ADDON_PREFIX}/${asset.provider}/assets/${tripId}/${asset.id}/${userId}/${what}`
|
||||
}
|
||||
|
||||
|
||||
@@ -189,7 +183,7 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
|
||||
}
|
||||
|
||||
// Lightbox
|
||||
const [lightboxId, setLightboxId] = useState<string | null>(null)
|
||||
const [lightboxId, setLightboxId] = useState<number | null>(null)
|
||||
const [lightboxUserId, setLightboxUserId] = useState<number | null>(null)
|
||||
const [lightboxInfo, setLightboxInfo] = useState<any>(null)
|
||||
const [lightboxInfoLoading, setLightboxInfoLoading] = useState(false)
|
||||
@@ -357,11 +351,10 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
|
||||
try {
|
||||
await apiClient.delete(buildUnifiedUrl('photos'), {
|
||||
data: {
|
||||
asset_id: photo.asset_id,
|
||||
provider: photo.provider,
|
||||
photo_id: photo.photo_id,
|
||||
},
|
||||
})
|
||||
setTripPhotos(prev => prev.filter(p => !(p.provider === photo.provider && p.asset_id === photo.asset_id)))
|
||||
setTripPhotos(prev => prev.filter(p => p.photo_id !== photo.photo_id))
|
||||
} catch { toast.error(t('memories.error.removePhoto')) }
|
||||
}
|
||||
|
||||
@@ -371,11 +364,10 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
|
||||
try {
|
||||
await apiClient.put(buildUnifiedUrl('photos', 'sharing'), {
|
||||
shared,
|
||||
asset_id: photo.asset_id,
|
||||
provider: photo.provider,
|
||||
photo_id: photo.photo_id,
|
||||
})
|
||||
setTripPhotos(prev => prev.map(p =>
|
||||
p.provider === photo.provider && p.asset_id === photo.asset_id ? { ...p, shared: shared ? 1 : 0 } : p
|
||||
p.photo_id === photo.photo_id ? { ...p, shared: shared ? 1 : 0 } : p
|
||||
))
|
||||
} catch { toast.error(t('memories.error.toggleSharing')) }
|
||||
}
|
||||
@@ -839,10 +831,10 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
|
||||
{allVisible.map(photo => {
|
||||
const isOwn = photo.user_id === currentUser?.id
|
||||
return (
|
||||
<div key={`${photo.provider}:${photo.asset_id}`} className="group"
|
||||
<div key={photo.photo_id} className="group"
|
||||
style={{ position: 'relative', aspectRatio: '1', borderRadius: 10, overflow: 'visible', cursor: 'pointer' }}
|
||||
onClick={() => {
|
||||
setLightboxId(photo.asset_id); setLightboxUserId(photo.user_id); setLightboxInfo(null)
|
||||
setLightboxId(photo.photo_id); setLightboxUserId(photo.user_id); setLightboxInfo(null)
|
||||
if (lightboxOriginalSrc) URL.revokeObjectURL(lightboxOriginalSrc)
|
||||
setLightboxOriginalSrc('')
|
||||
fetchImageAsBlob('/api' + buildProviderAssetUrl(photo, 'original')).then(setLightboxOriginalSrc)
|
||||
@@ -961,7 +953,7 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
|
||||
setShowMobileInfo(false)
|
||||
}
|
||||
|
||||
const currentIdx = allVisible.findIndex(p => p.asset_id === lightboxId)
|
||||
const currentIdx = allVisible.findIndex(p => p.photo_id === lightboxId)
|
||||
const hasPrev = currentIdx > 0
|
||||
const hasNext = currentIdx < allVisible.length - 1
|
||||
const navigateTo = (idx: number) => {
|
||||
@@ -969,7 +961,7 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
|
||||
if (!photo) return
|
||||
if (lightboxOriginalSrc) URL.revokeObjectURL(lightboxOriginalSrc)
|
||||
setLightboxOriginalSrc('')
|
||||
setLightboxId(photo.asset_id)
|
||||
setLightboxId(photo.photo_id)
|
||||
setLightboxUserId(photo.user_id)
|
||||
setLightboxInfo(null)
|
||||
fetchImageAsBlob('/api' + buildProviderAssetUrl(photo, 'original')).then(setLightboxOriginalSrc)
|
||||
|
||||
@@ -19,8 +19,7 @@ function abs(url: string | null | undefined): string {
|
||||
}
|
||||
|
||||
function pSrc(p: JourneyPhoto): string {
|
||||
if (p.provider === 'local') return abs(`/uploads/${p.file_path}`)
|
||||
return abs(`/api/integrations/memories/${p.provider}/assets/0/${p.asset_id}/${p.owner_id}/original`)
|
||||
return abs(`/api/photos/${p.photo_id}/original`)
|
||||
}
|
||||
|
||||
function fmtDate(d: string): string {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Trip PDF via browser print window
|
||||
import { createElement } from 'react'
|
||||
import { getCategoryIcon } from '../shared/categoryIcons'
|
||||
import { FileText, Info, Clock, MapPin, Navigation, Train, Plane, Bus, Car, Ship, Coffee, Ticket, Star, Heart, Camera, Flag, Lightbulb, AlertTriangle, ShoppingBag, Bookmark, Hotel, LogIn, LogOut, KeyRound, BedDouble, LucideIcon } from 'lucide-react'
|
||||
import { FileText, Info, Clock, MapPin, Navigation, Train, Plane, Bus, Car, Ship, Coffee, Ticket, Star, Heart, Camera, Flag, Lightbulb, AlertTriangle, ShoppingBag, Bookmark, Hotel, LogIn, LogOut, KeyRound, BedDouble, Utensils, Users, LucideIcon } from 'lucide-react'
|
||||
import { accommodationsApi, mapsApi } from '../../api/client'
|
||||
import type { Trip, Day, Place, Category, AssignmentsMap, DayNotesMap } from '../../types'
|
||||
|
||||
@@ -18,10 +18,12 @@ function noteIconSvg(iconId) {
|
||||
return renderLucideIcon(Icon, { size: 14, strokeWidth: 1.8, color: '#94a3b8' })
|
||||
}
|
||||
|
||||
const TRANSPORT_ICON_MAP = { flight: Plane, train: Train, bus: Bus, car: Car, cruise: Ship }
|
||||
function transportIconSvg(type) {
|
||||
const Icon = TRANSPORT_ICON_MAP[type] || Ticket
|
||||
return renderLucideIcon(Icon, { size: 14, strokeWidth: 1.8, color: '#3b82f6' })
|
||||
const RESERVATION_ICON_MAP = { flight: Plane, train: Train, bus: Bus, car: Car, cruise: Ship, restaurant: Utensils, event: Ticket, tour: Users, other: FileText }
|
||||
const RESERVATION_COLOR_MAP = { flight: '#3b82f6', train: '#06b6d4', bus: '#6b7280', car: '#6b7280', cruise: '#0ea5e9', restaurant: '#ef4444', event: '#f59e0b', tour: '#10b981', other: '#6b7280' }
|
||||
function reservationIconSvg(type) {
|
||||
const Icon = RESERVATION_ICON_MAP[type] || Ticket
|
||||
const color = RESERVATION_COLOR_MAP[type] || '#3b82f6'
|
||||
return renderLucideIcon(Icon, { size: 14, strokeWidth: 1.8, color })
|
||||
}
|
||||
|
||||
const ACCOMMODATION_ICON_MAP = { accommodation: Hotel, checkin: LogIn, checkout: LogOut, location: MapPin, note: FileText, confirmation: KeyRound }
|
||||
@@ -144,19 +146,18 @@ export async function downloadTripPDF({ trip, days, places, assignments, categor
|
||||
const notes = (dayNotes || []).filter(n => n.day_id === day.id)
|
||||
const cost = dayCost(assignments, day.id, loc)
|
||||
|
||||
// Transport bookings for this day
|
||||
const TRANSPORT_TYPES = new Set(['flight', 'train', 'bus', 'car', 'cruise'])
|
||||
const dayTransport = (reservations || []).filter(r => {
|
||||
if (!r.reservation_time || !TRANSPORT_TYPES.has(r.type)) return false
|
||||
// Reservations for this day (hotel rendered via accommodations block)
|
||||
const dayReservations = (reservations || []).filter(r => {
|
||||
if (!r.reservation_time || r.type === 'hotel') return false
|
||||
return day.date && r.reservation_time.split('T')[0] === day.date
|
||||
})
|
||||
|
||||
const merged = []
|
||||
assigned.forEach(a => merged.push({ type: 'place', k: a.order_index ?? a.sort_order ?? 0, data: a }))
|
||||
notes.forEach(n => merged.push({ type: 'note', k: n.sort_order ?? 0, data: n }))
|
||||
dayTransport.forEach(r => {
|
||||
dayReservations.forEach(r => {
|
||||
const pos = r.day_plan_position ?? (merged.length > 0 ? Math.max(...merged.map(m => m.k)) + 0.5 : 0.5)
|
||||
merged.push({ type: 'transport', k: pos, data: r })
|
||||
merged.push({ type: 'reservation', k: pos, data: r })
|
||||
})
|
||||
merged.sort((a, b) => a.k - b.k)
|
||||
|
||||
@@ -164,21 +165,27 @@ export async function downloadTripPDF({ trip, days, places, assignments, categor
|
||||
const itemsHtml = merged.length === 0
|
||||
? `<div class="empty-day">${escHtml(tr('dayplan.emptyDay'))}</div>`
|
||||
: merged.map(item => {
|
||||
if (item.type === 'transport') {
|
||||
if (item.type === 'reservation') {
|
||||
const r = item.data
|
||||
const meta = typeof r.metadata === 'string' ? JSON.parse(r.metadata || '{}') : (r.metadata || {})
|
||||
const icon = transportIconSvg(r.type)
|
||||
const icon = reservationIconSvg(r.type)
|
||||
const color = RESERVATION_COLOR_MAP[r.type] || '#3b82f6'
|
||||
let subtitle = ''
|
||||
if (r.type === 'flight') subtitle = [meta.airline, meta.flight_number, meta.departure_airport && meta.arrival_airport ? `${meta.departure_airport} → ${meta.arrival_airport}` : ''].filter(Boolean).join(' · ')
|
||||
else if (r.type === 'train') subtitle = [meta.train_number, meta.platform ? `Gl. ${meta.platform}` : '', meta.seat ? `Seat ${meta.seat}` : ''].filter(Boolean).join(' · ')
|
||||
else if (r.type === 'restaurant') subtitle = [meta.party_size ? `${meta.party_size} guests` : ''].filter(Boolean).join(' · ')
|
||||
else if (r.type === 'event') subtitle = [meta.venue].filter(Boolean).join(' · ')
|
||||
else if (r.type === 'tour') subtitle = [meta.operator].filter(Boolean).join(' · ')
|
||||
const locationLine = r.location || meta.location || ''
|
||||
const time = r.reservation_time?.includes('T') ? r.reservation_time.split('T')[1]?.substring(0, 5) : ''
|
||||
return `
|
||||
<div class="note-card" style="border-left: 3px solid #3b82f6;">
|
||||
<div class="note-line" style="background: #3b82f6;"></div>
|
||||
<div class="note-card" style="border-left: 3px solid ${color};">
|
||||
<div class="note-line" style="background: ${color};"></div>
|
||||
<span class="note-icon">${icon}</span>
|
||||
<div class="note-body">
|
||||
<div class="note-text" style="font-weight: 600;">${escHtml(r.title)}${time ? ` <span style="color:#6b7280;font-weight:400;font-size:10px;">${time}</span>` : ''}</div>
|
||||
${subtitle ? `<div class="note-time">${escHtml(subtitle)}</div>` : ''}
|
||||
${locationLine ? `<div class="note-time">${escHtml(locationLine)}</div>` : ''}
|
||||
${r.confirmation_number ? `<div class="note-time" style="font-size:9px;">Code: ${escHtml(r.confirmation_number)}</div>` : ''}
|
||||
</div>
|
||||
</div>`
|
||||
|
||||
@@ -286,7 +286,20 @@ export default function PlaceFormModal({
|
||||
onChange={e => handleChange('description', e.target.value)}
|
||||
rows={2}
|
||||
placeholder={t('places.formDescriptionPlaceholder')}
|
||||
className="form-input" style={{ resize: 'none' }}
|
||||
className="form-input" style={{ resize: 'vertical' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Notes */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">{t('places.formNotes')}</label>
|
||||
<textarea
|
||||
value={form.notes}
|
||||
onChange={e => handleChange('notes', e.target.value)}
|
||||
rows={3}
|
||||
maxLength={2000}
|
||||
placeholder={t('places.formNotesPlaceholder')}
|
||||
className="form-input" style={{ resize: 'vertical' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -341,9 +341,16 @@ export default function PlaceInspector({
|
||||
)}
|
||||
|
||||
{/* Description / Summary */}
|
||||
{(place.description || place.notes || googleDetails?.summary) && (
|
||||
{(place.description || googleDetails?.summary) && (
|
||||
<div className="collab-note-md" style={{ background: 'var(--bg-hover)', borderRadius: 10, overflow: 'hidden', fontSize: 12, color: 'var(--text-muted)', lineHeight: '1.5', padding: '8px 12px' }}>
|
||||
<Markdown remarkPlugins={[remarkGfm]}>{place.description || place.notes || googleDetails?.summary || ''}</Markdown>
|
||||
<Markdown remarkPlugins={[remarkGfm]}>{place.description || googleDetails?.summary || ''}</Markdown>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Notes */}
|
||||
{place.notes && (
|
||||
<div className="collab-note-md" style={{ background: 'var(--bg-hover)', borderRadius: 10, overflow: 'hidden', fontSize: 12, color: 'var(--text-muted)', lineHeight: '1.5', padding: '8px 12px' }}>
|
||||
<Markdown remarkPlugins={[remarkGfm]}>{place.notes}</Markdown>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ interface PlacesSidebarProps {
|
||||
onDeletePlace: (placeId: number) => void
|
||||
days: Day[]
|
||||
isMobile: boolean
|
||||
onCategoryFilterChange?: (categoryId: string) => void
|
||||
onCategoryFilterChange?: (categoryIds: Set<string>) => void
|
||||
onPlacesFilterChange?: (filter: string) => void
|
||||
pushUndo?: (label: string, undoFn: () => Promise<void> | void) => void
|
||||
}
|
||||
@@ -105,8 +105,7 @@ const PlacesSidebar = React.memo(function PlacesSidebar({
|
||||
setCategoryFiltersLocal(prev => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(catId)) next.delete(catId); else next.add(catId)
|
||||
// Notify parent with first selected or empty
|
||||
onCategoryFilterChange?.(next.size === 1 ? [...next][0] : '')
|
||||
onCategoryFilterChange?.(next)
|
||||
return next
|
||||
})
|
||||
}
|
||||
@@ -257,7 +256,7 @@ const PlacesSidebar = React.memo(function PlacesSidebar({
|
||||
)
|
||||
})}
|
||||
{categoryFilters.size > 0 && (
|
||||
<button onClick={() => { setCategoryFiltersLocal(new Set()); onCategoryFilterChange?.('') }} style={{
|
||||
<button onClick={() => { setCategoryFiltersLocal(new Set()); onCategoryFilterChange?.(new Set()) }} style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 4,
|
||||
width: '100%', padding: '6px 10px', borderRadius: 6, border: 'none', cursor: 'pointer',
|
||||
background: 'transparent', fontFamily: 'inherit', fontSize: 11, color: 'var(--text-faint)',
|
||||
|
||||
@@ -5,6 +5,8 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
|
||||
|
||||
// Common
|
||||
'common.save': 'حفظ',
|
||||
'common.showMore': 'عرض المزيد',
|
||||
'common.showLess': 'عرض أقل',
|
||||
'common.cancel': 'إلغاء',
|
||||
'common.delete': 'حذف',
|
||||
'common.edit': 'تعديل',
|
||||
@@ -927,6 +929,7 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
|
||||
'places.endTimeBeforeStart': 'وقت النهاية قبل وقت البداية',
|
||||
'places.timeCollision': 'تداخل في الوقت مع:',
|
||||
'places.formWebsite': 'الموقع الإلكتروني',
|
||||
'places.formNotes': 'ملاحظات',
|
||||
'places.formNotesPlaceholder': 'ملاحظات شخصية...',
|
||||
'places.formReservation': 'حجز',
|
||||
'places.reservationNotesPlaceholder': 'ملاحظات الحجز، رقم التأكيد...',
|
||||
@@ -1097,7 +1100,6 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
|
||||
'budget.settlement': 'التسوية',
|
||||
'budget.settlementInfo': 'انقر على صورة العضو في بند الميزانية لتحديده باللون الأخضر — وهذا يعني أنه دفع. ثم تُظهر التسوية من يدين لمن وبكم.',
|
||||
'budget.netBalances': 'الأرصدة الصافية',
|
||||
'budget.linkedToReservation': 'مرتبط بحجز — قم بتحرير الاسم هناك',
|
||||
|
||||
// Files
|
||||
'files.title': 'الملفات',
|
||||
@@ -1539,6 +1541,45 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
|
||||
'journey.entries.deleteTitle': 'حذف الإدخال',
|
||||
'journey.photosUploaded': 'تم رفع {count} صورة',
|
||||
'journey.photosAdded': 'تمت إضافة {count} صورة',
|
||||
'journey.picker.tripPeriod': 'فترة الرحلة',
|
||||
'journey.picker.dateRange': 'نطاق التاريخ',
|
||||
'journey.picker.allPhotos': 'كل الصور',
|
||||
'journey.picker.albums': 'ألبومات',
|
||||
'journey.picker.selected': 'محدد',
|
||||
'journey.picker.addTo': 'إضافة إلى',
|
||||
'journey.picker.newGallery': 'معرض جديد',
|
||||
'journey.picker.selectAll': 'تحديد الكل',
|
||||
'journey.picker.deselectAll': 'إلغاء تحديد الكل',
|
||||
'journey.picker.noAlbums': 'لم يتم العثور على ألبومات',
|
||||
'journey.picker.selectDate': 'اختر تاريخ',
|
||||
'journey.picker.search': 'بحث',
|
||||
|
||||
// Journey Detail
|
||||
'journey.detail.photos': 'صور',
|
||||
'journey.detail.backToJourney': 'العودة للمجلة',
|
||||
'journey.detail.day': 'اليوم {number}',
|
||||
'journey.detail.places': 'أماكن',
|
||||
'journey.skeletons.show': 'إظهار الاقتراحات',
|
||||
'journey.skeletons.hide': 'إخفاء الاقتراحات',
|
||||
|
||||
// Journey — Invite
|
||||
'journey.invite.role': 'الدور',
|
||||
'journey.invite.viewer': 'مشاهد',
|
||||
'journey.invite.editor': 'محرر',
|
||||
'journey.invite.invite': 'دعوة',
|
||||
'journey.invite.inviting': 'جارٍ الدعوة...',
|
||||
|
||||
// Journey Entry Editor
|
||||
'journey.editor.uploadPhotos': 'رفع صور',
|
||||
'journey.editor.uploading': '...جارٍ الرفع',
|
||||
'journey.editor.fromGallery': 'من المعرض',
|
||||
'journey.editor.addAnother': 'إضافة آخر',
|
||||
'journey.editor.makeFirst': 'جعله الأول',
|
||||
'journey.editor.searching': 'جارٍ البحث...',
|
||||
|
||||
// Journey — Share
|
||||
'journey.share.copy': 'نسخ',
|
||||
'journey.share.copied': 'تم النسخ!',
|
||||
|
||||
// Collab Addon
|
||||
'collab.tabs.chat': 'الدردشة',
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
const br: Record<string, string | { name: string; category: string }[]> = {
|
||||
// Common
|
||||
'common.save': 'Salvar',
|
||||
'common.showMore': 'Mostrar mais',
|
||||
'common.showLess': 'Mostrar menos',
|
||||
'common.cancel': 'Cancelar',
|
||||
'common.delete': 'Excluir',
|
||||
'common.edit': 'Editar',
|
||||
@@ -897,6 +899,7 @@ const br: Record<string, string | { name: string; category: string }[]> = {
|
||||
'places.endTimeBeforeStart': 'O horário de fim é antes do início',
|
||||
'places.timeCollision': 'Sobreposição de horário com:',
|
||||
'places.formWebsite': 'Site',
|
||||
'places.formNotes': 'Notas',
|
||||
'places.formNotesPlaceholder': 'Notas pessoais...',
|
||||
'places.formReservation': 'Reserva',
|
||||
'places.reservationNotesPlaceholder': 'Notas da reserva, código de confirmação...',
|
||||
@@ -1886,16 +1889,22 @@ const br: Record<string, string | { name: string; category: string }[]> = {
|
||||
'journey.detail.contributors': 'Colaboradores',
|
||||
'journey.detail.readMore': 'Ler mais',
|
||||
'journey.detail.prosCons': 'Prós e contras',
|
||||
'journey.detail.photos': 'fotos',
|
||||
'journey.detail.day': 'Dia {number}',
|
||||
'journey.detail.places': 'lugares',
|
||||
'journey.stats.days': 'Dias',
|
||||
'journey.stats.cities': 'Cidades',
|
||||
'journey.stats.entries': 'Entradas',
|
||||
'journey.stats.photos': 'Fotos',
|
||||
'journey.stats.places': 'Lugares',
|
||||
'journey.skeletons.show': 'Mostrar sugestões',
|
||||
'journey.skeletons.hide': 'Ocultar sugestões',
|
||||
'journey.verdict.lovedIt': 'Adorei',
|
||||
'journey.verdict.couldBeBetter': 'Poderia ser melhor',
|
||||
'journey.synced.places': 'lugares',
|
||||
'journey.synced.synced': 'sincronizado',
|
||||
'journey.editor.uploadPhotos': 'Enviar fotos',
|
||||
'journey.editor.uploading': 'Enviando...',
|
||||
'journey.editor.fromGallery': 'Da galeria',
|
||||
'journey.editor.allPhotosAdded': 'Todas as fotos já foram adicionadas',
|
||||
'journey.editor.writeStory': 'Escreva sua história...',
|
||||
@@ -1912,6 +1921,7 @@ const br: Record<string, string | { name: string; category: string }[]> = {
|
||||
'journey.editor.weather': 'Clima',
|
||||
'journey.editor.photoFirst': '1º',
|
||||
'journey.editor.makeFirst': 'Tornar 1º',
|
||||
'journey.editor.searching': 'Pesquisando...',
|
||||
'journey.mood.amazing': 'Incrível',
|
||||
'journey.mood.good': 'Bom',
|
||||
'journey.mood.neutral': 'Neutro',
|
||||
@@ -1956,6 +1966,13 @@ const br: Record<string, string | { name: string; category: string }[]> = {
|
||||
'journey.share.linkDeleted': 'Link de compartilhamento removido',
|
||||
'journey.share.deleteFailed': 'Não foi possível excluir',
|
||||
'journey.share.updateFailed': 'Não foi possível atualizar',
|
||||
|
||||
// Journey — Invite
|
||||
'journey.invite.role': 'Função',
|
||||
'journey.invite.viewer': 'Visualizador',
|
||||
'journey.invite.editor': 'Editor',
|
||||
'journey.invite.invite': 'Convidar',
|
||||
'journey.invite.inviting': 'Convidando...',
|
||||
'journey.settings.title': 'Configurações da jornada',
|
||||
'journey.settings.coverImage': 'Imagem de capa',
|
||||
'journey.settings.changeCover': 'Alterar capa',
|
||||
@@ -1986,6 +2003,18 @@ const br: Record<string, string | { name: string; category: string }[]> = {
|
||||
'journey.pdf.theEnd': 'Fim',
|
||||
'journey.pdf.saveAsPdf': 'Salvar como PDF',
|
||||
'journey.pdf.pages': 'páginas',
|
||||
'journey.picker.tripPeriod': 'Período da viagem',
|
||||
'journey.picker.dateRange': 'Período',
|
||||
'journey.picker.allPhotos': 'Todas as fotos',
|
||||
'journey.picker.albums': 'Álbuns',
|
||||
'journey.picker.selected': 'selecionados',
|
||||
'journey.picker.addTo': 'Adicionar a',
|
||||
'journey.picker.newGallery': 'Nova galeria',
|
||||
'journey.picker.selectAll': 'Selecionar tudo',
|
||||
'journey.picker.deselectAll': 'Desmarcar tudo',
|
||||
'journey.picker.noAlbums': 'Nenhum álbum encontrado',
|
||||
'journey.picker.selectDate': 'Selecionar data',
|
||||
'journey.picker.search': 'Pesquisar',
|
||||
'dashboard.greeting.morning': 'Bom dia,',
|
||||
'dashboard.greeting.afternoon': 'Boa tarde,',
|
||||
'dashboard.greeting.evening': 'Boa noite,',
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
const cs: Record<string, string | { name: string; category: string }[]> = {
|
||||
// Společné (Common)
|
||||
'common.save': 'Uložit',
|
||||
'common.showMore': 'Zobrazit více',
|
||||
'common.showLess': 'Zobrazit méně',
|
||||
'common.cancel': 'Zrušit',
|
||||
'common.delete': 'Smazat',
|
||||
'common.edit': 'Upravit',
|
||||
@@ -925,6 +927,7 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
|
||||
'places.endTimeBeforeStart': 'Čas konce je před časem začátku',
|
||||
'places.timeCollision': 'Časový překryv s:',
|
||||
'places.formWebsite': 'Webové stránky',
|
||||
'places.formNotes': 'Poznámky',
|
||||
'places.formNotesPlaceholder': 'Osobní poznámky...',
|
||||
'places.formReservation': 'Rezervace',
|
||||
'places.reservationNotesPlaceholder': 'Poznámky k rezervaci, potvrzovací kód...',
|
||||
@@ -1891,16 +1894,22 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
|
||||
'journey.detail.contributors': 'Přispěvatelé',
|
||||
'journey.detail.readMore': 'Číst dále',
|
||||
'journey.detail.prosCons': 'Klady a zápory',
|
||||
'journey.detail.photos': 'fotky',
|
||||
'journey.detail.day': 'Den {number}',
|
||||
'journey.detail.places': 'míst',
|
||||
'journey.stats.days': 'Dny',
|
||||
'journey.stats.cities': 'Města',
|
||||
'journey.stats.entries': 'Záznamy',
|
||||
'journey.stats.photos': 'Fotky',
|
||||
'journey.stats.places': 'Místa',
|
||||
'journey.skeletons.show': 'Zobrazit návrhy',
|
||||
'journey.skeletons.hide': 'Skrýt návrhy',
|
||||
'journey.verdict.lovedIt': 'Skvělé',
|
||||
'journey.verdict.couldBeBetter': 'Mohlo by být lepší',
|
||||
'journey.synced.places': 'místa',
|
||||
'journey.synced.synced': 'synchronizováno',
|
||||
'journey.editor.uploadPhotos': 'Nahrát fotky',
|
||||
'journey.editor.uploading': 'Nahrávání...',
|
||||
'journey.editor.fromGallery': 'Z galerie',
|
||||
'journey.editor.allPhotosAdded': 'Všechny fotky již přidány',
|
||||
'journey.editor.writeStory': 'Napište svůj příběh...',
|
||||
@@ -1917,6 +1926,7 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
|
||||
'journey.editor.weather': 'Počasí',
|
||||
'journey.editor.photoFirst': '1.',
|
||||
'journey.editor.makeFirst': 'Nastavit jako 1.',
|
||||
'journey.editor.searching': 'Hledání...',
|
||||
'journey.mood.amazing': 'Úžasný',
|
||||
'journey.mood.good': 'Dobrý',
|
||||
'journey.mood.neutral': 'Neutrální',
|
||||
@@ -1961,6 +1971,13 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
|
||||
'journey.share.linkDeleted': 'Odkaz ke sdílení smazán',
|
||||
'journey.share.deleteFailed': 'Smazání selhalo',
|
||||
'journey.share.updateFailed': 'Aktualizace selhala',
|
||||
|
||||
// Journey — Invite
|
||||
'journey.invite.role': 'Role',
|
||||
'journey.invite.viewer': 'Čtenář',
|
||||
'journey.invite.editor': 'Editor',
|
||||
'journey.invite.invite': 'Pozvat',
|
||||
'journey.invite.inviting': 'Zveme...',
|
||||
'journey.settings.title': 'Nastavení cestovního deníku',
|
||||
'journey.settings.coverImage': 'Titulní obrázek',
|
||||
'journey.settings.changeCover': 'Změnit obal',
|
||||
@@ -1991,6 +2008,18 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
|
||||
'journey.pdf.theEnd': 'Konec',
|
||||
'journey.pdf.saveAsPdf': 'Uložit jako PDF',
|
||||
'journey.pdf.pages': 'stran',
|
||||
'journey.picker.tripPeriod': 'Období cesty',
|
||||
'journey.picker.dateRange': 'Časové období',
|
||||
'journey.picker.allPhotos': 'Všechny fotky',
|
||||
'journey.picker.albums': 'Alba',
|
||||
'journey.picker.selected': 'vybráno',
|
||||
'journey.picker.addTo': 'Přidat do',
|
||||
'journey.picker.newGallery': 'Nová galerie',
|
||||
'journey.picker.selectAll': 'Vybrat vše',
|
||||
'journey.picker.deselectAll': 'Zrušit výběr',
|
||||
'journey.picker.noAlbums': 'Žádná alba nenalezena',
|
||||
'journey.picker.selectDate': 'Vyberte datum',
|
||||
'journey.picker.search': 'Hledat',
|
||||
'dashboard.greeting.morning': 'Dobré ráno,',
|
||||
'dashboard.greeting.afternoon': 'Dobré odpoledne,',
|
||||
'dashboard.greeting.evening': 'Dobrý večer,',
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
const de: Record<string, string | { name: string; category: string }[]> = {
|
||||
// Allgemein
|
||||
'common.save': 'Speichern',
|
||||
'common.showMore': 'Mehr anzeigen',
|
||||
'common.showLess': 'Weniger anzeigen',
|
||||
'common.cancel': 'Abbrechen',
|
||||
'common.delete': 'Löschen',
|
||||
'common.edit': 'Bearbeiten',
|
||||
@@ -928,6 +930,7 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
||||
'places.endTimeBeforeStart': 'Endzeit liegt vor der Startzeit',
|
||||
'places.timeCollision': 'Zeitliche Überschneidung mit:',
|
||||
'places.formWebsite': 'Website',
|
||||
'places.formNotes': 'Notizen',
|
||||
'places.formNotesPlaceholder': 'Persönliche Notizen...',
|
||||
'places.formReservation': 'Reservierung',
|
||||
'places.reservationNotesPlaceholder': 'Reservierungsnotizen, Bestätigungsnummer...',
|
||||
@@ -1892,16 +1895,22 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
||||
'journey.detail.contributors': 'Mitwirkende',
|
||||
'journey.detail.readMore': 'Mehr lesen',
|
||||
'journey.detail.prosCons': 'Pro & Contra',
|
||||
'journey.detail.photos': 'Fotos',
|
||||
'journey.detail.day': 'Tag {number}',
|
||||
'journey.detail.places': 'Orte',
|
||||
'journey.stats.days': 'Tage',
|
||||
'journey.stats.cities': 'Städte',
|
||||
'journey.stats.entries': 'Einträge',
|
||||
'journey.stats.photos': 'Fotos',
|
||||
'journey.stats.places': 'Orte',
|
||||
'journey.skeletons.show': 'Vorschläge anzeigen',
|
||||
'journey.skeletons.hide': 'Vorschläge ausblenden',
|
||||
'journey.verdict.lovedIt': 'Toll',
|
||||
'journey.verdict.couldBeBetter': 'Verbesserungswürdig',
|
||||
'journey.synced.places': 'Orte',
|
||||
'journey.synced.synced': 'synchronisiert',
|
||||
'journey.editor.uploadPhotos': 'Fotos hochladen',
|
||||
'journey.editor.uploading': 'Hochladen...',
|
||||
'journey.editor.fromGallery': 'Aus Galerie',
|
||||
'journey.editor.allPhotosAdded': 'Alle Fotos bereits hinzugefügt',
|
||||
'journey.editor.writeStory': 'Erzähle deine Geschichte...',
|
||||
@@ -1918,6 +1927,7 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
||||
'journey.editor.weather': 'Wetter',
|
||||
'journey.editor.photoFirst': '1.',
|
||||
'journey.editor.makeFirst': 'Als 1. setzen',
|
||||
'journey.editor.searching': 'Suche...',
|
||||
'journey.mood.amazing': 'Großartig',
|
||||
'journey.mood.good': 'Gut',
|
||||
'journey.mood.neutral': 'Neutral',
|
||||
@@ -1962,6 +1972,13 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
||||
'journey.share.linkDeleted': 'Link entfernt',
|
||||
'journey.share.deleteFailed': 'Entfernen fehlgeschlagen',
|
||||
'journey.share.updateFailed': 'Aktualisierung fehlgeschlagen',
|
||||
|
||||
// Journey — Invite
|
||||
'journey.invite.role': 'Rolle',
|
||||
'journey.invite.viewer': 'Betrachter',
|
||||
'journey.invite.editor': 'Bearbeiter',
|
||||
'journey.invite.invite': 'Einladen',
|
||||
'journey.invite.inviting': 'Wird eingeladen...',
|
||||
'journey.settings.title': 'Journey-Einstellungen',
|
||||
'journey.settings.coverImage': 'Titelbild',
|
||||
'journey.settings.changeCover': 'Titelbild ändern',
|
||||
@@ -1992,6 +2009,18 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
||||
'journey.pdf.theEnd': 'Ende',
|
||||
'journey.pdf.saveAsPdf': 'Als PDF speichern',
|
||||
'journey.pdf.pages': 'Seiten',
|
||||
'journey.picker.tripPeriod': 'Reisezeitraum',
|
||||
'journey.picker.dateRange': 'Zeitraum',
|
||||
'journey.picker.allPhotos': 'Alle Fotos',
|
||||
'journey.picker.albums': 'Alben',
|
||||
'journey.picker.selected': 'ausgewählt',
|
||||
'journey.picker.addTo': 'Hinzufügen zu',
|
||||
'journey.picker.newGallery': 'Neue Galerie',
|
||||
'journey.picker.selectAll': 'Alle auswählen',
|
||||
'journey.picker.deselectAll': 'Alle abwählen',
|
||||
'journey.picker.noAlbums': 'Keine Alben gefunden',
|
||||
'journey.picker.selectDate': 'Datum wählen',
|
||||
'journey.picker.search': 'Suchen',
|
||||
'dashboard.greeting.morning': 'Guten Morgen,',
|
||||
'dashboard.greeting.afternoon': 'Guten Tag,',
|
||||
'dashboard.greeting.evening': 'Guten Abend,',
|
||||
@@ -2027,14 +2056,6 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
||||
'dayplan.mobile.allAssigned': 'Alle Orte zugeordnet',
|
||||
'dayplan.mobile.noMatch': 'Kein Treffer',
|
||||
'dayplan.mobile.createNew': 'Neuen Ort erstellen',
|
||||
'memories.notConnectedMultipleHint': 'Connect any of these photo providers: {provider_names} in Settings to be able add photos to this trip.',
|
||||
'memories.providerUrl': 'Server URL',
|
||||
'memories.providerApiKey': 'API Key',
|
||||
'memories.providerUsername': 'Username',
|
||||
'memories.providerPassword': 'Password',
|
||||
'memories.saveError': 'Could not save {provider_name} settings',
|
||||
'memories.selectAlbumMultiple': 'Select Album',
|
||||
'memories.selectPhotosMultiple': 'Select Photos',
|
||||
|
||||
// OAuth scope groups
|
||||
'oauth.scope.group.trips': 'Reisen',
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
const en: Record<string, string | { name: string; category: string }[]> = {
|
||||
// Common
|
||||
'common.save': 'Save',
|
||||
'common.showMore': 'Show more',
|
||||
'common.showLess': 'Show less',
|
||||
'common.cancel': 'Cancel',
|
||||
'common.delete': 'Delete',
|
||||
'common.edit': 'Edit',
|
||||
@@ -950,6 +952,7 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
||||
'places.endTimeBeforeStart': 'End time is before start time',
|
||||
'places.timeCollision': 'Time overlap with:',
|
||||
'places.formWebsite': 'Website',
|
||||
'places.formNotes': 'Notes',
|
||||
'places.formNotesPlaceholder': 'Personal notes...',
|
||||
'places.formReservation': 'Reservation',
|
||||
'places.reservationNotesPlaceholder': 'Reservation notes, confirmation number...',
|
||||
@@ -1895,6 +1898,9 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
||||
'journey.detail.contributors': 'Contributors',
|
||||
'journey.detail.readMore': 'Read more',
|
||||
'journey.detail.prosCons': 'Pros & Cons',
|
||||
'journey.detail.photos': 'photos',
|
||||
'journey.detail.day': 'Day {number}',
|
||||
'journey.detail.places': 'places',
|
||||
|
||||
// Journey Detail — Stats
|
||||
'journey.stats.days': 'Days',
|
||||
@@ -1902,6 +1908,8 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
||||
'journey.stats.entries': 'Entries',
|
||||
'journey.stats.photos': 'Photos',
|
||||
'journey.stats.places': 'Places',
|
||||
'journey.skeletons.show': 'Show suggestions',
|
||||
'journey.skeletons.hide': 'Hide suggestions',
|
||||
|
||||
// Journey Detail — Verdict
|
||||
'journey.verdict.lovedIt': 'Loved it',
|
||||
@@ -1913,6 +1921,7 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
||||
|
||||
// Journey Entry Editor
|
||||
'journey.editor.uploadPhotos': 'Upload photos',
|
||||
'journey.editor.uploading': 'Uploading...',
|
||||
'journey.editor.fromGallery': 'From Gallery',
|
||||
'journey.editor.allPhotosAdded': 'All photos already added',
|
||||
'journey.editor.writeStory': 'Write your story...',
|
||||
@@ -1929,6 +1938,7 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
||||
'journey.editor.weather': 'Weather',
|
||||
'journey.editor.photoFirst': '1st',
|
||||
'journey.editor.makeFirst': 'Make 1st',
|
||||
'journey.editor.searching': 'Searching...',
|
||||
|
||||
// Journey Entry — Moods
|
||||
'journey.mood.amazing': 'Amazing',
|
||||
@@ -1984,6 +1994,13 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
||||
'journey.share.deleteFailed': 'Failed to delete',
|
||||
'journey.share.updateFailed': 'Failed to update',
|
||||
|
||||
// Journey — Invite
|
||||
'journey.invite.role': 'Role',
|
||||
'journey.invite.viewer': 'Viewer',
|
||||
'journey.invite.editor': 'Editor',
|
||||
'journey.invite.invite': 'Invite',
|
||||
'journey.invite.inviting': 'Inviting...',
|
||||
|
||||
// Journey — Settings Dialog
|
||||
'journey.settings.title': 'Journey Settings',
|
||||
'journey.settings.coverImage': 'Cover Image',
|
||||
@@ -2019,6 +2036,18 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
||||
'journey.pdf.theEnd': 'The End',
|
||||
'journey.pdf.saveAsPdf': 'Save as PDF',
|
||||
'journey.pdf.pages': 'pages',
|
||||
'journey.picker.tripPeriod': 'Trip Period',
|
||||
'journey.picker.dateRange': 'Date Range',
|
||||
'journey.picker.allPhotos': 'All Photos',
|
||||
'journey.picker.albums': 'Albums',
|
||||
'journey.picker.selected': 'selected',
|
||||
'journey.picker.addTo': 'Add to',
|
||||
'journey.picker.newGallery': 'New Gallery',
|
||||
'journey.picker.selectAll': 'Select all',
|
||||
'journey.picker.deselectAll': 'Deselect all',
|
||||
'journey.picker.noAlbums': 'No albums found',
|
||||
'journey.picker.selectDate': 'Select date',
|
||||
'journey.picker.search': 'Search',
|
||||
|
||||
// Dashboard Mobile
|
||||
'dashboard.greeting.morning': 'Good morning,',
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
const es: Record<string, string> = {
|
||||
// Common
|
||||
'common.save': 'Guardar',
|
||||
'common.showMore': 'Ver más',
|
||||
'common.showLess': 'Ver menos',
|
||||
'common.cancel': 'Cancelar',
|
||||
'common.delete': 'Eliminar',
|
||||
'common.edit': 'Editar',
|
||||
@@ -900,6 +902,7 @@ const es: Record<string, string> = {
|
||||
'places.endTimeBeforeStart': 'La hora de fin es anterior a la de inicio',
|
||||
'places.timeCollision': 'Solapamiento horario con:',
|
||||
'places.formWebsite': 'Página web',
|
||||
'places.formNotes': 'Notas',
|
||||
'places.formNotesPlaceholder': 'Notas personales...',
|
||||
'places.formReservation': 'Reserva',
|
||||
'places.reservationNotesPlaceholder': 'Notas de reserva, número de confirmación...',
|
||||
@@ -1893,16 +1896,22 @@ const es: Record<string, string> = {
|
||||
'journey.detail.contributors': 'Colaboradores',
|
||||
'journey.detail.readMore': 'Leer más',
|
||||
'journey.detail.prosCons': 'Pros y contras',
|
||||
'journey.detail.photos': 'fotos',
|
||||
'journey.detail.day': 'Día {number}',
|
||||
'journey.detail.places': 'lugares',
|
||||
'journey.stats.days': 'Días',
|
||||
'journey.stats.cities': 'Ciudades',
|
||||
'journey.stats.entries': 'Entradas',
|
||||
'journey.stats.photos': 'Fotos',
|
||||
'journey.stats.places': 'Lugares',
|
||||
'journey.skeletons.show': 'Mostrar sugerencias',
|
||||
'journey.skeletons.hide': 'Ocultar sugerencias',
|
||||
'journey.verdict.lovedIt': 'Me encantó',
|
||||
'journey.verdict.couldBeBetter': 'Podría mejorar',
|
||||
'journey.synced.places': 'lugares',
|
||||
'journey.synced.synced': 'sincronizado',
|
||||
'journey.editor.uploadPhotos': 'Subir fotos',
|
||||
'journey.editor.uploading': 'Subiendo...',
|
||||
'journey.editor.fromGallery': 'Desde galería',
|
||||
'journey.editor.allPhotosAdded': 'Todas las fotos ya fueron añadidas',
|
||||
'journey.editor.writeStory': 'Escribe tu historia...',
|
||||
@@ -1919,6 +1928,7 @@ const es: Record<string, string> = {
|
||||
'journey.editor.weather': 'Clima',
|
||||
'journey.editor.photoFirst': '1º',
|
||||
'journey.editor.makeFirst': 'Hacer 1º',
|
||||
'journey.editor.searching': 'Buscando...',
|
||||
'journey.mood.amazing': 'Increíble',
|
||||
'journey.mood.good': 'Bien',
|
||||
'journey.mood.neutral': 'Neutral',
|
||||
@@ -1963,6 +1973,13 @@ const es: Record<string, string> = {
|
||||
'journey.share.linkDeleted': 'Enlace para compartir eliminado',
|
||||
'journey.share.deleteFailed': 'No se pudo eliminar',
|
||||
'journey.share.updateFailed': 'No se pudo actualizar',
|
||||
|
||||
// Journey — Invite
|
||||
'journey.invite.role': 'Rol',
|
||||
'journey.invite.viewer': 'Lector',
|
||||
'journey.invite.editor': 'Editor',
|
||||
'journey.invite.invite': 'Invitar',
|
||||
'journey.invite.inviting': 'Invitando...',
|
||||
'journey.settings.title': 'Ajustes de la travesía',
|
||||
'journey.settings.coverImage': 'Imagen de portada',
|
||||
'journey.settings.changeCover': 'Cambiar portada',
|
||||
@@ -1993,6 +2010,18 @@ const es: Record<string, string> = {
|
||||
'journey.pdf.theEnd': 'Fin',
|
||||
'journey.pdf.saveAsPdf': 'Guardar como PDF',
|
||||
'journey.pdf.pages': 'páginas',
|
||||
'journey.picker.tripPeriod': 'Período del viaje',
|
||||
'journey.picker.dateRange': 'Rango de fechas',
|
||||
'journey.picker.allPhotos': 'Todas las fotos',
|
||||
'journey.picker.albums': 'Álbumes',
|
||||
'journey.picker.selected': 'seleccionados',
|
||||
'journey.picker.addTo': 'Añadir a',
|
||||
'journey.picker.newGallery': 'Nueva galería',
|
||||
'journey.picker.selectAll': 'Seleccionar todo',
|
||||
'journey.picker.deselectAll': 'Deseleccionar todo',
|
||||
'journey.picker.noAlbums': 'No se encontraron álbumes',
|
||||
'journey.picker.selectDate': 'Seleccionar fecha',
|
||||
'journey.picker.search': 'Buscar',
|
||||
'dashboard.greeting.morning': 'Buenos días,',
|
||||
'dashboard.greeting.afternoon': 'Buenas tardes,',
|
||||
'dashboard.greeting.evening': 'Buenas noches,',
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
const fr: Record<string, string> = {
|
||||
// Common
|
||||
'common.save': 'Enregistrer',
|
||||
'common.showMore': 'Voir plus',
|
||||
'common.showLess': 'Voir moins',
|
||||
'common.cancel': 'Annuler',
|
||||
'common.delete': 'Supprimer',
|
||||
'common.edit': 'Modifier',
|
||||
@@ -924,6 +926,7 @@ const fr: Record<string, string> = {
|
||||
'places.endTimeBeforeStart': 'L\'heure de fin est antérieure à l\'heure de début',
|
||||
'places.timeCollision': 'Chevauchement horaire avec :',
|
||||
'places.formWebsite': 'Site web',
|
||||
'places.formNotes': 'Notes',
|
||||
'places.formNotesPlaceholder': 'Notes personnelles…',
|
||||
'places.formReservation': 'Réservation',
|
||||
'places.reservationNotesPlaceholder': 'Notes de réservation, numéro de confirmation…',
|
||||
@@ -1887,16 +1890,22 @@ const fr: Record<string, string> = {
|
||||
'journey.detail.contributors': 'Contributeurs',
|
||||
'journey.detail.readMore': 'Lire la suite',
|
||||
'journey.detail.prosCons': 'Pour et contre',
|
||||
'journey.detail.photos': 'photos',
|
||||
'journey.detail.day': 'Jour {number}',
|
||||
'journey.detail.places': 'lieux',
|
||||
'journey.stats.days': 'Jours',
|
||||
'journey.stats.cities': 'Villes',
|
||||
'journey.stats.entries': 'Entrées',
|
||||
'journey.stats.photos': 'Photos',
|
||||
'journey.stats.places': 'Lieux',
|
||||
'journey.skeletons.show': 'Afficher les suggestions',
|
||||
'journey.skeletons.hide': 'Masquer les suggestions',
|
||||
'journey.verdict.lovedIt': 'Adoré',
|
||||
'journey.verdict.couldBeBetter': 'Pourrait être mieux',
|
||||
'journey.synced.places': 'lieux',
|
||||
'journey.synced.synced': 'synchronisé',
|
||||
'journey.editor.uploadPhotos': 'Téléverser des photos',
|
||||
'journey.editor.uploading': 'Envoi...',
|
||||
'journey.editor.fromGallery': 'Depuis la galerie',
|
||||
'journey.editor.allPhotosAdded': 'Toutes les photos ont déjà été ajoutées',
|
||||
'journey.editor.writeStory': 'Écrivez votre histoire...',
|
||||
@@ -1913,6 +1922,7 @@ const fr: Record<string, string> = {
|
||||
'journey.editor.weather': 'Météo',
|
||||
'journey.editor.photoFirst': '1er',
|
||||
'journey.editor.makeFirst': 'Mettre en 1er',
|
||||
'journey.editor.searching': 'Recherche...',
|
||||
'journey.mood.amazing': 'Incroyable',
|
||||
'journey.mood.good': 'Bien',
|
||||
'journey.mood.neutral': 'Neutre',
|
||||
@@ -1957,6 +1967,13 @@ const fr: Record<string, string> = {
|
||||
'journey.share.linkDeleted': 'Lien de partage supprimé',
|
||||
'journey.share.deleteFailed': 'Échec de la suppression',
|
||||
'journey.share.updateFailed': 'Échec de la mise à jour',
|
||||
|
||||
// Journey — Invite
|
||||
'journey.invite.role': 'Rôle',
|
||||
'journey.invite.viewer': 'Lecteur',
|
||||
'journey.invite.editor': 'Éditeur',
|
||||
'journey.invite.invite': 'Inviter',
|
||||
'journey.invite.inviting': 'Invitation...',
|
||||
'journey.settings.title': 'Paramètres du journal',
|
||||
'journey.settings.coverImage': 'Image de couverture',
|
||||
'journey.settings.changeCover': 'Changer la couverture',
|
||||
@@ -1987,6 +2004,18 @@ const fr: Record<string, string> = {
|
||||
'journey.pdf.theEnd': 'Fin',
|
||||
'journey.pdf.saveAsPdf': 'Enregistrer en PDF',
|
||||
'journey.pdf.pages': 'pages',
|
||||
'journey.picker.tripPeriod': 'Période du voyage',
|
||||
'journey.picker.dateRange': 'Plage de dates',
|
||||
'journey.picker.allPhotos': 'Toutes les photos',
|
||||
'journey.picker.albums': 'Albums',
|
||||
'journey.picker.selected': 'sélectionnés',
|
||||
'journey.picker.addTo': 'Ajouter à',
|
||||
'journey.picker.newGallery': 'Nouvelle galerie',
|
||||
'journey.picker.selectAll': 'Tout sélectionner',
|
||||
'journey.picker.deselectAll': 'Tout désélectionner',
|
||||
'journey.picker.noAlbums': 'Aucun album trouvé',
|
||||
'journey.picker.selectDate': 'Sélectionner une date',
|
||||
'journey.picker.search': 'Rechercher',
|
||||
'dashboard.greeting.morning': 'Bonjour,',
|
||||
'dashboard.greeting.afternoon': 'Bon après-midi,',
|
||||
'dashboard.greeting.evening': 'Bonsoir,',
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
const hu: Record<string, string | { name: string; category: string }[]> = {
|
||||
// Általános
|
||||
'common.save': 'Mentés',
|
||||
'common.showMore': 'Továbbiak',
|
||||
'common.showLess': 'Kevesebb',
|
||||
'common.cancel': 'Mégse',
|
||||
'common.delete': 'Törlés',
|
||||
'common.edit': 'Szerkesztés',
|
||||
@@ -925,6 +927,7 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
|
||||
'places.endTimeBeforeStart': 'A befejezési idő a kezdési idő előtt van',
|
||||
'places.timeCollision': 'Időbeli átfedés:',
|
||||
'places.formWebsite': 'Weboldal',
|
||||
'places.formNotes': 'Jegyzetek',
|
||||
'places.formNotesPlaceholder': 'Személyes jegyzetek...',
|
||||
'places.formReservation': 'Foglalás',
|
||||
'places.reservationNotesPlaceholder': 'Foglalási jegyzetek, visszaigazolási szám...',
|
||||
@@ -1888,16 +1891,22 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
|
||||
'journey.detail.contributors': 'Közreműködők',
|
||||
'journey.detail.readMore': 'Tovább olvasás',
|
||||
'journey.detail.prosCons': 'Előnyök és hátrányok',
|
||||
'journey.detail.photos': 'fotók',
|
||||
'journey.detail.day': '{number}. nap',
|
||||
'journey.detail.places': 'helyek',
|
||||
'journey.stats.days': 'Napok',
|
||||
'journey.stats.cities': 'Városok',
|
||||
'journey.stats.entries': 'Bejegyzések',
|
||||
'journey.stats.photos': 'Fotók',
|
||||
'journey.stats.places': 'Helyszínek',
|
||||
'journey.skeletons.show': 'Javaslatok megjelenítése',
|
||||
'journey.skeletons.hide': 'Javaslatok elrejtése',
|
||||
'journey.verdict.lovedIt': 'Imádtam',
|
||||
'journey.verdict.couldBeBetter': 'Lehetne jobb',
|
||||
'journey.synced.places': 'helyszín',
|
||||
'journey.synced.synced': 'szinkronizálva',
|
||||
'journey.editor.uploadPhotos': 'Fotók feltöltése',
|
||||
'journey.editor.uploading': 'Feltöltés...',
|
||||
'journey.editor.fromGallery': 'Galériából',
|
||||
'journey.editor.allPhotosAdded': 'Minden fotó már hozzáadva',
|
||||
'journey.editor.writeStory': 'Írd meg a történeted...',
|
||||
@@ -1914,6 +1923,7 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
|
||||
'journey.editor.weather': 'Időjárás',
|
||||
'journey.editor.photoFirst': '1.',
|
||||
'journey.editor.makeFirst': 'Legyen az 1.',
|
||||
'journey.editor.searching': 'Keresés...',
|
||||
'journey.mood.amazing': 'Fantasztikus',
|
||||
'journey.mood.good': 'Jó',
|
||||
'journey.mood.neutral': 'Semleges',
|
||||
@@ -1958,6 +1968,13 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
|
||||
'journey.share.linkDeleted': 'Megosztó link törölve',
|
||||
'journey.share.deleteFailed': 'Nem sikerült törölni',
|
||||
'journey.share.updateFailed': 'Nem sikerült frissíteni',
|
||||
|
||||
// Journey — Invite
|
||||
'journey.invite.role': 'Szerepkör',
|
||||
'journey.invite.viewer': 'Megtekintő',
|
||||
'journey.invite.editor': 'Szerkesztő',
|
||||
'journey.invite.invite': 'Meghívás',
|
||||
'journey.invite.inviting': 'Meghívás...',
|
||||
'journey.settings.title': 'Útinapló beállításai',
|
||||
'journey.settings.coverImage': 'Borítókép',
|
||||
'journey.settings.changeCover': 'Borító módosítása',
|
||||
@@ -1988,6 +2005,18 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
|
||||
'journey.pdf.theEnd': 'Vége',
|
||||
'journey.pdf.saveAsPdf': 'Mentés PDF-ként',
|
||||
'journey.pdf.pages': 'oldal',
|
||||
'journey.picker.tripPeriod': 'Utazási időszak',
|
||||
'journey.picker.dateRange': 'Időszak',
|
||||
'journey.picker.allPhotos': 'Összes fotó',
|
||||
'journey.picker.albums': 'Albumok',
|
||||
'journey.picker.selected': 'kiválasztva',
|
||||
'journey.picker.addTo': 'Hozzáadás',
|
||||
'journey.picker.newGallery': 'Új galéria',
|
||||
'journey.picker.selectAll': 'Összes kijelölése',
|
||||
'journey.picker.deselectAll': 'Összes kijelölés törlése',
|
||||
'journey.picker.noAlbums': 'Nem található album',
|
||||
'journey.picker.selectDate': 'Dátum választása',
|
||||
'journey.picker.search': 'Keresés',
|
||||
'dashboard.greeting.morning': 'Jó reggelt,',
|
||||
'dashboard.greeting.afternoon': 'Jó napot,',
|
||||
'dashboard.greeting.evening': 'Jó estét,',
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
const it: Record<string, string | { name: string; category: string }[]> = {
|
||||
// Common
|
||||
'common.save': 'Salva',
|
||||
'common.showMore': 'Mostra di più',
|
||||
'common.showLess': 'Mostra meno',
|
||||
'common.cancel': 'Annulla',
|
||||
'common.delete': 'Elimina',
|
||||
'common.edit': 'Modifica',
|
||||
@@ -925,6 +927,7 @@ const it: Record<string, string | { name: string; category: string }[]> = {
|
||||
'places.endTimeBeforeStart': 'L\'ora di fine è precedente all\'ora di inizio',
|
||||
'places.timeCollision': 'Sovrapposizione di orario con:',
|
||||
'places.formWebsite': 'Sito web',
|
||||
'places.formNotes': 'Note',
|
||||
'places.formNotesPlaceholder': 'Note personali...',
|
||||
'places.formReservation': 'Prenotazione',
|
||||
'places.reservationNotesPlaceholder': 'Note della prenotazione, numero di conferma...',
|
||||
@@ -1888,16 +1891,22 @@ const it: Record<string, string | { name: string; category: string }[]> = {
|
||||
'journey.detail.contributors': 'Contributori',
|
||||
'journey.detail.readMore': 'Leggi di più',
|
||||
'journey.detail.prosCons': 'Pro e contro',
|
||||
'journey.detail.photos': 'foto',
|
||||
'journey.detail.day': 'Giorno {number}',
|
||||
'journey.detail.places': 'luoghi',
|
||||
'journey.stats.days': 'Giorni',
|
||||
'journey.stats.cities': 'Città',
|
||||
'journey.stats.entries': 'Voci',
|
||||
'journey.stats.photos': 'Foto',
|
||||
'journey.stats.places': 'Luoghi',
|
||||
'journey.skeletons.show': 'Mostra suggerimenti',
|
||||
'journey.skeletons.hide': 'Nascondi suggerimenti',
|
||||
'journey.verdict.lovedIt': 'Adorato',
|
||||
'journey.verdict.couldBeBetter': 'Potrebbe essere meglio',
|
||||
'journey.synced.places': 'luoghi',
|
||||
'journey.synced.synced': 'sincronizzato',
|
||||
'journey.editor.uploadPhotos': 'Carica foto',
|
||||
'journey.editor.uploading': 'Caricamento...',
|
||||
'journey.editor.fromGallery': 'Dalla galleria',
|
||||
'journey.editor.allPhotosAdded': 'Tutte le foto sono già state aggiunte',
|
||||
'journey.editor.writeStory': 'Scrivi la tua storia...',
|
||||
@@ -1914,6 +1923,7 @@ const it: Record<string, string | { name: string; category: string }[]> = {
|
||||
'journey.editor.weather': 'Meteo',
|
||||
'journey.editor.photoFirst': '1°',
|
||||
'journey.editor.makeFirst': 'Metti 1°',
|
||||
'journey.editor.searching': 'Ricerca...',
|
||||
'journey.mood.amazing': 'Fantastico',
|
||||
'journey.mood.good': 'Buono',
|
||||
'journey.mood.neutral': 'Neutro',
|
||||
@@ -1958,6 +1968,13 @@ const it: Record<string, string | { name: string; category: string }[]> = {
|
||||
'journey.share.linkDeleted': 'Link di condivisione eliminato',
|
||||
'journey.share.deleteFailed': 'Eliminazione fallita',
|
||||
'journey.share.updateFailed': 'Aggiornamento fallito',
|
||||
|
||||
// Journey — Invite
|
||||
'journey.invite.role': 'Ruolo',
|
||||
'journey.invite.viewer': 'Visualizzatore',
|
||||
'journey.invite.editor': 'Editore',
|
||||
'journey.invite.invite': 'Invita',
|
||||
'journey.invite.inviting': 'Invito in corso...',
|
||||
'journey.settings.title': 'Impostazioni del diario',
|
||||
'journey.settings.coverImage': 'Immagine di copertina',
|
||||
'journey.settings.changeCover': 'Cambia copertina',
|
||||
@@ -1988,6 +2005,18 @@ const it: Record<string, string | { name: string; category: string }[]> = {
|
||||
'journey.pdf.theEnd': 'Fine',
|
||||
'journey.pdf.saveAsPdf': 'Salva come PDF',
|
||||
'journey.pdf.pages': 'pagine',
|
||||
'journey.picker.tripPeriod': 'Periodo del viaggio',
|
||||
'journey.picker.dateRange': 'Intervallo di date',
|
||||
'journey.picker.allPhotos': 'Tutte le foto',
|
||||
'journey.picker.albums': 'Album',
|
||||
'journey.picker.selected': 'selezionati',
|
||||
'journey.picker.addTo': 'Aggiungi a',
|
||||
'journey.picker.newGallery': 'Nuova galleria',
|
||||
'journey.picker.selectAll': 'Seleziona tutto',
|
||||
'journey.picker.deselectAll': 'Deseleziona tutto',
|
||||
'journey.picker.noAlbums': 'Nessun album trovato',
|
||||
'journey.picker.selectDate': 'Seleziona data',
|
||||
'journey.picker.search': 'Cerca',
|
||||
'dashboard.greeting.morning': 'Buongiorno,',
|
||||
'dashboard.greeting.afternoon': 'Buon pomeriggio,',
|
||||
'dashboard.greeting.evening': 'Buonasera,',
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
const nl: Record<string, string> = {
|
||||
// Common
|
||||
'common.save': 'Opslaan',
|
||||
'common.showMore': 'Meer tonen',
|
||||
'common.showLess': 'Minder tonen',
|
||||
'common.cancel': 'Annuleren',
|
||||
'common.delete': 'Verwijderen',
|
||||
'common.edit': 'Bewerken',
|
||||
@@ -924,6 +926,7 @@ const nl: Record<string, string> = {
|
||||
'places.endTimeBeforeStart': 'Eindtijd is vóór de starttijd',
|
||||
'places.timeCollision': 'Tijdoverlap met:',
|
||||
'places.formWebsite': 'Website',
|
||||
'places.formNotes': 'Notities',
|
||||
'places.formNotesPlaceholder': 'Persoonlijke notities...',
|
||||
'places.formReservation': 'Reservering',
|
||||
'places.reservationNotesPlaceholder': 'Reserveringsnotities, bevestigingsnummer...',
|
||||
@@ -1887,16 +1890,22 @@ const nl: Record<string, string> = {
|
||||
'journey.detail.contributors': 'Bijdragers',
|
||||
'journey.detail.readMore': 'Lees meer',
|
||||
'journey.detail.prosCons': 'Voor- & nadelen',
|
||||
'journey.detail.photos': 'foto\'s',
|
||||
'journey.detail.day': 'Dag {number}',
|
||||
'journey.detail.places': 'plaatsen',
|
||||
'journey.stats.days': 'Dagen',
|
||||
'journey.stats.cities': 'Steden',
|
||||
'journey.stats.entries': 'Vermeldingen',
|
||||
'journey.stats.photos': 'Foto\'s',
|
||||
'journey.stats.places': 'Plaatsen',
|
||||
'journey.skeletons.show': 'Suggesties tonen',
|
||||
'journey.skeletons.hide': 'Suggesties verbergen',
|
||||
'journey.verdict.lovedIt': 'Geweldig',
|
||||
'journey.verdict.couldBeBetter': 'Kan beter',
|
||||
'journey.synced.places': 'plaatsen',
|
||||
'journey.synced.synced': 'gesynchroniseerd',
|
||||
'journey.editor.uploadPhotos': 'Foto\'s uploaden',
|
||||
'journey.editor.uploading': 'Uploaden...',
|
||||
'journey.editor.fromGallery': 'Uit galerij',
|
||||
'journey.editor.allPhotosAdded': 'Alle foto\'s al toegevoegd',
|
||||
'journey.editor.writeStory': 'Schrijf je verhaal...',
|
||||
@@ -1913,6 +1922,7 @@ const nl: Record<string, string> = {
|
||||
'journey.editor.weather': 'Weer',
|
||||
'journey.editor.photoFirst': '1e',
|
||||
'journey.editor.makeFirst': 'Maak 1e',
|
||||
'journey.editor.searching': 'Zoeken...',
|
||||
'journey.mood.amazing': 'Fantastisch',
|
||||
'journey.mood.good': 'Goed',
|
||||
'journey.mood.neutral': 'Neutraal',
|
||||
@@ -1957,6 +1967,13 @@ const nl: Record<string, string> = {
|
||||
'journey.share.linkDeleted': 'Deellink verwijderd',
|
||||
'journey.share.deleteFailed': 'Verwijderen mislukt',
|
||||
'journey.share.updateFailed': 'Bijwerken mislukt',
|
||||
|
||||
// Journey — Invite
|
||||
'journey.invite.role': 'Rol',
|
||||
'journey.invite.viewer': 'Kijker',
|
||||
'journey.invite.editor': 'Bewerker',
|
||||
'journey.invite.invite': 'Uitnodigen',
|
||||
'journey.invite.inviting': 'Uitnodigen...',
|
||||
'journey.settings.title': 'Reisverslaginstellingen',
|
||||
'journey.settings.coverImage': 'Omslagfoto',
|
||||
'journey.settings.changeCover': 'Omslag wijzigen',
|
||||
@@ -1987,6 +2004,18 @@ const nl: Record<string, string> = {
|
||||
'journey.pdf.theEnd': 'Einde',
|
||||
'journey.pdf.saveAsPdf': 'Opslaan als PDF',
|
||||
'journey.pdf.pages': 'pagina\'s',
|
||||
'journey.picker.tripPeriod': 'Reisperiode',
|
||||
'journey.picker.dateRange': 'Datumbereik',
|
||||
'journey.picker.allPhotos': 'Alle foto\'s',
|
||||
'journey.picker.albums': 'Albums',
|
||||
'journey.picker.selected': 'geselecteerd',
|
||||
'journey.picker.addTo': 'Toevoegen aan',
|
||||
'journey.picker.newGallery': 'Nieuwe galerij',
|
||||
'journey.picker.selectAll': 'Alles selecteren',
|
||||
'journey.picker.deselectAll': 'Alles deselecteren',
|
||||
'journey.picker.noAlbums': 'Geen albums gevonden',
|
||||
'journey.picker.selectDate': 'Selecteer datum',
|
||||
'journey.picker.search': 'Zoeken',
|
||||
'dashboard.greeting.morning': 'Goedemorgen,',
|
||||
'dashboard.greeting.afternoon': 'Goedemiddag,',
|
||||
'dashboard.greeting.evening': 'Goedenavond,',
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
const pl: Record<string, string | { name: string; category: string }[]> = {
|
||||
// Common
|
||||
'common.save': 'Zapisz',
|
||||
'common.showMore': 'Pokaż więcej',
|
||||
'common.showLess': 'Pokaż mniej',
|
||||
'common.cancel': 'Anuluj',
|
||||
'common.delete': 'Usuń',
|
||||
'common.edit': 'Edytuj',
|
||||
@@ -886,6 +888,7 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
|
||||
'places.endTimeBeforeStart': 'Godzina zakończenia jest przed godziną rozpoczęcia',
|
||||
'places.timeCollision': 'Nakładanie się godzin z:',
|
||||
'places.formWebsite': 'Strona internetowa',
|
||||
'places.formNotes': 'Notatki',
|
||||
'places.formNotesPlaceholder': 'Osobiste notatki...',
|
||||
'places.formReservation': 'Rezerwacja',
|
||||
'places.reservationNotesPlaceholder': 'Notatki z rezerwacji, numer potwierdzenia...',
|
||||
@@ -1880,16 +1883,22 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
|
||||
'journey.detail.contributors': 'Współtwórcy',
|
||||
'journey.detail.readMore': 'Czytaj dalej',
|
||||
'journey.detail.prosCons': 'Zalety i wady',
|
||||
'journey.detail.photos': 'zdjęć',
|
||||
'journey.detail.day': 'Dzień {number}',
|
||||
'journey.detail.places': 'miejsc',
|
||||
'journey.stats.days': 'Dni',
|
||||
'journey.stats.cities': 'Miasta',
|
||||
'journey.stats.entries': 'Wpisy',
|
||||
'journey.stats.photos': 'Zdjęcia',
|
||||
'journey.stats.places': 'Miejsca',
|
||||
'journey.skeletons.show': 'Pokaż sugestie',
|
||||
'journey.skeletons.hide': 'Ukryj sugestie',
|
||||
'journey.verdict.lovedIt': 'Świetne',
|
||||
'journey.verdict.couldBeBetter': 'Mogłoby być lepiej',
|
||||
'journey.synced.places': 'miejsca',
|
||||
'journey.synced.synced': 'zsynchronizowane',
|
||||
'journey.editor.uploadPhotos': 'Prześlij zdjęcia',
|
||||
'journey.editor.uploading': 'Przesyłanie...',
|
||||
'journey.editor.fromGallery': 'Z galerii',
|
||||
'journey.editor.allPhotosAdded': 'Wszystkie zdjęcia już dodane',
|
||||
'journey.editor.writeStory': 'Napisz swoją historię...',
|
||||
@@ -1906,6 +1915,7 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
|
||||
'journey.editor.weather': 'Pogoda',
|
||||
'journey.editor.photoFirst': '1.',
|
||||
'journey.editor.makeFirst': 'Ustaw jako 1.',
|
||||
'journey.editor.searching': 'Szukanie...',
|
||||
'journey.mood.amazing': 'Niesamowity',
|
||||
'journey.mood.good': 'Dobry',
|
||||
'journey.mood.neutral': 'Neutralny',
|
||||
@@ -1950,6 +1960,13 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
|
||||
'journey.share.linkDeleted': 'Link udostępniania usunięty',
|
||||
'journey.share.deleteFailed': 'Usunięcie nie powiodło się',
|
||||
'journey.share.updateFailed': 'Aktualizacja nie powiodła się',
|
||||
|
||||
// Journey — Invite
|
||||
'journey.invite.role': 'Rola',
|
||||
'journey.invite.viewer': 'Obserwator',
|
||||
'journey.invite.editor': 'Redaktor',
|
||||
'journey.invite.invite': 'Zaproś',
|
||||
'journey.invite.inviting': 'Zapraszanie...',
|
||||
'journey.settings.title': 'Ustawienia dziennika podróży',
|
||||
'journey.settings.coverImage': 'Zdjęcie okładkowe',
|
||||
'journey.settings.changeCover': 'Zmień okładkę',
|
||||
@@ -1980,6 +1997,18 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
|
||||
'journey.pdf.theEnd': 'Koniec',
|
||||
'journey.pdf.saveAsPdf': 'Zapisz jako PDF',
|
||||
'journey.pdf.pages': 'stron',
|
||||
'journey.picker.tripPeriod': 'Okres podróży',
|
||||
'journey.picker.dateRange': 'Zakres dat',
|
||||
'journey.picker.allPhotos': 'Wszystkie zdjęcia',
|
||||
'journey.picker.albums': 'Albumy',
|
||||
'journey.picker.selected': 'wybranych',
|
||||
'journey.picker.addTo': 'Dodaj do',
|
||||
'journey.picker.newGallery': 'Nowa galeria',
|
||||
'journey.picker.selectAll': 'Zaznacz wszystko',
|
||||
'journey.picker.deselectAll': 'Odznacz wszystko',
|
||||
'journey.picker.noAlbums': 'Nie znaleziono albumów',
|
||||
'journey.picker.selectDate': 'Wybierz datę',
|
||||
'journey.picker.search': 'Szukaj',
|
||||
'dashboard.greeting.morning': 'Dzień dobry,',
|
||||
'dashboard.greeting.afternoon': 'Dzień dobry,',
|
||||
'dashboard.greeting.evening': 'Dobry wieczór,',
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
const ru: Record<string, string> = {
|
||||
// Common
|
||||
'common.save': 'Сохранить',
|
||||
'common.showMore': 'Показать больше',
|
||||
'common.showLess': 'Показать меньше',
|
||||
'common.cancel': 'Отмена',
|
||||
'common.delete': 'Удалить',
|
||||
'common.edit': 'Редактировать',
|
||||
@@ -924,6 +926,7 @@ const ru: Record<string, string> = {
|
||||
'places.endTimeBeforeStart': 'Время окончания раньше времени начала',
|
||||
'places.timeCollision': 'Пересечение по времени с:',
|
||||
'places.formWebsite': 'Сайт',
|
||||
'places.formNotes': 'Заметки',
|
||||
'places.formNotesPlaceholder': 'Личные заметки...',
|
||||
'places.formReservation': 'Бронирование',
|
||||
'places.reservationNotesPlaceholder': 'Заметки о бронировании, номер подтверждения...',
|
||||
@@ -1093,7 +1096,6 @@ const ru: Record<string, string> = {
|
||||
'budget.settlement': 'Взаиморасчёт',
|
||||
'budget.settlementInfo': 'Нажмите на аватар участника в строке бюджета, чтобы отметить его зелёным — это значит, что он заплатил. Взаиморасчёт покажет, кто кому и сколько должен.',
|
||||
'budget.netBalances': 'Чистые балансы',
|
||||
'budget.linkedToReservation': 'Привязано к бронированию — измените название там',
|
||||
|
||||
// Files
|
||||
'files.title': 'Файлы',
|
||||
@@ -1802,21 +1804,9 @@ const ru: Record<string, string> = {
|
||||
'common.justNow': 'только что',
|
||||
'common.hoursAgo': '{count} ч назад',
|
||||
'common.daysAgo': '{count} д назад',
|
||||
'budget.linkedToReservation': 'Привязано к бронированию — измените название там',
|
||||
'packing.saveAsTemplate': 'Сохранить как шаблон',
|
||||
'packing.templateName': 'Название шаблона',
|
||||
'packing.templateSaved': 'Список вещей сохранён как шаблон',
|
||||
'memories.notConnectedMultipleHint': 'Подключите любого из этих фото-провайдеров: {provider_names} в Настройках, чтобы добавлять фото к этой поездке.',
|
||||
'memories.providerUrl': 'URL сервера',
|
||||
'memories.providerApiKey': 'API-ключ',
|
||||
'memories.providerUsername': 'Имя пользователя',
|
||||
'memories.providerPassword': 'Пароль',
|
||||
'memories.saveError': 'Не удалось сохранить настройки {provider_name}',
|
||||
'memories.saveRouteNotConfigured': 'Маршрут сохранения не настроен для этого провайдера',
|
||||
'memories.testRouteNotConfigured': 'Маршрут тестирования не настроен для этого провайдера',
|
||||
'memories.fillRequiredFields': 'Пожалуйста, заполните все обязательные поля',
|
||||
'memories.selectAlbumMultiple': 'Выбрать альбом',
|
||||
'memories.selectPhotosMultiple': 'Выбрать фото',
|
||||
'journey.title': 'Путешествие',
|
||||
'journey.subtitle': 'Отслеживайте свои путешествия в реальном времени',
|
||||
'journey.new': 'Новое путешествие',
|
||||
@@ -1900,16 +1890,22 @@ const ru: Record<string, string> = {
|
||||
'journey.detail.contributors': 'Участники',
|
||||
'journey.detail.readMore': 'Читать далее',
|
||||
'journey.detail.prosCons': 'Плюсы и минусы',
|
||||
'journey.detail.photos': 'фото',
|
||||
'journey.detail.day': 'День {number}',
|
||||
'journey.detail.places': 'мест',
|
||||
'journey.stats.days': 'Дней',
|
||||
'journey.stats.cities': 'Городов',
|
||||
'journey.stats.entries': 'Записей',
|
||||
'journey.stats.photos': 'Фото',
|
||||
'journey.stats.places': 'Мест',
|
||||
'journey.skeletons.show': 'Показать предложения',
|
||||
'journey.skeletons.hide': 'Скрыть предложения',
|
||||
'journey.verdict.lovedIt': 'Понравилось',
|
||||
'journey.verdict.couldBeBetter': 'Могло быть лучше',
|
||||
'journey.synced.places': 'мест',
|
||||
'journey.synced.synced': 'синхронизировано',
|
||||
'journey.editor.uploadPhotos': 'Загрузить фото',
|
||||
'journey.editor.uploading': 'Загрузка...',
|
||||
'journey.editor.fromGallery': 'Из галереи',
|
||||
'journey.editor.allPhotosAdded': 'Все фото уже добавлены',
|
||||
'journey.editor.writeStory': 'Напишите свою историю...',
|
||||
@@ -1926,6 +1922,7 @@ const ru: Record<string, string> = {
|
||||
'journey.editor.weather': 'Погода',
|
||||
'journey.editor.photoFirst': '1-е',
|
||||
'journey.editor.makeFirst': 'Сделать 1-м',
|
||||
'journey.editor.searching': 'Поиск...',
|
||||
'journey.mood.amazing': 'Потрясающе',
|
||||
'journey.mood.good': 'Хорошо',
|
||||
'journey.mood.neutral': 'Нейтрально',
|
||||
@@ -1970,6 +1967,13 @@ const ru: Record<string, string> = {
|
||||
'journey.share.linkDeleted': 'Ссылка удалена',
|
||||
'journey.share.deleteFailed': 'Не удалось удалить',
|
||||
'journey.share.updateFailed': 'Не удалось обновить',
|
||||
|
||||
// Journey — Invite
|
||||
'journey.invite.role': 'Роль',
|
||||
'journey.invite.viewer': 'Наблюдатель',
|
||||
'journey.invite.editor': 'Редактор',
|
||||
'journey.invite.invite': 'Пригласить',
|
||||
'journey.invite.inviting': 'Приглашаем...',
|
||||
'journey.settings.title': 'Настройки путешествия',
|
||||
'journey.settings.coverImage': 'Обложка',
|
||||
'journey.settings.changeCover': 'Сменить обложку',
|
||||
@@ -2000,6 +2004,18 @@ const ru: Record<string, string> = {
|
||||
'journey.pdf.theEnd': 'Конец',
|
||||
'journey.pdf.saveAsPdf': 'Сохранить как PDF',
|
||||
'journey.pdf.pages': 'страниц',
|
||||
'journey.picker.tripPeriod': 'Период поездки',
|
||||
'journey.picker.dateRange': 'Диапазон дат',
|
||||
'journey.picker.allPhotos': 'Все фото',
|
||||
'journey.picker.albums': 'Альбомы',
|
||||
'journey.picker.selected': 'выбрано',
|
||||
'journey.picker.addTo': 'Добавить в',
|
||||
'journey.picker.newGallery': 'Новая галерея',
|
||||
'journey.picker.selectAll': 'Выбрать все',
|
||||
'journey.picker.deselectAll': 'Снять выбор',
|
||||
'journey.picker.noAlbums': 'Альбомы не найдены',
|
||||
'journey.picker.selectDate': 'Выберите дату',
|
||||
'journey.picker.search': 'Поиск',
|
||||
'dashboard.greeting.morning': 'Доброе утро,',
|
||||
'dashboard.greeting.afternoon': 'Добрый день,',
|
||||
'dashboard.greeting.evening': 'Добрый вечер,',
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
const zh: Record<string, string> = {
|
||||
// Common
|
||||
'common.save': '保存',
|
||||
'common.showMore': '显示更多',
|
||||
'common.showLess': '收起',
|
||||
'common.cancel': '取消',
|
||||
'common.delete': '删除',
|
||||
'common.edit': '编辑',
|
||||
@@ -924,6 +926,7 @@ const zh: Record<string, string> = {
|
||||
'places.endTimeBeforeStart': '结束时间早于开始时间',
|
||||
'places.timeCollision': '时间冲突:',
|
||||
'places.formWebsite': '网站',
|
||||
'places.formNotes': '备注',
|
||||
'places.formNotesPlaceholder': '个人备注...',
|
||||
'places.formReservation': '预订',
|
||||
'places.reservationNotesPlaceholder': '预订备注、确认号...',
|
||||
@@ -1093,7 +1096,6 @@ const zh: Record<string, string> = {
|
||||
'budget.settlement': '结算',
|
||||
'budget.settlementInfo': '点击预算项目上的成员头像将其标记为绿色——表示该成员已付款。结算会显示谁欠谁多少。',
|
||||
'budget.netBalances': '净余额',
|
||||
'budget.linkedToReservation': '已链接到预订——在那里编辑名称',
|
||||
|
||||
// Files
|
||||
'files.title': '文件',
|
||||
@@ -1802,21 +1804,9 @@ const zh: Record<string, string> = {
|
||||
'common.justNow': '刚刚',
|
||||
'common.hoursAgo': '{count}小时前',
|
||||
'common.daysAgo': '{count}天前',
|
||||
'budget.linkedToReservation': '已关联预订 — 请在预订中编辑名称',
|
||||
'packing.saveAsTemplate': '保存为模板',
|
||||
'packing.templateName': '模板名称',
|
||||
'packing.templateSaved': '打包清单已保存为模板',
|
||||
'memories.notConnectedMultipleHint': '在设置中连接以下任一照片服务:{provider_names},以便为此旅行添加照片。',
|
||||
'memories.providerUrl': '服务器地址',
|
||||
'memories.providerApiKey': 'API 密钥',
|
||||
'memories.providerUsername': '用户名',
|
||||
'memories.providerPassword': '密码',
|
||||
'memories.saveError': '无法保存 {provider_name} 设置',
|
||||
'memories.saveRouteNotConfigured': '此提供商未配置保存路由',
|
||||
'memories.testRouteNotConfigured': '此提供商未配置测试路由',
|
||||
'memories.fillRequiredFields': '请填写所有必填字段',
|
||||
'memories.selectAlbumMultiple': '选择相册',
|
||||
'memories.selectPhotosMultiple': '选择照片',
|
||||
'journey.title': '旅程',
|
||||
'journey.subtitle': '实时记录你的旅行',
|
||||
'journey.new': '新建旅程',
|
||||
@@ -1900,17 +1890,23 @@ const zh: Record<string, string> = {
|
||||
'journey.detail.contributors': '贡献者',
|
||||
'journey.detail.readMore': '阅读更多',
|
||||
'journey.detail.prosCons': '优缺点',
|
||||
'journey.detail.photos': '照片',
|
||||
'journey.detail.day': '第{number}天',
|
||||
'journey.detail.places': '个地点',
|
||||
'journey.stats.days': '天',
|
||||
'journey.stats.cities': '城市',
|
||||
'journey.stats.entries': '条目',
|
||||
'journey.stats.photos': '照片',
|
||||
'journey.stats.places': '地点',
|
||||
'journey.skeletons.show': '显示建议',
|
||||
'journey.skeletons.hide': '隐藏建议',
|
||||
'journey.verdict.lovedIt': '非常喜欢',
|
||||
'journey.verdict.couldBeBetter': '有待改进',
|
||||
'journey.synced.places': '个地点',
|
||||
'journey.synced.synced': '已同步',
|
||||
'journey.editor.uploadPhotos': '上传照片',
|
||||
'journey.editor.fromGallery': '从相册选择',
|
||||
'journey.editor.uploading': '上传中...',
|
||||
'journey.editor.fromGallery': '从相册',
|
||||
'journey.editor.allPhotosAdded': '所有照片已添加',
|
||||
'journey.editor.writeStory': '写下你的故事...',
|
||||
'journey.editor.prosCons': '优缺点',
|
||||
@@ -1926,6 +1922,7 @@ const zh: Record<string, string> = {
|
||||
'journey.editor.weather': '天气',
|
||||
'journey.editor.photoFirst': '第1张',
|
||||
'journey.editor.makeFirst': '设为第1张',
|
||||
'journey.editor.searching': '搜索中...',
|
||||
'journey.mood.amazing': '太棒了',
|
||||
'journey.mood.good': '不错',
|
||||
'journey.mood.neutral': '一般',
|
||||
@@ -1970,6 +1967,13 @@ const zh: Record<string, string> = {
|
||||
'journey.share.linkDeleted': '分享链接已删除',
|
||||
'journey.share.deleteFailed': '删除失败',
|
||||
'journey.share.updateFailed': '更新失败',
|
||||
|
||||
// Journey — Invite
|
||||
'journey.invite.role': '角色',
|
||||
'journey.invite.viewer': '查看者',
|
||||
'journey.invite.editor': '编辑者',
|
||||
'journey.invite.invite': '邀请',
|
||||
'journey.invite.inviting': '邀请中...',
|
||||
'journey.settings.title': '旅程设置',
|
||||
'journey.settings.coverImage': '封面图片',
|
||||
'journey.settings.changeCover': '更换封面',
|
||||
@@ -2000,6 +2004,18 @@ const zh: Record<string, string> = {
|
||||
'journey.pdf.theEnd': '终',
|
||||
'journey.pdf.saveAsPdf': '保存为 PDF',
|
||||
'journey.pdf.pages': '页',
|
||||
'journey.picker.tripPeriod': '旅行时间段',
|
||||
'journey.picker.dateRange': '日期范围',
|
||||
'journey.picker.allPhotos': '所有照片',
|
||||
'journey.picker.albums': '相册',
|
||||
'journey.picker.selected': '已选择',
|
||||
'journey.picker.addTo': '添加到',
|
||||
'journey.picker.newGallery': '新相册',
|
||||
'journey.picker.selectAll': '全选',
|
||||
'journey.picker.deselectAll': '取消全选',
|
||||
'journey.picker.noAlbums': '未找到相册',
|
||||
'journey.picker.selectDate': '选择日期',
|
||||
'journey.picker.search': '搜索',
|
||||
'dashboard.greeting.morning': '早上好,',
|
||||
'dashboard.greeting.afternoon': '下午好,',
|
||||
'dashboard.greeting.evening': '晚上好,',
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
const zhTw: Record<string, string> = {
|
||||
// Common
|
||||
'common.save': '儲存',
|
||||
'common.showMore': '顯示更多',
|
||||
'common.showLess': '收起',
|
||||
'common.cancel': '取消',
|
||||
'common.delete': '刪除',
|
||||
'common.edit': '編輯',
|
||||
@@ -133,8 +135,6 @@ const zhTw: Record<string, string> = {
|
||||
'dashboard.coverRemoveError': '移除失敗',
|
||||
'dashboard.titleRequired': '標題為必填項',
|
||||
'dashboard.endDateError': '結束日期必須晚於開始日期',
|
||||
'dashboard.dayCount': '天數',
|
||||
'dashboard.dayCountHint': '未設定旅行日期時的規劃天數。',
|
||||
|
||||
// Settings
|
||||
'settings.title': '設定',
|
||||
@@ -951,6 +951,7 @@ const zhTw: Record<string, string> = {
|
||||
'places.endTimeBeforeStart': '結束時間早於開始時間',
|
||||
'places.timeCollision': '時間衝突:',
|
||||
'places.formWebsite': '網站',
|
||||
'places.formNotes': '備註',
|
||||
'places.formNotesPlaceholder': '個人備註...',
|
||||
'places.formReservation': '預訂',
|
||||
'places.reservationNotesPlaceholder': '預訂備註、確認號...',
|
||||
@@ -1763,21 +1764,9 @@ const zhTw: Record<string, string> = {
|
||||
'common.justNow': '剛剛',
|
||||
'common.hoursAgo': '{count}小時前',
|
||||
'common.daysAgo': '{count}天前',
|
||||
'budget.linkedToReservation': '已關聯預訂 — 請在預訂中編輯名稱',
|
||||
'packing.saveAsTemplate': '儲存為範本',
|
||||
'packing.templateName': '範本名稱',
|
||||
'packing.templateSaved': '打包清單已儲存為範本',
|
||||
'memories.notConnectedMultipleHint': '在設定中連接以下任一照片服務:{provider_names},以便為此旅行新增照片。',
|
||||
'memories.providerUrl': '伺服器位址',
|
||||
'memories.providerApiKey': 'API 金鑰',
|
||||
'memories.providerUsername': '使用者名稱',
|
||||
'memories.providerPassword': '密碼',
|
||||
'memories.saveError': '無法儲存 {provider_name} 設定',
|
||||
'memories.saveRouteNotConfigured': '此提供商未設定儲存路由',
|
||||
'memories.testRouteNotConfigured': '此提供商未設定測試路由',
|
||||
'memories.fillRequiredFields': '請填寫所有必填欄位',
|
||||
'memories.selectAlbumMultiple': '選擇相簿',
|
||||
'memories.selectPhotosMultiple': '選擇照片',
|
||||
'journey.title': '旅程',
|
||||
'journey.subtitle': '即時記錄你的旅行',
|
||||
'journey.new': '新建旅程',
|
||||
@@ -1861,17 +1850,23 @@ const zhTw: Record<string, string> = {
|
||||
'journey.detail.contributors': '貢獻者',
|
||||
'journey.detail.readMore': '閱讀更多',
|
||||
'journey.detail.prosCons': '優缺點',
|
||||
'journey.detail.photos': '照片',
|
||||
'journey.detail.day': '第{number}天',
|
||||
'journey.detail.places': '個地點',
|
||||
'journey.stats.days': '天',
|
||||
'journey.stats.cities': '城市',
|
||||
'journey.stats.entries': '條目',
|
||||
'journey.stats.photos': '照片',
|
||||
'journey.stats.places': '地點',
|
||||
'journey.skeletons.show': '顯示建議',
|
||||
'journey.skeletons.hide': '隱藏建議',
|
||||
'journey.verdict.lovedIt': '非常喜歡',
|
||||
'journey.verdict.couldBeBetter': '有待改進',
|
||||
'journey.synced.places': '個地點',
|
||||
'journey.synced.synced': '已同步',
|
||||
'journey.editor.uploadPhotos': '上傳照片',
|
||||
'journey.editor.fromGallery': '從相簿選擇',
|
||||
'journey.editor.uploading': '上傳中...',
|
||||
'journey.editor.fromGallery': '從相簿',
|
||||
'journey.editor.allPhotosAdded': '所有照片已新增',
|
||||
'journey.editor.writeStory': '寫下你的故事...',
|
||||
'journey.editor.prosCons': '優缺點',
|
||||
@@ -1887,6 +1882,7 @@ const zhTw: Record<string, string> = {
|
||||
'journey.editor.weather': '天氣',
|
||||
'journey.editor.photoFirst': '第1張',
|
||||
'journey.editor.makeFirst': '設為第1張',
|
||||
'journey.editor.searching': '搜尋中...',
|
||||
'journey.mood.amazing': '太棒了',
|
||||
'journey.mood.good': '不錯',
|
||||
'journey.mood.neutral': '一般',
|
||||
@@ -1931,6 +1927,13 @@ const zhTw: Record<string, string> = {
|
||||
'journey.share.linkDeleted': '分享連結已刪除',
|
||||
'journey.share.deleteFailed': '刪除失敗',
|
||||
'journey.share.updateFailed': '更新失敗',
|
||||
|
||||
// Journey — Invite
|
||||
'journey.invite.role': '角色',
|
||||
'journey.invite.viewer': '檢視者',
|
||||
'journey.invite.editor': '編輯者',
|
||||
'journey.invite.invite': '邀請',
|
||||
'journey.invite.inviting': '邀請中...',
|
||||
'journey.settings.title': '旅程設定',
|
||||
'journey.settings.coverImage': '封面圖片',
|
||||
'journey.settings.changeCover': '更換封面',
|
||||
@@ -1961,6 +1964,18 @@ const zhTw: Record<string, string> = {
|
||||
'journey.pdf.theEnd': '終',
|
||||
'journey.pdf.saveAsPdf': '儲存為 PDF',
|
||||
'journey.pdf.pages': '頁',
|
||||
'journey.picker.tripPeriod': '旅行期間',
|
||||
'journey.picker.dateRange': '日期範圍',
|
||||
'journey.picker.allPhotos': '所有照片',
|
||||
'journey.picker.albums': '相簿',
|
||||
'journey.picker.selected': '已選擇',
|
||||
'journey.picker.addTo': '新增至',
|
||||
'journey.picker.newGallery': '新相簿',
|
||||
'journey.picker.selectAll': '全選',
|
||||
'journey.picker.deselectAll': '取消全選',
|
||||
'journey.picker.noAlbums': '未找到相簿',
|
||||
'journey.picker.selectDate': '選擇日期',
|
||||
'journey.picker.search': '搜尋',
|
||||
'dashboard.greeting.morning': '早安,',
|
||||
'dashboard.greeting.afternoon': '午安,',
|
||||
'dashboard.greeting.evening': '晚安,',
|
||||
@@ -1998,112 +2013,9 @@ const zhTw: Record<string, string> = {
|
||||
'dayplan.mobile.createNew': '建立新地點',
|
||||
'admin.addons.catalog.journey.name': '旅程',
|
||||
'admin.addons.catalog.journey.description': '旅行追蹤與旅行日誌,包含打卡、照片和每日故事',
|
||||
'dashboard.dayCount': '天數',
|
||||
'dashboard.dayCountHint': '未設定旅行日期時規劃的天數。',
|
||||
'settings.tabs.display': '顯示',
|
||||
'settings.tabs.map': '地圖',
|
||||
'settings.tabs.notifications': '通知',
|
||||
'settings.tabs.integrations': '整合',
|
||||
'settings.tabs.account': '帳戶',
|
||||
'settings.tabs.about': '關於',
|
||||
'settings.notifyVersionAvailable': '有新版本可用',
|
||||
'settings.notificationPreferences.email': '電子郵件',
|
||||
'settings.notificationPreferences.webhook': 'Webhook',
|
||||
'settings.notificationPreferences.inapp': '應用內',
|
||||
'settings.notificationPreferences.noChannels': '尚未設定通知管道。請聯繫管理員設定電子郵件或 Webhook 通知。',
|
||||
'settings.webhookUrl.label': 'Webhook 網址',
|
||||
'settings.webhookUrl.placeholder': 'https://discord.com/api/webhooks/...',
|
||||
'settings.webhookUrl.hint': '輸入你的 Discord、Slack 或自訂 Webhook 網址以接收通知。',
|
||||
'settings.webhookUrl.save': '儲存',
|
||||
'settings.webhookUrl.saved': 'Webhook 網址已儲存',
|
||||
'settings.webhookUrl.test': '測試',
|
||||
'settings.webhookUrl.testSuccess': '測試 Webhook 傳送成功',
|
||||
'settings.webhookUrl.testFailed': '測試 Webhook 失敗',
|
||||
'admin.notifications.emailPanel.title': '電子郵件 (SMTP)',
|
||||
'admin.notifications.webhookPanel.title': 'Webhook',
|
||||
'admin.notifications.inappPanel.title': '應用內',
|
||||
'admin.notifications.inappPanel.hint': '應用內通知始終處於啟用狀態,無法全域停用。',
|
||||
'admin.notifications.adminWebhookPanel.title': '管理員 Webhook',
|
||||
'admin.notifications.adminWebhookPanel.hint': '此 Webhook 僅用於管理員通知(例如版本更新提醒)。它與每位使用者的 Webhook 分開,設定後將始終觸發。',
|
||||
'admin.notifications.adminWebhookPanel.saved': '管理員 Webhook 網址已儲存',
|
||||
'admin.notifications.adminWebhookPanel.testSuccess': '測試 Webhook 傳送成功',
|
||||
'admin.notifications.adminWebhookPanel.testFailed': '測試 Webhook 失敗',
|
||||
'admin.notifications.adminWebhookPanel.alwaysOnHint': '設定網址後管理員 Webhook 將始終觸發',
|
||||
'admin.notifications.adminNotificationsHint': '設定哪些管道傳送僅限管理員的通知(例如版本更新提醒)。',
|
||||
'settings.about.reportBug': '回報錯誤',
|
||||
'settings.about.reportBugHint': '發現問題?請告訴我們',
|
||||
'settings.about.featureRequest': '功能建議',
|
||||
'settings.about.featureRequestHint': '提出新功能建議',
|
||||
'settings.about.wikiHint': '文件與指南',
|
||||
'settings.about.description': 'TREK 是一個自架式旅行規劃工具,幫助你從第一個想法到最後一個回憶來組織旅行。日程規劃、預算、打包清單、照片等等——全部集中在一處,在你自己的伺服器上。',
|
||||
'settings.about.madeWith': '以',
|
||||
'settings.about.madeBy': '由 Maurice 和不斷壯大的開源社群製作。',
|
||||
'admin.tabs.notifications': '通知',
|
||||
'atlas.confirmUnmarkRegion': '將此地區從已造訪清單中移除?',
|
||||
'atlas.markRegionVisitedHint': '將此地區新增至已造訪清單',
|
||||
'trip.tabs.lists': '清單',
|
||||
'trip.tabs.listsShort': '清單',
|
||||
'reservations.price': '價格',
|
||||
'reservations.budgetCategory': '預算類別',
|
||||
'reservations.budgetCategoryPlaceholder': '例如 交通、住宿',
|
||||
'reservations.budgetCategoryAuto': '自動(依預訂類型)',
|
||||
'reservations.budgetHint': '儲存時將自動建立一筆預算項目。',
|
||||
'reservations.departureDate': '出發日期',
|
||||
'reservations.arrivalDate': '抵達日期',
|
||||
'reservations.departureTime': '出發時間',
|
||||
'reservations.arrivalTime': '抵達時間',
|
||||
'reservations.pickupDate': '取車日期',
|
||||
'reservations.returnDate': '還車日期',
|
||||
'reservations.pickupTime': '取車時間',
|
||||
'reservations.returnTime': '還車時間',
|
||||
'reservations.endDate': '結束日期',
|
||||
'reservations.meta.departureTimezone': '出發時區',
|
||||
'reservations.meta.arrivalTimezone': '抵達時區',
|
||||
'reservations.span.departure': '出發',
|
||||
'reservations.span.arrival': '抵達',
|
||||
'reservations.span.inTransit': '運輸中',
|
||||
'reservations.span.pickup': '取車',
|
||||
'reservations.span.return': '還車',
|
||||
'reservations.span.active': '使用中',
|
||||
'reservations.span.start': '開始',
|
||||
'reservations.span.end': '結束',
|
||||
'reservations.span.ongoing': '進行中',
|
||||
'reservations.validation.endBeforeStart': '結束日期/時間必須晚於開始日期/時間',
|
||||
'notifications.versionAvailable.title': '有可用更新',
|
||||
'notifications.versionAvailable.text': 'TREK {version} 現已推出。',
|
||||
'notifications.versionAvailable.button': '查看詳情',
|
||||
'todo.subtab.packing': '打包清單',
|
||||
'todo.subtab.todo': '待辦事項',
|
||||
'todo.completed': '已完成',
|
||||
'todo.filter.all': '全部',
|
||||
'todo.filter.open': '未完成',
|
||||
'todo.filter.done': '已完成',
|
||||
'todo.uncategorized': '未分類',
|
||||
'todo.namePlaceholder': '任務名稱',
|
||||
'todo.descriptionPlaceholder': '描述(可選)',
|
||||
'todo.unassigned': '未指派',
|
||||
'todo.noCategory': '無類別',
|
||||
'todo.hasDescription': '有描述',
|
||||
'todo.addItem': '新增任務...',
|
||||
'todo.newCategory': '類別名稱',
|
||||
'todo.addCategory': '新增類別',
|
||||
'todo.newItem': '新任務',
|
||||
'todo.empty': '還沒有任務。新增一個任務開始吧!',
|
||||
'todo.filter.my': '我的任務',
|
||||
'todo.filter.overdue': '已逾期',
|
||||
'todo.sidebar.tasks': '任務',
|
||||
'todo.sidebar.categories': '類別',
|
||||
'todo.detail.title': '任務',
|
||||
'todo.detail.description': '描述',
|
||||
'todo.detail.category': '類別',
|
||||
'todo.detail.dueDate': '截止日期',
|
||||
'todo.detail.assignedTo': '指派給',
|
||||
'todo.detail.delete': '刪除',
|
||||
'todo.detail.save': '儲存變更',
|
||||
'todo.sortByPrio': '優先順序',
|
||||
'todo.detail.priority': '優先順序',
|
||||
'todo.detail.noPriority': '無',
|
||||
'todo.detail.create': '建立任務',
|
||||
'notif.test.title': '[測試] 通知',
|
||||
'notif.test.simple.text': '這是一則簡單的測試通知。',
|
||||
'notif.test.boolean.text': '你是否接受這則測試通知?',
|
||||
@@ -2130,39 +2042,10 @@ const zhTw: Record<string, string> = {
|
||||
'notif.action.view_photos': '查看照片',
|
||||
'notif.action.view_vacay': '查看 Vacay',
|
||||
'notif.action.view_admin': '前往管理',
|
||||
'notifications.versionAvailable.title': '有可用更新',
|
||||
'notifications.versionAvailable.text': 'TREK {version} 現已推出。',
|
||||
'notifications.versionAvailable.button': '查看詳情',
|
||||
|
||||
// Notifications — dev test events
|
||||
'notif.test.title': '[測試] 通知',
|
||||
'notif.test.simple.text': '這是一條簡單的測試通知。',
|
||||
'notif.test.boolean.text': '您接受此測試通知嗎?',
|
||||
'notif.test.navigate.text': '點選下方前往儀表板。',
|
||||
|
||||
// Notifications
|
||||
'notif.trip_invite.title': '行程邀請',
|
||||
'notif.trip_invite.text': '{actor} 邀請您加入 {trip}',
|
||||
'notif.booking_change.title': '預訂已更新',
|
||||
'notif.booking_change.text': '{actor} 已更新 {trip} 中的預訂',
|
||||
'notif.trip_reminder.title': '行程提醒',
|
||||
'notif.trip_reminder.text': '您的行程 {trip} 即將開始!',
|
||||
'notif.vacay_invite.title': 'Vacay 合併邀請',
|
||||
'notif.vacay_invite.text': '{actor} 邀請您合併假期計畫',
|
||||
'notif.photos_shared.title': '已分享照片',
|
||||
'notif.photos_shared.text': '{actor} 在 {trip} 中分享了 {count} 張照片',
|
||||
'notif.collab_message.title': '新訊息',
|
||||
'notif.collab_message.text': '{actor} 在 {trip} 中傳送了訊息',
|
||||
'notif.packing_tagged.title': '行李指派',
|
||||
'notif.packing_tagged.text': '{actor} 在 {trip} 中將您指派至 {category}',
|
||||
'notif.version_available.title': '有新版本可用',
|
||||
'notif.version_available.text': 'TREK {version} 現已推出',
|
||||
'notif.action.view_trip': '查看行程',
|
||||
'notif.action.view_collab': '查看訊息',
|
||||
'notif.action.view_packing': '查看行李',
|
||||
'notif.action.view_photos': '查看照片',
|
||||
'notif.action.view_vacay': '查看 Vacay',
|
||||
'notif.action.view_admin': '前往管理員',
|
||||
'notif.action.view': '查看',
|
||||
'notif.action.accept': '接受',
|
||||
'notif.action.decline': '拒絕',
|
||||
|
||||
@@ -1353,7 +1353,7 @@ export default function AdminPage(): React.ReactElement {
|
||||
disabled={!smtpValues.admin_webhook_url?.trim()}
|
||||
className="px-4 py-2 border border-slate-300 text-slate-700 rounded-lg text-sm font-medium hover:bg-slate-50 transition-colors disabled:opacity-40"
|
||||
>
|
||||
{t('admin.smtp.testButton')}
|
||||
{t('admin.notifications.testWebhook')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -296,8 +296,9 @@ export default function AtlasPage(): React.ReactElement {
|
||||
updateWhenIdle: false,
|
||||
tileSize: 256,
|
||||
zoomOffset: 0,
|
||||
crossOrigin: true
|
||||
}).addTo(map)
|
||||
crossOrigin: true,
|
||||
referrerPolicy: 'strict-origin-when-cross-origin',
|
||||
} as any).addTo(map)
|
||||
|
||||
// Preload adjacent zoom level tiles
|
||||
L.tileLayer(tileUrl, {
|
||||
@@ -306,6 +307,7 @@ export default function AtlasPage(): React.ReactElement {
|
||||
opacity: 0,
|
||||
tileSize: 256,
|
||||
crossOrigin: true,
|
||||
referrerPolicy: 'strict-origin-when-cross-origin',
|
||||
}).addTo(map)
|
||||
|
||||
// Custom pane for region layer — above overlay (z-index 400)
|
||||
|
||||
@@ -835,8 +835,8 @@ describe('DashboardPage', () => {
|
||||
expect(screen.getAllByText('2').length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
// Duration stat label
|
||||
expect(screen.getAllByText(/duration/i).length).toBeGreaterThan(0);
|
||||
// Days stat label
|
||||
expect(screen.getAllByText(/days/i).length).toBeGreaterThan(0);
|
||||
// Places stat label
|
||||
expect(screen.getAllByText(/places/i).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
@@ -113,6 +113,7 @@ const mockJourneyDetail = {
|
||||
{
|
||||
id: 100,
|
||||
entry_id: 10,
|
||||
photo_id: 100,
|
||||
provider: 'local',
|
||||
file_path: 'photos/test.jpg',
|
||||
asset_id: null,
|
||||
@@ -300,7 +301,7 @@ describe('JourneyDetailPage', () => {
|
||||
// img with alt="" is presentational (no 'img' role), so query the DOM directly
|
||||
const images = document.querySelectorAll('img');
|
||||
const srcs = Array.from(images).map((img) => img.getAttribute('src'));
|
||||
expect(srcs).toContain('/uploads/photos/test.jpg');
|
||||
expect(srcs).toContain('/api/photos/100/thumbnail');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -536,7 +537,7 @@ describe('JourneyDetailPage', () => {
|
||||
await renderAndWait();
|
||||
const imgs = document.querySelectorAll('img');
|
||||
const photoSrcs = Array.from(imgs).map((img) => img.getAttribute('src'));
|
||||
expect(photoSrcs).toContain('/uploads/photos/test.jpg');
|
||||
expect(photoSrcs).toContain('/api/photos/100/thumbnail');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -547,17 +548,17 @@ describe('JourneyDetailPage', () => {
|
||||
...mockJourneyDetail.entries[0],
|
||||
photos: [
|
||||
{
|
||||
id: 100, entry_id: 10, provider: 'local' as const, file_path: 'photos/a.jpg',
|
||||
id: 100, entry_id: 10, photo_id: 100, provider: 'local' as const, file_path: 'photos/a.jpg',
|
||||
asset_id: null, owner_id: null, thumbnail_path: null,
|
||||
caption: null, sort_order: 0, width: 800, height: 600, shared: 1, created_at: now,
|
||||
},
|
||||
{
|
||||
id: 101, entry_id: 10, provider: 'local' as const, file_path: 'photos/b.jpg',
|
||||
id: 101, entry_id: 10, photo_id: 101, provider: 'local' as const, file_path: 'photos/b.jpg',
|
||||
asset_id: null, owner_id: null, thumbnail_path: null,
|
||||
caption: null, sort_order: 1, width: 800, height: 600, shared: 1, created_at: now,
|
||||
},
|
||||
{
|
||||
id: 102, entry_id: 10, provider: 'local' as const, file_path: 'photos/c.jpg',
|
||||
id: 102, entry_id: 10, photo_id: 102, provider: 'local' as const, file_path: 'photos/c.jpg',
|
||||
asset_id: null, owner_id: null, thumbnail_path: null,
|
||||
caption: null, sort_order: 2, width: 800, height: 600, shared: 1, created_at: now,
|
||||
},
|
||||
@@ -575,9 +576,9 @@ describe('JourneyDetailPage', () => {
|
||||
|
||||
const imgs = document.querySelectorAll('img');
|
||||
const photoSrcs = Array.from(imgs).map((img) => img.getAttribute('src'));
|
||||
expect(photoSrcs).toContain('/uploads/photos/a.jpg');
|
||||
expect(photoSrcs).toContain('/uploads/photos/b.jpg');
|
||||
expect(photoSrcs).toContain('/uploads/photos/c.jpg');
|
||||
expect(photoSrcs).toContain('/api/photos/100/thumbnail');
|
||||
expect(photoSrcs).toContain('/api/photos/101/thumbnail');
|
||||
expect(photoSrcs).toContain('/api/photos/102/thumbnail');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1064,7 +1065,7 @@ describe('JourneyDetailPage', () => {
|
||||
// Gallery renders photos as images
|
||||
const imgs = document.querySelectorAll('img');
|
||||
const srcs = Array.from(imgs).map((img) => img.getAttribute('src'));
|
||||
expect(srcs).toContain('/uploads/photos/test.jpg');
|
||||
expect(srcs).toContain('/api/photos/100/thumbnail');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1745,7 +1746,7 @@ describe('JourneyDetailPage', () => {
|
||||
});
|
||||
|
||||
// Click the photo in the gallery grid
|
||||
const galleryImgs = document.querySelectorAll('img[src="/uploads/photos/test.jpg"]');
|
||||
const galleryImgs = document.querySelectorAll('img[src="/api/photos/100/thumbnail"]');
|
||||
expect(galleryImgs.length).toBeGreaterThanOrEqual(1);
|
||||
await user.click(galleryImgs[0] as HTMLElement);
|
||||
|
||||
@@ -1960,8 +1961,10 @@ describe('JourneyDetailPage', () => {
|
||||
expect(screen.getByText(/1 photos/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// The entry date '2026-03-15' is shown as an overlay on each gallery photo
|
||||
expect(screen.getByText('2026-03-15')).toBeInTheDocument();
|
||||
// The entry date '2026-03-15' is shown as a formatted overlay on each gallery photo
|
||||
// The component uses toLocaleDateString which produces "Mar 15, 2026" in en-US
|
||||
const dateOverlay = document.querySelector('[class*="opacity-0"]');
|
||||
expect(dateOverlay).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1991,7 +1994,7 @@ describe('JourneyDetailPage', () => {
|
||||
const immichEntry = {
|
||||
...mockJourneyDetail.entries[0],
|
||||
photos: [{
|
||||
id: 200, entry_id: 10, provider: 'immich', file_path: null,
|
||||
id: 200, entry_id: 10, photo_id: 200, provider: 'immich', file_path: null,
|
||||
asset_id: 'asset-123', owner_id: 1, thumbnail_path: null,
|
||||
caption: null, sort_order: 0, width: 800, height: 600, shared: 1, created_at: now,
|
||||
}],
|
||||
@@ -2025,7 +2028,7 @@ describe('JourneyDetailPage', () => {
|
||||
const synologyEntry = {
|
||||
...mockJourneyDetail.entries[0],
|
||||
photos: [{
|
||||
id: 201, entry_id: 10, provider: 'synology', file_path: null,
|
||||
id: 201, entry_id: 10, photo_id: 201, provider: 'synology', file_path: null,
|
||||
asset_id: 'syn-456', owner_id: 1, thumbnail_path: null,
|
||||
caption: null, sort_order: 0, width: 800, height: 600, shared: 1, created_at: now,
|
||||
}],
|
||||
@@ -2108,12 +2111,12 @@ describe('JourneyDetailPage', () => {
|
||||
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
|
||||
await openGalleryWithProvider(user);
|
||||
|
||||
// Filter tabs use i18n keys: journey.trips.link = "Link", common.edit = "Edit", journey.share.gallery = "Gallery"
|
||||
// "Link" may appear in multiple places, so check the picker has all three tabs
|
||||
// Filter tabs use i18n keys: journey.picker.tripPeriod, dateRange, allPhotos, albums
|
||||
const pickerModal = screen.getByText('Add to').closest('[class*="fixed"]')!;
|
||||
expect(pickerModal).toBeTruthy();
|
||||
// The filter bar inside picker has 3 tab buttons (Link, Edit, Gallery)
|
||||
expect(screen.getByText('Edit')).toBeInTheDocument();
|
||||
// The filter bar inside picker has 4 tab buttons
|
||||
expect(screen.getByText('Trip Period')).toBeInTheDocument();
|
||||
expect(screen.getByText('Albums')).toBeInTheDocument();
|
||||
expect(screen.getByText('Add to')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -2124,6 +2127,9 @@ describe('JourneyDetailPage', () => {
|
||||
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
|
||||
await openGalleryWithProvider(user);
|
||||
|
||||
// Flush pending timers/microtasks so the search fetch resolves
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
// Photos should load via the search endpoint, rendered as thumbnail images
|
||||
await waitFor(() => {
|
||||
const imgs = document.querySelectorAll('img[src*="/api/integrations/memories/"]');
|
||||
@@ -2293,8 +2299,8 @@ describe('JourneyDetailPage', () => {
|
||||
|
||||
// The gallery picker shows thumbnail images from existing photos
|
||||
await waitFor(() => {
|
||||
// The gallery picker grid renders gallery photos as clickable thumbnails
|
||||
const pickerImgs = document.querySelectorAll('img[src="/uploads/photos/test.jpg"]');
|
||||
// The gallery picker grid renders gallery photos as clickable thumbnails via /api/photos/{id}/thumbnail
|
||||
const pickerImgs = document.querySelectorAll('img[src="/api/photos/100/thumbnail"]');
|
||||
expect(pickerImgs.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
});
|
||||
@@ -2471,9 +2477,9 @@ describe('JourneyDetailPage', () => {
|
||||
expect(screen.getByText('Invite Contributor')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Role selector shows viewer and editor buttons
|
||||
expect(screen.getByText('viewer')).toBeInTheDocument();
|
||||
expect(screen.getByText('editor')).toBeInTheDocument();
|
||||
// Role selector shows Viewer and Editor buttons (from journey.invite.viewer / journey.invite.editor)
|
||||
expect(screen.getByText('Viewer')).toBeInTheDocument();
|
||||
expect(screen.getByText('Editor')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2501,11 +2507,11 @@ describe('JourneyDetailPage', () => {
|
||||
await user.click(inviteBtns[0] as HTMLElement);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('viewer')).toBeInTheDocument();
|
||||
expect(screen.getByText('Viewer')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Default is viewer - click editor to switch
|
||||
const editorBtn = screen.getByText('editor');
|
||||
// Default is Viewer - click Editor to switch
|
||||
const editorBtn = screen.getByText('Editor');
|
||||
await user.click(editorBtn);
|
||||
|
||||
// Editor button should now be active (bg-zinc-900 class)
|
||||
@@ -2617,11 +2623,11 @@ describe('JourneyDetailPage', () => {
|
||||
const multiPhotoEntry = {
|
||||
...mockJourneyDetail.entries[0],
|
||||
photos: [
|
||||
{ id: 100, entry_id: 10, provider: 'local', file_path: 'photos/a.jpg', asset_id: null, owner_id: null, thumbnail_path: null, caption: null, sort_order: 0, width: 800, height: 600, shared: 1, created_at: now },
|
||||
{ id: 101, entry_id: 10, provider: 'local', file_path: 'photos/b.jpg', asset_id: null, owner_id: null, thumbnail_path: null, caption: null, sort_order: 1, width: 800, height: 600, shared: 1, created_at: now },
|
||||
{ id: 102, entry_id: 10, provider: 'local', file_path: 'photos/c.jpg', asset_id: null, owner_id: null, thumbnail_path: null, caption: null, sort_order: 2, width: 800, height: 600, shared: 1, created_at: now },
|
||||
{ id: 103, entry_id: 10, provider: 'local', file_path: 'photos/d.jpg', asset_id: null, owner_id: null, thumbnail_path: null, caption: null, sort_order: 3, width: 800, height: 600, shared: 1, created_at: now },
|
||||
{ id: 104, entry_id: 10, provider: 'local', file_path: 'photos/e.jpg', asset_id: null, owner_id: null, thumbnail_path: null, caption: null, sort_order: 4, width: 800, height: 600, shared: 1, created_at: now },
|
||||
{ id: 100, entry_id: 10, photo_id: 100, provider: 'local', file_path: 'photos/a.jpg', asset_id: null, owner_id: null, thumbnail_path: null, caption: null, sort_order: 0, width: 800, height: 600, shared: 1, created_at: now },
|
||||
{ id: 101, entry_id: 10, photo_id: 101, provider: 'local', file_path: 'photos/b.jpg', asset_id: null, owner_id: null, thumbnail_path: null, caption: null, sort_order: 1, width: 800, height: 600, shared: 1, created_at: now },
|
||||
{ id: 102, entry_id: 10, photo_id: 102, provider: 'local', file_path: 'photos/c.jpg', asset_id: null, owner_id: null, thumbnail_path: null, caption: null, sort_order: 2, width: 800, height: 600, shared: 1, created_at: now },
|
||||
{ id: 103, entry_id: 10, photo_id: 103, provider: 'local', file_path: 'photos/d.jpg', asset_id: null, owner_id: null, thumbnail_path: null, caption: null, sort_order: 3, width: 800, height: 600, shared: 1, created_at: now },
|
||||
{ id: 104, entry_id: 10, photo_id: 104, provider: 'local', file_path: 'photos/e.jpg', asset_id: null, owner_id: null, thumbnail_path: null, caption: null, sort_order: 4, width: 800, height: 600, shared: 1, created_at: now },
|
||||
],
|
||||
};
|
||||
setupDefaultHandlers({
|
||||
@@ -2645,8 +2651,8 @@ describe('JourneyDetailPage', () => {
|
||||
const twoPhotoEntry = {
|
||||
...mockJourneyDetail.entries[0],
|
||||
photos: [
|
||||
{ id: 100, entry_id: 10, provider: 'local', file_path: 'photos/a.jpg', asset_id: null, owner_id: null, thumbnail_path: null, caption: null, sort_order: 0, width: 800, height: 600, shared: 1, created_at: now },
|
||||
{ id: 101, entry_id: 10, provider: 'local', file_path: 'photos/b.jpg', asset_id: null, owner_id: null, thumbnail_path: null, caption: null, sort_order: 1, width: 800, height: 600, shared: 1, created_at: now },
|
||||
{ id: 100, entry_id: 10, photo_id: 100, provider: 'local', file_path: 'photos/a.jpg', asset_id: null, owner_id: null, thumbnail_path: null, caption: null, sort_order: 0, width: 800, height: 600, shared: 1, created_at: now },
|
||||
{ id: 101, entry_id: 10, photo_id: 101, provider: 'local', file_path: 'photos/b.jpg', asset_id: null, owner_id: null, thumbnail_path: null, caption: null, sort_order: 1, width: 800, height: 600, shared: 1, created_at: now },
|
||||
],
|
||||
};
|
||||
setupDefaultHandlers({
|
||||
@@ -2662,8 +2668,8 @@ describe('JourneyDetailPage', () => {
|
||||
// Both photos render in the grid
|
||||
const imgs = document.querySelectorAll('img');
|
||||
const srcs = Array.from(imgs).map(img => img.getAttribute('src'));
|
||||
expect(srcs).toContain('/uploads/photos/a.jpg');
|
||||
expect(srcs).toContain('/uploads/photos/b.jpg');
|
||||
expect(srcs).toContain('/api/photos/100/thumbnail');
|
||||
expect(srcs).toContain('/api/photos/101/thumbnail');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2673,6 +2679,9 @@ describe('JourneyDetailPage', () => {
|
||||
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
|
||||
await openGalleryWithProvider(user);
|
||||
|
||||
// Flush pending timers/microtasks so the search fetch resolves
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
// Wait for photos to load
|
||||
await waitFor(() => {
|
||||
const imgs = document.querySelectorAll('img[src*="/api/integrations/memories/"]');
|
||||
@@ -2725,13 +2734,12 @@ describe('JourneyDetailPage', () => {
|
||||
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
|
||||
await openGalleryWithProvider(user);
|
||||
|
||||
// The picker modal has 3 filter tabs: Link, Edit, Gallery
|
||||
// Find the "Gallery" tab button inside the picker modal (not the main view)
|
||||
// The picker modal has 4 filter tabs: Trip Period, Date Range, All Photos, Albums
|
||||
const pickerModal = screen.getByText('Add to').closest('[class*="fixed"]')!;
|
||||
const filterButtons = pickerModal.querySelectorAll('[class*="px-3"][class*="py-1\\.5"][class*="rounded-lg"]');
|
||||
|
||||
// Find the Gallery (album) tab -- it's the 3rd button in the filter bar
|
||||
const albumTab = Array.from(filterButtons).find(btn => btn.textContent === 'Gallery');
|
||||
// Find the Albums tab button
|
||||
const albumTab = Array.from(filterButtons).find(btn => btn.textContent === 'Albums');
|
||||
expect(albumTab).toBeTruthy();
|
||||
await user.click(albumTab as HTMLElement);
|
||||
|
||||
@@ -2845,7 +2853,7 @@ describe('JourneyDetailPage', () => {
|
||||
const editorModal = screen.getByText('Edit Entry').closest('[class*="fixed"]')!;
|
||||
const editorImgs = editorModal.querySelectorAll('img');
|
||||
const editorSrcs = Array.from(editorImgs).map(img => img.getAttribute('src'));
|
||||
expect(editorSrcs).toContain('/uploads/photos/test.jpg');
|
||||
expect(editorSrcs).toContain('/api/photos/100/thumbnail');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3344,7 +3352,7 @@ describe('JourneyDetailPage', () => {
|
||||
}),
|
||||
http.post('/api/journeys/entries/88/photos', () => {
|
||||
uploadCalled = true;
|
||||
return HttpResponse.json([{ id: 999, entry_id: 88, provider: 'local', file_path: 'photos/new.jpg', asset_id: null, owner_id: null, thumbnail_path: null, caption: null, sort_order: 0, width: 100, height: 100, shared: 1, created_at: now }]);
|
||||
return HttpResponse.json([{ id: 999, entry_id: 88, photo_id: 999, provider: 'local', file_path: 'photos/new.jpg', asset_id: null, owner_id: null, thumbnail_path: null, caption: null, sort_order: 0, width: 100, height: 100, shared: 1, created_at: now }]);
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -3487,10 +3495,10 @@ describe('JourneyDetailPage', () => {
|
||||
expect(screen.getByText('Add to')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Switch to custom (Edit) tab
|
||||
// Switch to custom (Date Range) tab
|
||||
const pickerModal = screen.getByText('Add to').closest('[class*="fixed"]')!;
|
||||
const editTab = Array.from(pickerModal.querySelectorAll('button')).find(
|
||||
b => b.textContent === 'Edit',
|
||||
b => b.textContent === 'Date Range',
|
||||
);
|
||||
expect(editTab).toBeTruthy();
|
||||
await user.click(editTab as HTMLElement);
|
||||
@@ -3510,8 +3518,8 @@ describe('JourneyDetailPage', () => {
|
||||
const entryWithMultiPhotos = {
|
||||
...mockJourneyDetail.entries[0],
|
||||
photos: [
|
||||
{ id: 100, entry_id: 10, provider: 'local', file_path: 'photos/a.jpg', asset_id: null, owner_id: null, thumbnail_path: null, caption: null, sort_order: 0, width: 800, height: 600, shared: 1, created_at: now },
|
||||
{ id: 101, entry_id: 10, provider: 'local', file_path: 'photos/b.jpg', asset_id: null, owner_id: null, thumbnail_path: null, caption: null, sort_order: 1, width: 800, height: 600, shared: 1, created_at: now },
|
||||
{ id: 100, entry_id: 10, photo_id: 100, provider: 'local', file_path: 'photos/a.jpg', asset_id: null, owner_id: null, thumbnail_path: null, caption: null, sort_order: 0, width: 800, height: 600, shared: 1, created_at: now },
|
||||
{ id: 101, entry_id: 10, photo_id: 101, provider: 'local', file_path: 'photos/b.jpg', asset_id: null, owner_id: null, thumbnail_path: null, caption: null, sort_order: 1, width: 800, height: 600, shared: 1, created_at: now },
|
||||
],
|
||||
};
|
||||
setupDefaultHandlers({
|
||||
@@ -3564,7 +3572,7 @@ describe('JourneyDetailPage', () => {
|
||||
}),
|
||||
http.post('/api/journeys/entries/11/photos', () => {
|
||||
uploadCalled = true;
|
||||
return HttpResponse.json([{ id: 300, entry_id: 11, provider: 'local', file_path: 'photos/new.jpg', asset_id: null, owner_id: null, thumbnail_path: null, caption: null, sort_order: 0, width: 100, height: 100, shared: 1, created_at: now }]);
|
||||
return HttpResponse.json([{ id: 300, entry_id: 11, photo_id: 300, provider: 'local', file_path: 'photos/new.jpg', asset_id: null, owner_id: null, thumbnail_path: null, caption: null, sort_order: 0, width: 100, height: 100, shared: 1, created_at: now }]);
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
Clock, Package, Image, ChevronRight,
|
||||
UserPlus, Plus, Minus, Calendar, Camera, BookOpen, X, Check, ImagePlus, Trash2, Pencil,
|
||||
Laugh, Smile, Meh, Annoyed, Frown,
|
||||
Sun, CloudSun, Cloud, CloudRain, CloudLightning, Snowflake, ChevronDown,
|
||||
Sun, CloudSun, Cloud, CloudRain, CloudLightning, Snowflake, ChevronDown, Eye, EyeOff,
|
||||
} from 'lucide-react'
|
||||
import type { JourneyEntry, JourneyPhoto, JourneyDetail } from '../store/journeyStore'
|
||||
|
||||
@@ -64,20 +64,14 @@ function groupByDate(entries: JourneyEntry[]): Map<string, JourneyEntry[]> {
|
||||
function formatDate(d: string): { weekday: string; month: string; day: number } {
|
||||
const date = new Date(d + 'T00:00:00')
|
||||
return {
|
||||
weekday: date.toLocaleDateString('en', { weekday: 'long' }),
|
||||
month: date.toLocaleDateString('en', { month: 'long' }),
|
||||
weekday: date.toLocaleDateString(undefined, { weekday: 'long' }),
|
||||
month: date.toLocaleDateString(undefined, { month: 'long' }),
|
||||
day: date.getDate(),
|
||||
}
|
||||
}
|
||||
|
||||
function photoUrl(p: JourneyPhoto, size: 'thumbnail' | 'original' = 'thumbnail'): string {
|
||||
if (p.provider === 'local') {
|
||||
return `/uploads/${p.file_path}`
|
||||
}
|
||||
// Immich / Synology — stream through the existing memories proxy
|
||||
// tripId=0 is a placeholder, the proxy uses owner_id to find credentials
|
||||
const kind = size === 'thumbnail' ? 'thumbnail' : 'original'
|
||||
return `/api/integrations/memories/${p.provider}/assets/0/${p.asset_id}/${p.owner_id}/${kind}`
|
||||
return `/api/photos/${p.photo_id}/${size}`
|
||||
}
|
||||
|
||||
export default function JourneyDetailPage() {
|
||||
@@ -85,7 +79,7 @@ export default function JourneyDetailPage() {
|
||||
const navigate = useNavigate()
|
||||
const toast = useToast()
|
||||
const { t } = useTranslation()
|
||||
const { current, loading, loadJourney, updateEntry, deleteEntry, uploadPhotos, deletePhoto } = useJourneyStore()
|
||||
const { current, loading, notFound, loadJourney, updateEntry, deleteEntry, uploadPhotos, deletePhoto } = useJourneyStore()
|
||||
const mapRef = useRef<JourneyMapHandle>(null)
|
||||
const fullMapRef = useRef<JourneyMapHandle>(null)
|
||||
const [activeLocationId, setActiveLocationId] = useState<string | null>(null)
|
||||
@@ -98,11 +92,23 @@ export default function JourneyDetailPage() {
|
||||
const [showAddTrip, setShowAddTrip] = useState(false)
|
||||
const [unlinkTrip, setUnlinkTrip] = useState<{ trip_id: number; title: string } | null>(null)
|
||||
const [showSettings, setShowSettings] = useState(false)
|
||||
const [hideSkeletons, setHideSkeletons] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (id) loadJourney(Number(id))
|
||||
if (id) loadJourney(Number(id)).catch(() => {})
|
||||
}, [id])
|
||||
|
||||
useEffect(() => {
|
||||
if (current?.hide_skeletons !== undefined) setHideSkeletons(current.hide_skeletons)
|
||||
}, [current?.hide_skeletons])
|
||||
|
||||
useEffect(() => {
|
||||
if (notFound) {
|
||||
toast.error(t('journey.notFound'))
|
||||
navigate('/journey')
|
||||
}
|
||||
}, [notFound])
|
||||
|
||||
// WebSocket real-time updates
|
||||
useEffect(() => {
|
||||
if (!id) return
|
||||
@@ -157,6 +163,16 @@ export default function JourneyDetailPage() {
|
||||
[current?.entries]
|
||||
)
|
||||
|
||||
const sidebarMapItems = useMemo(() => mapEntries.map(e => ({
|
||||
id: String(e.id),
|
||||
lat: e.location_lat!,
|
||||
lng: e.location_lng!,
|
||||
title: e.title || '',
|
||||
mood: e.mood,
|
||||
created_at: e.entry_date,
|
||||
entry_date: e.entry_date,
|
||||
})), [mapEntries])
|
||||
|
||||
const tripDates = useMemo(() => {
|
||||
const dates = new Set<string>()
|
||||
if (!current?.trips) return dates
|
||||
@@ -182,7 +198,7 @@ export default function JourneyDetailPage() {
|
||||
)
|
||||
}
|
||||
|
||||
const timelineEntries = current.entries.filter(e => e.title !== 'Gallery' && e.title !== '[Trip Photos]')
|
||||
const timelineEntries = current.entries.filter(e => e.title !== 'Gallery' && e.title !== '[Trip Photos]' && (!hideSkeletons || e.type !== 'skeleton'))
|
||||
const dayGroups = groupByDate(timelineEntries)
|
||||
const sortedDates = [...dayGroups.keys()].sort()
|
||||
|
||||
@@ -195,7 +211,7 @@ export default function JourneyDetailPage() {
|
||||
{/* Back link — desktop */}
|
||||
<button onClick={() => navigate('/journey')} className="hidden md:inline-flex items-center gap-1.5 text-[12px] text-zinc-500 hover:text-zinc-700 dark:hover:text-zinc-300 mb-4 mx-0">
|
||||
<ArrowLeft size={14} />
|
||||
Back to Journey
|
||||
{t('journey.detail.backToJourney')}
|
||||
</button>
|
||||
|
||||
{/* Hero card — full width */}
|
||||
@@ -220,7 +236,7 @@ export default function JourneyDetailPage() {
|
||||
)}
|
||||
<div className="inline-flex items-center gap-1.5 px-2.5 py-1 bg-white/[0.12] backdrop-blur border border-white/15 rounded-full text-[11px] font-medium">
|
||||
<RefreshCw size={11} />
|
||||
Synced with Trips
|
||||
{t('journey.detail.syncedWithTrips')}
|
||||
</div>
|
||||
</div>
|
||||
{/* Mobile: back button on the left */}
|
||||
@@ -232,7 +248,21 @@ export default function JourneyDetailPage() {
|
||||
</button>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<button onClick={() => { import('../components/PDF/JourneyBookPDF').then(m => m.downloadJourneyBookPDF(current)) }} className="w-[34px] h-[34px] rounded-lg bg-white/15 backdrop-blur flex items-center justify-center hover:bg-white/25"><Download size={14} /></button>
|
||||
<button onClick={() => setShowSettings(true)} className="w-[34px] h-[34px] rounded-lg bg-white/15 backdrop-blur flex items-center justify-center hover:bg-white/25"><Share2 size={14} /></button>
|
||||
<div className="relative group">
|
||||
<button
|
||||
onClick={async () => {
|
||||
const next = !hideSkeletons
|
||||
setHideSkeletons(next)
|
||||
await journeyApi.updatePreferences(current.id, { hide_skeletons: next })
|
||||
}}
|
||||
className={`w-[34px] h-[34px] rounded-lg backdrop-blur flex items-center justify-center ${hideSkeletons ? 'bg-white/30' : 'bg-white/15 hover:bg-white/25'}`}
|
||||
>
|
||||
{hideSkeletons ? <EyeOff size={14} /> : <Eye size={14} />}
|
||||
</button>
|
||||
<span className="absolute top-full mt-2 left-1/2 -translate-x-1/2 px-2 py-1 rounded-md bg-zinc-900 dark:bg-zinc-100 text-white dark:text-zinc-900 text-[11px] font-medium whitespace-nowrap opacity-0 pointer-events-none group-hover:opacity-100 transition-opacity">
|
||||
{hideSkeletons ? t('journey.skeletons.show') : t('journey.skeletons.hide')}
|
||||
</span>
|
||||
</div>
|
||||
<button onClick={() => setShowSettings(true)} className="w-[34px] h-[34px] rounded-lg bg-white/15 backdrop-blur flex items-center justify-center hover:bg-white/25"><MoreHorizontal size={14} /></button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -326,7 +356,7 @@ export default function JourneyDetailPage() {
|
||||
{dayIdx + 1}
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-[14px] font-semibold text-zinc-900 dark:text-white">{fd.weekday}, {fd.month} {fd.day}</h3>
|
||||
<h3 className="text-[14px] font-semibold text-zinc-900 dark:text-white">{new Date(date + 'T00:00:00').toLocaleDateString(undefined, { weekday: 'long', day: 'numeric', month: 'long' })}</h3>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-[11px] text-zinc-500">
|
||||
@@ -386,17 +416,9 @@ export default function JourneyDetailPage() {
|
||||
<JourneyMap
|
||||
ref={mapRef}
|
||||
checkins={[]}
|
||||
entries={mapEntries.map(e => ({
|
||||
id: String(e.id),
|
||||
lat: e.location_lat!,
|
||||
lng: e.location_lng!,
|
||||
title: e.title || '',
|
||||
mood: e.mood,
|
||||
created_at: e.entry_date,
|
||||
entry_date: e.entry_date,
|
||||
})) as any}
|
||||
entries={sidebarMapItems as any}
|
||||
height={240}
|
||||
onMarkerClick={(id) => handleMarkerClick(id)}
|
||||
onMarkerClick={handleMarkerClick}
|
||||
/>
|
||||
<div className="px-3.5 py-2.5 border-t border-zinc-200 dark:border-zinc-700 flex items-center justify-between text-[11px] text-zinc-500">
|
||||
<span>{mapEntries.length} {t('journey.stats.places')}</span>
|
||||
@@ -405,17 +427,17 @@ export default function JourneyDetailPage() {
|
||||
|
||||
{/* Stats panel */}
|
||||
<div className="bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-700 rounded-xl p-4">
|
||||
<div className="text-[10px] font-semibold tracking-[0.1em] uppercase text-zinc-500 mb-3.5">{t('journey.detail.journeyStats')}</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="text-[10px] font-semibold tracking-[0.1em] uppercase text-zinc-500 mb-3">{t('journey.detail.journeyStats')}</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{[
|
||||
{ value: `${sortedDates.length}`, label: t('journey.stats.days') },
|
||||
{ value: `${current.stats.entries}`, label: t('journey.stats.entries') },
|
||||
{ value: `${current.stats.photos}`, label: t('journey.stats.photos') },
|
||||
{ value: `${current.stats.cities}`, label: t('journey.stats.cities') },
|
||||
{ value: sortedDates.length, label: t('journey.stats.days') },
|
||||
{ value: current.stats.entries, label: t('journey.stats.entries') },
|
||||
{ value: current.stats.photos, label: t('journey.stats.photos') },
|
||||
{ value: current.stats.cities, label: t('journey.stats.cities') },
|
||||
].map(s => (
|
||||
<div key={s.label}>
|
||||
<div className="text-[20px] font-bold tracking-[-0.02em] text-zinc-900 dark:text-white">{s.value}</div>
|
||||
<div className="text-[10px] uppercase tracking-[0.08em] text-zinc-500 font-medium">{s.label}</div>
|
||||
<div key={s.label} className="rounded-lg bg-zinc-50 dark:bg-zinc-800/60 border border-zinc-100 dark:border-zinc-700/50 px-3 py-2.5">
|
||||
<div className="text-[18px] font-bold tracking-[-0.02em] text-zinc-900 dark:text-white leading-none mb-0.5">{s.value}</div>
|
||||
<div className="text-[9px] uppercase tracking-[0.1em] text-zinc-400 dark:text-zinc-500 font-semibold">{s.label}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -440,7 +462,7 @@ export default function JourneyDetailPage() {
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-xs font-medium text-zinc-900 dark:text-white truncate">{trip.title}</div>
|
||||
<div className="text-[10px] text-zinc-500 flex items-center gap-1.5">
|
||||
{trip.place_count || 0} places
|
||||
{trip.place_count || 0} {t('journey.detail.places')}
|
||||
<span className="inline-flex items-center gap-0.5 px-1.5 py-px rounded-full bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 text-[9px] font-medium"><span className="w-1 h-1 rounded-full bg-emerald-500" />{t('journey.synced.synced')}</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -674,7 +696,7 @@ function MapView({ entries, mapEntries, sortedDates, activeLocationId, fullMapRe
|
||||
<div key={date}>
|
||||
{/* Day separator */}
|
||||
<div className="flex items-center gap-2.5 py-3">
|
||||
<span className="text-[10px] font-bold text-zinc-500 dark:text-zinc-400 tracking-[0.12em] uppercase">Day {dayIdx + 1}</span>
|
||||
<span className="text-[10px] font-bold text-zinc-500 dark:text-zinc-400 tracking-[0.12em] uppercase">{t('journey.detail.day', { number: dayIdx + 1 })}</span>
|
||||
<span className="text-[10px] text-zinc-400 font-medium">{fd.month} {fd.day}</span>
|
||||
<div className="flex-1 h-px bg-zinc-200 dark:bg-zinc-700" />
|
||||
</div>
|
||||
@@ -750,6 +772,7 @@ function GalleryView({ entries, journeyId, userId, trips, onPhotoClick, onRefres
|
||||
const [showPicker, setShowPicker] = useState(false)
|
||||
const [pickerProvider, setPickerProvider] = useState<string | null>(null)
|
||||
const [availableProviders, setAvailableProviders] = useState<{ id: string; name: string }[]>([])
|
||||
const [galleryUploading, setGalleryUploading] = useState(false)
|
||||
const toast = useToast()
|
||||
|
||||
// check which providers are enabled AND connected for the current user
|
||||
@@ -794,37 +817,50 @@ function GalleryView({ entries, journeyId, userId, trips, onPhotoClick, onRefres
|
||||
const handleGalleryUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = e.target.files
|
||||
if (!files?.length) return
|
||||
// find existing "Gallery" entry or create one
|
||||
let galleryEntry = entries.find(e => e.title === 'Gallery' && e.type === 'entry')
|
||||
let entryId = galleryEntry?.id
|
||||
if (!entryId) {
|
||||
try {
|
||||
setGalleryUploading(true)
|
||||
try {
|
||||
// find existing "Gallery" entry or create one
|
||||
let galleryEntry = entries.find(e => e.title === 'Gallery' && e.type === 'entry')
|
||||
let entryId = galleryEntry?.id
|
||||
if (!entryId) {
|
||||
const entry = await journeyApi.createEntry(journeyId, {
|
||||
title: t('journey.share.gallery'),
|
||||
entry_date: new Date().toISOString().split('T')[0],
|
||||
type: 'entry',
|
||||
})
|
||||
entryId = entry.id
|
||||
} catch { return }
|
||||
}
|
||||
const formData = new FormData()
|
||||
for (const f of files) formData.append('photos', f)
|
||||
try {
|
||||
}
|
||||
const formData = new FormData()
|
||||
for (const f of files) formData.append('photos', f)
|
||||
await journeyApi.uploadPhotos(entryId, formData)
|
||||
toast.success(t('journey.photosUploaded', { count: files.length }))
|
||||
onRefresh()
|
||||
} catch {
|
||||
toast.error(t('journey.settings.coverFailed'))
|
||||
} finally {
|
||||
setGalleryUploading(false)
|
||||
}
|
||||
e.target.value = ''
|
||||
}
|
||||
|
||||
const handleDeletePhoto = async (photoId: number) => {
|
||||
// Optimistic update — remove photo from local state immediately
|
||||
const store = useJourneyStore.getState()
|
||||
if (store.current) {
|
||||
const updated = {
|
||||
...store.current,
|
||||
entries: store.current.entries.map(e => ({
|
||||
...e,
|
||||
photos: e.photos.filter(p => p.id !== photoId),
|
||||
})).filter(e => e.type !== 'entry' || e.title !== 'Gallery' || e.photos.length > 0 || e.story),
|
||||
}
|
||||
useJourneyStore.setState({ current: updated })
|
||||
}
|
||||
try {
|
||||
await journeyApi.deletePhoto(photoId)
|
||||
onRefresh()
|
||||
} catch {
|
||||
toast.error(t('common.error'))
|
||||
onRefresh() // Revert on error
|
||||
}
|
||||
}
|
||||
|
||||
@@ -834,14 +870,20 @@ function GalleryView({ entries, journeyId, userId, trips, onPhotoClick, onRefres
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-4 flex-wrap gap-2">
|
||||
<span className="text-[11px] text-zinc-500">{allPhotos.length} photos</span>
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-zinc-100 dark:bg-zinc-800 text-[10px] font-medium text-zinc-500 dark:text-zinc-400">
|
||||
<Camera size={10} /> {allPhotos.length} {t('journey.detail.photos')}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => galleryFileRef.current?.click()}
|
||||
className="inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 text-[11px] font-medium hover:bg-zinc-800 dark:hover:bg-zinc-100"
|
||||
disabled={galleryUploading}
|
||||
className="inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 text-[11px] font-medium hover:bg-zinc-800 dark:hover:bg-zinc-100 disabled:opacity-50"
|
||||
>
|
||||
<Plus size={12} />
|
||||
Upload
|
||||
{galleryUploading ? (
|
||||
<><div className="w-3 h-3 border-2 border-white/30 dark:border-zinc-900/30 border-t-white dark:border-t-zinc-900 rounded-full animate-spin" /> {t('journey.editor.uploading')}</>
|
||||
) : (
|
||||
<><Plus size={12} /> {t('common.upload')}</>
|
||||
)}
|
||||
</button>
|
||||
{availableProviders.map(p => (
|
||||
<button
|
||||
@@ -873,7 +915,7 @@ function GalleryView({ entries, journeyId, userId, trips, onPhotoClick, onRefres
|
||||
onClick={() => onPhotoClick(entry.photos, entry.photos.indexOf(photo))}
|
||||
>
|
||||
<img
|
||||
src={photo.provider !== 'local' ? photoUrl(photo, 'original') : photoUrl(photo)}
|
||||
src={photoUrl(photo, 'thumbnail')}
|
||||
alt={photo.caption || ''}
|
||||
className="w-full h-full object-cover transition-transform group-hover:scale-105"
|
||||
loading="lazy"
|
||||
@@ -886,11 +928,11 @@ function GalleryView({ entries, journeyId, userId, trips, onPhotoClick, onRefres
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
{photo.provider !== 'local' && (
|
||||
{photo.provider && photo.provider !== 'local' && (
|
||||
<div className="absolute top-1.5 left-1.5">
|
||||
<span className="text-[8px] font-medium px-1.5 py-0.5 rounded-full bg-black/70 backdrop-blur text-white flex items-center gap-1">
|
||||
<RefreshCw size={7} />
|
||||
{photo.provider === 'immich' ? 'Immich' : 'Synology'}
|
||||
{photo.provider === 'immich' ? 'Immich' : photo.provider === 'synology' ? 'Synology' : photo.provider}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
@@ -901,7 +943,7 @@ function GalleryView({ entries, journeyId, userId, trips, onPhotoClick, onRefres
|
||||
)}
|
||||
<div className="absolute bottom-1.5 left-1.5 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<span className="text-[9px] font-medium px-1.5 py-0.5 rounded-full bg-black/50 backdrop-blur text-white">
|
||||
{entry.entry_date}
|
||||
{new Date(entry.entry_date + 'T12:00:00').toLocaleDateString(undefined, { day: 'numeric', month: 'short', year: 'numeric' })}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -931,12 +973,10 @@ function GalleryView({ entries, journeyId, userId, trips, onPhotoClick, onRefres
|
||||
} catch { return }
|
||||
}
|
||||
let added = 0
|
||||
for (const assetId of assetIds) {
|
||||
try {
|
||||
await journeyApi.addProviderPhoto(targetId, pickerProvider!, assetId)
|
||||
added++
|
||||
} catch {}
|
||||
}
|
||||
try {
|
||||
const result = await journeyApi.addProviderPhotos(targetId, pickerProvider!, assetIds)
|
||||
added = result.added || 0
|
||||
} catch {}
|
||||
if (added > 0) {
|
||||
toast.success(t('journey.photosAdded', { count: added }))
|
||||
onRefresh()
|
||||
@@ -952,6 +992,7 @@ function GalleryView({ entries, journeyId, userId, trips, onPhotoClick, onRefres
|
||||
// ── Expandable Story ─────────────────────────────────────────────────────
|
||||
|
||||
function ExpandableStory({ story }: { story: string }) {
|
||||
const { t } = useTranslation()
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const [clamped, setClamped] = useState(false)
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
@@ -977,16 +1018,24 @@ function ExpandableStory({ story }: { story: string }) {
|
||||
onClick={() => { if (clamped || expanded) setExpanded(e => !e) }}
|
||||
className={`text-[13px] text-zinc-700 dark:text-zinc-300 leading-relaxed ${
|
||||
expanded ? '' : 'line-clamp-3 md:line-clamp-[9]'
|
||||
} ${clamped || expanded ? 'cursor-pointer md:cursor-auto' : ''}`}
|
||||
} ${clamped || expanded ? 'cursor-pointer' : ''}`}
|
||||
>
|
||||
<JournalBody text={story} />
|
||||
</div>
|
||||
{clamped && !expanded && (
|
||||
<button
|
||||
onClick={() => setExpanded(true)}
|
||||
className="md:hidden mt-2 inline-flex items-center gap-1 px-2.5 py-1 rounded-full bg-zinc-100 dark:bg-zinc-800 text-[10px] font-medium text-zinc-500 dark:text-zinc-400 active:scale-95 transition-transform"
|
||||
className="mt-2 inline-flex items-center gap-1 px-2.5 py-1 rounded-full bg-zinc-100 dark:bg-zinc-800 text-[10px] font-medium text-zinc-500 dark:text-zinc-400 hover:bg-zinc-200 dark:hover:bg-zinc-700 active:scale-95 transition-all"
|
||||
>
|
||||
Read more <ChevronRight size={10} />
|
||||
{t('common.showMore')} <ChevronRight size={10} />
|
||||
</button>
|
||||
)}
|
||||
{expanded && (
|
||||
<button
|
||||
onClick={() => setExpanded(false)}
|
||||
className="mt-2 inline-flex items-center gap-1 px-2.5 py-1 rounded-full bg-zinc-100 dark:bg-zinc-800 text-[10px] font-medium text-zinc-500 dark:text-zinc-400 hover:bg-zinc-200 dark:hover:bg-zinc-700 active:scale-95 transition-all"
|
||||
>
|
||||
{t('common.showLess')} <ChevronRight size={10} className="rotate-[-90deg]" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
@@ -1008,7 +1057,7 @@ function VerdictSection({ pros, cons }: { pros: string[]; cons: string[] }) {
|
||||
>
|
||||
<div className="flex-1 h-px bg-zinc-200 dark:bg-zinc-700" />
|
||||
<span className="text-[10px] font-bold tracking-[0.14em] uppercase text-zinc-400 flex items-center gap-1.5">
|
||||
Pros & Cons
|
||||
{t('journey.editor.prosCons')}
|
||||
<ChevronDown
|
||||
size={12}
|
||||
className={`md:hidden text-zinc-400 transition-transform duration-300 ${open ? 'rotate-180' : ''}`}
|
||||
@@ -1046,12 +1095,12 @@ function VerdictSection({ pros, cons }: { pros: string[]; cons: string[] }) {
|
||||
}`}
|
||||
>
|
||||
{pros.length > 0 && (
|
||||
<div className="rounded-xl border border-green-200 dark:border-green-800/30 p-4" style={{ background: 'linear-gradient(180deg, #F0FDF4 0%, white 100%)' }}>
|
||||
<div className="rounded-xl border border-green-200 dark:border-green-800/30 p-4 bg-gradient-to-b from-green-50 to-white dark:from-green-950/30 dark:to-zinc-900">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<div className="w-6 h-6 rounded-lg bg-green-500 flex items-center justify-center">
|
||||
<Check size={14} className="text-white" strokeWidth={3} />
|
||||
</div>
|
||||
<span className="hidden md:inline text-[11px] font-bold tracking-[0.1em] uppercase text-green-700">{t('journey.verdict.lovedIt')}</span>
|
||||
<span className="hidden md:inline text-[11px] font-bold tracking-[0.1em] uppercase text-green-700 dark:text-green-400">{t('journey.verdict.lovedIt')}</span>
|
||||
<span className="ml-auto text-[11px] font-semibold text-green-600">{pros.length}</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
@@ -1065,12 +1114,12 @@ function VerdictSection({ pros, cons }: { pros: string[]; cons: string[] }) {
|
||||
</div>
|
||||
)}
|
||||
{cons.length > 0 && (
|
||||
<div className="rounded-xl border border-red-200 dark:border-red-800/30 p-4" style={{ background: 'linear-gradient(180deg, #FEF2F2 0%, white 100%)' }}>
|
||||
<div className="rounded-xl border border-red-200 dark:border-red-800/30 p-4 bg-gradient-to-b from-red-50 to-white dark:from-red-950/30 dark:to-zinc-900">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<div className="w-6 h-6 rounded-lg bg-red-500 flex items-center justify-center">
|
||||
<Minus size={14} className="text-white" strokeWidth={3} />
|
||||
</div>
|
||||
<span className="hidden md:inline text-[11px] font-bold tracking-[0.1em] uppercase text-red-700">{t('journey.verdict.couldBeBetter')}</span>
|
||||
<span className="hidden md:inline text-[11px] font-bold tracking-[0.1em] uppercase text-red-700 dark:text-red-400">{t('journey.verdict.couldBeBetter')}</span>
|
||||
<span className="ml-auto text-[11px] font-semibold text-red-600">{cons.length}</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
@@ -1272,7 +1321,7 @@ function CheckinCard({ entry, onClick }: { entry: JourneyEntry; onClick: () => v
|
||||
}
|
||||
|
||||
function PhotoImg({ photo, className, style, onClick }: { photo: JourneyPhoto; className?: string; style?: React.CSSProperties; onClick?: () => void }) {
|
||||
const src = photo.provider !== 'local' ? photoUrl(photo, 'original') : photoUrl(photo)
|
||||
const src = photoUrl(photo, 'thumbnail')
|
||||
return (
|
||||
<img
|
||||
src={src}
|
||||
@@ -1356,6 +1405,24 @@ function WeatherChip({ weather }: { weather: string }) {
|
||||
)
|
||||
}
|
||||
|
||||
// ── Scroll Trigger ───────────────────────────────────────────────────────
|
||||
|
||||
function ScrollTrigger({ onVisible, loading }: { onVisible: () => void; loading: boolean }) {
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
useEffect(() => {
|
||||
const el = ref.current
|
||||
if (!el) return
|
||||
const obs = new IntersectionObserver(([entry]) => { if (entry.isIntersecting && !loading) onVisible() }, { rootMargin: '200px' })
|
||||
obs.observe(el)
|
||||
return () => obs.disconnect()
|
||||
}, [onVisible, loading])
|
||||
return (
|
||||
<div ref={ref} className="flex justify-center py-4 mt-2">
|
||||
<div className="w-5 h-5 border-2 border-zinc-300 border-t-zinc-900 dark:border-zinc-600 dark:border-t-white rounded-full animate-spin" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Provider Picker ───────────────────────────────────────────────────────
|
||||
|
||||
function ProviderPicker({ provider, userId, entries, trips, existingAssetIds, onClose, onAdd }: {
|
||||
@@ -1368,16 +1435,23 @@ function ProviderPicker({ provider, userId, entries, trips, existingAssetIds, on
|
||||
onAdd: (assetIds: string[], entryId: number | null) => Promise<void>
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const [filter, setFilter] = useState<'trip' | 'custom' | 'album'>('trip')
|
||||
const [filter, setFilter] = useState<'trip' | 'custom' | 'all' | 'album'>('trip')
|
||||
const [photos, setPhotos] = useState<any[]>([])
|
||||
const [albums, setAlbums] = useState<any[]>([])
|
||||
const [selectedAlbum, setSelectedAlbum] = useState<string | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [loadingMore, setLoadingMore] = useState(false)
|
||||
const [hasMore, setHasMore] = useState(false)
|
||||
const [searchPage, setSearchPage] = useState(1)
|
||||
const [searchFrom, setSearchFrom] = useState('')
|
||||
const [searchTo, setSearchTo] = useState('')
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set())
|
||||
const [customFrom, setCustomFrom] = useState('')
|
||||
const [customTo, setCustomTo] = useState('')
|
||||
const [targetEntryId, setTargetEntryId] = useState<number | null>(null)
|
||||
const [addToOpen, setAddToOpen] = useState(false)
|
||||
const abortRef = useRef<AbortController | null>(null)
|
||||
const gridRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// compute trip range
|
||||
const tripRange = useMemo(() => {
|
||||
@@ -1389,17 +1463,53 @@ function ProviderPicker({ provider, userId, entries, trips, existingAssetIds, on
|
||||
return { from, to }
|
||||
}, [trips])
|
||||
|
||||
const searchPhotos = async (from: string, to: string) => {
|
||||
setLoading(true)
|
||||
const cancelPending = () => {
|
||||
if (abortRef.current) { abortRef.current.abort() }
|
||||
abortRef.current = new AbortController()
|
||||
return abortRef.current.signal
|
||||
}
|
||||
|
||||
const searchPhotos = async (from: string, to: string, page: number = 1, append: boolean = false) => {
|
||||
const signal = cancelPending()
|
||||
if (page === 1) { setLoading(true); setPhotos([]) } else { setLoadingMore(true) }
|
||||
setSearchFrom(from)
|
||||
setSearchTo(to)
|
||||
setSearchPage(page)
|
||||
try {
|
||||
const res = await fetch(`/api/integrations/memories/${provider}/search`, {
|
||||
method: 'POST', credentials: 'include',
|
||||
method: 'POST', credentials: 'include', signal,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ from, to }),
|
||||
body: JSON.stringify({ from, to, page, size: 50 }),
|
||||
})
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
const assets = data.assets || []
|
||||
setPhotos(prev => append ? [...prev, ...assets] : assets)
|
||||
setHasMore(!!data.hasMore)
|
||||
} else {
|
||||
setHasMore(false)
|
||||
}
|
||||
} catch (e: any) {
|
||||
if (e.name !== 'AbortError') setHasMore(false)
|
||||
}
|
||||
if (!signal.aborted) { setLoading(false); setLoadingMore(false) }
|
||||
}
|
||||
|
||||
const loadMorePhotos = () => {
|
||||
if (loadingMore || !hasMore) return
|
||||
searchPhotos(searchFrom, searchTo, searchPage + 1, true)
|
||||
}
|
||||
|
||||
const loadAlbumPhotos = async (albumId: string) => {
|
||||
const signal = cancelPending()
|
||||
setLoading(true)
|
||||
setPhotos([])
|
||||
setHasMore(false)
|
||||
try {
|
||||
const res = await fetch(`/api/integrations/memories/${provider}/albums/${albumId}/photos`, { credentials: 'include', signal })
|
||||
if (res.ok) setPhotos((await res.json()).assets || [])
|
||||
} catch {}
|
||||
setLoading(false)
|
||||
} catch (e: any) { if (e.name !== 'AbortError') {} }
|
||||
if (!signal.aborted) setLoading(false)
|
||||
}
|
||||
|
||||
const loadAlbums = async () => {
|
||||
@@ -1413,6 +1523,8 @@ function ProviderPicker({ provider, userId, entries, trips, existingAssetIds, on
|
||||
useEffect(() => {
|
||||
if (filter === 'trip' && tripRange.from && tripRange.to) {
|
||||
searchPhotos(tripRange.from, tripRange.to)
|
||||
} else if (filter === 'all') {
|
||||
searchPhotos('', '')
|
||||
} else if (filter === 'album' && albums.length === 0) {
|
||||
loadAlbums()
|
||||
}
|
||||
@@ -1432,7 +1544,7 @@ function ProviderPicker({ provider, userId, entries, trips, existingAssetIds, on
|
||||
|
||||
const targetLabel = targetEntryId
|
||||
? entries.find(e => e.id === targetEntryId)?.title || entries.find(e => e.id === targetEntryId)?.entry_date || t('journey.stats.entries')
|
||||
: 'Gallery'
|
||||
: t('journey.picker.newGallery')
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[200] flex items-center justify-center p-5" style={{ background: 'rgba(9,9,11,0.6)', backdropFilter: 'blur(6px)' }}>
|
||||
@@ -1453,9 +1565,10 @@ function ProviderPicker({ provider, userId, entries, trips, existingAssetIds, on
|
||||
{/* Tabs */}
|
||||
<div className="flex gap-1.5 mb-3">
|
||||
{[
|
||||
{ id: 'trip' as const, label: t('journey.trips.link') },
|
||||
{ id: 'custom' as const, label: t('common.edit') },
|
||||
{ id: 'album' as const, label: t('journey.share.gallery') },
|
||||
{ id: 'trip' as const, label: t('journey.picker.tripPeriod') },
|
||||
{ id: 'custom' as const, label: t('journey.picker.dateRange') },
|
||||
{ id: 'all' as const, label: t('journey.picker.allPhotos') },
|
||||
{ id: 'album' as const, label: t('journey.picker.albums') },
|
||||
].map(f => (
|
||||
<button
|
||||
key={f.id}
|
||||
@@ -1479,11 +1592,11 @@ function ProviderPicker({ provider, userId, entries, trips, existingAssetIds, on
|
||||
<>
|
||||
<Calendar size={13} className="text-zinc-400" />
|
||||
<span className="font-medium text-zinc-900 dark:text-white">
|
||||
{new Date(tripRange.from + 'T00:00:00').toLocaleDateString('en', { month: 'short', day: 'numeric' })}
|
||||
{new Date(tripRange.from + 'T00:00:00').toLocaleDateString(undefined, { month: 'short', day: 'numeric' })}
|
||||
</span>
|
||||
<span className="text-zinc-400">—</span>
|
||||
<span className="font-medium text-zinc-900 dark:text-white">
|
||||
{new Date(tripRange.to + 'T00:00:00').toLocaleDateString('en', { month: 'short', day: 'numeric', year: 'numeric' })}
|
||||
{new Date(tripRange.to + 'T00:00:00').toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })}
|
||||
</span>
|
||||
<span className="ml-1 text-zinc-400">
|
||||
({Math.ceil((new Date(tripRange.to).getTime() - new Date(tripRange.from).getTime()) / 86400000) + 1} days)
|
||||
@@ -1502,7 +1615,7 @@ function ProviderPicker({ provider, userId, entries, trips, existingAssetIds, on
|
||||
<div className="flex-1"><DatePicker value={customTo} onChange={setCustomTo} /></div>
|
||||
<button onClick={handleCustomSearch}
|
||||
className="px-3 py-1.5 rounded-lg bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 text-[12px] font-medium hover:bg-zinc-800 dark:hover:bg-zinc-100 flex-shrink-0">
|
||||
Search
|
||||
{t('journey.picker.search')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
@@ -1512,7 +1625,7 @@ function ProviderPicker({ provider, userId, entries, trips, existingAssetIds, on
|
||||
{albums.map((a: any) => (
|
||||
<button
|
||||
key={a.id}
|
||||
onClick={() => { setSelectedAlbum(a.id); searchPhotos(a.startDate || '2000-01-01', a.endDate || '2099-01-01') }}
|
||||
onClick={() => { setSelectedAlbum(a.id); loadAlbumPhotos(a.id) }}
|
||||
className={`px-2.5 py-1 rounded-lg text-[11px] font-medium whitespace-nowrap flex-shrink-0 border ${
|
||||
selectedAlbum === a.id
|
||||
? 'bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 border-zinc-900 dark:border-white'
|
||||
@@ -1522,16 +1635,16 @@ function ProviderPicker({ provider, userId, entries, trips, existingAssetIds, on
|
||||
{a.albumName || a.name || 'Album'}{a.assetCount != null ? ` (${a.assetCount})` : ''}
|
||||
</button>
|
||||
))}
|
||||
{albums.length === 0 && !loading && <span className="text-[12px] text-zinc-400">No albums found</span>}
|
||||
{albums.length === 0 && !loading && <span className="text-[12px] text-zinc-400">{t('journey.picker.noAlbums')}</span>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Add-to */}
|
||||
{/* Add-to entry selector */}
|
||||
<div className="px-6 py-2.5 border-b border-zinc-200 dark:border-zinc-700 bg-zinc-50 dark:bg-zinc-800/50">
|
||||
<div className="relative flex items-center gap-2">
|
||||
<span className="text-[10px] font-semibold tracking-[0.12em] uppercase text-zinc-500">Add to</span>
|
||||
<span className="text-[10px] font-semibold tracking-[0.12em] uppercase text-zinc-500">{t('journey.picker.addTo')}</span>
|
||||
<button
|
||||
onClick={() => setAddToOpen(!addToOpen)}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg border border-zinc-200 dark:border-zinc-700 text-[12px] font-medium text-zinc-700 dark:text-zinc-300 hover:bg-zinc-50 dark:hover:bg-zinc-800"
|
||||
@@ -1552,10 +1665,12 @@ function ProviderPicker({ provider, userId, entries, trips, existingAssetIds, on
|
||||
}`}
|
||||
>
|
||||
<Camera size={12} />
|
||||
Gallery
|
||||
{t('journey.picker.newGallery')}
|
||||
</button>
|
||||
<div className="h-px bg-zinc-200 dark:bg-zinc-700 my-1" />
|
||||
{entries.map(e => (
|
||||
{entries.filter(e => e.type !== 'skeleton' && e.title !== 'Gallery' && e.title !== '[Trip Photos]').length > 0 && (
|
||||
<div className="h-px bg-zinc-200 dark:bg-zinc-700 my-1" />
|
||||
)}
|
||||
{entries.filter(e => e.type !== 'skeleton' && e.title !== 'Gallery' && e.title !== '[Trip Photos]').map(e => (
|
||||
<button
|
||||
key={e.id}
|
||||
onClick={() => { setTargetEntryId(e.id); setAddToOpen(false) }}
|
||||
@@ -1565,7 +1680,7 @@ function ProviderPicker({ provider, userId, entries, trips, existingAssetIds, on
|
||||
: 'text-zinc-700 dark:text-zinc-300 hover:bg-zinc-50 dark:hover:bg-zinc-700'
|
||||
}`}
|
||||
>
|
||||
{e.title || e.location_name || e.entry_date}
|
||||
{e.title || e.location_name || new Date(e.entry_date + 'T12:00:00').toLocaleDateString(undefined, { day: 'numeric', month: 'short' })}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@@ -1574,6 +1689,36 @@ function ProviderPicker({ provider, userId, entries, trips, existingAssetIds, on
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Select all bar — sticky above grid */}
|
||||
{!loading && photos.length > 0 && (() => {
|
||||
const selectable = photos.filter((a: any) => !existingAssetIds.has(a.id))
|
||||
const allSelected = selectable.length > 0 && selectable.every((a: any) => selected.has(a.id))
|
||||
if (selectable.length === 0) return null
|
||||
return (
|
||||
<div className="px-4 py-2 border-b border-zinc-200 dark:border-zinc-700 bg-white dark:bg-zinc-900">
|
||||
<button
|
||||
onClick={() => {
|
||||
if (allSelected) {
|
||||
setSelected(new Set())
|
||||
} else {
|
||||
setSelected(new Set(selectable.map((a: any) => a.id)))
|
||||
}
|
||||
}}
|
||||
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg text-[11px] font-medium border border-zinc-200 dark:border-zinc-700 text-zinc-500 dark:text-zinc-400 hover:bg-zinc-50 dark:hover:bg-zinc-800"
|
||||
>
|
||||
<div className={`w-3.5 h-3.5 rounded border flex items-center justify-center ${
|
||||
allSelected
|
||||
? 'bg-zinc-900 dark:bg-white border-zinc-900 dark:border-white'
|
||||
: 'border-zinc-300 dark:border-zinc-600'
|
||||
}`}>
|
||||
{allSelected && <Check size={9} className="text-white dark:text-zinc-900" strokeWidth={3} />}
|
||||
</div>
|
||||
{allSelected ? t('journey.picker.deselectAll') : t('journey.picker.selectAll')} ({selectable.length})
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
|
||||
{/* Photo grid */}
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
{loading ? (
|
||||
@@ -1632,25 +1777,28 @@ function ProviderPicker({ provider, userId, entries, trips, existingAssetIds, on
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{/* Infinite scroll trigger */}
|
||||
{hasMore && !selectedAlbum && <ScrollTrigger onVisible={loadMorePhotos} loading={loadingMore} />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-t border-zinc-200 dark:border-zinc-700 bg-zinc-50 dark:bg-zinc-800/50">
|
||||
<span className="text-[12px] text-zinc-500">
|
||||
<strong className="text-zinc-900 dark:text-white">{selected.size}</strong> selected
|
||||
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full bg-zinc-200/60 dark:bg-zinc-700/60 text-[11px] leading-none text-zinc-500 dark:text-zinc-400">
|
||||
<span className="inline-flex items-center justify-center min-w-[18px] h-[18px] px-1 rounded-full bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 text-[10px] leading-none font-bold">{selected.size}</span>
|
||||
<span className="leading-[18px]">{t('journey.picker.selected')}</span>
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<button onClick={onClose} className="px-3.5 py-2 rounded-lg border border-zinc-200 dark:border-zinc-600 text-[13px] font-medium text-zinc-700 dark:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-zinc-700">
|
||||
Cancel
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onAdd([...selected], targetEntryId)}
|
||||
disabled={selected.size === 0}
|
||||
className="px-3.5 py-2 rounded-lg bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 text-[13px] font-medium hover:bg-zinc-800 dark:hover:bg-zinc-100 disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
Add {selected.size > 0 ? `(${selected.size})` : ''}
|
||||
{t('common.add')} {selected.size > 0 ? `(${selected.size})` : ''}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1666,6 +1814,7 @@ function DatePicker({ value, onChange, tripDates }: {
|
||||
onChange: (date: string) => void
|
||||
tripDates?: Set<string>
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const [open, setOpen] = useState(false)
|
||||
const [viewMonth, setViewMonth] = useState(() => {
|
||||
const d = value ? new Date(value + 'T00:00:00') : new Date()
|
||||
@@ -1674,7 +1823,7 @@ function DatePicker({ value, onChange, tripDates }: {
|
||||
|
||||
const daysInMonth = new Date(viewMonth.year, viewMonth.month + 1, 0).getDate()
|
||||
const firstDow = new Date(viewMonth.year, viewMonth.month, 1).getDay()
|
||||
const monthName = new Date(viewMonth.year, viewMonth.month).toLocaleDateString('en', { month: 'long', year: 'numeric' })
|
||||
const monthName = new Date(viewMonth.year, viewMonth.month).toLocaleDateString(undefined, { month: 'long', year: 'numeric' })
|
||||
|
||||
const prevMonth = () => {
|
||||
setViewMonth(p => p.month === 0 ? { year: p.year - 1, month: 11 } : { ...p, month: p.month - 1 })
|
||||
@@ -1689,7 +1838,7 @@ function DatePicker({ value, onChange, tripDates }: {
|
||||
for (let i = 0; i < firstDow; i++) cells.push(null)
|
||||
for (let d = 1; d <= daysInMonth; d++) cells.push(d)
|
||||
|
||||
const formatted = value ? new Date(value + 'T00:00:00').toLocaleDateString('en', { month: 'short', day: 'numeric', year: 'numeric' }) : 'Select date'
|
||||
const formatted = value ? new Date(value + 'T00:00:00').toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }) : t('journey.picker.selectDate')
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
@@ -1719,8 +1868,8 @@ function DatePicker({ value, onChange, tripDates }: {
|
||||
|
||||
{/* Weekday headers */}
|
||||
<div className="grid grid-cols-7 mb-1">
|
||||
{['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa'].map(d => (
|
||||
<div key={d} className="text-center text-[10px] font-medium text-zinc-400 py-1">{d}</div>
|
||||
{['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa'].map((d, i) => (
|
||||
<div key={i} className="text-center text-[10px] font-medium text-zinc-400 py-1">{d}</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -1789,6 +1938,7 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa
|
||||
const [pros, setPros] = useState<string[]>(entry.pros_cons?.pros?.length ? entry.pros_cons.pros : [''])
|
||||
const [cons, setCons] = useState<string[]>(entry.pros_cons?.cons?.length ? entry.pros_cons.cons : [''])
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const [photos, setPhotos] = useState<JourneyPhoto[]>(entry.photos || [])
|
||||
const [pendingFiles, setPendingFiles] = useState<File[]>([])
|
||||
const [pendingLinkIds, setPendingLinkIds] = useState<number[]>([])
|
||||
@@ -1837,10 +1987,15 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa
|
||||
// queue files for upload after save
|
||||
setPendingFiles(prev => [...prev, ...Array.from(files)])
|
||||
} else {
|
||||
const formData = new FormData()
|
||||
for (const f of files) formData.append('photos', f)
|
||||
const newPhotos = await onUploadPhotos(entry.id, formData)
|
||||
if (newPhotos?.length) setPhotos(prev => [...prev, ...newPhotos])
|
||||
setUploading(true)
|
||||
try {
|
||||
const formData = new FormData()
|
||||
for (const f of files) formData.append('photos', f)
|
||||
const newPhotos = await onUploadPhotos(entry.id, formData)
|
||||
if (newPhotos?.length) setPhotos(prev => [...prev, ...newPhotos])
|
||||
} finally {
|
||||
setUploading(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1868,9 +2023,14 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => fileRef.current?.click()}
|
||||
className="flex-1 border border-dashed border-zinc-200 dark:border-zinc-700 rounded-lg py-4 text-[12px] text-zinc-500 hover:border-zinc-400 dark:hover:border-zinc-500 hover:bg-zinc-50 dark:hover:bg-zinc-800 flex items-center justify-center gap-1.5"
|
||||
disabled={uploading}
|
||||
className="flex-1 border border-dashed border-zinc-200 dark:border-zinc-700 rounded-lg py-4 text-[12px] text-zinc-500 hover:border-zinc-400 dark:hover:border-zinc-500 hover:bg-zinc-50 dark:hover:bg-zinc-800 flex items-center justify-center gap-1.5 disabled:opacity-50"
|
||||
>
|
||||
<Plus size={13} /> Upload photos
|
||||
{uploading ? (
|
||||
<><div className="w-3.5 h-3.5 border-2 border-zinc-300 border-t-zinc-600 rounded-full animate-spin" /> {t('journey.editor.uploading')}</>
|
||||
) : (
|
||||
<><Plus size={13} /> {t('journey.editor.uploadPhotos')}</>
|
||||
)}
|
||||
</button>
|
||||
{galleryPhotos.length > 0 && (
|
||||
<button
|
||||
@@ -1881,7 +2041,7 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa
|
||||
: 'border-dashed border-zinc-200 dark:border-zinc-700 hover:border-zinc-400 dark:hover:border-zinc-500 hover:bg-zinc-50 dark:hover:bg-zinc-800'
|
||||
}`}
|
||||
>
|
||||
<Image size={13} /> From Gallery
|
||||
<Image size={13} /> {t('journey.editor.fromGallery')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
@@ -1906,7 +2066,7 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa
|
||||
}}
|
||||
className="aspect-square rounded-lg overflow-hidden cursor-pointer hover:ring-2 hover:ring-zinc-900 dark:hover:ring-white hover:ring-offset-1 dark:hover:ring-offset-zinc-900 transition-all"
|
||||
>
|
||||
<img src={photoUrl(gp)} alt="" className="w-full h-full object-cover" loading="lazy" onError={e => { if (gp.provider !== 'local') { const img = e.currentTarget; const orig = photoUrl(gp, 'original'); if (!img.src.includes('/original')) img.src = orig } }} />
|
||||
<img src={photoUrl(gp)} alt="" className="w-full h-full object-cover" loading="lazy" onError={e => { const img = e.currentTarget; const orig = photoUrl(gp, 'original'); if (!img.src.includes('/original')) img.src = orig }} />
|
||||
</div>
|
||||
))}
|
||||
{galleryPhotos.filter(gp => !photos.some(p => p.id === gp.id)).length === 0 && (
|
||||
@@ -1920,7 +2080,7 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{photos.map((p, idx) => (
|
||||
<div key={p.id} className={`w-20 h-20 rounded-lg overflow-hidden relative group ${idx === 0 && photos.length > 1 ? 'ring-2 ring-zinc-900 dark:ring-white ring-offset-1 dark:ring-offset-zinc-900' : ''}`}>
|
||||
<img src={photoUrl(p)} className="w-full h-full object-cover" alt="" onError={e => { if (p.provider !== 'local') { const img = e.currentTarget; const orig = photoUrl(p, 'original'); if (!img.src.includes('/original')) img.src = orig } }} />
|
||||
<img src={photoUrl(p)} className="w-full h-full object-cover" alt="" onError={e => { const img = e.currentTarget; const orig = photoUrl(p, 'original'); if (!img.src.includes('/original')) img.src = orig }} />
|
||||
{idx === 0 && photos.length > 1 && (
|
||||
<span className="absolute bottom-0.5 left-0.5 px-1 py-px rounded text-[8px] font-bold bg-zinc-900/70 text-white">{t('journey.editor.photoFirst')}</span>
|
||||
)}
|
||||
@@ -1938,7 +2098,7 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa
|
||||
}}
|
||||
className="absolute bottom-0.5 left-0.5 px-1.5 py-0.5 rounded bg-black/60 text-white text-[8px] font-semibold opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
>
|
||||
Make 1st
|
||||
{t('journey.editor.makeFirst')}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
@@ -2017,7 +2177,7 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa
|
||||
onClick={() => setPros([...pros, ''])}
|
||||
className="flex items-center justify-center gap-1.5 h-9 w-full border border-dashed border-green-200 dark:border-green-800/40 rounded-[10px] text-[12px] font-medium text-green-700 dark:text-green-400 hover:border-green-300 dark:hover:border-green-700 transition-colors"
|
||||
>
|
||||
<Plus size={13} strokeWidth={2.5} /> Add another
|
||||
<Plus size={13} strokeWidth={2.5} /> {t('journey.editor.addAnother')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -2051,7 +2211,7 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa
|
||||
onClick={() => setCons([...cons, ''])}
|
||||
className="flex items-center justify-center gap-1.5 h-9 w-full border border-dashed border-red-200 dark:border-red-800/40 rounded-[10px] text-[12px] font-medium text-red-700 dark:text-red-400 hover:border-red-300 dark:hover:border-red-700 transition-colors"
|
||||
>
|
||||
<Plus size={13} strokeWidth={2.5} /> Add another
|
||||
<Plus size={13} strokeWidth={2.5} /> {t('journey.editor.addAnother')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -2129,7 +2289,7 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa
|
||||
)}
|
||||
{locationSearching && (
|
||||
<div className="absolute left-0 right-0 top-full mt-1 z-[100] bg-white dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-xl shadow-lg px-3 py-3 text-center text-[12px] text-zinc-400">
|
||||
Searching...
|
||||
{t('journey.editor.searching')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -2203,11 +2363,11 @@ function AddTripDialog({ journeyId, existingTripIds, onClose, onAdded }: {
|
||||
journeyApi.availableTrips().then(d => setTrips(d.trips || [])).catch(() => {})
|
||||
}, [])
|
||||
|
||||
const filtered = trips.filter(t => {
|
||||
if (existingTripIds.includes(t.id)) return false
|
||||
const filtered = trips.filter(trip => {
|
||||
if (existingTripIds.includes(trip.id)) return false
|
||||
if (!search) return true
|
||||
const q = search.toLowerCase()
|
||||
return t.title.toLowerCase().includes(q) || (t.destination || '').toLowerCase().includes(q)
|
||||
return trip.title.toLowerCase().includes(q) || (trip.destination || '').toLowerCase().includes(q)
|
||||
})
|
||||
|
||||
const handleAdd = async (tripId: number) => {
|
||||
@@ -2249,26 +2409,26 @@ function AddTripDialog({ journeyId, existingTripIds, onClose, onAdded }: {
|
||||
{filtered.length === 0 && (
|
||||
<p className="text-[12px] text-zinc-400 text-center py-4">{t('journey.trips.noTripsAvailable')}</p>
|
||||
)}
|
||||
{filtered.map(t => (
|
||||
{filtered.map(trip => (
|
||||
<div
|
||||
key={t.id}
|
||||
key={trip.id}
|
||||
className="flex items-center gap-2.5 p-2.5 rounded-lg hover:bg-zinc-50 dark:hover:bg-zinc-800 border border-transparent"
|
||||
>
|
||||
<div className="w-9 h-9 rounded-md flex-shrink-0" style={{ background: pickGradient(t.id) }} />
|
||||
<div className="w-9 h-9 rounded-md flex-shrink-0" style={{ background: pickGradient(trip.id) }} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-[13px] font-medium text-zinc-900 dark:text-white truncate">{t.title}</div>
|
||||
{(t.destination || t.start_date) && (
|
||||
<div className="text-[13px] font-medium text-zinc-900 dark:text-white truncate">{trip.title}</div>
|
||||
{(trip.destination || trip.start_date) && (
|
||||
<div className="text-[11px] text-zinc-500 truncate">
|
||||
{t.destination}{t.destination && t.start_date ? ' · ' : ''}{t.start_date}
|
||||
{trip.destination}{trip.destination && trip.start_date ? ' · ' : ''}{trip.start_date}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleAdd(t.id)}
|
||||
disabled={adding === t.id}
|
||||
onClick={() => handleAdd(trip.id)}
|
||||
disabled={adding === trip.id}
|
||||
className="px-3 py-1.5 rounded-lg text-[11px] font-semibold bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 hover:bg-zinc-700 dark:hover:bg-zinc-200 disabled:opacity-50"
|
||||
>
|
||||
{adding === t.id ? '...' : 'Link'}
|
||||
{adding === trip.id ? '...' : t('journey.trips.link')}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
@@ -2376,19 +2536,19 @@ function ContributorInviteDialog({ journeyId, existingUserIds, onClose, onInvite
|
||||
|
||||
{/* Role selector */}
|
||||
<div>
|
||||
<label className="text-[10px] font-semibold tracking-[0.12em] uppercase text-zinc-500 block mb-2">Role</label>
|
||||
<label className="text-[10px] font-semibold tracking-[0.12em] uppercase text-zinc-500 block mb-2">{t('journey.invite.role')}</label>
|
||||
<div className="flex gap-2">
|
||||
{(['viewer', 'editor'] as const).map(r => (
|
||||
<button
|
||||
key={r}
|
||||
onClick={() => setRole(r)}
|
||||
className={`flex-1 py-2 rounded-lg text-[12px] font-medium border transition-all capitalize ${
|
||||
className={`flex-1 py-2 rounded-lg text-[12px] font-medium border transition-all ${
|
||||
role === r
|
||||
? 'bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 border-zinc-900 dark:border-white'
|
||||
: 'border-zinc-200 dark:border-zinc-700 text-zinc-500 hover:border-zinc-400'
|
||||
}`}
|
||||
>
|
||||
{r}
|
||||
{t(`journey.invite.${r}`)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@@ -2397,14 +2557,14 @@ function ContributorInviteDialog({ journeyId, existingUserIds, onClose, onInvite
|
||||
|
||||
<div className="flex items-center justify-end gap-2 px-6 py-4 border-t border-zinc-200 dark:border-zinc-700 bg-zinc-50 dark:bg-zinc-800/50">
|
||||
<button onClick={onClose} className="px-3.5 py-2 rounded-lg border border-zinc-200 dark:border-zinc-600 text-[13px] font-medium text-zinc-700 dark:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-zinc-700">
|
||||
Cancel
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleInvite}
|
||||
disabled={!selectedUserId || sending}
|
||||
className="px-3.5 py-2 rounded-lg bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 text-[13px] font-medium hover:bg-zinc-800 dark:hover:bg-zinc-100 disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
{sending ? 'Inviting...' : 'Invite'}
|
||||
{sending ? t('journey.invite.inviting') : t('journey.invite.invite')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -2471,7 +2631,7 @@ function JourneyShareSection({ journeyId }: { journeyId: number }) {
|
||||
onClick={createLink}
|
||||
className="w-full flex items-center justify-center gap-1.5 py-2.5 rounded-lg border border-dashed border-zinc-300 dark:border-zinc-600 text-[12px] font-medium text-zinc-500 hover:border-zinc-400 hover:text-zinc-700 dark:hover:border-zinc-500 dark:hover:text-zinc-300 transition-colors"
|
||||
>
|
||||
<Link size={14} /> Create share link
|
||||
<Link size={14} /> {t('journey.share.createLink')}
|
||||
</button>
|
||||
) : (
|
||||
<div className="flex flex-col gap-3">
|
||||
@@ -2483,7 +2643,7 @@ function JourneyShareSection({ journeyId }: { journeyId: number }) {
|
||||
onClick={copyLink}
|
||||
className="flex-shrink-0 px-2.5 py-1 rounded-md bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 text-[11px] font-medium hover:bg-zinc-700 dark:hover:bg-zinc-200"
|
||||
>
|
||||
{copied ? 'Copied!' : 'Copy'}
|
||||
{copied ? t('journey.share.copied') : t('journey.share.copy')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -2578,8 +2738,8 @@ function JourneySettingsDialog({ journey, onClose, onSaved, onOpenInvite }: {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[200] flex items-center justify-center p-5" style={{ background: 'rgba(9,9,11,0.6)', backdropFilter: 'blur(6px)' }}>
|
||||
<div className="bg-white dark:bg-zinc-900 rounded-2xl shadow-[0_20px_40px_rgba(0,0,0,0.2)] max-w-[480px] w-full max-h-[90vh] flex flex-col overflow-hidden">
|
||||
<div className="fixed inset-0 z-[200] flex items-end md:items-center justify-center md:p-5 overscroll-none" style={{ background: 'rgba(9,9,11,0.6)', backdropFilter: 'blur(6px)' }} onClick={onClose} onTouchMove={e => { if (e.target === e.currentTarget) e.preventDefault() }}>
|
||||
<div className="bg-white dark:bg-zinc-900 rounded-t-2xl md:rounded-2xl shadow-[0_20px_40px_rgba(0,0,0,0.2)] max-w-[480px] w-full max-h-[85vh] md:max-h-[90vh] flex flex-col overflow-hidden pb-safe" onClick={e => e.stopPropagation()}>
|
||||
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-zinc-200 dark:border-zinc-700">
|
||||
<h2 className="text-[16px] font-bold text-zinc-900 dark:text-white">{t('journey.settings.title')}</h2>
|
||||
@@ -2588,7 +2748,7 @@ function JourneySettingsDialog({ journey, onClose, onSaved, onOpenInvite }: {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto px-6 py-5 flex flex-col gap-5">
|
||||
<div className="flex-1 overflow-y-auto overscroll-contain px-6 py-5 flex flex-col gap-5">
|
||||
{/* Cover Image */}
|
||||
<div>
|
||||
<label className="text-[10px] font-semibold tracking-[0.12em] uppercase text-zinc-500 block mb-2">{t('journey.settings.coverImage')}</label>
|
||||
@@ -2691,7 +2851,7 @@ function JourneySettingsDialog({ journey, onClose, onSaved, onOpenInvite }: {
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center gap-2 px-6 py-4 border-t border-zinc-200 dark:border-zinc-700 bg-zinc-50 dark:bg-zinc-800/50">
|
||||
<div className="flex items-center gap-2 px-6 py-4 pb-6 md:pb-4 border-t border-zinc-200 dark:border-zinc-700 bg-zinc-50 dark:bg-zinc-800/50">
|
||||
<button
|
||||
onClick={() => setShowDeleteConfirm(true)}
|
||||
className="flex items-center gap-1.5 text-[12px] font-medium text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-lg px-2.5 py-2 mr-auto"
|
||||
|
||||
@@ -97,7 +97,7 @@ const mockJourneyData = {
|
||||
weather: 'cloudy',
|
||||
pros_cons: null,
|
||||
photos: [
|
||||
{ id: 100, entry_id: 11, provider: 'local', asset_id: null, owner_id: null, file_path: 'journey/temple.jpg', caption: 'Temple entrance' },
|
||||
{ id: 100, entry_id: 11, photo_id: 100, provider: 'local', asset_id: null, owner_id: null, file_path: 'journey/temple.jpg', caption: 'Temple entrance' },
|
||||
],
|
||||
},
|
||||
],
|
||||
@@ -348,9 +348,9 @@ describe('JourneyPublicPage', () => {
|
||||
entry_time: null, location_name: null, location_lat: null, location_lng: null,
|
||||
mood: null, weather: null, pros_cons: null,
|
||||
photos: [
|
||||
{ id: 200, entry_id: 20, provider: 'local', asset_id: null, owner_id: null, file_path: 'journey/a.jpg', caption: 'Photo A' },
|
||||
{ id: 201, entry_id: 20, provider: 'local', asset_id: null, owner_id: null, file_path: 'journey/b.jpg', caption: 'Photo B' },
|
||||
{ id: 202, entry_id: 20, provider: 'local', asset_id: null, owner_id: null, file_path: 'journey/c.jpg', caption: 'Photo C' },
|
||||
{ id: 200, entry_id: 20, photo_id: 200, provider: 'local', asset_id: null, owner_id: null, file_path: 'journey/a.jpg', caption: 'Photo A' },
|
||||
{ id: 201, entry_id: 20, photo_id: 201, provider: 'local', asset_id: null, owner_id: null, file_path: 'journey/b.jpg', caption: 'Photo B' },
|
||||
{ id: 202, entry_id: 20, photo_id: 202, provider: 'local', asset_id: null, owner_id: null, file_path: 'journey/c.jpg', caption: 'Photo C' },
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
@@ -26,7 +26,8 @@ interface PublicEntry {
|
||||
interface PublicPhoto {
|
||||
id: number
|
||||
entry_id: number
|
||||
provider: string
|
||||
photo_id: number
|
||||
provider?: string
|
||||
asset_id?: string | null
|
||||
owner_id?: number | null
|
||||
file_path?: string | null
|
||||
@@ -34,8 +35,7 @@ interface PublicPhoto {
|
||||
}
|
||||
|
||||
function photoUrl(p: PublicPhoto, shareToken: string): string {
|
||||
if (p.provider === 'local') return `/api/public/journey/${shareToken}/photo/local/${encodeURIComponent(p.file_path || '')}/0/original`
|
||||
return `/api/public/journey/${shareToken}/photo/${p.provider}/${p.asset_id}/${p.owner_id}/original`
|
||||
return `/api/public/journey/${shareToken}/photos/${p.photo_id}/original`
|
||||
}
|
||||
|
||||
function formatDate(d: string): { weekday: string; month: string; day: number } {
|
||||
|
||||
@@ -206,7 +206,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
||||
|
||||
useTripWebSocket(tripId)
|
||||
|
||||
const [mapCategoryFilter, setMapCategoryFilter] = useState<string>('')
|
||||
const [mapCategoryFilter, setMapCategoryFilter] = useState<Set<string>>(new Set())
|
||||
const [mapPlacesFilter, setMapPlacesFilter] = useState<string>('all')
|
||||
|
||||
const [expandedDayIds, setExpandedDayIds] = useState<Set<number> | null>(null)
|
||||
@@ -239,7 +239,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
||||
|
||||
return places.filter(p => {
|
||||
if (!p.lat || !p.lng) return false
|
||||
if (mapCategoryFilter && String(p.category_id) !== String(mapCategoryFilter)) return false
|
||||
if (mapCategoryFilter.size > 0 && !mapCategoryFilter.has(String(p.category_id))) return false
|
||||
if (hiddenPlaceIds.has(p.id)) return false
|
||||
if (plannedIds && plannedIds.has(p.id)) return false
|
||||
return true
|
||||
|
||||
@@ -148,6 +148,7 @@ describe('journeyStore', () => {
|
||||
);
|
||||
await expect(useJourneyStore.getState().loadJourney(999)).rejects.toThrow();
|
||||
expect(useJourneyStore.getState().loading).toBe(false);
|
||||
expect(useJourneyStore.getState().notFound).toBe(true);
|
||||
});
|
||||
|
||||
// ── createJourney ────────────────────────────────────────────────────────
|
||||
|
||||
@@ -42,17 +42,19 @@ export interface JourneyEntry {
|
||||
export interface JourneyPhoto {
|
||||
id: number
|
||||
entry_id: number
|
||||
provider: 'local' | 'immich' | 'synologyphotos'
|
||||
photo_id: number
|
||||
caption?: string | null
|
||||
sort_order: number
|
||||
shared: number
|
||||
created_at: number
|
||||
// Joined from trek_photos for display
|
||||
provider?: string
|
||||
asset_id?: string | null
|
||||
owner_id?: number | null
|
||||
file_path?: string | null
|
||||
thumbnail_path?: string | null
|
||||
caption?: string | null
|
||||
sort_order: number
|
||||
width?: number | null
|
||||
height?: number | null
|
||||
shared: number
|
||||
created_at: number
|
||||
}
|
||||
|
||||
export interface JourneyTrip {
|
||||
@@ -80,12 +82,14 @@ export interface JourneyDetail extends Journey {
|
||||
trips: JourneyTrip[]
|
||||
contributors: JourneyContributor[]
|
||||
stats: { entries: number; photos: number; cities: number }
|
||||
hide_skeletons?: boolean
|
||||
}
|
||||
|
||||
interface JourneyState {
|
||||
journeys: Journey[]
|
||||
current: JourneyDetail | null
|
||||
loading: boolean
|
||||
notFound: boolean
|
||||
|
||||
loadJourneys: () => Promise<void>
|
||||
loadJourney: (id: number) => Promise<void>
|
||||
@@ -107,6 +111,7 @@ export const useJourneyStore = create<JourneyState>((set, get) => ({
|
||||
journeys: [],
|
||||
current: null,
|
||||
loading: false,
|
||||
notFound: false,
|
||||
|
||||
loadJourneys: async () => {
|
||||
set({ loading: true })
|
||||
@@ -119,10 +124,15 @@ export const useJourneyStore = create<JourneyState>((set, get) => ({
|
||||
},
|
||||
|
||||
loadJourney: async (id) => {
|
||||
set({ loading: true })
|
||||
set({ loading: true, notFound: false })
|
||||
try {
|
||||
const data = await journeyApi.get(id)
|
||||
set({ current: data })
|
||||
} catch (err: any) {
|
||||
if (err?.response?.status === 404) {
|
||||
set({ current: null, notFound: true })
|
||||
}
|
||||
throw err
|
||||
} finally {
|
||||
set({ loading: false })
|
||||
}
|
||||
|
||||
@@ -43,18 +43,24 @@ export interface Place {
|
||||
trip_id: number
|
||||
name: string
|
||||
description: string | null
|
||||
notes: string | null
|
||||
lat: number | null
|
||||
lng: number | null
|
||||
address: string | null
|
||||
category_id: number | null
|
||||
icon: string | null
|
||||
price: string | null
|
||||
currency: string | null
|
||||
image_url: string | null
|
||||
google_place_id: string | null
|
||||
osm_id: string | null
|
||||
route_geometry: string | null
|
||||
place_time: string | null
|
||||
end_time: string | null
|
||||
duration_minutes: number | null
|
||||
transport_mode: string | null
|
||||
website: string | null
|
||||
phone: string | null
|
||||
created_at: string
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* Custom Vitest environment that extends jsdom but preserves the native
|
||||
* Node.js AbortController and AbortSignal.
|
||||
*
|
||||
* Problem: jsdom replaces globalThis.AbortController and AbortSignal with its
|
||||
* own implementations. Node.js's undici-based fetch validates signals via
|
||||
* `signal instanceof AbortSignal` against its own native class reference.
|
||||
* jsdom's AbortSignal instances fail this check, causing fetch to throw:
|
||||
* TypeError: RequestInit: Expected signal ("AbortSignal {}") to be an
|
||||
* instance of AbortSignal.
|
||||
*
|
||||
* Fix: after jsdom installs its globals, restore the native AbortController
|
||||
* and AbortSignal so fetch works correctly in tests.
|
||||
*/
|
||||
|
||||
import { builtinEnvironments } from 'vitest/environments';
|
||||
|
||||
const jsdomEnv = builtinEnvironments.jsdom;
|
||||
|
||||
export default {
|
||||
name: 'jsdom-native-abort',
|
||||
transformMode: 'web' as const,
|
||||
|
||||
async setup(global: typeof globalThis, options: Record<string, unknown>) {
|
||||
// Capture native AbortController/AbortSignal BEFORE jsdom patches them
|
||||
const NativeAbortController = global.AbortController;
|
||||
const NativeAbortSignal = global.AbortSignal;
|
||||
|
||||
// Run standard jsdom setup (installs jsdom globals, including its own AbortController)
|
||||
const env = await jsdomEnv.setup(global, options as Parameters<typeof jsdomEnv.setup>[1]);
|
||||
|
||||
// Restore native AbortController so Node.js fetch (undici) accepts the signals
|
||||
global.AbortController = NativeAbortController;
|
||||
global.AbortSignal = NativeAbortSignal;
|
||||
|
||||
return env;
|
||||
},
|
||||
};
|
||||
@@ -6,7 +6,7 @@ export default defineConfig({
|
||||
test: {
|
||||
root: '.',
|
||||
globals: true,
|
||||
environment: 'jsdom',
|
||||
environment: './tests/environment/jsdom-native-abort.ts',
|
||||
include: [
|
||||
'tests/**/*.test.{ts,tsx}',
|
||||
'src/**/*.test.{ts,tsx}',
|
||||
|
||||
@@ -36,6 +36,7 @@ import { oauthPublicRouter, oauthApiRouter } from './routes/oauth';
|
||||
import vacayRoutes from './routes/vacay';
|
||||
import atlasRoutes from './routes/atlas';
|
||||
import memoriesRoutes from './routes/memories/unified';
|
||||
import photoRoutes from './routes/photos';
|
||||
import notificationRoutes from './routes/notifications';
|
||||
import shareRoutes from './routes/share';
|
||||
import journeyRoutes from './routes/journey';
|
||||
@@ -266,6 +267,7 @@ export function createApp(): express.Application {
|
||||
app.use('/api/journeys', journeyRoutes);
|
||||
app.use('/api/public/journey', journeyPublicRoutes);
|
||||
app.use('/api/integrations/memories', memoriesRoutes);
|
||||
app.use('/api/photos', photoRoutes);
|
||||
app.use('/api/maps', mapsRoutes);
|
||||
app.use('/api/weather', weatherRoutes);
|
||||
app.use('/api/settings', settingsRoutes);
|
||||
|
||||
@@ -1435,6 +1435,149 @@ function runMigrations(db: Database.Database): void {
|
||||
() => {
|
||||
try { db.exec("ALTER TABLE vacay_plans ADD COLUMN week_start INTEGER NOT NULL DEFAULT 1"); } catch {}
|
||||
},
|
||||
// Migration: Unified Photo Provider Abstraction Layer (#584)
|
||||
// Central trek_photos registry; trip_photos + journey_photos reference via photo_id
|
||||
() => {
|
||||
// 1. Create the central photo registry
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS trek_photos (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
provider TEXT NOT NULL,
|
||||
asset_id TEXT,
|
||||
owner_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
||||
file_path TEXT,
|
||||
thumbnail_path TEXT,
|
||||
width INTEGER,
|
||||
height INTEGER,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
`);
|
||||
db.exec('CREATE UNIQUE INDEX IF NOT EXISTS idx_trek_photos_provider_asset ON trek_photos(provider, asset_id, owner_id) WHERE asset_id IS NOT NULL');
|
||||
db.exec('CREATE INDEX IF NOT EXISTS idx_trek_photos_owner ON trek_photos(owner_id)');
|
||||
|
||||
// 2. Migrate trip_photos → trek_photos + photo_id FK
|
||||
const tripPhotosExists = db.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'trip_photos'").get();
|
||||
if (tripPhotosExists) {
|
||||
// Detect schema variant: old (immich_asset_id) vs new (asset_id + provider)
|
||||
const tpCols = db.prepare("PRAGMA table_info('trip_photos')").all() as Array<{ name: string }>;
|
||||
const tpColNames = new Set(tpCols.map(c => c.name));
|
||||
const hasProvider = tpColNames.has('provider');
|
||||
const assetCol = tpColNames.has('asset_id') ? 'asset_id' : (tpColNames.has('immich_asset_id') ? 'immich_asset_id' : null);
|
||||
const hasAlbumLink = tpColNames.has('album_link_id');
|
||||
|
||||
if (assetCol) {
|
||||
const providerExpr = hasProvider ? 'provider' : "'immich'";
|
||||
// Qualified alias needed in JOIN context where both trip_photos and trek_photos have provider
|
||||
const providerJoinExpr = hasProvider ? 'tp.provider' : "'immich'";
|
||||
const sharedExpr = tpColNames.has('shared') ? 'shared' : '1';
|
||||
const addedAtExpr = tpColNames.has('added_at') ? 'COALESCE(added_at, CURRENT_TIMESTAMP)' : 'CURRENT_TIMESTAMP';
|
||||
const albumLinkExpr = hasAlbumLink ? 'album_link_id' : 'NULL';
|
||||
|
||||
// Insert existing trip photo references into trek_photos
|
||||
db.exec(`
|
||||
INSERT OR IGNORE INTO trek_photos (provider, asset_id, owner_id, created_at)
|
||||
SELECT DISTINCT ${providerExpr}, ${assetCol}, user_id, ${addedAtExpr}
|
||||
FROM trip_photos
|
||||
WHERE ${assetCol} IS NOT NULL AND TRIM(${assetCol}) != ''
|
||||
`);
|
||||
|
||||
// Recreate trip_photos with photo_id FK
|
||||
db.exec(`
|
||||
CREATE TABLE trip_photos_new (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
photo_id INTEGER NOT NULL REFERENCES trek_photos(id) ON DELETE CASCADE,
|
||||
shared INTEGER NOT NULL DEFAULT 1,
|
||||
album_link_id INTEGER REFERENCES trip_album_links(id) ON DELETE SET NULL,
|
||||
added_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(trip_id, user_id, photo_id)
|
||||
)
|
||||
`);
|
||||
db.exec(`
|
||||
INSERT OR IGNORE INTO trip_photos_new (trip_id, user_id, photo_id, shared, album_link_id, added_at)
|
||||
SELECT tp.trip_id, tp.user_id, tkp.id, ${sharedExpr}, ${albumLinkExpr}, ${addedAtExpr}
|
||||
FROM trip_photos tp
|
||||
JOIN trek_photos tkp ON tkp.provider = ${providerJoinExpr} AND tkp.asset_id = tp.${assetCol} AND tkp.owner_id = tp.user_id
|
||||
`);
|
||||
} else {
|
||||
// No asset column at all — just recreate empty
|
||||
db.exec(`
|
||||
CREATE TABLE trip_photos_new (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
photo_id INTEGER NOT NULL REFERENCES trek_photos(id) ON DELETE CASCADE,
|
||||
shared INTEGER NOT NULL DEFAULT 1,
|
||||
album_link_id INTEGER REFERENCES trip_album_links(id) ON DELETE SET NULL,
|
||||
added_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(trip_id, user_id, photo_id)
|
||||
)
|
||||
`);
|
||||
}
|
||||
db.exec('DROP TABLE trip_photos');
|
||||
db.exec('ALTER TABLE trip_photos_new RENAME TO trip_photos');
|
||||
db.exec('CREATE INDEX IF NOT EXISTS idx_trip_photos_trip ON trip_photos(trip_id)');
|
||||
db.exec('CREATE INDEX IF NOT EXISTS idx_trip_photos_photo ON trip_photos(photo_id)');
|
||||
}
|
||||
|
||||
// 3. Migrate journey_photos → trek_photos + photo_id FK
|
||||
const journeyPhotosExists = db.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'journey_photos'").get();
|
||||
if (journeyPhotosExists) {
|
||||
// Insert provider-based journey photos into trek_photos
|
||||
db.exec(`
|
||||
INSERT OR IGNORE INTO trek_photos (provider, asset_id, owner_id, width, height, created_at)
|
||||
SELECT DISTINCT provider, asset_id, owner_id, width, height, created_at
|
||||
FROM journey_photos
|
||||
WHERE provider != 'local' AND asset_id IS NOT NULL AND TRIM(asset_id) != ''
|
||||
`);
|
||||
// Insert local journey photos into trek_photos (each is unique)
|
||||
db.exec(`
|
||||
INSERT INTO trek_photos (provider, file_path, thumbnail_path, width, height, created_at)
|
||||
SELECT 'local', file_path, thumbnail_path, width, height, created_at
|
||||
FROM journey_photos
|
||||
WHERE provider = 'local' AND file_path IS NOT NULL
|
||||
`);
|
||||
|
||||
// Recreate journey_photos with photo_id FK
|
||||
db.exec(`
|
||||
CREATE TABLE journey_photos_new (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
entry_id INTEGER NOT NULL,
|
||||
photo_id INTEGER NOT NULL REFERENCES trek_photos(id) ON DELETE CASCADE,
|
||||
caption TEXT,
|
||||
sort_order INTEGER DEFAULT 0,
|
||||
shared INTEGER NOT NULL DEFAULT 1,
|
||||
created_at INTEGER NOT NULL,
|
||||
FOREIGN KEY (entry_id) REFERENCES journey_entries(id) ON DELETE CASCADE
|
||||
)
|
||||
`);
|
||||
// Migrate provider photos
|
||||
db.exec(`
|
||||
INSERT INTO journey_photos_new (entry_id, photo_id, caption, sort_order, shared, created_at)
|
||||
SELECT jp.entry_id, tkp.id, jp.caption, jp.sort_order, jp.shared, jp.created_at
|
||||
FROM journey_photos jp
|
||||
JOIN trek_photos tkp ON tkp.provider = jp.provider AND tkp.asset_id = jp.asset_id AND tkp.owner_id = jp.owner_id
|
||||
WHERE jp.provider != 'local' AND jp.asset_id IS NOT NULL
|
||||
`);
|
||||
// Migrate local photos (match by file_path)
|
||||
db.exec(`
|
||||
INSERT INTO journey_photos_new (entry_id, photo_id, caption, sort_order, shared, created_at)
|
||||
SELECT jp.entry_id, tkp.id, jp.caption, jp.sort_order, jp.shared, jp.created_at
|
||||
FROM journey_photos jp
|
||||
JOIN trek_photos tkp ON tkp.provider = 'local' AND tkp.file_path = jp.file_path
|
||||
WHERE jp.provider = 'local' AND jp.file_path IS NOT NULL
|
||||
`);
|
||||
db.exec('DROP TABLE journey_photos');
|
||||
db.exec('ALTER TABLE journey_photos_new RENAME TO journey_photos');
|
||||
db.exec('CREATE INDEX IF NOT EXISTS idx_journey_photos_entry ON journey_photos(entry_id)');
|
||||
db.exec('CREATE INDEX IF NOT EXISTS idx_journey_photos_photo ON journey_photos(photo_id)');
|
||||
}
|
||||
},
|
||||
// Migration 99: hide_skeletons per-user setting on journey_contributors
|
||||
() => {
|
||||
try { db.exec('ALTER TABLE journey_contributors ADD COLUMN hide_skeletons INTEGER NOT NULL DEFAULT 0'); } catch {}
|
||||
},
|
||||
];
|
||||
|
||||
if (currentVersion < migrations.length) {
|
||||
|
||||
@@ -64,14 +64,14 @@ router.get('/available-trips', authenticate, (req: Request, res: Response) => {
|
||||
|
||||
router.patch('/entries/:entryId', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const result = svc.updateEntry(Number(req.params.entryId), authReq.user.id, req.body || {});
|
||||
const result = svc.updateEntry(Number(req.params.entryId), authReq.user.id, req.body || {}, req.headers['x-socket-id'] as string);
|
||||
if (!result) return res.status(404).json({ error: 'Entry not found' });
|
||||
res.json(result);
|
||||
});
|
||||
|
||||
router.delete('/entries/:entryId', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
if (!svc.deleteEntry(Number(req.params.entryId), authReq.user.id)) {
|
||||
if (!svc.deleteEntry(Number(req.params.entryId), authReq.user.id, req.headers['x-socket-id'] as string)) {
|
||||
return res.status(404).json({ error: 'Entry not found' });
|
||||
}
|
||||
res.json({ success: true });
|
||||
@@ -115,7 +115,19 @@ router.post('/entries/:entryId/photos', authenticate, upload.array('photos', 10)
|
||||
|
||||
router.post('/entries/:entryId/provider-photos', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { provider, asset_id, caption } = req.body || {};
|
||||
const { provider, asset_id, asset_ids, caption } = req.body || {};
|
||||
|
||||
// Batch mode: { provider, asset_ids: string[] }
|
||||
if (Array.isArray(asset_ids) && provider) {
|
||||
const added: any[] = [];
|
||||
for (const id of asset_ids) {
|
||||
const photo = svc.addProviderPhoto(Number(req.params.entryId), authReq.user.id, provider, String(id), caption);
|
||||
if (photo) added.push(photo);
|
||||
}
|
||||
return res.status(201).json({ photos: added, added: added.length });
|
||||
}
|
||||
|
||||
// Single mode (backward compat)
|
||||
if (!provider || !asset_id) return res.status(400).json({ error: 'provider and asset_id required' });
|
||||
const photo = svc.addProviderPhoto(Number(req.params.entryId), authReq.user.id, provider, asset_id, caption);
|
||||
if (!photo) return res.status(403).json({ error: 'Not allowed or duplicate' });
|
||||
@@ -233,7 +245,7 @@ router.post('/:id/entries', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { entry_date } = req.body || {};
|
||||
if (!entry_date) return res.status(400).json({ error: 'entry_date is required' });
|
||||
const entry = svc.createEntry(Number(req.params.id), authReq.user.id, req.body);
|
||||
const entry = svc.createEntry(Number(req.params.id), authReq.user.id, req.body, req.headers['x-socket-id'] as string);
|
||||
if (!entry) return res.status(404).json({ error: 'Journey not found' });
|
||||
res.status(201).json(entry);
|
||||
});
|
||||
@@ -267,6 +279,15 @@ router.delete('/:id/contributors/:userId', authenticate, (req: Request, res: Res
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
// ── User Preferences ─────────────────────────────────────────────────────
|
||||
|
||||
router.patch('/:id/preferences', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const result = svc.updateJourneyPreferences(Number(req.params.id), authReq.user.id, req.body);
|
||||
if (!result) return res.status(403).json({ error: 'Not allowed' });
|
||||
res.json(result);
|
||||
});
|
||||
|
||||
// ── Share Link ────────────────────────────────────────────────────────────
|
||||
|
||||
router.get('/:id/share-link', authenticate, (req: Request, res: Response) => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import express, { Request, Response } from 'express';
|
||||
import { getPublicJourney, validateShareTokenForAsset } from '../services/journeyShareService';
|
||||
import { getPublicJourney, validateShareTokenForAsset, validateShareTokenForPhoto } from '../services/journeyShareService';
|
||||
import { streamPhoto } from '../services/memories/photoResolverService';
|
||||
import { streamImmichAsset } from '../services/memories/immichService';
|
||||
import path from 'node:path';
|
||||
import fs from 'node:fs';
|
||||
@@ -12,16 +13,23 @@ router.get('/:token', (req: Request, res: Response) => {
|
||||
res.json(data);
|
||||
});
|
||||
|
||||
// Public photo proxy — validates share token instead of auth
|
||||
// Unified public photo proxy — uses trek_photo_id
|
||||
router.get('/:token/photos/:photoId/:kind', async (req: Request, res: Response) => {
|
||||
const { token, photoId, kind } = req.params;
|
||||
const valid = validateShareTokenForPhoto(token, Number(photoId));
|
||||
if (!valid) return res.status(404).json({ error: 'Not found' });
|
||||
|
||||
await streamPhoto(res, valid.ownerId, Number(photoId), kind === 'thumbnail' ? 'thumbnail' : 'original');
|
||||
});
|
||||
|
||||
// Legacy public photo proxy — validates share token instead of auth
|
||||
router.get('/:token/photo/:provider/:assetId/:ownerId/:kind', async (req: Request, res: Response) => {
|
||||
const { token, provider, assetId, ownerId, kind } = req.params;
|
||||
|
||||
// Validate token and that this asset belongs to the shared journey
|
||||
const valid = validateShareTokenForAsset(token, assetId);
|
||||
if (!valid) return res.status(404).json({ error: 'Not found' });
|
||||
|
||||
if (provider === 'local') {
|
||||
// Local file — assetId is the file_path
|
||||
const filePath = path.join(__dirname, '../../uploads/journey', assetId);
|
||||
const resolved = path.resolve(filePath);
|
||||
const uploadsDir = path.resolve(__dirname, '../../uploads');
|
||||
@@ -32,12 +40,10 @@ router.get('/:token/photo/:provider/:assetId/:ownerId/:kind', async (req: Reques
|
||||
return res.sendFile(resolved);
|
||||
}
|
||||
|
||||
// Immich/Synology — proxy through
|
||||
const effectiveOwnerId = valid.ownerId || Number(ownerId);
|
||||
if (provider === 'immich') {
|
||||
await streamImmichAsset(res, effectiveOwnerId, assetId, kind === 'thumbnail' ? 'thumbnail' : 'original', effectiveOwnerId);
|
||||
} else {
|
||||
// Synology or other providers — try dynamic import
|
||||
try {
|
||||
const { streamSynologyAsset } = await import('../services/memories/synologyService');
|
||||
await streamSynologyAsset(res, effectiveOwnerId, effectiveOwnerId, assetId, kind === 'thumbnail' ? 'thumbnail' : 'original');
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
searchPhotos,
|
||||
streamImmichAsset,
|
||||
listAlbums,
|
||||
getAlbumPhotos,
|
||||
syncAlbumAssets,
|
||||
getAssetInfo,
|
||||
isValidAssetId,
|
||||
@@ -59,10 +60,16 @@ router.get('/browse', authenticate, async (req: Request, res: Response) => {
|
||||
|
||||
router.post('/search', authenticate, async (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { from, to } = req.body;
|
||||
const result = await searchPhotos(authReq.user.id, from, to);
|
||||
if (result.error) return res.status(result.status!).json({ error: result.error });
|
||||
res.json({ assets: result.assets });
|
||||
const { from, to, size } = req.body;
|
||||
const pageSize = Math.min(Number(size) || 50, 200);
|
||||
const allAssets: any[] = [];
|
||||
for (let page = 1; page <= 20; page++) {
|
||||
const result = await searchPhotos(authReq.user.id, from, to, page, pageSize);
|
||||
if (result.error) return res.status(result.status!).json({ error: result.error });
|
||||
if (result.assets) allAssets.push(...result.assets);
|
||||
if (!result.hasMore) break;
|
||||
}
|
||||
res.json({ assets: allAssets });
|
||||
});
|
||||
|
||||
// ── Asset Details ──────────────────────────────────────────────────────────
|
||||
@@ -113,6 +120,13 @@ router.get('/albums', authenticate, async (req: Request, res: Response) => {
|
||||
res.json({ albums: result.albums });
|
||||
});
|
||||
|
||||
router.get('/albums/:albumId/photos', authenticate, async (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const result = await getAlbumPhotos(authReq.user.id, req.params.albumId);
|
||||
if (result.error) return res.status(result.status!).json({ error: result.error });
|
||||
res.json({ assets: result.assets });
|
||||
});
|
||||
|
||||
router.post('/trips/:tripId/album-links/:linkId/sync', authenticate, async (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { tripId, linkId } = req.params;
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
getSynologyStatus,
|
||||
testSynologyConnection,
|
||||
listSynologyAlbums,
|
||||
getSynologyAlbumPhotos,
|
||||
syncSynologyAlbumLink,
|
||||
searchSynologyPhotos,
|
||||
getSynologyAssetInfo,
|
||||
@@ -77,6 +78,11 @@ router.get('/albums', authenticate, async (req: Request, res: Response) => {
|
||||
handleServiceResult(res, await listSynologyAlbums(authReq.user.id));
|
||||
});
|
||||
|
||||
router.get('/albums/:albumId/photos', authenticate, async (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
handleServiceResult(res, await getSynologyAlbumPhotos(authReq.user.id, req.params.albumId));
|
||||
});
|
||||
|
||||
router.post('/trips/:tripId/album-links/:linkId/sync', authenticate, async (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { tripId, linkId } = req.params;
|
||||
@@ -90,8 +96,12 @@ router.post('/search', authenticate, async (req: Request, res: Response) => {
|
||||
const body = req.body as Record<string, unknown>;
|
||||
const from = _parseStringBodyField(body.from);
|
||||
const to = _parseStringBodyField(body.to);
|
||||
const offset = _parseNumberBodyField(body.offset, 0);
|
||||
const limit = _parseNumberBodyField(body.limit, 100);
|
||||
let offset = _parseNumberBodyField(body.offset, 0);
|
||||
const page = _parseNumberBodyField(body.page, 1) - 1;
|
||||
let limit = _parseNumberBodyField(body.limit, 100);
|
||||
const size = _parseNumberBodyField(body.size, 0);
|
||||
if(page > 0) offset = page*limit;
|
||||
if(size > 0) limit = size;
|
||||
|
||||
handleServiceResult(res, await searchSynologyPhotos(
|
||||
authReq.user.id,
|
||||
|
||||
@@ -55,8 +55,7 @@ router.put('/unified/trips/:tripId/photos/sharing', authenticate, async (req: Re
|
||||
const result = await setTripPhotoSharing(
|
||||
tripId,
|
||||
authReq.user.id,
|
||||
req.body?.provider,
|
||||
req.body?.asset_id,
|
||||
Number(req.body?.photo_id),
|
||||
req.body?.shared,
|
||||
);
|
||||
if ('error' in result) return res.status(result.error.status).json({ error: result.error.message });
|
||||
@@ -66,7 +65,7 @@ router.put('/unified/trips/:tripId/photos/sharing', authenticate, async (req: Re
|
||||
router.delete('/unified/trips/:tripId/photos', authenticate, async (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { tripId } = req.params;
|
||||
const result = await removeTripPhoto(tripId, authReq.user.id, req.body?.provider, req.body?.asset_id);
|
||||
const result = removeTripPhoto(tripId, authReq.user.id, Number(req.body?.photo_id));
|
||||
if ('error' in result) return res.status(result.error.status).json({ error: result.error.message });
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
@@ -171,12 +171,23 @@ export const CONTINENT_MAP: Record<string, string> = {
|
||||
|
||||
// ── Geocoding helpers ───────────────────────────────────────────────────────
|
||||
|
||||
let lastNominatimCall = 0;
|
||||
|
||||
// Shared throttle: enforces ≥1.1s between any Nominatim request, across all callers.
|
||||
async function throttleNominatim() {
|
||||
const elapsed = Date.now() - lastNominatimCall;
|
||||
if (elapsed < 1100) await new Promise(r => setTimeout(r, 1100 - elapsed));
|
||||
lastNominatimCall = Date.now();
|
||||
}
|
||||
|
||||
export async function reverseGeocodeCountry(lat: number, lng: number): Promise<string | null> {
|
||||
const key = roundKey(lat, lng);
|
||||
if (geocodeCache.has(key)) return geocodeCache.get(key)!;
|
||||
await throttleNominatim();
|
||||
try {
|
||||
const res = await fetch(`https://nominatim.openstreetmap.org/reverse?lat=${lat}&lon=${lng}&format=json&zoom=3&accept-language=en`, {
|
||||
headers: { 'User-Agent': 'TREK Travel Planner' },
|
||||
headers: { 'User-Agent': 'TREK Travel Planner (https://github.com/mauriceboe/TREK)' },
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
const data = await res.json() as { address?: { country_code?: string } };
|
||||
@@ -215,15 +226,15 @@ export function getCountryFromAddress(address: string | null): string | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
// ── Resolve a place to a country code (address -> geocode -> bbox) ──────────
|
||||
// ── Resolve a place to a country code (address -> bbox -> geocode) ──────────
|
||||
|
||||
async function resolveCountryCode(place: Place): Promise<string | null> {
|
||||
let code = getCountryFromAddress(place.address);
|
||||
if (!code && place.lat && place.lng) {
|
||||
code = await reverseGeocodeCountry(place.lat, place.lng);
|
||||
code = getCountryFromCoords(place.lat, place.lng);
|
||||
}
|
||||
if (!code && place.lat && place.lng) {
|
||||
code = getCountryFromCoords(place.lat, place.lng);
|
||||
code = await reverseGeocodeCountry(place.lat, place.lng);
|
||||
}
|
||||
return code;
|
||||
}
|
||||
@@ -453,15 +464,22 @@ export function unmarkRegionVisited(userId: number, regionCode: string): void {
|
||||
|
||||
interface RegionInfo { country_code: string; region_code: string; region_name: string }
|
||||
|
||||
// Tracks place IDs currently being geocoded in the background to prevent duplicate enqueuing.
|
||||
const geocodingInFlight = new Set<number>();
|
||||
|
||||
const regionCache = new Map<string, RegionInfo | null>();
|
||||
|
||||
async function reverseGeocodeRegion(lat: number, lng: number): Promise<RegionInfo | null> {
|
||||
const key = roundKey(lat, lng);
|
||||
if (regionCache.has(key)) return regionCache.get(key)!;
|
||||
await throttleNominatim();
|
||||
try {
|
||||
const res = await fetch(
|
||||
`https://nominatim.openstreetmap.org/reverse?lat=${lat}&lon=${lng}&format=json&zoom=8&accept-language=en`,
|
||||
{ headers: { 'User-Agent': 'TREK Travel Planner' } }
|
||||
{
|
||||
headers: { 'User-Agent': 'TREK Travel Planner (https://github.com/mauriceboe/TREK)' },
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
}
|
||||
);
|
||||
if (!res.ok) return null;
|
||||
const data = await res.json() as { address?: Record<string, string> };
|
||||
@@ -498,20 +516,27 @@ export async function getVisitedRegions(userId: number): Promise<{ regions: Reco
|
||||
: [];
|
||||
const cachedMap = new Map(cached.map(c => [c.place_id, c]));
|
||||
|
||||
// Resolve uncached places (rate-limited to avoid hammering Nominatim)
|
||||
const uncached = places.filter(p => p.lat && p.lng && !cachedMap.has(p.id));
|
||||
const insertStmt = db.prepare('INSERT OR REPLACE INTO place_regions (place_id, country_code, region_code, region_name) VALUES (?, ?, ?, ?)');
|
||||
|
||||
for (const place of uncached) {
|
||||
const info = await reverseGeocodeRegion(place.lat!, place.lng!);
|
||||
if (info) {
|
||||
insertStmt.run(place.id, info.country_code, info.region_code, info.region_name);
|
||||
cachedMap.set(place.id, { place_id: place.id, ...info });
|
||||
}
|
||||
// Nominatim rate limit: 1 req/sec
|
||||
if (uncached.indexOf(place) < uncached.length - 1) {
|
||||
await new Promise(r => setTimeout(r, 1100));
|
||||
}
|
||||
// Kick off background geocoding for uncached places; return cached data immediately.
|
||||
const uncached = places.filter(p => p.lat && p.lng && !cachedMap.has(p.id) && !geocodingInFlight.has(p.id));
|
||||
if (uncached.length > 0) {
|
||||
const insertStmt = db.prepare('INSERT OR REPLACE INTO place_regions (place_id, country_code, region_code, region_name) VALUES (?, ?, ?, ?)');
|
||||
for (const p of uncached) geocodingInFlight.add(p.id);
|
||||
void (async () => {
|
||||
try {
|
||||
for (const place of uncached) {
|
||||
try {
|
||||
const info = await reverseGeocodeRegion(place.lat!, place.lng!);
|
||||
if (info) insertStmt.run(place.id, info.country_code, info.region_code, info.region_name);
|
||||
} catch {
|
||||
// individual failure — continue with remaining places
|
||||
} finally {
|
||||
geocodingInFlight.delete(place.id);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
for (const p of uncached) geocodingInFlight.delete(p.id);
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
// Group by country → regions with place counts
|
||||
|
||||
@@ -117,7 +117,7 @@ export function listBackups(): BackupInfo[] {
|
||||
filename,
|
||||
size: stat.size,
|
||||
sizeText: formatSize(stat.size),
|
||||
created_at: stat.birthtime.toISOString(),
|
||||
created_at: stat.mtime.toISOString(),
|
||||
};
|
||||
})
|
||||
.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime());
|
||||
|
||||
@@ -1,12 +1,20 @@
|
||||
import { db } from '../db/database';
|
||||
import { broadcastToUser } from '../websocket';
|
||||
import type { Journey, JourneyEntry, JourneyPhoto, JourneyContributor } from '../types';
|
||||
import { getOrCreateTrekPhoto, getOrCreateLocalTrekPhoto, setTrekPhotoProvider } from './memories/photoResolverService';
|
||||
|
||||
function ts(): number {
|
||||
return Date.now();
|
||||
}
|
||||
|
||||
function broadcastJourneyEvent(journeyId: number, event: string, data: Record<string, unknown>, excludeUserId?: number) {
|
||||
// Joined SELECT for journey_photos + trek_photos — returns fields matching JourneyPhoto interface
|
||||
const JP_SELECT = `
|
||||
jp.id, jp.entry_id, jp.photo_id, jp.caption, jp.sort_order, jp.shared, jp.created_at,
|
||||
tkp.provider, tkp.asset_id, tkp.owner_id, tkp.file_path, tkp.thumbnail_path, tkp.width, tkp.height
|
||||
`;
|
||||
const JP_JOIN = 'journey_photos jp JOIN trek_photos tkp ON tkp.id = jp.photo_id';
|
||||
|
||||
function broadcastJourneyEvent(journeyId: number, event: string, data: Record<string, unknown>, excludeSocketId?: string | number) {
|
||||
const contributors = db.prepare(
|
||||
'SELECT user_id FROM journey_contributors WHERE journey_id = ?'
|
||||
).all(journeyId) as { user_id: number }[];
|
||||
@@ -16,8 +24,7 @@ function broadcastJourneyEvent(journeyId: number, event: string, data: Record<st
|
||||
if (owner) userIds.add(owner.user_id);
|
||||
|
||||
for (const uid of userIds) {
|
||||
if (uid === excludeUserId) continue;
|
||||
broadcastToUser(uid, { type: event, journeyId, ...data });
|
||||
broadcastToUser(uid, { type: event, journeyId, ...data }, excludeSocketId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -105,7 +112,7 @@ export function getJourneyFull(journeyId: number, userId: number) {
|
||||
).all(journeyId) as JourneyEntry[];
|
||||
|
||||
const photos = db.prepare(
|
||||
'SELECT * FROM journey_photos WHERE entry_id IN (SELECT id FROM journey_entries WHERE journey_id = ?) ORDER BY sort_order ASC'
|
||||
`SELECT ${JP_SELECT} FROM ${JP_JOIN} WHERE jp.entry_id IN (SELECT id FROM journey_entries WHERE journey_id = ?) ORDER BY jp.sort_order ASC`
|
||||
).all(journeyId) as JourneyPhoto[];
|
||||
|
||||
// group photos by entry
|
||||
@@ -154,12 +161,17 @@ export function getJourneyFull(journeyId: number, userId: number) {
|
||||
const photoCount = photos.length;
|
||||
const cities = [...new Set(entries.map(e => e.location_name).filter(Boolean))];
|
||||
|
||||
const userPrefs = db.prepare(
|
||||
'SELECT hide_skeletons FROM journey_contributors WHERE journey_id = ? AND user_id = ?'
|
||||
).get(journeyId, userId) as { hide_skeletons: number } | undefined;
|
||||
|
||||
return {
|
||||
...journey,
|
||||
entries: enrichedEntries,
|
||||
trips,
|
||||
contributors,
|
||||
stats: { entries: entryCount, photos: photoCount, cities: cities.length },
|
||||
hide_skeletons: !!(userPrefs?.hide_skeletons),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -190,6 +202,19 @@ export function updateJourney(journeyId: number, userId: number, data: Partial<{
|
||||
return db.prepare('SELECT * FROM journeys WHERE id = ?').get(journeyId) as Journey;
|
||||
}
|
||||
|
||||
export function updateJourneyPreferences(journeyId: number, userId: number, data: { hide_skeletons?: boolean }) {
|
||||
if (!canAccessJourney(journeyId, userId)) return null;
|
||||
if (data.hide_skeletons !== undefined) {
|
||||
db.prepare(
|
||||
'UPDATE journey_contributors SET hide_skeletons = ? WHERE journey_id = ? AND user_id = ?'
|
||||
).run(data.hide_skeletons ? 1 : 0, journeyId, userId);
|
||||
}
|
||||
const row = db.prepare(
|
||||
'SELECT hide_skeletons FROM journey_contributors WHERE journey_id = ? AND user_id = ?'
|
||||
).get(journeyId, userId) as { hide_skeletons: number };
|
||||
return { hide_skeletons: !!row.hide_skeletons };
|
||||
}
|
||||
|
||||
export function deleteJourney(journeyId: number, userId: number): boolean {
|
||||
if (!isOwner(journeyId, userId)) return false;
|
||||
db.prepare('DELETE FROM journeys WHERE id = ?').run(journeyId);
|
||||
@@ -210,7 +235,7 @@ export function addTripToJourney(journeyId: number, tripId: number, userId: numb
|
||||
syncTripPlaces(journeyId, tripId, userId);
|
||||
// import existing trip photos (Immich/Synology) with sharing settings
|
||||
syncTripPhotos(journeyId, tripId);
|
||||
broadcastJourneyEvent(journeyId, 'journey:trip:synced', { tripId }, userId);
|
||||
broadcastJourneyEvent(journeyId, 'journey:trip:synced', { tripId });
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -253,6 +278,7 @@ export function syncTripPlaces(journeyId: number, tripId: number, authorId: numb
|
||||
|
||||
for (const place of places) {
|
||||
if (existingPlaceIds.has(place.id)) continue;
|
||||
existingPlaceIds.add(place.id);
|
||||
|
||||
const entryDate = place.day_date || new Date().toISOString().split('T')[0];
|
||||
const entryTime = place.assignment_time || place.place_time || null;
|
||||
@@ -272,8 +298,8 @@ export function syncTripPlaces(journeyId: number, tripId: number, authorId: numb
|
||||
// import trip_photos into journey when a trip is linked
|
||||
function syncTripPhotos(journeyId: number, tripId: number) {
|
||||
const tripPhotos = db.prepare(
|
||||
'SELECT * FROM trip_photos WHERE trip_id = ?'
|
||||
).all(tripId) as { id: number; trip_id: number; user_id: number; asset_id: string; provider: string; shared: number }[];
|
||||
'SELECT tp.photo_id, tp.user_id, tp.shared FROM trip_photos tp WHERE tp.trip_id = ?'
|
||||
).all(tripId) as { photo_id: number; user_id: number; shared: number }[];
|
||||
if (!tripPhotos.length) return;
|
||||
|
||||
const now = ts();
|
||||
@@ -285,7 +311,6 @@ function syncTripPhotos(journeyId: number, tripId: number) {
|
||||
`).get(journeyId, tripId) as { id: number } | undefined;
|
||||
|
||||
if (!photoEntry) {
|
||||
// get trip date for the entry
|
||||
const trip = db.prepare('SELECT start_date FROM trips WHERE id = ?').get(tripId) as { start_date: string } | undefined;
|
||||
const entryDate = trip?.start_date || new Date().toISOString().split('T')[0];
|
||||
const owner = db.prepare('SELECT user_id FROM journeys WHERE id = ?').get(journeyId) as { user_id: number };
|
||||
@@ -297,19 +322,19 @@ function syncTripPhotos(journeyId: number, tripId: number) {
|
||||
photoEntry = { id: Number(res.lastInsertRowid) };
|
||||
}
|
||||
|
||||
// import each trip photo, skip duplicates
|
||||
// import each trip photo, skip duplicates (by photo_id)
|
||||
for (const tp of tripPhotos) {
|
||||
const exists = db.prepare(
|
||||
'SELECT 1 FROM journey_photos WHERE entry_id = ? AND provider = ? AND asset_id = ?'
|
||||
).get(photoEntry.id, tp.provider, tp.asset_id);
|
||||
'SELECT 1 FROM journey_photos WHERE entry_id = ? AND photo_id = ?'
|
||||
).get(photoEntry.id, tp.photo_id);
|
||||
if (exists) continue;
|
||||
|
||||
const maxOrder = db.prepare('SELECT MAX(sort_order) as m FROM journey_photos WHERE entry_id = ?').get(photoEntry.id) as { m: number | null };
|
||||
|
||||
db.prepare(`
|
||||
INSERT INTO journey_photos (entry_id, provider, asset_id, owner_id, shared, sort_order, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(photoEntry.id, tp.provider, tp.asset_id, tp.user_id, tp.shared, (maxOrder?.m ?? -1) + 1, now);
|
||||
INSERT INTO journey_photos (entry_id, photo_id, shared, sort_order, created_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
`).run(photoEntry.id, tp.photo_id, tp.shared, (maxOrder?.m ?? -1) + 1, now);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -424,7 +449,7 @@ export function listEntries(journeyId: number, userId: number) {
|
||||
).all(journeyId) as JourneyEntry[];
|
||||
|
||||
const photos = db.prepare(
|
||||
'SELECT * FROM journey_photos WHERE entry_id IN (SELECT id FROM journey_entries WHERE journey_id = ?) ORDER BY sort_order ASC'
|
||||
`SELECT ${JP_SELECT} FROM ${JP_JOIN} WHERE jp.entry_id IN (SELECT id FROM journey_entries WHERE journey_id = ?) ORDER BY jp.sort_order ASC`
|
||||
).all(journeyId) as JourneyPhoto[];
|
||||
|
||||
const photosByEntry: Record<number, JourneyPhoto[]> = {};
|
||||
@@ -457,7 +482,7 @@ export function createEntry(journeyId: number, userId: number, data: {
|
||||
tags?: string[];
|
||||
pros_cons?: { pros: string[]; cons: string[] };
|
||||
visibility?: string;
|
||||
}): JourneyEntry | null {
|
||||
}, sid?: string): JourneyEntry | null {
|
||||
if (!canEdit(journeyId, userId)) return null;
|
||||
|
||||
const now = ts();
|
||||
@@ -491,7 +516,7 @@ export function createEntry(journeyId: number, userId: number, data: {
|
||||
);
|
||||
|
||||
const created = db.prepare('SELECT * FROM journey_entries WHERE id = ?').get(Number(res.lastInsertRowid)) as JourneyEntry;
|
||||
broadcastJourneyEvent(journeyId, 'journey:entry:created', { entry: created }, userId);
|
||||
broadcastJourneyEvent(journeyId, 'journey:entry:created', { entry: created }, sid);
|
||||
return created;
|
||||
}
|
||||
|
||||
@@ -510,7 +535,7 @@ export function updateEntry(entryId: number, userId: number, data: Partial<{
|
||||
pros_cons: { pros: string[]; cons: string[] };
|
||||
visibility: string;
|
||||
sort_order: number;
|
||||
}>): JourneyEntry | null {
|
||||
}>, sid?: string): JourneyEntry | null {
|
||||
const entry = db.prepare('SELECT * FROM journey_entries WHERE id = ?').get(entryId) as JourneyEntry | undefined;
|
||||
if (!entry) return null;
|
||||
if (!canEdit(entry.journey_id, userId)) return null;
|
||||
@@ -549,18 +574,31 @@ export function updateEntry(entryId: number, userId: number, data: Partial<{
|
||||
db.prepare('UPDATE journeys SET updated_at = ? WHERE id = ?').run(ts(), entry.journey_id);
|
||||
|
||||
const updated = db.prepare('SELECT * FROM journey_entries WHERE id = ?').get(entryId) as JourneyEntry;
|
||||
broadcastJourneyEvent(entry.journey_id, 'journey:entry:updated', { entry: updated }, userId);
|
||||
broadcastJourneyEvent(entry.journey_id, 'journey:entry:updated', { entry: updated }, sid);
|
||||
return updated;
|
||||
}
|
||||
|
||||
export function deleteEntry(entryId: number, userId: number): boolean {
|
||||
export function deleteEntry(entryId: number, userId: number, sid?: string): boolean {
|
||||
const entry = db.prepare('SELECT * FROM journey_entries WHERE id = ?').get(entryId) as JourneyEntry | undefined;
|
||||
if (!entry) return false;
|
||||
if (!canEdit(entry.journey_id, userId)) return false;
|
||||
|
||||
// delete photos along with the entry — no more orphan Gallery entries
|
||||
db.prepare('DELETE FROM journey_photos WHERE entry_id = ?').run(entryId);
|
||||
db.prepare('DELETE FROM journey_entries WHERE id = ?').run(entryId);
|
||||
|
||||
if (entry.source_trip_id && entry.source_place_id && entry.type !== 'skeleton') {
|
||||
// Revert filled entry back to skeleton instead of deleting
|
||||
db.prepare(`
|
||||
UPDATE journey_entries
|
||||
SET type = 'skeleton', story = NULL, mood = NULL, weather = NULL, pros_cons = NULL,
|
||||
visibility = 'private', updated_at = ?
|
||||
WHERE id = ?
|
||||
`).run(ts(), entryId);
|
||||
broadcastJourneyEvent(entry.journey_id, 'journey:entry:updated', { entryId }, sid);
|
||||
} else {
|
||||
db.prepare('DELETE FROM journey_entries WHERE id = ?').run(entryId);
|
||||
broadcastJourneyEvent(entry.journey_id, 'journey:entry:deleted', { entryId }, sid);
|
||||
}
|
||||
|
||||
// clean up any empty Gallery entries in this journey
|
||||
db.prepare(`
|
||||
@@ -568,7 +606,6 @@ export function deleteEntry(entryId: number, userId: number): boolean {
|
||||
AND id NOT IN (SELECT DISTINCT entry_id FROM journey_photos)
|
||||
`).run(entry.journey_id);
|
||||
|
||||
broadcastJourneyEvent(entry.journey_id, 'journey:entry:deleted', { entryId }, userId);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -579,15 +616,16 @@ export function addPhoto(entryId: number, userId: number, filePath: string, thum
|
||||
if (!entry) return null;
|
||||
if (!canEdit(entry.journey_id, userId)) return null;
|
||||
|
||||
const trekPhotoId = getOrCreateLocalTrekPhoto(filePath, thumbnailPath);
|
||||
const maxOrder = db.prepare('SELECT MAX(sort_order) as m FROM journey_photos WHERE entry_id = ?').get(entryId) as { m: number | null };
|
||||
const now = ts();
|
||||
|
||||
const res = db.prepare(`
|
||||
INSERT INTO journey_photos (entry_id, provider, file_path, thumbnail_path, caption, sort_order, created_at)
|
||||
VALUES (?, 'local', ?, ?, ?, ?, ?)
|
||||
`).run(entryId, filePath, thumbnailPath || null, caption || null, (maxOrder?.m ?? -1) + 1, now);
|
||||
INSERT INTO journey_photos (entry_id, photo_id, caption, sort_order, created_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
`).run(entryId, trekPhotoId, caption || null, (maxOrder?.m ?? -1) + 1, now);
|
||||
|
||||
return db.prepare('SELECT * FROM journey_photos WHERE id = ?').get(Number(res.lastInsertRowid)) as JourneyPhoto;
|
||||
return db.prepare(`SELECT ${JP_SELECT} FROM ${JP_JOIN} WHERE jp.id = ?`).get(Number(res.lastInsertRowid)) as JourneyPhoto;
|
||||
}
|
||||
|
||||
export function addProviderPhoto(entryId: number, userId: number, provider: string, assetId: string, caption?: string): JourneyPhoto | null {
|
||||
@@ -595,19 +633,21 @@ export function addProviderPhoto(entryId: number, userId: number, provider: stri
|
||||
if (!entry) return null;
|
||||
if (!canEdit(entry.journey_id, userId)) return null;
|
||||
|
||||
const trekPhotoId = getOrCreateTrekPhoto(provider, assetId, userId);
|
||||
|
||||
// skip if already added
|
||||
const exists = db.prepare('SELECT 1 FROM journey_photos WHERE entry_id = ? AND provider = ? AND asset_id = ?').get(entryId, provider, assetId);
|
||||
const exists = db.prepare('SELECT 1 FROM journey_photos WHERE entry_id = ? AND photo_id = ?').get(entryId, trekPhotoId);
|
||||
if (exists) return null;
|
||||
|
||||
const maxOrder = db.prepare('SELECT MAX(sort_order) as m FROM journey_photos WHERE entry_id = ?').get(entryId) as { m: number | null };
|
||||
const now = ts();
|
||||
|
||||
const res = db.prepare(`
|
||||
INSERT INTO journey_photos (entry_id, provider, asset_id, owner_id, caption, sort_order, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(entryId, provider, assetId, userId, caption || null, (maxOrder?.m ?? -1) + 1, now);
|
||||
INSERT INTO journey_photos (entry_id, photo_id, caption, sort_order, created_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
`).run(entryId, trekPhotoId, caption || null, (maxOrder?.m ?? -1) + 1, now);
|
||||
|
||||
return db.prepare('SELECT * FROM journey_photos WHERE id = ?').get(Number(res.lastInsertRowid)) as JourneyPhoto;
|
||||
return db.prepare(`SELECT ${JP_SELECT} FROM ${JP_JOIN} WHERE jp.id = ?`).get(Number(res.lastInsertRowid)) as JourneyPhoto;
|
||||
}
|
||||
|
||||
export function linkPhotoToEntry(entryId: number, photoId: number, userId: number): JourneyPhoto | null {
|
||||
@@ -615,7 +655,7 @@ export function linkPhotoToEntry(entryId: number, photoId: number, userId: numbe
|
||||
if (!entry) return null;
|
||||
if (!canEdit(entry.journey_id, userId)) return null;
|
||||
|
||||
const source = db.prepare('SELECT * FROM journey_photos WHERE id = ?').get(photoId) as JourneyPhoto | undefined;
|
||||
const source = db.prepare(`SELECT ${JP_SELECT} FROM ${JP_JOIN} WHERE jp.id = ?`).get(photoId) as JourneyPhoto | undefined;
|
||||
if (!source) return null;
|
||||
|
||||
if (source.entry_id === entryId) return source;
|
||||
@@ -634,16 +674,19 @@ export function linkPhotoToEntry(entryId: number, photoId: number, userId: numbe
|
||||
}
|
||||
}
|
||||
|
||||
return db.prepare('SELECT * FROM journey_photos WHERE id = ?').get(photoId) as JourneyPhoto;
|
||||
return db.prepare(`SELECT ${JP_SELECT} FROM ${JP_JOIN} WHERE jp.id = ?`).get(photoId) as JourneyPhoto;
|
||||
}
|
||||
|
||||
export function setPhotoProvider(photoId: number, provider: string, assetId: string, ownerId: number) {
|
||||
db.prepare('UPDATE journey_photos SET provider = ?, asset_id = ?, owner_id = ? WHERE id = ?').run(provider, assetId, ownerId, photoId);
|
||||
// Get the trek_photo_id from the journey_photo, then update the central registry
|
||||
const jp = db.prepare('SELECT photo_id FROM journey_photos WHERE id = ?').get(photoId) as { photo_id: number } | undefined;
|
||||
if (!jp) return;
|
||||
setTrekPhotoProvider(jp.photo_id, provider, assetId, ownerId);
|
||||
}
|
||||
|
||||
export function updatePhoto(photoId: number, userId: number, data: { caption?: string; sort_order?: number }): JourneyPhoto | null {
|
||||
const photo = db.prepare(`
|
||||
SELECT jp.*, je.journey_id FROM journey_photos jp
|
||||
SELECT ${JP_SELECT}, je.journey_id FROM ${JP_JOIN}
|
||||
JOIN journey_entries je ON jp.entry_id = je.id
|
||||
WHERE jp.id = ?
|
||||
`).get(photoId) as (JourneyPhoto & { journey_id: number }) | undefined;
|
||||
@@ -658,12 +701,12 @@ export function updatePhoto(photoId: number, userId: number, data: { caption?: s
|
||||
|
||||
values.push(photoId);
|
||||
db.prepare(`UPDATE journey_photos SET ${fields.join(', ')} WHERE id = ?`).run(...values);
|
||||
return db.prepare('SELECT * FROM journey_photos WHERE id = ?').get(photoId) as JourneyPhoto;
|
||||
return db.prepare(`SELECT ${JP_SELECT} FROM ${JP_JOIN} WHERE jp.id = ?`).get(photoId) as JourneyPhoto;
|
||||
}
|
||||
|
||||
export function deletePhoto(photoId: number, userId: number): (JourneyPhoto & { journey_id: number }) | null {
|
||||
const photo = db.prepare(`
|
||||
SELECT jp.*, je.journey_id FROM journey_photos jp
|
||||
SELECT ${JP_SELECT}, je.journey_id FROM ${JP_JOIN}
|
||||
JOIN journey_entries je ON jp.entry_id = je.id
|
||||
WHERE jp.id = ?
|
||||
`).get(photoId) as (JourneyPhoto & { journey_id: number }) | undefined;
|
||||
|
||||
@@ -59,7 +59,9 @@ export function validateShareTokenForPhoto(token: string, photoId: number): { jo
|
||||
const row = db.prepare('SELECT journey_id FROM journey_share_tokens WHERE token = ?').get(token) as any;
|
||||
if (!row) return null;
|
||||
const photo = db.prepare(`
|
||||
SELECT jp.*, je.journey_id FROM journey_photos jp
|
||||
SELECT jp.photo_id, tkp.owner_id, je.journey_id
|
||||
FROM journey_photos jp
|
||||
JOIN trek_photos tkp ON tkp.id = jp.photo_id
|
||||
JOIN journey_entries je ON jp.entry_id = je.id
|
||||
WHERE jp.id = ? AND je.journey_id = ?
|
||||
`).get(photoId, row.journey_id) as any;
|
||||
@@ -71,14 +73,13 @@ export function validateShareTokenForPhoto(token: string, photoId: number): { jo
|
||||
export function validateShareTokenForAsset(token: string, assetId: string): { ownerId: number } | null {
|
||||
const row = db.prepare('SELECT journey_id FROM journey_share_tokens WHERE token = ?').get(token) as any;
|
||||
if (!row) return null;
|
||||
// Check if this asset belongs to any photo in the shared journey
|
||||
const photo = db.prepare(`
|
||||
SELECT jp.owner_id FROM journey_photos jp
|
||||
SELECT tkp.owner_id FROM journey_photos jp
|
||||
JOIN trek_photos tkp ON tkp.id = jp.photo_id
|
||||
JOIN journey_entries je ON jp.entry_id = je.id
|
||||
WHERE jp.asset_id = ? AND je.journey_id = ?
|
||||
WHERE tkp.asset_id = ? AND je.journey_id = ?
|
||||
`).get(assetId, row.journey_id) as any;
|
||||
if (!photo) {
|
||||
// Fallback: get journey owner
|
||||
const journey = db.prepare('SELECT user_id FROM journeys WHERE id = ?').get(row.journey_id) as any;
|
||||
return journey ? { ownerId: journey.user_id } : null;
|
||||
}
|
||||
@@ -100,7 +101,10 @@ export function getPublicJourney(token: string) {
|
||||
`).all(row.journey_id) as any[];
|
||||
|
||||
const photos = db.prepare(`
|
||||
SELECT jp.* FROM journey_photos jp
|
||||
SELECT jp.id, jp.entry_id, jp.photo_id, jp.caption, jp.sort_order, jp.shared, jp.created_at,
|
||||
tkp.provider, tkp.asset_id, tkp.owner_id, tkp.file_path, tkp.thumbnail_path, tkp.width, tkp.height
|
||||
FROM journey_photos jp
|
||||
JOIN trek_photos tkp ON tkp.id = jp.photo_id
|
||||
JOIN journey_entries je ON jp.entry_id = je.id
|
||||
WHERE je.journey_id = ?
|
||||
ORDER BY jp.sort_order
|
||||
|
||||
@@ -129,15 +129,15 @@ export function canAccessUserPhoto(requestingUserId: number, ownerUserId: number
|
||||
const journeyPhoto = db.prepare(`
|
||||
SELECT jp.entry_id, je.journey_id
|
||||
FROM journey_photos jp
|
||||
JOIN trek_photos tkp ON tkp.id = jp.photo_id
|
||||
JOIN journey_entries je ON je.id = jp.entry_id
|
||||
WHERE jp.asset_id = ?
|
||||
AND jp.provider = ?
|
||||
AND jp.owner_id = ?
|
||||
WHERE tkp.asset_id = ?
|
||||
AND tkp.provider = ?
|
||||
AND tkp.owner_id = ?
|
||||
LIMIT 1
|
||||
`).get(assetId, provider, ownerUserId) as { entry_id: number; journey_id: number } | undefined;
|
||||
if (!journeyPhoto) return false;
|
||||
|
||||
// Check if requesting user is the journey owner or a contributor
|
||||
const access = db.prepare(`
|
||||
SELECT 1 FROM journeys WHERE id = ? AND user_id = ?
|
||||
UNION ALL
|
||||
@@ -147,15 +147,16 @@ export function canAccessUserPhoto(requestingUserId: number, ownerUserId: number
|
||||
return !!access;
|
||||
}
|
||||
|
||||
// Regular trip photos
|
||||
// Regular trip photos — join through trek_photos
|
||||
const sharedAsset = db.prepare(`
|
||||
SELECT 1
|
||||
FROM trip_photos
|
||||
WHERE user_id = ?
|
||||
AND asset_id = ?
|
||||
AND provider = ?
|
||||
AND trip_id = ?
|
||||
AND shared = 1
|
||||
FROM trip_photos tp
|
||||
JOIN trek_photos tkp ON tkp.id = tp.photo_id
|
||||
WHERE tp.user_id = ?
|
||||
AND tkp.asset_id = ?
|
||||
AND tkp.provider = ?
|
||||
AND tp.trip_id = ?
|
||||
AND tp.shared = 1
|
||||
LIMIT 1
|
||||
`).get(ownerUserId, assetId, provider, tripId);
|
||||
|
||||
@@ -166,6 +167,52 @@ export function canAccessUserPhoto(requestingUserId: number, ownerUserId: number
|
||||
}
|
||||
|
||||
|
||||
// ── Unified photo access check (trek_photos based) ──────────────────────
|
||||
|
||||
export function canAccessTrekPhoto(requestingUserId: number, trekPhotoId: number): boolean {
|
||||
const photo = db.prepare('SELECT * FROM trek_photos WHERE id = ?').get(trekPhotoId) as { id: number; provider: string; owner_id: number | null } | undefined;
|
||||
if (!photo) return false;
|
||||
|
||||
// Owner always has access
|
||||
if (photo.owner_id === requestingUserId) return true;
|
||||
|
||||
// Check trip_photos — is this photo shared in a trip the user has access to?
|
||||
const tripAccess = db.prepare(`
|
||||
SELECT 1 FROM trip_photos tp
|
||||
WHERE tp.photo_id = ?
|
||||
AND tp.shared = 1
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM trip_members tm WHERE tm.trip_id = tp.trip_id AND tm.user_id = ?
|
||||
UNION ALL
|
||||
SELECT 1 FROM trips t WHERE t.id = tp.trip_id AND t.user_id = ?
|
||||
)
|
||||
LIMIT 1
|
||||
`).get(trekPhotoId, requestingUserId, requestingUserId);
|
||||
if (tripAccess) return true;
|
||||
|
||||
// Check journey_photos — is this photo in a journey the user can access?
|
||||
const journeyAccess = db.prepare(`
|
||||
SELECT 1 FROM journey_photos jp
|
||||
JOIN journey_entries je ON je.id = jp.entry_id
|
||||
WHERE jp.photo_id = ?
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM journeys j WHERE j.id = je.journey_id AND j.user_id = ?
|
||||
UNION ALL
|
||||
SELECT 1 FROM journey_contributors jc WHERE jc.journey_id = je.journey_id AND jc.user_id = ?
|
||||
)
|
||||
LIMIT 1
|
||||
`).get(trekPhotoId, requestingUserId, requestingUserId);
|
||||
if (journeyAccess) return true;
|
||||
|
||||
// Local photos without owner (uploaded files) — check if user has journey access
|
||||
if (photo.provider === 'local' && !photo.owner_id) {
|
||||
return !!journeyAccess;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
// ----------------------------------------------
|
||||
//helpers for album link syncing
|
||||
|
||||
|
||||
@@ -149,44 +149,36 @@ export async function browseTimeline(
|
||||
export async function searchPhotos(
|
||||
userId: number,
|
||||
from?: string,
|
||||
to?: string
|
||||
): Promise<{ assets?: any[]; error?: string; status?: number }> {
|
||||
to?: string,
|
||||
page: number = 1,
|
||||
size: number = 50,
|
||||
): Promise<{ assets?: any[]; hasMore?: boolean; error?: string; status?: number }> {
|
||||
const creds = getImmichCredentials(userId);
|
||||
if (!creds) return { error: 'Immich not configured', status: 400 };
|
||||
|
||||
try {
|
||||
// Paginate through all results (Immich limits per-page to 1000)
|
||||
const allAssets: any[] = [];
|
||||
let page = 1;
|
||||
const pageSize = 1000;
|
||||
while (true) {
|
||||
const resp = await safeFetch(`${creds.immich_url}/api/search/metadata`, {
|
||||
method: 'POST',
|
||||
headers: { 'x-api-key': creds.immich_api_key, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
takenAfter: from ? `${from}T00:00:00.000Z` : undefined,
|
||||
takenBefore: to ? `${to}T23:59:59.999Z` : undefined,
|
||||
type: 'IMAGE',
|
||||
size: pageSize,
|
||||
page,
|
||||
}),
|
||||
signal: AbortSignal.timeout(15000) as any,
|
||||
});
|
||||
if (!resp.ok) return { error: 'Search failed', status: resp.status };
|
||||
const data = await resp.json() as { assets?: { items?: any[] } };
|
||||
const items = data.assets?.items || [];
|
||||
allAssets.push(...items);
|
||||
if (items.length < pageSize) break; // Last page
|
||||
page++;
|
||||
if (page > 20) break; // Safety limit (20k photos max)
|
||||
}
|
||||
const assets = allAssets.map((a: any) => ({
|
||||
const resp = await safeFetch(`${creds.immich_url}/api/search/metadata`, {
|
||||
method: 'POST',
|
||||
headers: { 'x-api-key': creds.immich_api_key, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
takenAfter: from ? `${from}T00:00:00.000Z` : undefined,
|
||||
takenBefore: to ? `${to}T23:59:59.999Z` : undefined,
|
||||
type: 'IMAGE',
|
||||
size,
|
||||
page,
|
||||
}),
|
||||
signal: AbortSignal.timeout(15000) as any,
|
||||
});
|
||||
if (!resp.ok) return { error: 'Search failed', status: resp.status };
|
||||
const data = await resp.json() as { assets?: { items?: any[] } };
|
||||
const items = data.assets?.items || [];
|
||||
const assets = items.map((a: any) => ({
|
||||
id: a.id,
|
||||
takenAt: a.fileCreatedAt || a.createdAt,
|
||||
city: a.exifInfo?.city || null,
|
||||
country: a.exifInfo?.country || null,
|
||||
}));
|
||||
return { assets };
|
||||
return { assets, hasMore: items.length >= size };
|
||||
} catch {
|
||||
return { error: 'Could not reach Immich', status: 502 };
|
||||
}
|
||||
@@ -266,18 +258,34 @@ export async function listAlbums(
|
||||
if (!creds) return { error: 'Immich not configured', status: 400 };
|
||||
|
||||
try {
|
||||
const resp = await safeFetch(`${creds.immich_url}/api/albums`, {
|
||||
headers: { 'x-api-key': creds.immich_api_key, 'Accept': 'application/json' },
|
||||
signal: AbortSignal.timeout(10000) as any,
|
||||
// Fetch both owned and shared albums
|
||||
const [ownResp, sharedResp] = await Promise.all([
|
||||
safeFetch(`${creds.immich_url}/api/albums`, {
|
||||
headers: { 'x-api-key': creds.immich_api_key, 'Accept': 'application/json' },
|
||||
signal: AbortSignal.timeout(10000) as any,
|
||||
}),
|
||||
safeFetch(`${creds.immich_url}/api/albums?shared=true`, {
|
||||
headers: { 'x-api-key': creds.immich_api_key, 'Accept': 'application/json' },
|
||||
signal: AbortSignal.timeout(10000) as any,
|
||||
}),
|
||||
]);
|
||||
if (!ownResp.ok) return { error: 'Failed to fetch albums', status: ownResp.status };
|
||||
const ownAlbums = await ownResp.json() as any[];
|
||||
const sharedAlbums = sharedResp.ok ? await sharedResp.json() as any[] : [];
|
||||
const seenIds = new Set<string>();
|
||||
const allAlbums = [...ownAlbums, ...sharedAlbums].filter((a: any) => {
|
||||
if (seenIds.has(a.id)) return false;
|
||||
seenIds.add(a.id);
|
||||
return true;
|
||||
});
|
||||
if (!resp.ok) return { error: 'Failed to fetch albums', status: resp.status };
|
||||
const albums = (await resp.json() as any[]).map((a: any) => ({
|
||||
const albums = allAlbums.map((a: any) => ({
|
||||
id: a.id,
|
||||
albumName: a.albumName,
|
||||
assetCount: a.assetCount || 0,
|
||||
startDate: a.startDate,
|
||||
endDate: a.endDate,
|
||||
albumThumbnailAssetId: a.albumThumbnailAssetId,
|
||||
shared: a.shared || a.sharedUsers?.length > 0,
|
||||
}));
|
||||
return { albums };
|
||||
} catch {
|
||||
@@ -285,6 +293,32 @@ export async function listAlbums(
|
||||
}
|
||||
}
|
||||
|
||||
export async function getAlbumPhotos(
|
||||
userId: number,
|
||||
albumId: string,
|
||||
): Promise<{ assets?: any[]; error?: string; status?: number }> {
|
||||
const creds = getImmichCredentials(userId);
|
||||
if (!creds) return { error: 'Immich not configured', status: 400 };
|
||||
|
||||
try {
|
||||
const resp = await safeFetch(`${creds.immich_url}/api/albums/${albumId}`, {
|
||||
headers: { 'x-api-key': creds.immich_api_key, 'Accept': 'application/json' },
|
||||
signal: AbortSignal.timeout(15000) as any,
|
||||
});
|
||||
if (!resp.ok) return { error: 'Failed to fetch album', status: resp.status };
|
||||
const albumData = await resp.json() as { assets?: any[] };
|
||||
const assets = (albumData.assets || []).filter((a: any) => a.type === 'IMAGE').map((a: any) => ({
|
||||
id: a.id,
|
||||
takenAt: a.fileCreatedAt || a.createdAt,
|
||||
city: a.exifInfo?.city || null,
|
||||
country: a.exifInfo?.country || null,
|
||||
}));
|
||||
return { assets };
|
||||
} catch {
|
||||
return { error: 'Could not reach Immich', status: 502 };
|
||||
}
|
||||
}
|
||||
|
||||
export function listAlbumLinks(tripId: string) {
|
||||
return db.prepare(`
|
||||
SELECT tal.*, u.username
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
import { Response } from 'express';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import { db } from '../../db/database';
|
||||
import type { TrekPhoto } from '../../types';
|
||||
import { streamImmichAsset, getAssetInfo as getImmichAssetInfo } from './immichService';
|
||||
import { streamSynologyAsset, getSynologyAssetInfo } from './synologyService';
|
||||
import type { ServiceResult, AssetInfo } from './helpersService';
|
||||
import { fail, success } from './helpersService';
|
||||
|
||||
// ── Lookup / Register ────────────────────────────────────────────────────
|
||||
|
||||
export function getOrCreateTrekPhoto(
|
||||
provider: string,
|
||||
assetId: string,
|
||||
ownerId: number,
|
||||
): number {
|
||||
const existing = db.prepare(
|
||||
'SELECT id FROM trek_photos WHERE provider = ? AND asset_id = ? AND owner_id = ?'
|
||||
).get(provider, assetId, ownerId) as { id: number } | undefined;
|
||||
if (existing) return existing.id;
|
||||
|
||||
const res = db.prepare(
|
||||
'INSERT INTO trek_photos (provider, asset_id, owner_id) VALUES (?, ?, ?)'
|
||||
).run(provider, assetId, ownerId);
|
||||
return Number(res.lastInsertRowid);
|
||||
}
|
||||
|
||||
export function getOrCreateLocalTrekPhoto(
|
||||
filePath: string,
|
||||
thumbnailPath?: string | null,
|
||||
width?: number | null,
|
||||
height?: number | null,
|
||||
): number {
|
||||
const existing = db.prepare(
|
||||
"SELECT id FROM trek_photos WHERE provider = 'local' AND file_path = ?"
|
||||
).get(filePath) as { id: number } | undefined;
|
||||
if (existing) return existing.id;
|
||||
|
||||
const res = db.prepare(
|
||||
'INSERT INTO trek_photos (provider, file_path, thumbnail_path, width, height) VALUES (?, ?, ?, ?, ?)'
|
||||
).run('local', filePath, thumbnailPath || null, width || null, height || null);
|
||||
return Number(res.lastInsertRowid);
|
||||
}
|
||||
|
||||
export function resolveTrekPhoto(photoId: number): TrekPhoto | null {
|
||||
return db.prepare('SELECT * FROM trek_photos WHERE id = ?').get(photoId) as TrekPhoto | undefined || null;
|
||||
}
|
||||
|
||||
// ── Streaming ────────────────────────────────────────────────────────────
|
||||
|
||||
export async function streamPhoto(
|
||||
res: Response,
|
||||
userId: number,
|
||||
photoId: number,
|
||||
kind: 'thumbnail' | 'original',
|
||||
): Promise<void> {
|
||||
const photo = resolveTrekPhoto(photoId);
|
||||
if (!photo) {
|
||||
res.status(404).json({ error: 'Photo not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
switch (photo.provider) {
|
||||
case 'local': {
|
||||
const filePath = path.join(__dirname, '../../../uploads', photo.file_path!);
|
||||
if (!fs.existsSync(filePath)) {
|
||||
res.status(404).json({ error: 'File not found' });
|
||||
return;
|
||||
}
|
||||
res.set('Cache-Control', 'public, max-age=86400');
|
||||
res.sendFile(filePath);
|
||||
return;
|
||||
}
|
||||
case 'immich': {
|
||||
await streamImmichAsset(res, userId, photo.asset_id!, kind, photo.owner_id!);
|
||||
return;
|
||||
}
|
||||
case 'synologyphotos': {
|
||||
await streamSynologyAsset(res, userId, photo.owner_id!, photo.asset_id!, kind);
|
||||
return;
|
||||
}
|
||||
default:
|
||||
res.status(400).json({ error: `Unknown provider: ${photo.provider}` });
|
||||
}
|
||||
}
|
||||
|
||||
// ── Asset Info ────────────────────────────────────────────────────────────
|
||||
|
||||
export async function getPhotoInfo(
|
||||
userId: number,
|
||||
photoId: number,
|
||||
): Promise<ServiceResult<AssetInfo>> {
|
||||
const photo = resolveTrekPhoto(photoId);
|
||||
if (!photo) return fail('Photo not found', 404);
|
||||
|
||||
switch (photo.provider) {
|
||||
case 'local': {
|
||||
return success({
|
||||
id: String(photo.id),
|
||||
takenAt: photo.created_at,
|
||||
city: null,
|
||||
country: null,
|
||||
width: photo.width,
|
||||
height: photo.height,
|
||||
fileName: photo.file_path?.split('/').pop() || null,
|
||||
} as AssetInfo);
|
||||
}
|
||||
case 'immich': {
|
||||
const result = await getImmichAssetInfo(userId, photo.asset_id!, photo.owner_id!);
|
||||
if (result.error) return fail(result.error, result.status || 500);
|
||||
return success(result.data as AssetInfo);
|
||||
}
|
||||
case 'synologyphotos': {
|
||||
return getSynologyAssetInfo(userId, photo.asset_id!, photo.owner_id!);
|
||||
}
|
||||
default:
|
||||
return fail(`Unknown provider: ${photo.provider}`, 400);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Update provider on existing trek_photo (for Immich upload sync) ─────
|
||||
|
||||
export function setTrekPhotoProvider(
|
||||
trekPhotoId: number,
|
||||
provider: string,
|
||||
assetId: string,
|
||||
ownerId: number,
|
||||
): void {
|
||||
db.prepare(
|
||||
'UPDATE trek_photos SET provider = ?, asset_id = ?, owner_id = ? WHERE id = ?'
|
||||
).run(provider, assetId, ownerId, trekPhotoId);
|
||||
}
|
||||
|
||||
// ── Delete local file for a trek_photo ──────────────────────────────────
|
||||
|
||||
export function getTrekPhotoFilePath(photoId: number): string | null {
|
||||
const photo = resolveTrekPhoto(photoId);
|
||||
if (!photo || photo.provider !== 'local' || !photo.file_path) return null;
|
||||
return path.join(__dirname, '../../../uploads', photo.file_path);
|
||||
}
|
||||
@@ -452,6 +452,36 @@ export async function listSynologyAlbums(userId: number): Promise<ServiceResult<
|
||||
}
|
||||
|
||||
|
||||
export async function getSynologyAlbumPhotos(userId: number, albumId: string): Promise<ServiceResult<AssetsList>> {
|
||||
const allItems: SynologyPhotoItem[] = [];
|
||||
const pageSize = 1000;
|
||||
let offset = 0;
|
||||
|
||||
while (true) {
|
||||
const result = await _requestSynologyApi<{ list: SynologyPhotoItem[] }>(userId, {
|
||||
api: 'SYNO.Foto.Browse.Item',
|
||||
method: 'list',
|
||||
version: 1,
|
||||
album_id: Number(albumId),
|
||||
offset,
|
||||
limit: pageSize,
|
||||
additional: ['thumbnail'],
|
||||
});
|
||||
if (!result.success) return result as ServiceResult<AssetsList>;
|
||||
const items = result.data.list || [];
|
||||
allItems.push(...items);
|
||||
if (items.length < pageSize) break;
|
||||
offset += pageSize;
|
||||
}
|
||||
|
||||
const assets = allItems.map(item => ({
|
||||
id: String(item.additional?.thumbnail?.cache_key || item.id || ''),
|
||||
takenAt: item.time ? new Date(item.time * 1000).toISOString() : '',
|
||||
})).filter(a => a.id);
|
||||
|
||||
return success({ assets, total: assets.length, hasMore: false });
|
||||
}
|
||||
|
||||
export async function syncSynologyAlbumLink(userId: number, tripId: string, linkId: string, sid: string): Promise<ServiceResult<SyncAlbumResult>> {
|
||||
const response = getAlbumIdFromLink(tripId, linkId, userId);
|
||||
if (!response.success) return response as ServiceResult<SyncAlbumResult>;
|
||||
@@ -555,7 +585,6 @@ export async function streamSynologyAsset(
|
||||
targetUserId: number,
|
||||
photoId: string,
|
||||
kind: 'thumbnail' | 'original',
|
||||
size?: string,
|
||||
): Promise<void> {
|
||||
const parsedId = _splitPackedSynologyId(photoId);
|
||||
if (!parsedId) {
|
||||
@@ -579,6 +608,8 @@ export async function streamSynologyAsset(
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
//size: 'sm' 240px| 'm' 320px| 'xl' 1280px| 'preview' ?
|
||||
const params = kind === 'thumbnail'
|
||||
? new URLSearchParams({
|
||||
api: 'SYNO.Foto.Thumbnail',
|
||||
@@ -587,7 +618,7 @@ export async function streamSynologyAsset(
|
||||
mode: 'download',
|
||||
id: parsedId.id,
|
||||
type: 'unit',
|
||||
size: size,
|
||||
size: 'sm',
|
||||
cache_key: parsedId.cacheKey,
|
||||
_sid: sid.data,
|
||||
})
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
mapDbError,
|
||||
Selection,
|
||||
} from './helpersService';
|
||||
import { getOrCreateTrekPhoto } from './photoResolverService';
|
||||
|
||||
|
||||
function _providers(): Array<{id: string; enabled: boolean}> {
|
||||
@@ -45,13 +46,14 @@ export function listTripPhotos(tripId: string, userId: number): ServiceResult<an
|
||||
}
|
||||
|
||||
const photos = db.prepare(`
|
||||
SELECT tp.asset_id, tp.provider, tp.user_id, tp.shared, tp.added_at,
|
||||
SELECT tp.photo_id, tkp.asset_id, tkp.provider, tp.user_id, tp.shared, tp.added_at,
|
||||
u.username, u.avatar
|
||||
FROM trip_photos tp
|
||||
JOIN trek_photos tkp ON tkp.id = tp.photo_id
|
||||
JOIN users u ON tp.user_id = u.id
|
||||
WHERE tp.trip_id = ?
|
||||
AND (tp.user_id = ? OR tp.shared = 1)
|
||||
AND tp.provider IN (${enabledProviders.map(() => '?').join(',')})
|
||||
AND tkp.provider IN (${enabledProviders.map(() => '?').join(',')})
|
||||
ORDER BY tp.added_at ASC
|
||||
`).all(tripId, userId, ...enabledProviders);
|
||||
|
||||
@@ -108,9 +110,10 @@ function _addTripPhoto(tripId: string, userId: number, provider: string, assetId
|
||||
return providerResult as ServiceResult<boolean>;
|
||||
}
|
||||
try {
|
||||
const photoId = getOrCreateTrekPhoto(provider, assetId, userId);
|
||||
const result = db.prepare(
|
||||
'INSERT OR IGNORE INTO trip_photos (trip_id, user_id, asset_id, provider, shared, album_link_id) VALUES (?, ?, ?, ?, ?, ?)'
|
||||
).run(tripId, userId, assetId, provider, shared ? 1 : 0, albumLinkId || null);
|
||||
'INSERT OR IGNORE INTO trip_photos (trip_id, user_id, photo_id, shared, album_link_id) VALUES (?, ?, ?, ?, ?)'
|
||||
).run(tripId, userId, photoId, shared ? 1 : 0, albumLinkId || null);
|
||||
return success(result.changes > 0);
|
||||
}
|
||||
catch (error) {
|
||||
@@ -163,8 +166,7 @@ export async function addTripPhotos(
|
||||
export async function setTripPhotoSharing(
|
||||
tripId: string,
|
||||
userId: number,
|
||||
provider: string,
|
||||
assetId: string,
|
||||
photoId: number,
|
||||
shared: boolean,
|
||||
sid?: string,
|
||||
): Promise<ServiceResult<true>> {
|
||||
@@ -179,9 +181,8 @@ export async function setTripPhotoSharing(
|
||||
SET shared = ?
|
||||
WHERE trip_id = ?
|
||||
AND user_id = ?
|
||||
AND asset_id = ?
|
||||
AND provider = ?
|
||||
`).run(shared ? 1 : 0, tripId, userId, assetId, provider);
|
||||
AND photo_id = ?
|
||||
`).run(shared ? 1 : 0, tripId, userId, photoId);
|
||||
|
||||
await _notifySharedTripPhotos(tripId, userId, 1);
|
||||
broadcast(tripId, 'memories:updated', { userId }, sid);
|
||||
@@ -194,8 +195,7 @@ export async function setTripPhotoSharing(
|
||||
export function removeTripPhoto(
|
||||
tripId: string,
|
||||
userId: number,
|
||||
provider: string,
|
||||
assetId: string,
|
||||
photoId: number,
|
||||
sid?: string,
|
||||
): ServiceResult<true> {
|
||||
const access = canAccessTrip(tripId, userId);
|
||||
@@ -208,9 +208,8 @@ export function removeTripPhoto(
|
||||
DELETE FROM trip_photos
|
||||
WHERE trip_id = ?
|
||||
AND user_id = ?
|
||||
AND asset_id = ?
|
||||
AND provider = ?
|
||||
`).run(tripId, userId, assetId, provider);
|
||||
AND photo_id = ?
|
||||
`).run(tripId, userId, photoId);
|
||||
|
||||
broadcast(tripId, 'memories:updated', { userId }, sid);
|
||||
|
||||
|
||||
@@ -399,9 +399,24 @@ export async function sendWebhook(url: string, payload: { event: string; title:
|
||||
}
|
||||
|
||||
export async function testSmtp(to: string): Promise<{ success: boolean; error?: string }> {
|
||||
if (!getSmtpConfig()) return { success: false, error: 'SMTP not configured' };
|
||||
try {
|
||||
const sent = await sendEmail(to, 'Test Notification', 'This is a test email from TREK. If you received this, your SMTP configuration is working correctly.');
|
||||
return sent ? { success: true } : { success: false, error: 'SMTP not configured' };
|
||||
const config = getSmtpConfig()!;
|
||||
const skipTls = process.env.SMTP_SKIP_TLS_VERIFY === 'true' || getAppSetting('smtp_skip_tls_verify') === 'true';
|
||||
const transporter = nodemailer.createTransport({
|
||||
host: config.host,
|
||||
port: config.port,
|
||||
secure: config.secure,
|
||||
auth: config.user ? { user: config.user, pass: config.pass } : undefined,
|
||||
...(skipTls ? { tls: { rejectUnauthorized: false } } : {}),
|
||||
});
|
||||
await transporter.sendMail({
|
||||
from: config.from,
|
||||
to,
|
||||
subject: 'TREK — Test Notification',
|
||||
text: 'This is a test email from TREK. If you received this, your SMTP configuration is working correctly.',
|
||||
});
|
||||
return { success: true };
|
||||
} catch (err) {
|
||||
return { success: false, error: err instanceof Error ? err.message : 'Unknown error' };
|
||||
}
|
||||
|
||||
@@ -197,7 +197,10 @@ export async function getWeather(
|
||||
if (diffDays > -1) {
|
||||
const month = targetDate.getMonth() + 1;
|
||||
const day = targetDate.getDate();
|
||||
const refYear = targetDate.getFullYear() - 1;
|
||||
let refYear = targetDate.getFullYear() - 1;
|
||||
// Archive API only has data up to yesterday — go back further if needed
|
||||
const yesterday = new Date(now.getTime() - 86400000);
|
||||
if (new Date(refYear, month - 1, day + 2) > yesterday) refYear--;
|
||||
const startDate = new Date(refYear, month - 1, day - 2);
|
||||
const endDate = new Date(refYear, month - 1, day + 2);
|
||||
const startStr = startDate.toISOString().slice(0, 10);
|
||||
@@ -299,7 +302,10 @@ export async function getDetailedWeather(
|
||||
|
||||
// Climate / archive path (> 16 days out)
|
||||
if (diffDays > 16) {
|
||||
const refYear = targetDate.getFullYear() - 1;
|
||||
let refYear = targetDate.getFullYear() - 1;
|
||||
// Archive API only has data up to yesterday — go back further if needed
|
||||
const yesterday = new Date(now.getTime() - 86400000);
|
||||
if (new Date(refYear, targetDate.getMonth(), targetDate.getDate()) > yesterday) refYear--;
|
||||
const refDateStr = `${refYear}-${String(targetDate.getMonth() + 1).padStart(2, '0')}-${String(targetDate.getDate()).padStart(2, '0')}`;
|
||||
|
||||
const url = `https://archive-api.open-meteo.com/v1/archive?latitude=${lat}&longitude=${lng}`
|
||||
|
||||
+19
-5
@@ -339,20 +339,34 @@ export interface JourneyEntry {
|
||||
updated_at: number;
|
||||
}
|
||||
|
||||
export interface JourneyPhoto {
|
||||
export interface TrekPhoto {
|
||||
id: number;
|
||||
entry_id: number;
|
||||
provider: 'local' | 'immich' | 'synologyphotos';
|
||||
provider: string;
|
||||
asset_id?: string | null;
|
||||
owner_id?: number | null;
|
||||
file_path?: string | null;
|
||||
thumbnail_path?: string | null;
|
||||
caption?: string | null;
|
||||
sort_order: number;
|
||||
width?: number | null;
|
||||
height?: number | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface JourneyPhoto {
|
||||
id: number;
|
||||
entry_id: number;
|
||||
photo_id: number;
|
||||
caption?: string | null;
|
||||
sort_order: number;
|
||||
shared: number;
|
||||
created_at: number;
|
||||
// Joined from trek_photos for API responses
|
||||
provider?: string;
|
||||
asset_id?: string | null;
|
||||
owner_id?: number | null;
|
||||
file_path?: string | null;
|
||||
thumbnail_path?: string | null;
|
||||
width?: number | null;
|
||||
height?: number | null;
|
||||
}
|
||||
|
||||
export interface JourneyTrip {
|
||||
|
||||
@@ -558,10 +558,23 @@ export function addTripPhoto(
|
||||
provider: string,
|
||||
opts: { shared?: boolean; albumLinkId?: number } = {}
|
||||
): TestTripPhoto {
|
||||
// Insert into trek_photos first (central registry)
|
||||
db.prepare(
|
||||
'INSERT OR IGNORE INTO trek_photos (provider, asset_id, owner_id) VALUES (?, ?, ?)'
|
||||
).run(provider, assetId, userId);
|
||||
const trekPhoto = db.prepare(
|
||||
'SELECT id FROM trek_photos WHERE provider = ? AND asset_id = ? AND owner_id = ?'
|
||||
).get(provider, assetId, userId) as { id: number };
|
||||
|
||||
const result = db.prepare(
|
||||
'INSERT OR IGNORE INTO trip_photos (trip_id, user_id, asset_id, provider, shared, album_link_id) VALUES (?, ?, ?, ?, ?, ?)'
|
||||
).run(tripId, userId, assetId, provider, opts.shared ? 1 : 0, opts.albumLinkId ?? null);
|
||||
return db.prepare('SELECT * FROM trip_photos WHERE id = ?').get(result.lastInsertRowid) as TestTripPhoto;
|
||||
'INSERT OR IGNORE INTO trip_photos (trip_id, user_id, photo_id, shared, album_link_id) VALUES (?, ?, ?, ?, ?)'
|
||||
).run(tripId, userId, trekPhoto.id, opts.shared ? 1 : 0, opts.albumLinkId ?? null);
|
||||
return db.prepare(`
|
||||
SELECT tp.id, tp.trip_id, tp.user_id, tkp.asset_id, tkp.provider, tp.shared, tp.album_link_id
|
||||
FROM trip_photos tp
|
||||
JOIN trek_photos tkp ON tkp.id = tp.photo_id
|
||||
WHERE tp.id = ?
|
||||
`).get(result.lastInsertRowid) as TestTripPhoto;
|
||||
}
|
||||
|
||||
export interface TestAlbumLink {
|
||||
|
||||
@@ -190,11 +190,16 @@ describe('Immich album links', () => {
|
||||
.get(trip.id, user.id, 'album-xyz', 'Album XYZ', 'immich') as any;
|
||||
|
||||
// Insert photos synced from the album
|
||||
testDb.prepare('INSERT INTO trip_photos (trip_id, user_id, asset_id, provider, shared, album_link_id) VALUES (?, ?, ?, ?, 1, ?)').run(trip.id, user.id, 'asset-001', 'immich', linkResult.id);
|
||||
testDb.prepare('INSERT INTO trip_photos (trip_id, user_id, asset_id, provider, shared, album_link_id) VALUES (?, ?, ?, ?, 1, ?)').run(trip.id, user.id, 'asset-002', 'immich', linkResult.id);
|
||||
for (const assetId of ['asset-001', 'asset-002']) {
|
||||
testDb.prepare('INSERT OR IGNORE INTO trek_photos (provider, asset_id, owner_id) VALUES (?, ?, ?)').run('immich', assetId, user.id);
|
||||
const tkp = testDb.prepare('SELECT id FROM trek_photos WHERE provider = ? AND asset_id = ? AND owner_id = ?').get('immich', assetId, user.id) as any;
|
||||
testDb.prepare('INSERT INTO trip_photos (trip_id, user_id, photo_id, shared, album_link_id) VALUES (?, ?, ?, 1, ?)').run(trip.id, user.id, tkp.id, linkResult.id);
|
||||
}
|
||||
|
||||
// Insert an individually-added photo (no album_link_id)
|
||||
testDb.prepare('INSERT INTO trip_photos (trip_id, user_id, asset_id, provider, shared) VALUES (?, ?, ?, ?, 1)').run(trip.id, user.id, 'asset-manual', 'immich');
|
||||
testDb.prepare('INSERT OR IGNORE INTO trek_photos (provider, asset_id, owner_id) VALUES (?, ?, ?)').run('immich', 'asset-manual', user.id);
|
||||
const tkpManual = testDb.prepare('SELECT id FROM trek_photos WHERE provider = ? AND asset_id = ? AND owner_id = ?').get('immich', 'asset-manual', user.id) as any;
|
||||
testDb.prepare('INSERT INTO trip_photos (trip_id, user_id, photo_id, shared) VALUES (?, ?, ?, 1)').run(trip.id, user.id, tkpManual.id);
|
||||
|
||||
const res = await request(app)
|
||||
.delete(`/api/integrations/memories/unified/trips/${trip.id}/album-links/${linkResult.id}`)
|
||||
@@ -204,7 +209,11 @@ describe('Immich album links', () => {
|
||||
expect(res.body.success).toBe(true);
|
||||
|
||||
// Album-linked photos should be gone
|
||||
const remainingPhotos = testDb.prepare('SELECT * FROM trip_photos WHERE trip_id = ?').all(trip.id) as any[];
|
||||
const remainingPhotos = testDb.prepare(`
|
||||
SELECT tp.*, tkp.asset_id FROM trip_photos tp
|
||||
JOIN trek_photos tkp ON tkp.id = tp.photo_id
|
||||
WHERE tp.trip_id = ?
|
||||
`).all(trip.id) as any[];
|
||||
expect(remainingPhotos.length).toBe(1);
|
||||
expect(remainingPhotos[0].asset_id).toBe('asset-manual');
|
||||
|
||||
@@ -220,7 +229,9 @@ describe('Immich album links', () => {
|
||||
|
||||
const linkResult = testDb.prepare('INSERT INTO trip_album_links (trip_id, user_id, album_id, album_name, provider) VALUES (?, ?, ?, ?, ?) RETURNING *')
|
||||
.get(trip.id, owner.id, 'album-secret', 'Secret Album', 'immich') as any;
|
||||
testDb.prepare('INSERT INTO trip_photos (trip_id, user_id, asset_id, provider, shared, album_link_id) VALUES (?, ?, ?, ?, 1, ?)').run(trip.id, owner.id, 'asset-owned', 'immich', linkResult.id);
|
||||
testDb.prepare('INSERT OR IGNORE INTO trek_photos (provider, asset_id, owner_id) VALUES (?, ?, ?)').run('immich', 'asset-owned', owner.id);
|
||||
const tkpOwned = testDb.prepare('SELECT id FROM trek_photos WHERE provider = ? AND asset_id = ? AND owner_id = ?').get('immich', 'asset-owned', owner.id) as any;
|
||||
testDb.prepare('INSERT INTO trip_photos (trip_id, user_id, photo_id, shared, album_link_id) VALUES (?, ?, ?, 1, ?)').run(trip.id, owner.id, tkpOwned.id, linkResult.id);
|
||||
|
||||
// Non-member tries to delete owner's album link — should be denied
|
||||
const res = await request(app)
|
||||
@@ -232,7 +243,11 @@ describe('Immich album links', () => {
|
||||
// Link and photos should still exist
|
||||
const link = testDb.prepare('SELECT * FROM trip_album_links WHERE id = ?').get(linkResult.id);
|
||||
expect(link).toBeDefined();
|
||||
const photo = testDb.prepare('SELECT * FROM trip_photos WHERE asset_id = ?').get('asset-owned');
|
||||
const photo = testDb.prepare(`
|
||||
SELECT tp.* FROM trip_photos tp
|
||||
JOIN trek_photos tkp ON tkp.id = tp.photo_id
|
||||
WHERE tkp.asset_id = ?
|
||||
`).get('asset-owned');
|
||||
expect(photo).toBeDefined();
|
||||
});
|
||||
|
||||
|
||||
@@ -119,8 +119,8 @@ vi.mock('../../src/utils/ssrfGuard', async () => {
|
||||
body: null,
|
||||
});
|
||||
}
|
||||
// /api/albums — list albums
|
||||
if (/\/api\/albums$/.test(u)) {
|
||||
// /api/albums — list albums (owned and shared?=true variant)
|
||||
if (/\/api\/albums(\?.*)?$/.test(u)) {
|
||||
return Promise.resolve({
|
||||
ok: true, status: 200,
|
||||
headers: { get: () => null },
|
||||
@@ -415,9 +415,11 @@ describe('Immich asset proxy', () => {
|
||||
const { user: member } = createUser(testDb);
|
||||
// Insert a shared photo referencing a trip that doesn't exist (FK disabled temporarily)
|
||||
testDb.exec('PRAGMA foreign_keys = OFF');
|
||||
testDb.prepare('INSERT OR IGNORE INTO trek_photos (provider, asset_id, owner_id) VALUES (?, ?, ?)').run('immich', 'asset-notrip', owner.id);
|
||||
const tkpNotrip = testDb.prepare('SELECT id FROM trek_photos WHERE provider = ? AND asset_id = ? AND owner_id = ?').get('immich', 'asset-notrip', owner.id) as any;
|
||||
testDb.prepare(
|
||||
'INSERT INTO trip_photos (trip_id, user_id, asset_id, provider, shared) VALUES (?, ?, ?, ?, ?)'
|
||||
).run(9999, owner.id, 'asset-notrip', 'immich', 1);
|
||||
'INSERT INTO trip_photos (trip_id, user_id, photo_id, shared) VALUES (?, ?, ?, ?)'
|
||||
).run(9999, owner.id, tkpNotrip.id, 1);
|
||||
testDb.exec('PRAGMA foreign_keys = ON');
|
||||
|
||||
const res = await request(app)
|
||||
@@ -531,7 +533,11 @@ describe('Immich syncAlbumAssets', () => {
|
||||
expect(typeof res.body.added).toBe('number');
|
||||
|
||||
// Verify photos were inserted into the DB
|
||||
const photos = testDb.prepare('SELECT * FROM trip_photos WHERE trip_id = ? AND user_id = ?').all(trip.id, user.id) as any[];
|
||||
const photos = testDb.prepare(`
|
||||
SELECT tp.*, tkp.provider FROM trip_photos tp
|
||||
JOIN trek_photos tkp ON tkp.id = tp.photo_id
|
||||
WHERE tp.trip_id = ? AND tp.user_id = ?
|
||||
`).all(trip.id, user.id) as any[];
|
||||
expect(photos.length).toBeGreaterThan(0);
|
||||
expect(photos[0].provider).toBe('immich');
|
||||
});
|
||||
|
||||
@@ -51,14 +51,16 @@ vi.mock('../../src/utils/ssrfGuard', async () => {
|
||||
// Determine which API was called from the URL query param (e.g. ?api=SYNO.API.Auth)
|
||||
// or from the body for POST requests.
|
||||
let apiName = '';
|
||||
let params = new URLSearchParams();
|
||||
try {
|
||||
apiName = new URL(u).searchParams.get('api') || '';
|
||||
params = new URL(u).searchParams;
|
||||
apiName = params.get('api') || '';
|
||||
} catch {}
|
||||
if (!apiName && init?.body) {
|
||||
const body = init.body instanceof URLSearchParams
|
||||
params = init.body instanceof URLSearchParams
|
||||
? init.body
|
||||
: new URLSearchParams(String(init.body));
|
||||
apiName = body.get('api') || '';
|
||||
apiName = params.get('api') || '';
|
||||
}
|
||||
|
||||
// Auth login — used by settings save, status, test-connection
|
||||
@@ -154,6 +156,8 @@ vi.mock('../../src/utils/ssrfGuard', async () => {
|
||||
|
||||
// Thumbnail stream
|
||||
if (apiName === 'SYNO.Foto.Thumbnail') {
|
||||
if (!(['sm', 'm', 'xl', 'preview'].includes(params.get('size') || '')))
|
||||
return Promise.reject(new Error(`Unexpected thumbnail size: ${params.get('size')}`));
|
||||
const imageBytes = Buffer.from('fake-synology-thumbnail');
|
||||
return Promise.resolve({
|
||||
ok: true, status: 200,
|
||||
@@ -437,6 +441,24 @@ describe('Synology asset access', () => {
|
||||
expect(res.headers['content-type']).toContain('image/jpeg');
|
||||
});
|
||||
|
||||
it('SYNO-032b — GET /api/photos/:id/thumbnail uses an allowed Synology thumbnail size', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
setSynologyCredentials(testDb, user.id, 'https://synology.example.com', 'admin', 'pass');
|
||||
|
||||
const insert = testDb.prepare(
|
||||
'INSERT INTO trek_photos (provider, asset_id, owner_id) VALUES (?, ?, ?)'
|
||||
).run('synologyphotos', '101_cachekey', user.id);
|
||||
const trekPhotoId = Number(insert.lastInsertRowid);
|
||||
|
||||
vi.mocked(safeFetch).mockClear();
|
||||
|
||||
const res = await request(app)
|
||||
.get(`/api/photos/${trekPhotoId}/thumbnail`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it('SYNO-033 — GET /assets/original streams image data for shared photo', async () => {
|
||||
const { user: owner } = createUser(testDb);
|
||||
const { user: member } = createUser(testDb);
|
||||
@@ -470,9 +492,11 @@ describe('Synology asset access', () => {
|
||||
const { user: member } = createUser(testDb);
|
||||
// Insert a shared photo referencing a trip that doesn't exist (FK disabled temporarily)
|
||||
testDb.exec('PRAGMA foreign_keys = OFF');
|
||||
testDb.prepare('INSERT OR IGNORE INTO trek_photos (provider, asset_id, owner_id) VALUES (?, ?, ?)').run('synologyphotos', '101_cachekey', owner.id);
|
||||
const tkpSyno35 = testDb.prepare('SELECT id FROM trek_photos WHERE provider = ? AND asset_id = ? AND owner_id = ?').get('synologyphotos', '101_cachekey', owner.id) as any;
|
||||
testDb.prepare(
|
||||
'INSERT INTO trip_photos (trip_id, user_id, asset_id, provider, shared) VALUES (?, ?, ?, ?, ?)'
|
||||
).run(9999, owner.id, '101_cachekey', 'synologyphotos', 1);
|
||||
'INSERT INTO trip_photos (trip_id, user_id, photo_id, shared) VALUES (?, ?, ?, ?)'
|
||||
).run(9999, owner.id, tkpSyno35.id, 1);
|
||||
testDb.exec('PRAGMA foreign_keys = ON');
|
||||
|
||||
const res = await request(app)
|
||||
@@ -568,7 +592,11 @@ describe('Synology syncSynologyAlbumLink', () => {
|
||||
expect(typeof res.body.total).toBe('number');
|
||||
|
||||
// Verify photos were inserted into the DB
|
||||
const photos = testDb.prepare('SELECT * FROM trip_photos WHERE trip_id = ? AND user_id = ?').all(trip.id, user.id) as any[];
|
||||
const photos = testDb.prepare(`
|
||||
SELECT tp.*, tkp.provider FROM trip_photos tp
|
||||
JOIN trek_photos tkp ON tkp.id = tp.photo_id
|
||||
WHERE tp.trip_id = ? AND tp.user_id = ?
|
||||
`).all(trip.id, user.id) as any[];
|
||||
expect(photos.length).toBeGreaterThan(0);
|
||||
expect(photos[0].provider).toBe('synologyphotos');
|
||||
});
|
||||
|
||||
@@ -146,7 +146,11 @@ describe('Unified photo management', () => {
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.added).toBe(2);
|
||||
|
||||
const rows = testDb.prepare('SELECT asset_id FROM trip_photos WHERE trip_id = ?').all(trip.id) as any[];
|
||||
const rows = testDb.prepare(`
|
||||
SELECT tkp.asset_id FROM trip_photos tp
|
||||
JOIN trek_photos tkp ON tkp.id = tp.photo_id
|
||||
WHERE tp.trip_id = ?
|
||||
`).all(trip.id) as any[];
|
||||
expect(rows.map((r: any) => r.asset_id)).toEqual(expect.arrayContaining(['asset-a', 'asset-b']));
|
||||
});
|
||||
|
||||
@@ -178,14 +182,23 @@ describe('Unified photo management', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
addTripPhoto(testDb, trip.id, user.id, 'asset-tog', 'immich', { shared: false });
|
||||
const trekRef = testDb.prepare(`
|
||||
SELECT tp.photo_id FROM trip_photos tp
|
||||
JOIN trek_photos tkp ON tkp.id = tp.photo_id
|
||||
WHERE tp.trip_id = ? AND tkp.asset_id = ?
|
||||
`).get(trip.id, 'asset-tog') as any;
|
||||
|
||||
const res = await request(app)
|
||||
.put(`${photosUrl(trip.id)}/sharing`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ provider: 'immich', asset_id: 'asset-tog', shared: true });
|
||||
.send({ photo_id: trekRef.photo_id, shared: true });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const row = testDb.prepare('SELECT shared FROM trip_photos WHERE asset_id = ?').get('asset-tog') as any;
|
||||
const row = testDb.prepare(`
|
||||
SELECT tp.shared FROM trip_photos tp
|
||||
JOIN trek_photos tkp ON tkp.id = tp.photo_id
|
||||
WHERE tkp.asset_id = ?
|
||||
`).get('asset-tog') as any;
|
||||
expect(row.shared).toBe(1);
|
||||
});
|
||||
|
||||
@@ -206,14 +219,23 @@ describe('Unified photo management', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
addTripPhoto(testDb, trip.id, user.id, 'asset-del', 'immich');
|
||||
const trekRef = testDb.prepare(`
|
||||
SELECT tp.photo_id FROM trip_photos tp
|
||||
JOIN trek_photos tkp ON tkp.id = tp.photo_id
|
||||
WHERE tp.trip_id = ? AND tkp.asset_id = ?
|
||||
`).get(trip.id, 'asset-del') as any;
|
||||
|
||||
const res = await request(app)
|
||||
.delete(photosUrl(trip.id))
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ provider: 'immich', asset_id: 'asset-del' });
|
||||
.send({ photo_id: trekRef.photo_id });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const row = testDb.prepare('SELECT * FROM trip_photos WHERE asset_id = ?').get('asset-del');
|
||||
const row = testDb.prepare(`
|
||||
SELECT tp.* FROM trip_photos tp
|
||||
JOIN trek_photos tkp ON tkp.id = tp.photo_id
|
||||
WHERE tkp.asset_id = ?
|
||||
`).get('asset-del');
|
||||
expect(row).toBeUndefined();
|
||||
});
|
||||
|
||||
|
||||
@@ -473,10 +473,12 @@ describe('getVisitedRegions', () => {
|
||||
const trip = createTrip(testDb, user.id, { title: 'Paris Trip' });
|
||||
insertPlaceWithCoords(testDb, trip.id, 'Paris Hotel', 48.85, 2.35);
|
||||
|
||||
const resultPromise = getVisitedRegions(user.id);
|
||||
// First call triggers the background geocoding fire-and-forget
|
||||
await getVisitedRegions(user.id);
|
||||
// Advance all pending timers (including the 1100ms Nominatim rate-limit delay)
|
||||
await vi.runAllTimersAsync();
|
||||
const result = await resultPromise;
|
||||
// Second call returns now-cached data
|
||||
const result = await getVisitedRegions(user.id);
|
||||
|
||||
expect(result.regions['FR']).toBeDefined();
|
||||
|
||||
|
||||
@@ -580,7 +580,7 @@ describe('BACKUP-041 listBackups', () => {
|
||||
fsMock.readdirSync.mockReturnValue(['backup-2026-01-01T00-00-00.zip']);
|
||||
fsMock.statSync.mockReturnValue({
|
||||
size: 1024,
|
||||
birthtime: new Date('2026-01-01T00:00:00Z'),
|
||||
mtime: new Date('2026-01-01T00:00:00Z'),
|
||||
});
|
||||
|
||||
const result = listBackups();
|
||||
@@ -599,9 +599,9 @@ describe('BACKUP-041 listBackups', () => {
|
||||
]);
|
||||
fsMock.statSync.mockImplementation((p: string) => {
|
||||
if (String(p).includes('2026-01-01')) {
|
||||
return { size: 512, birthtime: new Date('2026-01-01T00:00:00Z') };
|
||||
return { size: 512, mtime: new Date('2026-01-01T00:00:00Z') };
|
||||
}
|
||||
return { size: 2048, birthtime: new Date('2026-06-01T00:00:00Z') };
|
||||
return { size: 2048, mtime: new Date('2026-06-01T00:00:00Z') };
|
||||
});
|
||||
|
||||
const result = listBackups();
|
||||
@@ -619,7 +619,7 @@ describe('BACKUP-041 listBackups', () => {
|
||||
]);
|
||||
fsMock.statSync.mockReturnValue({
|
||||
size: 1024,
|
||||
birthtime: new Date('2026-01-01T00:00:00Z'),
|
||||
mtime: new Date('2026-01-01T00:00:00Z'),
|
||||
});
|
||||
|
||||
const result = listBackups();
|
||||
|
||||
@@ -389,6 +389,8 @@ describe('addTripToJourney / removeTripFromJourney', () => {
|
||||
end_date: '2026-03-03',
|
||||
});
|
||||
const place = createPlace(testDb, trip.id, { name: 'Eiffel Tower' });
|
||||
const day025 = testDb.prepare('SELECT id FROM days WHERE trip_id = ? ORDER BY date ASC LIMIT 1').get(trip.id) as { id: number };
|
||||
createDayAssignment(testDb, day025.id, place.id);
|
||||
|
||||
addTripToJourney(journey.id, trip.id, user.id);
|
||||
|
||||
@@ -563,6 +565,46 @@ describe('deleteEntry', () => {
|
||||
|
||||
expect(deleteEntry(entry.id, viewer.id)).toBe(false);
|
||||
});
|
||||
|
||||
it('JOURNEY-SVC-037b: deleting a filled skeleton reverts it back to skeleton', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const journey = createJourney(testDb, user.id);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const place = createPlace(testDb, trip.id, { name: 'Tokyo Tower' });
|
||||
|
||||
// Create a filled entry that originated from a trip skeleton
|
||||
const now = Date.now();
|
||||
testDb.prepare(`
|
||||
INSERT INTO journey_entries (journey_id, source_trip_id, source_place_id, author_id, type, title, story, mood, entry_date, location_name, visibility, sort_order, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, 'entry', 'Tokyo Tower', 'Amazing view!', 'amazing', '2026-03-01', 'Tokyo', 'private', 0, ?, ?)
|
||||
`).run(journey.id, trip.id, place.id, user.id, now, now);
|
||||
const entry = testDb.prepare('SELECT * FROM journey_entries WHERE journey_id = ? AND source_place_id = ?').get(journey.id, place.id) as any;
|
||||
|
||||
const result = deleteEntry(entry.id, user.id);
|
||||
expect(result).toBe(true);
|
||||
|
||||
// Entry should still exist but reverted to skeleton
|
||||
const reverted = testDb.prepare('SELECT * FROM journey_entries WHERE id = ?').get(entry.id) as any;
|
||||
expect(reverted).toBeDefined();
|
||||
expect(reverted.type).toBe('skeleton');
|
||||
expect(reverted.story).toBeNull();
|
||||
expect(reverted.mood).toBeNull();
|
||||
expect(reverted.source_trip_id).toBe(trip.id);
|
||||
expect(reverted.source_place_id).toBe(place.id);
|
||||
expect(reverted.title).toBe('Tokyo Tower');
|
||||
});
|
||||
|
||||
it('JOURNEY-SVC-037c: deleting an independent entry permanently removes it', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const journey = createJourney(testDb, user.id);
|
||||
const entry = createJourneyEntry(testDb, journey.id, user.id, { entry_date: '2026-03-01', story: 'Manual entry' });
|
||||
|
||||
const result = deleteEntry(entry.id, user.id);
|
||||
expect(result).toBe(true);
|
||||
|
||||
const row = testDb.prepare('SELECT * FROM journey_entries WHERE id = ?').get(entry.id);
|
||||
expect(row).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// -- Photos -------------------------------------------------------------------
|
||||
@@ -809,6 +851,9 @@ describe('syncTripPlaces', () => {
|
||||
});
|
||||
const place1 = createPlace(testDb, trip.id, { name: 'Eiffel Tower' });
|
||||
const place2 = createPlace(testDb, trip.id, { name: 'Louvre' });
|
||||
const days055 = testDb.prepare('SELECT id FROM days WHERE trip_id = ? ORDER BY date ASC LIMIT 2').all(trip.id) as { id: number }[];
|
||||
createDayAssignment(testDb, days055[0].id, place1.id);
|
||||
createDayAssignment(testDb, days055[1].id, place2.id);
|
||||
|
||||
syncTripPlaces(journey.id, trip.id, user.id);
|
||||
|
||||
@@ -828,7 +873,9 @@ describe('syncTripPlaces', () => {
|
||||
start_date: '2026-05-01',
|
||||
end_date: '2026-05-02',
|
||||
});
|
||||
createPlace(testDb, trip.id, { name: 'Notre Dame' });
|
||||
const place056 = createPlace(testDb, trip.id, { name: 'Notre Dame' });
|
||||
const day056 = testDb.prepare('SELECT id FROM days WHERE trip_id = ? ORDER BY date ASC LIMIT 1').get(trip.id) as { id: number };
|
||||
createDayAssignment(testDb, day056.id, place056.id);
|
||||
|
||||
syncTripPlaces(journey.id, trip.id, user.id);
|
||||
syncTripPlaces(journey.id, trip.id, user.id); // second call
|
||||
@@ -879,6 +926,8 @@ describe('onPlaceCreated', () => {
|
||||
|
||||
// Create a new place after trip is linked
|
||||
const place = createPlace(testDb, trip.id, { name: 'Sagrada Familia' });
|
||||
const day058 = testDb.prepare('SELECT id FROM days WHERE trip_id = ? ORDER BY date ASC LIMIT 1').get(trip.id) as { id: number };
|
||||
createDayAssignment(testDb, day058.id, place.id);
|
||||
onPlaceCreated(trip.id, place.id);
|
||||
|
||||
const skeleton = testDb.prepare(
|
||||
@@ -911,6 +960,8 @@ describe('onPlaceCreated', () => {
|
||||
addTripToJourney(journey.id, trip.id, user.id);
|
||||
|
||||
const place = createPlace(testDb, trip.id, { name: 'Arc de Triomphe' });
|
||||
const day060 = testDb.prepare('SELECT id FROM days WHERE trip_id = ? ORDER BY date ASC LIMIT 1').get(trip.id) as { id: number };
|
||||
createDayAssignment(testDb, day060.id, place.id);
|
||||
onPlaceCreated(trip.id, place.id);
|
||||
onPlaceCreated(trip.id, place.id); // second call
|
||||
|
||||
@@ -931,6 +982,8 @@ describe('onPlaceUpdated', () => {
|
||||
end_date: '2026-08-03',
|
||||
});
|
||||
const place = createPlace(testDb, trip.id, { name: 'Old Name' });
|
||||
const day061 = testDb.prepare('SELECT id FROM days WHERE trip_id = ? ORDER BY date ASC LIMIT 1').get(trip.id) as { id: number };
|
||||
createDayAssignment(testDb, day061.id, place.id);
|
||||
addTripToJourney(journey.id, trip.id, user.id);
|
||||
|
||||
// Update the place name directly in DB
|
||||
@@ -954,6 +1007,8 @@ describe('onPlaceUpdated', () => {
|
||||
end_date: '2026-08-02',
|
||||
});
|
||||
const place = createPlace(testDb, trip.id, { name: 'Original Place' });
|
||||
const day062 = testDb.prepare('SELECT id FROM days WHERE trip_id = ? ORDER BY date ASC LIMIT 1').get(trip.id) as { id: number };
|
||||
createDayAssignment(testDb, day062.id, place.id);
|
||||
addTripToJourney(journey.id, trip.id, user.id);
|
||||
|
||||
// Promote the skeleton to a full entry
|
||||
@@ -1017,6 +1072,8 @@ describe('onPlaceDeleted', () => {
|
||||
end_date: '2026-09-02',
|
||||
});
|
||||
const place = createPlace(testDb, trip.id, { name: 'Detach Place' });
|
||||
const day065 = testDb.prepare('SELECT id FROM days WHERE trip_id = ? ORDER BY date ASC LIMIT 1').get(trip.id) as { id: number };
|
||||
createDayAssignment(testDb, day065.id, place.id);
|
||||
addTripToJourney(journey.id, trip.id, user.id);
|
||||
|
||||
// Promote the skeleton to a filled entry
|
||||
@@ -1115,7 +1172,11 @@ describe('setPhotoProvider', () => {
|
||||
|
||||
setPhotoProvider(photo!.id, 'immich', 'immich-asset-789', user.id);
|
||||
|
||||
const updated = testDb.prepare('SELECT * FROM journey_photos WHERE id = ?').get(photo!.id) as any;
|
||||
const updated = testDb.prepare(`
|
||||
SELECT jp.*, tkp.provider, tkp.asset_id, tkp.owner_id
|
||||
FROM journey_photos jp JOIN trek_photos tkp ON tkp.id = jp.photo_id
|
||||
WHERE jp.id = ?
|
||||
`).get(photo!.id) as any;
|
||||
expect(updated.provider).toBe('immich');
|
||||
expect(updated.asset_id).toBe('immich-asset-789');
|
||||
expect(updated.owner_id).toBe(user.id);
|
||||
@@ -1219,7 +1280,7 @@ describe('listUserTrips', () => {
|
||||
// -- Edge cases ----------------------------------------------------------------
|
||||
|
||||
describe('Edge cases', () => {
|
||||
it('JOURNEY-SVC-081: deleteEntry moves photos to Gallery entry instead of deleting them', () => {
|
||||
it('JOURNEY-SVC-081: deleteEntry deletes photos along with the entry', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const journey = createJourney(testDb, user.id);
|
||||
const entry = createJourneyEntry(testDb, journey.id, user.id, { entry_date: '2026-03-01' });
|
||||
@@ -1228,13 +1289,9 @@ describe('Edge cases', () => {
|
||||
const result = deleteEntry(entry.id, user.id);
|
||||
expect(result).toBe(true);
|
||||
|
||||
// Photo should still exist, moved to a Gallery entry
|
||||
const movedPhoto = testDb.prepare('SELECT * FROM journey_photos WHERE id = ?').get(photo!.id) as any;
|
||||
expect(movedPhoto).toBeDefined();
|
||||
expect(movedPhoto.entry_id).not.toBe(entry.id);
|
||||
|
||||
const galleryEntry = testDb.prepare('SELECT * FROM journey_entries WHERE id = ?').get(movedPhoto.entry_id) as any;
|
||||
expect(galleryEntry.title).toBe('Gallery');
|
||||
// Photo should be deleted with the entry
|
||||
const deletedPhoto = testDb.prepare('SELECT * FROM journey_photos WHERE id = ?').get(photo!.id) as any;
|
||||
expect(deletedPhoto).toBeUndefined();
|
||||
});
|
||||
|
||||
it('JOURNEY-SVC-082: updateJourney can set cover_gradient', () => {
|
||||
@@ -1308,9 +1365,11 @@ describe('Edge cases', () => {
|
||||
).get(journey.id) as any;
|
||||
expect(photoEntry).toBeDefined();
|
||||
|
||||
const photos = testDb.prepare(
|
||||
'SELECT * FROM journey_photos WHERE entry_id = ?'
|
||||
).all(photoEntry.id);
|
||||
const photos = testDb.prepare(`
|
||||
SELECT jp.*, tkp.asset_id FROM journey_photos jp
|
||||
JOIN trek_photos tkp ON tkp.id = jp.photo_id
|
||||
WHERE jp.entry_id = ?
|
||||
`).all(photoEntry.id);
|
||||
expect(photos.length).toBe(1);
|
||||
expect((photos[0] as any).asset_id).toBe('immich-photo-1');
|
||||
});
|
||||
@@ -1325,6 +1384,9 @@ describe('Edge cases', () => {
|
||||
});
|
||||
const place1 = createPlace(testDb, trip.id, { name: 'Skeleton Place' });
|
||||
const place2 = createPlace(testDb, trip.id, { name: 'Filled Place' });
|
||||
const days087 = testDb.prepare('SELECT id FROM days WHERE trip_id = ? ORDER BY date ASC LIMIT 2').all(trip.id) as { id: number }[];
|
||||
createDayAssignment(testDb, days087[0].id, place1.id);
|
||||
createDayAssignment(testDb, days087[1].id, place2.id);
|
||||
addTripToJourney(journey.id, trip.id, user.id);
|
||||
|
||||
// Promote one skeleton to a filled entry
|
||||
|
||||
@@ -63,10 +63,17 @@ function insertJourneyPhoto(
|
||||
entryId: number,
|
||||
opts: { filePath?: string; assetId?: string; ownerId?: number } = {}
|
||||
): number {
|
||||
const provider = opts.assetId ? 'immich' : 'local';
|
||||
const filePath = !opts.assetId ? (opts.filePath ?? '/photos/test.jpg') : null;
|
||||
const trekResult = testDb.prepare(`
|
||||
INSERT INTO trek_photos (provider, asset_id, file_path, owner_id, created_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
`).run(provider, opts.assetId ?? null, filePath, opts.ownerId ?? null, Date.now());
|
||||
const trekId = trekResult.lastInsertRowid as number;
|
||||
const result = testDb.prepare(`
|
||||
INSERT INTO journey_photos (entry_id, file_path, caption, sort_order, created_at, asset_id, owner_id)
|
||||
VALUES (?, ?, NULL, 0, ?, ?, ?)
|
||||
`).run(entryId, opts.filePath ?? '/photos/test.jpg', Date.now(), opts.assetId ?? null, opts.ownerId ?? null);
|
||||
INSERT INTO journey_photos (entry_id, photo_id, caption, sort_order, created_at)
|
||||
VALUES (?, ?, NULL, 0, ?)
|
||||
`).run(entryId, trekId, Date.now());
|
||||
return result.lastInsertRowid as number;
|
||||
}
|
||||
|
||||
|
||||
@@ -384,7 +384,12 @@ describe('searchNominatim (fetch stubbed)', () => {
|
||||
});
|
||||
|
||||
it('MAPS-030b: throws when nominatim response is not ok', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false }));
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status: 500,
|
||||
statusText: 'Internal Server Error',
|
||||
text: async () => '',
|
||||
}));
|
||||
const { searchNominatim } = await import('../../../src/services/mapsService');
|
||||
await expect(searchNominatim('fail')).rejects.toThrow('Nominatim API error');
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user