mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-22 14:51:45 +00:00
9614a5cf84
Replace the static, color-only inline `style={{ ... 'var(--bg-primary)' ... }}` props with the new semantic Tailwind utilities (bg-surface, text-content, border-edge, ...) wherever the result is byte-identical; dynamic/conditional theme styles and hardcoded status colors are left inline. Extract the Atlas country-search autocomplete, the Admin update banner, and two Journey dialogs into their own presentational components to shrink the oversized page files, keeping behaviour and markup identical.
461 lines
27 KiB
TypeScript
461 lines
27 KiB
TypeScript
import PageShell from '../components/Layout/PageShell'
|
|
import { useTranslation, TransHtml } from '../i18n'
|
|
import {
|
|
Plus, Search, Sparkles, Calendar, MapPin, BookOpen, Camera,
|
|
Check, X, ChevronRight, RefreshCw, Users,
|
|
} from 'lucide-react'
|
|
import type { Journey } from '../store/journeyStore'
|
|
import { computeJourneyLifecycle } from '../utils/journeyLifecycle'
|
|
import { useJourney } from './journey/useJourney'
|
|
|
|
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 { t } = useTranslation()
|
|
// Page = wiring container: store load, create modal, search + suggestions in the hook.
|
|
const {
|
|
navigate, journeys, loading,
|
|
showCreate, setShowCreate, newTitle, setNewTitle,
|
|
availableTrips, selectedTripIds, setSelectedTripIds,
|
|
searchOpen, setSearchOpen, searchQuery, setSearchQuery, searchInputRef,
|
|
activeSuggestion, setDismissedSuggestions,
|
|
activeJourney, filteredJourneys,
|
|
openCreateModal, handleCreate, totalPlaces,
|
|
} = useJourney()
|
|
|
|
return (
|
|
<PageShell className="bg-zinc-50 dark:bg-zinc-950" navOffset="var(--nav-h, 56px)">
|
|
<div className="max-w-[1440px] mx-auto">
|
|
|
|
{/* Header — mobile */}
|
|
<div className="md:hidden px-5 pt-5 pb-4 flex flex-col gap-2">
|
|
<div className="flex items-center gap-2">
|
|
<button
|
|
onClick={() => {
|
|
if (searchOpen) {
|
|
setSearchOpen(false)
|
|
setSearchQuery('')
|
|
} else {
|
|
setSearchOpen(true)
|
|
setTimeout(() => searchInputRef.current?.focus(), 50)
|
|
}
|
|
}}
|
|
className="w-10 h-10 rounded-xl 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 flex-shrink-0"
|
|
>
|
|
{searchOpen ? <X size={15} /> : <Search size={15} />}
|
|
</button>
|
|
<button
|
|
onClick={() => openCreateModal()}
|
|
className="flex-1 flex items-center justify-center gap-2 py-2.5 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>
|
|
{searchOpen && (
|
|
<input
|
|
ref={searchInputRef}
|
|
value={searchQuery}
|
|
onChange={e => setSearchQuery(e.target.value)}
|
|
onKeyDown={e => { if (e.key === 'Escape') { setSearchQuery(''); setSearchOpen(false) } }}
|
|
placeholder={t('journey.search.placeholder')}
|
|
className="w-full px-3.5 py-2.5 border border-zinc-200 dark:border-zinc-700 rounded-xl text-[14px] bg-white dark:bg-zinc-800 text-zinc-900 dark:text-white focus:border-zinc-400 focus:outline-none"
|
|
/>
|
|
)}
|
|
</div>
|
|
|
|
{/* Header — desktop (unified toolbar) */}
|
|
<div className="hidden md:block px-8 pt-10 pb-7">
|
|
<div className="bg-surface-tertiary border border-edge" style={{
|
|
borderRadius: 18,
|
|
boxShadow: '0 1px 3px rgba(0,0,0,0.06), 0 1px 2px rgba(0,0,0,0.04)',
|
|
padding: '14px 16px 14px 22px',
|
|
display: 'flex', alignItems: 'center', gap: 16, flexWrap: 'wrap',
|
|
}}>
|
|
<h2 className="text-content" style={{ margin: 0, fontSize: 18, fontWeight: 600, letterSpacing: '-0.01em', flexShrink: 0 }}>
|
|
{t('journey.title')}
|
|
</h2>
|
|
<div style={{ width: 1, height: 22, background: 'var(--border-faint)', flexShrink: 0 }} />
|
|
<span className="text-content-muted" style={{ fontSize: 13 }}>
|
|
{t('journey.frontpage.subtitle')}
|
|
</span>
|
|
|
|
<div style={{ display: 'inline-flex', gap: 6, alignItems: 'center', marginLeft: 'auto', flexShrink: 0 }}>
|
|
<button
|
|
onClick={() => openCreateModal()}
|
|
className="bg-accent text-accent-text"
|
|
style={{
|
|
appearance: 'none', border: 'none', cursor: 'pointer', fontFamily: 'inherit',
|
|
display: 'inline-flex', alignItems: 'center', gap: 6,
|
|
padding: '9px 14px', borderRadius: 10, fontSize: 13, fontWeight: 500,
|
|
flexShrink: 0,
|
|
marginLeft: 2,
|
|
transition: 'opacity 0.15s ease',
|
|
}}
|
|
onMouseEnter={e => e.currentTarget.style.opacity = '0.88'}
|
|
onMouseLeave={e => e.currentTarget.style.opacity = '1'}
|
|
>
|
|
<Plus size={14} strokeWidth={2.5} />
|
|
{t('journey.frontpage.createJourney')}
|
|
</button>
|
|
</div>
|
|
</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">
|
|
<TransHtml html="journey.frontpage.suggestionText" params={{ 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-[transform,box-shadow] duration-300 ease-[cubic-bezier(0.23,1,0.32,1)] 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).place_count ?? '--', label: t("journey.stats.places") },
|
|
].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>
|
|
)}
|
|
|
|
{/* Search results info */}
|
|
{searchQuery.trim() && (
|
|
<div className="mb-4 flex items-center gap-2">
|
|
<span className="text-[13px] text-zinc-500">
|
|
{filteredJourneys.length === 0
|
|
? t('journey.search.noResults', { query: searchQuery.trim() })
|
|
: `${filteredJourneys.length} ${t('journey.frontpage.journeys')}`}
|
|
</span>
|
|
</div>
|
|
)}
|
|
|
|
{/* All Journeys */}
|
|
{!searchQuery.trim() && (
|
|
<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]">
|
|
{filteredJourneys.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-[border-color,background-color,transform] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)] 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-[background-color,transform] group-hover:rotate-90 duration-300 ease-[cubic-bezier(0.23,1,0.32,1)]">
|
|
<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>
|
|
|
|
{/* 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" style={{ paddingBottom: 'var(--bottom-nav-h)' }}>
|
|
|
|
{/* 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-[border-color,background-color] duration-150 ease-[cubic-bezier(0.23,1,0.32,1)] ${
|
|
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 overflow-hidden" style={{ background: pickGradient(trip.id) }}>
|
|
{trip.cover_image && (
|
|
<img src={trip.cover_image} className="w-full h-full object-cover" alt="" />
|
|
)}
|
|
</div>
|
|
<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>
|
|
)}
|
|
</PageShell>
|
|
)
|
|
}
|
|
|
|
function JourneyCard({ journey, onClick }: { journey: Journey & { entry_count?: number; photo_count?: number; place_count?: number; trip_date_min?: string | null; trip_date_max?: string | null }; onClick: () => void }) {
|
|
const { t } = useTranslation()
|
|
const j = journey
|
|
const entryCount = j.entry_count ?? 0
|
|
const photoCount = j.photo_count ?? 0
|
|
const placeCount = j.place_count ?? 0
|
|
const lifecycle = computeJourneyLifecycle(j.status, j.trip_date_min, j.trip_date_max)
|
|
|
|
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-[transform,box-shadow,border-color] duration-250 ease-[cubic-bezier(0.23,1,0.32,1)] 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>
|
|
)}
|
|
{lifecycle !== 'live' && (
|
|
<span className={`inline-flex self-start mt-1.5 px-2 py-0.5 rounded-full text-[10px] font-medium uppercase tracking-wide ${
|
|
lifecycle === 'archived' ? 'bg-zinc-100 dark:bg-zinc-800 text-zinc-500' :
|
|
lifecycle === 'upcoming' ? 'bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-400' :
|
|
lifecycle === 'completed' ? 'bg-emerald-100 dark:bg-emerald-900/30 text-emerald-700 dark:text-emerald-400' :
|
|
'bg-zinc-100 dark:bg-zinc-800 text-zinc-500'
|
|
}`}>
|
|
{t(`journey.status.${lifecycle}`)}
|
|
</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: placeCount, label: t('journey.stats.places') },
|
|
].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>
|
|
)
|
|
}
|