mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-22 06:41:46 +00:00
feat: Journey addon — travel journal with entries, photos, public sharing & PDF export
- 5-table schema (journeys, entries, photos, trips, contributors) with migrations 87-91 - Trip-to-Journey sync engine with skeleton entries and photo sync - Full CRUD API for journeys, entries, photos with Immich/Synology integration - Timeline, Gallery and Map views with entry editor (markdown, mood, weather, pros/cons) - Journey frontpage with hero card, stats and trip suggestions - Public share links with token-based access and photo proxy - PDF photo book export (Polarsteps-inspired) - Dashboard redesign: mobile greeting, live trip hero, quick actions, unified card design - BottomNav profile sheet with settings/admin/logout - DayPlan mobile inline place picker - TripFormModal members management - Vacay calendar trip date indicator dots - Fix contributor photo access (403) for journey Immich/Synology photos - Trip deletion cleanup for journey skeleton entries - i18n: 231 new keys across all 14 languages (native translations, no fallbacks)
This commit is contained in:
@@ -0,0 +1,69 @@
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
|
||||
interface Props {
|
||||
text: string
|
||||
dark?: boolean
|
||||
}
|
||||
|
||||
export default function JournalBody({ text, dark }: Props) {
|
||||
return (
|
||||
<div className="journal-body" style={{
|
||||
fontFamily: 'inherit',
|
||||
fontSize: 'inherit',
|
||||
lineHeight: 1.6,
|
||||
color: 'inherit',
|
||||
}}>
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={{
|
||||
h1: ({ children }) => <h1 style={{ fontFamily: 'inherit', fontSize: '1.3em', fontWeight: 700, margin: '16px 0 6px', lineHeight: 1.3 }}>{children}</h1>,
|
||||
h2: ({ children }) => <h2 style={{ fontFamily: 'inherit', fontSize: '1.15em', fontWeight: 600, margin: '14px 0 4px', lineHeight: 1.3 }}>{children}</h2>,
|
||||
h3: ({ children }) => <h3 style={{ fontFamily: 'inherit', fontSize: '1.05em', fontWeight: 600, margin: '12px 0 4px', lineHeight: 1.4 }}>{children}</h3>,
|
||||
p: ({ children }) => <p style={{ margin: '0 0 6px' }}>{children}</p>,
|
||||
blockquote: ({ children }) => (
|
||||
<blockquote style={{
|
||||
borderLeft: `3px solid var(--journal-accent)`,
|
||||
paddingLeft: 16, margin: '12px 0',
|
||||
fontStyle: 'italic', color: 'var(--journal-muted)',
|
||||
}}>{children}</blockquote>
|
||||
),
|
||||
a: ({ href, children }) => (
|
||||
<a href={href} target="_blank" rel="noopener noreferrer"
|
||||
style={{ color: 'var(--journal-accent)', textDecoration: 'underline', textUnderlineOffset: 2 }}>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
ul: ({ children }) => <ul style={{ paddingLeft: 20, margin: '8px 0' }}>{children}</ul>,
|
||||
ol: ({ children }) => <ol style={{ paddingLeft: 20, margin: '8px 0' }}>{children}</ol>,
|
||||
li: ({ children }) => <li style={{ margin: '4px 0' }}>{children}</li>,
|
||||
strong: ({ children }) => <strong style={{ fontWeight: 600 }}>{children}</strong>,
|
||||
em: ({ children }) => <em>{children}</em>,
|
||||
hr: () => <hr style={{ border: 'none', borderTop: '1px solid var(--journal-border)', margin: '20px 0' }} />,
|
||||
code: ({ children, className }) => {
|
||||
const isBlock = className?.includes('language-')
|
||||
if (isBlock) {
|
||||
return (
|
||||
<pre style={{
|
||||
background: dark ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.04)',
|
||||
borderRadius: 8, padding: 14, overflowX: 'auto',
|
||||
fontSize: 13, fontFamily: 'monospace', margin: '12px 0',
|
||||
}}>
|
||||
<code>{children}</code>
|
||||
</pre>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<code style={{
|
||||
background: dark ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.06)',
|
||||
borderRadius: 4, padding: '2px 5px', fontSize: '0.9em', fontFamily: 'monospace',
|
||||
}}>{children}</code>
|
||||
)
|
||||
},
|
||||
}}
|
||||
>
|
||||
{text}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,299 @@
|
||||
import { useEffect, useRef, useImperativeHandle, forwardRef, useCallback } from 'react'
|
||||
import L from 'leaflet'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
|
||||
export interface MapMarkerItem {
|
||||
id: string
|
||||
lat: number
|
||||
lng: number
|
||||
label: string
|
||||
mood?: string | null
|
||||
time: string
|
||||
}
|
||||
|
||||
export interface JourneyMapHandle {
|
||||
highlightMarker: (id: string | null) => void
|
||||
focusMarker: (id: string) => void
|
||||
}
|
||||
|
||||
interface MapEntry {
|
||||
id: string
|
||||
lat: number
|
||||
lng: number
|
||||
title?: string | null
|
||||
mood?: string | null
|
||||
entry_date: string
|
||||
}
|
||||
|
||||
interface Props {
|
||||
checkins: any[]
|
||||
entries: MapEntry[]
|
||||
trail?: { lat: number; lng: number }[]
|
||||
height?: number
|
||||
dark?: boolean
|
||||
activeMarkerId?: string | null
|
||||
onMarkerClick?: (id: string, type?: string) => void
|
||||
}
|
||||
|
||||
function buildMarkerItems(entries: MapEntry[]): MapMarkerItem[] {
|
||||
const items: MapMarkerItem[] = []
|
||||
for (const e of entries) {
|
||||
if (e.lat && e.lng) {
|
||||
items.push({
|
||||
id: e.id,
|
||||
lat: e.lat,
|
||||
lng: e.lng,
|
||||
label: e.title || 'Entry',
|
||||
mood: e.mood,
|
||||
time: e.entry_date,
|
||||
})
|
||||
}
|
||||
}
|
||||
items.sort((a, b) => a.time.localeCompare(b.time))
|
||||
return items
|
||||
}
|
||||
|
||||
const MARKER_W = 28
|
||||
const MARKER_H = 36
|
||||
|
||||
function markerSvg(index: number, highlighted: boolean, dark: boolean): string {
|
||||
const fill = dark
|
||||
? (highlighted ? '#FAFAFA' : '#FAFAFA')
|
||||
: (highlighted ? '#18181B' : '#18181B')
|
||||
const textColor = dark
|
||||
? (highlighted ? '#18181B' : '#18181B')
|
||||
: (highlighted ? '#fff' : '#fff')
|
||||
const stroke = dark ? '#3F3F46' : '#fff'
|
||||
const shadow = highlighted
|
||||
? 'filter:drop-shadow(0 0 8px rgba(0,0,0,0.4)) drop-shadow(0 2px 6px rgba(0,0,0,0.3))'
|
||||
: 'filter:drop-shadow(0 2px 4px rgba(0,0,0,0.25))'
|
||||
const label = String(index + 1)
|
||||
const scale = highlighted ? 1.2 : 1
|
||||
|
||||
return `<div style="transform:scale(${scale});transition:transform 0.2s ease;${shadow};transform-origin:bottom center">
|
||||
<svg width="${MARKER_W}" height="${MARKER_H}" viewBox="0 0 28 36" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M14 34C14 34 26 22.36 26 13C26 6.37 20.63 1 14 1C7.37 1 2 6.37 2 13C2 22.36 14 34 14 34Z" fill="${fill}" stroke="${stroke}" stroke-width="2"/>
|
||||
<circle cx="14" cy="13" r="8" fill="${fill}"/>
|
||||
<text x="14" y="13" text-anchor="middle" dominant-baseline="central" fill="${textColor}" font-family="-apple-system,system-ui,sans-serif" font-size="11" font-weight="700">${label}</text>
|
||||
</svg>
|
||||
</div>`
|
||||
}
|
||||
|
||||
const EMPTY_TRAIL: { lat: number; lng: number }[] = []
|
||||
|
||||
const JourneyMap = forwardRef<JourneyMapHandle, Props>(function JourneyMap(
|
||||
{ entries, trail, height = 220, dark, activeMarkerId, onMarkerClick },
|
||||
ref
|
||||
) {
|
||||
const stableTrail = trail || EMPTY_TRAIL
|
||||
const mapTileUrl = useSettingsStore(s => s.settings.map_tile_url)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const mapRef = useRef<L.Map | null>(null)
|
||||
const markersRef = useRef<Map<string, L.Marker>>(new Map())
|
||||
const itemsRef = useRef<MapMarkerItem[]>([])
|
||||
const highlightedRef = useRef<string | null>(null)
|
||||
const onMarkerClickRef = useRef(onMarkerClick)
|
||||
onMarkerClickRef.current = onMarkerClick
|
||||
|
||||
const darkRef = useRef(dark)
|
||||
darkRef.current = dark
|
||||
|
||||
const highlightMarker = useCallback((id: string | null) => {
|
||||
const prev = highlightedRef.current
|
||||
highlightedRef.current = id
|
||||
const isDark = !!darkRef.current
|
||||
|
||||
if (prev && prev !== id) {
|
||||
const marker = markersRef.current.get(prev)
|
||||
const item = itemsRef.current.find(i => i.id === prev)
|
||||
if (marker && item) {
|
||||
const idx = itemsRef.current.indexOf(item)
|
||||
marker.setIcon(L.divIcon({
|
||||
className: '',
|
||||
iconSize: [MARKER_W, MARKER_H],
|
||||
iconAnchor: [MARKER_W / 2, MARKER_H],
|
||||
html: markerSvg(idx, false, isDark),
|
||||
}))
|
||||
marker.setZIndexOffset(0)
|
||||
}
|
||||
}
|
||||
|
||||
if (id) {
|
||||
const marker = markersRef.current.get(id)
|
||||
const item = itemsRef.current.find(i => i.id === id)
|
||||
if (marker && item) {
|
||||
const idx = itemsRef.current.indexOf(item)
|
||||
marker.setIcon(L.divIcon({
|
||||
className: '',
|
||||
iconSize: [MARKER_W, MARKER_H],
|
||||
iconAnchor: [MARKER_W / 2, MARKER_H],
|
||||
html: markerSvg(idx, true, isDark),
|
||||
}))
|
||||
marker.setZIndexOffset(1000)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
const focusMarker = useCallback((id: string) => {
|
||||
highlightMarker(id)
|
||||
const marker = markersRef.current.get(id)
|
||||
if (marker && mapRef.current) {
|
||||
mapRef.current.flyTo(marker.getLatLng(), Math.max(mapRef.current.getZoom(), 12), { duration: 0.5 })
|
||||
}
|
||||
}, [])
|
||||
|
||||
useImperativeHandle(ref, () => ({ highlightMarker, focusMarker }), [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return
|
||||
|
||||
if (mapRef.current) {
|
||||
mapRef.current.remove()
|
||||
mapRef.current = null
|
||||
}
|
||||
markersRef.current.clear()
|
||||
|
||||
const map = L.map(containerRef.current, {
|
||||
zoomControl: false,
|
||||
attributionControl: false,
|
||||
scrollWheelZoom: false,
|
||||
dragging: true,
|
||||
touchZoom: true,
|
||||
})
|
||||
mapRef.current = map
|
||||
|
||||
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)
|
||||
|
||||
const items = buildMarkerItems(entries)
|
||||
itemsRef.current = items
|
||||
|
||||
const allCoords: L.LatLngTuple[] = []
|
||||
|
||||
if (stableTrail.length > 1) {
|
||||
const coords = stableTrail.map(p => [p.lat, p.lng] as L.LatLngTuple)
|
||||
L.polyline(coords, {
|
||||
color: '#6366f1', weight: 3, opacity: 0.4,
|
||||
dashArray: '6 4', lineCap: 'round',
|
||||
}).addTo(map)
|
||||
coords.forEach(c => allCoords.push(c))
|
||||
}
|
||||
|
||||
// route polyline — subtle dashed connection
|
||||
if (items.length > 1) {
|
||||
const routeCoords = items.map(i => [i.lat, i.lng] as L.LatLngTuple)
|
||||
L.polyline(routeCoords, {
|
||||
color: dark ? '#71717A' : '#A1A1AA',
|
||||
weight: 1.5,
|
||||
opacity: 0.5,
|
||||
dashArray: '4 6',
|
||||
lineCap: 'round', lineJoin: 'round',
|
||||
}).addTo(map)
|
||||
}
|
||||
|
||||
// place markers
|
||||
items.forEach((item, i) => {
|
||||
const pos: L.LatLngTuple = [item.lat, item.lng]
|
||||
allCoords.push(pos)
|
||||
|
||||
const icon = L.divIcon({
|
||||
className: '',
|
||||
iconSize: [MARKER_W, MARKER_H],
|
||||
iconAnchor: [MARKER_W / 2, MARKER_H],
|
||||
html: markerSvg(i, false, !!dark),
|
||||
})
|
||||
|
||||
const marker = L.marker(pos, { icon }).addTo(map)
|
||||
marker.bindTooltip(item.label, {
|
||||
direction: 'top',
|
||||
offset: [0, -MARKER_H],
|
||||
className: 'map-tooltip',
|
||||
})
|
||||
|
||||
marker.on('click', () => {
|
||||
onMarkerClickRef.current?.(item.id)
|
||||
})
|
||||
|
||||
markersRef.current.set(item.id, marker)
|
||||
})
|
||||
|
||||
// fit bounds
|
||||
requestAnimationFrame(() => {
|
||||
if (!mapRef.current) return
|
||||
try {
|
||||
map.invalidateSize()
|
||||
if (allCoords.length > 0) {
|
||||
map.fitBounds(L.latLngBounds(allCoords), { padding: [50, 50], maxZoom: 14 })
|
||||
} else {
|
||||
map.setView([30, 0], 2)
|
||||
}
|
||||
} catch {}
|
||||
})
|
||||
|
||||
setTimeout(() => {
|
||||
if (mapRef.current) map.invalidateSize()
|
||||
}, 200)
|
||||
|
||||
return () => {
|
||||
map.remove()
|
||||
mapRef.current = null
|
||||
markersRef.current.clear()
|
||||
}
|
||||
}, [entries, stableTrail, dark, mapTileUrl])
|
||||
|
||||
// react to activeMarkerId prop changes — runs after map is built
|
||||
useEffect(() => {
|
||||
if (!activeMarkerId || !mapRef.current) return
|
||||
// small delay to ensure markers are rendered after map build
|
||||
const timer = setTimeout(() => {
|
||||
highlightMarker(activeMarkerId)
|
||||
const marker = markersRef.current.get(activeMarkerId)
|
||||
if (marker && mapRef.current) {
|
||||
mapRef.current.flyTo(marker.getLatLng(), Math.max(mapRef.current.getZoom(), 12), { duration: 0.5 })
|
||||
}
|
||||
}, 50)
|
||||
return () => clearTimeout(timer)
|
||||
}, [activeMarkerId])
|
||||
|
||||
const zoomIn = () => mapRef.current?.zoomIn()
|
||||
const zoomOut = () => mapRef.current?.zoomOut()
|
||||
|
||||
return (
|
||||
<div style={{ position: 'relative', height: height === 9999 ? '100%' : height, width: '100%', borderRadius: 'inherit', overflow: 'hidden' }}>
|
||||
<div
|
||||
ref={containerRef}
|
||||
style={{ width: '100%', height: '100%' }}
|
||||
/>
|
||||
<div style={{ position: 'absolute', bottom: 12, right: 12, zIndex: 400, display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
<button
|
||||
onClick={zoomIn}
|
||||
style={{
|
||||
width: 32, height: 32, borderRadius: 8,
|
||||
background: dark ? 'rgba(255,255,255,0.12)' : 'rgba(255,255,255,0.9)',
|
||||
backdropFilter: 'blur(8px)',
|
||||
border: `1px solid ${dark ? 'rgba(255,255,255,0.15)' : 'rgba(0,0,0,0.1)'}`,
|
||||
color: dark ? '#fff' : '#18181B',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
cursor: 'pointer', fontSize: 16, fontWeight: 700, lineHeight: 1,
|
||||
}}
|
||||
>+</button>
|
||||
<button
|
||||
onClick={zoomOut}
|
||||
style={{
|
||||
width: 32, height: 32, borderRadius: 8,
|
||||
background: dark ? 'rgba(255,255,255,0.12)' : 'rgba(255,255,255,0.9)',
|
||||
backdropFilter: 'blur(8px)',
|
||||
border: `1px solid ${dark ? 'rgba(255,255,255,0.15)' : 'rgba(0,0,0,0.1)'}`,
|
||||
color: dark ? '#fff' : '#18181B',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
cursor: 'pointer', fontSize: 16, fontWeight: 700, lineHeight: 1,
|
||||
}}
|
||||
>−</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
export default JourneyMap
|
||||
@@ -0,0 +1,81 @@
|
||||
import { Bold, Italic, Heading2, Link, Quote, List, ListOrdered, Minus } from 'lucide-react'
|
||||
|
||||
interface Props {
|
||||
textareaRef: React.RefObject<HTMLTextAreaElement | null>
|
||||
onUpdate: (value: string) => void
|
||||
dark?: boolean
|
||||
}
|
||||
|
||||
type FormatAction = { type: 'wrap'; before: string; after: string } | { type: 'line'; prefix: string }
|
||||
|
||||
const ACTIONS: Array<{ icon: typeof Bold; label: string; action: FormatAction }> = [
|
||||
{ icon: Bold, label: 'Bold', action: { type: 'wrap', before: '**', after: '**' } },
|
||||
{ icon: Italic, label: 'Italic', action: { type: 'wrap', before: '_', after: '_' } },
|
||||
{ icon: Heading2, label: 'Heading', action: { type: 'line', prefix: '## ' } },
|
||||
{ icon: Quote, label: 'Quote', action: { type: 'line', prefix: '> ' } },
|
||||
{ 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' } },
|
||||
]
|
||||
|
||||
export default function MarkdownToolbar({ textareaRef, onUpdate, dark }: Props) {
|
||||
const apply = (action: FormatAction) => {
|
||||
const ta = textareaRef.current
|
||||
if (!ta) return
|
||||
|
||||
const start = ta.selectionStart
|
||||
const end = ta.selectionEnd
|
||||
const text = ta.value
|
||||
const selected = text.slice(start, end)
|
||||
|
||||
let result: string
|
||||
let cursorPos: number
|
||||
|
||||
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 {
|
||||
// line prefix — find start of current line
|
||||
const lineStart = text.lastIndexOf('\n', start - 1) + 1
|
||||
result = text.slice(0, lineStart) + action.prefix + text.slice(lineStart)
|
||||
cursorPos = start + action.prefix.length
|
||||
}
|
||||
|
||||
onUpdate(result)
|
||||
|
||||
// restore cursor after React re-render
|
||||
requestAnimationFrame(() => {
|
||||
ta.focus()
|
||||
ta.setSelectionRange(cursorPos, cursorPos)
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex', gap: 2, padding: '6px 4px',
|
||||
borderBottom: `1px solid var(--journal-border)`,
|
||||
overflowX: 'auto',
|
||||
}}>
|
||||
{ACTIONS.map(a => (
|
||||
<button
|
||||
key={a.label}
|
||||
type="button"
|
||||
title={a.label}
|
||||
onClick={() => apply(a.action)}
|
||||
style={{
|
||||
width: 32, height: 32, borderRadius: 6,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
background: 'none', border: 'none',
|
||||
color: 'var(--journal-muted)', cursor: 'pointer',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
onMouseEnter={e => e.currentTarget.style.background = dark ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.04)'}
|
||||
onMouseLeave={e => e.currentTarget.style.background = 'none'}
|
||||
>
|
||||
<a.icon size={15} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import { ChevronLeft, ChevronRight, X, Camera, Aperture } from 'lucide-react'
|
||||
import apiClient from '../../api/client'
|
||||
|
||||
interface LightboxPhoto {
|
||||
id: string
|
||||
src: string
|
||||
caption?: string | null
|
||||
provider?: string
|
||||
asset_id?: string | null
|
||||
owner_id?: number | null
|
||||
}
|
||||
|
||||
interface ExifData {
|
||||
camera?: string
|
||||
lens?: string
|
||||
focalLength?: string
|
||||
aperture?: string
|
||||
shutter?: string
|
||||
iso?: number
|
||||
fileName?: string
|
||||
}
|
||||
|
||||
interface Props {
|
||||
photos: LightboxPhoto[]
|
||||
startIndex?: number
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export default function PhotoLightbox({ photos, startIndex = 0, onClose }: Props) {
|
||||
const [idx, setIdx] = useState(startIndex)
|
||||
const [exif, setExif] = useState<ExifData | null>(null)
|
||||
const [exifLoading, setExifLoading] = useState(false)
|
||||
const touchStart = useRef<{ x: number; y: number } | null>(null)
|
||||
|
||||
const photo = photos[idx]
|
||||
const hasPrev = idx > 0
|
||||
const hasNext = idx < photos.length - 1
|
||||
|
||||
const prev = useCallback(() => { if (hasPrev) setIdx(i => i - 1) }, [hasPrev])
|
||||
const next = useCallback(() => { if (hasNext) setIdx(i => i + 1) }, [hasNext])
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose()
|
||||
if (e.key === 'ArrowLeft') prev()
|
||||
if (e.key === 'ArrowRight') next()
|
||||
}
|
||||
window.addEventListener('keydown', onKey)
|
||||
return () => window.removeEventListener('keydown', onKey)
|
||||
}, [prev, next, onClose])
|
||||
|
||||
// Fetch EXIF data for Immich photos
|
||||
useEffect(() => {
|
||||
setExif(null)
|
||||
if (!photo || photo.provider !== 'immich' || !photo.asset_id || !photo.owner_id) return
|
||||
let cancelled = false
|
||||
setExifLoading(true)
|
||||
apiClient.get(`/integrations/memories/immich/assets/0/${photo.asset_id}/${photo.owner_id}/info`)
|
||||
.then(r => {
|
||||
if (!cancelled && r.data) {
|
||||
const d = r.data
|
||||
const parts: Partial<ExifData> = {}
|
||||
if (d.camera && d.camera.trim() && d.camera !== 'undefined undefined') parts.camera = d.camera
|
||||
if (d.lens) parts.lens = d.lens
|
||||
if (d.focalLength) parts.focalLength = d.focalLength
|
||||
if (d.aperture) parts.aperture = d.aperture
|
||||
if (d.shutter) parts.shutter = d.shutter
|
||||
if (d.iso) parts.iso = d.iso
|
||||
if (d.fileName) parts.fileName = d.fileName
|
||||
if (Object.keys(parts).length > 0) setExif(parts)
|
||||
}
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => { if (!cancelled) setExifLoading(false) })
|
||||
return () => { cancelled = true }
|
||||
}, [photo])
|
||||
|
||||
const onTouchStart = (e: React.TouchEvent) => {
|
||||
const t = e.touches[0]
|
||||
touchStart.current = { x: t.clientX, y: t.clientY }
|
||||
}
|
||||
|
||||
const onTouchEnd = (e: React.TouchEvent) => {
|
||||
if (!touchStart.current) return
|
||||
const t = e.changedTouches[0]
|
||||
const dx = t.clientX - touchStart.current.x
|
||||
const dy = t.clientY - touchStart.current.y
|
||||
|
||||
// swipe down to close
|
||||
if (dy > 80 && Math.abs(dx) < 60) {
|
||||
onClose()
|
||||
return
|
||||
}
|
||||
// horizontal swipe
|
||||
if (Math.abs(dx) > 50 && Math.abs(dy) < 80) {
|
||||
if (dx < 0) next()
|
||||
else prev()
|
||||
}
|
||||
touchStart.current = null
|
||||
}
|
||||
|
||||
if (!photo) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed', inset: 0, zIndex: 500,
|
||||
background: 'rgba(0,0,0,0.92)', backdropFilter: 'blur(20px)',
|
||||
display: 'flex', flexDirection: 'column',
|
||||
}}
|
||||
onTouchStart={onTouchStart}
|
||||
onTouchEnd={onTouchEnd}
|
||||
>
|
||||
{/* Top bar */}
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '12px 16px', flexShrink: 0 }}>
|
||||
<span style={{ color: 'rgba(255,255,255,0.5)', fontSize: 13 }}>
|
||||
{idx + 1} / {photos.length}
|
||||
</span>
|
||||
<button onClick={onClose} style={{
|
||||
background: 'rgba(255,255,255,0.1)', border: 'none', borderRadius: '50%',
|
||||
width: 36, height: 36, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
color: '#fff', cursor: 'pointer',
|
||||
}}>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Photo */}
|
||||
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', position: 'relative', overflow: 'hidden' }}>
|
||||
{hasPrev && (
|
||||
<button onClick={prev} className="hidden sm:flex" style={{
|
||||
position: 'absolute', left: 12, zIndex: 2,
|
||||
width: 40, height: 40, borderRadius: '50%',
|
||||
background: 'rgba(255,255,255,0.1)', border: 'none',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
color: '#fff', cursor: 'pointer',
|
||||
}}>
|
||||
<ChevronLeft size={20} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div style={{ position: 'relative', display: 'inline-flex' }}>
|
||||
<img
|
||||
key={photo.id}
|
||||
src={photo.src}
|
||||
alt={photo.caption || ''}
|
||||
style={{
|
||||
maxWidth: '90vw', maxHeight: 'calc(100vh - 140px)',
|
||||
objectFit: 'contain', borderRadius: 4,
|
||||
animation: 'fadeIn 0.15s ease',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* EXIF metadata overlay */}
|
||||
{exif && !exifLoading && (
|
||||
<div style={{
|
||||
position: 'absolute', bottom: 12, right: 12,
|
||||
background: 'rgba(0,0,0,0.45)', backdropFilter: 'blur(16px)',
|
||||
borderRadius: 12, padding: '10px 14px',
|
||||
color: 'rgba(255,255,255,0.85)', fontSize: 11,
|
||||
display: 'flex', flexDirection: 'column', gap: 4,
|
||||
maxWidth: 220, border: '1px solid rgba(255,255,255,0.08)',
|
||||
}}>
|
||||
{exif.camera && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<Camera size={11} style={{ opacity: 0.6, flexShrink: 0 }} />
|
||||
<span style={{ fontWeight: 500 }}>{exif.camera}</span>
|
||||
</div>
|
||||
)}
|
||||
{exif.lens && (
|
||||
<div style={{ fontSize: 10, color: 'rgba(255,255,255,0.55)', paddingLeft: 17 }}>{exif.lens}</div>
|
||||
)}
|
||||
{(exif.focalLength || exif.aperture || exif.shutter || exif.iso) && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginTop: 2 }}>
|
||||
<Aperture size={11} style={{ opacity: 0.6, flexShrink: 0 }} />
|
||||
<span style={{ fontWeight: 400, letterSpacing: '0.02em' }}>
|
||||
{[exif.focalLength, exif.aperture, exif.shutter, exif.iso ? `ISO ${exif.iso}` : ''].filter(Boolean).join(' · ')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{hasNext && (
|
||||
<button onClick={next} className="hidden sm:flex" style={{
|
||||
position: 'absolute', right: 12, zIndex: 2,
|
||||
width: 40, height: 40, borderRadius: '50%',
|
||||
background: 'rgba(255,255,255,0.1)', border: 'none',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
color: '#fff', cursor: 'pointer',
|
||||
}}>
|
||||
<ChevronRight size={20} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Caption */}
|
||||
{photo.caption && (
|
||||
<div style={{ textAlign: 'center', padding: '12px 24px 20px', flexShrink: 0 }}>
|
||||
<p style={{
|
||||
fontFamily: 'var(--font-system)', fontSize: 14, fontStyle: 'italic',
|
||||
color: 'rgba(255,255,255,0.7)', margin: 0, lineHeight: 1.5,
|
||||
}}>{photo.caption}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { Sparkles, Sun, Minus, Moon, CloudRain, CloudSun, Cloud, CloudLightning, Snowflake, Thermometer, ThermometerSnowflake } from 'lucide-react'
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
|
||||
export interface MoodDef {
|
||||
id: string
|
||||
label: string
|
||||
icon: LucideIcon
|
||||
color: string
|
||||
cssVar: string
|
||||
}
|
||||
|
||||
export const MOODS: MoodDef[] = [
|
||||
{ id: 'amazing', label: 'Amazing', icon: Sparkles, color: '#E8654A', cssVar: 'var(--mood-amazing)' },
|
||||
{ id: 'good', label: 'Good', icon: Sun, color: '#EF9F27', cssVar: 'var(--mood-good)' },
|
||||
{ id: 'neutral', label: 'Neutral', icon: Minus, color: '#94928C', cssVar: 'var(--mood-neutral)' },
|
||||
{ id: 'tired', label: 'Tired', icon: Moon, color: '#6B9BD2', cssVar: 'var(--mood-tired)' },
|
||||
{ id: 'rough', label: 'Rough', icon: CloudRain,color: '#9B8EC4', cssVar: 'var(--mood-rough)' },
|
||||
]
|
||||
|
||||
export const MOOD_DEFAULT_COLOR = '#D4D4D4'
|
||||
|
||||
export function getMood(id: string | null | undefined): MoodDef | undefined {
|
||||
if (!id) return undefined
|
||||
return MOODS.find(m => m.id === id)
|
||||
}
|
||||
|
||||
export function moodColor(id: string | null | undefined): string {
|
||||
return getMood(id)?.cssVar || 'var(--journal-faint)'
|
||||
}
|
||||
|
||||
export interface WeatherDef {
|
||||
id: string
|
||||
label: string
|
||||
icon: LucideIcon
|
||||
}
|
||||
|
||||
export const WEATHERS: WeatherDef[] = [
|
||||
{ id: 'sunny', label: 'Sunny', icon: Sun },
|
||||
{ id: 'partly', label: 'Partly cloudy', icon: CloudSun },
|
||||
{ id: 'cloudy', label: 'Cloudy', icon: Cloud },
|
||||
{ id: 'rainy', label: 'Rainy', icon: CloudRain },
|
||||
{ id: 'stormy', label: 'Stormy', icon: CloudLightning },
|
||||
{ id: 'snowy', label: 'Snowy', icon: Snowflake },
|
||||
{ id: 'hot', label: 'Hot', icon: Thermometer },
|
||||
{ id: 'cold', label: 'Cold', icon: ThermometerSnowflake },
|
||||
]
|
||||
|
||||
export function getWeather(id: string | null | undefined): WeatherDef | undefined {
|
||||
if (!id) return undefined
|
||||
return WEATHERS.find(w => w.id === id)
|
||||
}
|
||||
|
||||
export const TAG_STYLES: Record<string, { bg: string; fg: string; darkBg: string; darkFg: string }> = {
|
||||
'hidden gem': { bg: '#dcfce7', fg: '#166534', darkBg: 'rgba(22,101,52,0.2)', darkFg: '#86efac' },
|
||||
'must revisit': { bg: '#dbeafe', fg: '#1e40af', darkBg: 'rgba(30,64,175,0.2)', darkFg: '#93c5fd' },
|
||||
'best meal': { bg: '#fef3c7', fg: '#92400e', darkBg: 'rgba(146,64,14,0.2)', darkFg: '#fcd34d' },
|
||||
'tourist trap': { bg: '#fee2e2', fg: '#991b1b', darkBg: 'rgba(153,27,27,0.2)', darkFg: '#fca5a5' },
|
||||
'disaster': { bg: '#fce4ec', fg: '#880e4f', darkBg: 'rgba(136,14,79,0.2)', darkFg: '#f48fb1' },
|
||||
}
|
||||
|
||||
export function tagColors(tag: string, dark: boolean) {
|
||||
const known = TAG_STYLES[tag.toLowerCase()]
|
||||
if (known) return { bg: dark ? known.darkBg : known.bg, fg: dark ? known.darkFg : known.fg }
|
||||
return { bg: dark ? 'rgba(255,255,255,0.07)' : 'rgba(0,0,0,0.05)', fg: dark ? '#a1a1aa' : '#374151' }
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* Strip markdown formatting to get plain text for previews.
|
||||
* Handles: bold, italic, headings, links, images, blockquotes, code, lists, hr.
|
||||
*/
|
||||
export function stripMarkdown(md: string): string {
|
||||
return md
|
||||
.replace(/^#{1,6}\s+/gm, '') // headings
|
||||
.replace(/!\[.*?\]\(.*?\)/g, '') // images
|
||||
.replace(/\[([^\]]*)\]\(.*?\)/g, '$1') // links → text
|
||||
.replace(/(`{3}[\s\S]*?`{3})/g, '') // code blocks
|
||||
.replace(/`([^`]+)`/g, '$1') // inline code
|
||||
.replace(/\*\*(.+?)\*\*/g, '$1') // bold **
|
||||
.replace(/__(.+?)__/g, '$1') // bold __
|
||||
.replace(/\*(.+?)\*/g, '$1') // italic *
|
||||
.replace(/_(.+?)_/g, '$1') // italic _
|
||||
.replace(/~~(.+?)~~/g, '$1') // strikethrough
|
||||
.replace(/^>\s?/gm, '') // blockquotes
|
||||
.replace(/^[-*+]\s+/gm, '') // unordered lists
|
||||
.replace(/^\d+\.\s+/gm, '') // ordered lists
|
||||
.replace(/^---+$/gm, '') // horizontal rules
|
||||
.replace(/\n{2,}/g, ' ') // collapse multiple newlines
|
||||
.replace(/\n/g, ' ') // remaining newlines → spaces
|
||||
.trim()
|
||||
}
|
||||
Reference in New Issue
Block a user