Add month-calendar view for events & tours (1.2.34)
Events and tours could only be browsed as a card list — there was no way to see what's happening on a given date, which is exactly the clarity the calendar ask called for. New EventsCalendar component: a purpose-built month grid (not the date-picker) with: - Prev/next/today month navigation - Per-day item dots (max 3 + overflow count) with correct multi-day overlap handling for ranged events and competition tours - A selected-day agenda panel showing title, time (or date range / "All day" for tours), location, and RSVP going counts, each linking straight to the detail page - Month total footer, empty-day state, full responsiveness, and aria-labels / aria-pressed for accessibility Wired a List⇄Calendar layout toggle into EventsPage for both the events and tours tabs. Calendar mode reuses the already-filtered event/tour sets (search, trust, type filters all still apply), hides the list-only date-window chips, and shows a matching skeleton while loading. Tours without a concrete date range are omitted from the calendar (they have no fixed date) and remain browsable in list view. Going counts are threaded through from the existing batch RSVP query, so the calendar adds no extra relay load. Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
This commit is contained in:
parent
9d069cb2f0
commit
f253fc55fa
3 changed files with 475 additions and 7 deletions
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"name": "skate-spots",
|
||||
"private": true,
|
||||
"version": "1.2.33",
|
||||
"version": "1.2.34",
|
||||
"type": "module",
|
||||
"homepage": "https://skate-spots.com",
|
||||
"scripts": {
|
||||
|
|
|
|||
374
src/components/EventsCalendar.tsx
Normal file
374
src/components/EventsCalendar.tsx
Normal file
|
|
@ -0,0 +1,374 @@
|
|||
import { useMemo, useState, useRef, useEffect } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { ChevronLeft, ChevronRight, Calendar as CalendarIcon, Route, MapPin, Clock, Users } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { SkateEvent, SkateTour } from '@/lib/types';
|
||||
|
||||
// ─── Types ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface CalendarItem {
|
||||
id: string;
|
||||
kind: 'event' | 'tour';
|
||||
title: string;
|
||||
/** Start of the item (unix seconds). */
|
||||
start: number;
|
||||
/** End of the item (unix seconds), if multi-day/ranged. */
|
||||
end?: number;
|
||||
location?: string;
|
||||
to: string;
|
||||
/** RSVP "going" count for events. */
|
||||
goingCount?: number;
|
||||
}
|
||||
|
||||
interface EventsCalendarProps {
|
||||
events: SkateEvent[];
|
||||
tours: SkateTour[];
|
||||
/** RSVP going counts keyed by event id (optional). */
|
||||
goingCounts?: Map<string, number>;
|
||||
/** Which kinds to display. */
|
||||
show: 'events' | 'tours';
|
||||
}
|
||||
|
||||
// ─── Date helpers (local time) ────────────────────────────────────────────────
|
||||
|
||||
const DAY_MS = 86_400_000;
|
||||
const WEEKDAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
||||
const MONTHS = [
|
||||
'January', 'February', 'March', 'April', 'May', 'June',
|
||||
'July', 'August', 'September', 'October', 'November', 'December',
|
||||
];
|
||||
|
||||
/** Midnight (local) for a given date. */
|
||||
function startOfDay(d: Date): Date {
|
||||
return new Date(d.getFullYear(), d.getMonth(), d.getDate());
|
||||
}
|
||||
|
||||
/** A stable YYYY-MM-DD key in local time. */
|
||||
function dayKey(d: Date): string {
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function sameDay(a: Date, b: Date): boolean {
|
||||
return a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate();
|
||||
}
|
||||
|
||||
/** Build the 6-week (42-cell) grid for a month, starting on Sunday. */
|
||||
function buildGrid(view: Date): Date[] {
|
||||
const first = new Date(view.getFullYear(), view.getMonth(), 1);
|
||||
const gridStart = new Date(first);
|
||||
gridStart.setDate(first.getDate() - first.getDay()); // back to Sunday
|
||||
const cells: Date[] = [];
|
||||
for (let i = 0; i < 42; i++) {
|
||||
cells.push(new Date(gridStart.getTime() + i * DAY_MS));
|
||||
}
|
||||
return cells;
|
||||
}
|
||||
|
||||
// ─── Item mapping ─────────────────────────────────────────────────────────────
|
||||
|
||||
function eventsToItems(events: SkateEvent[], goingCounts?: Map<string, number>): CalendarItem[] {
|
||||
return events.map((e) => ({
|
||||
id: e.id,
|
||||
kind: 'event' as const,
|
||||
title: e.title,
|
||||
start: e.start,
|
||||
end: e.end,
|
||||
location: e.location,
|
||||
to: `/event/${e.pubkey}/${e.identifier}`,
|
||||
goingCount: goingCounts?.get(e.event.id) ?? goingCounts?.get(e.id) ?? 0,
|
||||
}));
|
||||
}
|
||||
|
||||
function toursToItems(tours: SkateTour[]): CalendarItem[] {
|
||||
// Only tours with a concrete date range belong on a calendar (mostly
|
||||
// competition/"destruction" tours). Open-ended local guides have no fixed
|
||||
// date and are better browsed as a list.
|
||||
return tours
|
||||
.filter((t) => typeof t.startDate === 'number')
|
||||
.map((t) => ({
|
||||
id: t.id,
|
||||
kind: 'tour' as const,
|
||||
title: t.title,
|
||||
start: t.startDate as number,
|
||||
end: t.endDate,
|
||||
location: t.location ?? t.cities?.[0] ?? t.countries?.[0],
|
||||
to: `/tour/${t.pubkey}/${t.identifier}`,
|
||||
}));
|
||||
}
|
||||
|
||||
/** Does an item overlap a given calendar day? */
|
||||
function itemOnDay(item: CalendarItem, day: Date): boolean {
|
||||
const dayStart = startOfDay(day).getTime() / 1000;
|
||||
const dayEnd = dayStart + 86_400;
|
||||
const s = item.start;
|
||||
const e = item.end && item.end > item.start ? item.end : item.start;
|
||||
return s < dayEnd && e >= dayStart;
|
||||
}
|
||||
|
||||
function formatTime(unix: number): string {
|
||||
return new Date(unix * 1000).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' });
|
||||
}
|
||||
|
||||
function formatDayLong(d: Date): string {
|
||||
return d.toLocaleDateString([], { weekday: 'long', month: 'long', day: 'numeric' });
|
||||
}
|
||||
|
||||
// ─── Component ────────────────────────────────────────────────────────────────
|
||||
|
||||
export function EventsCalendar({ events, tours, goingCounts, show }: EventsCalendarProps) {
|
||||
const today = useMemo(() => startOfDay(new Date()), []);
|
||||
const [view, setView] = useState<Date>(() => new Date(today.getFullYear(), today.getMonth(), 1));
|
||||
const [selected, setSelected] = useState<Date>(today);
|
||||
|
||||
const accent = show === 'events'
|
||||
? { dot: 'bg-[#ccff00]', ring: 'ring-[#ccff00]', text: 'text-[#ccff00]', soft: 'bg-[#ccff00]/15', border: 'border-[#ccff00]/40', solid: 'bg-[#ccff00] text-black' }
|
||||
: { dot: 'bg-cyan-400', ring: 'ring-cyan-400', text: 'text-cyan-400', soft: 'bg-cyan-500/15', border: 'border-cyan-500/40', solid: 'bg-cyan-500 text-white' };
|
||||
|
||||
const items = useMemo(
|
||||
() => (show === 'events' ? eventsToItems(events, goingCounts) : toursToItems(tours)),
|
||||
[show, events, tours, goingCounts],
|
||||
);
|
||||
|
||||
// Map of dayKey -> items on that day, for quick dot rendering.
|
||||
const grid = useMemo(() => buildGrid(view), [view]);
|
||||
const itemsByDay = useMemo(() => {
|
||||
const map = new Map<string, CalendarItem[]>();
|
||||
for (const cell of grid) {
|
||||
const onDay = items.filter((it) => itemOnDay(it, cell));
|
||||
if (onDay.length) map.set(dayKey(cell), onDay.sort((a, b) => a.start - b.start));
|
||||
}
|
||||
return map;
|
||||
}, [grid, items]);
|
||||
|
||||
const selectedItems = useMemo(
|
||||
() => items.filter((it) => itemOnDay(it, selected)).sort((a, b) => a.start - b.start),
|
||||
[items, selected],
|
||||
);
|
||||
|
||||
const goToMonth = (delta: number) => {
|
||||
setView((v) => new Date(v.getFullYear(), v.getMonth() + delta, 1));
|
||||
};
|
||||
|
||||
const goToToday = () => {
|
||||
setView(new Date(today.getFullYear(), today.getMonth(), 1));
|
||||
setSelected(today);
|
||||
};
|
||||
|
||||
// When user changes the month via arrows, keep the selection sensible:
|
||||
// if the selected day is no longer in view, select the 1st of the month.
|
||||
const prevViewRef = useRef(view);
|
||||
useEffect(() => {
|
||||
if (prevViewRef.current.getTime() !== view.getTime()) {
|
||||
prevViewRef.current = view;
|
||||
if (selected.getMonth() !== view.getMonth() || selected.getFullYear() !== view.getFullYear()) {
|
||||
// If today is in the viewed month, prefer today; else the 1st.
|
||||
if (today.getMonth() === view.getMonth() && today.getFullYear() === view.getFullYear()) {
|
||||
setSelected(today);
|
||||
} else {
|
||||
setSelected(new Date(view.getFullYear(), view.getMonth(), 1));
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [view, selected, today]);
|
||||
|
||||
const totalThisMonth = useMemo(
|
||||
() => items.filter((it) => {
|
||||
const s = new Date(it.start * 1000);
|
||||
const e = it.end ? new Date(it.end * 1000) : s;
|
||||
// Overlaps the viewed month?
|
||||
const monthStart = new Date(view.getFullYear(), view.getMonth(), 1);
|
||||
const monthEnd = new Date(view.getFullYear(), view.getMonth() + 1, 1);
|
||||
return e >= monthStart && s < monthEnd;
|
||||
}).length,
|
||||
[items, view],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-[minmax(0,1fr)_360px] gap-4 md:gap-6">
|
||||
{/* ── Month grid ── */}
|
||||
<div className="rounded-2xl border border-white/10 bg-white/[0.02] p-3 md:p-5">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-baseline gap-2 min-w-0">
|
||||
<h2 className="text-lg md:text-2xl font-black uppercase tracking-tight truncate">
|
||||
{MONTHS[view.getMonth()]}
|
||||
</h2>
|
||||
<span className="text-gray-500 text-lg md:text-2xl font-black">{view.getFullYear()}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={goToToday}
|
||||
className="rounded-full border border-white/10 bg-white/5 px-3 py-1.5 text-[11px] font-bold uppercase tracking-wide text-gray-300 transition-all hover:border-white/25 hover:text-white active:scale-95"
|
||||
>
|
||||
Today
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Previous month"
|
||||
onClick={() => goToMonth(-1)}
|
||||
className="rounded-full border border-white/10 bg-white/5 p-2 text-gray-300 transition-all hover:border-white/25 hover:text-white active:scale-95"
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Next month"
|
||||
onClick={() => goToMonth(1)}
|
||||
className="rounded-full border border-white/10 bg-white/5 p-2 text-gray-300 transition-all hover:border-white/25 hover:text-white active:scale-95"
|
||||
>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Weekday labels */}
|
||||
<div className="grid grid-cols-7 gap-1 md:gap-1.5 mb-1 md:mb-2">
|
||||
{WEEKDAYS.map((wd) => (
|
||||
<div key={wd} className="text-center text-[10px] md:text-xs font-bold uppercase tracking-wider text-gray-500 py-1">
|
||||
<span className="md:hidden">{wd[0]}</span>
|
||||
<span className="hidden md:inline">{wd}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Day cells */}
|
||||
<div className="grid grid-cols-7 gap-1 md:gap-1.5">
|
||||
{grid.map((cell) => {
|
||||
const inMonth = cell.getMonth() === view.getMonth();
|
||||
const isToday = sameDay(cell, today);
|
||||
const isSelected = sameDay(cell, selected);
|
||||
const dayItems = itemsByDay.get(dayKey(cell)) ?? [];
|
||||
const has = dayItems.length > 0;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={cell.getTime()}
|
||||
type="button"
|
||||
onClick={() => setSelected(startOfDay(cell))}
|
||||
aria-label={`${formatDayLong(cell)}${has ? `, ${dayItems.length} ${show}` : ''}`}
|
||||
aria-pressed={isSelected}
|
||||
className={cn(
|
||||
'relative aspect-square rounded-lg md:rounded-xl border flex flex-col items-center justify-start pt-1 md:pt-1.5 transition-all active:scale-95',
|
||||
isSelected
|
||||
? cn('ring-2', accent.ring, accent.soft, accent.border)
|
||||
: 'border-white/5 hover:border-white/20',
|
||||
!inMonth && 'opacity-35',
|
||||
has && !isSelected && 'bg-white/[0.03]',
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
'text-[11px] md:text-sm font-bold leading-none flex items-center justify-center',
|
||||
isToday
|
||||
? cn('h-5 w-5 md:h-6 md:w-6 rounded-full', accent.solid)
|
||||
: inMonth ? 'text-gray-200' : 'text-gray-500',
|
||||
)}
|
||||
>
|
||||
{cell.getDate()}
|
||||
</span>
|
||||
|
||||
{/* Item dots (max 3, then +N) */}
|
||||
{has && (
|
||||
<div className="mt-auto mb-1 md:mb-1.5 flex items-center gap-0.5">
|
||||
{dayItems.slice(0, 3).map((it, i) => (
|
||||
<span key={i} className={cn('h-1 w-1 md:h-1.5 md:w-1.5 rounded-full', accent.dot)} />
|
||||
))}
|
||||
{dayItems.length > 3 && (
|
||||
<span className="text-[7px] md:text-[9px] font-bold text-gray-400 leading-none ml-0.5">
|
||||
+{dayItems.length - 3}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Footer summary */}
|
||||
<div className="mt-4 flex items-center justify-between text-xs text-gray-500">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className={cn('h-2 w-2 rounded-full', accent.dot)} />
|
||||
{show === 'events' ? 'Event' : 'Tour'} day
|
||||
</span>
|
||||
<span>
|
||||
<span className={cn('font-bold', accent.text)}>{totalThisMonth}</span>{' '}
|
||||
{show === 'events' ? 'event' : 'tour'}{totalThisMonth === 1 ? '' : 's'} this month
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Selected-day agenda ── */}
|
||||
<div className="rounded-2xl border border-white/10 bg-white/[0.02] p-4 md:p-5 lg:sticky lg:top-24 lg:self-start">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
{show === 'events'
|
||||
? <CalendarIcon className={cn('h-4 w-4', accent.text)} />
|
||||
: <Route className={cn('h-4 w-4', accent.text)} />}
|
||||
<h3 className="text-sm font-black uppercase tracking-wide">
|
||||
{sameDay(selected, today) ? 'Today' : formatDayLong(selected)}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
{selectedItems.length > 0 ? (
|
||||
<div className="space-y-2.5">
|
||||
{selectedItems.map((it) => {
|
||||
const multiDay = it.end && it.end > it.start && !sameDay(new Date(it.start * 1000), new Date(it.end * 1000));
|
||||
return (
|
||||
<Link
|
||||
key={it.id}
|
||||
to={it.to}
|
||||
className={cn(
|
||||
'group block rounded-xl border p-3 transition-all hover:bg-white/[0.04] active:scale-[0.99]',
|
||||
accent.border,
|
||||
'bg-white/[0.02]',
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start gap-2.5">
|
||||
<span className={cn('mt-1 h-2 w-2 flex-shrink-0 rounded-full', accent.dot)} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="font-bold text-sm leading-snug text-white group-hover:underline decoration-white/30 truncate">
|
||||
{it.title}
|
||||
</p>
|
||||
<div className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-[11px] text-gray-400">
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock className="h-3 w-3" />
|
||||
{multiDay
|
||||
? `${new Date(it.start * 1000).toLocaleDateString([], { month: 'short', day: 'numeric' })} – ${new Date((it.end as number) * 1000).toLocaleDateString([], { month: 'short', day: 'numeric' })}`
|
||||
: it.kind === 'tour'
|
||||
? 'All day'
|
||||
: formatTime(it.start)}
|
||||
</span>
|
||||
{it.location && (
|
||||
<span className="flex items-center gap-1 truncate">
|
||||
<MapPin className="h-3 w-3 flex-shrink-0" />
|
||||
<span className="truncate">{it.location}</span>
|
||||
</span>
|
||||
)}
|
||||
{it.kind === 'event' && (it.goingCount ?? 0) > 0 && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Users className="h-3 w-3" />
|
||||
{it.goingCount} going
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-xl border border-dashed border-white/10 py-8 px-4 text-center">
|
||||
<p className="text-sm text-gray-500">
|
||||
Nothing on this day.
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-gray-600">
|
||||
Tap a highlighted day to see what's on.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -5,7 +5,9 @@ import { Layout } from '@/components/Layout';
|
|||
import { EventCard, EventCardSkeleton } from '@/components/EventCard';
|
||||
import { TourCard, TourCardSkeleton } from '@/components/TourCard';
|
||||
import { HostingDashboard } from '@/components/HostingDashboard';
|
||||
import { EventsCalendar } from '@/components/EventsCalendar';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet';
|
||||
import { type TrustFilterLevel, filterByTrustLevel } from '@/components/TrustFilter';
|
||||
import { type SortOption } from '@/components/SortDropdown';
|
||||
|
|
@ -39,6 +41,8 @@ import {
|
|||
CheckCircle2,
|
||||
ChevronDown,
|
||||
Megaphone,
|
||||
LayoutGrid,
|
||||
CalendarDays,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
|
|
@ -728,6 +732,30 @@ function TourSkeletonGrid({ count = 6 }: { count?: number }) {
|
|||
);
|
||||
}
|
||||
|
||||
function CalendarSkeleton() {
|
||||
return (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-[minmax(0,1fr)_360px] gap-4 md:gap-6">
|
||||
<div className="rounded-2xl border border-white/10 bg-white/[0.02] p-3 md:p-5">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<Skeleton className="h-8 w-40" />
|
||||
<Skeleton className="h-8 w-28" />
|
||||
</div>
|
||||
<div className="grid grid-cols-7 gap-1.5">
|
||||
{Array.from({ length: 42 }).map((_, i) => (
|
||||
<Skeleton key={i} className="aspect-square rounded-xl" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-white/10 bg-white/[0.02] p-4 md:p-5 space-y-3">
|
||||
<Skeleton className="h-5 w-32" />
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-16 w-full rounded-xl" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Empty states ─────────────────────────────────────────────────────────────
|
||||
|
||||
function EmptyState({
|
||||
|
|
@ -799,6 +827,8 @@ export default function EventsPage() {
|
|||
|
||||
// ── View state ──────────────────────────────────────────────────────────────
|
||||
const [mainView, setMainView] = useState<MainView>('events');
|
||||
// Grid (list) vs month-calendar layout. Applies to events & tours only.
|
||||
const [layout, setLayout] = useState<'list' | 'calendar'>('list');
|
||||
|
||||
// ── Event filters ───────────────────────────────────────────────────────────
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
|
|
@ -845,6 +875,15 @@ export default function EventsPage() {
|
|||
const eventCoords = useMemo(() => (events ?? []).map((e) => eventCoord(e)), [events]);
|
||||
const batchRsvps = useBatchEventRsvps(eventCoords);
|
||||
|
||||
// Going counts keyed by SkateEvent.id, for the calendar agenda.
|
||||
const goingById = useMemo(() => {
|
||||
const map = new Map<string, number>();
|
||||
for (const e of events ?? []) {
|
||||
map.set(e.id, batchRsvps.get(eventCoord(e))?.goingCount ?? 0);
|
||||
}
|
||||
return map;
|
||||
}, [events, batchRsvps]);
|
||||
|
||||
// ── Counts for tab badges ─────────────────────────────────────────────────────
|
||||
const rawCounts = useMemo(() => ({
|
||||
events: events?.length ?? 0,
|
||||
|
|
@ -1086,8 +1125,8 @@ export default function EventsPage() {
|
|||
totalCount={totalCount}
|
||||
/>
|
||||
|
||||
{/* ── Quick date-window chips (events · upcoming) ── */}
|
||||
{mainView === 'events' && activeTab === 'upcoming' && (
|
||||
{/* ── Quick date-window chips (events · upcoming · list) ── */}
|
||||
{mainView === 'events' && activeTab === 'upcoming' && layout === 'list' && (
|
||||
<div className="mt-2 md:mt-4 grid grid-cols-5 gap-1.5 md:flex md:items-center md:gap-2">
|
||||
{(['all', 'today', 'weekend', 'week', 'month'] as DateWindow[]).map((w) => {
|
||||
const active = dateWindow === w;
|
||||
|
|
@ -1113,14 +1152,69 @@ export default function EventsPage() {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Layout toggle (list ⇄ calendar) — events & tours only ── */}
|
||||
{mainView !== 'hosting' && (
|
||||
<div className="mt-3 md:mt-5 flex items-center justify-end">
|
||||
<div className="inline-flex rounded-full border border-white/10 bg-white/5 p-0.5">
|
||||
{([
|
||||
{ key: 'list' as const, label: 'List', Icon: LayoutGrid },
|
||||
{ key: 'calendar' as const, label: 'Calendar', Icon: CalendarDays },
|
||||
]).map(({ key, label, Icon }) => {
|
||||
const active = layout === key;
|
||||
const activeCls = mainView === 'events'
|
||||
? 'bg-[#ccff00] text-black'
|
||||
: 'bg-cyan-500 text-white';
|
||||
return (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
onClick={() => setLayout(key)}
|
||||
aria-pressed={active}
|
||||
className={cn(
|
||||
'flex items-center gap-1.5 rounded-full px-3 py-1.5 text-[11px] font-bold uppercase tracking-wide transition-all active:scale-95',
|
||||
active ? activeCls : 'text-gray-400 hover:text-white',
|
||||
)}
|
||||
>
|
||||
<Icon className="h-3.5 w-3.5" />
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Results ── */}
|
||||
<div className="mt-3 md:mt-6">
|
||||
|
||||
{/* Hosting dashboard */}
|
||||
{mainView === 'hosting' && <HostingDashboard />}
|
||||
|
||||
{/* Events */}
|
||||
{mainView === 'events' && (
|
||||
{/* Calendar layout — events */}
|
||||
{mainView === 'events' && layout === 'calendar' && (
|
||||
eventsLoading ? (
|
||||
<CalendarSkeleton />
|
||||
) : (
|
||||
<EventsCalendar
|
||||
show="events"
|
||||
events={activeTab === 'upcoming' ? filteredEvents.upcoming : filteredEvents.past}
|
||||
tours={[]}
|
||||
goingCounts={goingById}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
|
||||
{/* Calendar layout — tours */}
|
||||
{mainView === 'tours' && layout === 'calendar' && (
|
||||
toursLoading ? (
|
||||
<CalendarSkeleton />
|
||||
) : (
|
||||
<EventsCalendar show="tours" events={[]} tours={filteredTours} />
|
||||
)
|
||||
)}
|
||||
|
||||
{/* Events (list) */}
|
||||
{mainView === 'events' && layout === 'list' && (
|
||||
eventsLoading ? (
|
||||
<SkeletonGrid />
|
||||
) : activeTab === 'upcoming' ? (
|
||||
|
|
@ -1169,8 +1263,8 @@ export default function EventsPage() {
|
|||
)
|
||||
)}
|
||||
|
||||
{/* Tours */}
|
||||
{mainView === 'tours' && (
|
||||
{/* Tours (list) */}
|
||||
{mainView === 'tours' && layout === 'list' && (
|
||||
toursLoading ? (
|
||||
<TourSkeletonGrid />
|
||||
) : filteredTours.length > 0 ? (
|
||||
|
|
|
|||
Loading…
Reference in a new issue