| @@ -0,0 +1,6 @@ | ||
| + | <svg width="512" height="512" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Stepping"> | |
| + | <rect width="512" height="512" rx="112" fill="#0F0E0C"/> | |
| + | <circle cx="256" cy="256" r="176" stroke="#C8A24C" stroke-opacity="0.18" stroke-width="6"/> | |
| + | <circle cx="256" cy="256" r="140" stroke="#C8A24C" stroke-opacity="0.34" stroke-width="6"/> | |
| + | <text x="256" y="256" fill="#C8A24C" font-family="Bricolage Grotesque, Arial, sans-serif" font-size="300" font-weight="800" letter-spacing="-12" text-anchor="middle" dominant-baseline="central">S</text> | |
| + | </svg> |
| @@ -0,0 +1,17 @@ | ||
| + | { | |
| + | "name": "Stepping", | |
| + | "short_name": "Stepping", | |
| + | "description": "The home for Divine Nine step show culture. Shows, strolls, the people, and the road to every set, city by city.", | |
| + | "start_url": "/", | |
| + | "display": "standalone", | |
| + | "background_color": "#0F0E0C", | |
| + | "theme_color": "#0F0E0C", | |
| + | "icons": [ | |
| + | { | |
| + | "src": "/icon.svg", | |
| + | "type": "image/svg+xml", | |
| + | "sizes": "any", | |
| + | "purpose": "any" | |
| + | } | |
| + | ] | |
| + | } |
| @@ -0,0 +1,106 @@ | ||
| + | import Link from "next/link"; | |
| + | ||
| + | export const metadata = { | |
| + | title: "What Is This - Stepping", | |
| + | description: | |
| + | "A plain-language guide to Stepping: find a city, see the shows, meet the people, and plan the road to every set.", | |
| + | }; | |
| + | ||
| + | const STEPS = [ | |
| + | { | |
| + | index: "01", | |
| + | title: "Find your city", | |
| + | body: "On the home page you will see a list of cities. Tap the one nearest to you. That is your starting point.", | |
| + | }, | |
| + | { | |
| + | index: "02", | |
| + | title: "See the shows", | |
| + | body: "Inside a city you will find The Line-Up: every step show, stroll, and competition coming up. Tap any one to see the date, the time, and the details.", | |
| + | }, | |
| + | { | |
| + | index: "03", | |
| + | title: "Enter The Yard", | |
| + | body: "The Yard is the community for that city: the chapters, the step teams, the DJs, the people who throw the shows, and the classes where you can learn. It is who's who and what's what.", | |
| + | }, | |
| + | { | |
| + | index: "04", | |
| + | title: "Plan The Route", | |
| + | body: "Traveling to a show? The Route helps you get there. Pick a city, see what's on, and make a plan for the road.", | |
| + | }, | |
| + | ]; | |
| + | ||
| + | export default function AboutPage() { | |
| + | return ( | |
| + | <main className="mx-auto w-full max-w-3xl px-5 py-16 sm:px-8"> | |
| + | <Link href="/" className="kicker hover:text-gold"> | |
| + | ← Stepping | |
| + | </Link> | |
| + | ||
| + | <p className="kicker mt-12 text-ash">What is this</p> | |
| + | <h1 className="display mt-3 text-6xl leading-[0.9] sm:text-8xl"> | |
| + | A guide to | |
| + | <br /> | |
| + | <span className="text-gold">Stepping</span> | |
| + | </h1> | |
| + | ||
| + | <p className="serif mt-10 text-2xl leading-snug text-bone/85 sm:text-3xl"> | |
| + | Stepping is an art form. It is body-as-instrument: stomps, claps, | |
| + | spoken word, and lines moving as one. The Divine Nine Black fraternities | |
| + | and sororities have carried it for over a hundred years. | |
| + | </p> | |
| + | ||
| + | <p className="serif mt-6 text-2xl leading-snug text-bone/85 sm:text-3xl"> | |
| + | This app is one simple thing: a home for that culture. It helps you find | |
| + | the shows near you, meet the people behind them, and get to every set. | |
| + | No accounts to figure out, no clutter. Just the show. | |
| + | </p> | |
| + | ||
| + | <section className="mt-16 border-t border-[var(--line)] pt-12"> | |
| + | <div className="flex items-end justify-between"> | |
| + | <h2 className="display text-4xl sm:text-5xl">How to use it</h2> | |
| + | <span className="kicker text-ash">Four steps</span> | |
| + | </div> | |
| + | ||
| + | <div className="mt-10 flex flex-col"> | |
| + | {STEPS.map((s) => ( | |
| + | <div | |
| + | key={s.index} | |
| + | className="flex flex-col gap-3 border-b border-[var(--line)] py-8 sm:flex-row sm:gap-8" | |
| + | > | |
| + | <span className="kicker pt-2 text-gold sm:w-12">{s.index}</span> | |
| + | <div className="flex-1"> | |
| + | <h3 className="display text-3xl sm:text-4xl">{s.title}</h3> | |
| + | <p className="serif mt-3 text-xl leading-relaxed text-bone/75 sm:text-2xl"> | |
| + | {s.body} | |
| + | </p> | |
| + | </div> | |
| + | </div> | |
| + | ))} | |
| + | </div> | |
| + | </section> | |
| + | ||
| + | <section className="mt-16 border-t border-[var(--line)] pt-12"> | |
| + | <h2 className="display text-4xl sm:text-5xl"> | |
| + | That's all there is | |
| + | </h2> | |
| + | <p className="serif mt-6 text-xl leading-relaxed text-bone/75 sm:text-2xl"> | |
| + | Take your time. Tap around. If you ever feel lost, the word | |
| + | <span className="text-gold"> STEPPING</span> in the top corner always | |
| + | brings you back to the start. | |
| + | </p> | |
| + | ||
| + | <Link | |
| + | href="/" | |
| + | className="kicker mt-12 inline-block border border-gold px-8 py-5 text-gold hover:bg-gold hover:text-stage" | |
| + | > | |
| + | Start here → | |
| + | </Link> | |
| + | </section> | |
| + | ||
| + | <footer className="mt-20 flex flex-wrap items-center justify-between gap-3 border-t border-[var(--line)] pt-8"> | |
| + | <span className="display text-xl">STEPPING</span> | |
| + | <span className="kicker text-ash">On Beat · Built for the culture</span> | |
| + | </footer> | |
| + | </main> | |
| + | ); | |
| + | } |
| @@ -0,0 +1,5 @@ | ||
| + | import { EventSkeleton } from "@/components/skeletons"; | |
| + | ||
| + | export default function Loading() { | |
| + | return <EventSkeleton />; | |
| + | } |
| @@ -0,0 +1,193 @@ | ||
| + | import Link from "next/link"; | |
| + | import { notFound } from "next/navigation"; | |
| + | import { revalidatePath } from "next/cache"; | |
| + | import { serverCaller } from "@/lib/trpc/server"; | |
| + | ||
| + | export const dynamic = "force-dynamic"; | |
| + | ||
| + | const TYPE_LABEL: Record<string, string> = { | |
| + | step_show: "Step show", | |
| + | stroll: "Stroll", | |
| + | competition: "Competition", | |
| + | class: "Class", | |
| + | social: "Social", | |
| + | special: "Special", | |
| + | }; | |
| + | ||
| + | function fmt(d: Date | null) { | |
| + | if (!d) return "DATE TBD"; | |
| + | return new Date(d) | |
| + | .toLocaleString("en-US", { | |
| + | weekday: "short", | |
| + | month: "short", | |
| + | day: "numeric", | |
| + | hour: "numeric", | |
| + | minute: "2-digit", | |
| + | }) | |
| + | .toUpperCase(); | |
| + | } | |
| + | ||
| + | function stamp(d: Date | null) { | |
| + | if (!d) return ""; | |
| + | return new Date(d) | |
| + | .toLocaleString("en-US", { | |
| + | month: "short", | |
| + | day: "numeric", | |
| + | hour: "numeric", | |
| + | minute: "2-digit", | |
| + | }) | |
| + | .toUpperCase(); | |
| + | } | |
| + | ||
| + | export default async function EventPage({ | |
| + | params, | |
| + | }: { | |
| + | params: Promise<{ slug: string; id: string }>; | |
| + | }) { | |
| + | const { slug, id } = await params; | |
| + | const caller = serverCaller(); | |
| + | const city = await caller.city.bySlug({ slug }); | |
| + | if (!city) notFound(); | |
| + | ||
| + | const event = await caller.event.byId({ id }); | |
| + | if (!event) notFound(); | |
| + | ||
| + | const comments = await caller.comment.list({ | |
| + | subjectType: "event", | |
| + | subjectId: id, | |
| + | }); | |
| + | ||
| + | async function addComment(formData: FormData) { | |
| + | "use server"; | |
| + | const body = String(formData.get("body") ?? "").trim(); | |
| + | if (!body) return; | |
| + | await serverCaller().comment.create({ | |
| + | subjectType: "event", | |
| + | subjectId: id, | |
| + | body, | |
| + | }); | |
| + | revalidatePath(`/city/${slug}/event/${id}`); | |
| + | } | |
| + | ||
| + | async function reportEvent(formData: FormData) { | |
| + | "use server"; | |
| + | const reason = String(formData.get("reason") ?? "").trim(); | |
| + | await serverCaller().flag.create({ | |
| + | subjectType: "event", | |
| + | subjectId: id, | |
| + | reason: reason || undefined, | |
| + | }); | |
| + | revalidatePath(`/city/${slug}/event/${id}`); | |
| + | } | |
| + | ||
| + | return ( | |
| + | <main className="mx-auto w-full max-w-3xl px-5 py-16 sm:px-8"> | |
| + | <Link href={`/city/${slug}`} className="kicker hover:text-gold"> | |
| + | ← {city.name} | |
| + | </Link> | |
| + | ||
| + | <p className="kicker mt-10 text-ash"> | |
| + | {(TYPE_LABEL[event.type] ?? event.type).toUpperCase()} · {fmt(event.startsAt)} ·{" "} | |
| + | {city.name} | |
| + | </p> | |
| + | <h1 className="display mt-3 text-6xl sm:text-7xl">{event.title}</h1> | |
| + | ||
| + | {event.description ? ( | |
| + | <p className="serif mt-6 max-w-xl text-xl text-bone"> | |
| + | {event.description} | |
| + | </p> | |
| + | ) : null} | |
| + | ||
| + | {event.externalUrl || event.ticketUrl ? ( | |
| + | <div className="mt-7 flex flex-wrap gap-4"> | |
| + | {event.ticketUrl ? ( | |
| + | <a | |
| + | href={event.ticketUrl} | |
| + | target="_blank" | |
| + | rel="noopener noreferrer" | |
| + | className="kicker bg-gold px-6 py-3 text-stage hover:bg-gold-bright" | |
| + | > | |
| + | Tickets → | |
| + | </a> | |
| + | ) : null} | |
| + | {event.externalUrl ? ( | |
| + | <a | |
| + | href={event.externalUrl} | |
| + | target="_blank" | |
| + | rel="noopener noreferrer" | |
| + | className="kicker border border-[var(--line)] px-6 py-3 text-gold hover:border-gold hover:text-gold-bright" | |
| + | > | |
| + | Details → | |
| + | </a> | |
| + | ) : null} | |
| + | </div> | |
| + | ) : null} | |
| + | ||
| + | {/* Comments */} | |
| + | <section className="mt-16 border-t border-[var(--line)] pt-10"> | |
| + | <div className="flex items-end justify-between"> | |
| + | <h2 className="display text-3xl">The Crowd</h2> | |
| + | <span className="kicker text-ash"> | |
| + | {String(comments.length).padStart(2, "0")} comments | |
| + | </span> | |
| + | </div> | |
| + | ||
| + | <form action={addComment} className="mt-6 flex flex-col gap-3"> | |
| + | <input | |
| + | name="body" | |
| + | required | |
| + | placeholder="Say something about the show" | |
| + | className="serif border-b border-[var(--line)] bg-transparent py-3 text-2xl text-bone outline-none placeholder:text-ash focus:border-gold" | |
| + | /> | |
| + | <div className="mt-2"> | |
| + | <button | |
| + | type="submit" | |
| + | className="kicker bg-gold px-6 py-3 text-stage hover:bg-gold-bright" | |
| + | > | |
| + | Post comment | |
| + | </button> | |
| + | </div> | |
| + | </form> | |
| + | ||
| + | {comments.length === 0 ? ( | |
| + | <p className="serif mt-8 text-xl text-ash"> | |
| + | No comments yet. Be the first to speak on it. | |
| + | </p> | |
| + | ) : ( | |
| + | <ul className="mt-8"> | |
| + | {comments.map((c) => ( | |
| + | <li | |
| + | key={c.id} | |
| + | className="border-b border-[var(--line)] py-6" | |
| + | > | |
| + | <p className="serif text-xl text-bone">{c.body}</p> | |
| + | <p className="kicker mt-2 text-ash">{stamp(c.createdAt)}</p> | |
| + | </li> | |
| + | ))} | |
| + | </ul> | |
| + | )} | |
| + | </section> | |
| + | ||
| + | {/* Report */} | |
| + | <section className="mt-16 border-t border-[var(--line)] pt-10"> | |
| + | <div className="flex items-end justify-between"> | |
| + | <h2 className="display text-3xl">Report</h2> | |
| + | <span className="kicker text-ash">Flag for review</span> | |
| + | </div> | |
| + | <form action={reportEvent} className="mt-6 flex flex-wrap items-center gap-4"> | |
| + | <input | |
| + | name="reason" | |
| + | placeholder="What's wrong with this listing? (optional)" | |
| + | className="kicker flex-1 border border-[var(--line)] bg-stage px-4 py-3 text-bone outline-none placeholder:text-ash focus:border-gold" | |
| + | /> | |
| + | <button | |
| + | type="submit" | |
| + | className="kicker bg-gold px-6 py-3 text-stage hover:bg-gold-bright" | |
| + | > | |
| + | Report | |
| + | </button> | |
| + | </form> | |
| + | </section> | |
| + | </main> | |
| + | ); | |
| + | } |
| @@ -0,0 +1,5 @@ | ||
| + | import { CitySkeleton } from "@/components/skeletons"; | |
| + | ||
| + | export default function Loading() { | |
| + | return <CitySkeleton />; | |
| + | } |
| @@ -66,12 +66,26 @@ export default async function CityPage({ | ||
| </p> | ||
| <h1 className="display mt-3 text-7xl sm:text-8xl">{city.name}</h1> | ||
| - | <Link | |
| - | href={`/city/${slug}/yard`} | |
| - | className="kicker mt-6 inline-block text-gold hover:text-gold-bright" | |
| - | > | |
| - | Enter The Yard → | |
| - | </Link> | |
| + | <div className="mt-6 flex flex-wrap gap-6"> | |
| + | <Link | |
| + | href={`/city/${slug}/yard`} | |
| + | className="kicker inline-block text-gold hover:text-gold-bright" | |
| + | > | |
| + | Enter The Yard → | |
| + | </Link> | |
| + | <Link | |
| + | href={`/city/${slug}/videos`} | |
| + | className="kicker inline-block text-gold hover:text-gold-bright" | |
| + | > | |
| + | Watch videos → | |
| + | </Link> | |
| + | <Link | |
| + | href={`/city/${slug}/route`} | |
| + | className="kicker inline-block text-gold hover:text-gold-bright" | |
| + | > | |
| + | The Route → | |
| + | </Link> | |
| + | </div> | |
| {/* Add an event */} | ||
| <section className="mt-14 border-t border-[var(--line)] pt-10"> | ||
| @@ -135,7 +149,12 @@ export default async function CityPage({ | ||
| {String(i + 1).padStart(2, "0")} | ||
| </span> | ||
| <div className="flex-1"> | ||
| - | <p className="display text-2xl sm:text-3xl">{e.title}</p> | |
| + | <Link | |
| + | href={`/city/${slug}/event/${e.id}`} | |
| + | className="display text-2xl text-bone hover:text-gold sm:text-3xl" | |
| + | > | |
| + | {e.title} | |
| + | </Link> | |
| <p className="kicker mt-1 text-ash">{fmt(e.startsAt)}</p> | ||
| </div> | ||
| <span className="kicker border border-[var(--line)] px-3 py-1"> |
| @@ -0,0 +1,135 @@ | ||
| + | import Link from "next/link"; | |
| + | import { notFound } from "next/navigation"; | |
| + | import { serverCaller } from "@/lib/trpc/server"; | |
| + | ||
| + | export const dynamic = "force-dynamic"; | |
| + | ||
| + | const KIND_LABEL: Record<string, string> = { | |
| + | dj: "DJ", | |
| + | promoter: "Promoter", | |
| + | "step-team": "Step Team", | |
| + | chapter: "Chapter", | |
| + | class: "Class", | |
| + | }; | |
| + | ||
| + | function stamp(d: Date | null | undefined) { | |
| + | if (!d) return ""; | |
| + | return new Date(d) | |
| + | .toLocaleString("en-US", { | |
| + | month: "short", | |
| + | day: "numeric", | |
| + | year: "numeric", | |
| + | }) | |
| + | .toUpperCase(); | |
| + | } | |
| + | ||
| + | export default async function ProfilePage({ | |
| + | params, | |
| + | }: { | |
| + | params: Promise<{ slug: string; kind: string; id: string }>; | |
| + | }) { | |
| + | const { slug, kind, id } = await params; | |
| + | const caller = serverCaller(); | |
| + | const city = await caller.city.bySlug({ slug }); | |
| + | if (!city) notFound(); | |
| + | ||
| + | const label = KIND_LABEL[kind]; | |
| + | if (!label) notFound(); | |
| + | ||
| + | let title = ""; | |
| + | let typeLine = label.toUpperCase(); | |
| + | let body: string | null = null; | |
| + | let emptyBody = "Nothing on record yet."; | |
| + | let createdAt: Date | null = null; | |
| + | const meta: string[] = []; | |
| + | ||
| + | if (kind === "dj") { | |
| + | const dj = await caller.dj.byId({ id }); | |
| + | if (!dj) notFound(); | |
| + | title = dj.name; | |
| + | body = dj.bio ?? null; | |
| + | emptyBody = "No bio yet. This DJ keeps the set list close."; | |
| + | createdAt = dj.createdAt; | |
| + | } else if (kind === "promoter") { | |
| + | const promoter = await caller.promoter.byId({ id }); | |
| + | if (!promoter) notFound(); | |
| + | title = promoter.name; | |
| + | body = promoter.bio ?? null; | |
| + | emptyBody = "No bio yet. This promoter lets the shows speak."; | |
| + | createdAt = promoter.createdAt; | |
| + | } else if (kind === "step-team") { | |
| + | const team = await caller.stepTeam.byId({ id }); | |
| + | if (!team) notFound(); | |
| + | title = team.name; | |
| + | body = team.bio ?? null; | |
| + | emptyBody = "No bio yet. This line lets the stomp do the talking."; | |
| + | createdAt = team.createdAt; | |
| + | if (team.affiliationOrgId) { | |
| + | const organizations = await caller.organization.list(); | |
| + | const org = organizations.find((o) => o.id === team.affiliationOrgId); | |
| + | if (org) { | |
| + | typeLine = `${label.toUpperCase()} · ${ | |
| + | org.letters ? `${org.letters} ` : "" | |
| + | }${org.name}`.toUpperCase(); | |
| + | } | |
| + | } | |
| + | } else if (kind === "chapter") { | |
| + | const chapter = await caller.chapter.byId({ id }); | |
| + | if (!chapter) notFound(); | |
| + | title = chapter.name; | |
| + | body = chapter.bio ?? chapter.school ?? null; | |
| + | emptyBody = "No detail yet. This chapter is still writing its history."; | |
| + | createdAt = chapter.createdAt; | |
| + | const organizations = await caller.organization.list(); | |
| + | const org = organizations.find((o) => o.id === chapter.organizationId); | |
| + | if (org) { | |
| + | typeLine = `${label.toUpperCase()} · ${ | |
| + | org.letters ? `${org.letters} ` : "" | |
| + | }${org.name}`.toUpperCase(); | |
| + | } | |
| + | if (chapter.school) meta.push(chapter.school.toUpperCase()); | |
| + | } else { | |
| + | const klass = await caller.class.byId({ id }); | |
| + | if (!klass) notFound(); | |
| + | title = klass.title; | |
| + | body = klass.instructor ? `Taught by ${klass.instructor}.` : null; | |
| + | emptyBody = "No detail yet. The first line gets taught soon."; | |
| + | createdAt = klass.createdAt; | |
| + | if (klass.instructor) meta.push(`WITH ${klass.instructor.toUpperCase()}`); | |
| + | if (klass.schedule) meta.push(klass.schedule.toUpperCase()); | |
| + | meta.push(klass.isFree ? "FREE" : "PAID"); | |
| + | } | |
| + | ||
| + | return ( | |
| + | <main className="mx-auto w-full max-w-3xl px-5 py-16 sm:px-8"> | |
| + | <Link href={`/city/${slug}/yard`} className="kicker hover:text-gold"> | |
| + | ← The Yard | |
| + | </Link> | |
| + | ||
| + | <p className="kicker mt-10 text-ash"> | |
| + | {typeLine} · {city.name} | |
| + | </p> | |
| + | <h1 className="display mt-3 text-6xl sm:text-7xl">{title}</h1> | |
| + | ||
| + | {body ? ( | |
| + | <p className="serif mt-6 max-w-xl text-xl text-bone">{body}</p> | |
| + | ) : ( | |
| + | <p className="serif mt-6 max-w-xl text-xl text-ash">{emptyBody}</p> | |
| + | )} | |
| + | ||
| + | <section className="mt-16 border-t border-[var(--line)] pt-10"> | |
| + | <p className="kicker text-gold">On the record</p> | |
| + | <p className="kicker mt-4 text-ash"> | |
| + | {[ | |
| + | label.toUpperCase(), | |
| + | (city.metroLabel ?? city.name).toUpperCase(), | |
| + | ...meta, | |
| + | createdAt ? `EST. ${stamp(createdAt)}` : null, | |
| + | ] | |
| + | .filter(Boolean) | |
| + | .join(" · ")} | |
| + | </p> | |
| + | </section> | |
| + | </main> | |
| + | ); | |
| + | } |
| @@ -0,0 +1,184 @@ | ||
| + | import Link from "next/link"; | |
| + | import { notFound } from "next/navigation"; | |
| + | import { revalidatePath } from "next/cache"; | |
| + | import { TRAVEL_CATEGORY } from "@stepping/types"; | |
| + | import { serverCaller } from "@/lib/trpc/server"; | |
| + | ||
| + | export const dynamic = "force-dynamic"; | |
| + | ||
| + | type Category = (typeof TRAVEL_CATEGORY)[number]; | |
| + | ||
| + | const SECTIONS: { | |
| + | category: Category; | |
| + | heading: string; | |
| + | count: string; | |
| + | badge: string; | |
| + | empty: string; | |
| + | field: string; | |
| + | cta: string; | |
| + | }[] = [ | |
| + | { | |
| + | category: "hotel", | |
| + | heading: "Hotels", | |
| + | count: "rooms", | |
| + | badge: "STAY", | |
| + | empty: "No rooms booked yet. Drop the first place to lay your head below.", | |
| + | field: "Hotel name (e.g. The Peabody)", | |
| + | cta: "Add hotel", | |
| + | }, | |
| + | { | |
| + | category: "restaurant", | |
| + | heading: "Restaurants", | |
| + | count: "tables", | |
| + | badge: "EAT", | |
| + | empty: "No tables set yet. Name the first plate worth the trip below.", | |
| + | field: "Restaurant name (e.g. The Four Way)", | |
| + | cta: "Add restaurant", | |
| + | }, | |
| + | { | |
| + | category: "mall", | |
| + | heading: "Shopping", | |
| + | count: "stops", | |
| + | badge: "SHOP", | |
| + | empty: "No stops yet. Add the first place to fit before the show below.", | |
| + | field: "Mall or shop (e.g. Wolfchase Galleria)", | |
| + | cta: "Add shopping", | |
| + | }, | |
| + | { | |
| + | category: "attraction", | |
| + | heading: "Attractions", | |
| + | count: "sights", | |
| + | badge: "SEE", | |
| + | empty: "No sights yet. Mark the first thing worth seeing in town below.", | |
| + | field: "Attraction (e.g. National Civil Rights Museum)", | |
| + | cta: "Add attraction", | |
| + | }, | |
| + | { | |
| + | category: "hospital", | |
| + | heading: "Hospitals", | |
| + | count: "nearest", | |
| + | badge: "CARE", | |
| + | empty: "No hospital listed yet. Add the nearest one below, just in case.", | |
| + | field: "Hospital (e.g. Methodist University)", | |
| + | cta: "Add hospital", | |
| + | }, | |
| + | ]; | |
| + | ||
| + | export default async function RoutePage({ | |
| + | params, | |
| + | }: { | |
| + | params: Promise<{ slug: string }>; | |
| + | }) { | |
| + | const { slug } = await params; | |
| + | const caller = serverCaller(); | |
| + | const city = await caller.city.bySlug({ slug }); | |
| + | if (!city) notFound(); | |
| + | ||
| + | const cityId = city.id; | |
| + | const grouped = await Promise.all( | |
| + | SECTIONS.map((s) => caller.travelPlace.list({ cityId, category: s.category })), | |
| + | ); | |
| + | ||
| + | async function addPlace(formData: FormData) { | |
| + | "use server"; | |
| + | const category = String(formData.get("category") ?? ""); | |
| + | const name = String(formData.get("name") ?? "").trim(); | |
| + | const address = String(formData.get("address") ?? "").trim(); | |
| + | if (!name || !TRAVEL_CATEGORY.includes(category as Category)) return; | |
| + | await serverCaller().travelPlace.create({ | |
| + | cityId: city!.id, | |
| + | category: category as Category, | |
| + | name, | |
| + | address: address || undefined, | |
| + | }); | |
| + | revalidatePath(`/city/${slug}/route`); | |
| + | } | |
| + | ||
| + | return ( | |
| + | <main className="mx-auto w-full max-w-3xl px-5 py-16 sm:px-8"> | |
| + | <Link href={`/city/${slug}`} className="kicker hover:text-gold"> | |
| + | ← {city.name} | |
| + | </Link> | |
| + | ||
| + | <p className="kicker mt-10 text-ash">The Route · {city.metroLabel}</p> | |
| + | <h1 className="display mt-3 text-7xl sm:text-8xl">The Route</h1> | |
| + | <p className="serif mt-5 max-w-xl text-xl text-ash"> | |
| + | In town for the show - where to stay, eat, shop, and play, plus the | |
| + | nearest hospital. Everything you need to do {city.name} right. | |
| + | </p> | |
| + | ||
| + | {SECTIONS.map((section, idx) => { | |
| + | const places = grouped[idx]; | |
| + | return ( | |
| + | <section | |
| + | key={section.category} | |
| + | className="mt-16 border-t border-[var(--line)] pt-10" | |
| + | > | |
| + | <div className="flex items-end justify-between"> | |
| + | <div> | |
| + | <p className="kicker text-gold">Stop {String(idx + 1).padStart(2, "0")}</p> | |
| + | <h2 className="display mt-2 text-4xl">{section.heading}</h2> | |
| + | </div> | |
| + | <span className="kicker text-ash"> | |
| + | {String(places.length).padStart(2, "0")} {section.count} | |
| + | </span> | |
| + | </div> | |
| + | ||
| + | {places.length === 0 ? ( | |
| + | <p className="serif mt-8 text-xl text-ash">{section.empty}</p> | |
| + | ) : ( | |
| + | <ul className="mt-6"> | |
| + | {places.map((p, i) => ( | |
| + | <li | |
| + | key={p.id} | |
| + | className="flex items-center gap-5 border-b border-[var(--line)] py-6" | |
| + | > | |
| + | <span className="kicker w-8 text-gold"> | |
| + | {String(i + 1).padStart(2, "0")} | |
| + | </span> | |
| + | <div className="flex-1"> | |
| + | <p className="display text-2xl text-bone sm:text-3xl"> | |
| + | {p.name} | |
| + | </p> | |
| + | {p.address ? ( | |
| + | <p className="kicker mt-1 text-ash"> | |
| + | {p.address.toUpperCase()} | |
| + | </p> | |
| + | ) : null} | |
| + | </div> | |
| + | <span className="kicker border border-[var(--line)] px-3 py-1"> | |
| + | {section.badge} | |
| + | </span> | |
| + | </li> | |
| + | ))} | |
| + | </ul> | |
| + | )} | |
| + | ||
| + | <form action={addPlace} className="mt-8 flex flex-col gap-3"> | |
| + | <input type="hidden" name="category" value={section.category} /> | |
| + | <input | |
| + | name="name" | |
| + | required | |
| + | placeholder={section.field} | |
| + | className="serif border-b border-[var(--line)] bg-transparent py-3 text-2xl text-bone outline-none placeholder:text-ash focus:border-gold" | |
| + | /> | |
| + | <div className="mt-2 flex flex-wrap items-center gap-4"> | |
| + | <input | |
| + | name="address" | |
| + | placeholder="Address (optional)" | |
| + | className="kicker flex-1 border border-[var(--line)] bg-stage px-4 py-3 text-bone outline-none placeholder:text-ash focus:border-gold" | |
| + | /> | |
| + | <button | |
| + | type="submit" | |
| + | className="kicker bg-gold px-6 py-3 text-stage hover:bg-gold-bright" | |
| + | > | |
| + | {section.cta} | |
| + | </button> | |
| + | </div> | |
| + | </form> | |
| + | </section> | |
| + | ); | |
| + | })} | |
| + | </main> | |
| + | ); | |
| + | } |
| @@ -0,0 +1,5 @@ | ||
| + | import { VideosSkeleton } from "@/components/skeletons"; | |
| + | ||
| + | export default function Loading() { | |
| + | return <VideosSkeleton />; | |
| + | } |
| @@ -0,0 +1,181 @@ | ||
| + | import Link from "next/link"; | |
| + | import { notFound } from "next/navigation"; | |
| + | import { revalidatePath } from "next/cache"; | |
| + | import { VIDEO_PLATFORM } from "@stepping/types"; | |
| + | import { serverCaller } from "@/lib/trpc/server"; | |
| + | ||
| + | export const dynamic = "force-dynamic"; | |
| + | ||
| + | const PLATFORM_LABEL: Record<string, string> = { | |
| + | youtube: "YouTube", | |
| + | instagram: "Instagram", | |
| + | tiktok: "TikTok", | |
| + | other: "Other", | |
| + | }; | |
| + | ||
| + | function youtubeId(url: string): string | null { | |
| + | try { | |
| + | const u = new URL(url); | |
| + | const host = u.hostname.replace(/^www\./, ""); | |
| + | if (host === "youtu.be") { | |
| + | const id = u.pathname.slice(1); | |
| + | return /^[a-zA-Z0-9_-]{11}$/.test(id) ? id : null; | |
| + | } | |
| + | if (host === "youtube.com" || host === "m.youtube.com") { | |
| + | if (u.pathname === "/watch") { | |
| + | const id = u.searchParams.get("v"); | |
| + | return id && /^[a-zA-Z0-9_-]{11}$/.test(id) ? id : null; | |
| + | } | |
| + | const embed = u.pathname.match(/^\/(?:embed|shorts)\/([a-zA-Z0-9_-]{11})/); | |
| + | if (embed) return embed[1]; | |
| + | } | |
| + | return null; | |
| + | } catch { | |
| + | return null; | |
| + | } | |
| + | } | |
| + | ||
| + | export default async function VideosPage({ | |
| + | params, | |
| + | }: { | |
| + | params: Promise<{ slug: string }>; | |
| + | }) { | |
| + | const { slug } = await params; | |
| + | const caller = serverCaller(); | |
| + | const city = await caller.city.bySlug({ slug }); | |
| + | if (!city) notFound(); | |
| + | ||
| + | const videos = await caller.video.list({ cityId: city.id }); | |
| + | ||
| + | async function addVideo(formData: FormData) { | |
| + | "use server"; | |
| + | const title = String(formData.get("title") ?? "").trim(); | |
| + | const embedUrl = String(formData.get("embedUrl") ?? "").trim(); | |
| + | const platform = String(formData.get("platform") ?? "other"); | |
| + | if (!title || !embedUrl) return; | |
| + | await serverCaller().video.create({ | |
| + | cityId: city!.id, | |
| + | title, | |
| + | embedUrl, | |
| + | platform: platform as (typeof VIDEO_PLATFORM)[number], | |
| + | }); | |
| + | revalidatePath(`/city/${slug}/videos`); | |
| + | } | |
| + | ||
| + | return ( | |
| + | <main className="mx-auto w-full max-w-3xl px-5 py-16 sm:px-8"> | |
| + | <Link href={`/city/${slug}`} className="kicker hover:text-gold"> | |
| + | ← {city.name} | |
| + | </Link> | |
| + | ||
| + | <p className="kicker mt-10 text-ash"> | |
| + | Tapes · {city.metroLabel} | |
| + | </p> | |
| + | <h1 className="display mt-3 text-7xl sm:text-8xl">The Tape</h1> | |
| + | <p className="serif mt-5 max-w-xl text-xl text-ash"> | |
| + | Step shows, strolls, and sets caught on camera across {city.name}. | |
| + | </p> | |
| + | ||
| + | {/* Add a video */} | |
| + | <section className="mt-14 border-t border-[var(--line)] pt-10"> | |
| + | <div className="flex items-end justify-between"> | |
| + | <h2 className="display text-3xl">Drop a clip</h2> | |
| + | <span className="kicker">Add to the tape</span> | |
| + | </div> | |
| + | <form action={addVideo} className="mt-6 flex flex-col gap-3"> | |
| + | <input | |
| + | name="title" | |
| + | required | |
| + | placeholder="Clip title (e.g. Spring Yardfest 2025)" | |
| + | className="serif border-b border-[var(--line)] bg-transparent py-3 text-2xl text-bone outline-none placeholder:text-ash focus:border-gold" | |
| + | /> | |
| + | <input | |
| + | name="embedUrl" | |
| + | required | |
| + | type="url" | |
| + | placeholder="Video URL (e.g. https://youtu.be/…)" | |
| + | className="serif border-b border-[var(--line)] bg-transparent py-2 text-lg text-bone outline-none placeholder:text-ash focus:border-gold" | |
| + | /> | |
| + | <div className="mt-2 flex flex-wrap gap-4"> | |
| + | <select | |
| + | name="platform" | |
| + | defaultValue="youtube" | |
| + | className="kicker border border-[var(--line)] bg-stage px-4 py-3 text-bone outline-none focus:border-gold" | |
| + | > | |
| + | {VIDEO_PLATFORM.map((p) => ( | |
| + | <option key={p} value={p} className="bg-stage"> | |
| + | {(PLATFORM_LABEL[p] ?? p).toUpperCase()} | |
| + | </option> | |
| + | ))} | |
| + | </select> | |
| + | <button | |
| + | type="submit" | |
| + | className="kicker bg-gold px-6 py-3 text-stage hover:bg-gold-bright" | |
| + | > | |
| + | Add clip | |
| + | </button> | |
| + | </div> | |
| + | </form> | |
| + | </section> | |
| + | ||
| + | {/* The tape */} | |
| + | <section className="mt-16 border-t border-[var(--line)] pt-10"> | |
| + | <div className="flex items-end justify-between"> | |
| + | <h2 className="display text-3xl">The Reel</h2> | |
| + | <span className="kicker text-ash"> | |
| + | {String(videos.length).padStart(2, "0")} clips | |
| + | </span> | |
| + | </div> | |
| + | ||
| + | {videos.length === 0 ? ( | |
| + | <p className="serif mt-8 text-xl text-ash"> | |
| + | No clips yet. Drop the first one above. | |
| + | </p> | |
| + | ) : ( | |
| + | <ul className="mt-8 flex flex-col gap-12"> | |
| + | {videos.map((v, i) => { | |
| + | const ytId = | |
| + | v.platform === "youtube" ? youtubeId(v.embedUrl) : null; | |
| + | return ( | |
| + | <li key={v.id}> | |
| + | <div className="flex items-end justify-between gap-5"> | |
| + | <div className="flex items-end gap-5"> | |
| + | <span className="kicker w-8 text-gold"> | |
| + | {String(i + 1).padStart(2, "0")} | |
| + | </span> | |
| + | <h3 className="display text-2xl sm:text-3xl">{v.title}</h3> | |
| + | </div> | |
| + | <span className="kicker border border-[var(--line)] px-3 py-1"> | |
| + | {(PLATFORM_LABEL[v.platform] ?? v.platform).toUpperCase()} | |
| + | </span> | |
| + | </div> | |
| + | ||
| + | {ytId ? ( | |
| + | <div className="mt-5 aspect-video w-full overflow-hidden border border-[var(--line)]"> | |
| + | <iframe | |
| + | src={`https://www.youtube-nocookie.com/embed/${ytId}`} | |
| + | title={v.title} | |
| + | allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" | |
| + | allowFullScreen | |
| + | className="h-full w-full" | |
| + | /> | |
| + | </div> | |
| + | ) : null} | |
| + | ||
| + | <a | |
| + | href={v.embedUrl} | |
| + | target="_blank" | |
| + | rel="noopener noreferrer" | |
| + | className="kicker mt-5 inline-block text-gold hover:text-gold-bright" | |
| + | > | |
| + | Watch → | |
| + | </a> | |
| + | </li> | |
| + | ); | |
| + | })} | |
| + | </ul> | |
| + | )} | |
| + | </section> | |
| + | </main> | |
| + | ); | |
| + | } |
| @@ -0,0 +1,5 @@ | ||
| + | import { YardSkeleton } from "@/components/skeletons"; | |
| + | ||
| + | export default function Loading() { | |
| + | return <YardSkeleton />; | |
| + | } |
| @@ -149,7 +149,12 @@ export default async function YardPage({ | ||
| {String(i + 1).padStart(2, "0")} | ||
| </span> | ||
| <div className="flex-1"> | ||
| - | <p className="display text-2xl sm:text-3xl">{t.name}</p> | |
| + | <Link | |
| + | href={`/city/${slug}/profile/step-team/${t.id}`} | |
| + | className="display text-2xl text-bone hover:text-gold sm:text-3xl" | |
| + | > | |
| + | {t.name} | |
| + | </Link> | |
| {t.bio ? ( | ||
| <p className="serif mt-1 text-lg text-ash">{t.bio}</p> | ||
| ) : null} | ||
| @@ -231,7 +236,12 @@ export default async function YardPage({ | ||
| {String(i + 1).padStart(2, "0")} | ||
| </span> | ||
| <div className="flex-1"> | ||
| - | <p className="display text-2xl sm:text-3xl">{c.name}</p> | |
| + | <Link | |
| + | href={`/city/${slug}/profile/chapter/${c.id}`} | |
| + | className="display text-2xl text-bone hover:text-gold sm:text-3xl" | |
| + | > | |
| + | {c.name} | |
| + | </Link> | |
| <p className="kicker mt-1 text-ash"> | ||
| {c.school ? c.school.toUpperCase() : "CHAPTER"} | ||
| </p> | ||
| @@ -312,7 +322,12 @@ export default async function YardPage({ | ||
| {String(i + 1).padStart(2, "0")} | ||
| </span> | ||
| <div className="flex-1"> | ||
| - | <p className="display text-2xl sm:text-3xl">{d.name}</p> | |
| + | <Link | |
| + | href={`/city/${slug}/profile/dj/${d.id}`} | |
| + | className="display text-2xl text-bone hover:text-gold sm:text-3xl" | |
| + | > | |
| + | {d.name} | |
| + | </Link> | |
| {d.bio ? ( | ||
| <p className="serif mt-1 text-lg text-ash">{d.bio}</p> | ||
| ) : null} | ||
| @@ -375,7 +390,12 @@ export default async function YardPage({ | ||
| {String(i + 1).padStart(2, "0")} | ||
| </span> | ||
| <div className="flex-1"> | ||
| - | <p className="display text-2xl sm:text-3xl">{p.name}</p> | |
| + | <Link | |
| + | href={`/city/${slug}/profile/promoter/${p.id}`} | |
| + | className="display text-2xl text-bone hover:text-gold sm:text-3xl" | |
| + | > | |
| + | {p.name} | |
| + | </Link> | |
| {p.bio ? ( | ||
| <p className="serif mt-1 text-lg text-ash">{p.bio}</p> | ||
| ) : null} | ||
| @@ -438,7 +458,12 @@ export default async function YardPage({ | ||
| {String(i + 1).padStart(2, "0")} | ||
| </span> | ||
| <div className="flex-1"> | ||
| - | <p className="display text-2xl sm:text-3xl">{c.title}</p> | |
| + | <Link | |
| + | href={`/city/${slug}/profile/class/${c.id}`} | |
| + | className="display text-2xl text-bone hover:text-gold sm:text-3xl" | |
| + | > | |
| + | {c.title} | |
| + | </Link> | |
| <p className="kicker mt-1 text-ash"> | ||
| {[ | ||
| c.instructor ? `WITH ${c.instructor.toUpperCase()}` : null, |
| @@ -152,9 +152,31 @@ body { | ||
| .b4 { animation-delay: 0.34s; } | ||
| .b5 { animation-delay: 0.46s; } | ||
| + | .skeleton { | |
| + | background: linear-gradient( | |
| + | 90deg, | |
| + | rgba(242, 236, 224, 0.05) 0%, | |
| + | rgba(242, 236, 224, 0.12) 50%, | |
| + | rgba(242, 236, 224, 0.05) 100% | |
| + | ); | |
| + | background-size: 200% 100%; | |
| + | border-radius: 2px; | |
| + | animation: skeletonShimmer 1.4s ease-in-out infinite; | |
| + | } | |
| + | ||
| + | @keyframes skeletonShimmer { | |
| + | 0% { | |
| + | background-position: 200% 0; | |
| + | } | |
| + | 100% { | |
| + | background-position: -200% 0; | |
| + | } | |
| + | } | |
| + | ||
| @media (prefers-reduced-motion: reduce) { | ||
| .marquee-track, | ||
| - | .beat-in { | |
| + | .beat-in, | |
| + | .skeleton { | |
| animation: none; | ||
| } | ||
| } |
| @@ -1,4 +1,4 @@ | ||
| - | import type { Metadata } from "next"; | |
| + | import type { Metadata, Viewport } from "next"; | |
| import { Bricolage_Grotesque, Instrument_Serif, Space_Mono } from "next/font/google"; | ||
| import "./globals.css"; | ||
| import { TRPCProvider } from "@/lib/trpc/provider"; | ||
| @@ -30,6 +30,17 @@ export const metadata: Metadata = { | ||
| title: "Stepping - On Beat", | ||
| description: | ||
| "The home for Divine Nine step show culture. Step shows, strolls, classes, DJs and the people, city by city.", | ||
| + | applicationName: "Stepping", | |
| + | manifest: "/manifest.webmanifest", | |
| + | icons: { | |
| + | icon: "/icon.svg", | |
| + | shortcut: "/icon.svg", | |
| + | apple: "/icon.svg", | |
| + | }, | |
| + | }; | |
| + | ||
| + | export const viewport: Viewport = { | |
| + | themeColor: "#0F0E0C", | |
| }; | ||
| export default function RootLayout({ |
| @@ -2,6 +2,7 @@ import Link from "next/link"; | ||
| import { serverCaller } from "@/lib/trpc/server"; | ||
| import { Marquee } from "@/components/marquee"; | ||
| import { HeroCanvas } from "@/components/hero-canvas"; | ||
| + | import { MobileNav } from "@/components/mobile-nav"; | |
| export const dynamic = "force-dynamic"; | ||
| @@ -60,6 +61,7 @@ export default async function Home() { | ||
| The Route | ||
| </a> | ||
| </nav> | ||
| + | <MobileNav /> | |
| </header> | ||
| {/* STAGE */} | ||
| @@ -188,9 +190,14 @@ export default async function Home() { | ||
| <footer className="flex flex-wrap items-center justify-between gap-3 border-t border-[var(--line)] px-5 py-8 sm:px-8"> | ||
| <span className="display text-xl">STEPPING</span> | ||
| - | <span className="kicker text-ash"> | |
| - | On Beat · Built for the culture | |
| - | </span> | |
| + | <div className="flex items-center gap-6"> | |
| + | <Link href="/about" className="kicker text-bone hover:text-gold"> | |
| + | About | |
| + | </Link> | |
| + | <span className="kicker text-ash"> | |
| + | On Beat · Built for the culture | |
| + | </span> | |
| + | </div> | |
| </footer> | ||
| </div> | ||
| ); |
| @@ -0,0 +1,89 @@ | ||
| + | "use client"; | |
| + | ||
| + | import { useEffect, useState } from "react"; | |
| + | import Link from "next/link"; | |
| + | ||
| + | const LINKS = [ | |
| + | { href: "#lineup", label: "The Line-Up", index: "01" }, | |
| + | { href: "#yard", label: "The Yard", index: "02" }, | |
| + | { href: "#route", label: "The Route", index: "03" }, | |
| + | ]; | |
| + | ||
| + | export function MobileNav() { | |
| + | const [open, setOpen] = useState(false); | |
| + | ||
| + | useEffect(() => { | |
| + | if (!open) return; | |
| + | const onKey = (e: KeyboardEvent) => { | |
| + | if (e.key === "Escape") setOpen(false); | |
| + | }; | |
| + | document.documentElement.style.overflow = "hidden"; | |
| + | window.addEventListener("keydown", onKey); | |
| + | return () => { | |
| + | document.documentElement.style.overflow = ""; | |
| + | window.removeEventListener("keydown", onKey); | |
| + | }; | |
| + | }, [open]); | |
| + | ||
| + | return ( | |
| + | <div className="sm:hidden"> | |
| + | <button | |
| + | type="button" | |
| + | aria-label="Open menu" | |
| + | aria-expanded={open} | |
| + | onClick={() => setOpen(true)} | |
| + | className="mobile-nav-trigger flex h-12 w-12 flex-col items-end justify-center gap-[6px] text-gold" | |
| + | > | |
| + | <span className="block h-[2px] w-7 bg-gold" /> | |
| + | <span className="block h-[2px] w-7 bg-gold" /> | |
| + | <span className="block h-[2px] w-5 bg-gold" /> | |
| + | </button> | |
| + | ||
| + | {open && ( | |
| + | <div className="mobile-nav-sheet fixed inset-0 z-[70] flex flex-col bg-stage px-5 pb-12 pt-4"> | |
| + | <div className="flex items-center justify-between"> | |
| + | <span className="display text-lg tracking-tight text-bone"> | |
| + | STEPPING | |
| + | </span> | |
| + | <button | |
| + | type="button" | |
| + | aria-label="Close menu" | |
| + | onClick={() => setOpen(false)} | |
| + | className="flex h-12 w-12 items-center justify-center text-gold" | |
| + | > | |
| + | <span className="display text-3xl leading-none">×</span> | |
| + | </button> | |
| + | </div> | |
| + | ||
| + | <nav className="mt-16 flex flex-1 flex-col justify-center gap-2"> | |
| + | {LINKS.map((l) => ( | |
| + | <a | |
| + | key={l.href} | |
| + | href={l.href} | |
| + | onClick={() => setOpen(false)} | |
| + | className="group flex items-baseline gap-5 border-b border-[var(--line)] py-6" | |
| + | > | |
| + | <span className="kicker text-gold">{l.index}</span> | |
| + | <span className="display text-5xl text-bone group-active:text-gold"> | |
| + | {l.label} | |
| + | </span> | |
| + | </a> | |
| + | ))} | |
| + | <Link | |
| + | href="/about" | |
| + | onClick={() => setOpen(false)} | |
| + | className="group flex items-baseline gap-5 border-b border-[var(--line)] py-6" | |
| + | > | |
| + | <span className="kicker text-gold">04</span> | |
| + | <span className="display text-5xl text-bone group-active:text-gold"> | |
| + | What is this? | |
| + | </span> | |
| + | </Link> | |
| + | </nav> | |
| + | ||
| + | <p className="kicker mt-8 text-ash">On Beat · Divine Nine Stepping</p> | |
| + | </div> | |
| + | )} | |
| + | </div> | |
| + | ); | |
| + | } |
| @@ -0,0 +1,90 @@ | ||
| + | function Bar({ className = "" }: { className?: string }) { | |
| + | return <span className={`skeleton block ${className}`} aria-hidden="true" />; | |
| + | } | |
| + | ||
| + | function SkeletonRows({ count = 4 }: { count?: number }) { | |
| + | return ( | |
| + | <ul className="mt-10"> | |
| + | {Array.from({ length: count }).map((_, i) => ( | |
| + | <li | |
| + | key={i} | |
| + | className="flex items-center gap-5 border-b border-[var(--line)] py-6" | |
| + | > | |
| + | <Bar className="h-4 w-8" /> | |
| + | <Bar className="h-7 flex-1 max-w-[60%]" /> | |
| + | <Bar className="hidden h-4 w-24 sm:block" /> | |
| + | </li> | |
| + | ))} | |
| + | </ul> | |
| + | ); | |
| + | } | |
| + | ||
| + | export function CitySkeleton() { | |
| + | return ( | |
| + | <main className="mx-auto w-full max-w-3xl px-5 py-16 sm:px-8"> | |
| + | <span className="kicker text-ash">Loading the city</span> | |
| + | <Bar className="mt-10 h-4 w-48" /> | |
| + | <Bar className="mt-4 h-16 w-3/4 sm:h-24" /> | |
| + | <div className="mt-6 flex gap-6"> | |
| + | <Bar className="h-4 w-32" /> | |
| + | <Bar className="h-4 w-28" /> | |
| + | </div> | |
| + | <div className="mt-16 border-t border-[var(--line)] pt-10"> | |
| + | <Bar className="h-7 w-40" /> | |
| + | <SkeletonRows count={4} /> | |
| + | </div> | |
| + | </main> | |
| + | ); | |
| + | } | |
| + | ||
| + | export function YardSkeleton() { | |
| + | return ( | |
| + | <main className="mx-auto w-full max-w-4xl px-5 py-16 sm:px-8"> | |
| + | <span className="kicker text-ash">Loading The Yard</span> | |
| + | <Bar className="mt-10 h-4 w-40" /> | |
| + | <Bar className="mt-4 h-14 w-2/3 sm:h-20" /> | |
| + | <div className="mt-14 grid gap-px sm:grid-cols-2"> | |
| + | {Array.from({ length: 4 }).map((_, i) => ( | |
| + | <div key={i} className="border-t border-[var(--line)] py-8"> | |
| + | <Bar className="h-6 w-1/2" /> | |
| + | <Bar className="mt-3 h-4 w-3/4" /> | |
| + | </div> | |
| + | ))} | |
| + | </div> | |
| + | </main> | |
| + | ); | |
| + | } | |
| + | ||
| + | export function EventSkeleton() { | |
| + | return ( | |
| + | <main className="mx-auto w-full max-w-3xl px-5 py-16 sm:px-8"> | |
| + | <span className="kicker text-ash">Loading the show</span> | |
| + | <Bar className="mt-10 h-4 w-40" /> | |
| + | <Bar className="mt-4 h-16 w-3/4 sm:h-24" /> | |
| + | <Bar className="mt-6 h-4 w-56" /> | |
| + | <div className="mt-14 border-t border-[var(--line)] pt-10"> | |
| + | <Bar className="h-5 w-2/3" /> | |
| + | <Bar className="mt-4 h-5 w-1/2" /> | |
| + | <Bar className="mt-4 h-5 w-3/5" /> | |
| + | </div> | |
| + | </main> | |
| + | ); | |
| + | } | |
| + | ||
| + | export function VideosSkeleton() { | |
| + | return ( | |
| + | <main className="mx-auto w-full max-w-4xl px-5 py-16 sm:px-8"> | |
| + | <span className="kicker text-ash">Loading the videos</span> | |
| + | <Bar className="mt-10 h-4 w-40" /> | |
| + | <Bar className="mt-4 h-14 w-2/3 sm:h-20" /> | |
| + | <div className="mt-14 grid gap-6 sm:grid-cols-2"> | |
| + | {Array.from({ length: 4 }).map((_, i) => ( | |
| + | <div key={i}> | |
| + | <Bar className="aspect-video w-full" /> | |
| + | <Bar className="mt-3 h-5 w-3/4" /> | |
| + | </div> | |
| + | ))} | |
| + | </div> | |
| + | </main> | |
| + | ); | |
| + | } |
| @@ -11,11 +11,13 @@ import { | ||
| organizations, | ||
| promoters, | ||
| stepTeams, | ||
| + | travelPlaces, | |
| videos, | ||
| } from "@stepping/db"; | ||
| import { | ||
| EVENT_TYPE, | ||
| SUBJECT_TYPE, | ||
| + | TRAVEL_CATEGORY, | |
| VIDEO_PLATFORM, | ||
| } from "@stepping/types"; | ||
| import { | ||
| @@ -192,6 +194,10 @@ export const appRouter = router({ | ||
| .from(promoters) | ||
| .where(and(eq(promoters.cityId, input.cityId), eq(promoters.status, "active"))), | ||
| ), | ||
| + | byId: publicProcedure.input(z.object({ id: uuid })).query(async ({ ctx, input }) => { | |
| + | const [row] = await ctx.db.select().from(promoters).where(eq(promoters.id, input.id)); | |
| + | return row ?? null; | |
| + | }), | |
| create: protectedProcedure | ||
| .input( | ||
| z.object({ cityId: uuid, name: z.string().min(1), bio: z.string().optional(), links }), | ||
| @@ -212,6 +218,10 @@ export const appRouter = router({ | ||
| .from(djs) | ||
| .where(and(eq(djs.cityId, input.cityId), eq(djs.status, "active"))), | ||
| ), | ||
| + | byId: publicProcedure.input(z.object({ id: uuid })).query(async ({ ctx, input }) => { | |
| + | const [row] = await ctx.db.select().from(djs).where(eq(djs.id, input.id)); | |
| + | return row ?? null; | |
| + | }), | |
| create: protectedProcedure | ||
| .input( | ||
| z.object({ cityId: uuid, name: z.string().min(1), bio: z.string().optional(), links }), | ||
| @@ -232,6 +242,10 @@ export const appRouter = router({ | ||
| .from(stepTeams) | ||
| .where(and(eq(stepTeams.cityId, input.cityId), eq(stepTeams.status, "active"))), | ||
| ), | ||
| + | byId: publicProcedure.input(z.object({ id: uuid })).query(async ({ ctx, input }) => { | |
| + | const [row] = await ctx.db.select().from(stepTeams).where(eq(stepTeams.id, input.id)); | |
| + | return row ?? null; | |
| + | }), | |
| create: protectedProcedure | ||
| .input( | ||
| z.object({ | ||
| @@ -258,6 +272,10 @@ export const appRouter = router({ | ||
| .from(chapters) | ||
| .where(and(eq(chapters.cityId, input.cityId), eq(chapters.status, "active"))), | ||
| ), | ||
| + | byId: publicProcedure.input(z.object({ id: uuid })).query(async ({ ctx, input }) => { | |
| + | const [row] = await ctx.db.select().from(chapters).where(eq(chapters.id, input.id)); | |
| + | return row ?? null; | |
| + | }), | |
| create: protectedProcedure | ||
| .input( | ||
| z.object({ | ||
| @@ -285,6 +303,10 @@ export const appRouter = router({ | ||
| .from(classes) | ||
| .where(and(eq(classes.cityId, input.cityId), eq(classes.status, "active"))), | ||
| ), | ||
| + | byId: publicProcedure.input(z.object({ id: uuid })).query(async ({ ctx, input }) => { | |
| + | const [row] = await ctx.db.select().from(classes).where(eq(classes.id, input.id)); | |
| + | return row ?? null; | |
| + | }), | |
| create: protectedProcedure | ||
| .input( | ||
| z.object({ | ||
| @@ -306,6 +328,37 @@ export const appRouter = router({ | ||
| }), | ||
| }), | ||
| + | // ---- Travel guide (per-city: hotels, restaurants, attractions, hospitals) - | |
| + | travelPlace: router({ | |
| + | list: publicProcedure | |
| + | .input(z.object({ cityId: uuid, category: z.enum(TRAVEL_CATEGORY).optional() })) | |
| + | .query(({ ctx, input }) => | |
| + | ctx.db | |
| + | .select() | |
| + | .from(travelPlaces) | |
| + | .where( | |
| + | and( | |
| + | eq(travelPlaces.cityId, input.cityId), | |
| + | eq(travelPlaces.status, "active"), | |
| + | input.category ? eq(travelPlaces.category, input.category) : undefined, | |
| + | ), | |
| + | ), | |
| + | ), | |
| + | create: protectedProcedure | |
| + | .input( | |
| + | z.object({ | |
| + | cityId: uuid, | |
| + | category: z.enum(TRAVEL_CATEGORY), | |
| + | name: z.string().min(1), | |
| + | address: z.string().optional(), | |
| + | }), | |
| + | ) | |
| + | .mutation(async ({ ctx, input }) => { | |
| + | const [row] = await ctx.db.insert(travelPlaces).values(input).returning(); | |
| + | return row; | |
| + | }), | |
| + | }), | |
| + | ||
| // ---- Minimal moderation (admin) ------------------------------------------ | ||
| moderation: router({ | ||
| recentEvents: adminProcedure.query(({ ctx }) => |
| @@ -0,0 +1,77 @@ | ||
| + | -- Demo content for the local POC (Atlanta + DC). Run once against a seeded DB. | |
| + | \set dev '00000000-0000-0000-0000-000000000001' | |
| + | begin; | |
| + | ||
| + | insert into venues (city_id, name, address, owner_id) | |
| + | select id, v.name, v.addr, :'dev' from cities, (values | |
| + | ('Georgia World Congress Center', '285 Andrew Young Intl Blvd NW, Atlanta, GA'), | |
| + | ('Forbes Arena (Morehouse College)', '830 Westview Dr SW, Atlanta, GA') | |
| + | ) as v(name, addr) where slug = 'atlanta'; | |
| + | ||
| + | insert into venues (city_id, name, address, owner_id) | |
| + | select id, v.name, v.addr, :'dev' from cities, (values | |
| + | ('Cramton Auditorium (Howard University)', '2455 6th St NW, Washington, DC'), | |
| + | ('Burr Gymnasium (Howard University)', '2400 6th St NW, Washington, DC') | |
| + | ) as v(name, addr) where slug = 'washington-dc'; | |
| + | ||
| + | insert into events (city_id, title, type, starts_at, owner_id) | |
| + | select id, e.title, e.type::event_type, e.ts::timestamptz, :'dev' from cities, (values | |
| + | ('Howard Homecoming Step Show', 'step_show', '2026-10-17 19:30-04'), | |
| + | ('DMV Greek Stroll-Off', 'stroll', '2026-08-15 18:00-04'), | |
| + | ('Yardfest Showcase', 'special', '2026-10-16 20:00-04') | |
| + | ) as e(title, type, ts) where slug = 'washington-dc'; | |
| + | ||
| + | insert into djs (city_id, name, owner_id) | |
| + | select id, n, :'dev' from cities, (values ('DJ Capitol'), ('DJ Metro Sound')) t(n) where slug = 'washington-dc'; | |
| + | insert into promoters (city_id, name, owner_id) | |
| + | select id, n, :'dev' from cities, (values ('Bison Entertainment'), ('DMV Greek Events')) t(n) where slug = 'washington-dc'; | |
| + | insert into step_teams (city_id, name, owner_id) | |
| + | select id, n, :'dev' from cities, (values ('Bison Steppers'), ('Capital City Line')) t(n) where slug = 'washington-dc'; | |
| + | insert into classes (city_id, title, instructor, owner_id) | |
| + | select id, 'Beginner Steppin Workshop', 'Coach Dawkins', :'dev' from cities where slug = 'washington-dc'; | |
| + | ||
| + | insert into chapters (organization_id, city_id, name, school, owner_id) | |
| + | select o.id, c.id, 'Beta Chapter', 'Howard University', :'dev' | |
| + | from cities c, organizations o where c.slug = 'washington-dc' and o.name = 'Alpha Kappa Alpha'; | |
| + | insert into chapters (organization_id, city_id, name, school, owner_id) | |
| + | select o.id, c.id, 'Xi Chapter', 'Howard University', :'dev' | |
| + | from cities c, organizations o where c.slug = 'washington-dc' and o.name = 'Omega Psi Phi'; | |
| + | ||
| + | insert into travel_places (city_id, category, name, address) | |
| + | select id, t.cat::travel_category, t.name, t.addr from cities, (values | |
| + | ('hotel', 'Signia by Hilton Atlanta', '210 Peachtree St NW, Atlanta, GA'), | |
| + | ('hotel', 'AC Hotel Atlanta Downtown', '166 Carnegie Way NW, Atlanta, GA'), | |
| + | ('restaurant', 'Paschal''s', '180b Northside Dr SW, Atlanta, GA'), | |
| + | ('restaurant', 'Busy Bee Cafe', '810 M.L.K. Jr Dr SW, Atlanta, GA'), | |
| + | ('mall', 'Atlantic Station', '1380 Atlantic Dr NW, Atlanta, GA'), | |
| + | ('attraction', 'Centennial Olympic Park', '265 Park Ave W NW, Atlanta, GA'), | |
| + | ('hospital', 'Grady Memorial Hospital', '80 Jesse Hill Jr Dr SE, Atlanta, GA') | |
| + | ) as t(cat, name, addr) where slug = 'atlanta'; | |
| + | ||
| + | insert into travel_places (city_id, category, name, address) | |
| + | select id, t.cat::travel_category, t.name, t.addr from cities, (values | |
| + | ('hotel', 'The LINE DC', '1770 Euclid St NW, Washington, DC'), | |
| + | ('hotel', 'Hotel RL Washington DC', '1823 L St NW, Washington, DC'), | |
| + | ('restaurant', 'Ben''s Chili Bowl', '1213 U St NW, Washington, DC'), | |
| + | ('restaurant', 'Oohh''s & Aahh''s', '1005 U St NW, Washington, DC'), | |
| + | ('mall', 'CityCenterDC', '825 10th St NW, Washington, DC'), | |
| + | ('attraction', 'Howard University Yard', '2400 6th St NW, Washington, DC'), | |
| + | ('hospital', 'Howard University Hospital', '2041 Georgia Ave NW, Washington, DC') | |
| + | ) as t(cat, name, addr) where slug = 'washington-dc'; | |
| + | ||
| + | insert into videos (city_id, title, embed_url, platform, owner_id) | |
| + | select id, v.title, v.url, v.plat::video_platform, :'dev' from cities, (values | |
| + | ('Atlanta Greek Picnic - Step Highlights', 'https://www.youtube.com/results?search_query=atlanta+greek+picnic+step+show', 'youtube'), | |
| + | ('AUC Homecoming Stroll-Off', 'https://www.tiktok.com/search?q=auc%20homecoming%20stroll', 'tiktok') | |
| + | ) as v(title, url, plat) where slug = 'atlanta'; | |
| + | ||
| + | insert into comments (subject_type, subject_id, body, owner_id) | |
| + | select 'event', id, 'Who all pulling up this year? The AUC chapters always bring it.', :'dev' | |
| + | from events where title = 'Atlanta Greek Picnic Step Show' limit 1; | |
| + | ||
| + | commit; | |
| + | select 'venues' c, count(*) n from venues | |
| + | union all select 'travel', count(*) from travel_places | |
| + | union all select 'videos', count(*) from videos | |
| + | union all select 'comments', count(*) from comments | |
| + | union all select 'events', count(*) from events; |