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:
Maurice
2026-04-11 19:01:34 +02:00
parent 0df90086bf
commit 13956804c2
56 changed files with 10843 additions and 332 deletions
@@ -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()
}