mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-19 13:21:46 +00:00
0104ecfee8
- Add --bottom-nav-h CSS token (84px + safe-area on mobile, 0px on desktop) to give all fixes a single source of truth for the nav height - Apply token to JourneySettingsDialog (fixes #650) and PlacesSidebar day-picker sheet so bottom-anchored sheets clear the nav bar - Add paddingBottom to TripPlannerPage Bookings, Lists, and Budget tab scroll containers so content can be scrolled past the nav - Bump Modal z-index from z-50 to z-[200] so modals render above the bottom nav (both share z-50 with nav winning by DOM order)
2912 lines
140 KiB
TypeScript
2912 lines
140 KiB
TypeScript
import { useEffect, useState, useRef, useCallback, useMemo } from 'react'
|
|
import { useParams, useNavigate } from 'react-router-dom'
|
|
import { useJourneyStore } from '../store/journeyStore'
|
|
import { useAuthStore } from '../store/authStore'
|
|
import { useTranslation } from '../i18n'
|
|
import { journeyApi, authApi, addonsApi, mapsApi } from '../api/client'
|
|
import { addListener, removeListener } from '../api/websocket'
|
|
import Navbar from '../components/Layout/Navbar'
|
|
import JourneyMap from '../components/Journey/JourneyMap'
|
|
import type { JourneyMapHandle } from '../components/Journey/JourneyMap'
|
|
import JournalBody from '../components/Journey/JournalBody'
|
|
import MarkdownToolbar from '../components/Journey/MarkdownToolbar'
|
|
import PhotoLightbox from '../components/Journey/PhotoLightbox'
|
|
import { useToast } from '../components/shared/Toast'
|
|
import ConfirmDialog from '../components/shared/ConfirmDialog'
|
|
import {
|
|
ArrowLeft, RefreshCw, MoreHorizontal, Share2, Download, List, Grid, MapPin, Link, Copy,
|
|
Clock, Package, Image, ChevronRight,
|
|
UserPlus, Plus, Minus, Calendar, Camera, BookOpen, X, Check, ImagePlus, Trash2, Pencil,
|
|
Laugh, Smile, Meh, Annoyed, Frown,
|
|
Sun, CloudSun, Cloud, CloudRain, CloudLightning, Snowflake, ChevronDown, Eye, EyeOff,
|
|
} from 'lucide-react'
|
|
import type { JourneyEntry, JourneyPhoto, JourneyDetail } from '../store/journeyStore'
|
|
|
|
const GRADIENTS = [
|
|
'linear-gradient(135deg, #0F172A 0%, #6366F1 45%, #EC4899 100%)',
|
|
'linear-gradient(135deg, #1E293B 0%, #7C3AED 50%, #F59E0B 100%)',
|
|
'linear-gradient(135deg, #134E5E 0%, #71B280 100%)',
|
|
'linear-gradient(135deg, #2D1B69 0%, #11998E 100%)',
|
|
'linear-gradient(135deg, #4B134F 0%, #C94B4B 100%)',
|
|
'linear-gradient(135deg, #373B44 0%, #4286F4 100%)',
|
|
]
|
|
|
|
function pickGradient(id: number): string {
|
|
return GRADIENTS[id % GRADIENTS.length]
|
|
}
|
|
|
|
const MOOD_CONFIG: Record<string, { bg: string; text: string; icon: typeof Laugh; label: string }> = {
|
|
amazing: { bg: '#FDF2F8', text: '#BE185D', icon: Laugh, label: 'journey.mood.amazing' },
|
|
good: { bg: '#FFFBEB', text: '#B45309', icon: Smile, label: 'journey.mood.good' },
|
|
neutral: { bg: '#F4F4F5', text: '#3F3F46', icon: Meh, label: 'journey.mood.neutral' },
|
|
rough: { bg: '#F5F3FF', text: '#6D28D9', icon: Frown, label: 'journey.mood.rough' },
|
|
}
|
|
|
|
const WEATHER_CONFIG: Record<string, { icon: typeof Sun; label: string }> = {
|
|
sunny: { icon: Sun, label: 'journey.weather.sunny' },
|
|
partly: { icon: CloudSun, label: 'journey.weather.partly' },
|
|
cloudy: { icon: Cloud, label: 'journey.weather.cloudy' },
|
|
rainy: { icon: CloudRain, label: 'journey.weather.rainy' },
|
|
stormy: { icon: CloudLightning, label: 'journey.weather.stormy' },
|
|
cold: { icon: Snowflake, label: 'journey.weather.cold' },
|
|
}
|
|
|
|
function groupByDate(entries: JourneyEntry[]): Map<string, JourneyEntry[]> {
|
|
const groups = new Map<string, JourneyEntry[]>()
|
|
for (const e of entries) {
|
|
const d = e.entry_date
|
|
if (!groups.has(d)) groups.set(d, [])
|
|
groups.get(d)!.push(e)
|
|
}
|
|
return groups
|
|
}
|
|
|
|
function formatDate(d: string): { weekday: string; month: string; day: number } {
|
|
const date = new Date(d + 'T00:00:00')
|
|
return {
|
|
weekday: date.toLocaleDateString(undefined, { weekday: 'long' }),
|
|
month: date.toLocaleDateString(undefined, { month: 'long' }),
|
|
day: date.getDate(),
|
|
}
|
|
}
|
|
|
|
function photoUrl(p: JourneyPhoto, size: 'thumbnail' | 'original' = 'thumbnail'): string {
|
|
return `/api/photos/${p.photo_id}/${size}`
|
|
}
|
|
|
|
export default function JourneyDetailPage() {
|
|
const { id } = useParams()
|
|
const navigate = useNavigate()
|
|
const toast = useToast()
|
|
const { t } = useTranslation()
|
|
const { current, loading, notFound, loadJourney, updateEntry, deleteEntry, uploadPhotos, deletePhoto } = useJourneyStore()
|
|
const mapRef = useRef<JourneyMapHandle>(null)
|
|
const fullMapRef = useRef<JourneyMapHandle>(null)
|
|
const [activeLocationId, setActiveLocationId] = useState<string | null>(null)
|
|
|
|
const [view, setView] = useState<'timeline' | 'gallery' | 'map'>('timeline')
|
|
const [editingEntry, setEditingEntry] = useState<JourneyEntry | null>(null)
|
|
const [lightbox, setLightbox] = useState<{ photos: { id: number; src: string; caption?: string | null; provider?: string; asset_id?: string | null; owner_id?: number | null }[]; index: number } | null>(null)
|
|
const [deleteTarget, setDeleteTarget] = useState<JourneyEntry | null>(null)
|
|
const [showInvite, setShowInvite] = useState(false)
|
|
const [showAddTrip, setShowAddTrip] = useState(false)
|
|
const [unlinkTrip, setUnlinkTrip] = useState<{ trip_id: number; title: string } | null>(null)
|
|
const [showSettings, setShowSettings] = useState(false)
|
|
const [hideSkeletons, setHideSkeletons] = useState(false)
|
|
|
|
useEffect(() => {
|
|
if (id) loadJourney(Number(id)).catch(() => {})
|
|
}, [id])
|
|
|
|
useEffect(() => {
|
|
if (current?.hide_skeletons !== undefined) setHideSkeletons(current.hide_skeletons)
|
|
}, [current?.hide_skeletons])
|
|
|
|
useEffect(() => {
|
|
if (notFound) {
|
|
toast.error(t('journey.notFound'))
|
|
navigate('/journey')
|
|
}
|
|
}, [notFound])
|
|
|
|
// WebSocket real-time updates
|
|
useEffect(() => {
|
|
if (!id) return
|
|
const journeyId = Number(id)
|
|
const handler = (event: Record<string, unknown>) => {
|
|
const type = event.type as string
|
|
if (!type?.startsWith('journey:')) return
|
|
if (event.journeyId !== journeyId) return
|
|
// reload journey data on any change from other contributors
|
|
loadJourney(journeyId)
|
|
}
|
|
addListener(handler)
|
|
return () => removeListener(handler)
|
|
}, [id])
|
|
|
|
// scroll sync with map
|
|
const observerRef = useRef<IntersectionObserver | null>(null)
|
|
const setupObserver = useCallback(() => {
|
|
observerRef.current?.disconnect()
|
|
observerRef.current = new IntersectionObserver((entries) => {
|
|
for (const e of entries) {
|
|
if (e.isIntersecting) {
|
|
const entryId = e.target.getAttribute('data-entry-id')
|
|
if (entryId) mapRef.current?.highlightMarker(entryId)
|
|
}
|
|
}
|
|
}, { threshold: 0.5 })
|
|
|
|
document.querySelectorAll('[data-entry-id]').forEach(el => {
|
|
observerRef.current?.observe(el)
|
|
})
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
if (current?.entries?.length) {
|
|
setTimeout(setupObserver, 300)
|
|
}
|
|
return () => observerRef.current?.disconnect()
|
|
}, [current?.entries, setupObserver])
|
|
|
|
const handleMarkerClick = useCallback((entryId: string) => {
|
|
const el = document.querySelector(`[data-entry-id="${entryId}"]`)
|
|
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
|
}, [])
|
|
|
|
const handleLocationClick = useCallback((id: string) => {
|
|
setActiveLocationId(id)
|
|
}, [])
|
|
|
|
const mapEntries = useMemo(
|
|
() => (current?.entries || []).filter(e => e.location_lat && e.location_lng),
|
|
[current?.entries]
|
|
)
|
|
|
|
const sidebarMapItems = useMemo(() => mapEntries.map(e => ({
|
|
id: String(e.id),
|
|
lat: e.location_lat!,
|
|
lng: e.location_lng!,
|
|
title: e.title || '',
|
|
mood: e.mood,
|
|
created_at: e.entry_date,
|
|
entry_date: e.entry_date,
|
|
})), [mapEntries])
|
|
|
|
const tripDates = useMemo(() => {
|
|
const dates = new Set<string>()
|
|
if (!current?.trips) return dates
|
|
for (const trip of current.trips) {
|
|
if (!trip.start_date || !trip.end_date) continue
|
|
const start = new Date(trip.start_date + 'T00:00:00')
|
|
const end = new Date(trip.end_date + 'T00:00:00')
|
|
for (let d = new Date(start); d <= end; d.setDate(d.getDate() + 1)) {
|
|
dates.add(d.toISOString().split('T')[0])
|
|
}
|
|
}
|
|
return dates
|
|
}, [current?.trips])
|
|
|
|
if (loading || !current) {
|
|
return (
|
|
<div className="min-h-screen bg-zinc-50 dark:bg-zinc-950">
|
|
<Navbar />
|
|
<div style={{ paddingTop: 'var(--nav-h, 0px)' }} className="flex justify-center py-20">
|
|
<div className="w-6 h-6 border-2 border-zinc-300 border-t-zinc-900 rounded-full animate-spin" />
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
const timelineEntries = current.entries.filter(e => e.title !== 'Gallery' && e.title !== '[Trip Photos]' && (!hideSkeletons || e.type !== 'skeleton'))
|
|
const dayGroups = groupByDate(timelineEntries)
|
|
const sortedDates = [...dayGroups.keys()].sort()
|
|
|
|
return (
|
|
<div className="min-h-screen bg-zinc-50 dark:bg-zinc-950">
|
|
<Navbar />
|
|
<div style={{ paddingTop: 'var(--nav-h, 0px)' }}>
|
|
<div className="max-w-[1440px] mx-auto px-0 md:px-8 pt-0 md:py-6">
|
|
|
|
{/* Back link — desktop */}
|
|
<button onClick={() => navigate('/journey')} className="hidden md:inline-flex items-center gap-1.5 text-[12px] text-zinc-500 hover:text-zinc-700 dark:hover:text-zinc-300 mb-4 mx-0">
|
|
<ArrowLeft size={14} />
|
|
{t('journey.detail.backToJourney')}
|
|
</button>
|
|
|
|
{/* Hero card — full width */}
|
|
<div className="px-4 md:px-0 mb-6">
|
|
<div className="rounded-none md:rounded-2xl -mx-4 md:mx-0 overflow-hidden relative p-5 md:p-7" style={{ background: pickGradient(current.id), color: 'white' }}>
|
|
{current.cover_image && (
|
|
<div className="absolute inset-0 z-[1]">
|
|
<img src={`/uploads/${current.cover_image}`} className="w-full h-full object-cover" alt="" />
|
|
<div className="absolute inset-0" style={{ background: pickGradient(current.id), opacity: 0.55 }} />
|
|
</div>
|
|
)}
|
|
<div className="absolute inset-0 pointer-events-none z-[2]" style={{ background: 'radial-gradient(circle at 20% 20%, rgba(236,72,153,0.3), transparent 50%), radial-gradient(circle at 80% 80%, rgba(99,102,241,0.3), transparent 50%)' }} />
|
|
|
|
<div className="relative z-[3] flex items-center justify-between mb-5">
|
|
{/* Desktop: badges */}
|
|
<div className="hidden md:flex items-center gap-2">
|
|
{current.status === 'active' && (
|
|
<div className="inline-flex items-center gap-2 px-2.5 py-1 bg-white/15 backdrop-blur rounded-full text-[10px] font-semibold uppercase">
|
|
<span className="w-1.5 h-1.5 rounded-full bg-emerald-500 animate-pulse" />
|
|
Live
|
|
</div>
|
|
)}
|
|
<div className="inline-flex items-center gap-1.5 px-2.5 py-1 bg-white/[0.12] backdrop-blur border border-white/15 rounded-full text-[11px] font-medium">
|
|
<RefreshCw size={11} />
|
|
{t('journey.detail.syncedWithTrips')}
|
|
</div>
|
|
</div>
|
|
{/* Mobile: back button on the left */}
|
|
<button
|
|
onClick={() => navigate('/journey')}
|
|
className="md:hidden w-[34px] h-[34px] rounded-lg bg-white/15 backdrop-blur flex items-center justify-center hover:bg-white/25"
|
|
>
|
|
<ArrowLeft size={14} />
|
|
</button>
|
|
<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>
|
|
<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>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="relative z-[3] mb-5">
|
|
<h1 className="text-[32px] font-bold tracking-[-0.02em] leading-tight mb-1.5">{current.title}</h1>
|
|
{current.subtitle && <p className="text-[13px] opacity-85">{current.subtitle}</p>}
|
|
</div>
|
|
|
|
<div className="relative z-[3] border-t border-white/15 pt-5 flex items-end justify-between">
|
|
<div className="flex gap-8">
|
|
{[
|
|
{ value: sortedDates.length, label: t('journey.stats.days') },
|
|
{ value: current.stats.cities, label: t('journey.stats.cities') },
|
|
{ value: current.stats.entries, label: t('journey.stats.entries') },
|
|
{ value: current.stats.photos, label: t('journey.stats.photos') },
|
|
].map(s => (
|
|
<div key={s.label} className="flex flex-col gap-0.5">
|
|
<span className="text-[20px] font-bold">{s.value}</span>
|
|
<span className="text-[10px] uppercase tracking-[0.08em] opacity-70">{s.label}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Main grid */}
|
|
<div className="grid grid-cols-1 lg:grid-cols-[1fr_320px] gap-6 px-4 md:px-0">
|
|
|
|
{/* Left column */}
|
|
<div>
|
|
{/* View Controls */}
|
|
<div className="flex items-center justify-between mt-5 mb-5">
|
|
<div className="flex bg-white dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-lg overflow-hidden">
|
|
{[
|
|
{ id: 'timeline' as const, icon: List, label: t('journey.share.timeline') },
|
|
{ id: 'gallery' as const, icon: Grid, label: t('journey.share.gallery') },
|
|
{ id: 'map' as const, icon: MapPin, label: t('journey.share.map') },
|
|
].map(v => (
|
|
<button
|
|
key={v.id}
|
|
onClick={() => setView(v.id)}
|
|
className={`flex items-center gap-1.5 px-3 py-[7px] text-[12px] font-medium ${
|
|
view === v.id
|
|
? 'bg-zinc-900 dark:bg-white text-white dark:text-zinc-900'
|
|
: 'text-zinc-500 hover:text-zinc-700 dark:hover:text-zinc-300'
|
|
}`}
|
|
>
|
|
<v.icon size={13} />
|
|
{v.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
{view === 'timeline' && (
|
|
<button
|
|
onClick={() => {
|
|
const today = new Date().toISOString().split('T')[0]
|
|
setEditingEntry({ id: 0, journey_id: current.id, author_id: 0, type: 'entry', entry_date: today, visibility: 'private', sort_order: 0, photos: [], created_at: 0, updated_at: 0 } as JourneyEntry)
|
|
}}
|
|
className="w-8 h-8 rounded-lg bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 flex items-center justify-center hover:bg-zinc-800 dark:hover:bg-zinc-100"
|
|
>
|
|
<Plus size={16} />
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
{/* Timeline */}
|
|
{view === 'timeline' && (
|
|
<div className="flex flex-col gap-6 pb-24 md:pb-6">
|
|
{sortedDates.length === 0 && (
|
|
<div className="text-center py-16">
|
|
<div className="w-16 h-16 rounded-full bg-zinc-100 dark:bg-zinc-800 flex items-center justify-center mx-auto mb-4">
|
|
<BookOpen size={24} className="text-zinc-400" />
|
|
</div>
|
|
<p className="text-[15px] font-medium text-zinc-700 dark:text-zinc-300">No entries yet</p>
|
|
<p className="text-[12px] text-zinc-500 mt-1">Add a trip to get started with skeleton entries</p>
|
|
</div>
|
|
)}
|
|
|
|
{sortedDates.map((date, dayIdx) => {
|
|
const entries = dayGroups.get(date)!
|
|
const fd = formatDate(date)
|
|
const locations = [...new Set(entries.map(e => e.location_name).filter(Boolean))]
|
|
|
|
return (
|
|
<div key={date} className="flex flex-col gap-3">
|
|
<div className="sticky top-0 md:top-[68px] z-[5] bg-white/95 dark:bg-zinc-900/95 backdrop-blur border-y md:border border-zinc-200 dark:border-zinc-700 rounded-none md:rounded-xl -mx-4 md:mx-0 px-4 py-3.5 flex items-center justify-between">
|
|
<div className="flex items-center gap-3">
|
|
<div className="w-8 h-8 rounded-lg bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 flex items-center justify-center text-[13px] font-bold">
|
|
{dayIdx + 1}
|
|
</div>
|
|
<div>
|
|
<h3 className="text-[14px] font-semibold text-zinc-900 dark:text-white">{new Date(date + 'T00:00:00').toLocaleDateString(undefined, { weekday: 'long', day: 'numeric', month: 'long' })}</h3>
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-3 text-[11px] text-zinc-500">
|
|
<span className="flex items-center gap-1"><MapPin size={12} /> {entries.length} {t('journey.synced.places')}</span>
|
|
</div>
|
|
</div>
|
|
|
|
{entries.map(entry => (
|
|
<div key={entry.id} data-entry-id={String(entry.id)}>
|
|
{entry.type === 'skeleton' ? (
|
|
<SkeletonCard entry={entry} onClick={() => setEditingEntry(entry)} />
|
|
) : entry.type === 'checkin' ? (
|
|
<CheckinCard entry={entry} onClick={() => setEditingEntry(entry)} />
|
|
) : (
|
|
<EntryCard
|
|
entry={entry}
|
|
onEdit={() => setEditingEntry(entry)}
|
|
onDelete={() => setDeleteTarget(entry)}
|
|
onPhotoClick={(photos, idx) => setLightbox({ photos: photos.map(p => ({ id: p.id, src: photoUrl(p, 'original'), caption: p.caption, provider: p.provider, asset_id: p.asset_id, owner_id: p.owner_id })), index: idx })}
|
|
/>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
)}
|
|
|
|
{/* Gallery View */}
|
|
{view === 'gallery' && (
|
|
<GalleryView
|
|
entries={current.entries}
|
|
journeyId={current.id}
|
|
userId={useAuthStore.getState().user?.id || 0}
|
|
trips={current.trips}
|
|
onPhotoClick={(photos, idx) => setLightbox({ photos: photos.map(p => ({ id: p.id, src: photoUrl(p, 'original'), caption: p.caption, provider: p.provider, asset_id: p.asset_id, owner_id: p.owner_id })), index: idx })}
|
|
onRefresh={() => loadJourney(Number(id))}
|
|
/>
|
|
)}
|
|
|
|
{/* Full Map View */}
|
|
{view === 'map' && <div className="pb-24 md:pb-6"><MapView
|
|
entries={current.entries}
|
|
mapEntries={mapEntries}
|
|
sortedDates={sortedDates}
|
|
activeLocationId={activeLocationId}
|
|
fullMapRef={fullMapRef}
|
|
onLocationClick={handleLocationClick}
|
|
/></div>}
|
|
</div>
|
|
|
|
{/* Right sidebar — hidden on mobile */}
|
|
<div className="hidden lg:flex flex-col gap-4 lg:sticky lg:top-[80px] lg:self-start">
|
|
{/* Map panel */}
|
|
<div className="bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-700 rounded-xl overflow-hidden">
|
|
<JourneyMap
|
|
ref={mapRef}
|
|
checkins={[]}
|
|
entries={sidebarMapItems as any}
|
|
height={240}
|
|
onMarkerClick={handleMarkerClick}
|
|
/>
|
|
<div className="px-3.5 py-2.5 border-t border-zinc-200 dark:border-zinc-700 flex items-center justify-between text-[11px] text-zinc-500">
|
|
<span>{mapEntries.length} {t('journey.stats.places')}</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Stats panel */}
|
|
<div className="bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-700 rounded-xl p-4">
|
|
<div className="text-[10px] font-semibold tracking-[0.1em] uppercase text-zinc-500 mb-3">{t('journey.detail.journeyStats')}</div>
|
|
<div className="grid grid-cols-2 gap-2">
|
|
{[
|
|
{ value: sortedDates.length, label: t('journey.stats.days') },
|
|
{ value: current.stats.entries, label: t('journey.stats.entries') },
|
|
{ value: current.stats.photos, label: t('journey.stats.photos') },
|
|
{ value: current.stats.cities, label: t('journey.stats.cities') },
|
|
].map(s => (
|
|
<div key={s.label} className="rounded-lg bg-zinc-50 dark:bg-zinc-800/60 border border-zinc-100 dark:border-zinc-700/50 px-3 py-2.5">
|
|
<div className="text-[18px] font-bold tracking-[-0.02em] text-zinc-900 dark:text-white leading-none mb-0.5">{s.value}</div>
|
|
<div className="text-[9px] uppercase tracking-[0.1em] text-zinc-400 dark:text-zinc-500 font-semibold">{s.label}</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Synced Trips panel */}
|
|
<div className="bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-700 rounded-xl p-4">
|
|
<div className="flex items-center justify-between mb-3.5">
|
|
<span className="text-[10px] font-semibold tracking-[0.1em] uppercase text-zinc-500">{t('journey.detail.syncedTrips')}</span>
|
|
<button onClick={() => setShowAddTrip(true)} className="w-[22px] h-[22px] rounded-md bg-zinc-100 dark:bg-zinc-800 flex items-center justify-center text-zinc-500 hover:bg-zinc-200 dark:hover:bg-zinc-700">
|
|
<Plus size={12} />
|
|
</button>
|
|
</div>
|
|
<div className="flex flex-col gap-1">
|
|
{current.trips.map((trip: any) => (
|
|
<div
|
|
key={trip.trip_id}
|
|
onClick={() => navigate(`/trips/${trip.trip_id}`)}
|
|
className="group flex items-center gap-2.5 p-2 rounded-lg hover:bg-zinc-100 dark:hover:bg-zinc-800 cursor-pointer"
|
|
>
|
|
<div className="w-9 h-9 rounded-md flex-shrink-0" style={{ background: pickGradient(trip.trip_id) }} />
|
|
<div className="flex-1 min-w-0">
|
|
<div className="text-xs font-medium text-zinc-900 dark:text-white truncate">{trip.title}</div>
|
|
<div className="text-[10px] text-zinc-500 flex items-center gap-1.5">
|
|
{trip.place_count || 0} {t('journey.detail.places')}
|
|
<span className="inline-flex items-center gap-0.5 px-1.5 py-px rounded-full bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 text-[9px] font-medium"><span className="w-1 h-1 rounded-full bg-emerald-500" />{t('journey.synced.synced')}</span>
|
|
</div>
|
|
</div>
|
|
<button
|
|
onClick={e => { e.stopPropagation(); setUnlinkTrip({ trip_id: trip.trip_id, title: trip.title }) }}
|
|
className="w-6 h-6 rounded-md flex items-center justify-center text-zinc-400 opacity-0 group-hover:opacity-100 hover:bg-red-100 hover:text-red-600 dark:hover:bg-red-900/30 dark:hover:text-red-400 transition-opacity"
|
|
title="Unlink trip"
|
|
>
|
|
<Trash2 size={12} />
|
|
</button>
|
|
<ChevronRight size={14} className="text-zinc-400" />
|
|
</div>
|
|
))}
|
|
{current.trips.length === 0 && (
|
|
<p className="text-[11px] text-zinc-400 text-center py-3">{t('journey.detail.noTripsLinked')}</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Contributors panel */}
|
|
<div className="bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-700 rounded-xl p-4">
|
|
<div className="flex items-center justify-between mb-3.5">
|
|
<span className="text-[10px] font-semibold tracking-[0.1em] uppercase text-zinc-500">{t('journey.detail.contributors')}</span>
|
|
<button onClick={() => setShowInvite(true)} className="w-[22px] h-[22px] rounded-md bg-zinc-100 dark:bg-zinc-800 flex items-center justify-center text-zinc-500 hover:bg-zinc-200 dark:hover:bg-zinc-700">
|
|
<UserPlus size={12} />
|
|
</button>
|
|
</div>
|
|
<div className="flex flex-col gap-2.5">
|
|
{current.contributors.map((c: any) => (
|
|
<div key={c.user_id} className="flex items-center gap-2.5">
|
|
{c.avatar_url ? (
|
|
<img src={c.avatar_url} className="w-7 h-7 rounded-full object-cover" alt="" />
|
|
) : (
|
|
<div className="w-7 h-7 rounded-full bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 flex items-center justify-center text-[11px] font-semibold">
|
|
{(c.username || '?')[0].toUpperCase()}
|
|
</div>
|
|
)}
|
|
<div className="flex-1 min-w-0">
|
|
<div className="text-xs font-medium text-zinc-900 dark:text-white">{c.username}</div>
|
|
</div>
|
|
<span className={`text-[9px] font-medium px-1.5 py-0.5 rounded-full ${
|
|
c.role === 'owner' ? 'bg-zinc-900 dark:bg-white text-white dark:text-zinc-900' : 'bg-zinc-100 dark:bg-zinc-800 text-zinc-500'
|
|
}`}>
|
|
{c.role}
|
|
</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Entry Editor */}
|
|
{editingEntry && (
|
|
<EntryEditor
|
|
entry={editingEntry}
|
|
journeyId={current.id}
|
|
tripDates={tripDates}
|
|
galleryPhotos={current.entries.flatMap(e => e.photos || [])}
|
|
onClose={() => setEditingEntry(null)}
|
|
onSave={async (data) => {
|
|
let entryId = editingEntry.id
|
|
if (editingEntry.id === 0) {
|
|
const created = await useJourneyStore.getState().createEntry(current.id, data)
|
|
entryId = created.id
|
|
} else {
|
|
await updateEntry(editingEntry.id, data)
|
|
}
|
|
return entryId
|
|
}}
|
|
onUploadPhotos={async (entryId, formData) => {
|
|
return await uploadPhotos(entryId, formData)
|
|
}}
|
|
onDone={() => {
|
|
setEditingEntry(null)
|
|
loadJourney(Number(id))
|
|
}}
|
|
/>
|
|
)}
|
|
|
|
{/* Journey Settings */}
|
|
{showSettings && (
|
|
<JourneySettingsDialog
|
|
journey={current}
|
|
onClose={() => setShowSettings(false)}
|
|
onSaved={() => { setShowSettings(false); loadJourney(Number(id)) }}
|
|
onOpenInvite={() => { setShowSettings(false); setShowInvite(true) }}
|
|
/>
|
|
)}
|
|
|
|
{/* Add Trip Dialog */}
|
|
{showAddTrip && current && (
|
|
<AddTripDialog
|
|
journeyId={current.id}
|
|
existingTripIds={current.trips.map((t: any) => t.trip_id)}
|
|
onClose={() => setShowAddTrip(false)}
|
|
onAdded={() => { setShowAddTrip(false); loadJourney(Number(id)) }}
|
|
/>
|
|
)}
|
|
|
|
{/* Contributor Invite Dialog */}
|
|
{showInvite && (
|
|
<ContributorInviteDialog
|
|
journeyId={current.id}
|
|
existingUserIds={current.contributors.map((c: any) => c.user_id)}
|
|
onClose={() => setShowInvite(false)}
|
|
onInvited={() => { setShowInvite(false); loadJourney(Number(id)) }}
|
|
/>
|
|
)}
|
|
|
|
{/* Delete confirm */}
|
|
<ConfirmDialog
|
|
isOpen={!!deleteTarget}
|
|
onClose={() => setDeleteTarget(null)}
|
|
onConfirm={async () => {
|
|
if (!deleteTarget) return
|
|
await deleteEntry(deleteTarget.id)
|
|
setDeleteTarget(null)
|
|
loadJourney(Number(id))
|
|
}}
|
|
title={t('journey.entries.deleteTitle')}
|
|
message={t('journey.deleteConfirmMessage', { title: deleteTarget?.title || 'this entry' })}
|
|
confirmLabel={t('common.delete')}
|
|
danger
|
|
/>
|
|
|
|
{/* Unlink Trip confirm */}
|
|
<ConfirmDialog
|
|
isOpen={!!unlinkTrip}
|
|
onClose={() => setUnlinkTrip(null)}
|
|
onConfirm={async () => {
|
|
if (!unlinkTrip || !current) return
|
|
try {
|
|
await journeyApi.removeTrip(current.id, unlinkTrip.trip_id)
|
|
toast.success(t('journey.trips.tripUnlinked'))
|
|
setUnlinkTrip(null)
|
|
loadJourney(Number(id))
|
|
} catch {
|
|
toast.error(t('journey.trips.unlinkFailed'))
|
|
}
|
|
}}
|
|
title={t('journey.trips.unlinkTrip')}
|
|
message={t('journey.trips.unlinkMessage', { title: unlinkTrip?.title })}
|
|
confirmLabel={t('journey.trips.unlink')}
|
|
danger
|
|
/>
|
|
|
|
{/* Lightbox */}
|
|
{lightbox && (
|
|
<PhotoLightbox
|
|
photos={lightbox.photos.map(p => ({ id: p.id.toString(), src: p.src, caption: p.caption, provider: p.provider, asset_id: p.asset_id, owner_id: p.owner_id }))}
|
|
startIndex={lightbox.index}
|
|
onClose={() => setLightbox(null)}
|
|
/>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// ── Map View ──────────────────────────────────────────────────────────────
|
|
|
|
function MapView({ entries, mapEntries, sortedDates, activeLocationId, fullMapRef, onLocationClick }: {
|
|
entries: JourneyEntry[]
|
|
mapEntries: JourneyEntry[]
|
|
sortedDates: string[]
|
|
activeLocationId: string | null
|
|
fullMapRef: React.RefObject<JourneyMapHandle | null>
|
|
onLocationClick: (id: string) => void
|
|
}) {
|
|
const { t } = useTranslation()
|
|
// group map entries by date
|
|
const byDate = new Map<string, { entry: JourneyEntry; globalIdx: number }[]>()
|
|
mapEntries.forEach((e, i) => {
|
|
const d = e.entry_date
|
|
if (!byDate.has(d)) byDate.set(d, [])
|
|
byDate.get(d)!.push({ entry: e, globalIdx: i })
|
|
})
|
|
const dates = [...byDate.keys()].sort()
|
|
|
|
// find first and last entry indices
|
|
const firstId = mapEntries[0]?.id
|
|
const lastId = mapEntries[mapEntries.length - 1]?.id
|
|
|
|
const mapItems = useMemo(() => mapEntries.map(e => ({
|
|
id: String(e.id),
|
|
lat: e.location_lat!,
|
|
lng: e.location_lng!,
|
|
title: e.title || '',
|
|
mood: e.mood,
|
|
entry_date: e.entry_date,
|
|
})), [mapEntries])
|
|
|
|
return (
|
|
<div className="bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-700 rounded-xl overflow-hidden">
|
|
<JourneyMap
|
|
ref={fullMapRef}
|
|
checkins={[]}
|
|
entries={mapItems as any}
|
|
height={560}
|
|
activeMarkerId={activeLocationId}
|
|
onMarkerClick={onLocationClick}
|
|
/>
|
|
|
|
{/* Locations list */}
|
|
<div>
|
|
{/* Stats header */}
|
|
{mapEntries.length > 0 && (
|
|
<div className="mx-5 mt-4 mb-2 grid grid-cols-3 gap-2">
|
|
{[
|
|
{ value: mapEntries.length, label: t('journey.stats.places') },
|
|
{ value: dates.length, label: t('journey.stats.days') },
|
|
{ value: entries.filter(e => e.type === 'entry').length, label: 'Stories' },
|
|
].map(s => (
|
|
<div key={s.label} className="bg-zinc-50 dark:bg-zinc-800 rounded-xl p-3 text-center">
|
|
<div className="text-[17px] font-bold text-zinc-900 dark:text-white tracking-tight">{s.value}</div>
|
|
<div className="text-[9px] font-medium text-zinc-500 uppercase tracking-[0.06em]">{s.label}</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{/* Day groups */}
|
|
<div className="px-5 pb-5">
|
|
{dates.map((date, dayIdx) => {
|
|
const items = byDate.get(date)!
|
|
const fd = formatDate(date)
|
|
|
|
return (
|
|
<div key={date}>
|
|
{/* Day separator */}
|
|
<div className="flex items-center gap-2.5 py-3">
|
|
<span className="text-[10px] font-bold text-zinc-500 dark:text-zinc-400 tracking-[0.12em] uppercase">{t('journey.detail.day', { number: dayIdx + 1 })}</span>
|
|
<span className="text-[10px] text-zinc-400 font-medium">{fd.month} {fd.day}</span>
|
|
<div className="flex-1 h-px bg-zinc-200 dark:bg-zinc-700" />
|
|
</div>
|
|
|
|
{/* Location items */}
|
|
{items.map(({ entry: e, globalIdx }, itemIdx) => {
|
|
const isActive = activeLocationId === String(e.id)
|
|
const isFirst = e.id === firstId
|
|
const isLast = e.id === lastId
|
|
const showConnector = itemIdx < items.length - 1
|
|
|
|
return (
|
|
<div key={e.id}>
|
|
<div
|
|
onClick={() => onLocationClick(String(e.id))}
|
|
className={`flex items-center gap-3 p-3 rounded-[14px] cursor-pointer transition-all ${
|
|
isActive
|
|
? 'bg-zinc-100 dark:bg-zinc-800 border border-zinc-900 dark:border-zinc-100 translate-x-0.5'
|
|
: 'bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-700 hover:border-zinc-400 dark:hover:border-zinc-500 hover:translate-x-0.5'
|
|
}`}
|
|
>
|
|
{/* Number badge */}
|
|
<div className={`w-9 h-9 rounded-full flex items-center justify-center text-[13px] font-bold flex-shrink-0 border-2 border-white dark:border-zinc-900 ${
|
|
isActive
|
|
? 'bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 shadow-[0_0_0_2px_rgba(0,0,0,0.15)]'
|
|
: 'bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 shadow-[0_0_0_1px_rgba(0,0,0,0.1)]'
|
|
}`}>
|
|
{globalIdx + 1}
|
|
</div>
|
|
|
|
{/* Info */}
|
|
<div className="flex-1 min-w-0">
|
|
<div className="flex items-center gap-1.5 mb-0.5">
|
|
<span className="text-[14px] font-semibold text-zinc-900 dark:text-white truncate">{e.title || e.location_name}</span>
|
|
</div>
|
|
<div className="text-[11px] text-zinc-500 truncate">
|
|
{e.location_name}{e.entry_time ? ` · ${e.entry_time}` : ''}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Chevron */}
|
|
<ChevronRight size={14} className={`flex-shrink-0 ${isActive ? 'text-zinc-900 dark:text-white' : 'text-zinc-300 dark:text-zinc-600'}`} />
|
|
</div>
|
|
|
|
{/* Connector line */}
|
|
{showConnector && (
|
|
<div className="w-0.5 h-2 bg-zinc-200 dark:bg-zinc-700 ml-[18px] rounded-full" />
|
|
)}
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
)
|
|
})}
|
|
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// ── Gallery View ──────────────────────────────────────────────────────────
|
|
|
|
function GalleryView({ entries, journeyId, userId, trips, onPhotoClick, onRefresh }: {
|
|
entries: JourneyEntry[]
|
|
journeyId: number
|
|
userId: number
|
|
trips: JourneyTrip[]
|
|
onPhotoClick: (photos: JourneyPhoto[], index: number) => void
|
|
onRefresh: () => void
|
|
}) {
|
|
const { t } = useTranslation()
|
|
const [showPicker, setShowPicker] = useState(false)
|
|
const [pickerProvider, setPickerProvider] = useState<string | null>(null)
|
|
const [availableProviders, setAvailableProviders] = useState<{ id: string; name: string }[]>([])
|
|
const [galleryUploading, setGalleryUploading] = useState(false)
|
|
const toast = useToast()
|
|
|
|
// check which providers are enabled AND connected for the current user
|
|
useEffect(() => {
|
|
(async () => {
|
|
try {
|
|
const addonsData = await addonsApi.enabled()
|
|
const enabledProviders = (addonsData.addons || []).filter(
|
|
(a: any) => a.type === 'photo_provider' && a.enabled
|
|
)
|
|
const connected: { id: string; name: string }[] = []
|
|
for (const p of enabledProviders) {
|
|
try {
|
|
const res = await fetch(`/api/integrations/memories/${p.id}/status`, { credentials: 'include' })
|
|
if (res.ok) {
|
|
const status = await res.json()
|
|
if (status.connected) connected.push({ id: p.id, name: p.name })
|
|
}
|
|
} catch {}
|
|
}
|
|
setAvailableProviders(connected)
|
|
} catch {}
|
|
})()
|
|
}, [])
|
|
|
|
const allPhotos: { photo: JourneyPhoto; entry: JourneyEntry }[] = []
|
|
for (const e of entries) {
|
|
for (const p of e.photos) {
|
|
allPhotos.push({ photo: p, entry: e })
|
|
}
|
|
}
|
|
|
|
const entriesWithContent = entries.filter(e => e.type !== 'skeleton' || e.title)
|
|
|
|
const browseProvider = (provider: string) => {
|
|
setPickerProvider(provider)
|
|
setShowPicker(true)
|
|
}
|
|
|
|
const galleryFileRef = useRef<HTMLInputElement>(null)
|
|
|
|
const handleGalleryUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
const files = e.target.files
|
|
if (!files?.length) return
|
|
setGalleryUploading(true)
|
|
try {
|
|
// find existing "Gallery" entry or create one
|
|
let galleryEntry = entries.find(e => e.title === 'Gallery' && e.type === 'entry')
|
|
let entryId = galleryEntry?.id
|
|
if (!entryId) {
|
|
const entry = await journeyApi.createEntry(journeyId, {
|
|
title: t('journey.share.gallery'),
|
|
entry_date: new Date().toISOString().split('T')[0],
|
|
type: 'entry',
|
|
})
|
|
entryId = entry.id
|
|
}
|
|
const formData = new FormData()
|
|
for (const f of files) formData.append('photos', f)
|
|
await journeyApi.uploadPhotos(entryId, formData)
|
|
toast.success(t('journey.photosUploaded', { count: files.length }))
|
|
onRefresh()
|
|
} catch {
|
|
toast.error(t('journey.settings.coverFailed'))
|
|
} finally {
|
|
setGalleryUploading(false)
|
|
}
|
|
e.target.value = ''
|
|
}
|
|
|
|
const handleDeletePhoto = async (photoId: number) => {
|
|
// Optimistic update — remove photo from local state immediately
|
|
const store = useJourneyStore.getState()
|
|
if (store.current) {
|
|
const updated = {
|
|
...store.current,
|
|
entries: store.current.entries.map(e => ({
|
|
...e,
|
|
photos: e.photos.filter(p => p.id !== photoId),
|
|
})).filter(e => e.type !== 'entry' || e.title !== 'Gallery' || e.photos.length > 0 || e.story),
|
|
}
|
|
useJourneyStore.setState({ current: updated })
|
|
}
|
|
try {
|
|
await journeyApi.deletePhoto(photoId)
|
|
} catch {
|
|
toast.error(t('common.error'))
|
|
onRefresh() // Revert on error
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div>
|
|
<input ref={galleryFileRef} type="file" accept="image/*" multiple onChange={handleGalleryUpload} className="hidden" />
|
|
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between mb-4 flex-wrap gap-2">
|
|
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-zinc-100 dark:bg-zinc-800 text-[10px] font-medium text-zinc-500 dark:text-zinc-400">
|
|
<Camera size={10} /> {allPhotos.length} {t('journey.detail.photos')}
|
|
</span>
|
|
<div className="flex items-center gap-2">
|
|
<button
|
|
onClick={() => galleryFileRef.current?.click()}
|
|
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"
|
|
>
|
|
{galleryUploading ? (
|
|
<><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>
|
|
{availableProviders.map(p => (
|
|
<button
|
|
key={p.id}
|
|
onClick={() => browseProvider(p.id)}
|
|
className="inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg border border-zinc-200 dark:border-zinc-700 text-[11px] font-medium text-zinc-600 dark:text-zinc-400 hover:bg-zinc-50 dark:hover:bg-zinc-800"
|
|
>
|
|
<Image size={12} />
|
|
{p.name}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{allPhotos.length === 0 ? (
|
|
<div className="text-center py-16">
|
|
<div className="w-16 h-16 rounded-full bg-zinc-100 dark:bg-zinc-800 flex items-center justify-center mx-auto mb-4">
|
|
<Image size={24} className="text-zinc-400" />
|
|
</div>
|
|
<p className="text-[15px] font-medium text-zinc-700 dark:text-zinc-300">{t('journey.detail.noPhotos')}</p>
|
|
<p className="text-[12px] text-zinc-500 mt-1">{t('journey.detail.noPhotosHint')}</p>
|
|
</div>
|
|
) : (
|
|
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-1.5 pb-24 md:pb-6">
|
|
{allPhotos.map(({ photo, entry }) => (
|
|
<div
|
|
key={photo.id}
|
|
className="relative aspect-square rounded-lg overflow-hidden cursor-pointer group"
|
|
onClick={() => onPhotoClick(entry.photos, entry.photos.indexOf(photo))}
|
|
>
|
|
<img
|
|
src={photoUrl(photo, 'thumbnail')}
|
|
alt={photo.caption || ''}
|
|
className="w-full h-full object-cover transition-transform group-hover:scale-105"
|
|
loading="lazy"
|
|
/>
|
|
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/20 transition-colors" />
|
|
{/* Delete button */}
|
|
<button
|
|
onClick={(e) => { e.stopPropagation(); handleDeletePhoto(photo.id) }}
|
|
className="absolute top-1.5 right-1.5 w-6 h-6 rounded-full bg-black/60 backdrop-blur text-white flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity z-10"
|
|
>
|
|
<X size={12} />
|
|
</button>
|
|
{photo.provider && photo.provider !== 'local' && (
|
|
<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">
|
|
<RefreshCw size={7} />
|
|
{photo.provider === 'immich' ? 'Immich' : photo.provider === 'synology' ? 'Synology' : photo.provider}
|
|
</span>
|
|
</div>
|
|
)}
|
|
{photo.caption && (
|
|
<div className="absolute bottom-0 left-0 right-0 p-2 bg-gradient-to-t from-black/60 to-transparent opacity-0 group-hover:opacity-100 transition-opacity">
|
|
<p className="text-[10px] text-white truncate">{photo.caption}</p>
|
|
</div>
|
|
)}
|
|
<div className="absolute bottom-1.5 left-1.5 opacity-0 group-hover:opacity-100 transition-opacity">
|
|
<span className="text-[9px] font-medium px-1.5 py-0.5 rounded-full bg-black/50 backdrop-blur text-white">
|
|
{new Date(entry.entry_date + 'T12:00:00').toLocaleDateString(undefined, { day: 'numeric', month: 'short', year: 'numeric' })}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{/* Provider Photo Picker Modal */}
|
|
{showPicker && (
|
|
<ProviderPicker
|
|
provider={pickerProvider!}
|
|
userId={userId}
|
|
entries={entriesWithContent}
|
|
trips={trips}
|
|
existingAssetIds={new Set(entries.flatMap(e => (e.photos || []).filter(p => p.asset_id).map(p => p.asset_id!)))}
|
|
onClose={() => setShowPicker(false)}
|
|
onAdd={async (assetIds, entryId) => {
|
|
let targetId = entryId
|
|
if (!targetId) {
|
|
try {
|
|
const entry = await journeyApi.createEntry(journeyId, {
|
|
title: t('journey.share.gallery'),
|
|
entry_date: new Date().toISOString().split('T')[0],
|
|
type: 'entry',
|
|
})
|
|
targetId = entry.id
|
|
} catch { return }
|
|
}
|
|
let added = 0
|
|
try {
|
|
const result = await journeyApi.addProviderPhotos(targetId, pickerProvider!, assetIds)
|
|
added = result.added || 0
|
|
} catch {}
|
|
if (added > 0) {
|
|
toast.success(t('journey.photosAdded', { count: added }))
|
|
onRefresh()
|
|
}
|
|
setShowPicker(false)
|
|
}}
|
|
/>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// ── Expandable Story ─────────────────────────────────────────────────────
|
|
|
|
function ExpandableStory({ story }: { story: string }) {
|
|
const { t } = useTranslation()
|
|
const [expanded, setExpanded] = useState(false)
|
|
const [clamped, setClamped] = useState(false)
|
|
const ref = useRef<HTMLDivElement>(null)
|
|
const measuredRef = useRef(false)
|
|
|
|
useEffect(() => {
|
|
measuredRef.current = false
|
|
}, [story])
|
|
|
|
useEffect(() => {
|
|
if (measuredRef.current) return
|
|
const el = ref.current
|
|
if (el && !expanded) {
|
|
setClamped(el.scrollHeight > el.clientHeight)
|
|
measuredRef.current = true
|
|
}
|
|
})
|
|
|
|
return (
|
|
<div>
|
|
<div
|
|
ref={ref}
|
|
onClick={() => { if (clamped || expanded) setExpanded(e => !e) }}
|
|
className={`text-[13px] text-zinc-700 dark:text-zinc-300 leading-relaxed ${
|
|
expanded ? '' : 'line-clamp-3 md:line-clamp-[9]'
|
|
} ${clamped || expanded ? 'cursor-pointer' : ''}`}
|
|
>
|
|
<JournalBody text={story} />
|
|
</div>
|
|
{clamped && !expanded && (
|
|
<button
|
|
onClick={() => setExpanded(true)}
|
|
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.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>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// ── Verdict Section (Pros & Cons) ────────────────────────────────────────
|
|
|
|
function VerdictSection({ pros, cons }: { pros: string[]; cons: string[] }) {
|
|
const { t } = useTranslation()
|
|
const [open, setOpen] = useState(false)
|
|
// On desktop always show, on mobile toggle
|
|
return (
|
|
<div className="mt-5">
|
|
{/* Header — clickable on mobile */}
|
|
<button
|
|
onClick={() => setOpen(o => !o)}
|
|
className="md:pointer-events-none w-full flex items-center gap-2.5 mb-3.5 group"
|
|
>
|
|
<div className="flex-1 h-px bg-zinc-200 dark:bg-zinc-700" />
|
|
<span className="text-[10px] font-bold tracking-[0.14em] uppercase text-zinc-400 flex items-center gap-1.5">
|
|
{t('journey.editor.prosCons')}
|
|
<ChevronDown
|
|
size={12}
|
|
className={`md:hidden text-zinc-400 transition-transform duration-300 ${open ? 'rotate-180' : ''}`}
|
|
/>
|
|
</span>
|
|
<div className="flex-1 h-px bg-zinc-200 dark:bg-zinc-700" />
|
|
</button>
|
|
|
|
{/* Collapsed summary on mobile */}
|
|
{!open && (
|
|
<div className="flex items-center justify-center gap-3 md:hidden">
|
|
{pros.length > 0 && (
|
|
<div className="flex items-center gap-1.5">
|
|
<div className="w-5 h-5 rounded-md bg-green-500 flex items-center justify-center">
|
|
<Check size={11} className="text-white" strokeWidth={3} />
|
|
</div>
|
|
<span className="text-[12px] font-semibold text-green-700 dark:text-green-400">{pros.length}</span>
|
|
</div>
|
|
)}
|
|
{cons.length > 0 && (
|
|
<div className="flex items-center gap-1.5">
|
|
<div className="w-5 h-5 rounded-md bg-red-500 flex items-center justify-center">
|
|
<Minus size={11} className="text-white" strokeWidth={3} />
|
|
</div>
|
|
<span className="text-[12px] font-semibold text-red-700 dark:text-red-400">{cons.length}</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Content — always visible on desktop, toggled on mobile */}
|
|
<div
|
|
className={`grid grid-cols-1 md:grid-cols-2 gap-3 overflow-hidden transition-all duration-300 ease-in-out ${
|
|
open ? 'max-h-[800px] opacity-100' : 'max-h-0 md:max-h-none opacity-0 md:opacity-100'
|
|
}`}
|
|
>
|
|
{pros.length > 0 && (
|
|
<div className="rounded-xl border border-green-200 dark:border-green-800/30 p-4 bg-gradient-to-b from-green-50 to-white dark:from-green-950/30 dark:to-zinc-900">
|
|
<div className="flex items-center gap-2 mb-3">
|
|
<div className="w-6 h-6 rounded-lg bg-green-500 flex items-center justify-center">
|
|
<Check size={14} className="text-white" strokeWidth={3} />
|
|
</div>
|
|
<span className="hidden md:inline text-[11px] font-bold tracking-[0.1em] uppercase text-green-700 dark:text-green-400">{t('journey.verdict.lovedIt')}</span>
|
|
<span className="ml-auto text-[11px] font-semibold text-green-600">{pros.length}</span>
|
|
</div>
|
|
<div className="flex flex-col gap-2">
|
|
{pros.map((p, i) => (
|
|
<div key={i} className="flex items-start gap-2">
|
|
<span className="w-[5px] h-[5px] rounded-full bg-green-500 flex-shrink-0 mt-[7px]" />
|
|
<span className="text-[13px] text-green-900 dark:text-green-100 leading-snug">{p}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
{cons.length > 0 && (
|
|
<div className="rounded-xl border border-red-200 dark:border-red-800/30 p-4 bg-gradient-to-b from-red-50 to-white dark:from-red-950/30 dark:to-zinc-900">
|
|
<div className="flex items-center gap-2 mb-3">
|
|
<div className="w-6 h-6 rounded-lg bg-red-500 flex items-center justify-center">
|
|
<Minus size={14} className="text-white" strokeWidth={3} />
|
|
</div>
|
|
<span className="hidden md:inline text-[11px] font-bold tracking-[0.1em] uppercase text-red-700 dark:text-red-400">{t('journey.verdict.couldBeBetter')}</span>
|
|
<span className="ml-auto text-[11px] font-semibold text-red-600">{cons.length}</span>
|
|
</div>
|
|
<div className="flex flex-col gap-2">
|
|
{cons.map((c, i) => (
|
|
<div key={i} className="flex items-start gap-2">
|
|
<span className="w-[5px] h-[5px] rounded-full bg-red-500 flex-shrink-0 mt-[7px]" />
|
|
<span className="text-[13px] text-red-900 dark:text-red-100 leading-snug">{c}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// ── Entry Card ────────────────────────────────────────────────────────────
|
|
|
|
function EntryCard({ entry, onEdit, onDelete, onPhotoClick }: {
|
|
entry: JourneyEntry
|
|
onEdit: () => void
|
|
onDelete: () => void
|
|
onPhotoClick: (photos: JourneyPhoto[], index: number) => void
|
|
}) {
|
|
const { t } = useTranslation()
|
|
const [menuOpen, setMenuOpen] = useState(false)
|
|
const menuBtnRef = useRef<HTMLButtonElement>(null)
|
|
const photos = entry.photos || []
|
|
const mood = entry.mood ? MOOD_CONFIG[entry.mood] : null
|
|
const weather = entry.weather ? WEATHER_CONFIG[entry.weather] : null
|
|
|
|
const prosArr = entry.pros_cons?.pros ?? []
|
|
const consArr = entry.pros_cons?.cons ?? []
|
|
const hasProscons = prosArr.length > 0 || consArr.length > 0
|
|
|
|
return (
|
|
<div className="bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-700 rounded-2xl overflow-hidden transition-all hover:border-zinc-400 dark:hover:border-zinc-500 hover:shadow-sm">
|
|
|
|
{/* Hero area: photos with title overlay */}
|
|
{photos.length > 0 ? (
|
|
<div className="relative">
|
|
<PhotoGrid photos={photos} onClick={(idx) => onPhotoClick(photos, idx)} />
|
|
{/* Gradient overlay for title */}
|
|
<div className="absolute inset-x-0 bottom-0 pointer-events-none" style={{ background: 'linear-gradient(to top, rgba(0,0,0,0.55) 0%, rgba(0,0,0,0.2) 50%, transparent 100%)', height: '60%' }} />
|
|
|
|
{/* Badges top-left */}
|
|
<div className="absolute top-3 left-4 right-14 flex items-center gap-1.5 z-[2]">
|
|
{entry.location_name && (
|
|
<span className="inline-flex items-center gap-1 px-2.5 py-1 bg-black/40 backdrop-blur-sm rounded-full text-[10px] font-semibold text-white tracking-wide max-w-full overflow-hidden">
|
|
<MapPin size={10} className="flex-shrink-0" />
|
|
<span className="truncate">{entry.location_name}</span>
|
|
</span>
|
|
)}
|
|
{entry.entry_time && (
|
|
<span className="inline-flex items-center gap-1 px-2.5 py-1 bg-black/40 backdrop-blur-sm rounded-full text-[10px] font-semibold text-white tracking-wide">
|
|
<Clock size={10} />
|
|
{entry.entry_time}
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
{/* Menu top-right */}
|
|
<div className="absolute top-2.5 right-3 z-[2]">
|
|
<button ref={menuBtnRef} onClick={() => setMenuOpen(!menuOpen)} className="w-8 h-8 rounded-[10px] bg-black/40 backdrop-blur-sm flex items-center justify-center text-white hover:bg-black/50">
|
|
<MoreHorizontal size={14} />
|
|
</button>
|
|
{menuOpen && (
|
|
<>
|
|
<div className="fixed inset-0 z-[99]" onClick={() => setMenuOpen(false)} />
|
|
<div className="fixed z-[100] bg-white dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-lg shadow-lg py-1 min-w-[120px]" style={{ top: (menuBtnRef.current?.getBoundingClientRect().bottom || 0) + 4, right: window.innerWidth - (menuBtnRef.current?.getBoundingClientRect().right || 0) }}>
|
|
<button onClick={() => { setMenuOpen(false); onEdit() }} className="w-full text-left px-3 py-1.5 text-[12px] text-zinc-700 dark:text-zinc-300 hover:bg-zinc-50 dark:hover:bg-zinc-700 flex items-center gap-2"><Pencil size={12} /> {t('common.edit')}</button>
|
|
<button onClick={() => { setMenuOpen(false); onDelete() }} className="w-full text-left px-3 py-1.5 text-[12px] text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 flex items-center gap-2"><Trash2 size={12} /> {t('common.delete')}</button>
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
{/* Title on photo */}
|
|
{entry.title && (
|
|
<div className="absolute bottom-4 left-5 right-5 z-[2] pointer-events-none">
|
|
<h3 className="text-[22px] font-bold text-white tracking-[-0.02em] leading-tight drop-shadow-sm">{entry.title}</h3>
|
|
</div>
|
|
)}
|
|
</div>
|
|
) : (
|
|
/* No photos: simple header */
|
|
<div className="flex items-center justify-between px-4 pt-3">
|
|
<div className="flex items-center gap-2 min-w-0 flex-1 mr-2">
|
|
{entry.location_name && (
|
|
<span className="inline-flex items-center gap-1 px-2 py-0.5 bg-zinc-100 dark:bg-zinc-800 rounded-full text-[10px] font-semibold text-zinc-500 max-w-full overflow-hidden">
|
|
<MapPin size={10} className="flex-shrink-0" /> <span className="truncate">{entry.location_name}</span>
|
|
</span>
|
|
)}
|
|
{entry.entry_time && (
|
|
<span className="inline-flex items-center gap-1 px-2 py-0.5 bg-zinc-100 dark:bg-zinc-800 rounded-full text-[10px] font-semibold text-zinc-500">
|
|
<Clock size={10} /> {entry.entry_time}
|
|
</span>
|
|
)}
|
|
</div>
|
|
<div className="relative">
|
|
<button ref={menuBtnRef} onClick={() => setMenuOpen(!menuOpen)} className="w-7 h-7 rounded-md flex items-center justify-center text-zinc-400 hover:bg-zinc-100 dark:hover:bg-zinc-800">
|
|
<MoreHorizontal size={14} />
|
|
</button>
|
|
{menuOpen && (
|
|
<>
|
|
<div className="fixed inset-0 z-[99]" onClick={() => setMenuOpen(false)} />
|
|
<div className="fixed z-[100] bg-white dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-lg shadow-lg py-1 min-w-[120px]" style={{ top: (menuBtnRef.current?.getBoundingClientRect().bottom || 0) + 4, right: window.innerWidth - (menuBtnRef.current?.getBoundingClientRect().right || 0) }}>
|
|
<button onClick={() => { setMenuOpen(false); onEdit() }} className="w-full text-left px-3 py-1.5 text-[12px] text-zinc-700 dark:text-zinc-300 hover:bg-zinc-50 dark:hover:bg-zinc-700 flex items-center gap-2"><Pencil size={12} /> {t('common.edit')}</button>
|
|
<button onClick={() => { setMenuOpen(false); onDelete() }} className="w-full text-left px-3 py-1.5 text-[12px] text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 flex items-center gap-2"><Trash2 size={12} /> {t('common.delete')}</button>
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<div className="px-5 pt-4 pb-5">
|
|
{/* Title (only if no photos — otherwise shown on image) */}
|
|
{!photos.length && entry.title && (
|
|
<h3 className="text-base font-semibold text-zinc-900 dark:text-white tracking-tight leading-snug mb-1">{entry.title}</h3>
|
|
)}
|
|
{!photos.length && entry.location_name && !entry.title && (
|
|
<div className="mb-2" />
|
|
)}
|
|
{entry.story && (
|
|
<ExpandableStory story={entry.story} />
|
|
)}
|
|
|
|
{/* Pros & Cons — "Pros & Cons" style */}
|
|
{hasProscons && (
|
|
<VerdictSection pros={prosArr} cons={consArr} />
|
|
)}
|
|
|
|
{(mood || weather || (entry.tags && entry.tags.length > 0)) && (
|
|
<div className="flex items-center justify-between pt-3 mt-3 border-t border-zinc-100 dark:border-zinc-800">
|
|
<div className="flex items-center gap-1.5">
|
|
{mood && <MoodChip mood={entry.mood!} />}
|
|
{weather && <WeatherChip weather={entry.weather!} />}
|
|
</div>
|
|
<div className="flex gap-1">
|
|
{entry.tags?.map((tag, i) => (
|
|
<span key={i} className="text-[10px] font-medium px-1.5 py-0.5 rounded-full bg-indigo-50 dark:bg-indigo-900/30 text-indigo-600 dark:text-indigo-400">{tag}</span>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function SkeletonCard({ entry, onClick }: { entry: JourneyEntry; onClick: () => void }) {
|
|
const { t } = useTranslation()
|
|
return (
|
|
<div
|
|
onClick={onClick}
|
|
className="bg-white dark:bg-zinc-900 border border-dashed border-zinc-200 dark:border-zinc-700 rounded-xl px-4 py-3.5 flex items-center gap-3 transition-all hover:border-solid hover:border-zinc-400 dark:hover:border-zinc-500 cursor-pointer"
|
|
>
|
|
<div className="w-9 h-9 rounded-lg bg-zinc-100 dark:bg-zinc-800 flex items-center justify-center text-zinc-500 flex-shrink-0">
|
|
<MapPin size={14} />
|
|
</div>
|
|
<div className="flex-1 min-w-0">
|
|
<div className="text-[13px] font-medium text-zinc-900 dark:text-white">
|
|
{entry.title || t('journey.detail.newEntry')}
|
|
</div>
|
|
<div className="text-[11px] text-zinc-500 mt-0.5">
|
|
{entry.location_name || ''}{entry.entry_time ? ` · ${entry.entry_time}` : ''}
|
|
</div>
|
|
</div>
|
|
<div className="text-[11px] text-zinc-500 font-medium flex-shrink-0">
|
|
{t('journey.detail.addEntry')} →
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function CheckinCard({ entry, onClick }: { entry: JourneyEntry; onClick: () => void }) {
|
|
return (
|
|
<div
|
|
onClick={onClick}
|
|
className="bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-700 rounded-xl px-3.5 py-2.5 flex items-center gap-2.5 transition-all hover:border-zinc-400 dark:hover:border-zinc-500 cursor-pointer"
|
|
>
|
|
<div className="w-7 h-7 rounded-lg bg-indigo-50 dark:bg-indigo-900/30 text-indigo-600 dark:text-indigo-400 flex items-center justify-center flex-shrink-0">
|
|
<MapPin size={13} />
|
|
</div>
|
|
<div className="flex-1 min-w-0">
|
|
<div className="text-[13px] font-medium text-zinc-900 dark:text-white flex items-center gap-1.5">
|
|
{entry.title}
|
|
{entry.location_name && <span className="text-zinc-500 font-normal text-xs">· {entry.location_name}</span>}
|
|
</div>
|
|
{entry.story && <div className="text-[11px] text-zinc-500 mt-0.5">{entry.story}</div>}
|
|
</div>
|
|
<div className="flex items-center gap-2.5 flex-shrink-0">
|
|
{entry.entry_time && <span className="text-[11px] text-zinc-400 tabular-nums">{entry.entry_time}</span>}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function PhotoImg({ photo, className, style, onClick }: { photo: JourneyPhoto; className?: string; style?: React.CSSProperties; onClick?: () => void }) {
|
|
const src = photoUrl(photo, 'thumbnail')
|
|
return (
|
|
<img
|
|
src={src}
|
|
alt=""
|
|
className={className}
|
|
style={style}
|
|
onClick={onClick}
|
|
loading="lazy"
|
|
/>
|
|
)
|
|
}
|
|
|
|
function PhotoGrid({ photos, onClick }: { photos: JourneyPhoto[]; onClick: (idx: number) => void }) {
|
|
const count = photos.length
|
|
if (count === 0) return null
|
|
|
|
if (count === 1) {
|
|
return (
|
|
<div className="overflow-hidden cursor-pointer" onClick={() => onClick(0)}>
|
|
<PhotoImg photo={photos[0]} className="w-full h-72 object-cover" />
|
|
</div>
|
|
)
|
|
}
|
|
|
|
if (count === 2) {
|
|
return (
|
|
<div className="grid grid-cols-2 gap-0.5 overflow-hidden">
|
|
{photos.slice(0, 2).map((p, i) => (
|
|
<PhotoImg key={p.id} photo={p} className="w-full h-52 object-cover cursor-pointer" onClick={() => onClick(i)} />
|
|
))}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div className="overflow-hidden flex" style={{ height: 300, gap: 2 }}>
|
|
<div className="flex-1 min-w-0 cursor-pointer" onClick={() => onClick(0)}>
|
|
<PhotoImg photo={photos[0]} className="w-full h-full object-cover" />
|
|
</div>
|
|
<div className="flex-1 min-w-0 flex flex-col" style={{ gap: 2 }}>
|
|
<div className="flex-1 min-h-0 cursor-pointer" onClick={() => onClick(1)}>
|
|
<PhotoImg photo={photos[1]} className="w-full h-full object-cover" />
|
|
</div>
|
|
<div className="flex-1 min-h-0 relative cursor-pointer" onClick={() => onClick(2)}>
|
|
<PhotoImg photo={photos[2]} className="w-full h-full object-cover" />
|
|
{count > 3 && (
|
|
<div className="absolute bottom-2 right-2 bg-black/60 backdrop-blur text-white rounded-full px-2 py-0.5 text-[10px] font-semibold flex items-center gap-1">
|
|
<Image size={10} />
|
|
+{count - 3}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function MoodChip({ mood }: { mood: string }) {
|
|
const { t } = useTranslation()
|
|
const config = MOOD_CONFIG[mood]
|
|
if (!config) return null
|
|
const Icon = config.icon
|
|
return (
|
|
<div className="flex items-center gap-1 px-2 py-0.5 rounded-full text-[10px] font-medium" style={{ background: config.bg, color: config.text }}>
|
|
<Icon size={11} />
|
|
{t(config.label)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function WeatherChip({ weather }: { weather: string }) {
|
|
const { t } = useTranslation()
|
|
const config = WEATHER_CONFIG[weather]
|
|
if (!config) return null
|
|
const Icon = config.icon
|
|
return (
|
|
<div className="flex items-center gap-1 px-2 py-0.5 rounded-full text-[10px] font-medium bg-zinc-100 dark:bg-zinc-800 text-zinc-600 dark:text-zinc-400">
|
|
<Icon size={11} />
|
|
{t(config.label)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// ── Scroll Trigger ───────────────────────────────────────────────────────
|
|
|
|
function ScrollTrigger({ onVisible, loading }: { onVisible: () => void; loading: boolean }) {
|
|
const ref = useRef<HTMLDivElement>(null)
|
|
useEffect(() => {
|
|
const el = ref.current
|
|
if (!el) return
|
|
const obs = new IntersectionObserver(([entry]) => { if (entry.isIntersecting && !loading) onVisible() }, { rootMargin: '200px' })
|
|
obs.observe(el)
|
|
return () => obs.disconnect()
|
|
}, [onVisible, loading])
|
|
return (
|
|
<div ref={ref} className="flex justify-center py-4 mt-2">
|
|
<div className="w-5 h-5 border-2 border-zinc-300 border-t-zinc-900 dark:border-zinc-600 dark:border-t-white rounded-full animate-spin" />
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// ── Provider Picker ───────────────────────────────────────────────────────
|
|
|
|
function ProviderPicker({ provider, userId, entries, trips, existingAssetIds, onClose, onAdd }: {
|
|
provider: string
|
|
userId: number
|
|
entries: JourneyEntry[]
|
|
trips: JourneyTrip[]
|
|
existingAssetIds: Set<string>
|
|
onClose: () => void
|
|
onAdd: (assetIds: string[], entryId: number | null) => Promise<void>
|
|
}) {
|
|
const { t } = useTranslation()
|
|
const [filter, setFilter] = useState<'trip' | 'custom' | 'all' | 'album'>('trip')
|
|
const [photos, setPhotos] = useState<any[]>([])
|
|
const [albums, setAlbums] = useState<any[]>([])
|
|
const [selectedAlbum, setSelectedAlbum] = useState<string | null>(null)
|
|
const [loading, setLoading] = useState(false)
|
|
const [loadingMore, setLoadingMore] = useState(false)
|
|
const [hasMore, setHasMore] = useState(false)
|
|
const [searchPage, setSearchPage] = useState(1)
|
|
const [searchFrom, setSearchFrom] = useState('')
|
|
const [searchTo, setSearchTo] = useState('')
|
|
const [selected, setSelected] = useState<Set<string>>(new Set())
|
|
const [customFrom, setCustomFrom] = useState('')
|
|
const [customTo, setCustomTo] = useState('')
|
|
const [targetEntryId, setTargetEntryId] = useState<number | null>(null)
|
|
const [addToOpen, setAddToOpen] = useState(false)
|
|
const abortRef = useRef<AbortController | null>(null)
|
|
const gridRef = useRef<HTMLDivElement>(null)
|
|
|
|
// compute trip range
|
|
const tripRange = useMemo(() => {
|
|
let from = '', to = ''
|
|
for (const t of trips) {
|
|
if (t.start_date && (!from || t.start_date < from)) from = t.start_date
|
|
if (t.end_date && (!to || t.end_date > to)) to = t.end_date
|
|
}
|
|
return { from, to }
|
|
}, [trips])
|
|
|
|
const cancelPending = () => {
|
|
if (abortRef.current) { abortRef.current.abort() }
|
|
abortRef.current = new AbortController()
|
|
return abortRef.current.signal
|
|
}
|
|
|
|
const searchPhotos = async (from: string, to: string, page: number = 1, append: boolean = false) => {
|
|
const signal = cancelPending()
|
|
if (page === 1) { setLoading(true); setPhotos([]) } else { setLoadingMore(true) }
|
|
setSearchFrom(from)
|
|
setSearchTo(to)
|
|
setSearchPage(page)
|
|
try {
|
|
const res = await fetch(`/api/integrations/memories/${provider}/search`, {
|
|
method: 'POST', credentials: 'include', signal,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ from, to, page, size: 50 }),
|
|
})
|
|
if (res.ok) {
|
|
const data = await res.json()
|
|
const assets = data.assets || []
|
|
setPhotos(prev => append ? [...prev, ...assets] : assets)
|
|
setHasMore(!!data.hasMore)
|
|
} else {
|
|
setHasMore(false)
|
|
}
|
|
} catch (e: any) {
|
|
if (e.name !== 'AbortError') setHasMore(false)
|
|
}
|
|
if (!signal.aborted) { setLoading(false); setLoadingMore(false) }
|
|
}
|
|
|
|
const loadMorePhotos = () => {
|
|
if (loadingMore || !hasMore) return
|
|
searchPhotos(searchFrom, searchTo, searchPage + 1, true)
|
|
}
|
|
|
|
const loadAlbumPhotos = async (albumId: string) => {
|
|
const signal = cancelPending()
|
|
setLoading(true)
|
|
setPhotos([])
|
|
setHasMore(false)
|
|
try {
|
|
const res = await fetch(`/api/integrations/memories/${provider}/albums/${albumId}/photos`, { credentials: 'include', signal })
|
|
if (res.ok) setPhotos((await res.json()).assets || [])
|
|
} catch (e: any) { if (e.name !== 'AbortError') {} }
|
|
if (!signal.aborted) setLoading(false)
|
|
}
|
|
|
|
const loadAlbums = async () => {
|
|
try {
|
|
const res = await fetch(`/api/integrations/memories/${provider}/albums`, { credentials: 'include' })
|
|
if (res.ok) setAlbums((await res.json()).albums || [])
|
|
} catch {}
|
|
}
|
|
|
|
// load on mount / filter change
|
|
useEffect(() => {
|
|
if (filter === 'trip' && tripRange.from && tripRange.to) {
|
|
searchPhotos(tripRange.from, tripRange.to)
|
|
} else if (filter === 'all') {
|
|
searchPhotos('', '')
|
|
} else if (filter === 'album' && albums.length === 0) {
|
|
loadAlbums()
|
|
}
|
|
}, [filter])
|
|
|
|
const handleCustomSearch = () => {
|
|
if (customFrom && customTo) searchPhotos(customFrom, customTo)
|
|
}
|
|
|
|
const toggleAsset = (id: string) => {
|
|
setSelected(prev => {
|
|
const next = new Set(prev)
|
|
if (next.has(id)) next.delete(id); else next.add(id)
|
|
return next
|
|
})
|
|
}
|
|
|
|
const targetLabel = targetEntryId
|
|
? entries.find(e => e.id === targetEntryId)?.title || entries.find(e => e.id === targetEntryId)?.entry_date || t('journey.stats.entries')
|
|
: t('journey.picker.newGallery')
|
|
|
|
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="bg-white dark:bg-zinc-900 rounded-2xl shadow-[0_20px_40px_rgba(0,0,0,0.2)] max-w-[720px] md:max-w-[960px] w-full max-h-[85vh] flex flex-col overflow-hidden">
|
|
|
|
{/* Header */}
|
|
<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">
|
|
{provider === 'immich' ? 'Immich' : 'Synology Photos'}
|
|
</h2>
|
|
<button onClick={onClose} className="w-8 h-8 rounded-lg flex items-center justify-center text-zinc-400 hover:bg-zinc-100 dark:hover:bg-zinc-800">
|
|
<X size={16} />
|
|
</button>
|
|
</div>
|
|
|
|
{/* Filter bar */}
|
|
<div className="px-6 py-3 border-b border-zinc-200 dark:border-zinc-700">
|
|
{/* Tabs */}
|
|
<div className="flex gap-1.5 mb-3">
|
|
{[
|
|
{ id: 'trip' as const, label: t('journey.picker.tripPeriod') },
|
|
{ id: 'custom' as const, label: t('journey.picker.dateRange') },
|
|
{ id: 'all' as const, label: t('journey.picker.allPhotos') },
|
|
{ id: 'album' as const, label: t('journey.picker.albums') },
|
|
].map(f => (
|
|
<button
|
|
key={f.id}
|
|
onClick={() => setFilter(f.id)}
|
|
className={`px-3 py-1.5 rounded-lg text-[12px] font-medium transition-colors ${
|
|
filter === f.id
|
|
? 'bg-zinc-900 dark:bg-white text-white dark:text-zinc-900'
|
|
: 'text-zinc-500 hover:bg-zinc-100 dark:hover:bg-zinc-800'
|
|
}`}
|
|
>
|
|
{f.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{/* Filter content — always visible row */}
|
|
<div className="min-h-[36px] flex items-center">
|
|
{filter === 'trip' && (
|
|
<div className="flex items-center gap-2 text-[12px] text-zinc-500">
|
|
{tripRange.from && tripRange.to ? (
|
|
<>
|
|
<Calendar size={13} className="text-zinc-400" />
|
|
<span className="font-medium text-zinc-900 dark:text-white">
|
|
{new Date(tripRange.from + 'T00:00:00').toLocaleDateString(undefined, { month: 'short', day: 'numeric' })}
|
|
</span>
|
|
<span className="text-zinc-400">—</span>
|
|
<span className="font-medium text-zinc-900 dark:text-white">
|
|
{new Date(tripRange.to + 'T00:00:00').toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })}
|
|
</span>
|
|
<span className="ml-1 text-zinc-400">
|
|
({Math.ceil((new Date(tripRange.to).getTime() - new Date(tripRange.from).getTime()) / 86400000) + 1} days)
|
|
</span>
|
|
</>
|
|
) : (
|
|
<span className="text-zinc-400">{t('journey.trips.noTripsLinkedSettings')}</span>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{filter === 'custom' && (
|
|
<div className="flex items-center gap-2 flex-1">
|
|
<div className="flex-1"><DatePicker value={customFrom} onChange={setCustomFrom} /></div>
|
|
<span className="text-zinc-400 text-[12px]">—</span>
|
|
<div className="flex-1"><DatePicker value={customTo} onChange={setCustomTo} /></div>
|
|
<button onClick={handleCustomSearch}
|
|
className="px-3 py-1.5 rounded-lg bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 text-[12px] font-medium hover:bg-zinc-800 dark:hover:bg-zinc-100 flex-shrink-0">
|
|
{t('journey.picker.search')}
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{filter === 'album' && (
|
|
<div className="flex gap-2 overflow-x-auto flex-1">
|
|
{albums.map((a: any) => (
|
|
<button
|
|
key={a.id}
|
|
onClick={() => { setSelectedAlbum(a.id); loadAlbumPhotos(a.id) }}
|
|
className={`px-2.5 py-1 rounded-lg text-[11px] font-medium whitespace-nowrap flex-shrink-0 border ${
|
|
selectedAlbum === a.id
|
|
? 'bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 border-zinc-900 dark:border-white'
|
|
: 'border-zinc-200 dark:border-zinc-700 text-zinc-600 dark:text-zinc-400 hover:bg-zinc-50 dark:hover:bg-zinc-800'
|
|
}`}
|
|
>
|
|
{a.albumName || a.name || 'Album'}{a.assetCount != null ? ` (${a.assetCount})` : ''}
|
|
</button>
|
|
))}
|
|
{albums.length === 0 && !loading && <span className="text-[12px] text-zinc-400">{t('journey.picker.noAlbums')}</span>}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Add-to entry selector */}
|
|
<div className="px-6 py-2.5 border-b border-zinc-200 dark:border-zinc-700 bg-zinc-50 dark:bg-zinc-800/50">
|
|
<div className="relative flex items-center gap-2">
|
|
<span className="text-[10px] font-semibold tracking-[0.12em] uppercase text-zinc-500">{t('journey.picker.addTo')}</span>
|
|
<button
|
|
onClick={() => setAddToOpen(!addToOpen)}
|
|
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg border border-zinc-200 dark:border-zinc-700 text-[12px] font-medium text-zinc-700 dark:text-zinc-300 hover:bg-zinc-50 dark:hover:bg-zinc-800"
|
|
>
|
|
<span className={targetEntryId ? '' : 'font-semibold'}>{targetLabel}</span>
|
|
<ChevronRight size={12} className="rotate-90 text-zinc-400" />
|
|
</button>
|
|
{addToOpen && (
|
|
<>
|
|
<div className="fixed inset-0 z-[9]" onClick={() => setAddToOpen(false)} />
|
|
<div className="absolute left-12 top-full mt-1 z-10 bg-white dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-xl shadow-lg py-1.5 min-w-[200px] max-h-[240px] overflow-y-auto">
|
|
<button
|
|
onClick={() => { setTargetEntryId(null); setAddToOpen(false) }}
|
|
className={`w-full text-left px-3 py-2 text-[12px] flex items-center gap-2 ${
|
|
!targetEntryId
|
|
? 'bg-zinc-100 dark:bg-zinc-700 font-semibold text-zinc-900 dark:text-white'
|
|
: 'text-zinc-700 dark:text-zinc-300 hover:bg-zinc-50 dark:hover:bg-zinc-700'
|
|
}`}
|
|
>
|
|
<Camera size={12} />
|
|
{t('journey.picker.newGallery')}
|
|
</button>
|
|
{entries.filter(e => e.type !== 'skeleton' && e.title !== 'Gallery' && e.title !== '[Trip Photos]').length > 0 && (
|
|
<div className="h-px bg-zinc-200 dark:bg-zinc-700 my-1" />
|
|
)}
|
|
{entries.filter(e => e.type !== 'skeleton' && e.title !== 'Gallery' && e.title !== '[Trip Photos]').map(e => (
|
|
<button
|
|
key={e.id}
|
|
onClick={() => { setTargetEntryId(e.id); setAddToOpen(false) }}
|
|
className={`w-full text-left px-3 py-2 text-[12px] truncate ${
|
|
targetEntryId === e.id
|
|
? 'bg-zinc-100 dark:bg-zinc-700 font-semibold text-zinc-900 dark:text-white'
|
|
: 'text-zinc-700 dark:text-zinc-300 hover:bg-zinc-50 dark:hover:bg-zinc-700'
|
|
}`}
|
|
>
|
|
{e.title || e.location_name || new Date(e.entry_date + 'T12:00:00').toLocaleDateString(undefined, { day: 'numeric', month: 'short' })}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Select all bar — sticky above grid */}
|
|
{!loading && photos.length > 0 && (() => {
|
|
const selectable = photos.filter((a: any) => !existingAssetIds.has(a.id))
|
|
const allSelected = selectable.length > 0 && selectable.every((a: any) => selected.has(a.id))
|
|
if (selectable.length === 0) return null
|
|
return (
|
|
<div className="px-4 py-2 border-b border-zinc-200 dark:border-zinc-700 bg-white dark:bg-zinc-900">
|
|
<button
|
|
onClick={() => {
|
|
if (allSelected) {
|
|
setSelected(new Set())
|
|
} else {
|
|
setSelected(new Set(selectable.map((a: any) => a.id)))
|
|
}
|
|
}}
|
|
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg text-[11px] font-medium border border-zinc-200 dark:border-zinc-700 text-zinc-500 dark:text-zinc-400 hover:bg-zinc-50 dark:hover:bg-zinc-800"
|
|
>
|
|
<div className={`w-3.5 h-3.5 rounded border flex items-center justify-center ${
|
|
allSelected
|
|
? 'bg-zinc-900 dark:bg-white border-zinc-900 dark:border-white'
|
|
: 'border-zinc-300 dark:border-zinc-600'
|
|
}`}>
|
|
{allSelected && <Check size={9} className="text-white dark:text-zinc-900" strokeWidth={3} />}
|
|
</div>
|
|
{allSelected ? t('journey.picker.deselectAll') : t('journey.picker.selectAll')} ({selectable.length})
|
|
</button>
|
|
</div>
|
|
)
|
|
})()}
|
|
|
|
{/* Photo grid */}
|
|
<div className="flex-1 overflow-y-auto p-4">
|
|
{loading ? (
|
|
<div className="flex justify-center py-12">
|
|
<div className="w-6 h-6 border-2 border-zinc-300 border-t-zinc-900 rounded-full animate-spin" />
|
|
</div>
|
|
) : photos.length === 0 ? (
|
|
<div className="text-center py-12">
|
|
<p className="text-[13px] text-zinc-500">
|
|
{filter === 'trip' && !tripRange.from ? t('journey.trips.noTripsLinkedSettings') : t('journey.detail.noPhotos')}
|
|
</p>
|
|
</div>
|
|
) : (
|
|
<div className="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-5 gap-1.5">
|
|
{photos.map((asset: any) => {
|
|
const isSelected = selected.has(asset.id)
|
|
const alreadyAdded = existingAssetIds.has(asset.id)
|
|
return (
|
|
<div
|
|
key={asset.id}
|
|
onClick={() => !alreadyAdded && toggleAsset(asset.id)}
|
|
className={`relative aspect-square rounded-lg overflow-hidden ${
|
|
alreadyAdded
|
|
? 'opacity-40 cursor-not-allowed'
|
|
: isSelected
|
|
? 'ring-2 ring-zinc-900 dark:ring-white ring-offset-2 dark:ring-offset-zinc-900 cursor-pointer'
|
|
: 'cursor-pointer'
|
|
}`}
|
|
>
|
|
<img
|
|
src={`/api/integrations/memories/${provider}/assets/0/${asset.id}/${userId}/thumbnail`}
|
|
alt=""
|
|
className="w-full h-full object-cover"
|
|
loading="lazy"
|
|
onError={e => {
|
|
const img = e.currentTarget
|
|
const original = `/api/integrations/memories/${provider}/assets/0/${asset.id}/${userId}/original`
|
|
if (!img.src.includes('/original')) img.src = original
|
|
}}
|
|
/>
|
|
{alreadyAdded && (
|
|
<div className="absolute top-1.5 right-1.5 w-5 h-5 rounded-full bg-zinc-500 text-white flex items-center justify-center">
|
|
<Check size={12} />
|
|
</div>
|
|
)}
|
|
{isSelected && !alreadyAdded && (
|
|
<div className="absolute top-1.5 right-1.5 w-5 h-5 rounded-full bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 flex items-center justify-center">
|
|
<Check size={12} />
|
|
</div>
|
|
)}
|
|
{asset.city && (
|
|
<div className="absolute bottom-0 left-0 right-0 p-1 bg-gradient-to-t from-black/50 to-transparent">
|
|
<p className="text-[8px] text-white truncate">{asset.city}</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
})}
|
|
{/* Infinite scroll trigger */}
|
|
{hasMore && !selectedAlbum && <ScrollTrigger onVisible={loadMorePhotos} loading={loadingMore} />}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Footer */}
|
|
<div className="flex items-center justify-between px-6 py-4 border-t border-zinc-200 dark:border-zinc-700 bg-zinc-50 dark:bg-zinc-800/50">
|
|
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full bg-zinc-200/60 dark:bg-zinc-700/60 text-[11px] leading-none text-zinc-500 dark:text-zinc-400">
|
|
<span className="inline-flex items-center justify-center min-w-[18px] h-[18px] px-1 rounded-full bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 text-[10px] leading-none font-bold">{selected.size}</span>
|
|
<span className="leading-[18px]">{t('journey.picker.selected')}</span>
|
|
</span>
|
|
<div className="flex items-center gap-2">
|
|
<button onClick={onClose} className="px-3.5 py-2 rounded-lg border border-zinc-200 dark:border-zinc-600 text-[13px] font-medium text-zinc-700 dark:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-zinc-700">
|
|
{t('common.cancel')}
|
|
</button>
|
|
<button
|
|
onClick={() => onAdd([...selected], targetEntryId)}
|
|
disabled={selected.size === 0}
|
|
className="px-3.5 py-2 rounded-lg bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 text-[13px] font-medium hover:bg-zinc-800 dark:hover:bg-zinc-100 disabled:opacity-40 disabled:cursor-not-allowed"
|
|
>
|
|
{t('common.add')} {selected.size > 0 ? `(${selected.size})` : ''}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// ── Date Picker ───────────────────────────────────────────────────────────
|
|
|
|
function DatePicker({ value, onChange, tripDates }: {
|
|
value: string
|
|
onChange: (date: string) => void
|
|
tripDates?: Set<string>
|
|
}) {
|
|
const { t } = useTranslation()
|
|
const [open, setOpen] = useState(false)
|
|
const [viewMonth, setViewMonth] = useState(() => {
|
|
const d = value ? new Date(value + 'T00:00:00') : new Date()
|
|
return { year: d.getFullYear(), month: d.getMonth() }
|
|
})
|
|
|
|
const daysInMonth = new Date(viewMonth.year, viewMonth.month + 1, 0).getDate()
|
|
const firstDow = new Date(viewMonth.year, viewMonth.month, 1).getDay()
|
|
const monthName = new Date(viewMonth.year, viewMonth.month).toLocaleDateString(undefined, { month: 'long', year: 'numeric' })
|
|
|
|
const prevMonth = () => {
|
|
setViewMonth(p => p.month === 0 ? { year: p.year - 1, month: 11 } : { ...p, month: p.month - 1 })
|
|
}
|
|
const nextMonth = () => {
|
|
setViewMonth(p => p.month === 11 ? { year: p.year + 1, month: 0 } : { ...p, month: p.month + 1 })
|
|
}
|
|
|
|
const pad = (n: number) => String(n).padStart(2, '0')
|
|
|
|
const cells: (number | null)[] = []
|
|
for (let i = 0; i < firstDow; i++) cells.push(null)
|
|
for (let d = 1; d <= daysInMonth; d++) cells.push(d)
|
|
|
|
const formatted = value ? new Date(value + 'T00:00:00').toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }) : t('journey.picker.selectDate')
|
|
|
|
return (
|
|
<div className="relative">
|
|
<button
|
|
type="button"
|
|
onClick={() => setOpen(!open)}
|
|
className="w-full px-3 py-2 border border-zinc-200 dark:border-zinc-700 rounded-lg text-[13px] bg-white dark:bg-zinc-800 text-zinc-900 dark:text-white text-left flex items-center justify-between"
|
|
>
|
|
<span>{formatted}</span>
|
|
<Calendar size={13} className="text-zinc-400" />
|
|
</button>
|
|
|
|
{open && (
|
|
<>
|
|
<div className="fixed inset-0 z-[10]" onClick={() => setOpen(false)} />
|
|
<div className="absolute top-full left-0 mt-1 z-[20] bg-white dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-xl shadow-lg p-3 w-[280px]">
|
|
{/* Month nav */}
|
|
<div className="flex items-center justify-between mb-2">
|
|
<button type="button" onClick={prevMonth} className="w-7 h-7 rounded-lg hover:bg-zinc-100 dark:hover:bg-zinc-700 flex items-center justify-center text-zinc-500">
|
|
<ArrowLeft size={14} />
|
|
</button>
|
|
<span className="text-[13px] font-semibold text-zinc-900 dark:text-white">{monthName}</span>
|
|
<button type="button" onClick={nextMonth} className="w-7 h-7 rounded-lg hover:bg-zinc-100 dark:hover:bg-zinc-700 flex items-center justify-center text-zinc-500">
|
|
<ChevronRight size={14} />
|
|
</button>
|
|
</div>
|
|
|
|
{/* Weekday headers */}
|
|
<div className="grid grid-cols-7 mb-1">
|
|
{['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa'].map((d, i) => (
|
|
<div key={i} className="text-center text-[10px] font-medium text-zinc-400 py-1">{d}</div>
|
|
))}
|
|
</div>
|
|
|
|
{/* Day grid */}
|
|
<div className="grid grid-cols-7">
|
|
{cells.map((day, i) => {
|
|
if (day === null) return <div key={`e${i}`} />
|
|
const dateStr = `${viewMonth.year}-${pad(viewMonth.month + 1)}-${pad(day)}`
|
|
const isSelected = dateStr === value
|
|
const isTrip = tripDates?.has(dateStr)
|
|
const isToday = dateStr === new Date().toISOString().split('T')[0]
|
|
|
|
return (
|
|
<button
|
|
key={dateStr}
|
|
type="button"
|
|
onClick={() => { onChange(dateStr); setOpen(false) }}
|
|
className={`w-9 h-9 rounded-lg text-[12px] font-medium flex items-center justify-center relative transition-colors ${
|
|
isSelected
|
|
? 'bg-zinc-900 dark:bg-white text-white dark:text-zinc-900'
|
|
: isToday
|
|
? 'text-zinc-900 dark:text-white font-bold'
|
|
: 'text-zinc-700 dark:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-zinc-700'
|
|
}`}
|
|
>
|
|
{day}
|
|
{isTrip && !isSelected && (
|
|
<span className="absolute bottom-1 left-1/2 -translate-x-1/2 w-1 h-1 rounded-full bg-indigo-500" />
|
|
)}
|
|
</button>
|
|
)
|
|
})}
|
|
</div>
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSave, onUploadPhotos, onDone }: {
|
|
entry: JourneyEntry
|
|
journeyId: number
|
|
tripDates: Set<string>
|
|
galleryPhotos: JourneyPhoto[]
|
|
onClose: () => void
|
|
onSave: (data: Record<string, unknown>) => Promise<number>
|
|
onUploadPhotos: (entryId: number, formData: FormData) => Promise<JourneyPhoto[]>
|
|
onDone: () => void
|
|
}) {
|
|
const { t } = useTranslation()
|
|
const [title, setTitle] = useState(entry.title || '')
|
|
const [story, setStory] = useState(entry.story || '')
|
|
const [entryDate, setEntryDate] = useState(entry.entry_date || new Date().toISOString().split('T')[0])
|
|
const [entryTime, setEntryTime] = useState(entry.entry_time || '')
|
|
const [locationName, setLocationName] = useState(entry.location_name || '')
|
|
const [locationLat, setLocationLat] = useState<number | null>(entry.location_lat ?? null)
|
|
const [locationLng, setLocationLng] = useState<number | null>(entry.location_lng ?? null)
|
|
const [locationQuery, setLocationQuery] = useState('')
|
|
const [locationResults, setLocationResults] = useState<{ name: string; address?: string; lat: number; lng: number }[]>([])
|
|
const [locationSearching, setLocationSearching] = useState(false)
|
|
const [showLocationResults, setShowLocationResults] = useState(false)
|
|
const locationTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
const [mood, setMood] = useState(entry.mood || '')
|
|
const [weather, setWeather] = useState(entry.weather || '')
|
|
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 [saving, setSaving] = useState(false)
|
|
const [uploading, setUploading] = useState(false)
|
|
const [photos, setPhotos] = useState<JourneyPhoto[]>(entry.photos || [])
|
|
const [pendingFiles, setPendingFiles] = useState<File[]>([])
|
|
const [pendingLinkIds, setPendingLinkIds] = useState<number[]>([])
|
|
const [showGalleryPick, setShowGalleryPick] = useState(false)
|
|
const fileRef = useRef<HTMLInputElement>(null)
|
|
const storyRef = useRef<HTMLTextAreaElement>(null)
|
|
|
|
const handleSave = async () => {
|
|
setSaving(true)
|
|
try {
|
|
const entryId = await onSave({
|
|
title: title || null,
|
|
story: story || null,
|
|
entry_date: entryDate,
|
|
entry_time: entryTime || null,
|
|
location_name: locationName || null,
|
|
location_lat: locationLat,
|
|
location_lng: locationLng,
|
|
mood: mood || null,
|
|
weather: weather || null,
|
|
pros_cons: { pros: pros.filter(p => p.trim()), cons: cons.filter(c => c.trim()) },
|
|
type: (entry.type === 'skeleton' && story.trim()) ? 'entry' : undefined,
|
|
})
|
|
// upload queued files after entry is created
|
|
if (pendingFiles.length > 0 && entryId) {
|
|
const formData = new FormData()
|
|
for (const f of pendingFiles) formData.append('photos', f)
|
|
await onUploadPhotos(entryId, formData)
|
|
}
|
|
// link gallery photos that were picked before save
|
|
if (pendingLinkIds.length > 0 && entryId) {
|
|
for (const photoId of pendingLinkIds) {
|
|
try { await journeyApi.linkPhoto(entryId, photoId) } catch {}
|
|
}
|
|
}
|
|
onDone()
|
|
} finally {
|
|
setSaving(false)
|
|
}
|
|
}
|
|
|
|
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
const files = e.target.files
|
|
if (!files?.length) return
|
|
if (entry.id === 0) {
|
|
// queue files for upload after save
|
|
setPendingFiles(prev => [...prev, ...Array.from(files)])
|
|
} else {
|
|
setUploading(true)
|
|
try {
|
|
const formData = new FormData()
|
|
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)
|
|
}
|
|
}
|
|
}
|
|
|
|
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="bg-white dark:bg-zinc-900 rounded-2xl shadow-[0_20px_40px_rgba(0,0,0,0.2)] max-w-[640px] w-full max-h-[90vh] flex flex-col overflow-hidden">
|
|
|
|
<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">{entry.id === 0 ? t('journey.detail.newEntry') : t('journey.detail.editEntry')}</h2>
|
|
<button onClick={onClose} className="w-8 h-8 rounded-lg flex items-center justify-center text-zinc-400 hover:bg-zinc-100 dark:hover:bg-zinc-800">
|
|
<X size={16} />
|
|
</button>
|
|
</div>
|
|
|
|
<div className="flex-1 min-h-0 overflow-y-auto px-6 py-5 flex flex-col gap-4">
|
|
<input
|
|
value={title}
|
|
onChange={e => setTitle(e.target.value)}
|
|
placeholder={t('journey.editor.titlePlaceholder')}
|
|
className="w-full text-[20px] font-medium bg-transparent border-0 border-b border-transparent focus:border-zinc-300 dark:focus:border-zinc-600 outline-none text-zinc-900 dark:text-white placeholder:text-zinc-400 pb-2"
|
|
/>
|
|
|
|
<div>
|
|
<input ref={fileRef} type="file" accept="image/*" multiple onChange={handleFileChange} onClick={e => { (e.target as HTMLInputElement).value = '' }} className="hidden" />
|
|
<div className="flex gap-2">
|
|
<button
|
|
onClick={() => fileRef.current?.click()}
|
|
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"
|
|
>
|
|
{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>
|
|
{galleryPhotos.length > 0 && (
|
|
<button
|
|
onClick={() => setShowGalleryPick(!showGalleryPick)}
|
|
className={`flex-1 border rounded-lg py-4 text-[12px] text-zinc-500 flex items-center justify-center gap-1.5 ${
|
|
showGalleryPick
|
|
? 'border-zinc-900 dark:border-white bg-zinc-50 dark:bg-zinc-800'
|
|
: 'border-dashed border-zinc-200 dark:border-zinc-700 hover:border-zinc-400 dark:hover:border-zinc-500 hover:bg-zinc-50 dark:hover:bg-zinc-800'
|
|
}`}
|
|
>
|
|
<Image size={13} /> {t('journey.editor.fromGallery')}
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
{/* Gallery picker — directly below buttons */}
|
|
{showGalleryPick && (
|
|
<div className="mt-2 border border-zinc-200 dark:border-zinc-700 rounded-xl p-3 bg-zinc-50 dark:bg-zinc-800/50">
|
|
<div className="grid grid-cols-5 sm:grid-cols-6 gap-1.5 max-h-[160px] overflow-y-auto">
|
|
{galleryPhotos.filter(gp => !photos.some(p => p.id === gp.id)).map(gp => (
|
|
<div
|
|
key={gp.id}
|
|
onClick={async () => {
|
|
if (entry.id > 0) {
|
|
try {
|
|
const linked = await journeyApi.linkPhoto(entry.id, gp.id)
|
|
if (linked) setPhotos(prev => [...prev, linked])
|
|
} catch {}
|
|
} else {
|
|
setPendingLinkIds(prev => [...prev, gp.id])
|
|
setPhotos(prev => [...prev, gp])
|
|
}
|
|
}}
|
|
className="aspect-square rounded-lg overflow-hidden cursor-pointer hover:ring-2 hover:ring-zinc-900 dark:hover:ring-white hover:ring-offset-1 dark:hover:ring-offset-zinc-900 transition-all"
|
|
>
|
|
<img src={photoUrl(gp)} alt="" className="w-full h-full object-cover" loading="lazy" onError={e => { const img = e.currentTarget; const orig = photoUrl(gp, 'original'); if (!img.src.includes('/original')) img.src = orig }} />
|
|
</div>
|
|
))}
|
|
{galleryPhotos.filter(gp => !photos.some(p => p.id === gp.id)).length === 0 && (
|
|
<div className="col-span-full text-center py-3 text-[11px] text-zinc-400">{t('journey.editor.allPhotosAdded')}</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
{(photos.length > 0 || pendingFiles.length > 0) && (
|
|
<div className="mt-3">
|
|
<div className="flex flex-wrap gap-2">
|
|
{photos.map((p, idx) => (
|
|
<div key={p.id} className={`w-20 h-20 rounded-lg overflow-hidden relative group ${idx === 0 && photos.length > 1 ? 'ring-2 ring-zinc-900 dark:ring-white ring-offset-1 dark:ring-offset-zinc-900' : ''}`}>
|
|
<img src={photoUrl(p)} className="w-full h-full object-cover" alt="" onError={e => { const img = e.currentTarget; const orig = photoUrl(p, 'original'); if (!img.src.includes('/original')) img.src = orig }} />
|
|
{idx === 0 && photos.length > 1 && (
|
|
<span className="absolute bottom-0.5 left-0.5 px-1 py-px rounded text-[8px] font-bold bg-zinc-900/70 text-white">{t('journey.editor.photoFirst')}</span>
|
|
)}
|
|
{idx > 0 && photos.length > 1 && (
|
|
<button
|
|
onClick={e => {
|
|
e.stopPropagation()
|
|
setPhotos(prev => {
|
|
const next = [...prev]
|
|
const [moved] = next.splice(idx, 1)
|
|
next.unshift(moved)
|
|
next.forEach((ph, i) => { journeyApi.updatePhoto(ph.id, { sort_order: i }).catch(() => {}) })
|
|
return next
|
|
})
|
|
}}
|
|
className="absolute bottom-0.5 left-0.5 px-1.5 py-0.5 rounded bg-black/60 text-white text-[8px] font-semibold opacity-0 group-hover:opacity-100 transition-opacity"
|
|
>
|
|
{t('journey.editor.makeFirst')}
|
|
</button>
|
|
)}
|
|
<button
|
|
onClick={async (e) => {
|
|
e.stopPropagation()
|
|
await journeyApi.deletePhoto(p.id)
|
|
setPhotos(prev => prev.filter(x => x.id !== p.id))
|
|
}}
|
|
className="absolute top-1 right-1 w-5 h-5 rounded-full bg-black/60 text-white flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"
|
|
>
|
|
<X size={10} />
|
|
</button>
|
|
</div>
|
|
))}
|
|
{pendingFiles.map((f, i) => (
|
|
<div key={`pending-${i}`} className="w-20 h-20 rounded-lg overflow-hidden relative group">
|
|
<img src={URL.createObjectURL(f)} className="w-full h-full object-cover" alt="" />
|
|
<button
|
|
onClick={() => setPendingFiles(prev => prev.filter((_, j) => j !== i))}
|
|
className="absolute top-1 right-1 w-5 h-5 rounded-full bg-black/60 text-white flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"
|
|
>
|
|
<X size={10} />
|
|
</button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="shrink-0 border border-zinc-200 dark:border-zinc-700 rounded-lg overflow-hidden focus-within:border-zinc-400 dark:focus-within:border-zinc-500">
|
|
<MarkdownToolbar textareaRef={storyRef} onUpdate={setStory} />
|
|
<textarea
|
|
ref={storyRef}
|
|
value={story}
|
|
onChange={e => setStory(e.target.value)}
|
|
placeholder={t('journey.editor.writeStory')}
|
|
rows={6}
|
|
style={{ minHeight: '144px' }}
|
|
className="w-full px-3 py-2.5 text-[14px] bg-white dark:bg-zinc-800 text-zinc-900 dark:text-white outline-none resize-none border-0 shrink-0"
|
|
/>
|
|
</div>
|
|
|
|
{/* Pros & Cons */}
|
|
<div className="bg-zinc-50 dark:bg-zinc-800/50 rounded-2xl p-5">
|
|
<div className="mb-4">
|
|
<span className="text-[11px] font-semibold tracking-[0.12em] uppercase text-zinc-500">{t('journey.editor.prosCons')}</span>
|
|
</div>
|
|
<div className="grid grid-cols-2 gap-4">
|
|
{/* Pros */}
|
|
<div>
|
|
<div className="flex items-center gap-[7px] mb-2.5">
|
|
<div className="w-4 h-4 rounded-full bg-green-100 dark:bg-green-900/30 flex items-center justify-center">
|
|
<Check size={9} className="text-green-700 dark:text-green-400" strokeWidth={3.5} />
|
|
</div>
|
|
<span className="text-[12px] font-semibold text-green-700 dark:text-green-400">{t('journey.editor.pros')}</span>
|
|
</div>
|
|
<div className="flex flex-col gap-1.5">
|
|
{pros.map((p, i) => (
|
|
<div key={i} className="flex items-center gap-2 h-9 px-3 bg-green-50 dark:bg-green-900/10 border border-green-200 dark:border-green-800/30 rounded-[10px]">
|
|
<span className="w-[5px] h-[5px] rounded-full bg-green-500 flex-shrink-0" />
|
|
<input
|
|
value={p}
|
|
onChange={e => { const next = [...pros]; next[i] = e.target.value; setPros(next) }}
|
|
placeholder={t('journey.editor.proPlaceholder')}
|
|
className="flex-1 min-w-0 bg-transparent border-none outline-none text-[13px] text-zinc-900 dark:text-zinc-100 placeholder:text-green-400 dark:placeholder:text-green-600"
|
|
/>
|
|
{pros.length > 1 && (
|
|
<button onClick={() => setPros(pros.filter((_, j) => j !== i))} className="p-1 text-green-300 dark:text-green-700 hover:text-green-600 dark:hover:text-green-400 flex-shrink-0">
|
|
<X size={13} strokeWidth={2.5} />
|
|
</button>
|
|
)}
|
|
</div>
|
|
))}
|
|
<button
|
|
onClick={() => setPros([...pros, ''])}
|
|
className="flex items-center justify-center gap-1.5 h-9 w-full border border-dashed border-green-200 dark:border-green-800/40 rounded-[10px] text-[12px] font-medium text-green-700 dark:text-green-400 hover:border-green-300 dark:hover:border-green-700 transition-colors"
|
|
>
|
|
<Plus size={13} strokeWidth={2.5} /> {t('journey.editor.addAnother')}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Cons */}
|
|
<div>
|
|
<div className="flex items-center gap-[7px] mb-2.5">
|
|
<div className="w-4 h-4 rounded-full bg-red-100 dark:bg-red-900/30 flex items-center justify-center">
|
|
<Minus size={9} className="text-red-700 dark:text-red-400" strokeWidth={3.5} />
|
|
</div>
|
|
<span className="text-[12px] font-semibold text-red-700 dark:text-red-400">{t('journey.editor.cons')}</span>
|
|
</div>
|
|
<div className="flex flex-col gap-1.5">
|
|
{cons.map((c, i) => (
|
|
<div key={i} className="flex items-center gap-2 h-9 px-3 bg-red-50 dark:bg-red-900/10 border border-red-200 dark:border-red-800/30 rounded-[10px]">
|
|
<span className="w-[5px] h-[5px] rounded-full bg-red-500 flex-shrink-0" />
|
|
<input
|
|
value={c}
|
|
onChange={e => { const next = [...cons]; next[i] = e.target.value; setCons(next) }}
|
|
placeholder={t('journey.editor.conPlaceholder')}
|
|
className="flex-1 min-w-0 bg-transparent border-none outline-none text-[13px] text-zinc-900 dark:text-zinc-100 placeholder:text-red-400 dark:placeholder:text-red-600"
|
|
/>
|
|
{cons.length > 1 && (
|
|
<button onClick={() => setCons(cons.filter((_, j) => j !== i))} className="p-1 text-red-300 dark:text-red-700 hover:text-red-600 dark:hover:text-red-400 flex-shrink-0">
|
|
<X size={13} strokeWidth={2.5} />
|
|
</button>
|
|
)}
|
|
</div>
|
|
))}
|
|
<button
|
|
onClick={() => setCons([...cons, ''])}
|
|
className="flex items-center justify-center gap-1.5 h-9 w-full border border-dashed border-red-200 dark:border-red-800/40 rounded-[10px] text-[12px] font-medium text-red-700 dark:text-red-400 hover:border-red-300 dark:hover:border-red-700 transition-colors"
|
|
>
|
|
<Plus size={13} strokeWidth={2.5} /> {t('journey.editor.addAnother')}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="h-px bg-zinc-200 dark:bg-zinc-700" />
|
|
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<div>
|
|
<label className="text-[10px] font-semibold tracking-[0.12em] uppercase text-zinc-500 block mb-1.5">{t('journey.editor.date')}</label>
|
|
<DatePicker value={entryDate} onChange={setEntryDate} tripDates={tripDates} />
|
|
</div>
|
|
<div className="relative">
|
|
<label className="text-[10px] font-semibold tracking-[0.12em] uppercase text-zinc-500 block mb-1.5">{t('journey.editor.location')}</label>
|
|
<div className="relative">
|
|
<input
|
|
value={locationQuery || locationName}
|
|
onChange={e => {
|
|
const q = e.target.value
|
|
setLocationQuery(q)
|
|
setShowLocationResults(true)
|
|
if (locationTimerRef.current) clearTimeout(locationTimerRef.current)
|
|
if (q.trim().length >= 2) {
|
|
locationTimerRef.current = setTimeout(async () => {
|
|
setLocationSearching(true)
|
|
try {
|
|
const res = await mapsApi.search(q)
|
|
setLocationResults((res.places || []).slice(0, 6).map((p: any) => ({
|
|
name: p.name, address: p.address, lat: Number(p.lat), lng: Number(p.lng),
|
|
})))
|
|
} catch { setLocationResults([]) }
|
|
finally { setLocationSearching(false) }
|
|
}, 400)
|
|
} else {
|
|
setLocationResults([])
|
|
}
|
|
}}
|
|
onFocus={() => { if (locationResults.length > 0) setShowLocationResults(true) }}
|
|
placeholder={t('journey.editor.searchLocation')}
|
|
className="w-full px-3 py-2 border border-zinc-200 dark:border-zinc-700 rounded-lg text-[13px] bg-white dark:bg-zinc-800 text-zinc-900 dark:text-white outline-none focus:border-zinc-400 dark:focus:border-zinc-500"
|
|
/>
|
|
{locationLat && (
|
|
<div className="absolute right-2 top-1/2 -translate-y-1/2">
|
|
<MapPin size={13} className="text-emerald-500" />
|
|
</div>
|
|
)}
|
|
</div>
|
|
{showLocationResults && locationResults.length > 0 && (
|
|
<>
|
|
<div className="fixed inset-0 z-[99]" onClick={() => setShowLocationResults(false)} />
|
|
<div className="absolute left-0 right-0 top-full mt-1 z-[100] bg-white dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-xl shadow-lg overflow-hidden max-h-[240px] overflow-y-auto">
|
|
{locationResults.map((r, i) => (
|
|
<button
|
|
key={i}
|
|
onClick={() => {
|
|
setLocationName(r.name)
|
|
setLocationLat(r.lat)
|
|
setLocationLng(r.lng)
|
|
setLocationQuery('')
|
|
setShowLocationResults(false)
|
|
setLocationResults([])
|
|
}}
|
|
className="w-full text-left px-3 py-2.5 hover:bg-zinc-50 dark:hover:bg-zinc-700 flex items-start gap-2.5 border-b border-zinc-100 dark:border-zinc-700 last:border-0"
|
|
>
|
|
<MapPin size={13} className="text-zinc-400 flex-shrink-0 mt-0.5" />
|
|
<div className="min-w-0">
|
|
<div className="text-[13px] font-medium text-zinc-900 dark:text-white truncate">{r.name}</div>
|
|
{r.address && <div className="text-[11px] text-zinc-500 truncate">{r.address}</div>}
|
|
</div>
|
|
</button>
|
|
))}
|
|
</div>
|
|
</>
|
|
)}
|
|
{locationSearching && (
|
|
<div className="absolute left-0 right-0 top-full mt-1 z-[100] bg-white dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-xl shadow-lg px-3 py-3 text-center text-[12px] text-zinc-400">
|
|
{t('journey.editor.searching')}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="text-[10px] font-semibold tracking-[0.12em] uppercase text-zinc-500 block mb-2">{t('journey.editor.mood')}</label>
|
|
<div className="flex gap-2">
|
|
{Object.entries(MOOD_CONFIG).map(([key, config]) => {
|
|
const Icon = config.icon
|
|
const active = mood === key
|
|
return (
|
|
<button key={key} onClick={() => setMood(active ? '' : key)}
|
|
className="flex items-center gap-1 px-2.5 py-1 rounded-full text-[11px] font-medium border transition-all"
|
|
style={{ background: active ? config.bg : 'transparent', color: active ? config.text : '#71717A', borderColor: active ? config.text + '30' : '#E4E4E7' }}>
|
|
<Icon size={12} />
|
|
{t(config.label)}
|
|
</button>
|
|
)
|
|
})}
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="text-[10px] font-semibold tracking-[0.12em] uppercase text-zinc-500 block mb-2">{t('journey.editor.weather')}</label>
|
|
<div className="flex flex-wrap gap-2">
|
|
{Object.entries(WEATHER_CONFIG).map(([key, config]) => {
|
|
const Icon = config.icon
|
|
const active = weather === key
|
|
return (
|
|
<button key={key} onClick={() => setWeather(active ? '' : key)}
|
|
className={`flex items-center gap-1 px-2 py-1 rounded-full text-[11px] font-medium border transition-all ${
|
|
active ? 'bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 border-zinc-900 dark:border-white' : 'border-zinc-200 dark:border-zinc-700 text-zinc-500 hover:border-zinc-400'
|
|
}`}>
|
|
<Icon size={12} />
|
|
{t(config.label)}
|
|
</button>
|
|
)
|
|
})}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
|
|
<div className="flex items-center justify-end gap-2 px-6 py-4 border-t border-zinc-200 dark:border-zinc-700 bg-zinc-50 dark:bg-zinc-800/50">
|
|
<button onClick={onClose} className="px-3.5 py-2 rounded-lg border border-zinc-200 dark:border-zinc-600 text-[13px] font-medium text-zinc-700 dark:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-zinc-700">{t('common.cancel')}</button>
|
|
<button onClick={handleSave} disabled={saving} className="px-3.5 py-2 rounded-lg bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 text-[13px] font-medium hover:bg-zinc-800 dark:hover:bg-zinc-100 disabled:opacity-50">
|
|
{saving ? t('common.saving') : t('common.save')}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// ── Add Trip Dialog ──────────────────────────────────────────────────────
|
|
|
|
function AddTripDialog({ journeyId, existingTripIds, onClose, onAdded }: {
|
|
journeyId: number
|
|
existingTripIds: number[]
|
|
onClose: () => void
|
|
onAdded: () => void
|
|
}) {
|
|
const { t } = useTranslation()
|
|
const [trips, setTrips] = useState<{ id: number; title: string; destination?: string; start_date?: string; end_date?: string }[]>([])
|
|
const [search, setSearch] = useState('')
|
|
const [adding, setAdding] = useState<number | null>(null)
|
|
const toast = useToast()
|
|
|
|
useEffect(() => {
|
|
journeyApi.availableTrips().then(d => setTrips(d.trips || [])).catch(() => {})
|
|
}, [])
|
|
|
|
const filtered = trips.filter(trip => {
|
|
if (existingTripIds.includes(trip.id)) return false
|
|
if (!search) return true
|
|
const q = search.toLowerCase()
|
|
return trip.title.toLowerCase().includes(q) || (trip.destination || '').toLowerCase().includes(q)
|
|
})
|
|
|
|
const handleAdd = async (tripId: number) => {
|
|
setAdding(tripId)
|
|
try {
|
|
await journeyApi.addTrip(journeyId, tripId)
|
|
toast.success(t('journey.trips.tripLinked'))
|
|
onAdded()
|
|
} catch {
|
|
toast.error(t('journey.trips.linkFailed'))
|
|
} finally {
|
|
setAdding(null)
|
|
}
|
|
}
|
|
|
|
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="bg-white dark:bg-zinc-900 rounded-2xl shadow-[0_20px_40px_rgba(0,0,0,0.2)] max-w-[420px] w-full flex flex-col overflow-hidden">
|
|
|
|
<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.trips.linkTrip')}</h2>
|
|
<button onClick={onClose} className="w-8 h-8 rounded-lg flex items-center justify-center text-zinc-400 hover:bg-zinc-100 dark:hover:bg-zinc-800">
|
|
<X size={16} />
|
|
</button>
|
|
</div>
|
|
|
|
<div className="px-6 py-5 flex flex-col gap-4">
|
|
<div>
|
|
<label className="text-[10px] font-semibold tracking-[0.12em] uppercase text-zinc-500 block mb-1.5">{t('journey.trips.searchTrip')}</label>
|
|
<input
|
|
value={search}
|
|
onChange={e => setSearch(e.target.value)}
|
|
placeholder={t('journey.trips.searchPlaceholder')}
|
|
className="w-full px-3 py-2 border border-zinc-200 dark:border-zinc-700 rounded-lg text-[13px] bg-white dark:bg-zinc-800 text-zinc-900 dark:text-white outline-none focus:border-zinc-400 dark:focus:border-zinc-500"
|
|
/>
|
|
</div>
|
|
|
|
<div className="max-h-[280px] overflow-y-auto flex flex-col gap-1">
|
|
{filtered.length === 0 && (
|
|
<p className="text-[12px] text-zinc-400 text-center py-4">{t('journey.trips.noTripsAvailable')}</p>
|
|
)}
|
|
{filtered.map(trip => (
|
|
<div
|
|
key={trip.id}
|
|
className="flex items-center gap-2.5 p-2.5 rounded-lg hover:bg-zinc-50 dark:hover:bg-zinc-800 border border-transparent"
|
|
>
|
|
<div className="w-9 h-9 rounded-md flex-shrink-0" style={{ background: pickGradient(trip.id) }} />
|
|
<div className="flex-1 min-w-0">
|
|
<div className="text-[13px] font-medium text-zinc-900 dark:text-white truncate">{trip.title}</div>
|
|
{(trip.destination || trip.start_date) && (
|
|
<div className="text-[11px] text-zinc-500 truncate">
|
|
{trip.destination}{trip.destination && trip.start_date ? ' · ' : ''}{trip.start_date}
|
|
</div>
|
|
)}
|
|
</div>
|
|
<button
|
|
onClick={() => handleAdd(trip.id)}
|
|
disabled={adding === trip.id}
|
|
className="px-3 py-1.5 rounded-lg text-[11px] font-semibold bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 hover:bg-zinc-700 dark:hover:bg-zinc-200 disabled:opacity-50"
|
|
>
|
|
{adding === trip.id ? '...' : t('journey.trips.link')}
|
|
</button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// ── Contributor Invite Dialog ─────────────────────────────────────────────
|
|
|
|
function ContributorInviteDialog({ journeyId, existingUserIds, onClose, onInvited }: {
|
|
journeyId: number
|
|
existingUserIds: number[]
|
|
onClose: () => void
|
|
onInvited: () => void
|
|
}) {
|
|
const { t } = useTranslation()
|
|
const [users, setUsers] = useState<{ id: number; username: string; email: string; avatar?: string | null }[]>([])
|
|
const [search, setSearch] = useState('')
|
|
const [selectedUserId, setSelectedUserId] = useState<number | null>(null)
|
|
const [role, setRole] = useState<'editor' | 'viewer'>('viewer')
|
|
const [sending, setSending] = useState(false)
|
|
const toast = useToast()
|
|
|
|
useEffect(() => {
|
|
authApi.listUsers().then(d => setUsers(d.users || [])).catch(() => {})
|
|
}, [])
|
|
|
|
const filtered = users.filter(u => {
|
|
if (existingUserIds.includes(u.id)) return false
|
|
if (!search) return true
|
|
const q = search.toLowerCase()
|
|
return u.username.toLowerCase().includes(q) || u.email.toLowerCase().includes(q)
|
|
})
|
|
|
|
const handleInvite = async () => {
|
|
if (!selectedUserId) return
|
|
setSending(true)
|
|
try {
|
|
await journeyApi.addContributor(journeyId, selectedUserId, role)
|
|
toast.success(t('journey.contributors.added'))
|
|
onInvited()
|
|
} catch {
|
|
toast.error(t('journey.contributors.addFailed'))
|
|
} finally {
|
|
setSending(false)
|
|
}
|
|
}
|
|
|
|
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="bg-white dark:bg-zinc-900 rounded-2xl shadow-[0_20px_40px_rgba(0,0,0,0.2)] max-w-[420px] w-full flex flex-col overflow-hidden">
|
|
|
|
<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.contributors.invite')}</h2>
|
|
<button onClick={onClose} className="w-8 h-8 rounded-lg flex items-center justify-center text-zinc-400 hover:bg-zinc-100 dark:hover:bg-zinc-800">
|
|
<X size={16} />
|
|
</button>
|
|
</div>
|
|
|
|
<div className="px-6 py-5 flex flex-col gap-4">
|
|
{/* Search */}
|
|
<div>
|
|
<label className="text-[10px] font-semibold tracking-[0.12em] uppercase text-zinc-500 block mb-1.5">{t('journey.contributors.searchUser')}</label>
|
|
<input
|
|
value={search}
|
|
onChange={e => setSearch(e.target.value)}
|
|
placeholder={t('journey.contributors.searchPlaceholder')}
|
|
className="w-full px-3 py-2 border border-zinc-200 dark:border-zinc-700 rounded-lg text-[13px] bg-white dark:bg-zinc-800 text-zinc-900 dark:text-white outline-none focus:border-zinc-400 dark:focus:border-zinc-500"
|
|
/>
|
|
</div>
|
|
|
|
{/* User list */}
|
|
<div className="max-h-[200px] overflow-y-auto flex flex-col gap-1">
|
|
{filtered.length === 0 && (
|
|
<p className="text-[12px] text-zinc-400 text-center py-4">{t('journey.contributors.noUsers')}</p>
|
|
)}
|
|
{filtered.map(u => (
|
|
<div
|
|
key={u.id}
|
|
onClick={() => setSelectedUserId(u.id)}
|
|
className={`flex items-center gap-2.5 p-2.5 rounded-lg cursor-pointer transition-all ${
|
|
selectedUserId === u.id
|
|
? 'bg-zinc-100 dark:bg-zinc-800 border border-zinc-900 dark:border-white'
|
|
: 'hover:bg-zinc-50 dark:hover:bg-zinc-800 border border-transparent'
|
|
}`}
|
|
>
|
|
<div className="w-8 h-8 rounded-full bg-zinc-200 dark:bg-zinc-700 text-zinc-600 dark:text-zinc-300 flex items-center justify-center text-[12px] font-semibold">
|
|
{u.username[0].toUpperCase()}
|
|
</div>
|
|
<div className="flex-1 min-w-0">
|
|
<div className="text-[13px] font-medium text-zinc-900 dark:text-white">{u.username}</div>
|
|
<div className="text-[11px] text-zinc-500 truncate">{u.email}</div>
|
|
</div>
|
|
{selectedUserId === u.id && (
|
|
<div className="w-5 h-5 rounded-full bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 flex items-center justify-center">
|
|
<Check size={12} />
|
|
</div>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
{/* Role selector */}
|
|
<div>
|
|
<label className="text-[10px] font-semibold tracking-[0.12em] uppercase text-zinc-500 block mb-2">{t('journey.invite.role')}</label>
|
|
<div className="flex gap-2">
|
|
{(['viewer', 'editor'] as const).map(r => (
|
|
<button
|
|
key={r}
|
|
onClick={() => setRole(r)}
|
|
className={`flex-1 py-2 rounded-lg text-[12px] font-medium border transition-all ${
|
|
role === r
|
|
? 'bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 border-zinc-900 dark:border-white'
|
|
: 'border-zinc-200 dark:border-zinc-700 text-zinc-500 hover:border-zinc-400'
|
|
}`}
|
|
>
|
|
{t(`journey.invite.${r}`)}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex items-center justify-end gap-2 px-6 py-4 border-t border-zinc-200 dark:border-zinc-700 bg-zinc-50 dark:bg-zinc-800/50">
|
|
<button onClick={onClose} className="px-3.5 py-2 rounded-lg border border-zinc-200 dark:border-zinc-600 text-[13px] font-medium text-zinc-700 dark:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-zinc-700">
|
|
{t('common.cancel')}
|
|
</button>
|
|
<button
|
|
onClick={handleInvite}
|
|
disabled={!selectedUserId || sending}
|
|
className="px-3.5 py-2 rounded-lg bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 text-[13px] font-medium hover:bg-zinc-800 dark:hover:bg-zinc-100 disabled:opacity-40 disabled:cursor-not-allowed"
|
|
>
|
|
{sending ? t('journey.invite.inviting') : t('journey.invite.invite')}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// ── Journey Settings Dialog ───────────────────────────────────────────────
|
|
|
|
// ── Journey Share Section ─────────────────────────────────────────────────
|
|
|
|
function JourneyShareSection({ journeyId }: { journeyId: number }) {
|
|
const { t } = useTranslation()
|
|
const [link, setLink] = useState<{ token: string; share_timeline: boolean; share_gallery: boolean; share_map: boolean } | null>(null)
|
|
const [loading, setLoading] = useState(true)
|
|
const [copied, setCopied] = useState(false)
|
|
const toast = useToast()
|
|
|
|
useEffect(() => {
|
|
journeyApi.getShareLink(journeyId).then(d => setLink(d.link || null)).catch(() => {}).finally(() => setLoading(false))
|
|
}, [journeyId])
|
|
|
|
const createLink = async () => {
|
|
try {
|
|
const res = await journeyApi.createShareLink(journeyId, { share_timeline: true, share_gallery: true, share_map: true })
|
|
setLink({ token: res.token, share_timeline: true, share_gallery: true, share_map: true })
|
|
toast.success(t('journey.share.linkCreated'))
|
|
} catch { toast.error(t('journey.share.createFailed')) }
|
|
}
|
|
|
|
const togglePerm = async (key: 'share_timeline' | 'share_gallery' | 'share_map') => {
|
|
if (!link) return
|
|
const updated = { ...link, [key]: !link[key] }
|
|
setLink(updated)
|
|
try {
|
|
await journeyApi.createShareLink(journeyId, { share_timeline: updated.share_timeline, share_gallery: updated.share_gallery, share_map: updated.share_map })
|
|
} catch { setLink(link); toast.error(t('journey.share.updateFailed')) }
|
|
}
|
|
|
|
const deleteLink = async () => {
|
|
try {
|
|
await journeyApi.deleteShareLink(journeyId)
|
|
setLink(null)
|
|
toast.success(t('journey.share.linkDeleted'))
|
|
} catch { toast.error(t('journey.share.deleteFailed')) }
|
|
}
|
|
|
|
const shareUrl = link ? `${window.location.origin}/public/journey/${link.token}` : ''
|
|
|
|
const copyLink = () => {
|
|
navigator.clipboard.writeText(shareUrl)
|
|
setCopied(true)
|
|
setTimeout(() => setCopied(false), 2000)
|
|
}
|
|
|
|
if (loading) return null
|
|
|
|
return (
|
|
<div>
|
|
<label className="text-[10px] font-semibold tracking-[0.12em] uppercase text-zinc-500 block mb-2">{t('journey.share.publicShare')}</label>
|
|
|
|
{!link ? (
|
|
<button
|
|
onClick={createLink}
|
|
className="w-full flex items-center justify-center gap-1.5 py-2.5 rounded-lg border border-dashed border-zinc-300 dark:border-zinc-600 text-[12px] font-medium text-zinc-500 hover:border-zinc-400 hover:text-zinc-700 dark:hover:border-zinc-500 dark:hover:text-zinc-300 transition-colors"
|
|
>
|
|
<Link size={14} /> {t('journey.share.createLink')}
|
|
</button>
|
|
) : (
|
|
<div className="flex flex-col gap-3">
|
|
{/* URL + Copy */}
|
|
<div className="flex items-center gap-2 p-2.5 rounded-lg bg-zinc-50 dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700">
|
|
<Link size={13} className="text-zinc-400 flex-shrink-0" />
|
|
<span className="flex-1 text-[11px] text-zinc-600 dark:text-zinc-400 truncate">{shareUrl}</span>
|
|
<button
|
|
onClick={copyLink}
|
|
className="flex-shrink-0 px-2.5 py-1 rounded-md bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 text-[11px] font-medium hover:bg-zinc-700 dark:hover:bg-zinc-200"
|
|
>
|
|
{copied ? t('journey.share.copied') : t('journey.share.copy')}
|
|
</button>
|
|
</div>
|
|
|
|
{/* Permission toggles */}
|
|
<div className="flex flex-col gap-1.5">
|
|
{[
|
|
{ key: 'share_timeline' as const, label: t('journey.share.timeline'), icon: List },
|
|
{ key: 'share_gallery' as const, label: t('journey.share.gallery'), icon: Grid },
|
|
{ key: 'share_map' as const, label: t('journey.share.map'), icon: MapPin },
|
|
].map(({ key, label, icon: Icon }) => (
|
|
<button
|
|
key={key}
|
|
onClick={() => togglePerm(key)}
|
|
className={`flex items-center gap-2.5 px-3 py-2 rounded-lg border text-[12px] font-medium transition-all ${
|
|
link[key]
|
|
? 'border-zinc-900 dark:border-white bg-zinc-900 dark:bg-white text-white dark:text-zinc-900'
|
|
: 'border-zinc-200 dark:border-zinc-700 text-zinc-500 hover:border-zinc-400'
|
|
}`}
|
|
>
|
|
<Icon size={13} />
|
|
{label}
|
|
{link[key] && <Check size={12} className="ml-auto" />}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{/* Delete link */}
|
|
<button
|
|
onClick={deleteLink}
|
|
className="text-[11px] font-medium text-red-500 hover:text-red-600 self-start"
|
|
>
|
|
Remove share link
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function JourneySettingsDialog({ journey, onClose, onSaved, onOpenInvite }: {
|
|
journey: JourneyDetail
|
|
onClose: () => void
|
|
onSaved: () => void
|
|
onOpenInvite: () => void
|
|
}) {
|
|
const { t } = useTranslation()
|
|
const [title, setTitle] = useState(journey.title)
|
|
const [subtitle, setSubtitle] = useState(journey.subtitle || '')
|
|
const [saving, setSaving] = useState(false)
|
|
const [showAddTrip, setShowAddTrip] = useState(false)
|
|
const [unlinkTarget, setUnlinkTarget] = useState<{ trip_id: number; title: string } | null>(null)
|
|
const coverRef = useRef<HTMLInputElement>(null)
|
|
const toast = useToast()
|
|
const navigate = useNavigate()
|
|
const { updateJourney, deleteJourney } = useJourneyStore()
|
|
|
|
const handleSave = async () => {
|
|
setSaving(true)
|
|
try {
|
|
await updateJourney(journey.id, { title, subtitle: subtitle || null })
|
|
onSaved()
|
|
} catch {
|
|
toast.error(t('journey.settings.saveFailed'))
|
|
} finally {
|
|
setSaving(false)
|
|
}
|
|
}
|
|
|
|
const handleCoverUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
const file = e.target.files?.[0]
|
|
if (!file) return
|
|
const formData = new FormData()
|
|
formData.append('cover', file)
|
|
try {
|
|
await journeyApi.uploadCover(journey.id, formData)
|
|
toast.success(t('journey.settings.coverUpdated'))
|
|
onSaved()
|
|
} catch {
|
|
toast.error(t('journey.settings.coverFailed'))
|
|
}
|
|
}
|
|
|
|
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
|
|
|
|
const handleDelete = async () => {
|
|
try {
|
|
await deleteJourney(journey.id)
|
|
navigate('/journey')
|
|
} catch {
|
|
toast.error(t('journey.settings.failedToDelete'))
|
|
}
|
|
}
|
|
|
|
return (
|
|
<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-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" style={{ paddingBottom: 'var(--bottom-nav-h)' }} onClick={e => e.stopPropagation()}>
|
|
|
|
<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>
|
|
<button onClick={onClose} className="w-8 h-8 rounded-lg flex items-center justify-center text-zinc-400 hover:bg-zinc-100 dark:hover:bg-zinc-800">
|
|
<X size={16} />
|
|
</button>
|
|
</div>
|
|
|
|
<div className="flex-1 overflow-y-auto overscroll-contain px-6 py-5 flex flex-col gap-5">
|
|
{/* Cover Image */}
|
|
<div>
|
|
<label className="text-[10px] font-semibold tracking-[0.12em] uppercase text-zinc-500 block mb-2">{t('journey.settings.coverImage')}</label>
|
|
<input ref={coverRef} type="file" accept="image/*" onChange={handleCoverUpload} className="hidden" />
|
|
<button
|
|
onClick={() => coverRef.current?.click()}
|
|
className="w-full h-28 rounded-xl border border-dashed border-zinc-200 dark:border-zinc-700 flex items-center justify-center gap-2 text-[12px] text-zinc-500 hover:border-zinc-400 dark:hover:border-zinc-500 hover:bg-zinc-50 dark:hover:bg-zinc-800 overflow-hidden relative"
|
|
>
|
|
{journey.cover_image ? (
|
|
<>
|
|
<img src={`/uploads/${journey.cover_image}`} className="absolute inset-0 w-full h-full object-cover opacity-50" alt="" />
|
|
<span className="relative z-10 flex items-center gap-1.5"><ImagePlus size={14} /> {t('journey.settings.changeCover')}</span>
|
|
</>
|
|
) : (
|
|
<span className="flex items-center gap-1.5"><ImagePlus size={14} /> {t('journey.settings.addCover')}</span>
|
|
)}
|
|
</button>
|
|
</div>
|
|
|
|
{/* Title */}
|
|
<div>
|
|
<label className="text-[10px] font-semibold tracking-[0.12em] uppercase text-zinc-500 block mb-1.5">{t('journey.settings.name')}</label>
|
|
<input
|
|
value={title}
|
|
onChange={e => setTitle(e.target.value)}
|
|
className="w-full px-3 py-2 border border-zinc-200 dark:border-zinc-700 rounded-lg text-[14px] bg-white dark:bg-zinc-800 text-zinc-900 dark:text-white outline-none focus:border-zinc-400"
|
|
/>
|
|
</div>
|
|
|
|
{/* Subtitle */}
|
|
<div>
|
|
<label className="text-[10px] font-semibold tracking-[0.12em] uppercase text-zinc-500 block mb-1.5">{t('journey.settings.subtitle')}</label>
|
|
<input
|
|
value={subtitle}
|
|
onChange={e => setSubtitle(e.target.value)}
|
|
placeholder={t('journey.settings.subtitlePlaceholder')}
|
|
className="w-full px-3 py-2 border border-zinc-200 dark:border-zinc-700 rounded-lg text-[14px] bg-white dark:bg-zinc-800 text-zinc-900 dark:text-white outline-none focus:border-zinc-400"
|
|
/>
|
|
</div>
|
|
|
|
<div className="h-px bg-zinc-200 dark:bg-zinc-700" />
|
|
|
|
{/* Synced Trips */}
|
|
<div>
|
|
<label className="text-[10px] font-semibold tracking-[0.12em] uppercase text-zinc-500 block mb-2">{t('journey.detail.syncedTrips')}</label>
|
|
<div className="flex flex-col gap-1.5">
|
|
{journey.trips.map((trip: any) => (
|
|
<div key={trip.trip_id} className="flex items-center gap-2.5 p-2 rounded-lg bg-zinc-50 dark:bg-zinc-800">
|
|
<div className="w-8 h-8 rounded-md flex-shrink-0" style={{ background: pickGradient(trip.trip_id) }} />
|
|
<div className="flex-1 min-w-0">
|
|
<div className="text-[12px] font-medium text-zinc-900 dark:text-white">{trip.title}</div>
|
|
<div className="text-[10px] text-zinc-500">{trip.place_count || 0} {t('journey.synced.places')}</div>
|
|
</div>
|
|
<button
|
|
onClick={() => setUnlinkTarget({ trip_id: trip.trip_id, title: trip.title })}
|
|
className="w-8 h-8 rounded-lg flex-shrink-0 flex items-center justify-center bg-red-500/10 text-red-500 hover:bg-red-500/20 dark:bg-red-500/15 dark:hover:bg-red-500/25 transition-colors"
|
|
title="Unlink trip"
|
|
>
|
|
<Trash2 size={14} />
|
|
</button>
|
|
</div>
|
|
))}
|
|
{journey.trips.length === 0 && <p className="text-[11px] text-zinc-400">{t('journey.trips.noTripsLinkedSettings')}</p>}
|
|
<button
|
|
onClick={() => setShowAddTrip(true)}
|
|
className="w-full mt-1 flex items-center justify-center gap-1.5 py-2.5 rounded-lg border border-dashed border-zinc-300 dark:border-zinc-600 text-[12px] font-medium text-zinc-500 hover:border-zinc-400 hover:text-zinc-700 dark:hover:border-zinc-500 dark:hover:text-zinc-300 transition-colors"
|
|
>
|
|
<Plus size={14} /> {t('journey.trips.addTrip')}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Contributors */}
|
|
<div>
|
|
<label className="text-[10px] font-semibold tracking-[0.12em] uppercase text-zinc-500 block mb-2">{t('journey.detail.contributors')}</label>
|
|
<div className="flex flex-col gap-2">
|
|
{journey.contributors.map((c: any) => (
|
|
<div key={c.user_id} className="flex items-center gap-2.5">
|
|
<div className="w-7 h-7 rounded-full bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 flex items-center justify-center text-[11px] font-semibold">
|
|
{(c.username || '?')[0].toUpperCase()}
|
|
</div>
|
|
<div className="flex-1 text-[12px] font-medium text-zinc-900 dark:text-white">{c.username}</div>
|
|
<span className={`text-[9px] font-medium px-1.5 py-0.5 rounded-full ${c.role === 'owner' ? 'bg-zinc-900 dark:bg-white text-white dark:text-zinc-900' : 'bg-zinc-100 dark:bg-zinc-800 text-zinc-500'}`}>{c.role}</span>
|
|
</div>
|
|
))}
|
|
<button
|
|
onClick={onOpenInvite}
|
|
className="w-full mt-1 flex items-center justify-center gap-1.5 py-2.5 rounded-lg border border-dashed border-zinc-300 dark:border-zinc-600 text-[12px] font-medium text-zinc-500 hover:border-zinc-400 hover:text-zinc-700 dark:hover:border-zinc-500 dark:hover:text-zinc-300 transition-colors"
|
|
>
|
|
<UserPlus size={14} /> {t('journey.contributors.invite')}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="h-px bg-zinc-200 dark:bg-zinc-700" />
|
|
|
|
{/* Public Share */}
|
|
<JourneyShareSection journeyId={journey.id} />
|
|
|
|
</div>
|
|
|
|
{/* Footer */}
|
|
<div className="flex flex-wrap 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
|
|
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"
|
|
>
|
|
<Trash2 size={13} />
|
|
{t('journey.settings.delete')}
|
|
</button>
|
|
<button onClick={onClose} className="px-3.5 py-2 rounded-lg border border-zinc-200 dark:border-zinc-600 text-[13px] font-medium text-zinc-700 dark:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-zinc-700">{t('common.cancel')}</button>
|
|
<button onClick={handleSave} disabled={saving || !title.trim()} className="px-3.5 py-2 rounded-lg bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 text-[13px] font-medium hover:bg-zinc-800 dark:hover:bg-zinc-100 disabled:opacity-40">
|
|
{saving ? t('common.saving') : t('common.save')}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Unlink Trip confirm */}
|
|
<ConfirmDialog
|
|
isOpen={!!unlinkTarget}
|
|
onClose={() => setUnlinkTarget(null)}
|
|
onConfirm={async () => {
|
|
if (!unlinkTarget) return
|
|
try {
|
|
await journeyApi.removeTrip(journey.id, unlinkTarget.trip_id)
|
|
toast.success(t('journey.trips.tripUnlinked'))
|
|
setUnlinkTarget(null)
|
|
onSaved()
|
|
} catch {
|
|
toast.error(t('journey.trips.unlinkFailed'))
|
|
}
|
|
}}
|
|
title={t('journey.trips.unlinkTrip')}
|
|
message={t('journey.trips.unlinkMessage', { title: unlinkTarget?.title })}
|
|
confirmLabel={t('journey.trips.unlink')}
|
|
danger
|
|
/>
|
|
|
|
{/* Add Trip */}
|
|
{showAddTrip && (
|
|
<AddTripDialog
|
|
journeyId={journey.id}
|
|
existingTripIds={journey.trips.map((t: any) => t.trip_id)}
|
|
onClose={() => setShowAddTrip(false)}
|
|
onAdded={() => { setShowAddTrip(false); onSaved() }}
|
|
/>
|
|
)}
|
|
|
|
<ConfirmDialog
|
|
isOpen={showDeleteConfirm}
|
|
onClose={() => setShowDeleteConfirm(false)}
|
|
onConfirm={handleDelete}
|
|
title={t('journey.settings.deleteJourney')}
|
|
message={t('journey.settings.deleteMessage', { title: journey.title })}
|
|
confirmLabel={t('common.delete')}
|
|
danger
|
|
/>
|
|
</div>
|
|
)
|
|
}
|