Show longform image tags on posts, use shortened npubs for author names, and paginate the homepage meetups section with responsive load more. Co-authored-by: Cursor <cursoragent@cursor.com>
167 lines
6.7 KiB
TypeScript
167 lines
6.7 KiB
TypeScript
"use client";
|
|
|
|
import { useState } from "react";
|
|
import { MapPin, Clock, ArrowRight } from "lucide-react";
|
|
import Link from "next/link";
|
|
import { AddToCalendarButton } from "@/components/public/AddToCalendarDialog";
|
|
import { formatMeetupCivilDate } from "@/lib/meetupEventTime";
|
|
|
|
// Initial number of meetups shown before "Load more". Mobile is enforced via
|
|
// CSS (3) so there's no SSR/viewport mismatch; desktop shows up to 6.
|
|
const INITIAL_MOBILE = 3;
|
|
const INITIAL_DESKTOP = 6;
|
|
|
|
interface MeetupData {
|
|
id?: string;
|
|
title: string;
|
|
date: string;
|
|
time?: string;
|
|
location?: string;
|
|
link?: string;
|
|
description?: string;
|
|
organizer?: { name: string; slug?: string };
|
|
}
|
|
|
|
interface MeetupsSectionProps {
|
|
meetups: MeetupData[];
|
|
}
|
|
|
|
export function MeetupsSection({ meetups }: MeetupsSectionProps) {
|
|
// Number of extra batches revealed via "Load more". Each batch adds 3 on
|
|
// mobile and 6 on desktop, so visible counts are 3*(pages+1) / 6*(pages+1).
|
|
const [pages, setPages] = useState(0);
|
|
|
|
const mobileVisible = INITIAL_MOBILE * (pages + 1);
|
|
const desktopVisible = INITIAL_DESKTOP * (pages + 1);
|
|
|
|
// Visibility per card. Enforced with CSS so the server-rendered markup matches
|
|
// both viewports (desktopVisible >= mobileVisible, so "mobile-only" never occurs).
|
|
const cardVisibilityClass = (i: number) => {
|
|
if (i < mobileVisible) return "flex";
|
|
if (i < desktopVisible) return "hidden md:flex";
|
|
return "hidden";
|
|
};
|
|
|
|
// Show the button only when content is still hidden at the given breakpoint.
|
|
let loadMoreClass = "hidden";
|
|
if (meetups.length > desktopVisible) loadMoreClass = "flex";
|
|
else if (meetups.length > mobileVisible) loadMoreClass = "flex md:hidden";
|
|
|
|
return (
|
|
<section className="py-24 px-8 border-t border-zinc-800/50">
|
|
<div className="max-w-6xl mx-auto">
|
|
<div className="flex justify-between items-end mb-12">
|
|
<div>
|
|
<p className="uppercase tracking-[0.2em] text-primary mb-2 font-semibold text-xs">
|
|
Mark your calendar
|
|
</p>
|
|
<h2 className="text-3xl font-black tracking-tight">Upcoming Meetups</h2>
|
|
</div>
|
|
<div className="hidden md:flex items-center gap-4">
|
|
<AddToCalendarButton />
|
|
<Link
|
|
href="/events"
|
|
className="flex items-center gap-2 text-sm text-primary font-semibold hover:gap-3 transition-all"
|
|
>
|
|
All events <ArrowRight size={16} />
|
|
</Link>
|
|
</div>
|
|
</div>
|
|
|
|
{meetups.length === 0 ? (
|
|
<div className="border border-zinc-800 rounded-xl px-8 py-12 text-center">
|
|
<p className="text-on-surface-variant text-sm">
|
|
No upcoming meetups scheduled. Check back soon.
|
|
</p>
|
|
</div>
|
|
) : (
|
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5">
|
|
{meetups.map((meetup, i) => {
|
|
const civil = formatMeetupCivilDate(meetup.date);
|
|
const month = civil?.monthShort ?? "—";
|
|
const day = civil?.day ?? "--";
|
|
const full = civil?.full ?? "";
|
|
const href = meetup.id ? `/events/${meetup.id}` : "#upcoming-meetups";
|
|
|
|
return (
|
|
<Link
|
|
key={meetup.id ?? i}
|
|
href={href}
|
|
className={`group ${cardVisibilityClass(i)} flex-col bg-zinc-900 border border-zinc-800 rounded-xl p-6 hover:border-zinc-700 hover:-translate-y-0.5 hover:shadow-xl transition-all duration-200`}
|
|
>
|
|
<div className="flex items-start gap-4 mb-4">
|
|
<div className="bg-zinc-800 rounded-lg px-3 py-2 text-center shrink-0 min-w-[52px]">
|
|
<span className="block text-[10px] font-bold uppercase text-primary tracking-wider leading-none mb-0.5">
|
|
{month}
|
|
</span>
|
|
<span className="block text-2xl font-black leading-none">{day}</span>
|
|
</div>
|
|
<div className="min-w-0">
|
|
<h3 className="font-bold text-base leading-snug group-hover:text-primary transition-colors">
|
|
{meetup.title}
|
|
</h3>
|
|
<p className="text-on-surface-variant/60 text-xs mt-1">{full}</p>
|
|
</div>
|
|
</div>
|
|
|
|
{meetup.description && (
|
|
<p className="text-on-surface-variant text-sm leading-relaxed mb-4 flex-1 line-clamp-2">
|
|
{meetup.description}
|
|
</p>
|
|
)}
|
|
|
|
<p className="text-[11px] text-on-surface-variant/50 font-medium uppercase tracking-wide mb-2">
|
|
Organized by{" "}
|
|
<span className="text-on-surface-variant/70 normal-case tracking-normal">
|
|
{meetup.organizer?.name || "Belgian Bitcoin Embassy"}
|
|
</span>
|
|
</p>
|
|
|
|
<div className="flex flex-col gap-1.5 mt-auto pt-4 border-t border-zinc-800/60">
|
|
{meetup.location && (
|
|
<p className="flex items-center gap-1.5 text-xs text-on-surface-variant/60">
|
|
<MapPin size={12} className="shrink-0 text-primary/60" />
|
|
{meetup.location}
|
|
</p>
|
|
)}
|
|
{meetup.time && (
|
|
<p className="flex items-center gap-1.5 text-xs text-on-surface-variant/60">
|
|
<Clock size={12} className="shrink-0 text-primary/60" />
|
|
{meetup.time}
|
|
</p>
|
|
)}
|
|
</div>
|
|
|
|
<span className="flex items-center gap-1.5 text-primary text-xs font-semibold mt-4 group-hover:gap-2.5 transition-all">
|
|
View Details <ArrowRight size={12} />
|
|
</span>
|
|
</Link>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
|
|
<div className={`${loadMoreClass} justify-center mt-10`}>
|
|
<button
|
|
type="button"
|
|
onClick={() => setPages((p) => p + 1)}
|
|
className="flex items-center gap-2 rounded-lg border border-zinc-700 px-6 py-2.5 text-sm font-semibold text-on-surface hover:border-primary hover:text-primary transition-colors"
|
|
>
|
|
Load more
|
|
</button>
|
|
</div>
|
|
|
|
<div className="md:hidden flex flex-col items-center gap-3 mt-8">
|
|
<Link
|
|
href="/events"
|
|
className="flex items-center gap-2 text-primary font-semibold text-sm"
|
|
>
|
|
All events <ArrowRight size={16} />
|
|
</Link>
|
|
<AddToCalendarButton />
|
|
</div>
|
|
</div>
|
|
</section>
|
|
);
|
|
}
|