mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-22 06:41:46 +00:00
feat: Journey addon — travel journal with entries, photos, public sharing & PDF export
- 5-table schema (journeys, entries, photos, trips, contributors) with migrations 87-91 - Trip-to-Journey sync engine with skeleton entries and photo sync - Full CRUD API for journeys, entries, photos with Immich/Synology integration - Timeline, Gallery and Map views with entry editor (markdown, mood, weather, pros/cons) - Journey frontpage with hero card, stats and trip suggestions - Public share links with token-based access and photo proxy - PDF photo book export (Polarsteps-inspired) - Dashboard redesign: mobile greeting, live trip hero, quick actions, unified card design - BottomNav profile sheet with settings/admin/logout - DayPlan mobile inline place picker - TripFormModal members management - Vacay calendar trip date indicator dots - Fix contributor photo access (403) for journey Immich/Synology photos - Trip deletion cleanup for journey skeleton entries - i18n: 231 new keys across all 14 languages (native translations, no fallbacks)
This commit is contained in:
@@ -756,7 +756,7 @@ export default function AtlasPage(): React.ReactElement {
|
||||
return (
|
||||
<div className="min-h-screen" style={{ background: 'var(--bg-primary)' }}>
|
||||
<Navbar />
|
||||
<div style={{ position: 'fixed', top: 'var(--nav-h)', left: 0, right: 0, bottom: 0 }}>
|
||||
<div style={{ position: 'fixed', top: 'var(--nav-h)', left: 0, right: 0, bottom: 'env(safe-area-inset-bottom, 0px)' }}>
|
||||
{/* Map */}
|
||||
<div ref={mapRef} style={{ position: 'absolute', inset: 0, zIndex: 1, background: dark ? '#1a1a2e' : '#f0f0f0' }} />
|
||||
|
||||
@@ -773,7 +773,7 @@ export default function AtlasPage(): React.ReactElement {
|
||||
}} />
|
||||
<div
|
||||
className="absolute z-20 flex justify-center"
|
||||
style={{ top: 14, left: 0, right: 0, pointerEvents: 'none' }}
|
||||
style={{ top: 'calc(env(safe-area-inset-top, 0px) + 14px)', left: 0, right: 0, pointerEvents: 'none' }}
|
||||
>
|
||||
<div style={{ width: 'min(520px, calc(100vw - 28px))', pointerEvents: 'auto' }}>
|
||||
<div style={{
|
||||
@@ -896,7 +896,7 @@ export default function AtlasPage(): React.ReactElement {
|
||||
</div>
|
||||
|
||||
{/* Mobile: Bottom bar */}
|
||||
<div className="md:hidden absolute bottom-3 left-0 right-0 z-10 flex justify-center" style={{ touchAction: 'manipulation' }}>
|
||||
<div className="md:hidden absolute left-0 right-0 z-10 flex justify-center" style={{ bottom: 'calc(84px + env(safe-area-inset-bottom, 0px) + 8px)', touchAction: 'manipulation' }}>
|
||||
<div className="flex items-center gap-4 px-5 py-4 rounded-2xl"
|
||||
style={{ background: dark ? 'rgba(0,0,0,0.45)' : 'rgba(255,255,255,0.5)', backdropFilter: 'blur(16px)' }}>
|
||||
{/* Countries highlighted */}
|
||||
|
||||
+511
-187
@@ -15,7 +15,7 @@ import { useToast } from '../components/shared/Toast'
|
||||
import {
|
||||
Plus, Calendar, Trash2, Edit2, Map, ChevronDown, ChevronUp,
|
||||
Archive, ArchiveRestore, Clock, MapPin, Settings, X, ArrowRightLeft, Users,
|
||||
LayoutGrid, List, Copy,
|
||||
LayoutGrid, List, Copy, Bell,
|
||||
} from 'lucide-react'
|
||||
import { useCanDo } from '../store/permissionsStore'
|
||||
|
||||
@@ -151,180 +151,312 @@ interface TripCardProps {
|
||||
dark?: boolean
|
||||
}
|
||||
|
||||
function SpotlightCard({ trip, onEdit, onCopy, onDelete, onArchive, onClick, t, locale, dark }: TripCardProps): React.ReactElement {
|
||||
function SpotlightCard({ trip, onEdit, onCopy, onDelete, onArchive, onClick, t, locale }: TripCardProps): React.ReactElement {
|
||||
const status = getTripStatus(trip)
|
||||
const isLive = status === 'ongoing'
|
||||
const today = new Date().toISOString().split('T')[0]
|
||||
const startDate = trip.start_date || today
|
||||
const endDate = trip.end_date || today
|
||||
const totalDays = Math.max(1, Math.ceil((new Date(endDate).getTime() - new Date(startDate).getTime()) / 86400000) + 1)
|
||||
const currentDay = Math.min(totalDays, Math.ceil((new Date(today).getTime() - new Date(startDate).getTime()) / 86400000) + 1)
|
||||
const daysLeft = Math.max(0, totalDays - currentDay)
|
||||
const progress = Math.round((currentDay / totalDays) * 100)
|
||||
|
||||
const coverBg = trip.cover_image
|
||||
? `url(${trip.cover_image}) center/cover no-repeat`
|
||||
: tripGradient(trip.id)
|
||||
|
||||
return (
|
||||
<LiquidGlass dark={dark} style={{ marginBottom: 32, borderRadius: 20, boxShadow: '0 8px 40px rgba(0,0,0,0.13)', cursor: 'pointer' }}
|
||||
onClick={() => onClick(trip)}>
|
||||
{/* Cover / Background */}
|
||||
<div style={{ height: 300, background: coverBg, position: 'relative' }}>
|
||||
<div style={{
|
||||
position: 'absolute', inset: 0,
|
||||
background: 'linear-gradient(to top, rgba(0,0,0,0.78) 0%, rgba(0,0,0,0.25) 50%, rgba(0,0,0,0.1) 100%)',
|
||||
}} />
|
||||
|
||||
{/* Badges top-left */}
|
||||
<div style={{ position: 'absolute', top: 16, left: 16, display: 'flex', gap: 8 }}>
|
||||
{status && (
|
||||
<span style={{
|
||||
background: 'rgba(255,255,255,0.15)', backdropFilter: 'blur(8px)',
|
||||
color: 'white', fontSize: 12, fontWeight: 700,
|
||||
padding: '5px 12px', borderRadius: 99, border: '1px solid rgba(255,255,255,0.25)',
|
||||
display: 'flex', alignItems: 'center', gap: 6,
|
||||
}}>
|
||||
{status === 'ongoing' && (
|
||||
<span style={{ width: 7, height: 7, borderRadius: '50%', background: '#ef4444', animation: 'blink 1s ease-in-out infinite', display: 'inline-block', flexShrink: 0 }} />
|
||||
)}
|
||||
{status === 'ongoing' ? t('dashboard.status.ongoing')
|
||||
: status === 'today' ? t('dashboard.status.today')
|
||||
: status === 'tomorrow' ? t('dashboard.status.tomorrow')
|
||||
: status === 'future' ? t('dashboard.status.daysLeft', { count: daysUntil(trip.start_date) })
|
||||
: t('dashboard.status.past')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Top-right actions */}
|
||||
{(onEdit || onCopy || onArchive || onDelete) && (
|
||||
<div style={{ position: 'absolute', top: 16, right: 16, display: 'flex', gap: 6 }}
|
||||
onClick={e => e.stopPropagation()}>
|
||||
{onEdit && <IconBtn onClick={() => onEdit(trip)} title={t('common.edit')}><Edit2 size={14} /></IconBtn>}
|
||||
{onCopy && <IconBtn onClick={() => onCopy(trip)} title={t('dashboard.copyTrip')}><Copy size={14} /></IconBtn>}
|
||||
{onArchive && <IconBtn onClick={() => onArchive(trip.id)} title={t('dashboard.archive')}><Archive size={14} /></IconBtn>}
|
||||
{onDelete && <IconBtn onClick={() => onDelete(trip)} title={t('common.delete')} danger><Trash2 size={14} /></IconBtn>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Bottom content */}
|
||||
<div style={{ position: 'absolute', bottom: 0, left: 0, right: 0, padding: '20px 24px' }}>
|
||||
<div style={{ fontSize: 11, fontWeight: 600, color: 'rgba(255,255,255,0.65)', textTransform: 'uppercase', letterSpacing: '0.08em', marginBottom: 6 }}>
|
||||
{trip.is_owner ? t('dashboard.nextTrip') : t('dashboard.sharedBy', { name: trip.owner_username })}
|
||||
</div>
|
||||
<h2 style={{ margin: 0, fontSize: 26, fontWeight: 800, color: 'white', lineHeight: 1.2, textShadow: '0 1px 4px rgba(0,0,0,0.3)' }}>
|
||||
{trip.title}
|
||||
</h2>
|
||||
{trip.description && (
|
||||
<p style={{ margin: '6px 0 0', fontSize: 13.5, color: 'rgba(255,255,255,0.75)', lineHeight: 1.4, overflow: 'hidden', display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical' }}>
|
||||
{trip.description}
|
||||
</p>
|
||||
)}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 16, marginTop: 12 }}>
|
||||
{trip.start_date && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 5, color: 'rgba(255,255,255,0.8)', fontSize: 13 }}>
|
||||
<Calendar size={13} />
|
||||
{formatDateShort(trip.start_date, locale)}
|
||||
{trip.end_date && <> — {formatDateShort(trip.end_date, locale)}</>}
|
||||
</div>
|
||||
)}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 5, color: 'rgba(255,255,255,0.8)', fontSize: 13 }}>
|
||||
<Clock size={13} /> {trip.day_count || 0} {t('dashboard.days')}
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 5, color: 'rgba(255,255,255,0.8)', fontSize: 13 }}>
|
||||
<MapPin size={13} /> {trip.place_count || 0} {t('dashboard.places')}
|
||||
</div>
|
||||
<div className="hidden md:flex" style={{ alignItems: 'center', gap: 5, color: 'rgba(255,255,255,0.8)', fontSize: 13 }}>
|
||||
<Users size={13} /> {trip.shared_count+1 || 0} {t('dashboard.members')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</LiquidGlass>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Regular Trip Card ────────────────────────────────────────────────────────
|
||||
function TripCard({ trip, onEdit, onCopy, onDelete, onArchive, onClick, t, locale }: Omit<TripCardProps, 'dark'>): React.ReactElement {
|
||||
const status = getTripStatus(trip)
|
||||
const [hovered, setHovered] = useState(false)
|
||||
|
||||
const coverBg = trip.cover_image
|
||||
? `url(${trip.cover_image}) center/cover no-repeat`
|
||||
: tripGradient(trip.id)
|
||||
const badgeText = isLive ? t('dashboard.mobile.liveNow')
|
||||
: status === 'today' ? t('dashboard.mobile.startsToday')
|
||||
: status === 'tomorrow' ? t('dashboard.mobile.tomorrow')
|
||||
: status === 'future' ? t('dashboard.status.daysLeft', { count: daysUntil(trip.start_date) })
|
||||
: status === 'past' ? t('dashboard.mobile.completed')
|
||||
: null
|
||||
|
||||
return (
|
||||
<div
|
||||
onMouseEnter={() => setHovered(true)}
|
||||
onMouseLeave={() => setHovered(false)}
|
||||
onClick={() => onClick(trip)}
|
||||
style={{
|
||||
background: hovered ? 'var(--bg-tertiary)' : 'var(--bg-card)', borderRadius: 16, overflow: 'hidden', cursor: 'pointer',
|
||||
border: `1px solid ${hovered ? 'var(--text-faint)' : 'var(--border-primary)'}`, transition: 'all 0.18s',
|
||||
boxShadow: hovered ? '0 8px 28px rgba(0,0,0,0.15)' : '0 1px 4px rgba(0,0,0,0.04)',
|
||||
transform: hovered ? 'translateY(-2px)' : 'none',
|
||||
}}
|
||||
className="group relative rounded-3xl overflow-hidden cursor-pointer mb-8"
|
||||
style={{ minHeight: 340, boxShadow: '0 8px 40px rgba(0,0,0,0.13)' }}
|
||||
>
|
||||
{/* Image area */}
|
||||
<div style={{ height: 120, background: coverBg, position: 'relative', overflow: 'hidden' }}>
|
||||
{trip.cover_image && <div style={{ position: 'absolute', inset: 0, background: 'linear-gradient(to top, rgba(0,0,0,0.35) 0%, transparent 60%)' }} />}
|
||||
|
||||
{/* Status badge */}
|
||||
{status && (
|
||||
<div style={{ position: 'absolute', top: 8, left: 8 }}>
|
||||
<span style={{
|
||||
fontSize: 10.5, fontWeight: 700, padding: '2px 8px', borderRadius: 99,
|
||||
background: 'rgba(0,0,0,0.4)', color: 'white', backdropFilter: 'blur(4px)',
|
||||
display: 'flex', alignItems: 'center', gap: 5,
|
||||
}}>
|
||||
{status === 'ongoing' && (
|
||||
<span style={{ width: 6, height: 6, borderRadius: '50%', background: '#ef4444', animation: 'blink 1s ease-in-out infinite', display: 'inline-block', flexShrink: 0 }} />
|
||||
)}
|
||||
{status === 'ongoing' ? t('dashboard.status.ongoing')
|
||||
: status === 'today' ? t('dashboard.status.today')
|
||||
: status === 'tomorrow' ? t('dashboard.status.tomorrow')
|
||||
: status === 'future' ? t('dashboard.status.daysLeft', { count: daysUntil(trip.start_date) })
|
||||
: t('dashboard.status.past')}
|
||||
</span>
|
||||
</div>
|
||||
{/* Background */}
|
||||
<div className="absolute inset-0" style={{
|
||||
background: trip.cover_image ? undefined : tripGradient(trip.id),
|
||||
}}>
|
||||
{trip.cover_image && (
|
||||
<>
|
||||
<img src={trip.cover_image} className="w-full h-full object-cover" alt="" />
|
||||
<div className="absolute inset-0" style={{ background: 'linear-gradient(180deg, rgba(0,0,0,0.2) 0%, rgba(0,0,0,0.6) 100%)' }} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="absolute inset-0" style={{ background: 'linear-gradient(180deg, transparent 0%, transparent 40%, rgba(0,0,0,0.5) 100%)' }} />
|
||||
|
||||
{/* Content */}
|
||||
<div style={{ padding: '12px 14px 14px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, overflow: 'hidden', marginBottom: 3 }}>
|
||||
<span style={{ fontWeight: 700, fontSize: 14, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{trip.title}
|
||||
</span>
|
||||
<div className="relative p-6 flex flex-col text-white z-[2]" style={{ minHeight: 340 }}>
|
||||
{/* Top: badge + actions */}
|
||||
<div className="flex items-center justify-between mb-5">
|
||||
{badgeText ? (
|
||||
<span className="inline-flex items-center gap-1.5 px-3 py-1.5 bg-black/40 backdrop-blur-sm border border-white/15 rounded-full text-[10px] font-bold uppercase tracking-[0.1em]">
|
||||
{isLive ? (
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-red-500 shadow-[0_0_6px_rgba(239,68,68,0.8)] animate-pulse" />
|
||||
) : (
|
||||
<Clock size={10} />
|
||||
)}
|
||||
{badgeText}
|
||||
</span>
|
||||
) : <span />}
|
||||
<div className="flex gap-1.5 opacity-0 group-hover:opacity-100 transition-opacity" onClick={e => e.stopPropagation()}>
|
||||
{onEdit && <button onClick={() => onEdit(trip)} className="w-[34px] h-[34px] rounded-[10px] bg-white/12 backdrop-blur-sm border border-white/15 flex items-center justify-center text-white hover:bg-white/20 transition-colors"><Edit2 size={14} /></button>}
|
||||
{onCopy && <button onClick={() => onCopy(trip)} className="w-[34px] h-[34px] rounded-[10px] bg-white/12 backdrop-blur-sm border border-white/15 flex items-center justify-center text-white hover:bg-white/20 transition-colors"><Copy size={14} /></button>}
|
||||
{onArchive && <button onClick={() => onArchive(trip.id)} className="w-[34px] h-[34px] rounded-[10px] bg-white/12 backdrop-blur-sm border border-white/15 flex items-center justify-center text-white hover:bg-white/20 transition-colors"><Archive size={14} /></button>}
|
||||
{onDelete && <button onClick={() => onDelete(trip)} className="w-[34px] h-[34px] rounded-[10px] bg-white/12 backdrop-blur-sm border border-white/15 flex items-center justify-center text-red-300 hover:bg-red-500/20 transition-colors"><Trash2 size={14} /></button>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Title area — pushed to bottom */}
|
||||
<div className="flex-1 flex flex-col justify-end mb-4">
|
||||
{!trip.is_owner && (
|
||||
<span style={{ fontSize: 10, fontWeight: 600, color: 'var(--text-muted)', background: 'var(--bg-tertiary)', padding: '1px 6px', borderRadius: 99, whiteSpace: 'nowrap', flexShrink: 0 }}>
|
||||
{t('dashboard.shared')}
|
||||
<span className="inline-flex items-center gap-1 self-start px-2 py-0.5 bg-white/15 backdrop-blur-sm border border-white/15 rounded-full text-[9px] font-semibold uppercase tracking-[0.06em] mb-2">
|
||||
<Users size={9} /> {t('dashboard.sharedBy', { name: trip.owner_username })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{trip.description && (
|
||||
<p style={{ fontSize: 12, color: 'var(--text-faint)', margin: '0 0 8px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{trip.description}
|
||||
<h2 className="text-[32px] font-extrabold tracking-[-0.03em] leading-[0.95] mb-1.5">{trip.title}</h2>
|
||||
<p className="text-[12px] opacity-80 font-medium">
|
||||
{formatDateShort(trip.start_date, locale)} — {formatDateShort(trip.end_date, locale)}
|
||||
{isLive && <> · {t('journey.pdf.day')} {currentDay} / {totalDays}</>}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{(trip.start_date || trip.end_date) && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 12, color: 'var(--text-muted)', marginBottom: 10 }}>
|
||||
<Calendar size={11} style={{ flexShrink: 0 }} />
|
||||
{trip.start_date && trip.end_date
|
||||
? `${formatDateShort(trip.start_date, locale)} — ${formatDateShort(trip.end_date, locale)}`
|
||||
: formatDate(trip.start_date || trip.end_date, locale)}
|
||||
{/* Progress bar — only for live trips */}
|
||||
{isLive && (
|
||||
<div className="mb-4">
|
||||
<div className="flex justify-between text-[11px] font-semibold mb-1.5">
|
||||
<span className="opacity-85">{t('dashboard.mobile.tripProgress')}</span>
|
||||
<span className="opacity-70">{t('dashboard.mobile.daysLeft', { count: daysLeft })}</span>
|
||||
</div>
|
||||
<div className="h-1.5 bg-white/15 rounded-full overflow-hidden">
|
||||
<div className="h-full bg-white rounded-full relative" style={{ width: `${progress}%` }}>
|
||||
<span className="absolute right-0 top-1/2 -translate-y-1/2 w-3 h-3 bg-white rounded-full shadow-[0_0_12px_rgba(255,255,255,0.9)]" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'flex', gap: 8, marginBottom: 10 }}>
|
||||
<Stat label={t('dashboard.days')} value={trip.day_count || 0} />
|
||||
<Stat label={t('dashboard.places')} value={trip.place_count || 0} />
|
||||
<Stat label={t('dashboard.members')} value={trip.shared_count+1 || 0} />
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-4 gap-2.5 p-3.5 bg-black/25 backdrop-blur-sm border border-white/10 rounded-2xl">
|
||||
{trip.start_date && !isLive && (
|
||||
<div className="text-center">
|
||||
<p className="text-[18px] font-extrabold tracking-[-0.02em] leading-none">{formatDateShort(trip.start_date, locale)}</p>
|
||||
<p className="text-[9px] uppercase tracking-[0.1em] opacity-70 font-semibold mt-1">{t('dashboard.mobile.starts')}</p>
|
||||
</div>
|
||||
)}
|
||||
{isLive && (
|
||||
<div className="text-center">
|
||||
<p className="text-[22px] font-extrabold tracking-[-0.02em] leading-none">{totalDays}</p>
|
||||
<p className="text-[9px] uppercase tracking-[0.1em] opacity-70 font-semibold mt-1">{t('dashboard.mobile.duration')}</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="text-center">
|
||||
<p className="text-[22px] font-extrabold tracking-[-0.02em] leading-none">{trip.place_count || 0}</p>
|
||||
<p className="text-[9px] uppercase tracking-[0.1em] opacity-70 font-semibold mt-1">{t('dashboard.mobile.places')}</p>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<p className="text-[22px] font-extrabold tracking-[-0.02em] leading-none">{trip.shared_count || 0}</p>
|
||||
<p className="text-[9px] uppercase tracking-[0.1em] opacity-70 font-semibold mt-1">{t('dashboard.mobile.buddies')}</p>
|
||||
</div>
|
||||
{!isLive && (
|
||||
<div className="text-center">
|
||||
<p className="text-[22px] font-extrabold tracking-[-0.02em] leading-none">{trip.day_count || totalDays}</p>
|
||||
<p className="text-[9px] uppercase tracking-[0.1em] opacity-70 font-semibold mt-1">{t('dashboard.mobile.days')}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Mobile Trip Card (upcoming style) ────────────────────────────────────────
|
||||
function MobileTripCard({ trip, onEdit, onCopy, onDelete, onArchive, onClick, t, locale }: Omit<TripCardProps, 'dark'>): React.ReactElement {
|
||||
const status = getTripStatus(trip)
|
||||
const until = daysUntil(trip.start_date)
|
||||
const duration = trip.start_date && trip.end_date
|
||||
? Math.ceil((new Date(trip.end_date).getTime() - new Date(trip.start_date).getTime()) / 86400000) + 1
|
||||
: trip.day_count || null
|
||||
|
||||
const badgeText = status === 'ongoing' ? t('dashboard.mobile.ongoing')
|
||||
: status === 'today' ? t('dashboard.mobile.startsToday')
|
||||
: status === 'tomorrow' ? t('dashboard.mobile.tomorrow')
|
||||
: until && until > 0 ? (until < 30 ? t('dashboard.mobile.inDays', { count: until }) : until < 365 ? t('dashboard.mobile.inMonths', { count: Math.round(until / 30) }) : `In ${Math.round(until / 365)}y`)
|
||||
: status === 'past' ? t('dashboard.mobile.completed')
|
||||
: null
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={() => onClick?.(trip)}
|
||||
className="rounded-2xl border border-zinc-200 dark:border-zinc-700 overflow-hidden cursor-pointer transition-all hover:-translate-y-0.5 hover:shadow-md"
|
||||
style={{ background: 'var(--bg-card)' }}
|
||||
>
|
||||
{/* Cover */}
|
||||
<div className="relative h-[120px] overflow-hidden" style={{ background: trip.cover_image ? undefined : tripGradient(trip.id) }}>
|
||||
{trip.cover_image && (
|
||||
<img src={trip.cover_image} className="absolute inset-0 w-full h-full object-cover" alt="" />
|
||||
)}
|
||||
<div className="absolute inset-0" style={{ background: 'linear-gradient(180deg, transparent 30%, rgba(0,0,0,0.5) 100%)' }} />
|
||||
|
||||
{/* Action buttons top-right */}
|
||||
<div className="absolute top-3 right-3 z-[2] flex gap-1">
|
||||
{onEdit && <button onClick={e => { e.stopPropagation(); onEdit(trip) }} className="w-[30px] h-[30px] rounded-[8px] bg-black/30 backdrop-blur-sm border border-white/20 flex items-center justify-center text-white"><Edit2 size={12} /></button>}
|
||||
{onCopy && <button onClick={e => { e.stopPropagation(); onCopy(trip) }} className="w-[30px] h-[30px] rounded-[8px] bg-black/30 backdrop-blur-sm border border-white/20 flex items-center justify-center text-white"><Copy size={12} /></button>}
|
||||
{onArchive && <button onClick={e => { e.stopPropagation(); onArchive(trip.id) }} className="w-[30px] h-[30px] rounded-[8px] bg-black/30 backdrop-blur-sm border border-white/20 flex items-center justify-center text-white"><Archive size={12} /></button>}
|
||||
{onDelete && <button onClick={e => { e.stopPropagation(); onDelete(trip) }} className="w-[30px] h-[30px] rounded-[8px] bg-black/30 backdrop-blur-sm border border-white/20 flex items-center justify-center text-red-300"><Trash2 size={12} /></button>}
|
||||
</div>
|
||||
|
||||
{(onEdit || onCopy || onArchive || onDelete) && (
|
||||
<div style={{ display: 'flex', gap: 6, borderTop: '1px solid #f3f4f6', paddingTop: 10 }}
|
||||
onClick={e => e.stopPropagation()}>
|
||||
{onEdit && <CardAction onClick={() => onEdit(trip)} icon={<Edit2 size={12} />} label={t('common.edit')} />}
|
||||
{onCopy && <CardAction onClick={() => onCopy(trip)} icon={<Copy size={12} />} label={t('dashboard.copyTrip')} />}
|
||||
{onArchive && <CardAction onClick={() => onArchive(trip.id)} icon={<Archive size={12} />} label={t('dashboard.archive')} />}
|
||||
{onDelete && <CardAction onClick={() => onDelete(trip)} icon={<Trash2 size={12} />} label={t('common.delete')} danger />}
|
||||
</div>
|
||||
{/* Countdown badge */}
|
||||
{badgeText && (
|
||||
<div className="absolute top-3.5 left-3.5 z-[2]">
|
||||
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 bg-black/40 backdrop-blur-sm border border-white/15 rounded-full text-white text-[10px] font-bold uppercase tracking-[0.08em]">
|
||||
{status === 'ongoing' ? (
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-red-500 shadow-[0_0_6px_rgba(239,68,68,0.8)] animate-pulse" />
|
||||
) : (
|
||||
<Clock size={10} />
|
||||
)}
|
||||
{badgeText}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Title on cover */}
|
||||
<div className="absolute bottom-3.5 left-3.5 right-3.5 z-[2] text-white">
|
||||
<h3 className="text-[22px] font-extrabold tracking-[-0.02em] leading-none">{trip.title}</h3>
|
||||
{trip.description && (
|
||||
<p className="text-[11px] opacity-75 font-medium mt-1 truncate">{trip.description}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bottom stats */}
|
||||
<div className="flex items-center justify-between px-4 py-3">
|
||||
<div className="flex gap-[18px]">
|
||||
{trip.start_date && (
|
||||
<div className="flex flex-col gap-px">
|
||||
<span className="text-[13px] font-bold tracking-[-0.01em]" style={{ color: 'var(--text-primary)' }}>{formatDateShort(trip.start_date, locale)}</span>
|
||||
<span className="text-[9px] uppercase tracking-[0.06em] font-medium" style={{ color: 'var(--text-faint)' }}>{t('dashboard.mobile.starts')}</span>
|
||||
</div>
|
||||
)}
|
||||
{duration && (
|
||||
<div className="flex flex-col gap-px">
|
||||
<span className="text-[13px] font-bold tracking-[-0.01em]" style={{ color: 'var(--text-primary)' }}>{duration} {duration === 1 ? t('dashboard.mobile.day') : t('dashboard.mobile.days')}</span>
|
||||
<span className="text-[9px] uppercase tracking-[0.06em] font-medium" style={{ color: 'var(--text-faint)' }}>{t('dashboard.mobile.duration')}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col gap-px">
|
||||
<span className="text-[13px] font-bold tracking-[-0.01em]" style={{ color: 'var(--text-primary)' }}>{trip.place_count || 0}</span>
|
||||
<span className="text-[9px] uppercase tracking-[0.06em] font-medium" style={{ color: 'var(--text-faint)' }}>{t('dashboard.mobile.places')}</span>
|
||||
</div>
|
||||
{(trip.shared_count || 0) > 0 && (
|
||||
<div className="flex flex-col gap-px">
|
||||
<span className="text-[13px] font-bold tracking-[-0.01em]" style={{ color: 'var(--text-primary)' }}>{trip.shared_count}</span>
|
||||
<span className="text-[9px] uppercase tracking-[0.06em] font-medium" style={{ color: 'var(--text-faint)' }}>{t('dashboard.mobile.buddies')}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Regular Trip Card (matches mobile card design) ──────────────────────────
|
||||
function TripCard({ trip, onEdit, onCopy, onDelete, onArchive, onClick, t, locale }: Omit<TripCardProps, 'dark'>): React.ReactElement {
|
||||
const status = getTripStatus(trip)
|
||||
const until = daysUntil(trip.start_date)
|
||||
const duration = trip.start_date && trip.end_date
|
||||
? Math.ceil((new Date(trip.end_date).getTime() - new Date(trip.start_date).getTime()) / 86400000) + 1
|
||||
: trip.day_count || null
|
||||
|
||||
const badgeText = status === 'ongoing' ? t('dashboard.mobile.ongoing')
|
||||
: status === 'today' ? t('dashboard.mobile.startsToday')
|
||||
: status === 'tomorrow' ? t('dashboard.mobile.tomorrow')
|
||||
: until && until > 0 ? (until < 30 ? t('dashboard.mobile.inDays', { count: until }) : until < 365 ? t('dashboard.mobile.inMonths', { count: Math.round(until / 30) }) : `In ${Math.round(until / 365)}y`)
|
||||
: status === 'past' ? t('dashboard.mobile.completed')
|
||||
: null
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={() => onClick(trip)}
|
||||
className="group rounded-2xl border border-zinc-200 dark:border-zinc-700 overflow-hidden cursor-pointer transition-all hover:-translate-y-0.5 hover:shadow-lg hover:border-zinc-300 dark:hover:border-zinc-600"
|
||||
style={{ background: 'var(--bg-card)' }}
|
||||
>
|
||||
{/* Cover */}
|
||||
<div className="relative h-[140px] overflow-hidden" style={{ background: trip.cover_image ? undefined : tripGradient(trip.id) }}>
|
||||
{trip.cover_image && (
|
||||
<img src={trip.cover_image} className="absolute inset-0 w-full h-full object-cover" alt="" />
|
||||
)}
|
||||
<div className="absolute inset-0" style={{ background: 'linear-gradient(180deg, transparent 30%, rgba(0,0,0,0.55) 100%)' }} />
|
||||
|
||||
{/* Action buttons top-right — visible on hover */}
|
||||
<div className="absolute top-3 right-3 z-[2] flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
{onEdit && <button onClick={e => { e.stopPropagation(); onEdit(trip) }} className="w-[30px] h-[30px] rounded-[8px] bg-black/30 backdrop-blur-sm border border-white/20 flex items-center justify-center text-white hover:bg-black/50 transition-colors"><Edit2 size={12} /></button>}
|
||||
{onCopy && <button onClick={e => { e.stopPropagation(); onCopy(trip) }} className="w-[30px] h-[30px] rounded-[8px] bg-black/30 backdrop-blur-sm border border-white/20 flex items-center justify-center text-white hover:bg-black/50 transition-colors"><Copy size={12} /></button>}
|
||||
{onArchive && <button onClick={e => { e.stopPropagation(); onArchive(trip.id) }} className="w-[30px] h-[30px] rounded-[8px] bg-black/30 backdrop-blur-sm border border-white/20 flex items-center justify-center text-white hover:bg-black/50 transition-colors"><Archive size={12} /></button>}
|
||||
{onDelete && <button onClick={e => { e.stopPropagation(); onDelete(trip) }} className="w-[30px] h-[30px] rounded-[8px] bg-black/30 backdrop-blur-sm border border-white/20 flex items-center justify-center text-red-300 hover:bg-red-500/30 transition-colors"><Trash2 size={12} /></button>}
|
||||
</div>
|
||||
|
||||
{/* Status badge top-left */}
|
||||
{badgeText && (
|
||||
<div className="absolute top-3.5 left-3.5 z-[2]">
|
||||
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 bg-black/40 backdrop-blur-sm border border-white/15 rounded-full text-white text-[10px] font-bold uppercase tracking-[0.08em]">
|
||||
{status === 'ongoing' ? (
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-red-500 shadow-[0_0_6px_rgba(239,68,68,0.8)] animate-pulse" />
|
||||
) : (
|
||||
<Clock size={10} />
|
||||
)}
|
||||
{badgeText}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Shared badge */}
|
||||
{!trip.is_owner && (
|
||||
<div className="absolute top-3.5 right-3.5 z-[1] group-hover:opacity-0 transition-opacity">
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 bg-black/40 backdrop-blur-sm border border-white/15 rounded-full text-white text-[9px] font-semibold uppercase tracking-[0.06em]">
|
||||
<Users size={9} /> {t('dashboard.shared')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Title on cover */}
|
||||
<div className="absolute bottom-3.5 left-3.5 right-3.5 z-[2] text-white">
|
||||
<h3 className="text-[20px] font-extrabold tracking-[-0.02em] leading-tight">{trip.title}</h3>
|
||||
{trip.description && (
|
||||
<p className="text-[11px] opacity-75 font-medium mt-1 truncate">{trip.description}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bottom stats */}
|
||||
<div className="flex items-center justify-between px-4 py-3">
|
||||
<div className="flex gap-[18px]">
|
||||
{trip.start_date && (
|
||||
<div className="flex flex-col gap-px">
|
||||
<span className="text-[13px] font-bold tracking-[-0.01em]" style={{ color: 'var(--text-primary)' }}>{formatDateShort(trip.start_date, locale)}</span>
|
||||
<span className="text-[9px] uppercase tracking-[0.06em] font-medium" style={{ color: 'var(--text-faint)' }}>{t('dashboard.mobile.starts')}</span>
|
||||
</div>
|
||||
)}
|
||||
{duration && (
|
||||
<div className="flex flex-col gap-px">
|
||||
<span className="text-[13px] font-bold tracking-[-0.01em]" style={{ color: 'var(--text-primary)' }}>{duration} {duration === 1 ? t('dashboard.mobile.day') : t('dashboard.mobile.days')}</span>
|
||||
<span className="text-[9px] uppercase tracking-[0.06em] font-medium" style={{ color: 'var(--text-faint)' }}>{t('dashboard.mobile.duration')}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col gap-px">
|
||||
<span className="text-[13px] font-bold tracking-[-0.01em]" style={{ color: 'var(--text-primary)' }}>{trip.place_count || 0}</span>
|
||||
<span className="text-[9px] uppercase tracking-[0.06em] font-medium" style={{ color: 'var(--text-faint)' }}>{t('dashboard.mobile.places')}</span>
|
||||
</div>
|
||||
{(trip.shared_count || 0) > 0 && (
|
||||
<div className="flex flex-col gap-px">
|
||||
<span className="text-[13px] font-bold tracking-[-0.01em]" style={{ color: 'var(--text-primary)' }}>{trip.shared_count}</span>
|
||||
<span className="text-[9px] uppercase tracking-[0.06em] font-medium" style={{ color: 'var(--text-faint)' }}>{t('dashboard.mobile.buddies')}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -415,7 +547,7 @@ function TripListItem({ trip, onEdit, onCopy, onDelete, onArchive, onClick, t, l
|
||||
<MapPin size={11} /> {trip.place_count || 0}
|
||||
</div>
|
||||
<div className="hidden md:flex" style={{ alignItems: 'center', gap: 4, fontSize: 12, color: 'var(--text-muted)' }}>
|
||||
<Users size={11} /> {trip.shared_count+1 || 0}
|
||||
<Users size={11} /> {trip.shared_count || 0}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -553,7 +685,7 @@ export default function DashboardPage(): React.ReactElement {
|
||||
const [showForm, setShowForm] = useState<boolean>(false)
|
||||
const [editingTrip, setEditingTrip] = useState<DashboardTrip | null>(null)
|
||||
const [showArchived, setShowArchived] = useState<boolean>(false)
|
||||
const [showWidgetSettings, setShowWidgetSettings] = useState<boolean | 'mobile'>(false)
|
||||
const [showWidgetSettings, setShowWidgetSettings] = useState<boolean | 'mobile' | 'mobile-currency' | 'mobile-timezone'>(false)
|
||||
const [viewMode, setViewMode] = useState<'grid' | 'list'>(() => (localStorage.getItem('trek_dashboard_view') as 'grid' | 'list') || 'grid')
|
||||
const [deleteTrip, setDeleteTrip] = useState<DashboardTrip | null>(null)
|
||||
|
||||
@@ -568,7 +700,7 @@ export default function DashboardPage(): React.ReactElement {
|
||||
const navigate = useNavigate()
|
||||
const toast = useToast()
|
||||
const { t, locale } = useTranslation()
|
||||
const { demoMode } = useAuthStore()
|
||||
const { demoMode, user } = useAuthStore()
|
||||
const { settings, updateSetting } = useSettingsStore()
|
||||
const can = useCanDo()
|
||||
const dm = settings.dark_mode
|
||||
@@ -578,7 +710,7 @@ export default function DashboardPage(): React.ReactElement {
|
||||
const showSidebar = showCurrency || showTimezone
|
||||
|
||||
useEffect(() => {
|
||||
if (showWidgetSettings === 'mobile') {
|
||||
if (showWidgetSettings === 'mobile' || showWidgetSettings === 'mobile-currency' || showWidgetSettings === 'mobile-timezone') {
|
||||
document.body.style.overflow = 'hidden'
|
||||
} else {
|
||||
document.body.style.overflow = ''
|
||||
@@ -689,10 +821,183 @@ export default function DashboardPage(): React.ReactElement {
|
||||
<Navbar />
|
||||
{demoMode && <DemoBanner />}
|
||||
<div style={{ flex: 1, overflow: 'auto', overscrollBehavior: 'contain', marginTop: 'var(--nav-h)' }}>
|
||||
<div style={{ maxWidth: 1300, margin: '0 auto', padding: '32px 20px 60px' }}>
|
||||
<div style={{ maxWidth: 1300, margin: '0 auto', paddingTop: 32, paddingLeft: 20, paddingRight: 20, paddingBottom: 'calc(100px + env(safe-area-inset-bottom, 0px))' }}>
|
||||
|
||||
{/* Header */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 28 }}>
|
||||
{/* Mobile greeting header */}
|
||||
<div className="md:hidden flex items-center justify-between mb-5">
|
||||
<div>
|
||||
<p className="text-[12px] text-zinc-500 font-medium">{new Date().getHours() < 12 ? t('dashboard.greeting.morning') : new Date().getHours() < 18 ? t('dashboard.greeting.afternoon') : t('dashboard.greeting.evening')}</p>
|
||||
<p className="text-[22px] font-extrabold tracking-[-0.025em] leading-tight" style={{ color: 'var(--text-primary)' }}>{user?.username || t('nav.profile')}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => navigate('/notifications')}
|
||||
className="w-10 h-10 rounded-xl flex items-center justify-center relative"
|
||||
style={{ background: 'var(--bg-card)', border: '1px solid var(--border-primary)', color: 'var(--text-secondary)' }}
|
||||
>
|
||||
<Bell size={18} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => navigate('/settings')}
|
||||
className="w-10 h-10 rounded-full flex items-center justify-center text-[15px] font-bold text-white overflow-hidden"
|
||||
style={{ background: user?.avatar_url ? undefined : 'linear-gradient(135deg, #6366F1, #8B5CF6)' }}
|
||||
>
|
||||
{user?.avatar_url
|
||||
? <img src={user.avatar_url} className="w-full h-full object-cover" alt="" />
|
||||
: (user?.username || '?')[0].toUpperCase()
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile: Live Trip Hero */}
|
||||
{(() => {
|
||||
const liveTrip = trips.find(t => getTripStatus(t) === 'ongoing')
|
||||
if (!liveTrip) return null
|
||||
const today = new Date().toISOString().split('T')[0]
|
||||
const startDate = liveTrip.start_date || today
|
||||
const endDate = liveTrip.end_date || today
|
||||
const totalDays = Math.max(1, Math.ceil((new Date(endDate).getTime() - new Date(startDate).getTime()) / 86400000) + 1)
|
||||
const currentDay = Math.min(totalDays, Math.ceil((new Date(today).getTime() - new Date(startDate).getTime()) / 86400000) + 1)
|
||||
const daysLeft = Math.max(0, totalDays - currentDay)
|
||||
const progress = Math.round((currentDay / totalDays) * 100)
|
||||
|
||||
return (
|
||||
<div className="md:hidden mb-5">
|
||||
<div
|
||||
onClick={() => navigate(`/trips/${liveTrip.id}`)}
|
||||
className="relative rounded-3xl overflow-hidden cursor-pointer"
|
||||
style={{ minHeight: 340 }}
|
||||
>
|
||||
{/* Background */}
|
||||
<div className="absolute inset-0" style={{
|
||||
background: liveTrip.cover_image ? undefined : `radial-gradient(circle at 15% 20%, rgba(16,185,129,0.7), transparent 45%), radial-gradient(circle at 85% 80%, rgba(6,182,212,0.6), transparent 50%), radial-gradient(circle at 50% 50%, rgba(14,165,233,0.4), transparent 55%), linear-gradient(135deg, #064E3B 0%, #065F46 35%, #0E7490 75%, #164E63 100%)`
|
||||
}}>
|
||||
{liveTrip.cover_image && (
|
||||
<>
|
||||
<img src={liveTrip.cover_image} className="w-full h-full object-cover" alt="" />
|
||||
<div className="absolute inset-0" style={{ background: 'linear-gradient(180deg, rgba(0,0,0,0.2) 0%, rgba(0,0,0,0.6) 100%)' }} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="absolute inset-0" style={{ background: 'linear-gradient(180deg, transparent 0%, transparent 40%, rgba(0,0,0,0.5) 100%)' }} />
|
||||
|
||||
{/* Content */}
|
||||
<div className="relative p-5 flex flex-col text-white z-[2]" style={{ minHeight: 340 }}>
|
||||
{/* Top badges */}
|
||||
<div className="flex items-center justify-between mb-5">
|
||||
<span className="inline-flex items-center gap-1.5 px-3 py-1.5 bg-black/40 backdrop-blur-sm border border-white/15 rounded-full text-[10px] font-bold uppercase tracking-[0.1em]">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-red-500 shadow-[0_0_6px_rgba(239,68,68,0.8)] animate-pulse" />
|
||||
{t("dashboard.mobile.liveNow")}
|
||||
</span>
|
||||
<div className="flex gap-1.5">
|
||||
<button
|
||||
onClick={e => { e.stopPropagation(); setEditingTrip(liveTrip); setShowForm(true) }}
|
||||
className="w-[34px] h-[34px] rounded-[10px] bg-white/12 backdrop-blur-sm border border-white/15 flex items-center justify-center text-white hover:bg-white/20"
|
||||
>
|
||||
<Edit2 size={14} />
|
||||
</button>
|
||||
<button
|
||||
onClick={e => { e.stopPropagation(); handleCopy(liveTrip) }}
|
||||
className="w-[34px] h-[34px] rounded-[10px] bg-white/12 backdrop-blur-sm border border-white/15 flex items-center justify-center text-white hover:bg-white/20"
|
||||
>
|
||||
<Copy size={14} />
|
||||
</button>
|
||||
<button
|
||||
onClick={e => { e.stopPropagation(); handleArchive(liveTrip.id) }}
|
||||
className="w-[34px] h-[34px] rounded-[10px] bg-white/12 backdrop-blur-sm border border-white/15 flex items-center justify-center text-white hover:bg-white/20"
|
||||
>
|
||||
<Archive size={14} />
|
||||
</button>
|
||||
<button
|
||||
onClick={e => { e.stopPropagation(); handleDelete(liveTrip) }}
|
||||
className="w-[34px] h-[34px] rounded-[10px] bg-white/12 backdrop-blur-sm border border-white/15 flex items-center justify-center text-red-300 hover:bg-red-500/20"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Title area */}
|
||||
<div className="flex-1 flex flex-col justify-end mb-4">
|
||||
<h2 className="text-[32px] font-extrabold tracking-[-0.03em] leading-[0.95] mb-1.5">{liveTrip.title}</h2>
|
||||
<p className="text-[12px] opacity-80 font-medium">
|
||||
{formatDateShort(liveTrip.start_date)} — {formatDateShort(liveTrip.end_date)} · {t('journey.pdf.day')} {currentDay} / {totalDays}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Progress */}
|
||||
<div className="mb-4">
|
||||
<div className="flex justify-between text-[11px] font-semibold mb-1.5">
|
||||
<span className="opacity-85">{t('dashboard.mobile.tripProgress')}</span>
|
||||
<span className="opacity-70">{daysLeft} days left</span>
|
||||
</div>
|
||||
<div className="h-1.5 bg-white/15 rounded-full overflow-hidden">
|
||||
<div className="h-full bg-white rounded-full relative" style={{ width: `${progress}%` }}>
|
||||
<span className="absolute right-0 top-1/2 -translate-y-1/2 w-3 h-3 bg-white rounded-full shadow-[0_0_12px_rgba(255,255,255,0.9)]" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-2 gap-2.5 p-3.5 bg-black/25 backdrop-blur-sm border border-white/10 rounded-2xl">
|
||||
<div className="text-center">
|
||||
<p className="text-[22px] font-extrabold tracking-[-0.02em] leading-none">{liveTrip.place_count || 0}</p>
|
||||
<p className="text-[9px] uppercase tracking-[0.1em] opacity-70 font-semibold mt-1">Places</p>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<p className="text-[22px] font-extrabold tracking-[-0.02em] leading-none">{liveTrip.shared_count || 0}</p>
|
||||
<p className="text-[9px] uppercase tracking-[0.1em] opacity-70 font-semibold mt-1">Buddies</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
|
||||
{/* Mobile: Quick Actions */}
|
||||
<div className="md:hidden grid grid-cols-3 gap-2 mb-6">
|
||||
{can('trip_create') && (
|
||||
<button
|
||||
onClick={() => { setEditingTrip(null); setShowForm(true) }}
|
||||
className="flex flex-col items-center gap-2 py-3.5 rounded-2xl border border-zinc-200 dark:border-zinc-700"
|
||||
style={{ background: 'var(--bg-card)' }}
|
||||
>
|
||||
<div className="w-9 h-9 rounded-[11px] flex items-center justify-center" style={{ background: '#FEF3C7', color: '#B45309' }}>
|
||||
<Plus size={16} />
|
||||
</div>
|
||||
<span className="text-[10px] font-semibold" style={{ color: 'var(--text-primary)' }}>{t('dashboard.mobile.newTrip')}</span>
|
||||
</button>
|
||||
)}
|
||||
{showCurrency && (
|
||||
<button
|
||||
onClick={() => setShowWidgetSettings('mobile-currency')}
|
||||
className="flex flex-col items-center gap-2 py-3.5 rounded-2xl border border-zinc-200 dark:border-zinc-700"
|
||||
style={{ background: 'var(--bg-card)' }}
|
||||
>
|
||||
<div className="w-9 h-9 rounded-[11px] flex items-center justify-center" style={{ background: '#DBEAFE', color: '#1E40AF' }}>
|
||||
<ArrowRightLeft size={16} />
|
||||
</div>
|
||||
<span className="text-[10px] font-semibold" style={{ color: 'var(--text-primary)' }}>{t('dashboard.mobile.currency')}</span>
|
||||
</button>
|
||||
)}
|
||||
{showTimezone && (
|
||||
<button
|
||||
onClick={() => setShowWidgetSettings('mobile-timezone')}
|
||||
className="flex flex-col items-center gap-2 py-3.5 rounded-2xl border border-zinc-200 dark:border-zinc-700"
|
||||
style={{ background: 'var(--bg-card)' }}
|
||||
>
|
||||
<div className="w-9 h-9 rounded-[11px] flex items-center justify-center" style={{ background: '#DCFCE7', color: '#15803D' }}>
|
||||
<Clock size={16} />
|
||||
</div>
|
||||
<span className="text-[10px] font-semibold" style={{ color: 'var(--text-primary)' }}>{t('dashboard.mobile.timezone')}</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Desktop header */}
|
||||
<div className="hidden md:flex" style={{ alignItems: 'center', justifyContent: 'space-between', marginBottom: 28 }}>
|
||||
<div>
|
||||
<h1 style={{ margin: 0, fontSize: 24, fontWeight: 800, color: 'var(--text-primary)' }}>{t('dashboard.title')}</h1>
|
||||
<p style={{ margin: '3px 0 0', fontSize: 13, color: '#9ca3af' }}>
|
||||
@@ -774,17 +1079,7 @@ export default function DashboardPage(): React.ReactElement {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Mobile widgets button */}
|
||||
{showSidebar && (
|
||||
<button
|
||||
onClick={() => setShowWidgetSettings('mobile')}
|
||||
className="lg:hidden flex items-center justify-center gap-2 w-full py-2.5 rounded-xl text-xs font-semibold mb-4"
|
||||
style={{ background: 'var(--bg-card)', border: '1px solid var(--border-primary)', color: 'var(--text-primary)' }}
|
||||
>
|
||||
<ArrowRightLeft size={13} style={{ color: 'var(--text-faint)' }} />
|
||||
{showCurrency && showTimezone ? `${t('dashboard.currency')} & ${t('dashboard.timezone')}` : showCurrency ? t('dashboard.currency') : t('dashboard.timezone')}
|
||||
</button>
|
||||
)}
|
||||
{/* Mobile widgets button — replaced by Quick Actions */}
|
||||
|
||||
<div style={{ display: 'flex', gap: 24, alignItems: 'flex-start' }}>
|
||||
{/* Main content */}
|
||||
@@ -819,9 +1114,9 @@ export default function DashboardPage(): React.ReactElement {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Spotlight (grid mode only) */}
|
||||
{/* Spotlight (grid mode, desktop only — mobile has Live Hero) */}
|
||||
{!isLoading && spotlight && viewMode === 'grid' && (
|
||||
<SpotlightCard
|
||||
<div className="hidden md:block"><SpotlightCard
|
||||
trip={spotlight}
|
||||
t={t} locale={locale} dark={dark}
|
||||
onEdit={(can('trip_edit', spotlight) || can('trip_cover_upload', spotlight)) ? tr => { setEditingTrip(tr); setShowForm(true) } : undefined}
|
||||
@@ -829,13 +1124,37 @@ export default function DashboardPage(): React.ReactElement {
|
||||
onDelete={can('trip_delete', spotlight) ? handleDelete : undefined}
|
||||
onArchive={can('trip_archive', spotlight) ? handleArchive : undefined}
|
||||
onClick={tr => navigate(`/trips/${tr.id}`)}
|
||||
/>
|
||||
/></div>
|
||||
)}
|
||||
|
||||
{/* Trips — grid or list */}
|
||||
{!isLoading && (viewMode === 'grid' ? rest : trips).length > 0 && (
|
||||
{/* Trips — mobile cards */}
|
||||
{!isLoading && rest.length > 0 && (
|
||||
<div className="md:hidden flex flex-col gap-3 mb-10">
|
||||
<div className="flex items-baseline justify-between px-1 pb-1">
|
||||
<span className="text-[11px] font-bold tracking-[0.12em] uppercase" style={{ color: 'var(--text-faint)' }}>
|
||||
{rest.some(t => getTripStatus(t) === 'future' || getTripStatus(t) === 'tomorrow') ? t('dashboard.mobile.upcomingTrips') : t('dashboard.mobile.yourTrips')}
|
||||
</span>
|
||||
<span className="text-[11px] font-medium" style={{ color: 'var(--text-muted)' }}>{rest.length} {t('dashboard.mobile.trips')}</span>
|
||||
</div>
|
||||
{rest.map(trip => (
|
||||
<MobileTripCard
|
||||
key={trip.id}
|
||||
trip={trip}
|
||||
t={t} locale={locale}
|
||||
onEdit={(can('trip_edit', trip) || can('trip_cover_upload', trip)) ? tr => { setEditingTrip(tr); setShowForm(true) } : undefined}
|
||||
onCopy={can('trip_create') ? handleCopy : undefined}
|
||||
onDelete={can('trip_delete', trip) ? handleDelete : undefined}
|
||||
onArchive={can('trip_archive', trip) ? handleArchive : undefined}
|
||||
onClick={tr => navigate(`/trips/${tr.id}`)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Trips — desktop grid or list */}
|
||||
{!isLoading && (viewMode === 'grid' ? rest : rest).length > 0 && (
|
||||
viewMode === 'grid' ? (
|
||||
<div className="trip-grid" style={{ display: 'grid', gap: 16, marginBottom: 40 }}>
|
||||
<div className="trip-grid hidden md:grid" style={{ gap: 16, marginBottom: 40 }}>
|
||||
{rest.map(trip => (
|
||||
<TripCard
|
||||
key={trip.id}
|
||||
@@ -850,8 +1169,8 @@ export default function DashboardPage(): React.ReactElement {
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, marginBottom: 40 }}>
|
||||
{trips.map(trip => (
|
||||
<div className="hidden md:flex" style={{ flexDirection: 'column', gap: 8, marginBottom: 40 }}>
|
||||
{rest.map(trip => (
|
||||
<TripListItem
|
||||
key={trip.id}
|
||||
trip={trip}
|
||||
@@ -912,20 +1231,25 @@ export default function DashboardPage(): React.ReactElement {
|
||||
</div>
|
||||
|
||||
{/* Mobile widgets bottom sheet */}
|
||||
{showWidgetSettings === 'mobile' && (
|
||||
{(showWidgetSettings === 'mobile' || showWidgetSettings === 'mobile-currency' || showWidgetSettings === 'mobile-timezone') && (
|
||||
<div className="lg:hidden fixed inset-0 z-50" style={{ background: 'rgba(0,0,0,0.3)', touchAction: 'none' }} onClick={() => setShowWidgetSettings(false)}>
|
||||
<div className="absolute bottom-0 left-0 right-0 flex flex-col overflow-hidden"
|
||||
style={{ maxHeight: '80vh', background: 'var(--bg-card)', borderRadius: '20px 20px 0 0', overscrollBehavior: 'contain' }}
|
||||
<div className="absolute left-0 right-0 flex flex-col overflow-hidden"
|
||||
style={{ bottom: 'calc(84px + env(safe-area-inset-bottom, 0px))', maxHeight: '70vh', background: 'var(--bg-card)', borderRadius: '20px 20px 0 0', overscrollBehavior: 'contain', animation: 'slideUp 0.25s ease-out' }}
|
||||
onClick={e => e.stopPropagation()}>
|
||||
<div className="flex items-center justify-between px-4 py-3" style={{ borderBottom: '1px solid var(--border-secondary)' }}>
|
||||
<span className="text-sm font-semibold" style={{ color: 'var(--text-primary)' }}>Widgets</span>
|
||||
<div className="flex justify-center pt-3 pb-2">
|
||||
<div className="w-10 h-1 rounded-full" style={{ background: 'var(--border-primary)' }} />
|
||||
</div>
|
||||
<div className="flex items-center justify-between px-5 pb-3">
|
||||
<span className="text-sm font-semibold" style={{ color: 'var(--text-primary)' }}>
|
||||
{showWidgetSettings === 'mobile-currency' ? t('dashboard.mobile.currencyConverter') : showWidgetSettings === 'mobile-timezone' ? t('dashboard.mobile.timezone') : t('common.settings')}
|
||||
</span>
|
||||
<button onClick={() => setShowWidgetSettings(false)} className="w-7 h-7 rounded-full flex items-center justify-center" style={{ background: 'var(--bg-secondary)' }}>
|
||||
<X size={14} style={{ color: 'var(--text-primary)' }} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-auto p-4 space-y-4">
|
||||
{showCurrency && <CurrencyWidget />}
|
||||
{showTimezone && <TimezoneWidget />}
|
||||
<div className="flex-1 overflow-auto p-4 space-y-4" style={{ borderTop: '1px solid var(--border-secondary)' }}>
|
||||
{(showWidgetSettings === 'mobile' || showWidgetSettings === 'mobile-currency') && showCurrency && <CurrencyWidget />}
|
||||
{(showWidgetSettings === 'mobile' || showWidgetSettings === 'mobile-timezone') && showTimezone && <TimezoneWidget />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,444 @@
|
||||
import { useEffect, useState, useMemo } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useJourneyStore } from '../store/journeyStore'
|
||||
import { journeyApi } from '../api/client'
|
||||
import Navbar from '../components/Layout/Navbar'
|
||||
import { useToast } from '../components/shared/Toast'
|
||||
import { useTranslation } from '../i18n'
|
||||
import {
|
||||
Plus, Search, Sparkles, Calendar, MapPin, BookOpen, Camera,
|
||||
Check, X, ChevronRight, RefreshCw, Users,
|
||||
} from 'lucide-react'
|
||||
import type { Journey } 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]
|
||||
}
|
||||
|
||||
function timeAgo(timestamp: number, t: (k: string, p?: any) => string): string {
|
||||
const diff = Date.now() - timestamp
|
||||
const hours = Math.floor(diff / 3600000)
|
||||
if (hours < 1) return t('common.justNow')
|
||||
if (hours < 24) return t('common.hoursAgo', { count: hours })
|
||||
const days = Math.floor(hours / 24)
|
||||
return t('common.daysAgo', { count: days })
|
||||
}
|
||||
|
||||
export default function JourneyPage() {
|
||||
const navigate = useNavigate()
|
||||
const toast = useToast()
|
||||
const { t } = useTranslation()
|
||||
const { journeys, loading, loadJourneys, createJourney } = useJourneyStore()
|
||||
|
||||
const [showCreate, setShowCreate] = useState(false)
|
||||
const [newTitle, setNewTitle] = useState('')
|
||||
const [availableTrips, setAvailableTrips] = useState<any[]>([])
|
||||
const [selectedTripIds, setSelectedTripIds] = useState<Set<number>>(new Set())
|
||||
|
||||
// suggestion
|
||||
const [suggestions, setSuggestions] = useState<any[]>([])
|
||||
const [dismissedSuggestions, setDismissedSuggestions] = useState<Set<number>>(new Set())
|
||||
|
||||
useEffect(() => {
|
||||
loadJourneys()
|
||||
journeyApi.suggestions().then(d => setSuggestions(d.trips || [])).catch(() => {})
|
||||
}, [])
|
||||
|
||||
const activeSuggestion = suggestions.find(s => !dismissedSuggestions.has(s.id))
|
||||
|
||||
const activeJourney = useMemo(() => {
|
||||
return journeys.find(j => j.status === 'active') || null
|
||||
}, [journeys])
|
||||
|
||||
const otherJourneys = useMemo(() => {
|
||||
return journeys.filter(j => j.id !== activeJourney?.id)
|
||||
}, [journeys, activeJourney])
|
||||
|
||||
const openCreateModal = async (preSelectedTripId?: number) => {
|
||||
setShowCreate(true)
|
||||
setNewTitle('')
|
||||
const initial = new Set<number>()
|
||||
if (preSelectedTripId) initial.add(preSelectedTripId)
|
||||
setSelectedTripIds(initial)
|
||||
try {
|
||||
const data = await journeyApi.availableTrips()
|
||||
setAvailableTrips(data.trips || [])
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!newTitle.trim()) return
|
||||
try {
|
||||
const j = await createJourney({
|
||||
title: newTitle.trim(),
|
||||
trip_ids: [...selectedTripIds],
|
||||
})
|
||||
setShowCreate(false)
|
||||
navigate(`/journey/${j.id}`)
|
||||
} catch {
|
||||
toast.error(t('journey.createError'))
|
||||
}
|
||||
}
|
||||
|
||||
const totalPlaces = useMemo(() => {
|
||||
return availableTrips.filter(t => selectedTripIds.has(t.id)).reduce((sum: number, t: any) => sum + (t.place_count || 0), 0)
|
||||
}, [availableTrips, selectedTripIds])
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-zinc-50 dark:bg-zinc-950">
|
||||
<Navbar />
|
||||
<div style={{ paddingTop: 'var(--nav-h, 56px)' }}>
|
||||
<div className="max-w-[1440px] mx-auto">
|
||||
|
||||
{/* Header — mobile: just a create button */}
|
||||
<div className="md:hidden px-5 pt-5 pb-4">
|
||||
<button
|
||||
onClick={() => openCreateModal()}
|
||||
className="w-full flex items-center justify-center gap-2 py-3 rounded-xl bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 text-[14px] font-semibold active:scale-[0.98] transition-transform"
|
||||
>
|
||||
<Plus size={16} strokeWidth={2.5} />
|
||||
{t('journey.frontpage.createJourney')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Header — desktop */}
|
||||
<div className="hidden md:flex items-start justify-between px-8 pt-10 pb-7">
|
||||
<div>
|
||||
<h1 className="text-[32px] font-extrabold tracking-[-0.025em] text-zinc-900 dark:text-white leading-none">{t('journey.title')}</h1>
|
||||
<p className="text-[13px] text-zinc-500 mt-1.5">{t("journey.frontpage.subtitle")}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button className="w-9 h-9 rounded-[10px] border border-zinc-200 dark:border-zinc-700 bg-white dark:bg-zinc-800 flex items-center justify-center text-zinc-500 hover:bg-zinc-50 dark:hover:bg-zinc-700">
|
||||
<Search size={15} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => openCreateModal()}
|
||||
className="inline-flex items-center gap-1.5 px-3.5 py-2 rounded-[10px] 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 transition-all hover:-translate-y-px"
|
||||
>
|
||||
<Plus size={14} />
|
||||
{t('journey.frontpage.createJourney')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="px-4 md:px-8 pb-16">
|
||||
|
||||
{/* Suggestion banner */}
|
||||
{activeSuggestion && (
|
||||
<div className="relative rounded-2xl overflow-hidden mb-8" style={{ background: 'linear-gradient(135deg, #1E293B 0%, #334155 100%)' }}>
|
||||
<div className="absolute inset-0 pointer-events-none hidden md:block" style={{ background: 'radial-gradient(circle at 85% 50%, rgba(99,102,241,0.4), transparent 50%), radial-gradient(circle at 100% 100%, rgba(236,72,153,0.3), transparent 50%)' }} />
|
||||
<div className="absolute inset-0 pointer-events-none md:hidden" style={{ background: 'radial-gradient(circle at 80% 20%, rgba(99,102,241,0.5), transparent 60%), radial-gradient(circle at 20% 90%, rgba(236,72,153,0.35), transparent 60%)' }} />
|
||||
<div className="relative flex flex-col md:flex-row md:items-center justify-between gap-4 md:gap-6 p-5 text-white">
|
||||
<div className="flex items-center gap-3.5">
|
||||
<div className="w-10 h-10 rounded-[10px] bg-white/15 backdrop-blur flex items-center justify-center flex-shrink-0">
|
||||
<Sparkles size={18} />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-[10px] font-semibold tracking-[0.12em] uppercase opacity-70">{t("journey.frontpage.suggestionLabel")}</div>
|
||||
<div className="text-[13px] mt-0.5">
|
||||
<span dangerouslySetInnerHTML={{ __html: t('journey.frontpage.suggestionText', { title: activeSuggestion.title }) }} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
<button
|
||||
onClick={() => setDismissedSuggestions(prev => new Set([...prev, activeSuggestion.id]))}
|
||||
className="px-3 py-1.5 rounded-lg bg-white/10 border border-white/20 text-[12px] font-medium text-white hover:bg-white/20"
|
||||
>
|
||||
{t('journey.frontpage.dismiss')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => openCreateModal(activeSuggestion.id)}
|
||||
className="px-3 py-1.5 rounded-lg bg-white text-zinc-900 text-[12px] font-medium hover:bg-zinc-100"
|
||||
>
|
||||
{t('journey.frontpage.createJourney')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Active Journey Hero */}
|
||||
{activeJourney && (
|
||||
<div className="mb-10">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<span className="text-[11px] font-bold tracking-[0.14em] uppercase text-zinc-500">{t("journey.frontpage.activeJourney")}</span>
|
||||
<span className="text-[11px] text-zinc-400 flex items-center gap-1.5">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-emerald-500 animate-pulse" />
|
||||
{t('journey.frontpage.updated', { time: timeAgo(activeJourney.updated_at, t) })}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
onClick={() => navigate(`/journey/${activeJourney.id}`)}
|
||||
className="relative rounded-3xl overflow-hidden cursor-pointer transition-all duration-300 hover:-translate-y-1 hover:shadow-xl h-[340px] md:h-[400px]"
|
||||
style={{ background: pickGradient(activeJourney.id) }}
|
||||
>
|
||||
{/* Cover image */}
|
||||
{activeJourney.cover_image && (
|
||||
<div className="absolute inset-0 z-[1]">
|
||||
<img src={`/uploads/${activeJourney.cover_image}`} className="w-full h-full object-cover" alt="" />
|
||||
<div className="absolute inset-0" style={{ background: pickGradient(activeJourney.id), opacity: 0.45 }} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Gradient overlays */}
|
||||
<div className="absolute inset-0 pointer-events-none z-[2]" style={{ background: 'radial-gradient(circle at 15% 20%, rgba(236,72,153,0.35), transparent 40%), radial-gradient(circle at 85% 80%, rgba(251,146,60,0.3), transparent 45%), radial-gradient(circle at 50% 50%, rgba(99,102,241,0.25), transparent 50%)' }} />
|
||||
<div className="absolute inset-0 pointer-events-none z-[2]" style={{ background: 'linear-gradient(180deg, transparent 0%, transparent 50%, rgba(0,0,0,0.4) 100%), linear-gradient(90deg, rgba(0,0,0,0.15) 0%, transparent 50%)' }} />
|
||||
|
||||
<div className="relative h-full p-6 md:p-8 flex flex-col z-[3] text-white">
|
||||
{/* Top badges */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="inline-flex items-center gap-2 px-3 py-1.5 bg-white/12 backdrop-blur-sm border border-white/15 rounded-full text-[10px] font-semibold uppercase tracking-[0.08em]">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-emerald-500 shadow-[0_0_6px_rgba(16,185,129,0.6)] animate-pulse" />
|
||||
{t('journey.frontpage.live')}
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1.5 px-3 py-1.5 bg-white/12 backdrop-blur-sm border border-white/15 rounded-full text-[10px] font-medium">
|
||||
<RefreshCw size={10} />
|
||||
{t('journey.frontpage.synced')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Middle — title */}
|
||||
<div className="flex-1 flex flex-col justify-center py-4">
|
||||
{activeJourney.subtitle && (
|
||||
<p className="text-[13px] font-medium opacity-85 mb-3">{activeJourney.subtitle}</p>
|
||||
)}
|
||||
<h2 className="text-[40px] md:text-[56px] font-extrabold tracking-[-0.035em] leading-[0.95] mb-3" style={{ textShadow: '0 2px 30px rgba(0,0,0,0.15)' }}>
|
||||
{activeJourney.title}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
{/* Bottom stats */}
|
||||
<div className="flex items-end justify-between gap-6">
|
||||
<div className="flex gap-7">
|
||||
{[
|
||||
{ val: (activeJourney as any).entry_count ?? '--', label: t("journey.stats.entries") },
|
||||
{ val: (activeJourney as any).photo_count ?? '--', label: t("journey.stats.photos") },
|
||||
{ val: (activeJourney as any).city_count ?? '--', label: t("journey.stats.cities") },
|
||||
].map(s => (
|
||||
<div key={s.label} className="flex flex-col gap-1">
|
||||
<span className="text-[28px] font-extrabold tracking-[-0.02em] leading-none">{s.val}</span>
|
||||
<span className="text-[10px] uppercase tracking-[0.12em] opacity-70 font-semibold">{s.label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<span className="hidden md:inline-flex items-center gap-1.5 px-3 py-1.5 bg-white/15 backdrop-blur-sm rounded-full text-[11px] font-medium">
|
||||
{t('journey.frontpage.continueWriting')}<ChevronRight size={12} />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* All Journeys */}
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<span className="text-[11px] font-bold tracking-[0.14em] uppercase text-zinc-500">{t("journey.frontpage.allJourneys")}</span>
|
||||
<span className="text-[11px] text-zinc-400">{journeys.length} {t('journey.frontpage.journeys')}</span>
|
||||
</div>
|
||||
|
||||
{loading && journeys.length === 0 ? (
|
||||
<div className="flex justify-center py-16">
|
||||
<div className="w-6 h-6 border-2 border-zinc-300 border-t-zinc-900 rounded-full animate-spin" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 md:gap-[18px]">
|
||||
{otherJourneys.map(j => (
|
||||
<JourneyCard key={j.id} journey={j} onClick={() => navigate(`/journey/${j.id}`)} />
|
||||
))}
|
||||
|
||||
{/* Create card */}
|
||||
<button
|
||||
onClick={() => openCreateModal()}
|
||||
className="group min-h-[320px] rounded-2xl border-[1.5px] border-dashed border-zinc-200 dark:border-zinc-700 bg-white dark:bg-zinc-900 flex flex-col items-center justify-center gap-2.5 hover:border-zinc-400 hover:bg-zinc-50 dark:hover:bg-zinc-800 transition-all cursor-pointer hover:-translate-y-0.5"
|
||||
>
|
||||
<div className="w-14 h-14 rounded-full bg-zinc-100 dark:bg-zinc-800 flex items-center justify-center text-zinc-400 group-hover:bg-white dark:group-hover:bg-zinc-700 transition-all group-hover:rotate-90 duration-300">
|
||||
<Plus size={22} />
|
||||
</div>
|
||||
<span className="text-[14px] font-semibold text-zinc-700 dark:text-zinc-300">{t("journey.frontpage.createNew")}</span>
|
||||
<span className="text-[12px] text-zinc-400 max-w-[180px] text-center leading-snug">{t("journey.frontpage.createNewSub")}</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Create Modal */}
|
||||
{showCreate && (
|
||||
<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">
|
||||
|
||||
{/* Header */}
|
||||
<div className="px-7 pt-6 pb-5 border-b border-zinc-200 dark:border-zinc-700">
|
||||
<h2 className="text-[18px] font-bold tracking-[-0.01em] text-zinc-900 dark:text-white">{t("journey.frontpage.createJourney")}</h2>
|
||||
<p className="text-[13px] text-zinc-500 mt-1">{t('journey.frontpage.createNewSub')}</p>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="flex-1 overflow-y-auto px-7 py-5">
|
||||
<label className="text-[10px] font-semibold tracking-[0.12em] uppercase text-zinc-500 block mb-2.5">{t('journey.frontpage.journeyName')}</label>
|
||||
<input
|
||||
value={newTitle}
|
||||
onChange={e => setNewTitle(e.target.value)}
|
||||
placeholder={t('journey.frontpage.namePlaceholder')}
|
||||
className="w-full px-3.5 py-2.5 border border-zinc-200 dark:border-zinc-700 rounded-lg text-[14px] bg-white dark:bg-zinc-800 text-zinc-900 dark:text-white focus:border-zinc-900 dark:focus:border-zinc-400 focus:outline-none mb-5"
|
||||
/>
|
||||
|
||||
<label className="text-[10px] font-semibold tracking-[0.12em] uppercase text-zinc-500 block mb-2.5">{t('journey.frontpage.selectTrips')}</label>
|
||||
<div className="flex flex-col gap-2 max-h-[320px] overflow-y-auto">
|
||||
{availableTrips.map(trip => {
|
||||
const selected = selectedTripIds.has(trip.id)
|
||||
const status = trip.end_date && trip.end_date < new Date().toISOString().split('T')[0]
|
||||
? 'completed'
|
||||
: trip.start_date && trip.start_date <= new Date().toISOString().split('T')[0]
|
||||
? 'active'
|
||||
: 'upcoming'
|
||||
const statusColors: Record<string, string> = {
|
||||
completed: 'bg-emerald-100 text-emerald-800 dark:bg-emerald-900/30 dark:text-emerald-400',
|
||||
active: 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400',
|
||||
upcoming: 'bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-400',
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={trip.id}
|
||||
onClick={() => {
|
||||
setSelectedTripIds(prev => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(trip.id)) next.delete(trip.id)
|
||||
else next.add(trip.id)
|
||||
return next
|
||||
})
|
||||
}}
|
||||
className={`flex items-center gap-3 p-3 rounded-xl border cursor-pointer transition-all ${
|
||||
selected
|
||||
? 'border-zinc-900 dark:border-zinc-400 bg-zinc-50 dark:bg-zinc-800'
|
||||
: 'border-zinc-200 dark:border-zinc-700 hover:border-zinc-400 dark:hover:border-zinc-500'
|
||||
}`}
|
||||
>
|
||||
<div className={`w-5 h-5 rounded-md border-2 flex items-center justify-center flex-shrink-0 ${
|
||||
selected
|
||||
? 'bg-zinc-900 dark:bg-white border-zinc-900 dark:border-white'
|
||||
: 'border-zinc-300 dark:border-zinc-600'
|
||||
}`}>
|
||||
{selected && <Check size={12} className="text-white dark:text-zinc-900" />}
|
||||
</div>
|
||||
<div className="w-12 h-12 rounded-lg flex-shrink-0" style={{ background: pickGradient(trip.id) }} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-[14px] font-semibold text-zinc-900 dark:text-white">{trip.title}</div>
|
||||
<div className="text-[12px] text-zinc-500 flex items-center gap-2.5 mt-0.5">
|
||||
<span className="flex items-center gap-1"><Calendar size={11} /> {trip.start_date ? Math.ceil((new Date(trip.end_date || trip.start_date).getTime() - new Date(trip.start_date).getTime()) / 86400000) + 1 : '?'}<span className="hidden md:inline"> {t('journey.stats.days').toLowerCase()}</span></span>
|
||||
<span className="flex items-center gap-1"><MapPin size={11} /> {trip.place_count || 0}<span className="hidden md:inline"> {t("journey.frontpage.places")}</span></span>
|
||||
</div>
|
||||
</div>
|
||||
<span className={`text-[10px] font-medium uppercase tracking-[0.05em] px-2 py-0.5 rounded-full ${statusColors[status]}`}>
|
||||
{t(`journey.status.${status}`)}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="px-7 py-4 border-t border-zinc-200 dark:border-zinc-700 bg-zinc-50 dark:bg-zinc-800/50 flex items-center justify-between">
|
||||
<div className="text-[12px] text-zinc-500">
|
||||
<strong className="text-zinc-900 dark:text-white">{selectedTripIds.size}</strong> <span className="hidden md:inline">{t('journey.frontpage.tripsSelected')}</span><span className="md:hidden">{t('journey.frontpage.trips')}</span>
|
||||
{selectedTripIds.size > 0 && <> · <strong className="text-zinc-900 dark:text-white">{totalPlaces}</strong> <span className="hidden md:inline">{t('journey.frontpage.placesImported')}</span><span className="md:hidden">{t('journey.frontpage.places')}</span></>}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setShowCreate(false)}
|
||||
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={handleCreate}
|
||||
disabled={!newTitle.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 disabled:cursor-not-allowed"
|
||||
>
|
||||
<span className="md:hidden">{t('journey.create')}</span><span className="hidden md:inline">{t('journey.frontpage.createJourney')}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function JourneyCard({ journey, onClick }: { journey: Journey & { entry_count?: number; photo_count?: number; city_count?: number }; onClick: () => void }) {
|
||||
const { t } = useTranslation()
|
||||
const j = journey
|
||||
const entryCount = j.entry_count ?? 0
|
||||
const photoCount = j.photo_count ?? 0
|
||||
const cityCount = j.city_count ?? 0
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={onClick}
|
||||
className="rounded-2xl border border-zinc-200 dark:border-zinc-700 bg-white dark:bg-zinc-900 overflow-hidden cursor-pointer transition-all duration-250 hover:border-zinc-400 hover:-translate-y-1 hover:shadow-[0_20px_40px_rgba(0,0,0,0.06)] flex flex-col"
|
||||
>
|
||||
{/* Cover */}
|
||||
<div className="h-[170px] relative overflow-hidden" style={{ background: pickGradient(j.id) }}>
|
||||
{j.cover_image && (
|
||||
<>
|
||||
<img src={`/uploads/${j.cover_image}`} className="absolute inset-0 w-full h-full object-cover" alt="" />
|
||||
<div className="absolute inset-0" style={{ background: pickGradient(j.id), opacity: 0.4 }} />
|
||||
</>
|
||||
)}
|
||||
<div className="absolute inset-0" style={{ background: 'linear-gradient(180deg, transparent 50%, rgba(0,0,0,0.4) 100%)' }} />
|
||||
|
||||
{/* Top overlay */}
|
||||
<div className="absolute top-3.5 left-3.5 right-3.5 flex items-start justify-between z-[2]">
|
||||
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 bg-black/45 backdrop-blur-sm rounded-full text-white text-[10px] font-semibold tracking-wide">
|
||||
<Calendar size={10} />
|
||||
{new Date(j.created_at).getFullYear()}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="px-[18px] pt-4 pb-[18px] flex flex-col flex-1">
|
||||
<h3 className="text-[16px] font-bold tracking-[-0.01em] text-zinc-900 dark:text-white">{j.title}</h3>
|
||||
{j.subtitle && (
|
||||
<p className="text-[12px] text-zinc-500 mt-1">{j.subtitle}</p>
|
||||
)}
|
||||
{j.status === 'draft' && (
|
||||
<span className="inline-flex self-start mt-1.5 px-2 py-0.5 rounded-full bg-zinc-100 dark:bg-zinc-800 text-[10px] font-medium text-zinc-500 uppercase tracking-wide">{t('journey.status.draft')}</span>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-3 gap-2.5 mt-auto pt-3.5 border-t border-zinc-100 dark:border-zinc-800" style={{ marginTop: j.subtitle ? 14 : 'auto' }}>
|
||||
{[
|
||||
{ val: entryCount, label: t('journey.stats.entries') },
|
||||
{ val: photoCount, label: t('journey.stats.photos') },
|
||||
{ val: cityCount, label: t('journey.stats.cities') },
|
||||
].map(s => (
|
||||
<div key={s.label} className="flex flex-col gap-1">
|
||||
<span className={`text-[16px] font-bold leading-none tracking-[-0.01em] ${s.val > 0 ? 'text-zinc-900 dark:text-white' : 'text-zinc-300 dark:text-zinc-600'}`}>
|
||||
{s.val > 0 ? s.val : '--'}
|
||||
</span>
|
||||
<span className="text-[9px] uppercase tracking-[0.06em] text-zinc-500 font-medium">{s.label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,347 @@
|
||||
import { useEffect, useState, useMemo } from 'react'
|
||||
import { useParams } from 'react-router-dom'
|
||||
import { journeyApi } from '../api/client'
|
||||
import { useTranslation, SUPPORTED_LANGUAGES } from '../i18n'
|
||||
import { useSettingsStore } from '../store/settingsStore'
|
||||
import { List, Grid, MapPin, Camera, BookOpen, Image } from 'lucide-react'
|
||||
import JourneyMap from '../components/Journey/JourneyMap'
|
||||
import JournalBody from '../components/Journey/JournalBody'
|
||||
import PhotoLightbox from '../components/Journey/PhotoLightbox'
|
||||
|
||||
interface PublicEntry {
|
||||
id: number
|
||||
title?: string | null
|
||||
story?: string | null
|
||||
entry_date: string
|
||||
entry_time?: string | null
|
||||
location_name?: string | null
|
||||
location_lat?: number | null
|
||||
location_lng?: number | null
|
||||
mood?: string | null
|
||||
weather?: string | null
|
||||
pros_cons?: { pros: string[]; cons: string[] } | null
|
||||
photos: PublicPhoto[]
|
||||
}
|
||||
|
||||
interface PublicPhoto {
|
||||
id: number
|
||||
entry_id: number
|
||||
provider: string
|
||||
asset_id?: string | null
|
||||
owner_id?: number | null
|
||||
file_path?: string | null
|
||||
caption?: string | null
|
||||
}
|
||||
|
||||
function photoUrl(p: PublicPhoto, shareToken: string): string {
|
||||
if (p.provider === 'local') return `/api/public/journey/${shareToken}/photo/local/${encodeURIComponent(p.file_path || '')}/0/original`
|
||||
return `/api/public/journey/${shareToken}/photo/${p.provider}/${p.asset_id}/${p.owner_id}/original`
|
||||
}
|
||||
|
||||
function formatDate(d: string): { weekday: string; month: string; day: number } {
|
||||
const date = new Date(d + 'T00:00:00')
|
||||
return {
|
||||
weekday: date.toLocaleDateString('en', { weekday: 'long' }),
|
||||
month: date.toLocaleDateString('en', { month: 'long' }),
|
||||
day: date.getDate(),
|
||||
}
|
||||
}
|
||||
|
||||
function groupByDate(entries: PublicEntry[]): Map<string, PublicEntry[]> {
|
||||
const groups = new Map<string, PublicEntry[]>()
|
||||
for (const e of entries) {
|
||||
const d = e.entry_date
|
||||
if (!groups.has(d)) groups.set(d, [])
|
||||
groups.get(d)!.push(e)
|
||||
}
|
||||
return groups
|
||||
}
|
||||
|
||||
export default function JourneyPublicPage() {
|
||||
const { token } = useParams()
|
||||
const [data, setData] = useState<any>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState(false)
|
||||
const [view, setView] = useState<'timeline' | 'gallery' | 'map'>('timeline')
|
||||
const [lightbox, setLightbox] = useState<{ photos: { id: string; src: string; caption?: string | null }[]; index: number } | null>(null)
|
||||
const { t } = useTranslation()
|
||||
const [showLangPicker, setShowLangPicker] = useState(false)
|
||||
const locale = useSettingsStore(s => s.settings.language) || 'en'
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) return
|
||||
journeyApi.getPublicJourney(token)
|
||||
.then(d => setData(d))
|
||||
.catch(() => setError(true))
|
||||
.finally(() => setLoading(false))
|
||||
}, [token])
|
||||
|
||||
const entries = (data?.entries || []) as PublicEntry[]
|
||||
const perms = data?.permissions || {}
|
||||
const journey = data?.journey || {}
|
||||
const stats = data?.stats || {}
|
||||
|
||||
const groupedEntries = useMemo(() => groupByDate(entries), [entries])
|
||||
const sortedDates = useMemo(() => [...groupedEntries.keys()].sort(), [groupedEntries])
|
||||
const mapEntries = useMemo(() => entries.filter(e => e.location_lat && e.location_lng), [entries])
|
||||
const allPhotos = useMemo(() => entries.flatMap(e => (e.photos || []).map(p => ({ photo: p, entry: e }))), [entries])
|
||||
|
||||
// Set default view based on permissions
|
||||
useEffect(() => {
|
||||
if (!perms.share_timeline && perms.share_gallery) setView('gallery')
|
||||
else if (!perms.share_timeline && !perms.share_gallery && perms.share_map) setView('map')
|
||||
}, [perms])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-zinc-50 dark:bg-zinc-950 flex items-center justify-center">
|
||||
<div className="w-6 h-6 border-2 border-zinc-300 border-t-zinc-900 rounded-full animate-spin" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error || !data) {
|
||||
return (
|
||||
<div className="min-h-screen bg-zinc-50 dark:bg-zinc-950 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<h1 className="text-2xl font-bold text-zinc-900 dark:text-white mb-2">{t('journey.public.notFound')}</h1>
|
||||
<p className="text-zinc-500">{t('journey.public.notFoundMessage')}</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const availableViews = [
|
||||
perms.share_timeline && { id: 'timeline' as const, icon: List, label: t('journey.share.timeline') },
|
||||
perms.share_gallery && { id: 'gallery' as const, icon: Grid, label: t('journey.share.gallery') },
|
||||
perms.share_map && { id: 'map' as const, icon: MapPin, label: t('journey.share.map') },
|
||||
].filter(Boolean) as { id: 'timeline' | 'gallery' | 'map'; icon: any; label: string }[]
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-zinc-50 dark:bg-zinc-950">
|
||||
{/* Hero */}
|
||||
<div className="relative text-center text-white" style={{ background: 'linear-gradient(135deg, #000 0%, #0f172a 50%, #1e293b 100%)', padding: '32px 20px 28px' }}>
|
||||
{/* Cover image background */}
|
||||
{journey.cover_image && (
|
||||
<div style={{ position: 'absolute', inset: 0, backgroundImage: `url(/uploads/${journey.cover_image})`, backgroundSize: 'cover', backgroundPosition: 'center', opacity: 0.15 }} />
|
||||
)}
|
||||
{/* Decorative circles */}
|
||||
<div style={{ position: 'absolute', top: -60, right: -60, width: 200, height: 200, borderRadius: '50%', background: 'rgba(255,255,255,0.03)' }} />
|
||||
<div style={{ position: 'absolute', bottom: -40, left: -40, width: 150, height: 150, borderRadius: '50%', background: 'rgba(255,255,255,0.02)' }} />
|
||||
|
||||
{/* Language picker */}
|
||||
<div style={{ position: 'absolute', top: 12, right: 12, zIndex: 10 }}>
|
||||
<button onClick={() => setShowLangPicker(v => !v)} style={{
|
||||
padding: '5px 12px', borderRadius: 20, border: '1px solid rgba(255,255,255,0.15)',
|
||||
background: 'rgba(255,255,255,0.1)', backdropFilter: 'blur(8px)',
|
||||
color: 'rgba(255,255,255,0.7)', fontSize: 11, fontWeight: 500, cursor: 'pointer', fontFamily: 'inherit',
|
||||
}}>
|
||||
{SUPPORTED_LANGUAGES.find(l => l.value === (locale?.split('-')[0] || 'en'))?.label || 'Language'}
|
||||
</button>
|
||||
{showLangPicker && (
|
||||
<div style={{ position: 'absolute', top: '100%', right: 0, marginTop: 6, background: 'white', borderRadius: 10, boxShadow: '0 4px 16px rgba(0,0,0,0.2)', padding: 4, zIndex: 50, minWidth: 150 }}>
|
||||
{SUPPORTED_LANGUAGES.map(lang => (
|
||||
<button key={lang.value} onClick={() => {
|
||||
useSettingsStore.setState(s => ({ settings: { ...s.settings, language: lang.value } }))
|
||||
setShowLangPicker(false)
|
||||
}}
|
||||
style={{ display: 'block', width: '100%', padding: '6px 12px', border: 'none', background: 'none', textAlign: 'left', cursor: 'pointer', fontSize: 12, color: '#374151', borderRadius: 6, fontFamily: 'inherit' }}
|
||||
onMouseEnter={e => e.currentTarget.style.background = '#f3f4f6'}
|
||||
onMouseLeave={e => e.currentTarget.style.background = 'none'}
|
||||
>{lang.label}</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Logo */}
|
||||
<div style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: 44, height: 44, borderRadius: 12, background: 'rgba(255,255,255,0.08)', backdropFilter: 'blur(8px)', marginBottom: 12, border: '1px solid rgba(255,255,255,0.1)', position: 'relative' }}>
|
||||
<img src="/icons/icon-white.svg" alt="TREK" width={26} height={26} />
|
||||
</div>
|
||||
|
||||
<div style={{ fontSize: 10, fontWeight: 600, letterSpacing: 3, textTransform: 'uppercase', opacity: 0.35, marginBottom: 12, position: 'relative' }}>{t('journey.public.tagline')}</div>
|
||||
|
||||
<h1 className="relative" style={{ margin: '0 0 4px', fontSize: 26, fontWeight: 700, letterSpacing: -0.5 }}>{journey.title}</h1>
|
||||
|
||||
{journey.subtitle && (
|
||||
<div className="relative" style={{ fontSize: 13, opacity: 0.5, maxWidth: 400, margin: '0 auto', lineHeight: 1.5 }}>{journey.subtitle}</div>
|
||||
)}
|
||||
|
||||
{/* Stats pill */}
|
||||
<div className="relative" style={{ marginTop: 12, display: 'inline-flex', alignItems: 'center', gap: 12, padding: '8px 18px', borderRadius: 20, background: 'rgba(255,255,255,0.08)', backdropFilter: 'blur(4px)', border: '1px solid rgba(255,255,255,0.08)' }}>
|
||||
<span style={{ fontSize: 12, fontWeight: 500, opacity: 0.8, display: 'flex', alignItems: 'center', gap: 5 }}><BookOpen size={12} /> {stats.entries} {t('journey.stats.entries')}</span>
|
||||
<span style={{ fontSize: 11, opacity: 0.4 }}>·</span>
|
||||
<span style={{ fontSize: 12, fontWeight: 500, opacity: 0.8, display: 'flex', alignItems: 'center', gap: 5 }}><Camera size={12} /> {stats.photos} {t('journey.stats.photos')}</span>
|
||||
<span style={{ fontSize: 11, opacity: 0.4 }}>·</span>
|
||||
<span style={{ fontSize: 12, fontWeight: 500, opacity: 0.8, display: 'flex', alignItems: 'center', gap: 5 }}><MapPin size={12} /> {stats.cities} {t('journey.stats.places')}</span>
|
||||
</div>
|
||||
|
||||
<div className="relative" style={{ marginTop: 12, fontSize: 9, fontWeight: 500, letterSpacing: 1.5, textTransform: 'uppercase', opacity: 0.25 }}>{t('journey.public.readOnly')}</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="max-w-[900px] mx-auto px-4 md:px-8 py-6">
|
||||
|
||||
{/* View tabs */}
|
||||
{availableViews.length > 1 && (
|
||||
<div className="flex bg-white dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-lg overflow-hidden mb-6 w-fit">
|
||||
{availableViews.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>
|
||||
)}
|
||||
|
||||
{/* Timeline */}
|
||||
{view === 'timeline' && perms.share_timeline && (
|
||||
<div className="flex flex-col gap-6">
|
||||
{sortedDates.map(date => {
|
||||
const dayEntries = groupedEntries.get(date)!
|
||||
const fd = formatDate(date)
|
||||
return (
|
||||
<div key={date}>
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="w-10 h-10 rounded-xl bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 flex items-center justify-center text-[14px] font-bold">{fd.day}</div>
|
||||
<div>
|
||||
<div className="text-[14px] font-semibold text-zinc-900 dark:text-white">{fd.weekday}</div>
|
||||
<div className="text-[11px] text-zinc-500">{fd.month} {fd.day}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4 pl-[52px]">
|
||||
{dayEntries.map(entry => (
|
||||
<div key={entry.id} className="bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-700 rounded-2xl overflow-hidden">
|
||||
{entry.photos.length > 0 && (
|
||||
<div className="relative">
|
||||
<img
|
||||
src={photoUrl(entry.photos[0], token!)}
|
||||
className="w-full h-52 object-cover cursor-pointer"
|
||||
alt=""
|
||||
onClick={() => setLightbox({ photos: entry.photos.map(p => ({ id: String(p.id), src: photoUrl(p, token!), caption: p.caption })), index: 0 })}
|
||||
/>
|
||||
{entry.photos.length > 1 && (
|
||||
<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} /> +{entry.photos.length - 1}
|
||||
</div>
|
||||
)}
|
||||
{entry.title && (
|
||||
<div className="absolute inset-x-0 bottom-0 p-4" style={{ background: 'linear-gradient(to top, rgba(0,0,0,0.5) 0%, transparent 100%)' }}>
|
||||
<h3 className="text-[18px] font-bold text-white drop-shadow-sm">{entry.title}</h3>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="px-5 py-4">
|
||||
{!entry.photos.length && entry.title && (
|
||||
<h3 className="text-[16px] font-semibold text-zinc-900 dark:text-white mb-1">{entry.title}</h3>
|
||||
)}
|
||||
{entry.location_name && (
|
||||
<div className="flex items-center gap-1.5 text-[11px] text-zinc-500 mb-2">
|
||||
<MapPin size={11} /> {entry.location_name}
|
||||
</div>
|
||||
)}
|
||||
{entry.story && (
|
||||
<div className="text-[13px] text-zinc-700 dark:text-zinc-300 leading-relaxed">
|
||||
<JournalBody text={entry.story} />
|
||||
</div>
|
||||
)}
|
||||
{entry.pros_cons && ((entry.pros_cons.pros?.length ?? 0) > 0 || (entry.pros_cons.cons?.length ?? 0) > 0) && (
|
||||
<div className="grid grid-cols-2 gap-3 mt-4">
|
||||
{(entry.pros_cons.pros?.length ?? 0) > 0 && (
|
||||
<div className="rounded-xl border border-green-200 dark:border-green-800/30 p-3" style={{ background: 'linear-gradient(180deg, #F0FDF4 0%, white 100%)' }}>
|
||||
<div className="text-[10px] font-bold uppercase tracking-wide text-green-700 mb-2">{t('journey.editor.pros')}</div>
|
||||
{entry.pros_cons.pros!.map((p, i) => (
|
||||
<div key={i} className="flex items-start gap-1.5 text-[12px] text-green-900 mb-1">
|
||||
<span className="w-[5px] h-[5px] rounded-full bg-green-500 flex-shrink-0 mt-[6px]" />{p}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{(entry.pros_cons.cons?.length ?? 0) > 0 && (
|
||||
<div className="rounded-xl border border-red-200 dark:border-red-800/30 p-3" style={{ background: 'linear-gradient(180deg, #FEF2F2 0%, white 100%)' }}>
|
||||
<div className="text-[10px] font-bold uppercase tracking-wide text-red-700 mb-2">{t('journey.editor.cons')}</div>
|
||||
{entry.pros_cons.cons!.map((c, i) => (
|
||||
<div key={i} className="flex items-start gap-1.5 text-[12px] text-red-900 mb-1">
|
||||
<span className="w-[5px] h-[5px] rounded-full bg-red-500 flex-shrink-0 mt-[6px]" />{c}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Gallery */}
|
||||
{view === 'gallery' && perms.share_gallery && (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-1.5">
|
||||
{allPhotos.map(({ photo }, idx) => (
|
||||
<div
|
||||
key={photo.id}
|
||||
className="aspect-square rounded-lg overflow-hidden cursor-pointer"
|
||||
onClick={() => setLightbox({ photos: allPhotos.map(({ photo: p }) => ({ id: String(p.id), src: photoUrl(p, token!), caption: p.caption })), index: idx })}
|
||||
>
|
||||
<img src={photoUrl(photo, token!)} className="w-full h-full object-cover hover:scale-105 transition-transform" alt="" loading="lazy" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Map */}
|
||||
{view === 'map' && perms.share_map && (
|
||||
<div className="rounded-2xl overflow-hidden border border-zinc-200 dark:border-zinc-700">
|
||||
<JourneyMap
|
||||
checkins={[]}
|
||||
entries={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,
|
||||
})) as any}
|
||||
height={500}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Powered by */}
|
||||
<div className="flex flex-col items-center py-8 gap-2">
|
||||
<div style={{ display: 'inline-flex', alignItems: 'center', gap: 8, padding: '8px 16px', borderRadius: 20, background: 'white', border: '1px solid #e5e7eb', boxShadow: '0 1px 3px rgba(0,0,0,0.04)' }}>
|
||||
<img src="/icons/icon.svg" alt="TREK" width={18} height={18} style={{ borderRadius: 4 }} />
|
||||
<span style={{ fontSize: 11, color: '#9ca3af' }}>{t('journey.public.sharedVia')} <strong style={{ color: '#6b7280' }}>TREK</strong></span>
|
||||
</div>
|
||||
<div style={{ fontSize: 10, color: '#d1d5db' }}>
|
||||
Made with <span style={{ color: '#ef4444' }}>♥</span> by Maurice · <a href="https://github.com/mauriceboe/TREK" style={{ color: '#9ca3af', textDecoration: 'none' }}>GitHub</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Lightbox */}
|
||||
{lightbox && (
|
||||
<PhotoLightbox
|
||||
photos={lightbox.photos}
|
||||
startIndex={lightbox.index}
|
||||
onClose={() => setLightbox(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -14,7 +14,7 @@ import PlaceFormModal from '../components/Planner/PlaceFormModal'
|
||||
import TripFormModal from '../components/Trips/TripFormModal'
|
||||
import TripMembersModal from '../components/Trips/TripMembersModal'
|
||||
import { ReservationModal } from '../components/Planner/ReservationModal'
|
||||
import MemoriesPanel from '../components/Memories/MemoriesPanel'
|
||||
// MemoriesPanel moved to Journey addon
|
||||
import ReservationsPanel from '../components/Planner/ReservationsPanel'
|
||||
import PackingListPanel from '../components/Packing/PackingListPanel'
|
||||
import TodoListPanel from '../components/Todo/TodoListPanel'
|
||||
@@ -23,7 +23,7 @@ import BudgetPanel from '../components/Budget/BudgetPanel'
|
||||
import CollabPanel from '../components/Collab/CollabPanel'
|
||||
import Navbar from '../components/Layout/Navbar'
|
||||
import { useToast } from '../components/shared/Toast'
|
||||
import { Map, X, PanelLeftClose, PanelLeftOpen, PanelRightClose, PanelRightOpen, Ticket, PackageCheck, Wallet, FolderOpen, Camera, Users } from 'lucide-react'
|
||||
import { Map, X, PanelLeftClose, PanelLeftOpen, PanelRightClose, PanelRightOpen, Ticket, PackageCheck, Wallet, FolderOpen, Users } from 'lucide-react'
|
||||
import { useTranslation } from '../i18n'
|
||||
import { addonsApi, accommodationsApi, authApi, tripsApi, assignmentsApi, mapsApi } from '../api/client'
|
||||
import ConfirmDialog from '../components/shared/ConfirmDialog'
|
||||
@@ -97,7 +97,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
||||
toast.info(t('undo.done', { action: label ?? '' }))
|
||||
}, [undo, lastActionLabel, toast])
|
||||
|
||||
const [enabledAddons, setEnabledAddons] = useState<Record<string, boolean>>({ packing: true, budget: true, documents: true })
|
||||
const [enabledAddons, setEnabledAddons] = useState<Record<string, boolean>>({ packing: true, budget: true, documents: true, collab: false })
|
||||
const [tripAccommodations, setTripAccommodations] = useState<Accommodation[]>([])
|
||||
const [allowedFileTypes, setAllowedFileTypes] = useState<string | null>(null)
|
||||
const [tripMembers, setTripMembers] = useState<TripMember[]>([])
|
||||
@@ -113,9 +113,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
||||
addonsApi.enabled().then(data => {
|
||||
const map = {}
|
||||
data.addons.forEach(a => { map[a.id] = true })
|
||||
// Check if any photo provider is enabled (for memories tab to show)
|
||||
const hasPhotoProviders = data.addons.some(a => a.type === 'photo_provider')
|
||||
setEnabledAddons({ packing: !!map.packing, budget: !!map.budget, documents: !!map.documents, collab: !!map.collab, memories: hasPhotoProviders })
|
||||
setEnabledAddons({ packing: !!map.packing, budget: !!map.budget, documents: !!map.documents, collab: !!map.collab })
|
||||
}).catch(() => {})
|
||||
authApi.getAppConfig().then(config => {
|
||||
if (config.allowed_file_types) setAllowedFileTypes(config.allowed_file_types)
|
||||
@@ -128,7 +126,6 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
||||
...(enabledAddons.packing ? [{ id: 'listen', label: t('trip.tabs.lists'), shortLabel: t('trip.tabs.listsShort'), icon: PackageCheck }] : []),
|
||||
...(enabledAddons.budget ? [{ id: 'finanzplan', label: t('trip.tabs.budget'), icon: Wallet }] : []),
|
||||
...(enabledAddons.documents ? [{ id: 'dateien', label: t('trip.tabs.files'), icon: FolderOpen }] : []),
|
||||
...(enabledAddons.memories ? [{ id: 'memories', label: t('memories.title'), icon: Camera }] : []),
|
||||
...(enabledAddons.collab ? [{ id: 'collab', label: t('admin.addons.catalog.collab.name'), icon: Users }] : []),
|
||||
]
|
||||
|
||||
@@ -890,7 +887,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
||||
</div>
|
||||
<div style={{ flex: 1, overflow: 'auto' }}>
|
||||
{mobileSidebarOpen === 'left'
|
||||
? <DayPlanSidebar tripId={tripId} trip={trip} days={days} places={places} categories={categories} assignments={assignments} selectedDayId={selectedDayId} selectedPlaceId={selectedPlaceId} selectedAssignmentId={selectedAssignmentId} onSelectDay={(id) => { handleSelectDay(id); setMobileSidebarOpen(null) }} onPlaceClick={(placeId, assignmentId) => { handlePlaceClick(placeId, assignmentId); setMobileSidebarOpen(null) }} onReorder={handleReorder} onUpdateDayTitle={handleUpdateDayTitle} onAssignToDay={handleAssignToDay} onRouteCalculated={(r) => { if (r) { setRoute(r.coordinates); setRouteInfo({ distance: r.distanceText, duration: r.durationText }) } }} reservations={reservations} onAddReservation={(dayId) => { setEditingReservation(null); tripActions.setSelectedDay(dayId); setShowReservationModal(true); setMobileSidebarOpen(null) }} onDayDetail={(day) => { setShowDayDetail(day); setSelectedPlaceId(null); setSelectedAssignmentId(null); setMobileSidebarOpen(null) }} accommodations={tripAccommodations} onNavigateToFiles={() => { setMobileSidebarOpen(null); handleTabChange('dateien') }} onExpandedDaysChange={setExpandedDayIds} pushUndo={pushUndo} canUndo={canUndo} lastActionLabel={lastActionLabel} onUndo={handleUndo} />
|
||||
? <DayPlanSidebar tripId={tripId} trip={trip} days={days} places={places} categories={categories} assignments={assignments} selectedDayId={selectedDayId} selectedPlaceId={selectedPlaceId} selectedAssignmentId={selectedAssignmentId} onSelectDay={(id) => { handleSelectDay(id); setMobileSidebarOpen(null) }} onPlaceClick={(placeId, assignmentId) => { handlePlaceClick(placeId, assignmentId); setMobileSidebarOpen(null) }} onReorder={handleReorder} onUpdateDayTitle={handleUpdateDayTitle} onAssignToDay={handleAssignToDay} onRouteCalculated={(r) => { if (r) { setRoute(r.coordinates); setRouteInfo({ distance: r.distanceText, duration: r.durationText }) } }} reservations={reservations} onAddReservation={(dayId) => { setEditingReservation(null); tripActions.setSelectedDay(dayId); setShowReservationModal(true); setMobileSidebarOpen(null) }} onAddPlace={() => { setEditingPlace(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onDayDetail={(day) => { setShowDayDetail(day); setSelectedPlaceId(null); setSelectedAssignmentId(null); setMobileSidebarOpen(null) }} accommodations={tripAccommodations} onNavigateToFiles={() => { setMobileSidebarOpen(null); handleTabChange('dateien') }} onExpandedDaysChange={setExpandedDayIds} pushUndo={pushUndo} canUndo={canUndo} lastActionLabel={lastActionLabel} onUndo={handleUndo} />
|
||||
: <PlacesSidebar tripId={tripId} places={places} categories={categories} assignments={assignments} selectedDayId={selectedDayId} selectedPlaceId={selectedPlaceId} onPlaceClick={(placeId) => { handlePlaceClick(placeId); setMobileSidebarOpen(null) }} onAddPlace={() => { setEditingPlace(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onAssignToDay={handleAssignToDay} onEditPlace={(place) => { setEditingPlace(place); setEditingAssignmentId(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onDeletePlace={(placeId) => handleDeletePlace(placeId)} days={days} isMobile onCategoryFilterChange={setMapCategoryFilter} onPlacesFilterChange={setMapPlacesFilter} pushUndo={pushUndo} />
|
||||
}
|
||||
</div>
|
||||
@@ -946,12 +943,6 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'memories' && (
|
||||
<div style={{ position: 'absolute', inset: 0, overflow: 'hidden' }}>
|
||||
<MemoriesPanel tripId={Number(tripId)} startDate={trip?.start_date || null} endDate={trip?.end_date || null} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'collab' && (
|
||||
<div style={{ position: 'absolute', inset: 0, overflow: 'hidden' }}>
|
||||
<CollabPanel tripId={tripId} tripMembers={tripMembers} />
|
||||
|
||||
Reference in New Issue
Block a user