v1.3 - added an sqlite db and built data structure
This commit is contained in:
parent
b0fba52c0e
commit
5efdafbb97
37 changed files with 6414 additions and 1988 deletions
568
src/pages/sections/EventList-Cards.tsx
Normal file
568
src/pages/sections/EventList-Cards.tsx
Normal file
|
|
@ -0,0 +1,568 @@
|
|||
import { useEffect, useRef, useState } from "react";
|
||||
import { splitByStatus, useEvents } from "../../data/eventData.js";
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════
|
||||
EVENT LIST — CARDS
|
||||
|
||||
A band of event cards, as a peek carousel or a grid. Self
|
||||
contained: give it a filter and it fetches, so the same section
|
||||
appears three times on Retreats with a different `section` each
|
||||
time, and could appear on a region's page with `host` instead.
|
||||
|
||||
<EventListCards section="national" view="carousel" />
|
||||
<EventListCards host="northwest" view="grid" />
|
||||
|
||||
`view` and `accent` come from the page's section manifest.
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
|
||||
const LOGO_FILES = import.meta.glob("../../assets/event-logos/*.svg", {
|
||||
eager: true,
|
||||
import: "default",
|
||||
});
|
||||
|
||||
const LOGOS = Object.fromEntries(
|
||||
Object.entries(LOGO_FILES).map(([path, src]) => [path.split("/").pop(), src])
|
||||
);
|
||||
|
||||
const logoSrc = file => (file && LOGOS[file]) || null;
|
||||
|
||||
/* Last resort only. The API already falls back to the host
|
||||
organization's logo when an event doesn't name its own, so this
|
||||
fires only for an event with no host at all. */
|
||||
const DEFAULT_ORG_LOGO = null;
|
||||
|
||||
/* An <img> that removes itself if the file 404s. */
|
||||
function Logo({ file, alt = "", className }) {
|
||||
const [failed, setFailed] = useState(false);
|
||||
const src = logoSrc(file);
|
||||
if (!src || failed) return null;
|
||||
return (
|
||||
<img
|
||||
src={src}
|
||||
alt={alt}
|
||||
className={className}
|
||||
onError={() => setFailed(true)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const TEAL = "#138ba0"; // last-resort card color
|
||||
const CARD_BG = "#ffffff"; // sits under every card, gradient or not
|
||||
|
||||
/* ── Grid view ────────────────────────────────────────────────
|
||||
The grid fits as many columns as the screen allows, with no
|
||||
breakpoints: each card is at least CARD_MIN wide, and the
|
||||
columns share whatever space is left over.
|
||||
CARD_MIN narrowest a card may get before dropping a column
|
||||
MAX_COLS ceiling on columns, so cards don't get absurd on
|
||||
very wide monitors. GRID_MAX is derived from it.
|
||||
───────────────────────────────────────────────────────────── */
|
||||
const CARD_MIN = "32rem";
|
||||
const MAX_COLS = 4;
|
||||
const GRID_MAX = `calc(${MAX_COLS} * 38rem)`;
|
||||
const GRID_TEMPLATE = `repeat(auto-fill, minmax(min(${CARD_MIN}, 100%), 1fr))`;
|
||||
|
||||
/* Collapsed past-events strip: how much shows, and the downward
|
||||
fade applied while it's collapsed. */
|
||||
const PAST_PEEK = "10rem";
|
||||
const PAST_FADE = "linear-gradient(to bottom, black 0%, black 45%, transparent 100%)";
|
||||
|
||||
/* ── Carousel sizing ──────────────────────────────────────────
|
||||
CARD the card itself
|
||||
GAP space between cards — raise to push neighbors out
|
||||
FADE_DIST how far past the card's edge neighbors fade to nothing
|
||||
───────────────────────────────────────────────────────────── */
|
||||
const CARD = "min(48rem, 90vw)";
|
||||
const GAP = "5rem";
|
||||
const SLIDE = `calc(${CARD} + ${GAP})`;
|
||||
const HALF_SLIDE = `calc(${CARD} / 2)`;
|
||||
const FADE_DIST = "18rem";
|
||||
|
||||
const EDGE_FADE = `linear-gradient(to right,
|
||||
transparent calc(50% - (${HALF_SLIDE} + ${FADE_DIST})),
|
||||
black calc(50% - ${HALF_SLIDE}),
|
||||
black calc(50% + ${HALF_SLIDE}),
|
||||
transparent calc(50% + (${HALF_SLIDE} + ${FADE_DIST})))`;
|
||||
|
||||
/* Card layout CSS lives in index.css under the .ev- prefix. */
|
||||
|
||||
const InstagramIcon = ({ id = "ig-gradient" }) => (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
className="ig-icon w-6 h-6"
|
||||
style={{ "--ig-fill": `url(#${id})` }}
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id={id} x1="0%" y1="100%" x2="100%" y2="0%">
|
||||
<stop offset="0%" stopColor="#FEDA75" />
|
||||
<stop offset="25%" stopColor="#FA7E1E" />
|
||||
<stop offset="50%" stopColor="#D62976" />
|
||||
<stop offset="75%" stopColor="#962FBF" />
|
||||
<stop offset="100%" stopColor="#4F5BD5" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path d="M12 2.163c3.204 0 3.584.012 4.85.07 3.252.148 4.771 1.691 4.919 4.919.058 1.265.069 1.645.069 4.849 0 3.205-.012 3.584-.069 4.849-.149 3.225-1.664 4.771-4.919 4.919-1.266.058-1.644.07-4.85.07-3.204 0-3.584-.012-4.849-.07-3.26-.149-4.771-1.699-4.919-4.92-.058-1.265-.07-1.644-.07-4.849 0-3.204.013-3.583.07-4.849.149-3.227 1.664-4.771 4.919-4.919 1.266-.057 1.645-.069 4.849-.069zM12 0C8.741 0 8.333.014 7.053.072 2.695.272.273 2.69.073 7.052.014 8.333 0 8.741 0 12c0 3.259.014 3.668.072 4.948.2 4.358 2.618 6.78 6.98 6.98C8.333 23.986 8.741 24 12 24c3.259 0 3.668-.014 4.948-.072 4.354-.2 6.782-2.618 6.979-6.98.059-1.28.073-1.689.073-4.948 0-3.259-.014-3.667-.072-4.947-.196-4.354-2.617-6.78-6.979-6.98C15.668.014 15.259 0 12 0zm0 5.838a6.162 6.162 0 100 12.324 6.162 6.162 0 000-12.324zM12 16a4 4 0 110-8 4 4 0 010 8zm6.406-11.845a1.44 1.44 0 100 2.881 1.44 1.44 0 000-2.881z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════
|
||||
EVENT CARD — one component, two sizes.
|
||||
compact=false → the full card used in the carousel
|
||||
compact=true → the grid card: details left, text right, and a
|
||||
footer pinned to the bottom so every card in a
|
||||
row is the same height with aligned buttons.
|
||||
|
||||
Exported because it takes an event and nothing else: a region's
|
||||
page or a home strip can render one without the section around
|
||||
it.
|
||||
|
||||
Fields arrive pre-resolved from the API — `color` is the event's
|
||||
own or its host's, `status` is derived from the dates when it
|
||||
isn't set — so nothing here reimplements those rules.
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
export function Card({
|
||||
ev,
|
||||
defaultColor = TEAL,
|
||||
accent = TEAL,
|
||||
compact = false,
|
||||
interactive = true,
|
||||
}) {
|
||||
const past = ev.status === "past";
|
||||
const color = ev.color || defaultColor;
|
||||
const orgLogo = ev.org_logo || DEFAULT_ORG_LOGO;
|
||||
const eventLogo = ev.event_logo;
|
||||
const links = ev.links ?? [];
|
||||
const igHandle = ev.instagram || null;
|
||||
const igUrl = igHandle
|
||||
? `https://instagram.com/${igHandle.replace(/^@/, "")}`
|
||||
: null;
|
||||
|
||||
/* An ordered array, so a card can carry one paragraph or five
|
||||
without the component changing. */
|
||||
const descriptions = (ev.description ?? []).map((text, i) => (
|
||||
<p
|
||||
key={i}
|
||||
className={`leading-relaxed ${compact ? "text-base" : ""} ${
|
||||
i > 0 ? "mt-2" : ""
|
||||
}`}
|
||||
>
|
||||
{text}
|
||||
</p>
|
||||
));
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`rounded-3xl text-black overflow-hidden shadow-2xl h-full flex flex-col ${
|
||||
compact ? "ev-card" : ""
|
||||
}`}
|
||||
style={{
|
||||
border: `1px solid ${color}`,
|
||||
background: ev.gradient ? `${ev.gradient}, ${CARD_BG}` : CARD_BG,
|
||||
filter: past ? "saturate(0.75)" : "none",
|
||||
pointerEvents: interactive ? "auto" : "none",
|
||||
}}
|
||||
>
|
||||
<div className={`flex-1 flex flex-col ${compact ? "p-8" : "p-10"}`}>
|
||||
{compact ? (
|
||||
/* ── GRID CARD: layout driven by .ev-grid container queries ── */
|
||||
<div className="ev-grid mb-2">
|
||||
<Logo file={orgLogo} className="ev-ngu h-11 w-auto mb-4" />
|
||||
<div className="ev-info">
|
||||
<h3 className="text-3xl font-900 leading-tight">{ev.title}</h3>
|
||||
{ev.theme && (
|
||||
<p className="text-xl font-300 font-bold">"{ev.theme}"</p>
|
||||
)}
|
||||
{ev.date_label && <p className="text-xl">{ev.date_label}</p>}
|
||||
{ev.location_label && (
|
||||
<p className="text-xl">{ev.location_label}</p>
|
||||
)}
|
||||
</div>
|
||||
<Logo
|
||||
file={eventLogo}
|
||||
alt={ev.title}
|
||||
className="ev-image w-full h-auto object-contain"
|
||||
/>
|
||||
<div className="ev-desc">{descriptions}</div>
|
||||
</div>
|
||||
) : (
|
||||
/* ── FULL: details left, large event logo right ── */
|
||||
<>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-x-8 mb-4">
|
||||
<div className="md:col-span-2">
|
||||
<Logo file={orgLogo} className="h-15 w-auto mb-6" />
|
||||
<h3 className="text-4xl font-900">{ev.title}</h3>
|
||||
{ev.theme && (
|
||||
<p className="text-2xl font-300 font-bold">"{ev.theme}"</p>
|
||||
)}
|
||||
{ev.date_label && <p className="text-2xl">{ev.date_label}</p>}
|
||||
{ev.location_label && (
|
||||
<p className="text-2xl">{ev.location_label}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="md:col-span-1 flex items-center justify-end">
|
||||
<Logo
|
||||
file={eventLogo}
|
||||
alt={ev.title}
|
||||
className="w-full h-auto max-h-72 object-contain"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{descriptions}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Footer — mt-auto pins it to the bottom so buttons line up
|
||||
across every card in a grid row. */}
|
||||
{links.length > 0 ? (
|
||||
<div className={`flex flex-wrap justify-center gap-2 mt-auto ${compact ? "pt-6" : "pt-8 gap-3"}`}>
|
||||
{links.map(item => (
|
||||
<a
|
||||
key={item.label}
|
||||
href={item.url}
|
||||
className={`rounded-xl font-700 transition-all duration-200 hover:scale-105 text-center ${
|
||||
compact ? "py-2.5 px-5" : "py-2.5 px-6"
|
||||
}`}
|
||||
style={{ border: `1px solid ${color}` }}
|
||||
>
|
||||
{item.label}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
) : past ? (
|
||||
<p
|
||||
className="mt-auto text-center font-600 pt-8"
|
||||
style={{ color: accent }}
|
||||
>
|
||||
This event has concluded — thank you to everyone who joined us!
|
||||
</p>
|
||||
) : (
|
||||
<div className={`mt-auto ${compact ? "pt-6" : "pt-8"}`}>
|
||||
<p className="text-center font-600" style={{ color: accent }}>
|
||||
{igHandle
|
||||
? "Registration has not opened yet, follow our instagram for more details."
|
||||
: "Registration has not opened yet — check back soon for more details."}
|
||||
</p>
|
||||
{igHandle && (
|
||||
<a
|
||||
href={igUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={`ig-link mt-4 w-fit mx-auto flex items-center justify-center rounded-xl font-700 transition-all duration-200 hover:scale-[1.02] ${
|
||||
compact ? "gap-3 py-2.5 px-5" : "gap-3 py-3 px-6"
|
||||
}`}
|
||||
style={{ border: `1px solid ${accent}`, color: accent }}
|
||||
>
|
||||
<InstagramIcon id={`ig-${ev.id}`} />
|
||||
{igHandle}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════
|
||||
TOGGLE — the control for the section heading's action bar
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
export function EventCardsToggle({ view, setView, accent }) {
|
||||
const btn = active => ({
|
||||
background: active ? accent : "transparent",
|
||||
color: active ? "#ffffff" : accent,
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
className="inline-flex rounded-xl overflow-hidden shrink-0"
|
||||
style={{ border: `1px solid ${accent}` }}
|
||||
role="group"
|
||||
aria-label="Change how events are displayed"
|
||||
>
|
||||
<button
|
||||
onClick={() => setView("carousel")}
|
||||
aria-pressed={view === "carousel"}
|
||||
className="flex items-center gap-2 py-2 px-4 font-700 text-sm transition-colors duration-200"
|
||||
style={btn(view === "carousel")}
|
||||
>
|
||||
<svg viewBox="0 0 24 24" className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<rect x="7" y="5" width="10" height="14" rx="2" />
|
||||
<path d="M3.5 8v8M20.5 8v8" />
|
||||
</svg>
|
||||
Carousel
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setView("grid")}
|
||||
aria-pressed={view === "grid"}
|
||||
className="flex items-center gap-2 py-2 px-4 font-700 text-sm transition-colors duration-200"
|
||||
style={btn(view === "grid")}
|
||||
>
|
||||
<svg viewBox="0 0 24 24" className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<rect x="3" y="3" width="7" height="7" rx="1.5" />
|
||||
<rect x="14" y="3" width="7" height="7" rx="1.5" />
|
||||
<rect x="3" y="14" width="7" height="7" rx="1.5" />
|
||||
<rect x="14" y="14" width="7" height="7" rx="1.5" />
|
||||
</svg>
|
||||
Grid
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════
|
||||
SECTION
|
||||
Fetches its own slice of the events, then draws it as a carousel
|
||||
or a grid.
|
||||
|
||||
An empty list means three different things now — still loading,
|
||||
failed, or genuinely nothing scheduled — and they read very
|
||||
differently to someone waiting, so they're distinguished rather
|
||||
than all falling through to "coming soon".
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
export default function EventListCards({
|
||||
section,
|
||||
host,
|
||||
status,
|
||||
view = "carousel",
|
||||
accent = TEAL,
|
||||
defaultColor,
|
||||
empty = "· Events coming soon, stay connected for announcements ·",
|
||||
}) {
|
||||
const { events, loading, error } = useEvents({ section, host, status });
|
||||
const cardColor = defaultColor ?? accent;
|
||||
|
||||
const [index, setIndex] = useState(0);
|
||||
const [showPast, setShowPast] = useState(false);
|
||||
|
||||
/* Open on the first upcoming event. The list is empty on the
|
||||
first render, so this can't be a useState initialiser — it has
|
||||
to wait for the data and then run once. Clearing the guard when
|
||||
the list empties means a refetch re-seeds. */
|
||||
const seeded = useRef(false);
|
||||
useEffect(() => {
|
||||
if (events.length === 0) {
|
||||
seeded.current = false;
|
||||
return;
|
||||
}
|
||||
if (seeded.current) return;
|
||||
seeded.current = true;
|
||||
|
||||
const first = events.findIndex(e => e.status === "upcoming");
|
||||
setIndex(first === -1 ? events.length - 1 : first);
|
||||
}, [events]);
|
||||
|
||||
// Grid view splits the list; the carousel still shows everything.
|
||||
const { upcoming, past: pastEvents } = splitByStatus(events);
|
||||
|
||||
const prev = () => setIndex(i => Math.max(0, i - 1));
|
||||
const next = () => setIndex(i => Math.min(events.length - 1, i + 1));
|
||||
|
||||
// Arrows and dots follow the section accent, not the active card.
|
||||
const arrowStyle = enabled => ({
|
||||
border: `1px solid ${accent}`,
|
||||
background: "rgba(255,255,255,0.85)",
|
||||
color: enabled ? accent : "#b8c6c9",
|
||||
cursor: enabled ? "pointer" : "default",
|
||||
opacity: enabled ? 1 : 0.4,
|
||||
});
|
||||
|
||||
const notice = text => (
|
||||
<p className="max-w-6xl mx-auto px-6 font-600" style={{ color: accent }}>
|
||||
{text}
|
||||
</p>
|
||||
);
|
||||
|
||||
if (loading && events.length === 0) {
|
||||
return (
|
||||
<div className="overflow-hidden">
|
||||
<div
|
||||
className="mx-auto px-8 md:px-12 lg:px-16"
|
||||
style={{ maxWidth: GRID_MAX }}
|
||||
>
|
||||
<div className="skeleton" role="status" aria-label="Loading events" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error && events.length === 0) {
|
||||
return (
|
||||
<div className="overflow-hidden">
|
||||
{notice("· Events couldn't be loaded just now — please try again shortly ·")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="overflow-hidden">
|
||||
{events.length === 0 ? (
|
||||
notice(empty)
|
||||
) : view === "grid" ? (
|
||||
/* ── GRID VIEW — upcoming first, past events collapsed below ── */
|
||||
<div className="mx-auto px-8 md:px-12 lg:px-16" style={{ maxWidth: GRID_MAX }}>
|
||||
{upcoming.length > 0 && (
|
||||
<div
|
||||
className="grid gap-6 items-stretch"
|
||||
style={{ gridTemplateColumns: GRID_TEMPLATE }}
|
||||
>
|
||||
{upcoming.map(ev => (
|
||||
<Card
|
||||
key={ev.id}
|
||||
ev={ev}
|
||||
defaultColor={cardColor}
|
||||
accent={accent}
|
||||
compact
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{pastEvents.length > 0 && (
|
||||
<div className="mt-12">
|
||||
<h3
|
||||
className="text-2xl font-800 mb-6"
|
||||
style={{ color: accent, opacity: 0.85 }}
|
||||
>
|
||||
Past Events
|
||||
</h3>
|
||||
|
||||
{/* Collapsed: clipped to PAST_PEEK and faded out at the
|
||||
bottom. Expanded: full height, no mask. */}
|
||||
<div
|
||||
className="relative transition-all duration-500 ease-out"
|
||||
style={{
|
||||
maxHeight: showPast ? "none" : PAST_PEEK,
|
||||
overflow: showPast ? "visible" : "hidden",
|
||||
maskImage: showPast ? "none" : PAST_FADE,
|
||||
WebkitMaskImage: showPast ? "none" : PAST_FADE,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="grid gap-6 items-stretch"
|
||||
style={{ gridTemplateColumns: GRID_TEMPLATE }}
|
||||
aria-hidden={!showPast}
|
||||
>
|
||||
{pastEvents.map(ev => (
|
||||
<Card
|
||||
key={ev.id}
|
||||
ev={ev}
|
||||
defaultColor={cardColor}
|
||||
accent={accent}
|
||||
compact
|
||||
interactive={showPast}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-center mt-6">
|
||||
<button
|
||||
onClick={() => setShowPast(v => !v)}
|
||||
aria-expanded={showPast}
|
||||
className="flex items-center gap-2 py-2.5 px-6 rounded-xl font-700 transition-all duration-200 hover:scale-105"
|
||||
style={{ border: `1px solid ${accent}`, color: accent }}
|
||||
>
|
||||
{showPast
|
||||
? "Hide past events"
|
||||
: `See past events (${pastEvents.length})`}
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
className="h-4 w-4 transition-transform duration-300"
|
||||
style={{ transform: showPast ? "rotate(180deg)" : "none" }}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M6 9l6 6-6 6" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
/* ── CAROUSEL VIEW ── */
|
||||
<>
|
||||
<div className="relative mb-6">
|
||||
<div
|
||||
className="overflow-hidden"
|
||||
style={{ maskImage: EDGE_FADE, WebkitMaskImage: EDGE_FADE }}
|
||||
>
|
||||
<div
|
||||
className="flex items-stretch transition-transform duration-500 ease-out"
|
||||
style={{ transform: `translateX(calc(50% - ${index + 0.5} * ${SLIDE}))` }}
|
||||
>
|
||||
{events.map((ev, i) => {
|
||||
const active = i === index;
|
||||
return (
|
||||
<div
|
||||
key={ev.id}
|
||||
className="shrink-0"
|
||||
style={{
|
||||
width: SLIDE,
|
||||
padding: `0 calc(${GAP} / 2)`,
|
||||
cursor: active ? "default" : "pointer",
|
||||
}}
|
||||
onClick={() => !active && setIndex(i)}
|
||||
aria-hidden={!active}
|
||||
>
|
||||
<Card
|
||||
ev={ev}
|
||||
defaultColor={cardColor}
|
||||
accent={accent}
|
||||
interactive={active}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{events.length > 1 && (
|
||||
<>
|
||||
<button
|
||||
onClick={prev}
|
||||
disabled={index === 0}
|
||||
aria-label="Previous event"
|
||||
className="absolute left-2 md:left-6 top-1/2 -translate-y-1/2 z-10 h-12 w-12 rounded-full flex items-center justify-center text-2xl font-700 shadow-lg transition-all duration-200 hover:scale-110 disabled:hover:scale-100"
|
||||
style={arrowStyle(index > 0)}
|
||||
>
|
||||
‹
|
||||
</button>
|
||||
<button
|
||||
onClick={next}
|
||||
disabled={index === events.length - 1}
|
||||
aria-label="Next event"
|
||||
className="absolute right-2 md:right-6 top-1/2 -translate-y-1/2 z-10 h-12 w-12 rounded-full flex items-center justify-center text-2xl font-700 shadow-lg transition-all duration-200 hover:scale-110 disabled:hover:scale-100"
|
||||
style={arrowStyle(index < events.length - 1)}
|
||||
>
|
||||
›
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{events.length > 1 && (
|
||||
<div className="flex justify-center gap-2">
|
||||
{events.map((e, i) => (
|
||||
<button
|
||||
key={e.id}
|
||||
onClick={() => setIndex(i)}
|
||||
aria-label={`Go to ${e.title}`}
|
||||
className="h-2.5 rounded-full transition-all duration-200"
|
||||
style={{
|
||||
width: i === index ? "1.5rem" : "0.625rem",
|
||||
background: i === index ? accent : "#b8c6c9",
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue