mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-22 06:41:46 +00:00
feat: add list/grid view toggle on dashboard — closes #73
This commit is contained in:
@@ -51,6 +51,8 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'dashboard.subtitle.activeMany': '{count} aktive Reisen',
|
'dashboard.subtitle.activeMany': '{count} aktive Reisen',
|
||||||
'dashboard.subtitle.archivedSuffix': ' · {count} archiviert',
|
'dashboard.subtitle.archivedSuffix': ' · {count} archiviert',
|
||||||
'dashboard.newTrip': 'Neue Reise',
|
'dashboard.newTrip': 'Neue Reise',
|
||||||
|
'dashboard.gridView': 'Kachelansicht',
|
||||||
|
'dashboard.listView': 'Listenansicht',
|
||||||
'dashboard.currency': 'Währung',
|
'dashboard.currency': 'Währung',
|
||||||
'dashboard.timezone': 'Zeitzonen',
|
'dashboard.timezone': 'Zeitzonen',
|
||||||
'dashboard.localTime': 'Lokal',
|
'dashboard.localTime': 'Lokal',
|
||||||
|
|||||||
@@ -51,6 +51,8 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'dashboard.subtitle.activeMany': '{count} active trips',
|
'dashboard.subtitle.activeMany': '{count} active trips',
|
||||||
'dashboard.subtitle.archivedSuffix': ' · {count} archived',
|
'dashboard.subtitle.archivedSuffix': ' · {count} archived',
|
||||||
'dashboard.newTrip': 'New Trip',
|
'dashboard.newTrip': 'New Trip',
|
||||||
|
'dashboard.gridView': 'Grid view',
|
||||||
|
'dashboard.listView': 'List view',
|
||||||
'dashboard.currency': 'Currency',
|
'dashboard.currency': 'Currency',
|
||||||
'dashboard.timezone': 'Timezones',
|
'dashboard.timezone': 'Timezones',
|
||||||
'dashboard.localTime': 'Local',
|
'dashboard.localTime': 'Local',
|
||||||
|
|||||||
@@ -52,6 +52,8 @@ const es: Record<string, string> = {
|
|||||||
'dashboard.subtitle.activeMany': '{count} viajes activos',
|
'dashboard.subtitle.activeMany': '{count} viajes activos',
|
||||||
'dashboard.subtitle.archivedSuffix': ' · {count} archivados',
|
'dashboard.subtitle.archivedSuffix': ' · {count} archivados',
|
||||||
'dashboard.newTrip': 'Nuevo viaje',
|
'dashboard.newTrip': 'Nuevo viaje',
|
||||||
|
'dashboard.gridView': 'Vista de cuadrícula',
|
||||||
|
'dashboard.listView': 'Vista de lista',
|
||||||
'dashboard.currency': 'Divisa',
|
'dashboard.currency': 'Divisa',
|
||||||
'dashboard.timezone': 'Zonas horarias',
|
'dashboard.timezone': 'Zonas horarias',
|
||||||
'dashboard.localTime': 'Hora local',
|
'dashboard.localTime': 'Hora local',
|
||||||
|
|||||||
@@ -51,6 +51,8 @@ const fr: Record<string, string> = {
|
|||||||
'dashboard.subtitle.activeMany': '{count} voyages actifs',
|
'dashboard.subtitle.activeMany': '{count} voyages actifs',
|
||||||
'dashboard.subtitle.archivedSuffix': ' · {count} archivés',
|
'dashboard.subtitle.archivedSuffix': ' · {count} archivés',
|
||||||
'dashboard.newTrip': 'Nouveau voyage',
|
'dashboard.newTrip': 'Nouveau voyage',
|
||||||
|
'dashboard.gridView': 'Vue en grille',
|
||||||
|
'dashboard.listView': 'Vue en liste',
|
||||||
'dashboard.currency': 'Devise',
|
'dashboard.currency': 'Devise',
|
||||||
'dashboard.timezone': 'Fuseaux horaires',
|
'dashboard.timezone': 'Fuseaux horaires',
|
||||||
'dashboard.localTime': 'Local',
|
'dashboard.localTime': 'Local',
|
||||||
|
|||||||
@@ -51,6 +51,8 @@ const nl: Record<string, string> = {
|
|||||||
'dashboard.subtitle.activeMany': '{count} actieve reizen',
|
'dashboard.subtitle.activeMany': '{count} actieve reizen',
|
||||||
'dashboard.subtitle.archivedSuffix': ' · {count} gearchiveerd',
|
'dashboard.subtitle.archivedSuffix': ' · {count} gearchiveerd',
|
||||||
'dashboard.newTrip': 'Nieuwe reis',
|
'dashboard.newTrip': 'Nieuwe reis',
|
||||||
|
'dashboard.gridView': 'Rasterweergave',
|
||||||
|
'dashboard.listView': 'Lijstweergave',
|
||||||
'dashboard.currency': 'Valuta',
|
'dashboard.currency': 'Valuta',
|
||||||
'dashboard.timezone': 'Tijdzones',
|
'dashboard.timezone': 'Tijdzones',
|
||||||
'dashboard.localTime': 'Lokaal',
|
'dashboard.localTime': 'Lokaal',
|
||||||
|
|||||||
@@ -51,6 +51,8 @@ const ru: Record<string, string> = {
|
|||||||
'dashboard.subtitle.activeMany': '{count} активных поездок',
|
'dashboard.subtitle.activeMany': '{count} активных поездок',
|
||||||
'dashboard.subtitle.archivedSuffix': ' · {count} в архиве',
|
'dashboard.subtitle.archivedSuffix': ' · {count} в архиве',
|
||||||
'dashboard.newTrip': 'Новая поездка',
|
'dashboard.newTrip': 'Новая поездка',
|
||||||
|
'dashboard.gridView': 'Плитка',
|
||||||
|
'dashboard.listView': 'Список',
|
||||||
'dashboard.currency': 'Валюта',
|
'dashboard.currency': 'Валюта',
|
||||||
'dashboard.timezone': 'Часовые пояса',
|
'dashboard.timezone': 'Часовые пояса',
|
||||||
'dashboard.localTime': 'Местное',
|
'dashboard.localTime': 'Местное',
|
||||||
|
|||||||
@@ -51,6 +51,8 @@ const zh: Record<string, string> = {
|
|||||||
'dashboard.subtitle.activeMany': '{count} 个进行中的旅行',
|
'dashboard.subtitle.activeMany': '{count} 个进行中的旅行',
|
||||||
'dashboard.subtitle.archivedSuffix': ' · {count} 已归档',
|
'dashboard.subtitle.archivedSuffix': ' · {count} 已归档',
|
||||||
'dashboard.newTrip': '新建旅行',
|
'dashboard.newTrip': '新建旅行',
|
||||||
|
'dashboard.gridView': '网格视图',
|
||||||
|
'dashboard.listView': '列表视图',
|
||||||
'dashboard.currency': '货币',
|
'dashboard.currency': '货币',
|
||||||
'dashboard.timezone': '时区',
|
'dashboard.timezone': '时区',
|
||||||
'dashboard.localTime': '本地',
|
'dashboard.localTime': '本地',
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { useToast } from '../components/shared/Toast'
|
|||||||
import {
|
import {
|
||||||
Plus, Calendar, Trash2, Edit2, Map, ChevronDown, ChevronUp,
|
Plus, Calendar, Trash2, Edit2, Map, ChevronDown, ChevronUp,
|
||||||
Archive, ArchiveRestore, Clock, MapPin, Settings, X, ArrowRightLeft,
|
Archive, ArchiveRestore, Clock, MapPin, Settings, X, ArrowRightLeft,
|
||||||
|
LayoutGrid, List,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
|
|
||||||
interface DashboardTrip {
|
interface DashboardTrip {
|
||||||
@@ -315,6 +316,102 @@ function TripCard({ trip, onEdit, onDelete, onArchive, onClick, t, locale }: Omi
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── List View Item ──────────────────────────────────────────────────────────
|
||||||
|
function TripListItem({ trip, onEdit, 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)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
onMouseEnter={() => setHovered(true)}
|
||||||
|
onMouseLeave={() => setHovered(false)}
|
||||||
|
onClick={() => onClick(trip)}
|
||||||
|
style={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: 14, padding: '10px 16px',
|
||||||
|
background: hovered ? 'var(--bg-tertiary)' : 'var(--bg-card)', borderRadius: 14,
|
||||||
|
border: `1px solid ${hovered ? 'var(--text-faint)' : 'var(--border-primary)'}`,
|
||||||
|
cursor: 'pointer', transition: 'all 0.15s',
|
||||||
|
boxShadow: hovered ? '0 4px 16px rgba(0,0,0,0.08)' : '0 1px 3px rgba(0,0,0,0.03)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Cover thumbnail */}
|
||||||
|
<div style={{
|
||||||
|
width: 52, height: 52, borderRadius: 12, flexShrink: 0,
|
||||||
|
background: coverBg, position: 'relative', overflow: 'hidden',
|
||||||
|
}}>
|
||||||
|
{status === 'ongoing' && (
|
||||||
|
<span style={{
|
||||||
|
position: 'absolute', top: 4, left: 4,
|
||||||
|
width: 7, height: 7, borderRadius: '50%', background: '#ef4444',
|
||||||
|
animation: 'blink 1s ease-in-out infinite',
|
||||||
|
}} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Title & description */}
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||||
|
<span style={{ fontWeight: 700, fontSize: 14, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||||
|
{trip.title}
|
||||||
|
</span>
|
||||||
|
{!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>
|
||||||
|
)}
|
||||||
|
{status && (
|
||||||
|
<span style={{
|
||||||
|
fontSize: 10, fontWeight: 700, padding: '1px 8px', borderRadius: 99,
|
||||||
|
background: status === 'ongoing' ? 'rgba(239,68,68,0.1)' : 'var(--bg-tertiary)',
|
||||||
|
color: status === 'ongoing' ? '#ef4444' : 'var(--text-muted)',
|
||||||
|
whiteSpace: 'nowrap', 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>
|
||||||
|
{trip.description && (
|
||||||
|
<p style={{ fontSize: 12, color: 'var(--text-faint)', margin: '2px 0 0', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||||
|
{trip.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Date & stats */}
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 16, flexShrink: 0 }}>
|
||||||
|
{trip.start_date && (
|
||||||
|
<div className="hidden sm:flex" style={{ alignItems: 'center', gap: 4, fontSize: 12, color: 'var(--text-muted)' }}>
|
||||||
|
<Calendar size={11} />
|
||||||
|
{formatDateShort(trip.start_date, locale)}
|
||||||
|
{trip.end_date && <> — {formatDateShort(trip.end_date, locale)}</>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="hidden md:flex" style={{ alignItems: 'center', gap: 4, fontSize: 12, color: 'var(--text-muted)' }}>
|
||||||
|
<Clock size={11} /> {trip.day_count || 0}
|
||||||
|
</div>
|
||||||
|
<div className="hidden md:flex" style={{ alignItems: 'center', gap: 4, fontSize: 12, color: 'var(--text-muted)' }}>
|
||||||
|
<MapPin size={11} /> {trip.place_count || 0}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div style={{ display: 'flex', gap: 4, flexShrink: 0 }} onClick={e => e.stopPropagation()}>
|
||||||
|
<CardAction onClick={() => onEdit(trip)} icon={<Edit2 size={12} />} label="" />
|
||||||
|
<CardAction onClick={() => onArchive(trip.id)} icon={<Archive size={12} />} label="" />
|
||||||
|
<CardAction onClick={() => onDelete(trip)} icon={<Trash2 size={12} />} label="" danger />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// ── Archived Trip Row ────────────────────────────────────────────────────────
|
// ── Archived Trip Row ────────────────────────────────────────────────────────
|
||||||
interface ArchivedRowProps {
|
interface ArchivedRowProps {
|
||||||
trip: DashboardTrip
|
trip: DashboardTrip
|
||||||
@@ -429,6 +526,15 @@ export default function DashboardPage(): React.ReactElement {
|
|||||||
const [editingTrip, setEditingTrip] = useState<DashboardTrip | null>(null)
|
const [editingTrip, setEditingTrip] = useState<DashboardTrip | null>(null)
|
||||||
const [showArchived, setShowArchived] = useState<boolean>(false)
|
const [showArchived, setShowArchived] = useState<boolean>(false)
|
||||||
const [showWidgetSettings, setShowWidgetSettings] = useState<boolean | 'mobile'>(false)
|
const [showWidgetSettings, setShowWidgetSettings] = useState<boolean | 'mobile'>(false)
|
||||||
|
const [viewMode, setViewMode] = useState<'grid' | 'list'>(() => (localStorage.getItem('trek_dashboard_view') as 'grid' | 'list') || 'grid')
|
||||||
|
|
||||||
|
const toggleViewMode = () => {
|
||||||
|
setViewMode(prev => {
|
||||||
|
const next = prev === 'grid' ? 'list' : 'grid'
|
||||||
|
localStorage.setItem('trek_dashboard_view', next)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
@@ -554,6 +660,22 @@ export default function DashboardPage(): React.ReactElement {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', gap: 8, alignItems: 'stretch' }}>
|
<div style={{ display: 'flex', gap: 8, alignItems: 'stretch' }}>
|
||||||
|
{/* View mode toggle */}
|
||||||
|
<button
|
||||||
|
onClick={toggleViewMode}
|
||||||
|
title={viewMode === 'grid' ? t('dashboard.listView') : t('dashboard.gridView')}
|
||||||
|
style={{
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
padding: '0 14px',
|
||||||
|
background: 'var(--bg-card)', border: '1px solid var(--border-primary)', borderRadius: 12,
|
||||||
|
cursor: 'pointer', color: 'var(--text-faint)', fontFamily: 'inherit',
|
||||||
|
transition: 'background 0.15s, border-color 0.15s',
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => { e.currentTarget.style.background = 'var(--bg-hover)'; e.currentTarget.style.borderColor = 'var(--text-faint)' }}
|
||||||
|
onMouseLeave={e => { e.currentTarget.style.background = 'var(--bg-card)'; e.currentTarget.style.borderColor = 'var(--border-primary)' }}
|
||||||
|
>
|
||||||
|
{viewMode === 'grid' ? <List size={15} /> : <LayoutGrid size={15} />}
|
||||||
|
</button>
|
||||||
{/* Widget settings */}
|
{/* Widget settings */}
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowWidgetSettings(s => s ? false : true)}
|
onClick={() => setShowWidgetSettings(s => s ? false : true)}
|
||||||
@@ -655,8 +777,8 @@ export default function DashboardPage(): React.ReactElement {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Spotlight */}
|
{/* Spotlight (grid mode only) */}
|
||||||
{!isLoading && spotlight && (
|
{!isLoading && spotlight && viewMode === 'grid' && (
|
||||||
<SpotlightCard
|
<SpotlightCard
|
||||||
trip={spotlight}
|
trip={spotlight}
|
||||||
t={t} locale={locale} dark={dark}
|
t={t} locale={locale} dark={dark}
|
||||||
@@ -667,21 +789,37 @@ export default function DashboardPage(): React.ReactElement {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Rest grid */}
|
{/* Trips — grid or list */}
|
||||||
{!isLoading && rest.length > 0 && (
|
{!isLoading && (viewMode === 'grid' ? rest : trips).length > 0 && (
|
||||||
<div className="trip-grid" style={{ display: 'grid', gap: 16, marginBottom: 40 }}>
|
viewMode === 'grid' ? (
|
||||||
{rest.map(trip => (
|
<div className="trip-grid" style={{ display: 'grid', gap: 16, marginBottom: 40 }}>
|
||||||
<TripCard
|
{rest.map(trip => (
|
||||||
key={trip.id}
|
<TripCard
|
||||||
trip={trip}
|
key={trip.id}
|
||||||
t={t} locale={locale}
|
trip={trip}
|
||||||
onEdit={tr => { setEditingTrip(tr); setShowForm(true) }}
|
t={t} locale={locale}
|
||||||
onDelete={handleDelete}
|
onEdit={tr => { setEditingTrip(tr); setShowForm(true) }}
|
||||||
onArchive={handleArchive}
|
onDelete={handleDelete}
|
||||||
onClick={tr => navigate(`/trips/${tr.id}`)}
|
onArchive={handleArchive}
|
||||||
/>
|
onClick={tr => navigate(`/trips/${tr.id}`)}
|
||||||
))}
|
/>
|
||||||
</div>
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, marginBottom: 40 }}>
|
||||||
|
{trips.map(trip => (
|
||||||
|
<TripListItem
|
||||||
|
key={trip.id}
|
||||||
|
trip={trip}
|
||||||
|
t={t} locale={locale}
|
||||||
|
onEdit={tr => { setEditingTrip(tr); setShowForm(true) }}
|
||||||
|
onDelete={handleDelete}
|
||||||
|
onArchive={handleArchive}
|
||||||
|
onClick={tr => navigate(`/trips/${tr.id}`)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Archived section */}
|
{/* Archived section */}
|
||||||
|
|||||||
Reference in New Issue
Block a user