diff --git a/client/package-lock.json b/client/package-lock.json index 75d58947..2722ac74 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -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", diff --git a/client/package.json b/client/package.json index e5d2c276..ce178383 100644 --- a/client/package.json +++ b/client/package.json @@ -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" diff --git a/client/src/api/client.ts b/client/src/api/client.ts index c8334e8a..950eed0b 100644 --- a/client/src/api/client.ts +++ b/client/src/api/client.ts @@ -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) => 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), diff --git a/client/src/components/Admin/AddonManager.test.tsx b/client/src/components/Admin/AddonManager.test.tsx index 51054bef..206f063d 100644 --- a/client/src/components/Admin/AddonManager.test.tsx +++ b/client/src/components/Admin/AddonManager.test.tsx @@ -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(); - // 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 () => { diff --git a/client/src/components/Admin/AddonManager.tsx b/client/src/components/Admin/AddonManager.tsx index 5d9f7887..8a564381 100644 --- a/client/src/components/Admin/AddonManager.tsx +++ b/client/src/components/Admin/AddonManager.tsx @@ -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 } {tripAddons.map(addon => (
- - {photosAddon && addon.id === photosAddon.id && providerOptions.length > 0 && ( -
-
- {providerOptions.map(provider => ( -
-
-
{provider.label}
-
{provider.description}
-
-
- - {provider.enabled ? t('admin.addons.enabled') : t('admin.addons.disabled')} - - -
-
- ))} -
-
- )} + {addon.id === 'packing' && addon.enabled && onToggleBagTracking && (
@@ -223,7 +188,37 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking }
{globalAddons.map(addon => ( - +
+ + {/* Memories providers as sub-items under Journey addon */} + {addon.id === 'journey' && providerOptions.length > 0 && ( +
+
+ {providerOptions.map(provider => ( +
+
+
{provider.label}
+
{provider.description}
+
+
+ + {provider.enabled ? t('admin.addons.enabled') : t('admin.addons.disabled')} + + +
+
+ ))} +
+
+ )} +
))}
)} diff --git a/client/src/components/Collab/CollabNotes.tsx b/client/src/components/Collab/CollabNotes.tsx index 66a4dbd7..2585cabf 100644 --- a/client/src/components/Collab/CollabNotes.tsx +++ b/client/src/components/Collab/CollabNotes.tsx @@ -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, }}> - {note.content} + {note.content}
)} @@ -1352,7 +1353,7 @@ export default function CollabNotes({ tripId, currentUser }: CollabNotesProps) {
- {viewingNote.content || ''} + {viewingNote.content || ''} {(viewingNote.attachments || []).length > 0 && (
{t('files.title')}
diff --git a/client/src/components/Journey/JournalBody.test.tsx b/client/src/components/Journey/JournalBody.test.tsx index 39da6246..4a74878d 100644 --- a/client/src/components/Journey/JournalBody.test.tsx +++ b/client/src/components/Journey/JournalBody.test.tsx @@ -27,9 +27,9 @@ describe('JournalBody', () => { it('FE-COMP-JOURNALBODY-004: renders headings with proper elements', () => { const { container } = render(); - 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', () => { diff --git a/client/src/components/Journey/JournalBody.tsx b/client/src/components/Journey/JournalBody.tsx index b043c199..2caa84c3 100644 --- a/client/src/components/Journey/JournalBody.tsx +++ b/client/src/components/Journey/JournalBody.tsx @@ -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', }}>

{children}

, - h2: ({ children }) =>

{children}

, - h3: ({ children }) =>

{children}

, + h1: ({ children }) =>

{children}

, + h2: ({ children }) =>

{children}

, + h3: ({ children }) =>

{children}

, p: ({ children }) =>

{children}

, blockquote: ({ children }) => (
- {text} + {text.replace(/^(.+)\n([-=]{3,})$/gm, '$1\n\n$2')}
) diff --git a/client/src/components/Journey/JourneyMap.tsx b/client/src/components/Journey/JourneyMap.tsx index f8a0d58a..88b08d0b 100644 --- a/client/src/components/Journey/JourneyMap.tsx +++ b/client/src/components/Journey/JourneyMap.tsx @@ -155,7 +155,7 @@ const JourneyMap = forwardRef(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(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: '© OpenStreetMap', + referrerPolicy: 'strict-origin-when-cross-origin', + } as any).addTo(map) const items = buildMarkerItems(entries) itemsRef.current = items diff --git a/client/src/components/Journey/MarkdownToolbar.tsx b/client/src/components/Journey/MarkdownToolbar.tsx index 6a82cadb..4ad519eb 100644 --- a/client/src/components/Journey/MarkdownToolbar.tsx +++ b/client/src/components/Journey/MarkdownToolbar.tsx @@ -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 diff --git a/client/src/components/Memories/MemoriesPanel.test.tsx b/client/src/components/Memories/MemoriesPanel.test.tsx index f25a3dce..cbb914a2 100644 --- a/client/src/components/Memories/MemoriesPanel.test.tsx +++ b/client/src/components/Memories/MemoriesPanel.test.tsx @@ -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' }, ], }) ), diff --git a/client/src/components/Memories/MemoriesPanel.tsx b/client/src/components/Memories/MemoriesPanel.tsx index ec14ab25..72c79f18 100644 --- a/client/src/components/Memories/MemoriesPanel.tsx +++ b/client/src/components/Memories/MemoriesPanel.tsx @@ -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(null) + const [lightboxId, setLightboxId] = useState(null) const [lightboxUserId, setLightboxUserId] = useState(null) const [lightboxInfo, setLightboxInfo] = useState(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 ( -
{ - 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) diff --git a/client/src/components/PDF/JourneyBookPDF.tsx b/client/src/components/PDF/JourneyBookPDF.tsx index dfd07348..80d38333 100644 --- a/client/src/components/PDF/JourneyBookPDF.tsx +++ b/client/src/components/PDF/JourneyBookPDF.tsx @@ -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 { diff --git a/client/src/components/PDF/TripPDF.tsx b/client/src/components/PDF/TripPDF.tsx index 2ef5fdc7..1491a4cf 100644 --- a/client/src/components/PDF/TripPDF.tsx +++ b/client/src/components/PDF/TripPDF.tsx @@ -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 ? `
${escHtml(tr('dayplan.emptyDay'))}
` : 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 ` -
-
+
+
${icon}
${escHtml(r.title)}${time ? ` ${time}` : ''}
${subtitle ? `
${escHtml(subtitle)}
` : ''} + ${locationLine ? `
${escHtml(locationLine)}
` : ''} ${r.confirmation_number ? `
Code: ${escHtml(r.confirmation_number)}
` : ''}
` diff --git a/client/src/components/Planner/PlaceFormModal.tsx b/client/src/components/Planner/PlaceFormModal.tsx index 0566b3ae..803a889e 100644 --- a/client/src/components/Planner/PlaceFormModal.tsx +++ b/client/src/components/Planner/PlaceFormModal.tsx @@ -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' }} + /> +
+ + {/* Notes */} +
+ +