● Zion Boggan zionboggan.com ↗

Implement Dad's July 2nd spec: stepping-couple emblem replaces the disco ball, Chicago Stepping identity and tagline, flyers, photo albums with moderation, PDF programs, vendors, instructors, readability and copy pass, sample-only seed data

fc433cb   Zion Boggan committed on Jul 4, 2026 (1 month ago)
DESIGN_SYSTEM.md +81 -65
@@ -1,98 +1,114 @@
-# Stepping Design System ("IN THE POCKET")
+# Chicago Stepping Design System ("IN THE POCKET" / Chrome Noir)
A bespoke visual system for the **Chicago Stepping** app. Built from first
principles, grounded in Chicago-stepping culture: the smooth, gliding, soulful
6-/8-count partner dance that came out of Chicago's West and South sides in the
1940s-60s, evolved from the jitterbug and the 1950s glide-and-stride "bop." It
deliberately does NOT use the `ui-ux-pro-max` skill or any generic
-component-kit look. Every choice is sourced from (a) Zion's reference portfolios
-and (b) license-clean open-source technique, then bent toward the floor.
+component-kit look.
+
+Updated 2026-07-04 to match Dad's July 2nd decisions (`JULY2ND_SPEC.md`, D1-D10)
+and the shipped Chrome Noir code. This file describes what is actually in
+`apps/web`, not an aspiration.
+
+## Locked identity decisions (Dad, July 2nd)
+
+- **No disco ball.** The old chrome disco ball hero (WebGL) sent a conflicted
+ message (disco vs. stepping) and is deleted: components, canvas fallback,
+ PNG, everything. Do not bring it back.
+- **Signature emblem: the stepping couple.** An abstract line-art couple
+ mid-turn; she glides under his raised arm, gown fanning into the sweep, a
+ cobalt dot marking the held hands. Lives in
+ `apps/web/src/components/stepping-couple.tsx`, mirrored in `public/icon.svg`
+ and the manifest. Pure SVG + CSS, no WebGL, gated on
+ `prefers-reduced-motion`.
+- **Wordmark: CHICAGO STEPPING.** Serif italic "Chicago" over display
+ "STEPPING" (`apps/web/src/components/wordmark.tsx`). The name leads with
+ Chicago Stepping everywhere; final clever phrasing subject to a family vote.
+- **Tagline LOCKED: "Chicago Stepping. Your City."** Appears in the hero and
+ footers verbatim.
+- **Voice: grown-up, concise, straight to the point.** No fluff, no slang-cute
+ filler. Section names The Sets / The Floor / The Road are approved and stay.
+- **Professional bar: Apple / BlackRock / Fidelity.** Color can pop but never
+ goes cheesy.
+- **Readability is a requirement, not a preference.** The audience skews
+ older. Kickers stay at or above 0.8rem, body copy at or above 1rem, strong
+ contrast. Never shrink type to make a layout work.
## Sourcing (provenance, so this stays defensible and original)
- **Design DNA** distilled from Zion's references: monolithstudio.com,
airborne.studio, donprod.uk, ning-h.com, tenbinlabs.xyz, wildyriftian.com,
- oscarpecher.com. Common laws we adopt: two-tone discipline, warm-black /
- warm-paper (never pure #000/#fff), type-as-art, mono micro-type for metadata,
- one signature interactive object, velocity-driven motion, custom cursor as a
- discovery mechanic, a named concept spine, negative space as luxury.
-- **Technique** lifted only from MIT/CC0 repos (security-scanned, semgrep clean):
- `studio-freight/lenis` (MIT) for smooth scroll; structural patterns from
- `brunosimon/folio-2019` and `ektogamat/threejs-andy-boilerplate` (MIT). The
- `olivierlarose` staggered-reveal / zoom-parallax repos are no-license, so we
- REIMPLEMENT their technique from scratch, never copy their files.
+ oscarpecher.com. Common laws we adopt: two-tone discipline, near-black
+ ground / off-white ink (never pure #000/#fff), type-as-art, mono micro-type
+ for metadata, one signature object, negative space as luxury.
+- **Technique** lifted only from MIT/CC0 repos (security-scanned):
+ `studio-freight/lenis` (MIT) for smooth scroll. No-license references are
+ reimplemented from scratch, never copied.
## Concept spine: IN THE POCKET
-The site runs like a **set that never stops**. Chicago stepping is not a stomp
-or a clap; it is a glide. Smooth, in-the-pocket, soulful. Lead and follow on a
-6-/8-count, the dancers locked into the music. So the interface keeps the count
-and rides the pocket. The page is a set with a run of acts:
+The site runs like a set that never stops. Chicago stepping is a glide, not a
+stomp: lead and follow on a 6-/8-count, locked into the music. The page is a
+run of acts:
-1. **THE SPOTLIGHT**: the hero. The lights drop, the cut comes in, the name
- glides in on the one.
-2. **THE SETS**: sets, weekenders, classes, workshops, competitions, special,
- social.
+1. **THE SPOTLIGHT**: the hero. Type-as-art (STEP. GLIDE. spin.), the couple
+ emblem, the locked tagline.
+2. **THE SETS**: sets, weekenders, classes, workshops, competitions, socials.
3. **THE FLOOR**: stepping groups, DJs, promoters/hosts, classes.
-4. **THE ROAD**: per-city travel guide for the traveling stepper (out-of-town
- sets and weekenders).
+4. **THE ROAD**: per-city travel guide for the traveling stepper.
-The old "THE NINE / Greek-letter" framing is gone entirely. This is the
-partner dance: the count, the glide, the pocket, the spin, partner lead/follow,
-soulful sets.
+The old "THE NINE / Greek-letter" framing is gone entirely. This is Chicago
+stepping, never Greek stepping.
-## Color: two-tone + one heritage accent (warm)
+## Color: Chrome Noir (two-tone + one cobalt accent)
| Token | Hex | Role |
|---|---|---|
-| `--stage` | `#0F0E0C` | ground (warm near-black, the floor after dark / brick at night) |
-| `--bone` | `#F2ECE0` | ink (warm spotlight bone, never pure white) |
-| `--gold` | `#C8A24C` | the only accent: heritage gold, warm and dignified |
-| `--gold-bright` | `#E4C572` | gold hover / active |
-| `--ash` | `#8A8275` | warm grey for secondary mono metadata |
-| `--line` | `rgba(242,236,224,0.14)` | hairlines |
+| `--stage` | `#0a0a0b` | ground (near-black, the floor after dark) |
+| `--bone` | `#f4f1ea` | ink (warm off-white, never pure white) |
+| `--gold` | `#3d5afe` | the ONE accent: electric cobalt (token keeps the legacy `--gold` name so every `text-gold`/`bg-gold` utility reskins from one place) |
+| `--gold-bright` | `#6e86ff` | accent hover / active |
+| `--ash` | `#9aa1b0` | cool grey for secondary mono metadata (lightened for contrast) |
+| `--line` | `rgba(244,241,234,0.12)` | hairlines |
-Discipline: no other hues. Gold is the one warm dignifier across every city and
-every set.
+Discipline: no other hues. Cobalt pops but stays professional; if a treatment
+reads cheesy, cut it.
## Type: smooth + editorial duality (lead and follow)
- **Display / impact:** `Bricolage Grotesque`, oversized, tight, declarative.
- The "glide." Used huge for act titles and the name.
-- **Editorial accent:** `Instrument Serif`, high-contrast serif for the
- storytelling lines (the history of this dance is literally storytelling, born
- on Chicago's West/South sides, 1940s-60s, out of the bop and the jitterbug).
-- **Metadata:** `Space Mono`, every label, index, set time, and city
- coordinate. Maps the "art-tier mono micro-type" law onto real steppin' data:
- `CHI · 41.878, -87.629`, `6-COUNT · 8-COUNT`, `SET · 9:00 PM`, `01 / 06`.
-
-Type rule: on any screen, either the display type is loud or the imagery is.
-Never both.
-
-## Motion: "in the pocket"
-
-- **Cadence:** reveals ease in like a glide, staggered on the count (the
- 6-/8-count), in the pocket, not a uniform fade and not a percussive stomp.
- Smooth, slightly behind-the-beat soulful timing.
-- **Velocity-reactive:** display type reacts to scroll speed (skew/weight),
- reimplemented with Lenis + transform mapping (not copied).
-- **Smooth scroll:** Lenis (MIT). The whole page glides; nothing jumps.
-- **Signature object / cursor:** a custom cursor that leaves a glide-ripple on
- click and a soft trailing dot, the pocket you can feel. This is our "one
- signature interactive object," lightweight (canvas/DOM), no heavy WebGL.
-- **Reduced motion:** all of the above gate on `prefers-reduced-motion`.
+- **Editorial accent:** `Instrument Serif`, high-contrast serif for
+ storytelling lines and the "Chicago" half of the wordmark.
+- **Metadata:** `Space Mono` kickers for labels, indices, set times, city
+ coordinates (`CHI · 41.878, -87.629`, `01 / 06`).
+
+Rules:
+- On any screen, either the display type is loud or the imagery is. Never both.
+- Floors (older-people-friendly): `.kicker` >= 0.8rem, letter-spacing 0.22em;
+ body/serif copy >= 1rem. Do not shrink anything below these.
+
+## Motion: "in the pocket" (cheap on old phones)
+
+- **Reveals:** `beat-in` staggered ease-ins on the count (b1-b5 delays).
+- **Emblem:** entrance stagger (figures fade-rise, arms draw in, the held-hands
+ dot pops), then a slow 9s float and 0.7deg sway. Transform/opacity only; no
+ WebGL anywhere (the disco ball lagged and is gone).
+- **Smooth scroll:** Lenis (MIT) via `SmoothScroll`.
+- **Marquee:** mono ticker of cities and count motifs.
+- **Reduced motion:** every animation gates on `prefers-reduced-motion`.
## Layout
-Editorial and asymmetric. Generous negative space. Numbered/lettered systems
-everywhere (set times, city tiers, `01/06` indices). Hairline rules and mono
-kickers frame each act. Mobile-first; the set reflows to a single column
-without losing the count.
+Editorial and asymmetric. Generous negative space. Numbered mono indices
+(`01 / Sets`). Hairline rules frame each act. Mobile-first; the set reflows to
+a single column without losing the count.
-## Components (first set)
+## Components (current set)
-- `GlideCursor`: custom cursor + glide-ripple on click.
+- `SteppingCouple`: the signature emblem (hero + site mark).
+- `Wordmark`: CHICAGO STEPPING lockup (header, footers, mobile nav).
- `SmoothScroll`: Lenis provider.
-- `Marquee`: a running mono ticker of set times / city names (the glide-by).
+- `Marquee`: running mono ticker.
+- `MobileNav`, `SiteFooter`, skeletons.
- Act section shell: mono kicker + huge display title + content.
-- Event/set card, profile card: bone-on-stage, gold for the one thing that matters.
apps/web/.gitignore +3 -0
@@ -39,3 +39,6 @@ yarn-error.log*
# typescript
*.tsbuildinfo
next-env.d.ts
+
+# self-hosted user uploads (served via /api/uploads)
+/uploads/
apps/web/next.config.ts +7 -0
@@ -11,6 +11,13 @@ const securityHeaders = [
];
const nextConfig: NextConfig = {
+ experimental: {
+ serverActions: {
+ // Uploads run through server actions (flyers <=8MB, programs <=15MB);
+ // per-file limits are enforced in lib/uploads.ts.
+ bodySizeLimit: "16mb",
+ },
+ },
transpilePackages: [
"@stepping/api",
"@stepping/db",
apps/web/package.json +0 -3
@@ -9,7 +9,6 @@
"lint": "eslint"
},
"dependencies": {
- "@react-three/fiber": "^9.1.0",
"@stepping/api": "workspace:*",
"@stepping/config": "workspace:*",
"@stepping/db": "workspace:*",
@@ -26,7 +25,6 @@
"react": "19.2.4",
"react-dom": "19.2.4",
"superjson": "^2.2.2",
- "three": "^0.171.0",
"zod": "^3.24.1"
},
"devDependencies": {
@@ -34,7 +32,6 @@
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
- "@types/three": "^0.171.0",
"eslint": "^9",
"eslint-config-next": "16.2.9",
"tailwindcss": "^4",
apps/web/public/disco-ball.png +0 -0
Binary file not shown
apps/web/public/icon.svg +16 -17
@@ -1,19 +1,18 @@
-<svg width="512" height="512" viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Stepping">
- <defs>
- <linearGradient id="ball" x1="0" y1="0" x2="0" y2="1">
- <stop offset="0" stop-color="#f4f1ea"/>
- <stop offset="0.5" stop-color="#aab4c6"/>
- <stop offset="1" stop-color="#3d5afe"/>
- </linearGradient>
- <clipPath id="c"><circle cx="32" cy="34" r="20"/></clipPath>
- </defs>
- <rect width="64" height="64" rx="14" fill="#0a0a0b"/>
- <rect x="31" y="6" width="2" height="9" fill="#8c93a3"/>
- <circle cx="32" cy="14" r="3" fill="#c9cad0"/>
- <circle cx="32" cy="34" r="20" fill="url(#ball)"/>
- <g clip-path="url(#c)" stroke="#0a0a0b" stroke-width="1.6" opacity="0.85" fill="none">
- <path d="M12 20 H52 M12 27 H52 M12 34 H52 M12 41 H52 M12 48 H52"/>
- <path d="M16 16 V52 M24 14 V54 M32 14 V54 M40 14 V54 M48 16 V52"/>
+<svg width="512" height="512" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Chicago Stepping">
+ <rect width="512" height="512" rx="112" fill="#0a0a0b"/>
+ <g transform="translate(70.6 -31.1) scale(1.10)">
+ <circle cx="140" cy="114" r="12" fill="#f4f1ea"/>
+ <path d="M136 130 C124 160 122 192 134 222 L146 224 C141 194 142 164 148 136 C144 127 140 127 136 130 Z" fill="#f4f1ea"/>
+ <path d="M134 220 C100 260 68 324 58 404 C88 430 138 432 162 414 C155 344 150 278 146 222 Z" fill="#f4f1ea"/>
+ <path fill="none" d="M126 248 C102 288 88 336 86 388" stroke="#3d5afe" stroke-width="4.5" stroke-linecap="round"/>
+ <circle cx="236" cy="95" r="12.5" fill="#f4f1ea"/>
+ <path d="M236 109 C226 109 216 115 213 127 C207 167 214 210 227 250 C238 256 250 256 259 249 C268 209 269 166 261 126 C257 115 247 109 236 109 Z" fill="#f4f1ea"/>
+ <path d="M261 130 C270 154 274 182 272 210 C271 220 265 224 260 220 C263 192 260 160 253 136 Z" fill="#f4f1ea"/>
+ <path d="M226 244 C221 300 216 352 211 398 C202 404 195 410 194 418 C203 424 218 423 224 415 C229 376 235 336 241 300 C244 282 245 262 245 246 Z" fill="#f4f1ea"/>
+ <path d="M259 246 C262 300 264 356 264 402 C270 406 276 411 278 417 C271 422 257 421 252 413 C248 372 245 334 243 300 C242 282 242 262 242 248 Z" fill="#f4f1ea"/>
+ <path fill="none" d="M239 116 C241 134 244 152 248 170" stroke="#3d5afe" stroke-width="4.5" stroke-linecap="round"/>
+ <path fill="none" d="M226 132 C205 103 186 94 166 98" stroke="#f4f1ea" stroke-width="8" stroke-linecap="round"/>
+ <path fill="none" d="M150 132 C156 120 160 110 163 100" stroke="#f4f1ea" stroke-width="5" stroke-linecap="round"/>
+ <circle cx="164" cy="98" r="7" fill="#3d5afe"/>
</g>
- <circle cx="25" cy="27" r="4" fill="#ffffff" opacity="0.7"/>
</svg>
apps/web/public/manifest.webmanifest +2 -2
@@ -1,7 +1,7 @@
{
- "name": "Stepping",
+ "name": "Chicago Stepping",
"short_name": "Stepping",
- "description": "The home for Chicago stepping. The sets, the classes, the DJs, the people, and the road to every set, city by city.",
+ "description": "Chicago Stepping. Your City. The sets, the people, and the road between cities, in one place.",
"start_url": "/",
"display": "standalone",
"background_color": "#0a0a0b",
apps/web/public/og-image.png +0 -0
Binary file not shown
apps/web/src/app/about/page.tsx +21 -19
@@ -1,9 +1,10 @@
import Link from "next/link";
+import { Wordmark } from "@/components/wordmark";
export const metadata = {
- title: "What Is This - Stepping",
+ title: "About · Chicago Stepping",
description:
- "A plain-language guide to the Chicago stepping app: find a city, see the sets, meet the people, and plan the road to every city.",
+ "A plain-language guide to Chicago Stepping: find your city, see the sets, meet the people, and plan the road.",
};
const STEPS = [
@@ -20,7 +21,7 @@ const STEPS = [
{
index: "03",
title: "Step onto The Floor",
- body: "The Floor is the community for that city: the stepping groups, the DJs, the promoters who host the sets, and the classes where you can learn the count. It is who's who and what's what.",
+ body: "The Floor is the community for that city: the stepping groups, the DJs, the promoters who host the sets, and the classes where you can learn the count.",
},
{
index: "04",
@@ -33,13 +34,14 @@ 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
+ ← Home
</Link>
- <p className="kicker mt-12 text-ash">What is this</p>
+ <p className="kicker mt-12 text-ash">About</p>
<h1 className="display mt-3 text-6xl leading-[0.9] sm:text-8xl">
- A guide to
- <br />
+ <span className="serif block text-4xl font-normal italic text-bone/80 sm:text-5xl">
+ Chicago
+ </span>
<span className="text-gold">Stepping</span>
</h1>
@@ -47,16 +49,14 @@ export default function AboutPage() {
Chicago stepping is a partner dance. Smooth, gliding, soulful, danced on
a count. It came up in the African American community on Chicago&apos;s
West and South sides, and from the 1940s to the 60s it grew out of the
- jitterbug and the 50s glide-and-stride dance they called the bop. This
- is Chicago stepping, not Greek stepping.
+ jitterbug and the glide-and-stride dance called the bop. This is
+ Chicago stepping, not Greek stepping.
</p>
<p className="serif mt-6 text-2xl leading-snug text-bone/85 sm:text-3xl">
- This app is one simple thing. In dad&apos;s words, it is so &quot;people
- in different cities can just share, so people [are] abreast of what&apos;s
- going on, besides having to go to YouTube and Facebook Live to see
- content.&quot; One place, organized by city, where the community posts
- and finds the sets, the classes, the DJs, and the people.
+ This site does one thing. One place, organized by city, where the
+ community posts and finds the sets, the classes, the DJs, and the
+ people.
</p>
<section className="mt-16 border-t border-[var(--line)] pt-12">
@@ -88,9 +88,9 @@ export default function AboutPage() {
That&apos;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.
+ Take your time. Tap around. If you ever feel lost, the
+ <span className="text-gold"> Chicago Stepping</span> mark in the top
+ corner always brings you back to the start.
</p>
<Link
@@ -102,8 +102,10 @@ export default function AboutPage() {
</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">In the Pocket · Built for the culture</span>
+ <Link href="/" className="text-xl">
+ <Wordmark />
+ </Link>
+ <span className="kicker text-ash">Chicago Stepping. Your City.</span>
</footer>
</main>
);
apps/web/src/app/admin/login/page.tsx +1 -1
@@ -3,7 +3,7 @@ import { login, getCurrentUser, isAdmin } from "@/lib/auth";
export const dynamic = "force-dynamic";
-export const metadata = { title: "Admin - Stepping", robots: { index: false } };
+export const metadata = { title: "Admin · Stepping", robots: { index: false } };
export default async function AdminLogin({
searchParams,
apps/web/src/app/admin/page.tsx +247 -1
@@ -3,10 +3,11 @@ import { redirect } from "next/navigation";
import { revalidatePath } from "next/cache";
import { getCurrentUser, isAdmin, logout } from "@/lib/auth";
import { sessionCaller } from "@/lib/trpc/server";
+import { saveUpload } from "@/lib/uploads";
export const dynamic = "force-dynamic";
-export const metadata = { title: "Admin - Stepping", robots: { index: false } };
+export const metadata = { title: "Admin · Stepping", robots: { index: false } };
function stamp(d: Date | null) {
if (!d) return "";
@@ -28,6 +29,8 @@ export default async function AdminPage() {
const sponsorsList = await caller.sponsor.adminList();
const proList = await caller.proAccount.adminList();
const launchedCities = await caller.city.listLaunched();
+ const pendingPhotos = await caller.eventPhoto.adminPending();
+ const vendorList = await caller.vendor.adminList();
const money = (c: number) => (c === 0 ? "Free" : `$${(c / 100).toFixed(2)}`);
@@ -46,6 +49,69 @@ export default async function AdminPage() {
revalidatePath("/admin");
}
+ async function attachMedia(formData: FormData) {
+ "use server";
+ if (!isAdmin(await getCurrentUser())) return;
+ const eventId = String(formData.get("eventId") ?? "");
+ const kind = String(formData.get("kind") ?? "");
+ const file = formData.get("file");
+ if (!eventId || !(file instanceof File) || file.size === 0) return;
+ let url: string;
+ try {
+ url = await saveUpload(file, kind === "program" ? "pdf" : "image");
+ } catch {
+ return;
+ }
+ const { caller } = await sessionCaller();
+ if (kind === "program") {
+ await caller.event.setProgram({ id: eventId, programUrl: url });
+ } else {
+ await caller.event.setFlyer({ id: eventId, flyerUrl: url });
+ }
+ revalidatePath("/admin");
+ }
+
+ async function reviewPhoto(formData: FormData) {
+ "use server";
+ const id = String(formData.get("id") ?? "");
+ const approve = String(formData.get("decision") ?? "") === "approve";
+ if (!id) return;
+ const { caller } = await sessionCaller();
+ await caller.eventPhoto.adminReview({ id, approve });
+ revalidatePath("/admin");
+ }
+
+ async function createVendor(formData: FormData) {
+ "use server";
+ const cityId = String(formData.get("cityId") ?? "");
+ const eventId = String(formData.get("eventId") ?? "");
+ const name = String(formData.get("name") ?? "").trim();
+ const category = String(formData.get("category") ?? "").trim();
+ const description = String(formData.get("description") ?? "").trim();
+ const linkUrl = String(formData.get("linkUrl") ?? "").trim();
+ if (!cityId || !name) return;
+ const { caller } = await sessionCaller();
+ await caller.vendor.adminCreate({
+ cityId,
+ eventId: eventId || undefined,
+ name,
+ category: category || undefined,
+ description: description || undefined,
+ linkUrl: linkUrl || undefined,
+ });
+ revalidatePath("/admin");
+ }
+
+ async function setVendorStatus(formData: FormData) {
+ "use server";
+ const id = String(formData.get("id") ?? "");
+ const status = String(formData.get("status") ?? "") as "active" | "hidden";
+ if (!id) return;
+ const { caller } = await sessionCaller();
+ await caller.vendor.adminSetStatus({ id, status });
+ revalidatePath("/admin");
+ }
+
async function createSponsor(formData: FormData) {
"use server";
const cityId = String(formData.get("cityId") ?? "");
@@ -139,6 +205,99 @@ export default async function AdminPage() {
</ul>
</section>
+ {/* Photo queue [F2/H1]: community submissions wait here for approval */}
+ <section className="mt-14 border-t border-[var(--line)] pt-8">
+ <div className="flex items-end justify-between">
+ <h2 className="display text-3xl">Photo Queue</h2>
+ <span className="kicker text-ash">Pending event photos</span>
+ </div>
+ <ul className="mt-6">
+ {pendingPhotos.length === 0 ? (
+ <li className="kicker text-ash">Nothing waiting for review.</li>
+ ) : (
+ pendingPhotos.map((p) => (
+ <li
+ key={p.id}
+ className="flex items-center gap-4 border-b border-[var(--line)] py-4"
+ >
+ <a href={p.url} target="_blank" rel="noopener noreferrer">
+ <img
+ src={p.url}
+ alt="Pending submission"
+ className="h-16 w-16 border border-[var(--line)] object-cover"
+ />
+ </a>
+ <div className="flex-1">
+ <p className="display text-lg text-bone">{p.eventTitle}</p>
+ {p.caption ? (
+ <p className="kicker mt-1 text-ash">{p.caption}</p>
+ ) : null}
+ </div>
+ <span className="kicker hidden text-ash sm:block">{stamp(p.createdAt)}</span>
+ <form action={reviewPhoto} className="flex gap-3">
+ <input type="hidden" name="id" value={p.id} />
+ <button
+ name="decision"
+ value="approve"
+ className="kicker text-gold hover:text-gold-bright"
+ >
+ Approve
+ </button>
+ <button name="decision" value="reject" className="kicker text-ash hover:text-bone">
+ Reject
+ </button>
+ </form>
+ </li>
+ ))
+ )}
+ </ul>
+ </section>
+
+ {/* Event media [F1/F4]: attach a flyer image or PDF program */}
+ <section className="mt-14 border-t border-[var(--line)] pt-8">
+ <div className="flex items-end justify-between">
+ <h2 className="display text-3xl">Event Media</h2>
+ <span className="kicker text-ash">Flyers + PDF programs</span>
+ </div>
+ <form action={attachMedia} className="mt-5 flex flex-wrap items-end gap-3">
+ <select
+ name="eventId"
+ required
+ defaultValue=""
+ className="kicker max-w-60 border border-[var(--line)] bg-stage px-3 py-2 text-bone outline-none focus:border-gold"
+ >
+ <option value="" disabled>EVENT</option>
+ {recent.map((e) => (
+ <option key={e.id} value={e.id} className="bg-stage">
+ {e.title.toUpperCase()}
+ </option>
+ ))}
+ </select>
+ <select
+ name="kind"
+ required
+ defaultValue="flyer"
+ className="kicker border border-[var(--line)] bg-stage px-3 py-2 text-bone outline-none focus:border-gold"
+ >
+ <option value="flyer" className="bg-stage">FLYER (IMAGE)</option>
+ <option value="program" className="bg-stage">PROGRAM (PDF)</option>
+ </select>
+ <input
+ type="file"
+ name="file"
+ required
+ accept="image/jpeg,image/png,image/webp,application/pdf"
+ className="kicker flex-1 border border-[var(--line)] bg-stage px-3 py-2 text-bone outline-none file:mr-3 file:border-0 file:bg-gold file:px-3 file:py-1 file:text-stage"
+ />
+ <button type="submit" className="kicker bg-gold px-5 py-2 text-stage hover:bg-gold-bright">
+ Attach
+ </button>
+ </form>
+ <p className="kicker mt-3 text-ash">
+ Flyers: JPEG/PNG/WebP up to 8MB. Programs: PDF up to 15MB. Attaching again replaces the current file.
+ </p>
+ </section>
+
{/* Revenue log (orders: tickets + boosts) */}
<section className="mt-14 border-t border-[var(--line)] pt-8">
<div className="flex items-end justify-between">
@@ -214,6 +373,93 @@ export default async function AdminPage() {
</ul>
</section>
+ {/* Vendors [F5] */}
+ <section className="mt-14 border-t border-[var(--line)] pt-8">
+ <div className="flex items-end justify-between">
+ <h2 className="display text-3xl">Vendors</h2>
+ <span className="kicker text-ash">City-wide or tied to one event</span>
+ </div>
+ <form action={createVendor} className="mt-5 flex flex-wrap items-end gap-3">
+ <select
+ name="cityId"
+ required
+ defaultValue=""
+ className="kicker border border-[var(--line)] bg-stage px-3 py-2 text-bone outline-none focus:border-gold"
+ >
+ <option value="" disabled>CITY</option>
+ {launchedCities.map((c) => (
+ <option key={c.id} value={c.id} className="bg-stage">
+ {c.name.toUpperCase()}
+ </option>
+ ))}
+ </select>
+ <select
+ name="eventId"
+ defaultValue=""
+ className="kicker max-w-52 border border-[var(--line)] bg-stage px-3 py-2 text-bone outline-none focus:border-gold"
+ >
+ <option value="" className="bg-stage">ALL CITY EVENTS</option>
+ {recent.map((e) => (
+ <option key={e.id} value={e.id} className="bg-stage">
+ {e.title.toUpperCase()}
+ </option>
+ ))}
+ </select>
+ <input
+ name="name"
+ required
+ placeholder="Vendor name"
+ className="serif flex-1 border-b border-[var(--line)] bg-transparent py-2 text-lg text-bone outline-none placeholder:text-ash focus:border-gold"
+ />
+ <input
+ name="category"
+ placeholder="Category (opt)"
+ className="kicker w-36 border border-[var(--line)] bg-stage px-3 py-2 text-bone outline-none placeholder:text-ash focus:border-gold"
+ />
+ <input
+ name="description"
+ placeholder="What they sell (opt)"
+ className="serif flex-1 border-b border-[var(--line)] bg-transparent py-2 text-lg text-bone outline-none placeholder:text-ash focus:border-gold"
+ />
+ <input
+ name="linkUrl"
+ placeholder="https:// (opt)"
+ className="kicker border border-[var(--line)] bg-stage px-3 py-2 text-bone outline-none placeholder:text-ash focus:border-gold"
+ />
+ <button type="submit" className="kicker bg-gold px-5 py-2 text-stage hover:bg-gold-bright">Add</button>
+ </form>
+ <ul className="mt-6">
+ {vendorList.length === 0 ? (
+ <li className="kicker text-ash">No vendors yet.</li>
+ ) : (
+ vendorList.map((v) => (
+ <li key={v.id} className="flex items-center gap-4 border-b border-[var(--line)] py-3">
+ <span
+ className="kicker w-20"
+ style={{ color: v.status === "active" ? "var(--gold)" : "var(--ash)" }}
+ >
+ {v.status.toUpperCase()}
+ </span>
+ <span className="display flex-1 text-lg">{v.name}</span>
+ <span className="kicker hidden text-ash sm:block">
+ {v.category ? v.category.toUpperCase() : ""}
+ </span>
+ <form action={setVendorStatus} className="flex gap-3">
+ <input type="hidden" name="id" value={v.id} />
+ <button
+ name="status"
+ value={v.status === "active" ? "hidden" : "active"}
+ className="kicker text-gold hover:text-gold-bright"
+ >
+ {v.status === "active" ? "Hide" : "Activate"}
+ </button>
+ </form>
+ </li>
+ ))
+ )}
+ </ul>
+ </section>
+
{/* Pro Accounts */}
<section className="mt-14 border-t border-[var(--line)] pt-8">
<div className="flex items-end justify-between">
apps/web/src/app/api/uploads/[name]/route.ts +31 -0
@@ -0,0 +1,31 @@
+import { readFile } from "node:fs/promises";
+import path from "node:path";
+import { UPLOADS_DIR, UPLOAD_NAME_RE, contentTypeFor } from "@/lib/uploads";
+
+// Serves stored uploads. Names are strictly <32 hex>.<ext>, so no traversal
+// and no client-controlled paths ever reach the filesystem.
+export async function GET(
+ _request: Request,
+ { params }: { params: Promise<{ name: string }> },
+) {
+ const { name } = await params;
+ if (!UPLOAD_NAME_RE.test(name)) return new Response("Not found", { status: 404 });
+
+ const type = contentTypeFor(name.split(".")[1] ?? "");
+ if (!type) return new Response("Not found", { status: 404 });
+
+ try {
+ const buf = await readFile(path.join(UPLOADS_DIR, name));
+ return new Response(new Uint8Array(buf), {
+ headers: {
+ "Content-Type": type,
+ "Content-Length": String(buf.length),
+ "Cache-Control": "public, max-age=31536000, immutable",
+ "X-Content-Type-Options": "nosniff",
+ "Content-Disposition": "inline",
+ },
+ });
+ } catch {
+ return new Response("Not found", { status: 404 });
+ }
+}
apps/web/src/app/api/uploads/route.ts +38 -0
@@ -0,0 +1,38 @@
+import { getCurrentUser, isAdmin } from "@/lib/auth";
+import { saveUpload, type UploadKind } from "@/lib/uploads";
+
+export const dynamic = "force-dynamic";
+
+// Multipart upload endpoint. Requires a real signed-in session; PDFs (event
+// programs) are admin-only. Fields: file (required), kind ("image" | "pdf").
+export async function POST(request: Request) {
+ const user = await getCurrentUser();
+ if (!user) {
+ return Response.json({ error: "Sign in to upload." }, { status: 401 });
+ }
+
+ let form: FormData;
+ try {
+ form = await request.formData();
+ } catch {
+ return Response.json({ error: "Expected multipart form data." }, { status: 400 });
+ }
+
+ const file = form.get("file");
+ if (!(file instanceof File)) {
+ return Response.json({ error: "No file provided." }, { status: 400 });
+ }
+
+ const kind: UploadKind = form.get("kind") === "pdf" ? "pdf" : "image";
+ if (kind === "pdf" && !isAdmin(user)) {
+ return Response.json({ error: "Admins only." }, { status: 403 });
+ }
+
+ try {
+ const url = await saveUpload(file, kind);
+ return Response.json({ url });
+ } catch (err) {
+ const message = err instanceof Error ? err.message : "Upload failed.";
+ return Response.json({ error: message }, { status: 400 });
+ }
+}
apps/web/src/app/city/[slug]/event/[id]/page.tsx +134 -1
@@ -1,8 +1,10 @@
import Link from "next/link";
import { SiteFooter } from "@/components/site-footer";
+import { PhotoGallery } from "@/components/photo-gallery";
import { notFound, redirect } from "next/navigation";
import { revalidatePath } from "next/cache";
import { serverCaller } from "@/lib/trpc/server";
+import { saveUpload } from "@/lib/uploads";
import { cityTimeZone, fmtEventTime } from "@/lib/format";
export const dynamic = "force-dynamic";
@@ -62,6 +64,12 @@ export default async function EventPage({
subjectId: id,
});
+ const photos = await caller.eventPhoto.list({ eventId: id });
+ const eventVendors = await caller.vendor.listForEvent({
+ cityId: city.id,
+ eventId: id,
+ });
+
async function reserve(formData: FormData) {
"use server";
const ticketTypeId = String(formData.get("ticketTypeId") ?? "");
@@ -104,6 +112,25 @@ export default async function EventPage({
revalidatePath(`/city/${slug}/event/${id}`);
}
+ async function submitPhoto(formData: FormData) {
+ "use server";
+ const file = formData.get("photo");
+ const caption = String(formData.get("caption") ?? "").trim();
+ if (!(file instanceof File) || file.size === 0) return;
+ let url: string;
+ try {
+ url = await saveUpload(file, "image");
+ } catch {
+ return;
+ }
+ await serverCaller().eventPhoto.submit({
+ eventId: id,
+ url,
+ caption: caption || undefined,
+ });
+ revalidatePath(`/city/${slug}/event/${id}`);
+ }
+
async function reportEvent(formData: FormData) {
"use server";
const reason = String(formData.get("reason") ?? "").trim();
@@ -133,7 +160,17 @@ export default async function EventPage({
</p>
) : null}
- {event.externalUrl || event.ticketUrl ? (
+ {event.flyerUrl ? (
+ <figure className="mt-10">
+ <img
+ src={event.flyerUrl}
+ alt={`${event.title} flyer`}
+ className="w-full border border-[var(--line)]"
+ />
+ </figure>
+ ) : null}
+
+ {event.externalUrl || event.ticketUrl || event.programUrl ? (
<div className="mt-7 flex flex-wrap gap-4">
{event.ticketUrl ? (
<a
@@ -155,6 +192,16 @@ export default async function EventPage({
Details →
</a>
) : null}
+ {event.programUrl ? (
+ <a
+ href={event.programUrl}
+ target="_blank"
+ rel="noopener noreferrer"
+ className="kicker border border-[var(--line)] px-6 py-3 text-gold hover:border-gold hover:text-gold-bright"
+ >
+ Download the program (PDF) →
+ </a>
+ ) : null}
</div>
) : null}
@@ -256,6 +303,92 @@ export default async function EventPage({
</p>
</section>
+ {/* Photo album [F2] - approved shots only; submissions go to review */}
+ <section className="mt-16 border-t border-[var(--line)] pt-10">
+ <div className="flex items-end justify-between">
+ <h2 className="display text-3xl">Photos</h2>
+ <span className="kicker text-ash">
+ {String(photos.length).padStart(2, "0")} in the album
+ </span>
+ </div>
+
+ {photos.length === 0 ? (
+ <p className="serif mt-6 text-xl text-ash">
+ No photos in the album yet. Were you there? Send yours in below.
+ </p>
+ ) : (
+ <div className="mt-6">
+ <PhotoGallery photos={photos} />
+ </div>
+ )}
+
+ <form action={submitPhoto} className="mt-8 flex flex-wrap items-end gap-3">
+ <label className="flex flex-1 flex-col gap-2">
+ <span className="kicker text-ash">Send in your photos</span>
+ <input
+ type="file"
+ name="photo"
+ required
+ accept="image/jpeg,image/png,image/webp"
+ className="kicker border border-[var(--line)] bg-stage px-3 py-2 text-bone outline-none file:mr-3 file:border-0 file:bg-gold file:px-3 file:py-1 file:text-stage"
+ />
+ </label>
+ <input
+ name="caption"
+ maxLength={200}
+ placeholder="Caption (opt)"
+ className="serif flex-1 border-b border-[var(--line)] bg-transparent py-2 text-lg text-bone outline-none placeholder:text-ash focus:border-gold"
+ />
+ <button
+ type="submit"
+ className="kicker bg-gold px-5 py-2 text-stage hover:bg-gold-bright"
+ >
+ Submit
+ </button>
+ </form>
+ <p className="kicker mt-3 text-ash">
+ JPEG, PNG, or WebP up to 8MB. Every photo is reviewed before it appears.
+ </p>
+ </section>
+
+ {/* Vendors [F5] */}
+ {eventVendors.length > 0 ? (
+ <section className="mt-16 border-t border-[var(--line)] pt-10">
+ <div className="flex items-end justify-between">
+ <h2 className="display text-3xl">Vendors</h2>
+ <span className="kicker text-ash">On site at this event</span>
+ </div>
+ <ul className="mt-6 flex flex-col gap-3">
+ {eventVendors.map((v) => (
+ <li
+ key={v.id}
+ className="flex flex-wrap items-center gap-4 border border-[var(--line)] px-5 py-4"
+ >
+ <div className="flex-1">
+ <p className="display text-2xl">{v.name}</p>
+ <p className="kicker mt-1 text-ash">
+ {v.category ? v.category.toUpperCase() : "VENDOR"}
+ </p>
+ {v.description ? (
+ <p className="serif mt-2 text-lg text-bone">{v.description}</p>
+ ) : null}
+ </div>
+ {v.linkUrl ? (
+ <a
+ href={v.linkUrl}
+ target="_blank"
+ rel="noopener noreferrer"
+ className="kicker text-gold hover:text-gold-bright"
+ >
+ Visit →
+ </a>
+ ) : null}
+ </li>
+ ))}
+ </ul>
+ </section>
+ ) : null}
+
{/* Comments */}
<section className="mt-16 border-t border-[var(--line)] pt-10">
<div className="flex items-end justify-between">
apps/web/src/app/city/[slug]/floor/page.tsx +99 -8
@@ -17,10 +17,11 @@ export default async function FloorPage({
if (!city) notFound();
const cityId = city.id;
- const [steppingGroups, djs, promoters, classes] = await Promise.all([
+ const [steppingGroups, djs, promoters, instructors, classes] = await Promise.all([
caller.steppingGroup.list({ cityId }),
caller.dj.list({ cityId }),
caller.promoter.list({ cityId }),
+ caller.instructor.list({ cityId }),
caller.class.list({ cityId }),
]);
@@ -63,6 +64,19 @@ export default async function FloorPage({
revalidatePath(`/city/${slug}/floor`);
}
+ async function addInstructor(formData: FormData) {
+ "use server";
+ const name = String(formData.get("name") ?? "").trim();
+ const bio = String(formData.get("bio") ?? "").trim();
+ if (!name) return;
+ await serverCaller().instructor.create({
+ cityId: city!.id,
+ name,
+ bio: bio || undefined,
+ });
+ revalidatePath(`/city/${slug}/floor`);
+ }
+
async function addClass(formData: FormData) {
"use server";
const title = String(formData.get("title") ?? "").trim();
@@ -91,8 +105,8 @@ export default async function FloorPage({
</p>
<h1 className="display mt-3 text-7xl sm:text-8xl">The Floor</h1>
<p className="serif mt-5 max-w-xl text-xl text-ash">
- The community on the floor - stepping groups, DJs, promoters, and
- classes keeping {city.name} in the pocket.
+ The people who keep {city.name} stepping. Groups, DJs, promoters,
+ instructors, and classes.
</p>
{/* Stepping Groups */}
@@ -109,7 +123,8 @@ export default async function FloorPage({
{steppingGroups.length === 0 ? (
<p className="serif mt-8 text-xl text-ash">
- No stepping groups yet. Put the first crew on the floor below.
+ Stepping groups will be listed here as they come on board. Know
+ one? Add it below.
</p>
) : (
<ul className="mt-6">
@@ -174,7 +189,8 @@ export default async function FloorPage({
{djs.length === 0 ? (
<p className="serif mt-8 text-xl text-ash">
- No DJs yet. Spin up the first one below.
+ DJs will be listed here as they come on board. Add the first one
+ below.
</p>
) : (
<ul className="mt-6">
@@ -242,7 +258,8 @@ export default async function FloorPage({
{promoters.length === 0 ? (
<p className="serif mt-8 text-xl text-ash">
- No promoters yet. Name the first one hosting the set below.
+ Promoters will be listed here as they come on board. Add the one
+ behind your next set below.
</p>
) : (
<ul className="mt-6">
@@ -296,11 +313,84 @@ export default async function FloorPage({
</form>
</section>
- {/* Classes */}
+ {/* Instructors [F7] */}
<section className="mt-16 border-t border-[var(--line)] pt-10">
<div className="flex items-end justify-between">
<div>
<p className="kicker text-gold">Section 04</p>
+ <h2 className="display mt-2 text-4xl">Instructors</h2>
+ </div>
+ <span className="kicker text-ash">
+ {String(instructors.length).padStart(2, "0")} instructors
+ </span>
+ </div>
+ <p className="serif mt-4 max-w-xl text-lg text-ash">
+ Learn from the people who teach it. Instructors offering lessons in{" "}
+ {city.name}.
+ </p>
+
+ {instructors.length === 0 ? (
+ <p className="serif mt-8 text-xl text-ash">
+ Instructors will be listed here as they come on board. Teach in{" "}
+ {city.name}? Add your name below.
+ </p>
+ ) : (
+ <ul className="mt-6">
+ {instructors.map((t, i) => (
+ <li
+ key={t.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">
+ <Link
+ href={`/city/${slug}/profile/instructor/${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}
+ </div>
+ <span className="kicker border border-[var(--line)] px-3 py-1">
+ LESSONS
+ </span>
+ </li>
+ ))}
+ </ul>
+ )}
+
+ <form action={addInstructor} className="mt-8 flex flex-col gap-3">
+ <input
+ name="name"
+ required
+ placeholder="Instructor name"
+ 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="bio"
+ placeholder="What you teach and how to book a lesson (optional)"
+ 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">
+ <button
+ type="submit"
+ className="kicker bg-gold px-6 py-3 text-stage hover:bg-gold-bright"
+ >
+ Add instructor
+ </button>
+ </div>
+ </form>
+ </section>
+
+ {/* Classes */}
+ <section className="mt-16 border-t border-[var(--line)] pt-10">
+ <div className="flex items-end justify-between">
+ <div>
+ <p className="kicker text-gold">Section 05</p>
<h2 className="display mt-2 text-4xl">Classes</h2>
</div>
<span className="kicker text-ash">
@@ -310,7 +400,8 @@ export default async function FloorPage({
{classes.length === 0 ? (
<p className="serif mt-8 text-xl text-ash">
- No classes yet. Teach the first count below.
+ Classes will be listed here as they come on board. Add the first
+ one below.
</p>
) : (
<ul className="mt-6">
apps/web/src/app/city/[slug]/page.tsx +5 -5
@@ -19,9 +19,9 @@ const TYPE_LABEL: Record<string, string> = {
};
const MODULES = [
- { seg: "floor", label: "The Floor", note: "Stepping groups, DJs, promoters, classes" },
+ { seg: "floor", label: "The Floor", note: "Groups, DJs, promoters, instructors, classes" },
{ seg: "videos", label: "The Tape", note: "Sets and footwork on camera" },
- { seg: "road", label: "The Road", note: "Event centers, hotels, food, the city guide" },
+ { seg: "road", label: "The Road", note: "Host hotels, event centers, food, the city guide" },
{ seg: "live", label: "Live", note: "Watch a set live, or go live" },
];
@@ -67,7 +67,7 @@ export default async function CityPage({
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
+ ← Chicago Stepping
</Link>
<p className="kicker mt-10 text-ash">
@@ -75,8 +75,8 @@ export default async function CityPage({
</p>
<h1 className="display mt-3 text-7xl sm:text-8xl">{city.name}</h1>
<p className="serif mt-5 max-w-xl text-xl text-ash">
- Everything Chicago steppin&apos; in {city.name} - the sets, the people,
- the music, and the road in. All in one place.
+ The sets, the people, the music, and the road in. Everything Chicago
+ steppin&apos; in {city.name}, in one place.
</p>
{/* Live now */}
apps/web/src/app/city/[slug]/profile/[kind]/[id]/page.tsx +8 -0
@@ -8,6 +8,7 @@ const KIND_LABEL: Record<string, string> = {
dj: "DJ",
promoter: "Promoter",
"stepping-group": "Stepping Group",
+ instructor: "Instructor",
class: "Class",
};
@@ -59,6 +60,13 @@ export default async function ProfilePage({
body = promoter.bio ?? null;
emptyBody = "No bio yet. This promoter lets the sets speak.";
createdAt = promoter.createdAt;
+ } else if (kind === "instructor") {
+ const instructor = await caller.instructor.byId({ id });
+ if (!instructor) notFound();
+ title = instructor.name;
+ body = instructor.bio ?? null;
+ emptyBody = "No bio yet. Ask about lessons at the next set.";
+ createdAt = instructor.createdAt;
} else if (kind === "stepping-group") {
const group = await caller.steppingGroup.byId({ id });
if (!group) notFound();
apps/web/src/app/city/[slug]/road/page.tsx +31 -23
@@ -12,37 +12,40 @@ type Category = (typeof TRAVEL_CATEGORY)[number];
const SECTIONS: {
category: Category;
heading: string;
+ note?: string;
count: string;
badge: string;
empty: string;
field: string;
cta: string;
}[] = [
+ {
+ category: "hotel",
+ heading: "Hotels",
+ note: "The first stop for out-of-towners. Host hotels and places to stay near the sets.",
+ count: "stays",
+ badge: "STAY",
+ empty:
+ "Hotels will be listed here as events name their host hotels. Add a place to stay below.",
+ field: "Hotel name",
+ cta: "Add hotel",
+ },
{
category: "event_center",
heading: "Event Centers",
count: "venues",
badge: "VENUE",
- empty: "No event centers listed yet. Add the first venue that hosts sets below.",
- field: "Event center (e.g. The Promontory)",
+ empty: "Venues will be listed here as they come on board. Add one that hosts sets below.",
+ field: "Event center name",
cta: "Add event center",
},
- {
- category: "hotel",
- heading: "Hotels",
- count: "rooms",
- badge: "STAY",
- empty: "No hotels yet. Drop the host hotel or a place to stay below.",
- field: "Hotel name (e.g. Hyatt Regency)",
- 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. Pearl's Place)",
+ empty: "Restaurants will be listed here soon. Add a plate worth the trip below.",
+ field: "Restaurant name",
cta: "Add restaurant",
},
{
@@ -50,8 +53,8 @@ const SECTIONS: {
heading: "Shopping",
count: "stops",
badge: "SHOP",
- empty: "No stops yet. Add the closest mall or shop to the set below.",
- field: "Mall or shop (e.g. Block 37)",
+ empty: "Shopping stops will be listed here soon. Add the closest one to the set below.",
+ field: "Mall or shopping district",
cta: "Add shopping",
},
{
@@ -59,8 +62,8 @@ const SECTIONS: {
heading: "Attractions",
count: "sights",
badge: "SEE",
- empty: "No sights yet. Mark a main attraction worth seeing in town below.",
- field: "Attraction (e.g. DuSable Black History Museum)",
+ empty: "Attractions will be listed here soon. Add one worth seeing in town below.",
+ field: "Attraction name",
cta: "Add attraction",
},
{
@@ -68,17 +71,18 @@ const SECTIONS: {
heading: "Entertainment",
count: "spots",
badge: "PLAY",
- empty: "No entertainment yet. Add a lounge, club, or show near the set below.",
- field: "Entertainment (e.g. jazz lounge, comedy show)",
+ empty: "Spots will be listed here soon. Add a lounge, club, or show near the set below.",
+ field: "Entertainment spot",
cta: "Add entertainment",
},
{
category: "hospital",
heading: "Hospitals",
+ note: "Just in case. The nearest care to the sets.",
count: "nearest",
badge: "CARE",
empty: "No hospital listed yet. Add the nearest one below, just in case.",
- field: "Hospital (e.g. University of Chicago Medical Center)",
+ field: "Hospital name",
cta: "Add hospital",
},
];
@@ -122,9 +126,9 @@ export default async function RoutePage({
<p className="kicker mt-10 text-ash">The Road · {city.metroLabel}</p>
<h1 className="display mt-3 text-7xl sm:text-8xl">The Road</h1>
<p className="serif mt-5 max-w-xl text-xl text-ash">
- In town for a set or a weekender. The event centers, hotels, food,
- shopping, attractions, entertainment, and the nearest hospital just in
- case. Everything you need to do {city.name} right.
+ In town for a set or a weekender. Hotels first, then the event centers,
+ food, shopping, attractions, entertainment, and the nearest hospital
+ just in case. Everything you need to do {city.name} right.
</p>
{SECTIONS.map((section, idx) => {
@@ -144,6 +148,10 @@ export default async function RoutePage({
</span>
</div>
+ {section.note ? (
+ <p className="serif mt-4 max-w-xl text-lg text-ash">{section.note}</p>
+ ) : null}
+
{places.length === 0 ? (
<p className="serif mt-8 text-xl text-ash">{section.empty}</p>
) : (
apps/web/src/app/city/[slug]/videos/page.tsx +3 -2
@@ -81,7 +81,7 @@ export default async function VideosPage({
{/* 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>
+ <h2 className="display text-3xl">Add a clip</h2>
<span className="kicker">Add to the tape</span>
</div>
<form action={addVideo} className="mt-6 flex flex-col gap-3">
@@ -131,7 +131,8 @@ export default async function VideosPage({
{videos.length === 0 ? (
<p className="serif mt-8 text-xl text-ash">
- No clips yet. Drop the first one above.
+ Clips will appear here as the community adds them. Add the first
+ one above.
</p>
) : (
<ul className="mt-8 flex flex-col gap-12">
apps/web/src/app/globals.css +96 -15
@@ -8,7 +8,7 @@
--bone: #f4f1ea;
--gold: #3d5afe;
--gold-bright: #6e86ff;
- --ash: #8c93a3;
+ --ash: #9aa1b0;
--line: rgba(244, 241, 234, 0.12);
}
@@ -44,10 +44,12 @@ body {
color: var(--stage);
}
+/* Kickers stay at or above 0.8rem: the audience skews older and Dad asked for
+ type people can actually read. Do not shrink these. */
.kicker {
font-family: var(--font-space-mono), monospace;
- font-size: 0.72rem;
- letter-spacing: 0.28em;
+ font-size: 0.8rem;
+ letter-spacing: 0.22em;
text-transform: uppercase;
color: var(--ash);
}
@@ -63,17 +65,6 @@ body {
font-family: var(--font-instrument), Georgia, serif;
}
-.hero-fallback {
- background:
- radial-gradient(
- 120% 90% at 70% 45%,
- rgba(61, 90, 254, 0.18) 0%,
- rgba(61, 90, 254, 0.05) 30%,
- transparent 60%
- ),
- var(--stage);
-}
-
@keyframes marquee {
from {
transform: translateX(0);
@@ -108,6 +99,81 @@ body {
.b4 { animation-delay: 0.34s; }
.b5 { animation-delay: 0.46s; }
+/* Stepping-couple emblem: entrance + a slow glide. Transform/opacity only. */
+.emblem-float {
+ animation: emblemFloat 9s ease-in-out infinite alternate;
+}
+
+.emblem-sway {
+ transform-origin: 50% 86%;
+ animation: emblemSway 11s ease-in-out infinite alternate;
+}
+
+.emblem-in {
+ opacity: 0;
+ animation: emblemIn 0.9s cubic-bezier(0.2, 0.85, 0.2, 1) forwards;
+}
+
+.emblem-draw {
+ stroke-dasharray: 1;
+ stroke-dashoffset: 1;
+ animation: emblemDraw 1.1s cubic-bezier(0.4, 0, 0.2, 1) forwards;
+}
+
+.emblem-pop {
+ opacity: 0;
+ transform-box: fill-box;
+ transform-origin: center;
+ animation:
+ emblemPop 0.5s cubic-bezier(0.2, 0.85, 0.3, 1.3) forwards,
+ emblemPulse 4s ease-in-out 1.6s infinite;
+}
+
+.emblem-glint {
+ animation: emblemGlint 5s ease-in-out infinite alternate;
+}
+
+.e1 { animation-delay: 0.15s; }
+.e2 { animation-delay: 0.3s; }
+.e3 { animation-delay: 0.55s; }
+.e4 { animation-delay: 0.7s; }
+.e5 { animation-delay: 0.85s; }
+.e6 { animation-delay: 1.05s, 1.6s; }
+
+@keyframes emblemFloat {
+ from { transform: translateY(0); }
+ to { transform: translateY(-8px); }
+}
+
+@keyframes emblemSway {
+ from { transform: rotate(-0.7deg); }
+ to { transform: rotate(0.7deg); }
+}
+
+@keyframes emblemIn {
+ from { opacity: 0; transform: translateY(14px); }
+ to { opacity: 1; transform: translateY(0); }
+}
+
+@keyframes emblemDraw {
+ to { stroke-dashoffset: 0; }
+}
+
+@keyframes emblemPop {
+ from { opacity: 0; transform: scale(0); }
+ to { opacity: 1; transform: scale(1); }
+}
+
+@keyframes emblemPulse {
+ 0%, 100% { opacity: 1; }
+ 50% { opacity: 0.55; }
+}
+
+@keyframes emblemGlint {
+ from { opacity: 0.55; }
+ to { opacity: 1; }
+}
+
.skeleton {
background: linear-gradient(
90deg,
@@ -132,7 +198,22 @@ body {
@media (prefers-reduced-motion: reduce) {
.marquee-track,
.beat-in,
- .skeleton {
+ .skeleton,
+ .emblem-float,
+ .emblem-sway,
+ .emblem-in,
+ .emblem-draw,
+ .emblem-pop,
+ .emblem-glint {
animation: none;
}
+
+ .emblem-in,
+ .emblem-pop {
+ opacity: 1;
+ }
+
+ .emblem-draw {
+ stroke-dashoffset: 0;
+ }
}
apps/web/src/app/layout.tsx +6 -6
@@ -27,13 +27,13 @@ const spaceMono = Space_Mono({
const SITE_URL = process.env.NEXT_PUBLIC_SITE_URL || "https://stepping.zionboggan.com";
const DESCRIPTION =
- "The home for Chicago stepping. Find the sets, the people, the music, and the road to every city.";
+ "Chicago Stepping. Your City. The sets, the people, and the road between cities, in one place.";
export const metadata: Metadata = {
metadataBase: new URL(SITE_URL),
- title: "Stepping - In the Pocket",
+ title: "Chicago Stepping. Your City.",
description: DESCRIPTION,
- applicationName: "Stepping",
+ applicationName: "Chicago Stepping",
manifest: "/manifest.webmanifest",
icons: {
icon: "/icon.svg",
@@ -41,16 +41,16 @@ export const metadata: Metadata = {
apple: "/icon.svg",
},
openGraph: {
- title: "Stepping - Chicago Stepping, city by city",
+ title: "Chicago Stepping. Your City.",
description: DESCRIPTION,
- siteName: "Stepping",
+ siteName: "Chicago Stepping",
url: SITE_URL,
images: [{ url: `${SITE_URL}/og-image.png`, width: 1200, height: 630 }],
type: "website",
},
twitter: {
card: "summary_large_image",
- title: "Stepping - Chicago Stepping, city by city",
+ title: "Chicago Stepping. Your City.",
description: DESCRIPTION,
images: [`${SITE_URL}/og-image.png`],
},
apps/web/src/app/page.tsx +42 -19
@@ -1,18 +1,28 @@
import Link from "next/link";
import { serverCaller } from "@/lib/trpc/server";
import { Marquee } from "@/components/marquee";
-import { ChromeDiscoCanvas } from "@/components/chrome-disco-canvas";
+import { SteppingCouple } from "@/components/stepping-couple";
import { MobileNav } from "@/components/mobile-nav";
+import { Wordmark } from "@/components/wordmark";
export const dynamic = "force-dynamic";
const COUNT = ["6", "·", "COUNT", "·", "8"];
+const COMING = [
+ "Little Rock",
+ "St. Louis",
+ "Huntsville",
+ "Montgomery",
+ "New Orleans",
+ "Las Vegas",
+];
+
const YARD = [
- { k: "Stepping groups", d: "The crews and clubs holding it down in each city." },
+ { k: "Stepping groups", d: "The groups that keep each city stepping." },
{ k: "DJs", d: "Who is spinning the set, and where." },
{ k: "Promoters", d: "The people who host the sets and weekenders." },
- { k: "Classes", d: "Learn the count. Smooth, in the pocket, on time." },
+ { k: "Classes", d: "Lessons and instructors, beginner on up." },
];
function fmt(d: Date | null) {
@@ -46,8 +56,8 @@ export default async function Home() {
return (
<div className="relative">
<header className="fixed top-0 z-50 flex w-full items-center justify-between px-5 py-4 sm:px-8">
- <Link href="/" className="display text-lg tracking-tight text-bone">
- STEPPING
+ <Link href="/" className="text-lg">
+ <Wordmark />
</Link>
<nav className="hidden gap-7 sm:flex">
<a href="#sets" className="kicker hover:text-bone">
@@ -63,18 +73,18 @@ export default async function Home() {
<MobileNav />
</header>
- {/* HERO - type-as-art + the hanging disco ball */}
+ {/* HERO - type-as-art + the stepping couple */}
<section className="relative flex min-h-[100svh] flex-col justify-center px-5 sm:px-10">
- <div className="pointer-events-none relative order-1 mx-auto mt-24 h-[34vh] w-[84%] max-w-[360px] sm:absolute sm:inset-y-0 sm:right-[-3%] sm:order-none sm:mx-0 sm:mt-0 sm:h-auto sm:w-[46%] sm:max-w-none">
+ <div className="pointer-events-none relative order-1 mx-auto mt-24 h-[34vh] w-[84%] max-w-[300px] sm:absolute sm:inset-y-0 sm:right-[2%] sm:order-none sm:mx-0 sm:mt-0 sm:h-auto sm:w-[38%] sm:max-w-none">
<div
className="absolute left-1/2 top-1/2 h-[78%] w-[78%] -translate-x-1/2 -translate-y-1/2 rounded-full blur-3xl"
- style={{ background: "radial-gradient(circle, rgba(61,90,254,0.16) 0%, transparent 62%)" }}
+ style={{ background: "radial-gradient(circle, rgba(61,90,254,0.14) 0%, transparent 62%)" }}
/>
- <ChromeDiscoCanvas />
+ <SteppingCouple className="absolute inset-0 flex items-center justify-center py-[4%]" />
</div>
<p className="kicker beat-in b1 order-2 mt-6 sm:absolute sm:left-10 sm:top-24 sm:mt-0">
- In the Pocket <span className="text-gold">/</span> Chicago Stepping
+ The partner dance <span className="text-gold">/</span> Born in Chicago
</p>
<div className="relative z-10 order-3 mt-3 select-none sm:mt-0 sm:-ml-[2vw]">
@@ -85,10 +95,14 @@ export default async function Home() {
</h1>
</div>
- <p className="serif beat-in b4 relative z-10 order-4 mt-6 max-w-md text-lg leading-snug text-bone/80 sm:mt-8 sm:text-2xl">
- The home for Chicago stepping. Smooth, on the count, in the pocket.
- Find the sets, the people, and the road to every city.
- </p>
+ <div className="beat-in b4 relative z-10 order-4 mt-6 max-w-md sm:mt-8">
+ <p className="serif text-2xl leading-snug text-bone sm:text-3xl">
+ Chicago Stepping. Your City.
+ </p>
+ <p className="serif mt-3 text-lg leading-snug text-bone/75 sm:text-xl">
+ The sets, the people, and the road between cities, in one place.
+ </p>
+ </div>
<div className="beat-in b5 order-5 mt-8 flex flex-wrap items-center gap-x-6 gap-y-2">
{cities.map((c) => (
@@ -96,8 +110,17 @@ export default async function Home() {
{c.name}
</Link>
))}
- <span className="kicker text-ash">More cities loading</span>
</div>
+ <p className="beat-in b5 order-6 mt-4 max-w-2xl text-sm leading-relaxed text-ash">
+ <span className="kicker">More cities coming</span>
+ <span className="mx-2 text-gold">/</span>
+ {COMING.map((name, i) => (
+ <span key={name}>
+ <span className="whitespace-nowrap">{name}</span>
+ {i < COMING.length - 1 && " · "}
+ </span>
+ ))}
+ </p>
</section>
<Marquee />
@@ -199,14 +222,14 @@ export default async function Home() {
</section>
<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>
+ <Link href="/" className="text-xl">
+ <Wordmark />
+ </Link>
<div className="flex items-center gap-6">
<Link href="/about" className="kicker text-bone hover:text-gold">
About
</Link>
- <span className="kicker text-ash">
- In the Pocket · Built for the culture
- </span>
+ <span className="kicker text-ash">Chicago Stepping. Your City.</span>
</div>
</footer>
</div>
apps/web/src/app/tickets/page.tsx +2 -2
@@ -3,7 +3,7 @@ import { SiteFooter } from "@/components/site-footer";
import { serverCaller } from "@/lib/trpc/server";
export const dynamic = "force-dynamic";
-export const metadata = { title: "My Tickets - Stepping" };
+export const metadata = { title: "My Tickets · Stepping" };
function fmt(d: Date | null) {
if (!d) return "DATE TBD";
@@ -24,7 +24,7 @@ export default async function TicketsPage() {
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
+ ← Chicago Stepping
</Link>
<p className="kicker mt-10 text-ash">Your passes</p>
apps/web/src/components/chrome-disco-canvas.tsx +0 -70
@@ -1,70 +0,0 @@
-"use client";
-
-import dynamic from "next/dynamic";
-import { useEffect, useRef, useState } from "react";
-
-const ChromeDisco = dynamic(() => import("./chrome-disco"), { ssr: false });
-
-// Renders the spinning disco ball only when it is on screen, the tab is visible,
-// and the device is capable. Reduced-motion / low-core devices get a static
-// chrome-ball image fallback so the hero never blanks.
-export function ChromeDiscoCanvas() {
- const wrap = useRef<HTMLDivElement>(null);
- const [enabled, setEnabled] = useState(false);
- const [active, setActive] = useState(true);
-
- useEffect(() => {
- // Only fall back when WebGL is genuinely missing. Don't gate on core count:
- // phones report few cores but render this fine (and that wrongly downgraded
- // real iPhones to the static ball).
- let webgl = false;
- try {
- const c = document.createElement("canvas");
- webgl = !!(c.getContext("webgl2") || c.getContext("webgl"));
- } catch {
- webgl = false;
- }
- if (!webgl) return;
- setEnabled(true);
-
- const el = wrap.current;
- if (!el) return;
- let inView = true;
- let hidden = false;
- const update = () => setActive(inView && !hidden);
-
- const io = new IntersectionObserver(
- ([e]) => {
- inView = e.isIntersecting;
- update();
- },
- { threshold: 0.01 },
- );
- io.observe(el);
- const onVis = () => {
- hidden = document.hidden;
- update();
- };
- document.addEventListener("visibilitychange", onVis);
- return () => {
- io.disconnect();
- document.removeEventListener("visibilitychange", onVis);
- };
- }, []);
-
- return (
- <div ref={wrap} className="absolute inset-0">
- {enabled ? (
- <ChromeDisco active={active} />
- ) : (
- // Only for the rare device with no WebGL: a real rendered disco ball,
- // never the plain gradient. The signature object always shows.
- <img
- src="/disco-ball.png"
- alt="Chicago stepping disco ball"
- className="absolute inset-0 h-full w-full object-contain"
- />
- )}
- </div>
- );
-}
apps/web/src/components/chrome-disco.tsx +0 -226
@@ -1,226 +0,0 @@
-"use client";
-
-import { Canvas, useFrame } from "@react-three/fiber";
-import { useEffect, useMemo, useRef } from "react";
-import * as THREE from "three";
-
-// Procedural chrome matcap - sky/horizon/ground baked into a 2D texture with a
-// tight bright specular, tinted cobalt at the rim. MeshMatcapMaterial reads it
-// directly, so the chrome look needs no HDR/env asset (self-contained, MIT).
-function useChromeMatcap() {
- return useMemo(() => {
- const s = 256;
- const c = document.createElement("canvas");
- c.width = c.height = s;
- const ctx = c.getContext("2d")!;
-
- const g = ctx.createLinearGradient(0, 0, 0, s);
- g.addColorStop(0.0, "#ffffff");
- g.addColorStop(0.22, "#eef2f8");
- g.addColorStop(0.46, "#9aa6ba");
- g.addColorStop(0.5, "#6f7a8e");
- g.addColorStop(0.515, "#ffffff"); // horizon glint
- g.addColorStop(0.55, "#48515f");
- g.addColorStop(0.74, "#161922");
- g.addColorStop(1.0, "#020203");
- ctx.fillStyle = g;
- ctx.fillRect(0, 0, s, s);
-
- // tight, hot specular (upper-left) for real chrome shine
- const hot = ctx.createRadialGradient(s * 0.34, s * 0.24, 0, s * 0.34, s * 0.24, s * 0.26);
- hot.addColorStop(0, "rgba(255,255,255,1)");
- hot.addColorStop(0.35, "rgba(255,255,255,0.7)");
- hot.addColorStop(1, "rgba(255,255,255,0)");
- ctx.fillStyle = hot;
- ctx.fillRect(0, 0, s, s);
-
- // second smaller glint (lower-right) so tiles flash twice as they turn
- const hot2 = ctx.createRadialGradient(s * 0.7, s * 0.4, 0, s * 0.7, s * 0.4, s * 0.16);
- hot2.addColorStop(0, "rgba(255,255,255,0.95)");
- hot2.addColorStop(1, "rgba(255,255,255,0)");
- ctx.fillStyle = hot2;
- ctx.fillRect(0, 0, s, s);
-
- // cobalt bounce off the ground
- const cob = ctx.createRadialGradient(s * 0.58, s * 0.76, 0, s * 0.58, s * 0.76, s * 0.55);
- cob.addColorStop(0, "rgba(61,90,254,0.85)");
- cob.addColorStop(0.4, "rgba(61,90,254,0.3)");
- cob.addColorStop(1, "rgba(61,90,254,0)");
- ctx.fillStyle = cob;
- ctx.fillRect(0, 0, s, s);
-
- const tex = new THREE.CanvasTexture(c);
- tex.colorSpace = THREE.SRGBColorSpace;
- return tex;
- }, []);
-}
-
-// soft round additive sprite for the twinkle sparkles
-function useSparkTexture() {
- return useMemo(() => {
- const s = 64;
- const c = document.createElement("canvas");
- c.width = c.height = s;
- const ctx = c.getContext("2d")!;
- const g = ctx.createRadialGradient(s / 2, s / 2, 0, s / 2, s / 2, s / 2);
- g.addColorStop(0, "rgba(255,255,255,1)");
- g.addColorStop(0.25, "rgba(255,255,255,0.9)");
- g.addColorStop(0.5, "rgba(220,230,255,0.35)");
- g.addColorStop(1, "rgba(255,255,255,0)");
- ctx.fillStyle = g;
- ctx.fillRect(0, 0, s, s);
- const tex = new THREE.CanvasTexture(c);
- tex.colorSpace = THREE.SRGBColorSpace;
- return tex;
- }, []);
-}
-
-const R = 1.5;
-const TILE = 0.12;
-
-function DiscoBall({ pointer }: { pointer: React.RefObject<{ x: number; y: number }> }) {
- const rig = useRef<THREE.Group>(null); // hangs + sways; cord stays vertical
- const spinner = useRef<THREE.Group>(null); // the ball spins underneath
- const inst = useRef<THREE.InstancedMesh>(null);
- const sparks = useRef<THREE.Group>(null);
- const matcap = useChromeMatcap();
- const sparkTex = useSparkTexture();
-
- const tiles = useMemo(() => {
- const pts: THREE.Vector3[] = [];
- const rows = 28;
- for (let i = 1; i < rows; i++) {
- const theta = (Math.PI * i) / rows;
- const y = R * Math.cos(theta);
- const ring = R * Math.sin(theta);
- const cols = Math.max(3, Math.round((2 * Math.PI * ring) / (TILE * 1.22)));
- for (let j = 0; j < cols; j++) {
- const phi = (2 * Math.PI * j) / cols + (i % 2) * (Math.PI / cols);
- pts.push(new THREE.Vector3(ring * Math.cos(phi), y, ring * Math.sin(phi)));
- }
- }
- return pts;
- }, []);
-
- // twinkle sparkles: sit just above random tiles; the opaque core occludes the
- // ones on the back, so additive blending only shows on the visible face.
- const sparkData = useMemo(() => {
- const out: { pos: THREE.Vector3; phase: number; speed: number }[] = [];
- for (let i = 0; i < 18; i++) {
- const t = tiles[Math.floor((i / 18) * tiles.length)];
- out.push({
- pos: t.clone().multiplyScalar(1.02),
- phase: (i * 2.399) % (Math.PI * 2),
- speed: 1.6 + (i % 5) * 0.5,
- });
- }
- return out;
- }, [tiles]);
-
- useEffect(() => {
- const m = inst.current;
- if (!m) return;
- const dummy = new THREE.Object3D();
- tiles.forEach((p, i) => {
- dummy.position.copy(p);
- dummy.lookAt(p.clone().multiplyScalar(2));
- dummy.scale.setScalar(TILE);
- dummy.updateMatrix();
- m.setMatrixAt(i, dummy.matrix);
- });
- m.instanceMatrix.needsUpdate = true;
- }, [tiles]);
-
- useFrame((state, dt) => {
- const t = state.clock.elapsedTime;
- const rg = rig.current; // bob + sway; cord hangs with it but stays vertical
- if (rg) {
- const p = pointer.current;
- rg.position.y = Math.sin(t * 0.6) * 0.06;
- rg.position.x += ((p ? p.x * 0.4 : 0) - rg.position.x) * 0.04;
- rg.rotation.z = Math.sin(t * 0.5) * 0.02; // faint pendulum sway
- }
- const sp = spinner.current;
- if (sp) {
- sp.rotation.y += dt * 0.3;
- sp.rotation.x = Math.sin(t * 0.3) * 0.05;
- }
- // twinkle
- const sg = sparks.current;
- if (sg) {
- sg.children.forEach((child, i) => {
- const d = sparkData[i];
- const tw = Math.max(0, Math.sin(t * d.speed + d.phase));
- const s = child as THREE.Sprite;
- s.scale.setScalar(0.12 + tw * tw * 0.5);
- (s.material as THREE.SpriteMaterial).opacity = tw * tw;
- });
- }
- });
-
- return (
- <group ref={rig}>
- {/* cord dropping from the ceiling (long, so it always exits the top) */}
- <mesh position={[0, R + 15, 0]}>
- <cylinderGeometry args={[0.012, 0.012, 30, 8]} />
- <meshMatcapMaterial matcap={matcap} />
- </mesh>
- {/* eyelet ring where the cord meets the cap */}
- <mesh position={[0, R + 0.17, 0]} rotation={[Math.PI / 2, 0, 0]}>
- <torusGeometry args={[0.05, 0.017, 8, 20]} />
- <meshMatcapMaterial matcap={matcap} />
- </mesh>
- {/* chrome mount cap sitting on the top of the ball */}
- <mesh position={[0, R + 0.01, 0]}>
- <cylinderGeometry args={[0.07, 0.13, 0.18, 20]} />
- <meshMatcapMaterial matcap={matcap} />
- </mesh>
-
- {/* the ball spins under the fixed cord */}
- <group ref={spinner}>
- <mesh>
- <sphereGeometry args={[R * 0.965, 48, 48]} />
- <meshBasicMaterial color="#0a0b10" />
- </mesh>
- <instancedMesh ref={inst} args={[undefined, undefined, tiles.length]}>
- <planeGeometry args={[1, 1]} />
- <meshMatcapMaterial matcap={matcap} side={THREE.DoubleSide} />
- </instancedMesh>
- <group ref={sparks}>
- {sparkData.map((s, i) => (
- <sprite key={i} position={s.pos} scale={0.1}>
- <spriteMaterial
- map={sparkTex}
- blending={THREE.AdditiveBlending}
- transparent
- depthWrite={false}
- opacity={0}
- />
- </sprite>
- ))}
- </group>
- </group>
- </group>
- );
-}
-
-export default function ChromeDisco({ active = true }: { active?: boolean }) {
- const pointer = useRef({ x: 0, y: 0 });
- return (
- <Canvas
- dpr={[1, 1.8]}
- frameloop={active ? "always" : "never"}
- camera={{ position: [0, 0, 5.6], fov: 40 }}
- gl={{ antialias: true, alpha: true, powerPreference: "high-performance" }}
- style={{ width: "100%", height: "100%" }}
- onPointerMove={(e) => {
- const r = (e.target as HTMLElement).getBoundingClientRect?.();
- if (!r) return;
- pointer.current.x = ((e.clientX - r.left) / r.width) * 2 - 1;
- pointer.current.y = ((e.clientY - r.top) / r.height) * 2 - 1;
- }}
- >
- <DiscoBall pointer={pointer} />
- </Canvas>
- );
-}
apps/web/src/components/marquee.tsx +5 -5
@@ -3,15 +3,15 @@ const ROLL = [
"CHICAGO",
"6 · COUNT · 8",
"ATLANTA",
- "IN THE POCKET",
- "DETROIT",
"STEP · GLIDE · SPIN",
+ "DETROIT",
+ "YOUR CITY",
"HOUSTON",
- "OFF THE BOP",
- "CHARLOTTE",
"SMOOTH",
- "WASHINGTON DC",
+ "CHARLOTTE",
"ON TIME",
+ "WASHINGTON DC",
+ "CHICAGO STEPPING",
];
export function Marquee() {
apps/web/src/components/mobile-nav.tsx +5 -4
@@ -2,6 +2,7 @@
import { useEffect, useState } from "react";
import Link from "next/link";
+import { Wordmark } from "@/components/wordmark";
const LINKS = [
{ href: "#sets", label: "The Sets", index: "01" },
@@ -42,8 +43,8 @@ export function MobileNav() {
{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 className="text-lg">
+ <Wordmark />
</span>
<button
type="button"
@@ -76,12 +77,12 @@ export function MobileNav() {
>
<span className="kicker text-gold">04</span>
<span className="display text-5xl text-bone group-active:text-gold">
- What is this?
+ About
</span>
</Link>
</nav>
- <p className="kicker mt-8 text-ash">In the Pocket · Chicago Stepping</p>
+ <p className="kicker mt-8 text-ash">Chicago Stepping. Your City.</p>
</div>
)}
</div>
apps/web/src/components/photo-gallery.tsx +43 -0
@@ -0,0 +1,43 @@
+type GalleryPhoto = {
+ id: string;
+ url: string;
+ caption: string | null;
+};
+
+// Zero-JS gallery: a details-based lightbox. The open state renders a
+// full-screen backdrop inside the summary, so clicking anywhere closes it.
+export function PhotoGallery({ photos }: { photos: GalleryPhoto[] }) {
+ return (
+ <ul className="grid grid-cols-2 gap-3 sm:grid-cols-3">
+ {photos.map((p) => (
+ <li key={p.id}>
+ <details className="group">
+ <summary className="block cursor-pointer list-none [&::-webkit-details-marker]:hidden">
+ <img
+ src={p.url}
+ alt={p.caption ?? "Event photo"}
+ loading="lazy"
+ className="aspect-square w-full border border-[var(--line)] object-cover transition-opacity hover:opacity-80"
+ />
+ <span
+ aria-hidden
+ className="fixed inset-0 z-40 hidden bg-black/90 group-open:block"
+ />
+ </summary>
+ <div className="pointer-events-none fixed inset-0 z-50 flex flex-col items-center justify-center gap-4 p-6">
+ <img
+ src={p.url}
+ alt={p.caption ?? "Event photo"}
+ className="max-h-[80vh] max-w-full border border-[var(--line)] object-contain"
+ />
+ {p.caption ? (
+ <p className="serif max-w-xl text-center text-lg text-bone">{p.caption}</p>
+ ) : null}
+ <p className="kicker text-ash">Click anywhere to close</p>
+ </div>
+ </details>
+ </li>
+ ))}
+ </ul>
+ );
+}
apps/web/src/components/site-footer.tsx +4 -3
@@ -1,10 +1,11 @@
import Link from "next/link";
+import { Wordmark } from "@/components/wordmark";
export function SiteFooter() {
return (
<footer className="mt-24 flex flex-col gap-4 border-t border-[var(--line)] pt-8 sm:flex-row sm:items-center sm:justify-between">
- <Link href="/" className="display text-xl">
- STEPPING
+ <Link href="/" className="text-xl">
+ <Wordmark />
</Link>
<nav className="flex flex-wrap gap-x-6 gap-y-2">
<Link href="/" className="kicker text-bone hover:text-gold">
@@ -17,7 +18,7 @@ export function SiteFooter() {
About
</Link>
</nav>
- <span className="kicker text-ash">In the Pocket · Chicago Stepping</span>
+ <span className="kicker text-ash">Chicago Stepping. Your City.</span>
</footer>
);
}
apps/web/src/components/stepping-couple.tsx +96 -0
@@ -0,0 +1,96 @@
+// The site mark: an abstract stepping couple mid-turn. She glides under his
+// raised arm, gown fanning into the sweep; the cobalt dot is the held hands.
+// Pure SVG + CSS so it stays light on phones; motion gates on
+// prefers-reduced-motion in globals.css.
+export function SteppingCouple({ className = "" }: { className?: string }) {
+ return (
+ <div className={`emblem-float ${className}`}>
+ <svg
+ viewBox="0 0 400 500"
+ fill="none"
+ xmlns="http://www.w3.org/2000/svg"
+ role="img"
+ aria-label="Emblem of a stepping couple mid-turn"
+ className="h-full w-full"
+ >
+ <g className="emblem-sway">
+ <ellipse
+ className="emblem-in e5"
+ cx="175"
+ cy="440"
+ rx="135"
+ ry="17"
+ stroke="var(--bone)"
+ strokeOpacity="0.15"
+ strokeWidth="1.5"
+ />
+
+ <g className="emblem-in e1">
+ <circle cx="140" cy="114" r="10" fill="var(--bone)" />
+ <path
+ d="M136 130 C124 160 122 192 134 222 L146 224 C141 194 142 164 148 136 C144 127 140 127 136 130 Z"
+ fill="var(--bone)"
+ />
+ <path
+ d="M134 220 C100 260 68 324 58 404 C88 430 138 432 162 414 C155 344 150 278 146 222 Z"
+ fill="var(--bone)"
+ />
+ <path
+ className="emblem-glint"
+ d="M126 248 C102 288 88 336 86 388"
+ stroke="var(--gold)"
+ strokeWidth="2.5"
+ strokeLinecap="round"
+ />
+ </g>
+
+ <g className="emblem-in e2">
+ <circle cx="236" cy="95" r="11.5" fill="var(--bone)" />
+ <path
+ d="M236 109 C226 109 216 115 213 127 C207 167 214 210 227 250 C238 256 250 256 259 249 C268 209 269 166 261 126 C257 115 247 109 236 109 Z"
+ fill="var(--bone)"
+ />
+ <path
+ d="M261 130 C270 154 274 182 272 210 C271 220 265 224 260 220 C263 192 260 160 253 136 Z"
+ fill="var(--bone)"
+ />
+ <path
+ d="M226 244 C221 300 216 352 211 398 C202 404 195 410 194 418 C203 424 218 423 224 415 C229 376 235 336 241 300 C244 282 245 262 245 246 Z"
+ fill="var(--bone)"
+ />
+ <path
+ d="M259 246 C262 300 264 356 264 402 C270 406 276 411 278 417 C271 422 257 421 252 413 C248 372 245 334 243 300 C242 282 242 262 242 248 Z"
+ fill="var(--bone)"
+ />
+ <path
+ className="emblem-glint"
+ d="M239 116 C241 134 244 152 248 170"
+ stroke="var(--gold)"
+ strokeWidth="2.5"
+ strokeLinecap="round"
+ />
+ </g>
+
+ <path
+ className="emblem-draw e3"
+ pathLength={1}
+ d="M226 132 C205 103 186 94 166 98"
+ stroke="var(--bone)"
+ strokeWidth="6.5"
+ strokeLinecap="round"
+ />
+ <path
+ className="emblem-draw e4"
+ pathLength={1}
+ d="M150 132 C156 120 160 110 163 100"
+ stroke="var(--bone)"
+ strokeWidth="2.75"
+ strokeLinecap="round"
+ />
+
+ <circle className="emblem-pop e6" cx="164" cy="98" r="5" fill="var(--gold)" />
+ </g>
+ </svg>
+ </div>
+ );
+}
apps/web/src/components/wordmark.tsx +14 -0
@@ -0,0 +1,14 @@
+// CHICAGO STEPPING wordmark: serif "Chicago" over the display "STEPPING".
+// Size scales from the parent font-size via em units.
+export function Wordmark({ className = "" }: { className?: string }) {
+ return (
+ <span className={`inline-flex flex-col ${className}`}>
+ <span className="serif text-[0.72em] italic leading-none tracking-wide text-bone/75">
+ Chicago
+ </span>
+ <span className="display text-[1em] leading-none tracking-tight text-bone">
+ STEPPING
+ </span>
+ </span>
+ );
+}
apps/web/src/lib/uploads.ts +80 -0
@@ -0,0 +1,80 @@
+import { randomBytes } from "node:crypto";
+import { mkdir, writeFile } from "node:fs/promises";
+import path from "node:path";
+
+// Self-hosted upload storage. Files live OUTSIDE .next/ and public/ so
+// `next start` serves fresh uploads without a rebuild; the GET route handler
+// at /api/uploads/[name] streams them back out.
+export const UPLOADS_DIR =
+ process.env.UPLOADS_DIR ?? path.join(process.cwd(), "uploads");
+
+export const MAX_IMAGE_BYTES = 8 * 1024 * 1024; // 8MB flyers / photos
+export const MAX_PDF_BYTES = 15 * 1024 * 1024; // 15MB event programs
+
+const CONTENT_TYPES: Record<string, string> = {
+ jpg: "image/jpeg",
+ png: "image/png",
+ webp: "image/webp",
+ pdf: "application/pdf",
+};
+
+export function contentTypeFor(ext: string): string | null {
+ return CONTENT_TYPES[ext] ?? null;
+}
+
+// Stored names are always <32 hex>.<ext> - never derived from client input.
+export const UPLOAD_NAME_RE = /^[a-f0-9]{32}\.[a-z0-9]{3,4}$/;
+
+// Sniff the real type from magic bytes; the client's filename and declared
+// MIME type are never trusted.
+function sniffExt(buf: Buffer): keyof typeof CONTENT_TYPES | null {
+ if (buf.length >= 3 && buf[0] === 0xff && buf[1] === 0xd8 && buf[2] === 0xff) {
+ return "jpg";
+ }
+ if (
+ buf.length >= 8 &&
+ buf
+ .subarray(0, 8)
+ .equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))
+ ) {
+ return "png";
+ }
+ if (
+ buf.length >= 12 &&
+ buf.subarray(0, 4).toString("ascii") === "RIFF" &&
+ buf.subarray(8, 12).toString("ascii") === "WEBP"
+ ) {
+ return "webp";
+ }
+ if (buf.length >= 4 && buf.subarray(0, 4).toString("ascii") === "%PDF") {
+ return "pdf";
+ }
+ return null;
+}
+
+export type UploadKind = "image" | "pdf";
+
+// Validates size + magic bytes, stores under a random name, and returns the
+// public URL path. Throws with a user-safe message on any rejection.
+export async function saveUpload(file: File, kind: UploadKind): Promise<string> {
+ const max = kind === "pdf" ? MAX_PDF_BYTES : MAX_IMAGE_BYTES;
+ if (file.size === 0) throw new Error("Empty file.");
+ if (file.size > max) {
+ throw new Error(`File too large. Max ${Math.round(max / 1024 / 1024)}MB.`);
+ }
+ const buf = Buffer.from(await file.arrayBuffer());
+ const ext = sniffExt(buf);
+ const accepted =
+ kind === "pdf" ? ext === "pdf" : ext === "jpg" || ext === "png" || ext === "webp";
+ if (!ext || !accepted) {
+ throw new Error(
+ kind === "pdf"
+ ? "Only PDF files are accepted."
+ : "Only JPEG, PNG, or WebP images are accepted.",
+ );
+ }
+ const name = `${randomBytes(16).toString("hex")}.${ext}`;
+ await mkdir(UPLOADS_DIR, { recursive: true });
+ await writeFile(path.join(UPLOADS_DIR, name), buf);
+ return `/api/uploads/${name}`;
+}
packages/api/src/root.ts +173 -1
@@ -1,13 +1,15 @@
import { randomBytes } from "node:crypto";
-import { and, desc, eq, sql } from "drizzle-orm";
+import { and, desc, eq, isNull, or, sql } from "drizzle-orm";
import { z } from "zod";
import {
cities,
classes,
comments,
djs,
+ eventPhotos,
events,
flags,
+ instructors,
orders,
proAccounts,
promoters,
@@ -16,6 +18,7 @@ import {
ticketTypes,
tickets,
travelPlaces,
+ vendors,
videos,
} from "@stepping/db";
import {
@@ -35,6 +38,8 @@ const uuid = z.string().uuid();
const links = z
.array(z.object({ label: z.string().min(1), url: z.string().url() }))
.optional();
+// Self-hosted upload paths only (written by the web app's upload handler).
+const uploadUrl = z.string().regex(/^\/api\/uploads\/[a-f0-9]{32}\.[a-z0-9]{3,4}$/);
export const appRouter = router({
// ---- Geography -----------------------------------------------------------
@@ -140,6 +145,148 @@ export const appRouter = router({
});
return row;
}),
+ // [F1] attach or clear the event flyer image (admin-managed).
+ setFlyer: adminProcedure
+ .input(z.object({ id: uuid, flyerUrl: uploadUrl.nullable() }))
+ .mutation(async ({ ctx, input }) => {
+ const [row] = await ctx.db
+ .update(events)
+ .set({ flyerUrl: input.flyerUrl })
+ .where(eq(events.id, input.id))
+ .returning();
+ return row;
+ }),
+ // [F4] attach or clear the downloadable PDF program (admin-managed).
+ setProgram: adminProcedure
+ .input(z.object({ id: uuid, programUrl: uploadUrl.nullable() }))
+ .mutation(async ({ ctx, input }) => {
+ const [row] = await ctx.db
+ .update(events)
+ .set({ programUrl: input.programUrl })
+ .where(eq(events.id, input.id))
+ .returning();
+ return row;
+ }),
+ }),
+
+ // ---- Event photo albums [F2] - pending until an admin approves ------------
+ eventPhoto: router({
+ list: publicProcedure.input(z.object({ eventId: uuid })).query(({ ctx, input }) =>
+ ctx.db
+ .select()
+ .from(eventPhotos)
+ .where(and(eq(eventPhotos.eventId, input.eventId), eq(eventPhotos.status, "active")))
+ .orderBy(desc(eventPhotos.createdAt)),
+ ),
+ submit: protectedProcedure
+ .input(
+ z.object({
+ eventId: uuid,
+ url: uploadUrl,
+ caption: z.string().max(200).optional(),
+ }),
+ )
+ .mutation(async ({ ctx, input }) => {
+ const [row] = await ctx.db
+ .insert(eventPhotos)
+ .values({ ...input, ownerId: ctx.user.id, status: "pending" })
+ .returning();
+ return row;
+ }),
+ adminPending: adminProcedure.query(({ ctx }) =>
+ ctx.db
+ .select({
+ id: eventPhotos.id,
+ url: eventPhotos.url,
+ caption: eventPhotos.caption,
+ createdAt: eventPhotos.createdAt,
+ eventId: eventPhotos.eventId,
+ eventTitle: events.title,
+ })
+ .from(eventPhotos)
+ .innerJoin(events, eq(events.id, eventPhotos.eventId))
+ .where(eq(eventPhotos.status, "pending"))
+ .orderBy(desc(eventPhotos.createdAt))
+ .limit(100),
+ ),
+ adminReview: adminProcedure
+ .input(z.object({ id: uuid, approve: z.boolean() }))
+ .mutation(async ({ ctx, input }) => {
+ const [row] = await ctx.db
+ .update(eventPhotos)
+ .set(
+ input.approve
+ ? { status: "active", verifiedAt: new Date() }
+ : { status: "removed", hiddenAt: new Date() },
+ )
+ .where(eq(eventPhotos.id, input.id))
+ .returning();
+ return row;
+ }),
+ }),
+
+ // ---- Vendors [F5] - city-scoped, optional event link (admin-managed) ------
+ vendor: router({
+ // Vendors for an event page: linked to this event, or city-wide (no event).
+ listForEvent: publicProcedure
+ .input(z.object({ cityId: uuid, eventId: uuid }))
+ .query(({ ctx, input }) =>
+ ctx.db
+ .select()
+ .from(vendors)
+ .where(
+ and(
+ eq(vendors.status, "active"),
+ or(
+ eq(vendors.eventId, input.eventId),
+ and(eq(vendors.cityId, input.cityId), isNull(vendors.eventId)),
+ ),
+ ),
+ )
+ .orderBy(vendors.name),
+ ),
+ listByCity: publicProcedure.input(z.object({ cityId: uuid })).query(({ ctx, input }) =>
+ ctx.db
+ .select()
+ .from(vendors)
+ .where(and(eq(vendors.cityId, input.cityId), eq(vendors.status, "active")))
+ .orderBy(vendors.name),
+ ),
+ adminList: adminProcedure.query(({ ctx }) =>
+ ctx.db.select().from(vendors).orderBy(desc(vendors.createdAt)).limit(100),
+ ),
+ adminCreate: adminProcedure
+ .input(
+ z.object({
+ cityId: uuid,
+ eventId: uuid.optional(),
+ name: z.string().min(1).max(120),
+ description: z.string().max(500).optional(),
+ category: z.string().max(60).optional(),
+ linkUrl: z.string().url().optional(),
+ }),
+ )
+ .mutation(async ({ ctx, input }) => {
+ const [row] = await ctx.db
+ .insert(vendors)
+ .values({ ...input, ownerId: ctx.user.id })
+ .returning();
+ return row;
+ }),
+ adminSetStatus: adminProcedure
+ .input(z.object({ id: uuid, status: z.enum(["active", "hidden"]) }))
+ .mutation(async ({ ctx, input }) => {
+ const [row] = await ctx.db
+ .update(vendors)
+ .set(
+ input.status === "hidden"
+ ? { status: "hidden", hiddenAt: new Date() }
+ : { status: "active", hiddenAt: null },
+ )
+ .where(eq(vendors.id, input.id))
+ .returning();
+ return row;
+ }),
}),
// ---- Ticketing -----------------------------------------------------------
@@ -431,6 +578,31 @@ export const appRouter = router({
}),
}),
+ // [F7] instructors people can go to for lessons, advertised per city.
+ instructor: router({
+ list: publicProcedure.input(z.object({ cityId: uuid })).query(({ ctx, input }) =>
+ ctx.db
+ .select()
+ .from(instructors)
+ .where(and(eq(instructors.cityId, input.cityId), eq(instructors.status, "active"))),
+ ),
+ byId: publicProcedure.input(z.object({ id: uuid })).query(async ({ ctx, input }) => {
+ const [row] = await ctx.db.select().from(instructors).where(eq(instructors.id, input.id));
+ return row ?? null;
+ }),
+ create: protectedProcedure
+ .input(
+ z.object({ cityId: uuid, name: z.string().min(1), bio: z.string().optional(), links }),
+ )
+ .mutation(async ({ ctx, input }) => {
+ const [row] = await ctx.db
+ .insert(instructors)
+ .values({ ...input, ownerId: ctx.user.id })
+ .returning();
+ return row;
+ }),
+ }),
+
steppingGroup: router({
list: publicProcedure.input(z.object({ cityId: uuid })).query(({ ctx, input }) =>
ctx.db
packages/db/seed-content.sql +59 -26
@@ -2,25 +2,27 @@
-- Chicago stepping: smooth partner-dance "sets", weekenders, classes, DJs,
-- a live stream, a promotion, a special event, and the traveling-stepper guide.
-- Cities are seeded by seed.ts; this loads sample content against them.
+-- Per H1: no real businesses or real people by name. Every venue, place, and
+-- sponsor is invented and labeled "(Sample)"; people and orgs are invented.
-- Run once against a seeded DB.
\set dev '00000000-0000-0000-0000-000000000001'
begin;
+-- Venues (fictional, sample-labeled) ----------------------------------------
insert into venues (city_id, name, address, owner_id)
select id, v.name, v.addr, :'dev' from cities, (values
- ('The Promontory', '5311 S Lake Park Ave W, Chicago, IL'),
- ('Parkway Ballroom', '4455 S King Dr, Chicago, IL')
+ ('The Southside Ballroom (Sample)', 'Bronzeville, Chicago, IL'),
+ ('The Lakefront Ballroom (Sample)', 'Hyde Park, Chicago, IL')
) as v(name, addr) where slug = 'chicago';
insert into venues (city_id, name, address, owner_id)
select id, v.name, v.addr, :'dev' from cities, (values
- ('Cafe Circa', '464 Edgewood Ave SE, Atlanta, GA')
+ ('The Peachtree Room (Sample)', 'Old Fourth Ward, 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
- ('Bert''s Marketplace', '2727 Russell St, Detroit, MI')
+ ('The Riverfront Hall (Sample)', 'Rivertown, Detroit, MI')
) as v(name, addr) where slug = 'detroit';
-- Events -------------------------------------------------------------------
@@ -29,7 +31,7 @@ insert into events (city_id, title, type, description, starts_at, is_live, live_
select id, e.title, e.type::event_type, e.descr, e.ts::timestamptz, e.live, e.lurl, e.promo, :'dev'
from cities, (values
('Friday Night Smooth Set', 'set',
- 'Weekly grown-and-sexy steppers set on the South Side. Live DJ, dress code in effect.',
+ 'Weekly grown folks'' steppers set on the South Side. Live DJ, dress code in effect.',
'2026-07-10 21:00', true, 'https://www.youtube.com/watch?v=M3SlJHI_zYU', false),
('World Steppers Weekend', 'weekender',
'Three-day Chicago steppin'' weekender: sets, workshops, and a contest. Hotel block open now.',
@@ -49,10 +51,10 @@ select id, e.title, e.type::event_type, e.descr, e.ts::timestamptz, :'dev' from
insert into events (city_id, title, type, description, starts_at, owner_id)
select id, e.title, e.type::event_type, e.descr, e.ts::timestamptz, :'dev' from cities, (values
- ('Motor City Steppers Set', 'set', 'Detroit''s monthly steppin'' set with a guest Chicago DJ.', '2026-07-25 21:00')
+ ('Rivertown Steppers Set', 'set', 'Detroit''s monthly steppin'' set with a guest Chicago DJ.', '2026-07-25 21:00')
) as e(title, type, descr, ts) where slug = 'detroit';
+-- DJs (invented names) -------------------------------------------------------
insert into djs (city_id, name, bio, owner_id)
select id, t.n, t.b, :'dev' from cities, (values
('DJ Smooth Glide', 'South Side steppers'' DJ, smooth soul and steppin'' cuts.'),
@@ -61,25 +63,35 @@ select id, t.n, t.b, :'dev' from cities, (values
insert into djs (city_id, name, bio, owner_id)
select id, 'DJ Peachtree Steppin''', 'Atlanta''s go-to for Chicago-style sets.', :'dev' from cities where slug = 'atlanta';
+-- Promoters / hosts (invented names) -----------------------------------------
insert into promoters (city_id, name, bio, owner_id)
select id, t.n, t.b, :'dev' from cities, (values
- ('Windy City Steppers Events', 'Throwing South Side sets and weekenders since the 90s.'),
+ ('Glideline Events', 'Throwing South Side sets and weekenders since the 90s.'),
('South Side Smooth Productions', 'Sets, contests, and the annual steppers weekend.')
) t(n, b) where slug = 'chicago';
insert into promoters (city_id, name, bio, owner_id)
select id, 'ATL Steppers Connection', 'Bringing Chicago steppin'' to the ATL.', :'dev' from cities where slug = 'atlanta';
+-- Stepping groups (invented, city-scoped crews) ------------------------------
insert into stepping_groups (city_id, name, bio, owner_id)
select id, g.name, g.bio, :'dev' from cities, (values
('Smooth Steppers Society', 'Longtime South Side steppin'' crew, sets and classes citywide.'),
- ('Windy City Steppers', 'Community group keeping Chicago steppin'' culture moving.')
+ ('The Glideline Steppers', 'Community group keeping Chicago steppin'' culture moving.')
) as g(name, bio) where slug = 'chicago';
insert into stepping_groups (city_id, name, bio, owner_id)
-select id, 'Motor City Steppers', 'Detroit''s home for Chicago-style steppin''.', :'dev'
+select id, 'The Rivertown Steppers', 'Detroit''s home for Chicago-style steppin''.', :'dev'
from cities where slug = 'detroit';
+-- Instructors [F7] (invented names; real instructors added with permission) --
+insert into instructors (city_id, name, bio, owner_id)
+select id, t.n, t.b, :'dev' from cities, (values
+ ('Coach Renee', 'Beginner lessons on the South Side. Group classes Tuesdays, privates by appointment.'),
+ ('Mr. Williams', 'Footwork and turn work for intermediate and advanced steppers.')
+) t(n, b) where slug = 'chicago';
+insert into instructors (city_id, name, bio, owner_id)
+select id, 'Coach Dee', 'Chicago-style fundamentals for steppers in the ATL.', :'dev'
+from cities where slug = 'atlanta';
+
-- Classes ------------------------------------------------------------------
insert into classes (city_id, title, instructor, schedule, is_free, owner_id)
select id, c.title, c.inst, c.sched, c.free, :'dev' from cities, (values
@@ -88,31 +100,31 @@ select id, c.title, c.inst, c.sched, c.free, :'dev' from cities, (values
('Free Community Steppin'' Practice', 'Smooth Steppers Society', 'Sundays 4:00pm', true)
) as c(title, inst, sched, free) where slug = 'chicago';
+-- Per-city travel guide (fictional, sample-labeled places) -------------------
insert into travel_places (city_id, category, name, address)
select id, t.cat::travel_category, t.name, t.addr from cities, (values
- ('event_center', 'The Promontory', '5311 S Lake Park Ave W, Chicago, IL'),
- ('event_center', 'Parkway Ballroom', '4455 S King Dr, Chicago, IL'),
- ('hotel', 'Hyatt Regency McCormick Place', '2233 S Dr Martin Luther King Jr Dr, Chicago, IL'),
- ('hotel', 'Sophy Hyde Park', '1411 E 53rd St, Chicago, IL'),
- ('restaurant', 'Pearl''s Place', '3901 S Michigan Ave, Chicago, IL'),
- ('restaurant', 'Lem''s Bar-B-Q', '311 E 75th St, Chicago, IL'),
- ('mall', 'Block 37', '108 N State St, Chicago, IL'),
- ('attraction', 'DuSable Black History Museum', '740 E 56th Pl, Chicago, IL'),
- ('entertainment', 'Buddy Guy''s Legends', '700 S Wabash Ave, Chicago, IL'),
- ('hospital', 'University of Chicago Medical Center', '5841 S Maryland Ave, Chicago, IL')
+ ('event_center', 'The Southside Ballroom (Sample)', 'Bronzeville, Chicago, IL'),
+ ('event_center', 'The Lakefront Ballroom (Sample)', 'Hyde Park, Chicago, IL'),
+ ('hotel', 'The Convention Way Hotel (Sample)', 'Near South Side, Chicago, IL'),
+ ('hotel', 'The Park Row Hotel (Sample)', 'Hyde Park, Chicago, IL'),
+ ('restaurant', 'The Corner Kitchen (Sample)', 'Bronzeville, Chicago, IL'),
+ ('restaurant', 'The South Side Smokehouse (Sample)', 'Chatham, Chicago, IL'),
+ ('mall', 'The State Street Shops (Sample)', 'The Loop, Chicago, IL'),
+ ('attraction', 'The Heritage Museum (Sample)', 'Washington Park, Chicago, IL'),
+ ('entertainment', 'The Rhythm Lounge (Sample)', 'South Loop, Chicago, IL'),
+ ('hospital', 'South Side Medical Center (Sample)', 'Hyde Park, Chicago, IL')
) as t(cat, name, addr) where slug = 'chicago';
insert into travel_places (city_id, category, name, address)
select id, t.cat::travel_category, t.name, t.addr from cities, (values
- ('event_center', 'Cafe Circa', '464 Edgewood Ave SE, Atlanta, GA'),
- ('hotel', 'AC Hotel Atlanta Downtown', '166 Carnegie Way NW, Atlanta, GA'),
- ('restaurant', 'Paschal''s', '180b Northside Dr SW, 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')
+ ('event_center', 'The Peachtree Room (Sample)', 'Old Fourth Ward, Atlanta, GA'),
+ ('hotel', 'The Downtown Atlanta Hotel (Sample)', 'Downtown, Atlanta, GA'),
+ ('restaurant', 'The Westside Kitchen (Sample)', 'Castleberry Hill, Atlanta, GA'),
+ ('attraction', 'The Downtown Park Commons (Sample)', 'Downtown, Atlanta, GA'),
+ ('hospital', 'Downtown Atlanta Medical Center (Sample)', 'Downtown, Atlanta, GA')
) as t(cat, name, addr) where slug = 'atlanta';
+-- Videos (per-city, real embeds; titles describe content, no personal names) -
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
('Chicago Steppin Is Freedom on the Dance Floor', 'https://www.youtube.com/watch?v=M3SlJHI_zYU', 'youtube'),
@@ -121,7 +133,7 @@ select id, v.title, v.url, v.plat::video_platform, :'dev' from cities, (values
) as v(title, url, plat) where slug = 'chicago';
insert into videos (city_id, title, embed_url, platform, owner_id)
-select id, 'Chicago Step Lessons with Linda & Pete Frazier', 'https://www.youtube.com/watch?v=HUbSuIPJiKc', 'youtube', :'dev'
+select id, 'Chicago Steppin'' Lesson Breakdown', 'https://www.youtube.com/watch?v=HUbSuIPJiKc', 'youtube', :'dev'
from cities where slug = 'atlanta';
-- Comments (polymorphic over subject) --------------------------------------
@@ -145,14 +157,28 @@ where e.title = 'World Steppers Weekend';
insert into ticket_types (event_id, name, price_cents, owner_id)
select id, 'Cover', 1500, :'dev' from events where title = 'Friday Night Smooth Set';
+-- Sponsor (demo, admin-placed, sample-labeled) -------------------------------
insert into sponsors (city_id, name, blurb, link_url, status)
-select id, 'Smooth Threads', 'Steppers-sharp menswear on 79th', 'https://example.com', 'active'
+select id, 'Smooth Threads (Sample)', 'Steppers-sharp menswear on 79th', 'https://example.com', 'active'
from cities where slug = 'chicago';
+-- Vendors (demo, admin-placed; clearly-labeled samples only) -----------------
+insert into vendors (city_id, name, description, category, link_url, owner_id)
+select id, v.n, v.d, v.c, v.l, :'dev' from cities, (values
+ ('Sample Vendor: Stepping Shoes', 'Demo listing. Comfortable soles built for a long night on the floor.', 'Footwear', 'https://example.com'),
+ ('Sample Vendor: Sharp Shades', 'Demo listing. Frames for stepping sharp and coming clean.', 'Accessories', NULL)
+) as v(n, d, c, l) where slug = 'chicago';
+
+insert into vendors (city_id, event_id, name, description, category, owner_id)
+select c.id, e.id, 'Sample Vendor: Weekend Boutique', 'Demo listing. Purses and accessories, this event only.', 'Accessories', :'dev'
+from cities c
+join events e on e.city_id = c.id and e.title = 'World Steppers Weekend'
+where c.slug = 'chicago';
+
commit;
select 'venues' c, count(*) n from venues
union all select 'events', count(*) from events
union all select 'travel', count(*) from travel_places
union all select 'videos', count(*) from videos
+union all select 'instructors', count(*) from instructors
union all select 'comments', count(*) from comments;
packages/db/src/schema.ts +49 -0
@@ -169,6 +169,20 @@ export const djs = pgTable("djs", {
...timestamps,
});
+// [F7] instructors people can go to for lessons, advertised per city.
+export const instructors = pgTable("instructors", {
+ id: uuid("id").primaryKey().defaultRandom(),
+ cityId: uuid("city_id")
+ .references(() => cities.id, { onDelete: "cascade" })
+ .notNull(),
+ name: text("name").notNull(),
+ bio: text("bio"),
+ links: jsonb("links").$type<LinkRef[]>(),
+ ...ownership,
+ ...moderatable,
+ ...timestamps,
+});
+
export const classes = pgTable("classes", {
id: uuid("id").primaryKey().defaultRandom(),
cityId: uuid("city_id")
@@ -211,6 +225,41 @@ export const events = pgTable("events", {
ticketUrl: text("ticket_url"),
priceText: text("price_text"),
isFree: boolean("is_free").default(false).notNull(),
+ // [F1] event flyer image + [F4] downloadable PDF program (admin-attached).
+ flyerUrl: text("flyer_url"),
+ programUrl: text("program_url"),
+ ...ownership,
+ ...moderatable,
+ ...timestamps,
+});
+
+// [F2] per-event photo albums. Community submissions land as "pending" and
+// only render publicly once an admin approves (flips status to "active").
+export const eventPhotos = pgTable("event_photos", {
+ id: uuid("id").primaryKey().defaultRandom(),
+ eventId: uuid("event_id")
+ .references(() => events.id, { onDelete: "cascade" })
+ .notNull(),
+ url: text("url").notNull(),
+ caption: text("caption"),
+ ...ownership,
+ ...moderatable,
+ status: contentStatus("status").default("pending").notNull(),
+ ...timestamps,
+});
+
+// [F5] vendors (glasses, purses, apparel, wide range) - city-scoped with an
+// optional event link; null eventId = shows across the whole city.
+export const vendors = pgTable("vendors", {
+ id: uuid("id").primaryKey().defaultRandom(),
+ cityId: uuid("city_id")
+ .references(() => cities.id, { onDelete: "cascade" })
+ .notNull(),
+ eventId: uuid("event_id").references(() => events.id, { onDelete: "set null" }),
+ name: text("name").notNull(),
+ description: text("description"),
+ category: text("category"),
+ linkUrl: text("link_url"),
...ownership,
...moderatable,
...timestamps,
pnpm-lock.yaml +0 -203
@@ -20,9 +20,6 @@ importers:
apps/web:
dependencies:
- '@react-three/fiber':
- specifier: ^9.1.0
- version: 9.6.1(@types/react@19.2.17)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(three@0.171.0)
'@stepping/api':
specifier: workspace:*
version: link:../../packages/api
@@ -71,9 +68,6 @@ importers:
superjson:
specifier: ^2.2.2
version: 2.2.6
- three:
- specifier: ^0.171.0
- version: 0.171.0
zod:
specifier: ^3.24.1
version: 3.25.76
@@ -90,9 +84,6 @@ importers:
'@types/react-dom':
specifier: ^19
version: 19.2.3(@types/react@19.2.17)
- '@types/three':
- specifier: ^0.171.0
- version: 0.171.0
eslint:
specifier: ^9
version: 9.39.4(jiti@2.7.0)
@@ -250,10 +241,6 @@ packages:
engines: {node: '>=6.0.0'}
hasBin: true
- '@babel/runtime@7.29.7':
- resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==}
- engines: {node: '>=6.9.0'}
-
'@babel/template@7.29.7':
resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==}
engines: {node: '>=6.9.0'}
@@ -1005,31 +992,6 @@ packages:
'@petamoriken/float16@3.9.3':
resolution: {integrity: sha512-8awtpHXCx/bNpFt4mt2xdkgtgVvKqty8VbjHI/WWWQuEw+KLzFot3f4+LkQY9YmOtq7A5GdOnqoIC8Pdygjk2g==}
- '@react-three/fiber@9.6.1':
- resolution: {integrity: sha512-zF0rsKcVYpcJwbFEnv2HkHX9cvOEgsfQo/X8lwmR2dn13S4qEQJXir9fxf5js2LQFoXqxOY7MDkOkYx2uZ4gSg==}
- peerDependencies:
- expo: '>=43.0'
- expo-asset: '>=8.4'
- expo-file-system: '>=11.0'
- expo-gl: '>=11.0'
- react: '>=19 <19.3'
- react-dom: '>=19 <19.3'
- react-native: '>=0.78'
- three: '>=0.156'
- peerDependenciesMeta:
- expo:
- optional: true
- expo-asset:
- optional: true
- expo-file-system:
- optional: true
- expo-gl:
- optional: true
- react-dom:
- optional: true
- react-native:
- optional: true
-
'@rtsao/scc@1.1.0':
resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==}
@@ -1216,9 +1178,6 @@ packages:
cpu: [arm64]
os: [win32]
- '@tweenjs/tween.js@23.1.3':
- resolution: {integrity: sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==}
-
'@tybys/wasm-util@0.10.3':
resolution: {integrity: sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==}
@@ -1242,23 +1201,9 @@ packages:
peerDependencies:
'@types/react': ^19.2.0
- '@types/react-reconciler@0.28.9':
- resolution: {integrity: sha512-HHM3nxyUZ3zAylX8ZEyrDNd2XZOnQ0D5XfunJF5FLQnZbHHYq4UWvW1QfelQNXv1ICNkwYhfxjwfnqivYB6bFg==}
- peerDependencies:
- '@types/react': '*'
-
'@types/react@19.2.17':
resolution: {integrity: sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==}
- '@types/stats.js@0.17.4':
- resolution: {integrity: sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==}
-
- '@types/three@0.171.0':
- resolution: {integrity: sha512-oLuT1SAsT+CUg/wxUTFHo0K3NtJLnx9sJhZWQJp/0uXqFpzSk1hRHmvWvpaAWSfvx2db0lVKZ5/wV0I0isD2mQ==}
-
- '@types/webxr@0.5.24':
- resolution: {integrity: sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==}
-
'@typescript-eslint/eslint-plugin@8.62.0':
resolution: {integrity: sha512-o+mpz7EYiMzXoySXiKmzlabIvTVqUuK5yLrAedRPRDA0IpPFMUV1IXt6OqljIxX/kumN6EjUYp41Hqelh6p/Dw==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
@@ -1428,9 +1373,6 @@ packages:
cpu: [x64]
os: [win32]
- '@webgpu/types@0.1.71':
- resolution: {integrity: sha512-mMy8/ODcKhab808co15eW+yN+HgXoQxRQHTiBV9Mrvl1r0ufnid7YOcI+gi4eUWSWl9ezD6TW2KXccrL8HCh2A==}
-
acorn-jsx@5.3.2:
resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==}
peerDependencies:
@@ -1513,9 +1455,6 @@ packages:
resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==}
engines: {node: 18 || 20 || >=22}
- base64-js@1.5.1:
- resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==}
-
baseline-browser-mapping@2.10.38:
resolution: {integrity: sha512-31/02mVB4yuQU6adKk5SlY6m+mxDwUq5KZkyYgnLrrKl7TEm1+3PyDtDBz2kOv/wxZz41GHsvV1A/u6RmiyBvw==}
engines: {node: '>=6.0.0'}
@@ -1540,9 +1479,6 @@ packages:
buffer-from@1.1.2:
resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==}
- buffer@6.0.3:
- resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==}
-
call-bind-apply-helpers@1.0.2:
resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==}
engines: {node: '>= 0.4'}
@@ -1975,9 +1911,6 @@ packages:
picomatch:
optional: true
- fflate@0.8.3:
- resolution: {integrity: sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==}
-
file-entry-cache@8.0.0:
resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==}
engines: {node: '>=16.0.0'}
@@ -2108,9 +2041,6 @@ packages:
resolution: {integrity: sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA==}
engines: {node: '>=20.0.0'}
- ieee754@1.2.1:
- resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==}
-
ignore@5.3.2:
resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==}
engines: {node: '>= 4'}
@@ -2256,11 +2186,6 @@ packages:
resolution: {integrity: sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==}
engines: {node: '>= 0.4'}
- its-fine@2.0.0:
- resolution: {integrity: sha512-KLViCmWx94zOvpLwSlsx6yOCeMhZYaxrJV87Po5k/FoZzcPSahvK5qJ7fYhS61sZi5ikmh2S3Hz55A2l3U69ng==}
- peerDependencies:
- react: ^19.0.0
-
jiti@2.7.0:
resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==}
hasBin: true
@@ -2422,9 +2347,6 @@ packages:
resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==}
engines: {node: '>= 8'}
- meshoptimizer@0.18.1:
- resolution: {integrity: sha512-ZhoIoL7TNV4s5B6+rx5mC//fw8/POGyNxS/DZyCJeiZ12ScLfVwRE/GfsxwiTkMYYD5DmK2/JXnEVXqL4rF+Sw==}
-
micromatch@4.0.8:
resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==}
engines: {node: '>=8.6'}
@@ -2601,15 +2523,6 @@ packages:
react-is@16.13.1:
resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
- react-use-measure@2.1.7:
- resolution: {integrity: sha512-KrvcAo13I/60HpwGO5jpW7E9DfusKyLPLvuHlUyP5zqnmAPhNc6qTRjUQrdTADl0lpPpDVU2/Gg51UlOGHXbdg==}
- peerDependencies:
- react: '>=16.13'
- react-dom: '>=16.13'
- peerDependenciesMeta:
- react-dom:
- optional: true
-
react@19.2.4:
resolution: {integrity: sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==}
engines: {node: '>=0.10.0'}
@@ -2783,11 +2696,6 @@ packages:
resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==}
engines: {node: '>= 0.4'}
- suspend-react@0.1.3:
- resolution: {integrity: sha512-aqldKgX9aZqpoDp3e8/BZ8Dm7x1pJl+qI3ZKxDN0i/IQTWUwBx/ManmlVJ3wowqbno6c2bmiIfs+Um6LbsjJyQ==}
- peerDependencies:
- react: '>=17.0'
-
tailwindcss@4.3.1:
resolution: {integrity: sha512-hk+TB1m+K8CYNrP6rjQaq/Y+4Zylwpa87mLYBKCunwnnQ9p+fHb7kmSfGqyEJoxF/O6CDyABWVFEafNSYKll+Q==}
@@ -2795,9 +2703,6 @@ packages:
resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==}
engines: {node: '>=6'}
- three@0.171.0:
- resolution: {integrity: sha512-Y/lAXPaKZPcEdkKjh0JOAHVv8OOnv/NDJqm0wjfCzyQmfKxV7zvkwsnBgPBKTzJHToSOhRGQAGbPJObT59B/PQ==}
-
tinyglobby@0.2.17:
resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==}
engines: {node: '>=12.0.0'}
@@ -2878,11 +2783,6 @@ packages:
uri-js@4.4.1:
resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==}
- use-sync-external-store@1.6.0:
- resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==}
- peerDependencies:
- react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
-
which-boxed-primitive@1.1.1:
resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==}
engines: {node: '>= 0.4'}
@@ -2929,24 +2829,6 @@ packages:
zod@3.25.76:
resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==}
- zustand@5.0.14:
- resolution: {integrity: sha512-/8tAspM5LMPr28b3fwLYrtdj77ECpfZviaP75CMTnwO8ISyaE4GDIG/9rDDYq/cH9D2Xw2A2RXglLInmVBQB/g==}
- engines: {node: '>=12.20.0'}
- peerDependencies:
- '@types/react': '>=18.0.0'
- immer: '>=9.0.6'
- react: '>=18.0.0'
- use-sync-external-store: '>=1.2.0'
- peerDependenciesMeta:
- '@types/react':
- optional: true
- immer:
- optional: true
- react:
- optional: true
- use-sync-external-store:
- optional: true
-
snapshots:
'@alloc/quick-lru@5.2.0': {}
@@ -3028,8 +2910,6 @@ snapshots:
dependencies:
'@babel/types': 7.29.7
- '@babel/runtime@7.29.7': {}
-
'@babel/template@7.29.7':
dependencies:
'@babel/code-frame': 7.29.7
@@ -3530,26 +3410,6 @@ snapshots:
'@petamoriken/float16@3.9.3': {}
- '@react-three/fiber@9.6.1(@types/react@19.2.17)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(three@0.171.0)':
- dependencies:
- '@babel/runtime': 7.29.7
- '@types/webxr': 0.5.24
- base64-js: 1.5.1
- buffer: 6.0.3
- its-fine: 2.0.0(@types/react@19.2.17)(react@19.2.4)
- react: 19.2.4
- react-use-measure: 2.1.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- scheduler: 0.27.0
- suspend-react: 0.1.3(react@19.2.4)
- three: 0.171.0
- use-sync-external-store: 1.6.0(react@19.2.4)
- zustand: 5.0.14(@types/react@19.2.17)(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4))
- optionalDependencies:
- react-dom: 19.2.4(react@19.2.4)
- transitivePeerDependencies:
- - '@types/react'
- - immer
-
'@rtsao/scc@1.1.0': {}
'@supabase/auth-js@2.108.2':
@@ -3705,8 +3565,6 @@ snapshots:
'@turbo/windows-arm64@2.10.0':
optional: true
- '@tweenjs/tween.js@23.1.3': {}
-
'@tybys/wasm-util@0.10.3':
dependencies:
tslib: 2.8.1
@@ -3728,27 +3586,10 @@ snapshots:
dependencies:
'@types/react': 19.2.17
- '@types/react-reconciler@0.28.9(@types/react@19.2.17)':
- dependencies:
- '@types/react': 19.2.17
-
'@types/react@19.2.17':
dependencies:
csstype: 3.2.3
- '@types/stats.js@0.17.4': {}
-
- '@types/three@0.171.0':
- dependencies:
- '@tweenjs/tween.js': 23.1.3
- '@types/stats.js': 0.17.4
- '@types/webxr': 0.5.24
- '@webgpu/types': 0.1.71
- fflate: 0.8.3
- meshoptimizer: 0.18.1
-
- '@types/webxr@0.5.24': {}
-
'@typescript-eslint/eslint-plugin@8.62.0(@typescript-eslint/parser@8.62.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3)':
dependencies:
'@eslint-community/regexpp': 4.12.2
@@ -3910,8 +3751,6 @@ snapshots:
'@unrs/resolver-binding-win32-x64-msvc@1.12.2':
optional: true
- '@webgpu/types@0.1.71': {}
-
acorn-jsx@5.3.2(acorn@8.17.0):
dependencies:
acorn: 8.17.0
@@ -4016,8 +3855,6 @@ snapshots:
balanced-match@4.0.4: {}
- base64-js@1.5.1: {}
-
baseline-browser-mapping@2.10.38: {}
brace-expansion@1.1.15:
@@ -4043,11 +3880,6 @@ snapshots:
buffer-from@1.1.2: {}
- buffer@6.0.3:
- dependencies:
- base64-js: 1.5.1
- ieee754: 1.2.1
-
call-bind-apply-helpers@1.0.2:
dependencies:
es-errors: 1.3.0
@@ -4611,8 +4443,6 @@ snapshots:
optionalDependencies:
picomatch: 4.0.4
- fflate@0.8.3: {}
-
file-entry-cache@8.0.0:
dependencies:
flat-cache: 4.0.1
@@ -4750,8 +4580,6 @@ snapshots:
iceberg-js@0.8.1: {}
- ieee754@1.2.1: {}
-
ignore@5.3.2: {}
ignore@7.0.5: {}
@@ -4902,13 +4730,6 @@ snapshots:
has-symbols: 1.1.0
set-function-name: 2.0.2
- its-fine@2.0.0(@types/react@19.2.17)(react@19.2.4):
- dependencies:
- '@types/react-reconciler': 0.28.9(@types/react@19.2.17)
- react: 19.2.4
- transitivePeerDependencies:
- - '@types/react'
-
jiti@2.7.0: {}
js-tokens@4.0.0: {}
@@ -5028,8 +4849,6 @@ snapshots:
merge2@1.4.1: {}
- meshoptimizer@0.18.1: {}
-
micromatch@4.0.8:
dependencies:
braces: 3.0.3
@@ -5204,12 +5023,6 @@ snapshots:
react-is@16.13.1: {}
- react-use-measure@2.1.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
- dependencies:
- react: 19.2.4
- optionalDependencies:
- react-dom: 19.2.4(react@19.2.4)
-
react@19.2.4: {}
reflect.getprototypeof@1.0.10:
@@ -5454,16 +5267,10 @@ snapshots:
supports-preserve-symlinks-flag@1.0.0: {}
- suspend-react@0.1.3(react@19.2.4):
- dependencies:
- react: 19.2.4
-
tailwindcss@4.3.1: {}
tapable@2.3.3: {}
- three@0.171.0: {}
-
tinyglobby@0.2.17:
dependencies:
fdir: 6.5.0(picomatch@4.0.4)
@@ -5597,10 +5404,6 @@ snapshots:
dependencies:
punycode: 2.3.1
- use-sync-external-store@1.6.0(react@19.2.4):
- dependencies:
- react: 19.2.4
-
which-boxed-primitive@1.1.1:
dependencies:
is-bigint: 1.1.0
@@ -5661,9 +5464,3 @@ snapshots:
zod: 3.25.76
zod@3.25.76: {}
-
- zustand@5.0.14(@types/react@19.2.17)(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)):
- optionalDependencies:
- '@types/react': 19.2.17
- react: 19.2.4
- use-sync-external-store: 1.6.0(react@19.2.4)
promo/shots-july4/about-full.png +0 -0
Binary file not shown
promo/shots-july4/home-desktop.png +0 -0
Binary file not shown
promo/shots-july4/home-full.png +0 -0
Binary file not shown
promo/shots-july4/home-mobile.png +0 -0
Binary file not shown
promo/shots-july4/icon-direct.png +0 -0
Binary file not shown
promo/shots-july4/icon-sizes.png +0 -0
Binary file not shown