Merge pull request #647 from mauriceboe/fix/session-14042026-b

Journey Bug Fixes #2
This commit is contained in:
Maurice
2026-04-14 20:53:38 +02:00
committed by GitHub
26 changed files with 297 additions and 90 deletions
+3
View File
@@ -322,6 +322,9 @@ export const journeyApi = {
updateContributor: (id: number, userId: number, role: string) => apiClient.patch(`/journeys/${id}/contributors/${userId}`, { role }).then(r => r.data), updateContributor: (id: number, userId: number, role: string) => apiClient.patch(`/journeys/${id}/contributors/${userId}`, { role }).then(r => r.data),
removeContributor: (id: number, userId: number) => apiClient.delete(`/journeys/${id}/contributors/${userId}`).then(r => r.data), removeContributor: (id: number, userId: number) => apiClient.delete(`/journeys/${id}/contributors/${userId}`).then(r => r.data),
// Preferences
updatePreferences: (id: number, data: { hide_skeletons?: boolean }) => apiClient.patch(`/journeys/${id}/preferences`, data).then(r => r.data),
// Share // Share
getShareLink: (id: number) => apiClient.get(`/journeys/${id}/share-link`).then(r => r.data), getShareLink: (id: number) => apiClient.get(`/journeys/${id}/share-link`).then(r => r.data),
createShareLink: (id: number, perms: { share_timeline?: boolean; share_gallery?: boolean; share_map?: boolean }) => apiClient.post(`/journeys/${id}/share-link`, perms).then(r => r.data), createShareLink: (id: number, perms: { share_timeline?: boolean; share_gallery?: boolean; share_map?: boolean }) => apiClient.post(`/journeys/${id}/share-link`, perms).then(r => r.data),
@@ -190,11 +190,12 @@ describe('AddonManager', () => {
expect(screen.queryByText('Bag Tracking')).not.toBeInTheDocument(); expect(screen.queryByText('Bag Tracking')).not.toBeInTheDocument();
}); });
it('FE-ADMIN-ADDON-010: photo provider sub-toggles shown for Memories addon', async () => { it('FE-ADMIN-ADDON-010: photo provider sub-toggles shown under Journey addon', async () => {
server.use( server.use(
http.get('/api/admin/addons', () => http.get('/api/admin/addons', () =>
HttpResponse.json({ HttpResponse.json({
addons: [ addons: [
buildAddon({ id: 'journey', name: 'Journey', type: 'global', icon: 'Compass', enabled: true }),
buildAddon({ id: 'photos', name: 'Memories', type: 'trip', icon: 'Image', enabled: false }), buildAddon({ id: 'photos', name: 'Memories', type: 'trip', icon: 'Image', enabled: false }),
buildAddon({ id: 'unsplash', name: 'Unsplash', type: 'photo_provider', enabled: true }), buildAddon({ id: 'unsplash', name: 'Unsplash', type: 'photo_provider', enabled: true }),
buildAddon({ id: 'pexels', name: 'Pexels', type: 'photo_provider', enabled: false }), buildAddon({ id: 'pexels', name: 'Pexels', type: 'photo_provider', enabled: false }),
@@ -204,18 +205,16 @@ describe('AddonManager', () => {
); );
render(<AddonManager />); render(<AddonManager />);
// Provider sub-rows are visible // Provider sub-rows are visible under Journey addon
await screen.findByText('Unsplash'); await screen.findByText('Unsplash');
expect(screen.getByText('Pexels')).toBeInTheDocument(); expect(screen.getByText('Pexels')).toBeInTheDocument();
// Memories row shows name override // Journey addon is rendered
expect(screen.getByText('Memories providers')).toBeInTheDocument(); expect(screen.getByText('Journey')).toBeInTheDocument();
// The photos addon row itself has no top-level toggle (hideToggle = true) // Toggle buttons: journey toggle + 2 provider toggles
// The toggle buttons are only for the providers
const toggleBtns = screen.getAllByRole('button').filter(b => b.classList.contains('rounded-full')); const toggleBtns = screen.getAllByRole('button').filter(b => b.classList.contains('rounded-full'));
// Should be 2 provider toggles (no main toggle for the photos addon) expect(toggleBtns.length).toBe(3);
expect(toggleBtns.length).toBe(2);
}); });
it('FE-ADMIN-ADDON-011: icon falls back to Puzzle when icon name unknown', async () => { it('FE-ADMIN-ADDON-011: icon falls back to Puzzle when icon name unknown', async () => {
+37 -42
View File
@@ -4,10 +4,10 @@ import { useTranslation } from '../../i18n'
import { useSettingsStore } from '../../store/settingsStore' import { useSettingsStore } from '../../store/settingsStore'
import { useAddonStore } from '../../store/addonStore' import { useAddonStore } from '../../store/addonStore'
import { useToast } from '../shared/Toast' import { useToast } from '../shared/Toast'
import { Puzzle, ListChecks, Wallet, FileText, CalendarDays, Globe, Briefcase, Image, Terminal, Link2, Compass } from 'lucide-react' import { Puzzle, ListChecks, Wallet, FileText, CalendarDays, Globe, Briefcase, Image, Terminal, Link2, Compass, BookOpen } from 'lucide-react'
const ICON_MAP = { const ICON_MAP = {
ListChecks, Wallet, FileText, CalendarDays, Puzzle, Globe, Briefcase, Image, Terminal, Link2, Compass, ListChecks, Wallet, FileText, CalendarDays, Puzzle, Globe, Briefcase, Image, Terminal, Link2, Compass, BookOpen,
} }
interface Addon { interface Addon {
@@ -103,11 +103,11 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking }
} }
} }
const tripAddons = addons.filter(a => a.type === 'trip')
const globalAddons = addons.filter(a => a.type === 'global')
const photoProviderAddons = addons.filter(isPhotoProviderAddon) const photoProviderAddons = addons.filter(isPhotoProviderAddon)
const photosAddon = addons.filter(a => a.type === 'trip').find(isPhotosAddon)
const tripAddons = addons.filter(a => a.type === 'trip' && !isPhotosAddon(a))
const globalAddons = addons.filter(a => a.type === 'global')
const integrationAddons = addons.filter(a => a.type === 'integration') const integrationAddons = addons.filter(a => a.type === 'integration')
const photosAddon = tripAddons.find(isPhotosAddon)
const providerOptions: ProviderOption[] = photoProviderAddons.map((provider) => ({ const providerOptions: ProviderOption[] = photoProviderAddons.map((provider) => ({
key: provider.id, key: provider.id,
label: provider.name, label: provider.name,
@@ -153,42 +153,7 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking }
</div> </div>
{tripAddons.map(addon => ( {tripAddons.map(addon => (
<div key={addon.id}> <div key={addon.id}>
<AddonRow <AddonRow addon={addon} onToggle={handleToggle} t={t} />
addon={addon}
onToggle={handleToggle}
t={t}
nameOverride={photosAddon && addon.id === photosAddon.id ? 'Memories providers' : undefined}
descriptionOverride={photosAddon && addon.id === photosAddon.id ? 'Enable or disable each photo provider.' : undefined}
statusOverride={photosAddon && addon.id === photosAddon.id ? photosDerivedEnabled : undefined}
hideToggle={photosAddon && addon.id === photosAddon.id}
/>
{photosAddon && addon.id === photosAddon.id && providerOptions.length > 0 && (
<div className="px-6 py-3 border-b" style={{ borderColor: 'var(--border-secondary)', background: 'var(--bg-secondary)', paddingLeft: 70 }}>
<div className="space-y-2">
{providerOptions.map(provider => (
<div key={provider.key} className="flex items-center gap-4" style={{ minHeight: 32 }}>
<div style={{ flex: 1, minWidth: 0 }}>
<div className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>{provider.label}</div>
<div className="text-xs mt-0.5" style={{ color: 'var(--text-faint)' }}>{provider.description}</div>
</div>
<div className="flex items-center gap-2 shrink-0">
<span className="hidden sm:inline text-xs font-medium" style={{ color: provider.enabled ? 'var(--text-primary)' : 'var(--text-faint)' }}>
{provider.enabled ? t('admin.addons.enabled') : t('admin.addons.disabled')}
</span>
<button
onClick={provider.toggle}
className="relative inline-flex h-6 w-11 items-center rounded-full transition-colors"
style={{ background: provider.enabled ? 'var(--text-primary)' : 'var(--border-primary)' }}
>
<span className="absolute left-0.5 h-5 w-5 rounded-full bg-white transition-transform duration-200"
style={{ transform: provider.enabled ? 'translateX(20px)' : 'translateX(0)' }} />
</button>
</div>
</div>
))}
</div>
</div>
)}
{addon.id === 'packing' && addon.enabled && onToggleBagTracking && ( {addon.id === 'packing' && addon.enabled && onToggleBagTracking && (
<div className="flex items-center gap-4 px-6 py-3 border-b" style={{ borderColor: 'var(--border-secondary)', background: 'var(--bg-secondary)', paddingLeft: 70 }}> <div className="flex items-center gap-4 px-6 py-3 border-b" style={{ borderColor: 'var(--border-secondary)', background: 'var(--bg-secondary)', paddingLeft: 70 }}>
<div style={{ flex: 1, minWidth: 0 }}> <div style={{ flex: 1, minWidth: 0 }}>
@@ -223,7 +188,37 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking }
</span> </span>
</div> </div>
{globalAddons.map(addon => ( {globalAddons.map(addon => (
<AddonRow key={addon.id} addon={addon} onToggle={handleToggle} t={t} /> <div key={addon.id}>
<AddonRow addon={addon} onToggle={handleToggle} t={t} />
{/* Memories providers as sub-items under Journey addon */}
{addon.id === 'journey' && providerOptions.length > 0 && (
<div className="px-6 py-3 border-b" style={{ borderColor: 'var(--border-secondary)', background: 'var(--bg-secondary)', paddingLeft: 70 }}>
<div className="space-y-2">
{providerOptions.map(provider => (
<div key={provider.key} className="flex items-center gap-4" style={{ minHeight: 32 }}>
<div style={{ flex: 1, minWidth: 0 }}>
<div className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>{provider.label}</div>
<div className="text-xs mt-0.5" style={{ color: 'var(--text-faint)' }}>{provider.description}</div>
</div>
<div className="flex items-center gap-2 shrink-0">
<span className="hidden sm:inline text-xs font-medium" style={{ color: provider.enabled ? 'var(--text-primary)' : 'var(--text-faint)' }}>
{provider.enabled ? t('admin.addons.enabled') : t('admin.addons.disabled')}
</span>
<button
onClick={provider.toggle}
className="relative inline-flex h-6 w-11 items-center rounded-full transition-colors"
style={{ background: provider.enabled ? 'var(--text-primary)' : 'var(--border-primary)' }}
>
<span className="absolute left-0.5 h-5 w-5 rounded-full bg-white transition-transform duration-200"
style={{ transform: provider.enabled ? 'translateX(20px)' : 'translateX(0)' }} />
</button>
</div>
</div>
))}
</div>
</div>
)}
</div>
))} ))}
</div> </div>
)} )}
+2 -1
View File
@@ -168,7 +168,8 @@ const JourneyMap = forwardRef<JourneyMapHandle, Props>(function JourneyMap(
L.tileLayer(mapTileUrl || defaultTile, { L.tileLayer(mapTileUrl || defaultTile, {
maxZoom: 18, maxZoom: 18,
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>', attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>',
}).addTo(map) referrerPolicy: 'strict-origin-when-cross-origin',
} as any).addTo(map)
const items = buildMarkerItems(entries) const items = buildMarkerItems(entries)
itemsRef.current = items itemsRef.current = items
@@ -6,7 +6,7 @@ interface Props {
dark?: boolean dark?: boolean
} }
type FormatAction = { type: 'wrap'; before: string; after: string } | { type: 'line'; prefix: string } type FormatAction = { type: 'wrap'; before: string; after: string } | { type: 'line'; prefix: string } | { type: 'insert'; text: string }
const ACTIONS: Array<{ icon: typeof Bold; label: string; action: FormatAction }> = [ const ACTIONS: Array<{ icon: typeof Bold; label: string; action: FormatAction }> = [
{ icon: Bold, label: 'Bold', action: { type: 'wrap', before: '**', after: '**' } }, { icon: Bold, label: 'Bold', action: { type: 'wrap', before: '**', after: '**' } },
@@ -16,7 +16,7 @@ const ACTIONS: Array<{ icon: typeof Bold; label: string; action: FormatAction }>
{ icon: Link, label: 'Link', action: { type: 'wrap', before: '[', after: '](url)' } }, { icon: Link, label: 'Link', action: { type: 'wrap', before: '[', after: '](url)' } },
{ icon: List, label: 'List', action: { type: 'line', prefix: '- ' } }, { icon: List, label: 'List', action: { type: 'line', prefix: '- ' } },
{ icon: ListOrdered, label: 'Ordered', action: { type: 'line', prefix: '1. ' } }, { icon: ListOrdered, label: 'Ordered', action: { type: 'line', prefix: '1. ' } },
{ icon: Minus, label: 'Divider', action: { type: 'line', prefix: '\n---\n' } }, { icon: Minus, label: 'Divider', action: { type: 'insert', text: '\n\n---\n\n' } },
] ]
export default function MarkdownToolbar({ textareaRef, onUpdate, dark }: Props) { export default function MarkdownToolbar({ textareaRef, onUpdate, dark }: Props) {
@@ -35,6 +35,9 @@ export default function MarkdownToolbar({ textareaRef, onUpdate, dark }: Props)
if (action.type === 'wrap') { if (action.type === 'wrap') {
result = text.slice(0, start) + action.before + selected + action.after + text.slice(end) result = text.slice(0, start) + action.before + selected + action.after + text.slice(end)
cursorPos = selected ? end + action.before.length + action.after.length : start + action.before.length cursorPos = selected ? end + action.before.length + action.after.length : start + action.before.length
} else if (action.type === 'insert') {
result = text.slice(0, start) + action.text + text.slice(end)
cursorPos = start + action.text.length
} else { } else {
// line prefix — find start of current line // line prefix — find start of current line
const lineStart = text.lastIndexOf('\n', start - 1) + 1 const lineStart = text.lastIndexOf('\n', start - 1) + 1
+5
View File
@@ -5,6 +5,8 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
// Common // Common
'common.save': 'حفظ', 'common.save': 'حفظ',
'common.showMore': 'عرض المزيد',
'common.showLess': 'عرض أقل',
'common.cancel': 'إلغاء', 'common.cancel': 'إلغاء',
'common.delete': 'حذف', 'common.delete': 'حذف',
'common.edit': 'تعديل', 'common.edit': 'تعديل',
@@ -1557,6 +1559,8 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
'journey.detail.backToJourney': 'العودة للمجلة', 'journey.detail.backToJourney': 'العودة للمجلة',
'journey.detail.day': 'اليوم {number}', 'journey.detail.day': 'اليوم {number}',
'journey.detail.places': 'أماكن', 'journey.detail.places': 'أماكن',
'journey.skeletons.show': 'إظهار الاقتراحات',
'journey.skeletons.hide': 'إخفاء الاقتراحات',
// Journey — Invite // Journey — Invite
'journey.invite.role': 'الدور', 'journey.invite.role': 'الدور',
@@ -1567,6 +1571,7 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
// Journey Entry Editor // Journey Entry Editor
'journey.editor.uploadPhotos': 'رفع صور', 'journey.editor.uploadPhotos': 'رفع صور',
'journey.editor.uploading': '...جارٍ الرفع',
'journey.editor.fromGallery': 'من المعرض', 'journey.editor.fromGallery': 'من المعرض',
'journey.editor.addAnother': 'إضافة آخر', 'journey.editor.addAnother': 'إضافة آخر',
'journey.editor.makeFirst': 'جعله الأول', 'journey.editor.makeFirst': 'جعله الأول',
+5
View File
@@ -1,6 +1,8 @@
const br: Record<string, string | { name: string; category: string }[]> = { const br: Record<string, string | { name: string; category: string }[]> = {
// Common // Common
'common.save': 'Salvar', 'common.save': 'Salvar',
'common.showMore': 'Mostrar mais',
'common.showLess': 'Mostrar menos',
'common.cancel': 'Cancelar', 'common.cancel': 'Cancelar',
'common.delete': 'Excluir', 'common.delete': 'Excluir',
'common.edit': 'Editar', 'common.edit': 'Editar',
@@ -1895,11 +1897,14 @@ const br: Record<string, string | { name: string; category: string }[]> = {
'journey.stats.entries': 'Entradas', 'journey.stats.entries': 'Entradas',
'journey.stats.photos': 'Fotos', 'journey.stats.photos': 'Fotos',
'journey.stats.places': 'Lugares', 'journey.stats.places': 'Lugares',
'journey.skeletons.show': 'Mostrar sugestões',
'journey.skeletons.hide': 'Ocultar sugestões',
'journey.verdict.lovedIt': 'Adorei', 'journey.verdict.lovedIt': 'Adorei',
'journey.verdict.couldBeBetter': 'Poderia ser melhor', 'journey.verdict.couldBeBetter': 'Poderia ser melhor',
'journey.synced.places': 'lugares', 'journey.synced.places': 'lugares',
'journey.synced.synced': 'sincronizado', 'journey.synced.synced': 'sincronizado',
'journey.editor.uploadPhotos': 'Enviar fotos', 'journey.editor.uploadPhotos': 'Enviar fotos',
'journey.editor.uploading': 'Enviando...',
'journey.editor.fromGallery': 'Da galeria', 'journey.editor.fromGallery': 'Da galeria',
'journey.editor.allPhotosAdded': 'Todas as fotos já foram adicionadas', 'journey.editor.allPhotosAdded': 'Todas as fotos já foram adicionadas',
'journey.editor.writeStory': 'Escreva sua história...', 'journey.editor.writeStory': 'Escreva sua história...',
+5
View File
@@ -1,6 +1,8 @@
const cs: Record<string, string | { name: string; category: string }[]> = { const cs: Record<string, string | { name: string; category: string }[]> = {
// Společné (Common) // Společné (Common)
'common.save': 'Uložit', 'common.save': 'Uložit',
'common.showMore': 'Zobrazit více',
'common.showLess': 'Zobrazit méně',
'common.cancel': 'Zrušit', 'common.cancel': 'Zrušit',
'common.delete': 'Smazat', 'common.delete': 'Smazat',
'common.edit': 'Upravit', 'common.edit': 'Upravit',
@@ -1900,11 +1902,14 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
'journey.stats.entries': 'Záznamy', 'journey.stats.entries': 'Záznamy',
'journey.stats.photos': 'Fotky', 'journey.stats.photos': 'Fotky',
'journey.stats.places': 'Místa', 'journey.stats.places': 'Místa',
'journey.skeletons.show': 'Zobrazit návrhy',
'journey.skeletons.hide': 'Skrýt návrhy',
'journey.verdict.lovedIt': 'Skvělé', 'journey.verdict.lovedIt': 'Skvělé',
'journey.verdict.couldBeBetter': 'Mohlo by být lepší', 'journey.verdict.couldBeBetter': 'Mohlo by být lepší',
'journey.synced.places': 'místa', 'journey.synced.places': 'místa',
'journey.synced.synced': 'synchronizováno', 'journey.synced.synced': 'synchronizováno',
'journey.editor.uploadPhotos': 'Nahrát fotky', 'journey.editor.uploadPhotos': 'Nahrát fotky',
'journey.editor.uploading': 'Nahrávání...',
'journey.editor.fromGallery': 'Z galerie', 'journey.editor.fromGallery': 'Z galerie',
'journey.editor.allPhotosAdded': 'Všechny fotky již přidány', 'journey.editor.allPhotosAdded': 'Všechny fotky již přidány',
'journey.editor.writeStory': 'Napište svůj příběh...', 'journey.editor.writeStory': 'Napište svůj příběh...',
+5
View File
@@ -1,6 +1,8 @@
const de: Record<string, string | { name: string; category: string }[]> = { const de: Record<string, string | { name: string; category: string }[]> = {
// Allgemein // Allgemein
'common.save': 'Speichern', 'common.save': 'Speichern',
'common.showMore': 'Mehr anzeigen',
'common.showLess': 'Weniger anzeigen',
'common.cancel': 'Abbrechen', 'common.cancel': 'Abbrechen',
'common.delete': 'Löschen', 'common.delete': 'Löschen',
'common.edit': 'Bearbeiten', 'common.edit': 'Bearbeiten',
@@ -1901,11 +1903,14 @@ const de: Record<string, string | { name: string; category: string }[]> = {
'journey.stats.entries': 'Einträge', 'journey.stats.entries': 'Einträge',
'journey.stats.photos': 'Fotos', 'journey.stats.photos': 'Fotos',
'journey.stats.places': 'Orte', 'journey.stats.places': 'Orte',
'journey.skeletons.show': 'Vorschläge anzeigen',
'journey.skeletons.hide': 'Vorschläge ausblenden',
'journey.verdict.lovedIt': 'Toll', 'journey.verdict.lovedIt': 'Toll',
'journey.verdict.couldBeBetter': 'Verbesserungswürdig', 'journey.verdict.couldBeBetter': 'Verbesserungswürdig',
'journey.synced.places': 'Orte', 'journey.synced.places': 'Orte',
'journey.synced.synced': 'synchronisiert', 'journey.synced.synced': 'synchronisiert',
'journey.editor.uploadPhotos': 'Fotos hochladen', 'journey.editor.uploadPhotos': 'Fotos hochladen',
'journey.editor.uploading': 'Hochladen...',
'journey.editor.fromGallery': 'Aus Galerie', 'journey.editor.fromGallery': 'Aus Galerie',
'journey.editor.allPhotosAdded': 'Alle Fotos bereits hinzugefügt', 'journey.editor.allPhotosAdded': 'Alle Fotos bereits hinzugefügt',
'journey.editor.writeStory': 'Erzähle deine Geschichte...', 'journey.editor.writeStory': 'Erzähle deine Geschichte...',
+5
View File
@@ -1,6 +1,8 @@
const en: Record<string, string | { name: string; category: string }[]> = { const en: Record<string, string | { name: string; category: string }[]> = {
// Common // Common
'common.save': 'Save', 'common.save': 'Save',
'common.showMore': 'Show more',
'common.showLess': 'Show less',
'common.cancel': 'Cancel', 'common.cancel': 'Cancel',
'common.delete': 'Delete', 'common.delete': 'Delete',
'common.edit': 'Edit', 'common.edit': 'Edit',
@@ -1906,6 +1908,8 @@ const en: Record<string, string | { name: string; category: string }[]> = {
'journey.stats.entries': 'Entries', 'journey.stats.entries': 'Entries',
'journey.stats.photos': 'Photos', 'journey.stats.photos': 'Photos',
'journey.stats.places': 'Places', 'journey.stats.places': 'Places',
'journey.skeletons.show': 'Show suggestions',
'journey.skeletons.hide': 'Hide suggestions',
// Journey Detail — Verdict // Journey Detail — Verdict
'journey.verdict.lovedIt': 'Loved it', 'journey.verdict.lovedIt': 'Loved it',
@@ -1917,6 +1921,7 @@ const en: Record<string, string | { name: string; category: string }[]> = {
// Journey Entry Editor // Journey Entry Editor
'journey.editor.uploadPhotos': 'Upload photos', 'journey.editor.uploadPhotos': 'Upload photos',
'journey.editor.uploading': 'Uploading...',
'journey.editor.fromGallery': 'From Gallery', 'journey.editor.fromGallery': 'From Gallery',
'journey.editor.allPhotosAdded': 'All photos already added', 'journey.editor.allPhotosAdded': 'All photos already added',
'journey.editor.writeStory': 'Write your story...', 'journey.editor.writeStory': 'Write your story...',
+5
View File
@@ -1,6 +1,8 @@
const es: Record<string, string> = { const es: Record<string, string> = {
// Common // Common
'common.save': 'Guardar', 'common.save': 'Guardar',
'common.showMore': 'Ver más',
'common.showLess': 'Ver menos',
'common.cancel': 'Cancelar', 'common.cancel': 'Cancelar',
'common.delete': 'Eliminar', 'common.delete': 'Eliminar',
'common.edit': 'Editar', 'common.edit': 'Editar',
@@ -1902,11 +1904,14 @@ const es: Record<string, string> = {
'journey.stats.entries': 'Entradas', 'journey.stats.entries': 'Entradas',
'journey.stats.photos': 'Fotos', 'journey.stats.photos': 'Fotos',
'journey.stats.places': 'Lugares', 'journey.stats.places': 'Lugares',
'journey.skeletons.show': 'Mostrar sugerencias',
'journey.skeletons.hide': 'Ocultar sugerencias',
'journey.verdict.lovedIt': 'Me encantó', 'journey.verdict.lovedIt': 'Me encantó',
'journey.verdict.couldBeBetter': 'Podría mejorar', 'journey.verdict.couldBeBetter': 'Podría mejorar',
'journey.synced.places': 'lugares', 'journey.synced.places': 'lugares',
'journey.synced.synced': 'sincronizado', 'journey.synced.synced': 'sincronizado',
'journey.editor.uploadPhotos': 'Subir fotos', 'journey.editor.uploadPhotos': 'Subir fotos',
'journey.editor.uploading': 'Subiendo...',
'journey.editor.fromGallery': 'Desde galería', 'journey.editor.fromGallery': 'Desde galería',
'journey.editor.allPhotosAdded': 'Todas las fotos ya fueron añadidas', 'journey.editor.allPhotosAdded': 'Todas las fotos ya fueron añadidas',
'journey.editor.writeStory': 'Escribe tu historia...', 'journey.editor.writeStory': 'Escribe tu historia...',
+5
View File
@@ -1,6 +1,8 @@
const fr: Record<string, string> = { const fr: Record<string, string> = {
// Common // Common
'common.save': 'Enregistrer', 'common.save': 'Enregistrer',
'common.showMore': 'Voir plus',
'common.showLess': 'Voir moins',
'common.cancel': 'Annuler', 'common.cancel': 'Annuler',
'common.delete': 'Supprimer', 'common.delete': 'Supprimer',
'common.edit': 'Modifier', 'common.edit': 'Modifier',
@@ -1896,11 +1898,14 @@ const fr: Record<string, string> = {
'journey.stats.entries': 'Entrées', 'journey.stats.entries': 'Entrées',
'journey.stats.photos': 'Photos', 'journey.stats.photos': 'Photos',
'journey.stats.places': 'Lieux', 'journey.stats.places': 'Lieux',
'journey.skeletons.show': 'Afficher les suggestions',
'journey.skeletons.hide': 'Masquer les suggestions',
'journey.verdict.lovedIt': 'Adoré', 'journey.verdict.lovedIt': 'Adoré',
'journey.verdict.couldBeBetter': 'Pourrait être mieux', 'journey.verdict.couldBeBetter': 'Pourrait être mieux',
'journey.synced.places': 'lieux', 'journey.synced.places': 'lieux',
'journey.synced.synced': 'synchronisé', 'journey.synced.synced': 'synchronisé',
'journey.editor.uploadPhotos': 'Téléverser des photos', 'journey.editor.uploadPhotos': 'Téléverser des photos',
'journey.editor.uploading': 'Envoi...',
'journey.editor.fromGallery': 'Depuis la galerie', 'journey.editor.fromGallery': 'Depuis la galerie',
'journey.editor.allPhotosAdded': 'Toutes les photos ont déjà été ajoutées', 'journey.editor.allPhotosAdded': 'Toutes les photos ont déjà été ajoutées',
'journey.editor.writeStory': 'Écrivez votre histoire...', 'journey.editor.writeStory': 'Écrivez votre histoire...',
+5
View File
@@ -1,6 +1,8 @@
const hu: Record<string, string | { name: string; category: string }[]> = { const hu: Record<string, string | { name: string; category: string }[]> = {
// Általános // Általános
'common.save': 'Mentés', 'common.save': 'Mentés',
'common.showMore': 'Továbbiak',
'common.showLess': 'Kevesebb',
'common.cancel': 'Mégse', 'common.cancel': 'Mégse',
'common.delete': 'Törlés', 'common.delete': 'Törlés',
'common.edit': 'Szerkesztés', 'common.edit': 'Szerkesztés',
@@ -1897,11 +1899,14 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
'journey.stats.entries': 'Bejegyzések', 'journey.stats.entries': 'Bejegyzések',
'journey.stats.photos': 'Fotók', 'journey.stats.photos': 'Fotók',
'journey.stats.places': 'Helyszínek', 'journey.stats.places': 'Helyszínek',
'journey.skeletons.show': 'Javaslatok megjelenítése',
'journey.skeletons.hide': 'Javaslatok elrejtése',
'journey.verdict.lovedIt': 'Imádtam', 'journey.verdict.lovedIt': 'Imádtam',
'journey.verdict.couldBeBetter': 'Lehetne jobb', 'journey.verdict.couldBeBetter': 'Lehetne jobb',
'journey.synced.places': 'helyszín', 'journey.synced.places': 'helyszín',
'journey.synced.synced': 'szinkronizálva', 'journey.synced.synced': 'szinkronizálva',
'journey.editor.uploadPhotos': 'Fotók feltöltése', 'journey.editor.uploadPhotos': 'Fotók feltöltése',
'journey.editor.uploading': 'Feltöltés...',
'journey.editor.fromGallery': 'Galériából', 'journey.editor.fromGallery': 'Galériából',
'journey.editor.allPhotosAdded': 'Minden fotó már hozzáadva', 'journey.editor.allPhotosAdded': 'Minden fotó már hozzáadva',
'journey.editor.writeStory': 'Írd meg a történeted...', 'journey.editor.writeStory': 'Írd meg a történeted...',
+5
View File
@@ -1,6 +1,8 @@
const it: Record<string, string | { name: string; category: string }[]> = { const it: Record<string, string | { name: string; category: string }[]> = {
// Common // Common
'common.save': 'Salva', 'common.save': 'Salva',
'common.showMore': 'Mostra di più',
'common.showLess': 'Mostra meno',
'common.cancel': 'Annulla', 'common.cancel': 'Annulla',
'common.delete': 'Elimina', 'common.delete': 'Elimina',
'common.edit': 'Modifica', 'common.edit': 'Modifica',
@@ -1897,11 +1899,14 @@ const it: Record<string, string | { name: string; category: string }[]> = {
'journey.stats.entries': 'Voci', 'journey.stats.entries': 'Voci',
'journey.stats.photos': 'Foto', 'journey.stats.photos': 'Foto',
'journey.stats.places': 'Luoghi', 'journey.stats.places': 'Luoghi',
'journey.skeletons.show': 'Mostra suggerimenti',
'journey.skeletons.hide': 'Nascondi suggerimenti',
'journey.verdict.lovedIt': 'Adorato', 'journey.verdict.lovedIt': 'Adorato',
'journey.verdict.couldBeBetter': 'Potrebbe essere meglio', 'journey.verdict.couldBeBetter': 'Potrebbe essere meglio',
'journey.synced.places': 'luoghi', 'journey.synced.places': 'luoghi',
'journey.synced.synced': 'sincronizzato', 'journey.synced.synced': 'sincronizzato',
'journey.editor.uploadPhotos': 'Carica foto', 'journey.editor.uploadPhotos': 'Carica foto',
'journey.editor.uploading': 'Caricamento...',
'journey.editor.fromGallery': 'Dalla galleria', 'journey.editor.fromGallery': 'Dalla galleria',
'journey.editor.allPhotosAdded': 'Tutte le foto sono già state aggiunte', 'journey.editor.allPhotosAdded': 'Tutte le foto sono già state aggiunte',
'journey.editor.writeStory': 'Scrivi la tua storia...', 'journey.editor.writeStory': 'Scrivi la tua storia...',
+5
View File
@@ -1,6 +1,8 @@
const nl: Record<string, string> = { const nl: Record<string, string> = {
// Common // Common
'common.save': 'Opslaan', 'common.save': 'Opslaan',
'common.showMore': 'Meer tonen',
'common.showLess': 'Minder tonen',
'common.cancel': 'Annuleren', 'common.cancel': 'Annuleren',
'common.delete': 'Verwijderen', 'common.delete': 'Verwijderen',
'common.edit': 'Bewerken', 'common.edit': 'Bewerken',
@@ -1896,11 +1898,14 @@ const nl: Record<string, string> = {
'journey.stats.entries': 'Vermeldingen', 'journey.stats.entries': 'Vermeldingen',
'journey.stats.photos': 'Foto\'s', 'journey.stats.photos': 'Foto\'s',
'journey.stats.places': 'Plaatsen', 'journey.stats.places': 'Plaatsen',
'journey.skeletons.show': 'Suggesties tonen',
'journey.skeletons.hide': 'Suggesties verbergen',
'journey.verdict.lovedIt': 'Geweldig', 'journey.verdict.lovedIt': 'Geweldig',
'journey.verdict.couldBeBetter': 'Kan beter', 'journey.verdict.couldBeBetter': 'Kan beter',
'journey.synced.places': 'plaatsen', 'journey.synced.places': 'plaatsen',
'journey.synced.synced': 'gesynchroniseerd', 'journey.synced.synced': 'gesynchroniseerd',
'journey.editor.uploadPhotos': 'Foto\'s uploaden', 'journey.editor.uploadPhotos': 'Foto\'s uploaden',
'journey.editor.uploading': 'Uploaden...',
'journey.editor.fromGallery': 'Uit galerij', 'journey.editor.fromGallery': 'Uit galerij',
'journey.editor.allPhotosAdded': 'Alle foto\'s al toegevoegd', 'journey.editor.allPhotosAdded': 'Alle foto\'s al toegevoegd',
'journey.editor.writeStory': 'Schrijf je verhaal...', 'journey.editor.writeStory': 'Schrijf je verhaal...',
+5
View File
@@ -1,6 +1,8 @@
const pl: Record<string, string | { name: string; category: string }[]> = { const pl: Record<string, string | { name: string; category: string }[]> = {
// Common // Common
'common.save': 'Zapisz', 'common.save': 'Zapisz',
'common.showMore': 'Pokaż więcej',
'common.showLess': 'Pokaż mniej',
'common.cancel': 'Anuluj', 'common.cancel': 'Anuluj',
'common.delete': 'Usuń', 'common.delete': 'Usuń',
'common.edit': 'Edytuj', 'common.edit': 'Edytuj',
@@ -1889,11 +1891,14 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
'journey.stats.entries': 'Wpisy', 'journey.stats.entries': 'Wpisy',
'journey.stats.photos': 'Zdjęcia', 'journey.stats.photos': 'Zdjęcia',
'journey.stats.places': 'Miejsca', 'journey.stats.places': 'Miejsca',
'journey.skeletons.show': 'Pokaż sugestie',
'journey.skeletons.hide': 'Ukryj sugestie',
'journey.verdict.lovedIt': 'Świetne', 'journey.verdict.lovedIt': 'Świetne',
'journey.verdict.couldBeBetter': 'Mogłoby być lepiej', 'journey.verdict.couldBeBetter': 'Mogłoby być lepiej',
'journey.synced.places': 'miejsca', 'journey.synced.places': 'miejsca',
'journey.synced.synced': 'zsynchronizowane', 'journey.synced.synced': 'zsynchronizowane',
'journey.editor.uploadPhotos': 'Prześlij zdjęcia', 'journey.editor.uploadPhotos': 'Prześlij zdjęcia',
'journey.editor.uploading': 'Przesyłanie...',
'journey.editor.fromGallery': 'Z galerii', 'journey.editor.fromGallery': 'Z galerii',
'journey.editor.allPhotosAdded': 'Wszystkie zdjęcia już dodane', 'journey.editor.allPhotosAdded': 'Wszystkie zdjęcia już dodane',
'journey.editor.writeStory': 'Napisz swoją historię...', 'journey.editor.writeStory': 'Napisz swoją historię...',
+5
View File
@@ -1,6 +1,8 @@
const ru: Record<string, string> = { const ru: Record<string, string> = {
// Common // Common
'common.save': 'Сохранить', 'common.save': 'Сохранить',
'common.showMore': 'Показать больше',
'common.showLess': 'Показать меньше',
'common.cancel': 'Отмена', 'common.cancel': 'Отмена',
'common.delete': 'Удалить', 'common.delete': 'Удалить',
'common.edit': 'Редактировать', 'common.edit': 'Редактировать',
@@ -1896,11 +1898,14 @@ const ru: Record<string, string> = {
'journey.stats.entries': 'Записей', 'journey.stats.entries': 'Записей',
'journey.stats.photos': 'Фото', 'journey.stats.photos': 'Фото',
'journey.stats.places': 'Мест', 'journey.stats.places': 'Мест',
'journey.skeletons.show': 'Показать предложения',
'journey.skeletons.hide': 'Скрыть предложения',
'journey.verdict.lovedIt': 'Понравилось', 'journey.verdict.lovedIt': 'Понравилось',
'journey.verdict.couldBeBetter': 'Могло быть лучше', 'journey.verdict.couldBeBetter': 'Могло быть лучше',
'journey.synced.places': 'мест', 'journey.synced.places': 'мест',
'journey.synced.synced': 'синхронизировано', 'journey.synced.synced': 'синхронизировано',
'journey.editor.uploadPhotos': 'Загрузить фото', 'journey.editor.uploadPhotos': 'Загрузить фото',
'journey.editor.uploading': 'Загрузка...',
'journey.editor.fromGallery': 'Из галереи', 'journey.editor.fromGallery': 'Из галереи',
'journey.editor.allPhotosAdded': 'Все фото уже добавлены', 'journey.editor.allPhotosAdded': 'Все фото уже добавлены',
'journey.editor.writeStory': 'Напишите свою историю...', 'journey.editor.writeStory': 'Напишите свою историю...',
+5
View File
@@ -1,6 +1,8 @@
const zh: Record<string, string> = { const zh: Record<string, string> = {
// Common // Common
'common.save': '保存', 'common.save': '保存',
'common.showMore': '显示更多',
'common.showLess': '收起',
'common.cancel': '取消', 'common.cancel': '取消',
'common.delete': '删除', 'common.delete': '删除',
'common.edit': '编辑', 'common.edit': '编辑',
@@ -1896,11 +1898,14 @@ const zh: Record<string, string> = {
'journey.stats.entries': '条目', 'journey.stats.entries': '条目',
'journey.stats.photos': '照片', 'journey.stats.photos': '照片',
'journey.stats.places': '地点', 'journey.stats.places': '地点',
'journey.skeletons.show': '显示建议',
'journey.skeletons.hide': '隐藏建议',
'journey.verdict.lovedIt': '非常喜欢', 'journey.verdict.lovedIt': '非常喜欢',
'journey.verdict.couldBeBetter': '有待改进', 'journey.verdict.couldBeBetter': '有待改进',
'journey.synced.places': '个地点', 'journey.synced.places': '个地点',
'journey.synced.synced': '已同步', 'journey.synced.synced': '已同步',
'journey.editor.uploadPhotos': '上传照片', 'journey.editor.uploadPhotos': '上传照片',
'journey.editor.uploading': '上传中...',
'journey.editor.fromGallery': '从相册', 'journey.editor.fromGallery': '从相册',
'journey.editor.allPhotosAdded': '所有照片已添加', 'journey.editor.allPhotosAdded': '所有照片已添加',
'journey.editor.writeStory': '写下你的故事...', 'journey.editor.writeStory': '写下你的故事...',
+5
View File
@@ -1,6 +1,8 @@
const zhTw: Record<string, string> = { const zhTw: Record<string, string> = {
// Common // Common
'common.save': '儲存', 'common.save': '儲存',
'common.showMore': '顯示更多',
'common.showLess': '收起',
'common.cancel': '取消', 'common.cancel': '取消',
'common.delete': '刪除', 'common.delete': '刪除',
'common.edit': '編輯', 'common.edit': '編輯',
@@ -1856,11 +1858,14 @@ const zhTw: Record<string, string> = {
'journey.stats.entries': '條目', 'journey.stats.entries': '條目',
'journey.stats.photos': '照片', 'journey.stats.photos': '照片',
'journey.stats.places': '地點', 'journey.stats.places': '地點',
'journey.skeletons.show': '顯示建議',
'journey.skeletons.hide': '隱藏建議',
'journey.verdict.lovedIt': '非常喜歡', 'journey.verdict.lovedIt': '非常喜歡',
'journey.verdict.couldBeBetter': '有待改進', 'journey.verdict.couldBeBetter': '有待改進',
'journey.synced.places': '個地點', 'journey.synced.places': '個地點',
'journey.synced.synced': '已同步', 'journey.synced.synced': '已同步',
'journey.editor.uploadPhotos': '上傳照片', 'journey.editor.uploadPhotos': '上傳照片',
'journey.editor.uploading': '上傳中...',
'journey.editor.fromGallery': '從相簿', 'journey.editor.fromGallery': '從相簿',
'journey.editor.allPhotosAdded': '所有照片已新增', 'journey.editor.allPhotosAdded': '所有照片已新增',
'journey.editor.writeStory': '寫下你的故事...', 'journey.editor.writeStory': '寫下你的故事...',
+4 -2
View File
@@ -296,8 +296,9 @@ export default function AtlasPage(): React.ReactElement {
updateWhenIdle: false, updateWhenIdle: false,
tileSize: 256, tileSize: 256,
zoomOffset: 0, zoomOffset: 0,
crossOrigin: true crossOrigin: true,
}).addTo(map) referrerPolicy: 'strict-origin-when-cross-origin',
} as any).addTo(map)
// Preload adjacent zoom level tiles // Preload adjacent zoom level tiles
L.tileLayer(tileUrl, { L.tileLayer(tileUrl, {
@@ -306,6 +307,7 @@ export default function AtlasPage(): React.ReactElement {
opacity: 0, opacity: 0,
tileSize: 256, tileSize: 256,
crossOrigin: true, crossOrigin: true,
referrerPolicy: 'strict-origin-when-cross-origin',
}).addTo(map) }).addTo(map)
// Custom pane for region layer — above overlay (z-index 400) // Custom pane for region layer — above overlay (z-index 400)
+83 -33
View File
@@ -18,7 +18,7 @@ import {
Clock, Package, Image, ChevronRight, Clock, Package, Image, ChevronRight,
UserPlus, Plus, Minus, Calendar, Camera, BookOpen, X, Check, ImagePlus, Trash2, Pencil, UserPlus, Plus, Minus, Calendar, Camera, BookOpen, X, Check, ImagePlus, Trash2, Pencil,
Laugh, Smile, Meh, Annoyed, Frown, Laugh, Smile, Meh, Annoyed, Frown,
Sun, CloudSun, Cloud, CloudRain, CloudLightning, Snowflake, ChevronDown, Sun, CloudSun, Cloud, CloudRain, CloudLightning, Snowflake, ChevronDown, Eye, EyeOff,
} from 'lucide-react' } from 'lucide-react'
import type { JourneyEntry, JourneyPhoto, JourneyDetail } from '../store/journeyStore' import type { JourneyEntry, JourneyPhoto, JourneyDetail } from '../store/journeyStore'
@@ -92,11 +92,16 @@ export default function JourneyDetailPage() {
const [showAddTrip, setShowAddTrip] = useState(false) const [showAddTrip, setShowAddTrip] = useState(false)
const [unlinkTrip, setUnlinkTrip] = useState<{ trip_id: number; title: string } | null>(null) const [unlinkTrip, setUnlinkTrip] = useState<{ trip_id: number; title: string } | null>(null)
const [showSettings, setShowSettings] = useState(false) const [showSettings, setShowSettings] = useState(false)
const [hideSkeletons, setHideSkeletons] = useState(false)
useEffect(() => { useEffect(() => {
if (id) loadJourney(Number(id)).catch(() => {}) if (id) loadJourney(Number(id)).catch(() => {})
}, [id]) }, [id])
useEffect(() => {
if (current?.hide_skeletons !== undefined) setHideSkeletons(current.hide_skeletons)
}, [current?.hide_skeletons])
useEffect(() => { useEffect(() => {
if (notFound) { if (notFound) {
toast.error(t('journey.notFound')) toast.error(t('journey.notFound'))
@@ -193,7 +198,7 @@ export default function JourneyDetailPage() {
) )
} }
const timelineEntries = current.entries.filter(e => e.title !== 'Gallery' && e.title !== '[Trip Photos]') const timelineEntries = current.entries.filter(e => e.title !== 'Gallery' && e.title !== '[Trip Photos]' && (!hideSkeletons || e.type !== 'skeleton'))
const dayGroups = groupByDate(timelineEntries) const dayGroups = groupByDate(timelineEntries)
const sortedDates = [...dayGroups.keys()].sort() const sortedDates = [...dayGroups.keys()].sort()
@@ -243,7 +248,21 @@ export default function JourneyDetailPage() {
</button> </button>
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<button onClick={() => { import('../components/PDF/JourneyBookPDF').then(m => m.downloadJourneyBookPDF(current)) }} className="w-[34px] h-[34px] rounded-lg bg-white/15 backdrop-blur flex items-center justify-center hover:bg-white/25"><Download size={14} /></button> <button onClick={() => { import('../components/PDF/JourneyBookPDF').then(m => m.downloadJourneyBookPDF(current)) }} className="w-[34px] h-[34px] rounded-lg bg-white/15 backdrop-blur flex items-center justify-center hover:bg-white/25"><Download size={14} /></button>
<button onClick={() => setShowSettings(true)} className="w-[34px] h-[34px] rounded-lg bg-white/15 backdrop-blur flex items-center justify-center hover:bg-white/25"><Share2 size={14} /></button> <div className="relative group">
<button
onClick={async () => {
const next = !hideSkeletons
setHideSkeletons(next)
await journeyApi.updatePreferences(current.id, { hide_skeletons: next })
}}
className={`w-[34px] h-[34px] rounded-lg backdrop-blur flex items-center justify-center ${hideSkeletons ? 'bg-white/30' : 'bg-white/15 hover:bg-white/25'}`}
>
{hideSkeletons ? <EyeOff size={14} /> : <Eye size={14} />}
</button>
<span className="absolute top-full mt-2 left-1/2 -translate-x-1/2 px-2 py-1 rounded-md bg-zinc-900 dark:bg-zinc-100 text-white dark:text-zinc-900 text-[11px] font-medium whitespace-nowrap opacity-0 pointer-events-none group-hover:opacity-100 transition-opacity">
{hideSkeletons ? t('journey.skeletons.show') : t('journey.skeletons.hide')}
</span>
</div>
<button onClick={() => setShowSettings(true)} className="w-[34px] h-[34px] rounded-lg bg-white/15 backdrop-blur flex items-center justify-center hover:bg-white/25"><MoreHorizontal size={14} /></button> <button onClick={() => setShowSettings(true)} className="w-[34px] h-[34px] rounded-lg bg-white/15 backdrop-blur flex items-center justify-center hover:bg-white/25"><MoreHorizontal size={14} /></button>
</div> </div>
</div> </div>
@@ -753,6 +772,7 @@ function GalleryView({ entries, journeyId, userId, trips, onPhotoClick, onRefres
const [showPicker, setShowPicker] = useState(false) const [showPicker, setShowPicker] = useState(false)
const [pickerProvider, setPickerProvider] = useState<string | null>(null) const [pickerProvider, setPickerProvider] = useState<string | null>(null)
const [availableProviders, setAvailableProviders] = useState<{ id: string; name: string }[]>([]) const [availableProviders, setAvailableProviders] = useState<{ id: string; name: string }[]>([])
const [galleryUploading, setGalleryUploading] = useState(false)
const toast = useToast() const toast = useToast()
// check which providers are enabled AND connected for the current user // check which providers are enabled AND connected for the current user
@@ -797,27 +817,28 @@ function GalleryView({ entries, journeyId, userId, trips, onPhotoClick, onRefres
const handleGalleryUpload = async (e: React.ChangeEvent<HTMLInputElement>) => { const handleGalleryUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files const files = e.target.files
if (!files?.length) return if (!files?.length) return
// find existing "Gallery" entry or create one setGalleryUploading(true)
let galleryEntry = entries.find(e => e.title === 'Gallery' && e.type === 'entry') try {
let entryId = galleryEntry?.id // find existing "Gallery" entry or create one
if (!entryId) { let galleryEntry = entries.find(e => e.title === 'Gallery' && e.type === 'entry')
try { let entryId = galleryEntry?.id
if (!entryId) {
const entry = await journeyApi.createEntry(journeyId, { const entry = await journeyApi.createEntry(journeyId, {
title: t('journey.share.gallery'), title: t('journey.share.gallery'),
entry_date: new Date().toISOString().split('T')[0], entry_date: new Date().toISOString().split('T')[0],
type: 'entry', type: 'entry',
}) })
entryId = entry.id entryId = entry.id
} catch { return } }
} const formData = new FormData()
const formData = new FormData() for (const f of files) formData.append('photos', f)
for (const f of files) formData.append('photos', f)
try {
await journeyApi.uploadPhotos(entryId, formData) await journeyApi.uploadPhotos(entryId, formData)
toast.success(t('journey.photosUploaded', { count: files.length })) toast.success(t('journey.photosUploaded', { count: files.length }))
onRefresh() onRefresh()
} catch { } catch {
toast.error(t('journey.settings.coverFailed')) toast.error(t('journey.settings.coverFailed'))
} finally {
setGalleryUploading(false)
} }
e.target.value = '' e.target.value = ''
} }
@@ -855,10 +876,14 @@ function GalleryView({ entries, journeyId, userId, trips, onPhotoClick, onRefres
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<button <button
onClick={() => galleryFileRef.current?.click()} onClick={() => galleryFileRef.current?.click()}
className="inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 text-[11px] font-medium hover:bg-zinc-800 dark:hover:bg-zinc-100" disabled={galleryUploading}
className="inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 text-[11px] font-medium hover:bg-zinc-800 dark:hover:bg-zinc-100 disabled:opacity-50"
> >
<Plus size={12} /> {galleryUploading ? (
{t('common.upload')} <><div className="w-3 h-3 border-2 border-white/30 dark:border-zinc-900/30 border-t-white dark:border-t-zinc-900 rounded-full animate-spin" /> {t('journey.editor.uploading')}</>
) : (
<><Plus size={12} /> {t('common.upload')}</>
)}
</button> </button>
{availableProviders.map(p => ( {availableProviders.map(p => (
<button <button
@@ -903,11 +928,11 @@ function GalleryView({ entries, journeyId, userId, trips, onPhotoClick, onRefres
> >
<X size={12} /> <X size={12} />
</button> </button>
{photo.provider !== 'local' && ( {photo.provider && photo.provider !== 'local' && (
<div className="absolute top-1.5 left-1.5"> <div className="absolute top-1.5 left-1.5">
<span className="text-[8px] font-medium px-1.5 py-0.5 rounded-full bg-black/70 backdrop-blur text-white flex items-center gap-1"> <span className="text-[8px] font-medium px-1.5 py-0.5 rounded-full bg-black/70 backdrop-blur text-white flex items-center gap-1">
<RefreshCw size={7} /> <RefreshCw size={7} />
{photo.provider === 'immich' ? 'Immich' : 'Synology'} {photo.provider === 'immich' ? 'Immich' : photo.provider === 'synology' ? 'Synology' : photo.provider}
</span> </span>
</div> </div>
)} )}
@@ -967,6 +992,7 @@ function GalleryView({ entries, journeyId, userId, trips, onPhotoClick, onRefres
// ── Expandable Story ───────────────────────────────────────────────────── // ── Expandable Story ─────────────────────────────────────────────────────
function ExpandableStory({ story }: { story: string }) { function ExpandableStory({ story }: { story: string }) {
const { t } = useTranslation()
const [expanded, setExpanded] = useState(false) const [expanded, setExpanded] = useState(false)
const [clamped, setClamped] = useState(false) const [clamped, setClamped] = useState(false)
const ref = useRef<HTMLDivElement>(null) const ref = useRef<HTMLDivElement>(null)
@@ -992,16 +1018,24 @@ function ExpandableStory({ story }: { story: string }) {
onClick={() => { if (clamped || expanded) setExpanded(e => !e) }} onClick={() => { if (clamped || expanded) setExpanded(e => !e) }}
className={`text-[13px] text-zinc-700 dark:text-zinc-300 leading-relaxed ${ className={`text-[13px] text-zinc-700 dark:text-zinc-300 leading-relaxed ${
expanded ? '' : 'line-clamp-3 md:line-clamp-[9]' expanded ? '' : 'line-clamp-3 md:line-clamp-[9]'
} ${clamped || expanded ? 'cursor-pointer md:cursor-auto' : ''}`} } ${clamped || expanded ? 'cursor-pointer' : ''}`}
> >
<JournalBody text={story} /> <JournalBody text={story} />
</div> </div>
{clamped && !expanded && ( {clamped && !expanded && (
<button <button
onClick={() => setExpanded(true)} onClick={() => setExpanded(true)}
className="md:hidden mt-2 inline-flex items-center gap-1 px-2.5 py-1 rounded-full bg-zinc-100 dark:bg-zinc-800 text-[10px] font-medium text-zinc-500 dark:text-zinc-400 active:scale-95 transition-transform" className="mt-2 inline-flex items-center gap-1 px-2.5 py-1 rounded-full bg-zinc-100 dark:bg-zinc-800 text-[10px] font-medium text-zinc-500 dark:text-zinc-400 hover:bg-zinc-200 dark:hover:bg-zinc-700 active:scale-95 transition-all"
> >
Read more <ChevronRight size={10} /> {t('common.showMore')} <ChevronRight size={10} />
</button>
)}
{expanded && (
<button
onClick={() => setExpanded(false)}
className="mt-2 inline-flex items-center gap-1 px-2.5 py-1 rounded-full bg-zinc-100 dark:bg-zinc-800 text-[10px] font-medium text-zinc-500 dark:text-zinc-400 hover:bg-zinc-200 dark:hover:bg-zinc-700 active:scale-95 transition-all"
>
{t('common.showLess')} <ChevronRight size={10} className="rotate-[-90deg]" />
</button> </button>
)} )}
</div> </div>
@@ -1452,8 +1486,12 @@ function ProviderPicker({ provider, userId, entries, trips, existingAssetIds, on
const assets = data.assets || [] const assets = data.assets || []
setPhotos(prev => append ? [...prev, ...assets] : assets) setPhotos(prev => append ? [...prev, ...assets] : assets)
setHasMore(!!data.hasMore) setHasMore(!!data.hasMore)
} else {
setHasMore(false)
} }
} catch (e: any) { if (e.name !== 'AbortError') {} } } catch (e: any) {
if (e.name !== 'AbortError') setHasMore(false)
}
if (!signal.aborted) { setLoading(false); setLoadingMore(false) } if (!signal.aborted) { setLoading(false); setLoadingMore(false) }
} }
@@ -1466,6 +1504,7 @@ function ProviderPicker({ provider, userId, entries, trips, existingAssetIds, on
const signal = cancelPending() const signal = cancelPending()
setLoading(true) setLoading(true)
setPhotos([]) setPhotos([])
setHasMore(false)
try { try {
const res = await fetch(`/api/integrations/memories/${provider}/albums/${albumId}/photos`, { credentials: 'include', signal }) const res = await fetch(`/api/integrations/memories/${provider}/albums/${albumId}/photos`, { credentials: 'include', signal })
if (res.ok) setPhotos((await res.json()).assets || []) if (res.ok) setPhotos((await res.json()).assets || [])
@@ -1739,7 +1778,7 @@ function ProviderPicker({ provider, userId, entries, trips, existingAssetIds, on
) )
})} })}
{/* Infinite scroll trigger */} {/* Infinite scroll trigger */}
{hasMore && <ScrollTrigger onVisible={loadMorePhotos} loading={loadingMore} />} {hasMore && !selectedAlbum && <ScrollTrigger onVisible={loadMorePhotos} loading={loadingMore} />}
</div> </div>
)} )}
</div> </div>
@@ -1899,6 +1938,7 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa
const [pros, setPros] = useState<string[]>(entry.pros_cons?.pros?.length ? entry.pros_cons.pros : ['']) const [pros, setPros] = useState<string[]>(entry.pros_cons?.pros?.length ? entry.pros_cons.pros : [''])
const [cons, setCons] = useState<string[]>(entry.pros_cons?.cons?.length ? entry.pros_cons.cons : ['']) const [cons, setCons] = useState<string[]>(entry.pros_cons?.cons?.length ? entry.pros_cons.cons : [''])
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
const [uploading, setUploading] = useState(false)
const [photos, setPhotos] = useState<JourneyPhoto[]>(entry.photos || []) const [photos, setPhotos] = useState<JourneyPhoto[]>(entry.photos || [])
const [pendingFiles, setPendingFiles] = useState<File[]>([]) const [pendingFiles, setPendingFiles] = useState<File[]>([])
const [pendingLinkIds, setPendingLinkIds] = useState<number[]>([]) const [pendingLinkIds, setPendingLinkIds] = useState<number[]>([])
@@ -1947,10 +1987,15 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa
// queue files for upload after save // queue files for upload after save
setPendingFiles(prev => [...prev, ...Array.from(files)]) setPendingFiles(prev => [...prev, ...Array.from(files)])
} else { } else {
const formData = new FormData() setUploading(true)
for (const f of files) formData.append('photos', f) try {
const newPhotos = await onUploadPhotos(entry.id, formData) const formData = new FormData()
if (newPhotos?.length) setPhotos(prev => [...prev, ...newPhotos]) for (const f of files) formData.append('photos', f)
const newPhotos = await onUploadPhotos(entry.id, formData)
if (newPhotos?.length) setPhotos(prev => [...prev, ...newPhotos])
} finally {
setUploading(false)
}
} }
} }
@@ -1978,9 +2023,14 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa
<div className="flex gap-2"> <div className="flex gap-2">
<button <button
onClick={() => fileRef.current?.click()} onClick={() => fileRef.current?.click()}
className="flex-1 border border-dashed border-zinc-200 dark:border-zinc-700 rounded-lg py-4 text-[12px] text-zinc-500 hover:border-zinc-400 dark:hover:border-zinc-500 hover:bg-zinc-50 dark:hover:bg-zinc-800 flex items-center justify-center gap-1.5" disabled={uploading}
className="flex-1 border border-dashed border-zinc-200 dark:border-zinc-700 rounded-lg py-4 text-[12px] text-zinc-500 hover:border-zinc-400 dark:hover:border-zinc-500 hover:bg-zinc-50 dark:hover:bg-zinc-800 flex items-center justify-center gap-1.5 disabled:opacity-50"
> >
<Plus size={13} /> {t('journey.editor.uploadPhotos')} {uploading ? (
<><div className="w-3.5 h-3.5 border-2 border-zinc-300 border-t-zinc-600 rounded-full animate-spin" /> {t('journey.editor.uploading')}</>
) : (
<><Plus size={13} /> {t('journey.editor.uploadPhotos')}</>
)}
</button> </button>
{galleryPhotos.length > 0 && ( {galleryPhotos.length > 0 && (
<button <button
@@ -2688,8 +2738,8 @@ function JourneySettingsDialog({ journey, onClose, onSaved, onOpenInvite }: {
} }
return ( return (
<div className="fixed inset-0 z-[200] flex items-center justify-center p-5" style={{ background: 'rgba(9,9,11,0.6)', backdropFilter: 'blur(6px)' }}> <div className="fixed inset-0 z-[200] flex items-end md:items-center justify-center md:p-5 overscroll-none" style={{ background: 'rgba(9,9,11,0.6)', backdropFilter: 'blur(6px)' }} onClick={onClose} onTouchMove={e => { if (e.target === e.currentTarget) e.preventDefault() }}>
<div className="bg-white dark:bg-zinc-900 rounded-2xl shadow-[0_20px_40px_rgba(0,0,0,0.2)] max-w-[480px] w-full max-h-[90vh] flex flex-col overflow-hidden"> <div className="bg-white dark:bg-zinc-900 rounded-t-2xl md:rounded-2xl shadow-[0_20px_40px_rgba(0,0,0,0.2)] max-w-[480px] w-full max-h-[85vh] md:max-h-[90vh] flex flex-col overflow-hidden pb-safe" onClick={e => e.stopPropagation()}>
<div className="flex items-center justify-between px-6 py-4 border-b border-zinc-200 dark:border-zinc-700"> <div className="flex items-center justify-between px-6 py-4 border-b border-zinc-200 dark:border-zinc-700">
<h2 className="text-[16px] font-bold text-zinc-900 dark:text-white">{t('journey.settings.title')}</h2> <h2 className="text-[16px] font-bold text-zinc-900 dark:text-white">{t('journey.settings.title')}</h2>
@@ -2698,7 +2748,7 @@ function JourneySettingsDialog({ journey, onClose, onSaved, onOpenInvite }: {
</button> </button>
</div> </div>
<div className="flex-1 overflow-y-auto px-6 py-5 flex flex-col gap-5"> <div className="flex-1 overflow-y-auto overscroll-contain px-6 py-5 flex flex-col gap-5">
{/* Cover Image */} {/* Cover Image */}
<div> <div>
<label className="text-[10px] font-semibold tracking-[0.12em] uppercase text-zinc-500 block mb-2">{t('journey.settings.coverImage')}</label> <label className="text-[10px] font-semibold tracking-[0.12em] uppercase text-zinc-500 block mb-2">{t('journey.settings.coverImage')}</label>
@@ -2801,7 +2851,7 @@ function JourneySettingsDialog({ journey, onClose, onSaved, onOpenInvite }: {
</div> </div>
{/* Footer */} {/* Footer */}
<div className="flex items-center gap-2 px-6 py-4 border-t border-zinc-200 dark:border-zinc-700 bg-zinc-50 dark:bg-zinc-800/50"> <div className="flex items-center gap-2 px-6 py-4 pb-6 md:pb-4 border-t border-zinc-200 dark:border-zinc-700 bg-zinc-50 dark:bg-zinc-800/50">
<button <button
onClick={() => setShowDeleteConfirm(true)} onClick={() => setShowDeleteConfirm(true)}
className="flex items-center gap-1.5 text-[12px] font-medium text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-lg px-2.5 py-2 mr-auto" className="flex items-center gap-1.5 text-[12px] font-medium text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-lg px-2.5 py-2 mr-auto"
+1
View File
@@ -82,6 +82,7 @@ export interface JourneyDetail extends Journey {
trips: JourneyTrip[] trips: JourneyTrip[]
contributors: JourneyContributor[] contributors: JourneyContributor[]
stats: { entries: number; photos: number; cities: number } stats: { entries: number; photos: number; cities: number }
hide_skeletons?: boolean
} }
interface JourneyState { interface JourneyState {
+4
View File
@@ -1574,6 +1574,10 @@ function runMigrations(db: Database.Database): void {
db.exec('CREATE INDEX IF NOT EXISTS idx_journey_photos_photo ON journey_photos(photo_id)'); db.exec('CREATE INDEX IF NOT EXISTS idx_journey_photos_photo ON journey_photos(photo_id)');
} }
}, },
// Migration 99: hide_skeletons per-user setting on journey_contributors
() => {
try { db.exec('ALTER TABLE journey_contributors ADD COLUMN hide_skeletons INTEGER NOT NULL DEFAULT 0'); } catch {}
},
]; ];
if (currentVersion < migrations.length) { if (currentVersion < migrations.length) {
+9
View File
@@ -279,6 +279,15 @@ router.delete('/:id/contributors/:userId', authenticate, (req: Request, res: Res
res.json({ success: true }); res.json({ success: true });
}); });
// ── User Preferences ─────────────────────────────────────────────────────
router.patch('/:id/preferences', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const result = svc.updateJourneyPreferences(Number(req.params.id), authReq.user.id, req.body);
if (!result) return res.status(403).json({ error: 'Not allowed' });
res.json(result);
});
// ── Share Link ──────────────────────────────────────────────────────────── // ── Share Link ────────────────────────────────────────────────────────────
router.get('/:id/share-link', authenticate, (req: Request, res: Response) => { router.get('/:id/share-link', authenticate, (req: Request, res: Response) => {
+32 -2
View File
@@ -161,12 +161,17 @@ export function getJourneyFull(journeyId: number, userId: number) {
const photoCount = photos.length; const photoCount = photos.length;
const cities = [...new Set(entries.map(e => e.location_name).filter(Boolean))]; const cities = [...new Set(entries.map(e => e.location_name).filter(Boolean))];
const userPrefs = db.prepare(
'SELECT hide_skeletons FROM journey_contributors WHERE journey_id = ? AND user_id = ?'
).get(journeyId, userId) as { hide_skeletons: number } | undefined;
return { return {
...journey, ...journey,
entries: enrichedEntries, entries: enrichedEntries,
trips, trips,
contributors, contributors,
stats: { entries: entryCount, photos: photoCount, cities: cities.length }, stats: { entries: entryCount, photos: photoCount, cities: cities.length },
hide_skeletons: !!(userPrefs?.hide_skeletons),
}; };
} }
@@ -197,6 +202,19 @@ export function updateJourney(journeyId: number, userId: number, data: Partial<{
return db.prepare('SELECT * FROM journeys WHERE id = ?').get(journeyId) as Journey; return db.prepare('SELECT * FROM journeys WHERE id = ?').get(journeyId) as Journey;
} }
export function updateJourneyPreferences(journeyId: number, userId: number, data: { hide_skeletons?: boolean }) {
if (!canAccessJourney(journeyId, userId)) return null;
if (data.hide_skeletons !== undefined) {
db.prepare(
'UPDATE journey_contributors SET hide_skeletons = ? WHERE journey_id = ? AND user_id = ?'
).run(data.hide_skeletons ? 1 : 0, journeyId, userId);
}
const row = db.prepare(
'SELECT hide_skeletons FROM journey_contributors WHERE journey_id = ? AND user_id = ?'
).get(journeyId, userId) as { hide_skeletons: number };
return { hide_skeletons: !!row.hide_skeletons };
}
export function deleteJourney(journeyId: number, userId: number): boolean { export function deleteJourney(journeyId: number, userId: number): boolean {
if (!isOwner(journeyId, userId)) return false; if (!isOwner(journeyId, userId)) return false;
db.prepare('DELETE FROM journeys WHERE id = ?').run(journeyId); db.prepare('DELETE FROM journeys WHERE id = ?').run(journeyId);
@@ -567,7 +585,20 @@ export function deleteEntry(entryId: number, userId: number, sid?: string): bool
// delete photos along with the entry — no more orphan Gallery entries // delete photos along with the entry — no more orphan Gallery entries
db.prepare('DELETE FROM journey_photos WHERE entry_id = ?').run(entryId); db.prepare('DELETE FROM journey_photos WHERE entry_id = ?').run(entryId);
db.prepare('DELETE FROM journey_entries WHERE id = ?').run(entryId);
if (entry.source_trip_id && entry.source_place_id && entry.type !== 'skeleton') {
// Revert filled entry back to skeleton instead of deleting
db.prepare(`
UPDATE journey_entries
SET type = 'skeleton', story = NULL, mood = NULL, weather = NULL, pros_cons = NULL,
visibility = 'private', updated_at = ?
WHERE id = ?
`).run(ts(), entryId);
broadcastJourneyEvent(entry.journey_id, 'journey:entry:updated', { entryId }, sid);
} else {
db.prepare('DELETE FROM journey_entries WHERE id = ?').run(entryId);
broadcastJourneyEvent(entry.journey_id, 'journey:entry:deleted', { entryId }, sid);
}
// clean up any empty Gallery entries in this journey // clean up any empty Gallery entries in this journey
db.prepare(` db.prepare(`
@@ -575,7 +606,6 @@ export function deleteEntry(entryId: number, userId: number, sid?: string): bool
AND id NOT IN (SELECT DISTINCT entry_id FROM journey_photos) AND id NOT IN (SELECT DISTINCT entry_id FROM journey_photos)
`).run(entry.journey_id); `).run(entry.journey_id);
broadcastJourneyEvent(entry.journey_id, 'journey:entry:deleted', { entryId }, sid);
return true; return true;
} }
@@ -565,6 +565,46 @@ describe('deleteEntry', () => {
expect(deleteEntry(entry.id, viewer.id)).toBe(false); expect(deleteEntry(entry.id, viewer.id)).toBe(false);
}); });
it('JOURNEY-SVC-037b: deleting a filled skeleton reverts it back to skeleton', () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
const trip = createTrip(testDb, user.id);
const place = createPlace(testDb, trip.id, { name: 'Tokyo Tower' });
// Create a filled entry that originated from a trip skeleton
const now = Date.now();
testDb.prepare(`
INSERT INTO journey_entries (journey_id, source_trip_id, source_place_id, author_id, type, title, story, mood, entry_date, location_name, visibility, sort_order, created_at, updated_at)
VALUES (?, ?, ?, ?, 'entry', 'Tokyo Tower', 'Amazing view!', 'amazing', '2026-03-01', 'Tokyo', 'private', 0, ?, ?)
`).run(journey.id, trip.id, place.id, user.id, now, now);
const entry = testDb.prepare('SELECT * FROM journey_entries WHERE journey_id = ? AND source_place_id = ?').get(journey.id, place.id) as any;
const result = deleteEntry(entry.id, user.id);
expect(result).toBe(true);
// Entry should still exist but reverted to skeleton
const reverted = testDb.prepare('SELECT * FROM journey_entries WHERE id = ?').get(entry.id) as any;
expect(reverted).toBeDefined();
expect(reverted.type).toBe('skeleton');
expect(reverted.story).toBeNull();
expect(reverted.mood).toBeNull();
expect(reverted.source_trip_id).toBe(trip.id);
expect(reverted.source_place_id).toBe(place.id);
expect(reverted.title).toBe('Tokyo Tower');
});
it('JOURNEY-SVC-037c: deleting an independent entry permanently removes it', () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
const entry = createJourneyEntry(testDb, journey.id, user.id, { entry_date: '2026-03-01', story: 'Manual entry' });
const result = deleteEntry(entry.id, user.id);
expect(result).toBe(true);
const row = testDb.prepare('SELECT * FROM journey_entries WHERE id = ?').get(entry.id);
expect(row).toBeUndefined();
});
}); });
// -- Photos ------------------------------------------------------------------- // -- Photos -------------------------------------------------------------------