// Journey Photo Book PDF — Polarsteps-inspired, magazine-density import { marked } from 'marked' import type { JourneyDetail, JourneyEntry, JourneyPhoto } from '../../store/journeyStore' function esc(str: string | null | undefined): string { if (!str) return '' return String(str).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"') } function md(str: string | null | undefined): string { if (!str) return '' return marked.parse(str, { async: false, breaks: true }) as string } function abs(url: string | null | undefined): string { if (!url) return '' if (url.startsWith('http://') || url.startsWith('https://') || url.startsWith('data:')) return url return window.location.origin + (url.startsWith('/') ? '' : '/') + url } function pSrc(p: JourneyPhoto): string { return abs(`/api/photos/${p.photo_id}/original`) } function fmtDate(d: string): string { const date = new Date(d + 'T00:00:00') return date.toLocaleDateString('en', { weekday: 'long', month: 'long', day: 'numeric', year: 'numeric' }) } function fmtShort(d: string): string { return new Date(d + 'T00:00:00').toLocaleDateString('en', { month: 'short', day: 'numeric' }) } function groupByDate(entries: JourneyEntry[]): Map { const groups = new Map() for (const e of entries) { if (!e.entry_date) continue if (!groups.has(e.entry_date)) groups.set(e.entry_date, []) groups.get(e.entry_date)!.push(e) } return groups } function renderProscons(entry: JourneyEntry): string { const pc = entry.pros_cons if (!pc) return '' const pros = pc.pros?.filter(p => p.trim()) || [] const cons = pc.cons?.filter(c => c.trim()) || [] if (pros.length === 0 && cons.length === 0) return '' return `
${pros.length > 0 ? `
Loved it
    ${pros.map(p => `
  • ${esc(p)}
  • `).join('')}
` : ''} ${cons.length > 0 ? `
Could be better
    ${cons.map(c => `
  • ${esc(c)}
  • `).join('')}
` : ''}
` } function renderPhotoBlock(photos: JourneyPhoto[]): string { if (photos.length === 0) return '' if (photos.length === 1) { return `
` } if (photos.length === 2) { return `
${photos.map(p => `
`).join('')}
` } // 3+ photos: hero left + stack right return `
` } export async function downloadJourneyBookPDF(journey: JourneyDetail) { const entries = (journey.entries || []).filter(e => e.type !== 'skeleton' && e.type !== 'gallery') const allPhotos = entries.flatMap(e => e.photos || []) const coverUrl = journey.cover_image ? abs(`/uploads/${journey.cover_image}`) : (allPhotos[0] ? pSrc(allPhotos[0]) : '') const grouped = groupByDate(entries) const dates = [...grouped.keys()].sort() // Build entry pages — one per entry, day header inline on first entry of day const entryPages: string[] = [] let pageNum = 1 // cover=1 dates.forEach((date, di) => { const dayEntries = grouped.get(date)! dayEntries.forEach((entry, ei) => { pageNum++ const isFirstOfDay = ei === 0 const photos = entry.photos || [] const meta = [entry.entry_time, entry.location_name].filter(Boolean).join(' · ') // Day header (inline, only on first entry of day) const dayHeaderHtml = isFirstOfDay ? `
Day ${di + 1} · ${fmtDate(date)}
` : '' // Photo block const photoHtml = renderPhotoBlock(photos) // Pros/cons const prosconsHtml = renderProscons(entry) // Story (markdown) const storyHtml = entry.story ? `
${md(entry.story)}
` : '' entryPages.push(`
${dayHeaderHtml} ${photoHtml}
${meta ? `` : ''} ${entry.title ? `

${esc(entry.title)}

` : ''} ${storyHtml} ${prosconsHtml}
`) }) }) const totalPages = pageNum + 1 // +1 for closing page const html = ` ${esc(journey.title)} — Journey Book
${coverUrl ? `
` : ''}
Journey Book

${esc(journey.title)}

${journey.subtitle ? `
${esc(journey.subtitle)}
` : ''}
${dates.length}
Days
${entries.length}
Entries
${allPhotos.length}
Photos
${entryPages.join('\n')}
The End
Made with TREK · ${new Date().getFullYear()}
` // Render in a fixed overlay + srcdoc iframe — same pattern as TripPDF. // This avoids window.open() which Safari iOS blocks in async callbacks // and window.close() which doesn't work reliably in standalone PWA mode. const overlay = document.createElement('div') overlay.id = 'journey-pdf-overlay' overlay.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.75);z-index:9999;display:flex;align-items:center;justify-content:center;padding:8px;' overlay.onclick = (e) => { if (e.target === overlay) overlay.remove() } const card = document.createElement('div') card.style.cssText = 'width:100%;max-width:1100px;height:95vh;background:#fff;border-radius:12px;overflow:hidden;display:flex;flex-direction:column;box-shadow:0 20px 60px rgba(0,0,0,0.35);' const header = document.createElement('div') header.style.cssText = 'display:flex;align-items:center;justify-content:space-between;padding:8px 16px;border-bottom:1px solid #e4e4e7;flex-shrink:0;background:#0f172a;' header.innerHTML = ` ${esc(journey.title)} · ${totalPages} pages
` const iframe = document.createElement('iframe') iframe.style.cssText = 'flex:1;width:100%;border:none;' iframe.sandbox = 'allow-same-origin allow-modals allow-scripts' iframe.srcdoc = html card.appendChild(header) card.appendChild(iframe) overlay.appendChild(card) document.body.appendChild(overlay) header.querySelector('#journey-pdf-close')!.onclick = () => overlay.remove() header.querySelector('#journey-pdf-save')!.onclick = () => { iframe.contentWindow?.print() } }