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}',
|
||||
|
||||
Reference in New Issue
Block a user