From a371ff36457a75f9600c5eb67c8a029baf8bcb8d Mon Sep 17 00:00:00 2001 From: "shakespeare.diy" Date: Wed, 29 Jul 2026 22:39:51 +0700 Subject: [PATCH] Add Trick Glossary + harden Sasquatch AI rate-limit handling Glossary: - New /glossary page: 80+ tricks across 11 categories (fundamentals, flips, shoves/spins, grinds, slides, grabs, airs, lip, stalls, handplants, boneless, freestyle) with alternate names, difficulty badges, live search, and category filters. - Added to desktop "More" dropdown and mobile burger menu. Sasquatch AI: - Fixed/expanded free-model fallback list with valid OpenRouter slugs (dropped the non-existent gemma-4 slug). - Added exponential backoff + one retry on 429 rate limits, respecting Retry-After headers. - SasquatchGuide now reuses the shared sasquatchBrain caller instead of a duplicated implementation. - Friendlier fallback message when the AI is saturated. Co-authored-by: shakespeare.diy --- src/AppRouter.tsx | 3 + src/components/Layout.tsx | 14 ++ src/components/SasquatchGuide.tsx | 52 +------ src/lib/glossary.ts | 239 ++++++++++++++++++++++++++++++ src/lib/sasquatchBrain.ts | 84 +++++++---- src/pages/GlossaryPage.tsx | 217 +++++++++++++++++++++++++++ 6 files changed, 537 insertions(+), 72 deletions(-) create mode 100644 src/lib/glossary.ts create mode 100644 src/pages/GlossaryPage.tsx diff --git a/src/AppRouter.tsx b/src/AppRouter.tsx index 39e82f2..13887cb 100644 --- a/src/AppRouter.tsx +++ b/src/AppRouter.tsx @@ -56,6 +56,7 @@ const RiderCardPage = lazy(() => import("./pages/RiderCardPage")); const AmberCallbackPage = lazy(() => import("./pages/AmberCallbackPage")); const MarketplacePage = lazy(() => import("./pages/MarketplacePage")); const NewsletterPage = lazy(() => import("./pages/NewsletterPage")); +const GlossaryPage = lazy(() => import("./pages/GlossaryPage")); const NotFound = lazy(() => import("./pages/NotFound")); // Minimal inline fallback — no extra component needed @@ -136,6 +137,8 @@ export function AppRouter() { } /> {/* Newsletter */} } /> + {/* Trick Glossary */} + } /> {/* NIP-19 route for npub1, note1, naddr1, nevent1, nprofile1 */} } /> {/* ADD ALL CUSTOM ROUTES ABOVE THE CATCH-ALL "*" ROUTE */} diff --git a/src/components/Layout.tsx b/src/components/Layout.tsx index a5af17c..08d1bb9 100644 --- a/src/components/Layout.tsx +++ b/src/components/Layout.tsx @@ -49,6 +49,7 @@ import { Radio, Users, SlidersHorizontal, + BookOpen, } from 'lucide-react'; import { OfflineIndicator } from '@/components/OfflineIndicator'; import { UserStatusIndicator } from '@/components/UserStatusIndicator'; @@ -474,6 +475,12 @@ export function Layout({ children, noPadding }: LayoutProps) { ))} + + + + Glossary + + @@ -713,6 +720,13 @@ export function Layout({ children, noPadding }: LayoutProps) { isActive={isActive('/stats')} onClick={() => setMobileMenuOpen(false)} /> + setMobileMenuOpen(false)} + /> { }); } -// ─── OpenRouter free model — no credits, no NIP-98, works for everyone ──────── -const OPENROUTER_API_KEY = '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'; -const OPENROUTER_URL = 'https://openrouter.ai/api/v1/chat/completions'; -const FREE_MODEL = 'meta-llama/llama-3.3-70b-instruct:free'; - -// Try multiple free models in order — if one is rate-limited, fall back to the next -const FREE_MODELS = [ - 'openai/gpt-oss-20b:free', // Fast, reliable - 'google/gemma-4-31b-it:free', // Good fallback - 'meta-llama/llama-3.3-70b-instruct:free', // Best quality, sometimes rate-limited -]; - -async function callAI(messages: Array<{ role: 'user' | 'assistant'; content: string }>): Promise { - let lastError = ''; - for (const model of FREE_MODELS) { - try { - const response = await fetch(OPENROUTER_URL, { - method: 'POST', - headers: { - 'Authorization': `Bearer ${OPENROUTER_API_KEY}`, - 'Content-Type': 'application/json', - 'HTTP-Referer': 'https://skate-spots.org', - 'X-Title': 'SkateSpots', - }, - body: JSON.stringify({ - model, - messages, - max_tokens: 400, - }), - }); - if (!response.ok) { - const err = await response.text(); - console.warn(`[Sasquatch] model ${model} failed ${response.status}:`, err); - lastError = `${response.status}: ${err}`; - continue; // try next model - } - const data = await response.json(); - const content = data.choices?.[0]?.message?.content ?? ''; - if (content) return content; - } catch (err) { - console.warn(`[Sasquatch] model ${model} threw:`, err); - lastError = String(err); - } - } - throw new Error(`All AI models failed. Last error: ${lastError}`); -} +// AI calls route through the shared Sasquatch brain (sasquatchBrain.ts), which +// handles the free-model fallback list + rate-limit backoff/retry in one place. +const callAI = callSasquatchAI; export type SasquatchContext = 'home' | 'onboarding' | 'map' | 'lab'; @@ -597,7 +555,7 @@ Instructions: if (!aiSucceeded) { const fallback = staticAnswer ?? - "The AI is a bit slow right now (free tier rate limits). Try again in a moment, or check the 🗺️ Map for spots, 🧪 The Lab for coaches. 🛹"; + "My brain's catching a lot of traffic right now 🦧 — give it a few seconds and ask again. Meanwhile you can hit the 🗺️ Map for spots, 🧪 The Lab for coaches, or the 📖 Glossary to level up your trick vocab. 🛹"; appendMessage({ role: 'sasquatch', text: fallback, timestamp: Date.now() }); } } catch (err) { diff --git a/src/lib/glossary.ts b/src/lib/glossary.ts new file mode 100644 index 0000000..d0fe2e5 --- /dev/null +++ b/src/lib/glossary.ts @@ -0,0 +1,239 @@ +/** + * Skateboarding trick glossary. + * ───────────────────────────────────────────────────────────────────────────── + * A curated, categorized reference of skate tricks with descriptions and their + * common alternate names (tricks pick up regional / era-specific nicknames). + * + * Static content — no network, no Nostr. Purely a learning reference for the + * community. Keep entries concise and skater-accurate. + */ + +export interface Trick { + /** Canonical name. */ + name: string; + /** Other names the same trick is known by. */ + aka?: string[]; + /** Short, skater-accurate description. */ + desc: string; + /** Difficulty hint for filtering. */ + level?: 'beginner' | 'intermediate' | 'advanced'; +} + +export interface TrickCategory { + id: string; + /** Display title. */ + title: string; + /** One-line description of the category. */ + blurb: string; + /** Emoji used as a lightweight category glyph. */ + glyph: string; + tricks: Trick[]; +} + +export const GLOSSARY: TrickCategory[] = [ + { + id: 'fundamentals', + title: 'Fundamentals', + blurb: 'The building blocks every trick is stacked on.', + glyph: '🛹', + tricks: [ + { name: 'Ollie', desc: 'The foundational jump — pop the tail, slide the front foot, and lift the board with no hands.', level: 'beginner' }, + { name: 'Nollie', aka: ['Nose Ollie'], desc: 'An ollie popped off the nose instead of the tail, riding forward.', level: 'intermediate' }, + { name: 'Fakie Ollie', desc: 'An ollie performed while rolling backwards (fakie stance).', level: 'beginner' }, + { name: 'Switch Ollie', desc: 'An ollie done in your non-natural (switch) stance.', level: 'intermediate' }, + { name: 'Pop Shove-it', aka: ['Pop Shuv', 'Shove-it'], desc: 'Spin the board 180° under your feet without flipping it, with pop.', level: 'beginner' }, + { name: 'Manual', aka: ['Wheelie'], desc: 'Balance rolling on the back two wheels; nose manual uses the front two.', level: 'beginner' }, + { name: 'Powerslide', desc: 'Turn the board perpendicular and slide the wheels to scrub speed.', level: 'beginner' }, + { name: 'Boardslide', desc: 'Slide along an obstacle with the board perpendicular, deck contacting the edge.', level: 'intermediate' }, + ], + }, + { + id: 'flips', + title: 'Flip Tricks', + blurb: 'Tricks where the board rotates around one or more axes.', + glyph: '🌀', + tricks: [ + { name: 'Kickflip', aka: ['Magic Flip (original name)'], desc: 'Flick the board so it flips one full rotation along its length toward the heel side of the toes.', level: 'intermediate' }, + { name: 'Heelflip', desc: 'Like a kickflip but flicked off the heel, flipping the opposite direction.', level: 'intermediate' }, + { name: 'Varial Kickflip', desc: 'A kickflip combined with a backside pop shove-it.', level: 'intermediate' }, + { name: 'Varial Heelflip', desc: 'A heelflip combined with a frontside pop shove-it.', level: 'intermediate' }, + { name: '360 Flip', aka: ['Tre Flip', '360 Kickflip', 'Three-Sixty Flip'], desc: 'A kickflip combined with a 360° backside pop shove-it — a signature street trick.', level: 'advanced' }, + { name: 'Hardflip', desc: 'A frontside pop shove-it combined with a kickflip; the board rotates vertically through the legs.', level: 'advanced' }, + { name: 'Inward Heelflip', desc: 'A backside pop shove-it combined with a heelflip.', level: 'advanced' }, + { name: 'Laser Flip', desc: 'A frontside 360 pop shove-it combined with a heelflip — the mirror of a tre flip.', level: 'advanced' }, + { name: 'Double Kickflip', desc: 'A kickflip that rotates twice before you catch it.', level: 'advanced' }, + ], + }, + { + id: 'shoves', + title: 'Shove-its & Spins', + blurb: 'Board and body rotations without a flip.', + glyph: '🔄', + tricks: [ + { name: 'Shove-it', aka: ['Shuvit'], desc: 'Scoop the board 180° flat under your feet without popping.', level: 'beginner' }, + { name: 'Frontside Shove-it', desc: 'A shove-it where the board spins frontside (toward your toes).', level: 'beginner' }, + { name: '360 Shove-it', aka: ['360 Shuv'], desc: 'A shove-it that spins the board a full 360°.', level: 'intermediate' }, + { name: 'Frontside 180', aka: ['FS 180'], desc: 'An ollie with a 180° body-and-board rotation frontside.', level: 'intermediate' }, + { name: 'Backside 180', aka: ['BS 180'], desc: 'An ollie with a 180° body-and-board rotation backside.', level: 'intermediate' }, + { name: 'Bigspin', desc: 'A 360° board shove-it combined with a 180° body rotation, same direction.', level: 'advanced' }, + { name: 'Bigflip', desc: 'A bigspin with a kickflip added.', level: 'advanced' }, + ], + }, + { + id: 'grinds', + title: 'Grinds', + blurb: 'Sliding along an edge on the trucks.', + glyph: '⚙️', + tricks: [ + { name: '50-50', aka: ['Fifty-Fifty'], desc: 'Grind on both trucks equally along a rail or ledge.', level: 'intermediate' }, + { name: '5-0', aka: ['Five-O'], desc: 'Grind on the back truck only, nose up (like a manual on an edge).', level: 'intermediate' }, + { name: 'Nosegrind', desc: 'Grind on the front truck only, tail up.', level: 'intermediate' }, + { name: 'Crooked Grind', aka: ['K-Grind', 'Crooks'], desc: 'A nosegrind angled so the nose also slides on the edge.', level: 'advanced' }, + { name: 'Smith Grind', desc: 'Back truck grinds while the front dips below the edge, nose pointed down.', level: 'advanced' }, + { name: 'Feeble Grind', desc: 'Back truck grinds with the front truck hanging over the far side, nose down.', level: 'advanced' }, + { name: 'Overcrook', aka: ['Overcrooked'], desc: 'A crooked grind on the far side of the rail with the nose overhanging.', level: 'advanced' }, + { name: 'Salad Grind', desc: 'A 5-0 variation with the back truck grinding and rear wheels on the far side.', level: 'advanced' }, + ], + }, + { + id: 'slides', + title: 'Slides', + blurb: 'Sliding on the deck instead of the trucks.', + glyph: '➖', + tricks: [ + { name: 'Boardslide', desc: 'Slide with the middle of the deck perpendicular across the edge.', level: 'intermediate' }, + { name: 'Lipslide', desc: 'Like a boardslide but the back of the board clears the obstacle first.', level: 'advanced' }, + { name: 'Noseslide', desc: 'Slide on the nose of the board along a ledge.', level: 'intermediate' }, + { name: 'Tailslide', desc: 'Slide on the tail of the board along a ledge.', level: 'advanced' }, + { name: 'Bluntslide', desc: 'Slide with the back wheels up on the ledge and tail on the surface.', level: 'advanced' }, + { name: 'Darkslide', desc: 'Slide upside-down on the grip tape — an advanced flip-in slide.', level: 'advanced' }, + ], + }, + { + id: 'grabs', + title: 'Grabs', + blurb: 'Airborne tricks where a hand grabs the board.', + glyph: '✋', + tricks: [ + { name: 'Indy', aka: ['Indy Grab'], desc: 'Trailing hand grabs the toe edge between the feet.', level: 'intermediate' }, + { name: 'Melon', aka: ['Melancholie'], desc: 'Leading hand grabs the heel edge between the feet.', level: 'intermediate' }, + { name: 'Mute', aka: ['Mute Grab'], desc: 'Leading hand grabs the toe edge; named after deaf skater Chris Weddle.', level: 'intermediate' }, + { name: 'Nosegrab', desc: 'Grab the nose of the board in the air.', level: 'intermediate' }, + { name: 'Tailgrab', desc: 'Grab the tail of the board in the air.', level: 'intermediate' }, + { name: 'Stalefish', desc: 'Trailing hand grabs the heel edge behind the back leg.', level: 'advanced' }, + { name: 'Method', aka: ['Method Air'], desc: 'Heel-edge grab with the board pulled up behind you — a stylish, arched air.', level: 'advanced' }, + { name: 'Nosebone', desc: 'A nose grab with the front leg boned out straight.', level: 'advanced' }, + ], + }, + { + id: 'airs', + title: 'Airs (Vert / Transition)', + blurb: 'Big air tricks off ramps, bowls, and quarterpipes.', + glyph: '🚀', + tricks: [ + { name: 'Air', aka: ['Aerial'], desc: 'Any launch off a transition with the board leaving the coping.', level: 'intermediate' }, + { name: 'Backside Air', desc: 'An air rotating backside out of the transition, usually grabbed.', level: 'advanced' }, + { name: 'Frontside Air', desc: 'An air rotating frontside out of the transition.', level: 'advanced' }, + { name: '540', aka: ['Five-Forty', 'McTwist (with flip)'], desc: 'A one-and-a-half rotation air; the McTwist adds a frontside flip.', level: 'advanced' }, + { name: '900', desc: 'Two-and-a-half rotations — famously landed by Tony Hawk in 1999.', level: 'advanced' }, + { name: 'Judo Air', desc: 'A grab air with the front foot kicked off and out to the side.', level: 'advanced' }, + ], + }, + { + id: 'lip', + title: 'Lip Tricks', + blurb: 'Tricks performed on the coping / lip of a ramp or bowl.', + glyph: '👄', + tricks: [ + { name: 'Rock to Fakie', aka: ['Rock n Roll to Fakie'], desc: 'Ride up, rock the board over the coping, then roll back in fakie.', level: 'intermediate' }, + { name: 'Rock n Roll', desc: 'Rock over the coping then pivot 180° to roll back in forward.', level: 'intermediate' }, + { name: 'Axle Stall', aka: ['50-50 Stall'], desc: 'Stall on both trucks on the coping, then drop back in.', level: 'intermediate' }, + { name: 'Feeble Stall', desc: 'A feeble grind held as a stall on the coping.', level: 'advanced' }, + { name: 'Blunt to Fakie', desc: 'Stall in a blunt position on the coping then come back in fakie.', level: 'advanced' }, + { name: 'Disaster', desc: 'Ollie over the coping so the middle of the board rests on the lip.', level: 'advanced' }, + { name: 'Smith Stall', desc: 'A smith grind held as a stall on the coping before dropping back in.', level: 'advanced' }, + ], + }, + { + id: 'stalls', + title: 'Stalls', + blurb: 'Pausing balanced on an obstacle before re-entering.', + glyph: '⏸️', + tricks: [ + { name: 'Nose Stall', desc: 'Balance stalled on the nose against a ledge or coping.', level: 'intermediate' }, + { name: 'Tail Stall', desc: 'Balance stalled on the tail on the coping.', level: 'intermediate' }, + { name: 'Pivot Stall', desc: 'Stall balanced on the back truck and pivot to re-enter.', level: 'advanced' }, + { name: 'Nose Pickup', aka: ['Nose Stall Pickup'], desc: 'Stall on the nose then pick the board back up to roll away.', level: 'advanced' }, + ], + }, + { + id: 'handplants', + title: 'Handplants & Inverts', + blurb: 'One-hand-on-the-coping inverted tricks — old-school flair.', + glyph: '🤸', + tricks: [ + { name: 'Invert', aka: ['Handplant', 'One-Hand Plant'], desc: 'Plant one hand on the coping and balance inverted with the board grabbed.', level: 'advanced' }, + { name: 'Andrecht', desc: 'A backside invert grabbing heel-side with the plant hand behind.', level: 'advanced' }, + { name: 'Eggplant', desc: 'A frontside invert planting the leading hand.', level: 'advanced' }, + { name: 'Sad Plant', desc: 'A handplant variation with a straightened, "sad" board position.', level: 'advanced' }, + { name: 'Ho-Ho', aka: ['Two-Hand Plant'], desc: 'A handstand-style invert balanced on both hands.', level: 'advanced' }, + ], + }, + { + id: 'boneless', + title: 'Boneless & Foot-Plants', + blurb: 'Grab-and-jump tricks planting a foot on the ground.', + glyph: '🦴', + tricks: [ + { name: 'Boneless', desc: 'Grab the board, plant the front foot on the ground, and jump back on.', level: 'intermediate' }, + { name: 'No Comply', desc: 'Step the front foot off, pop the tail so the board spins/hops, then reload.', level: 'intermediate' }, + { name: 'Bean Plant', desc: 'A boneless variation planting the back foot and grabbing with the leading hand.', level: 'intermediate' }, + { name: 'Nose Bonk', desc: 'Tap the nose off an obstacle mid-line for style.', level: 'beginner' }, + ], + }, + { + id: 'freestyle', + title: 'Freestyle', + blurb: 'Flatground balance and pivot tricks from skate’s roots.', + glyph: '🎪', + tricks: [ + { name: 'Casper', aka: ['Casper Slide'], desc: 'Flip the board upside-down and balance on the tail with one foot under the deck.', level: 'advanced' }, + { name: 'Primo', aka: ['Primo Slide'], desc: 'Balance the board on its side (rail) and stand on it.', level: 'advanced' }, + { name: 'Rail Stand', aka: ['Primo Stand'], desc: 'Stand on the upturned rail of the board — classic freestyle balance.', level: 'advanced' }, + { name: 'Truckstand', desc: 'Balance on one truck with the board tilted vertically.', level: 'advanced' }, + { name: 'Pogo', desc: 'Hop on the tail with the board vertical, holding the nose.', level: 'advanced' }, + { name: 'Handstand', aka: ['Handstand Fingerflip'], desc: 'A handstand on the board, often flipped out — a freestyle staple.', level: 'advanced' }, + ], + }, +]; + +/** Total trick count across all categories (for headers/stats). */ +export const TOTAL_TRICKS = GLOSSARY.reduce((sum, c) => sum + c.tricks.length, 0); + +/** Flat, searchable list joining name + aka + description + category. */ +export interface FlatTrick extends Trick { + categoryId: string; + categoryTitle: string; + categoryGlyph: string; +} + +export const ALL_TRICKS: FlatTrick[] = GLOSSARY.flatMap((cat) => + cat.tricks.map((t) => ({ + ...t, + categoryId: cat.id, + categoryTitle: cat.title, + categoryGlyph: cat.glyph, + })), +); + +/** Case-insensitive search across name, aka, and description. */ +export function searchTricks(query: string): FlatTrick[] { + const q = query.trim().toLowerCase(); + if (!q) return ALL_TRICKS; + return ALL_TRICKS.filter((t) => { + if (t.name.toLowerCase().includes(q)) return true; + if (t.desc.toLowerCase().includes(q)) return true; + if (t.aka?.some((a) => a.toLowerCase().includes(q))) return true; + return false; + }); +} diff --git a/src/lib/sasquatchBrain.ts b/src/lib/sasquatchBrain.ts index ba69540..5275c63 100644 --- a/src/lib/sasquatchBrain.ts +++ b/src/lib/sasquatchBrain.ts @@ -9,43 +9,77 @@ const OPENROUTER_API_KEY = 'sk-or-v1-ce29b4c58cd842212e4b2f1fae7eb31ab85ce93870b const OPENROUTER_URL = 'https://openrouter.ai/api/v1/chat/completions'; // Try multiple free models in order — if one is rate-limited, fall back. +// Slugs verified against OpenRouter's free tier. Ordered by reliability + +// availability so the first pick is usually the one that answers. If any is +// rate-limited (429) we back off briefly then move to the next model. export const FREE_MODELS = [ - 'openai/gpt-oss-20b:free', - 'google/gemma-4-31b-it:free', 'meta-llama/llama-3.3-70b-instruct:free', + 'google/gemma-2-9b-it:free', + 'mistralai/mistral-7b-instruct:free', + 'qwen/qwen-2.5-7b-instruct:free', + 'openai/gpt-oss-20b:free', ]; export type ChatTurn = { role: 'user' | 'assistant'; content: string }; -/** Call the AI with model fallback. Throws only if every model fails. */ +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); + +/** + * Call the AI with model fallback + light retry on rate limits. + * + * Strategy: for each model, try once; on a 429 (rate limit) wait a short, + * increasing beat and retry that same model once before moving on. This + * smooths over the free tier's bursty limits without hammering the API. + * Throws only if every model fails after its retries. + */ export async function callSasquatchAI(messages: ChatTurn[], maxTokens = 400): Promise { let lastError = ''; + for (const model of FREE_MODELS) { - try { - const response = await fetch(OPENROUTER_URL, { - method: 'POST', - headers: { - 'Authorization': `Bearer ${OPENROUTER_API_KEY}`, - 'Content-Type': 'application/json', - 'HTTP-Referer': 'https://skate-spots.org', - 'X-Title': 'SkateSpots', - }, - body: JSON.stringify({ model, messages, max_tokens: maxTokens }), - }); - if (!response.ok) { - const err = await response.text(); - console.warn(`[Sasquatch] model ${model} failed ${response.status}:`, err); - lastError = `${response.status}: ${err}`; - continue; + // Up to 2 attempts per model (initial + 1 retry after backoff on 429). + for (let attempt = 0; attempt < 2; attempt++) { + try { + const response = await fetch(OPENROUTER_URL, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${OPENROUTER_API_KEY}`, + 'Content-Type': 'application/json', + 'HTTP-Referer': 'https://skate-spots.org', + 'X-Title': 'SkateSpots', + }, + body: JSON.stringify({ model, messages, max_tokens: maxTokens }), + }); + + if (response.status === 429 && attempt === 0) { + // Rate limited — respect Retry-After if present, else short backoff. + const retryAfter = Number(response.headers.get('retry-after')); + const waitMs = Number.isFinite(retryAfter) && retryAfter > 0 + ? Math.min(retryAfter * 1000, 2500) + : 600; + console.warn(`[Sasquatch] ${model} rate-limited, retrying in ${waitMs}ms`); + await sleep(waitMs); + continue; // retry same model + } + + if (!response.ok) { + const err = await response.text(); + console.warn(`[Sasquatch] model ${model} failed ${response.status}:`, err); + lastError = `${response.status}: ${err}`; + break; // move to next model + } + + const data = await response.json(); + const content = data.choices?.[0]?.message?.content ?? ''; + if (content) return content; + break; // empty content — try next model + } catch (err) { + console.warn(`[Sasquatch] model ${model} threw:`, err); + lastError = String(err); + break; // network error — try next model } - const data = await response.json(); - const content = data.choices?.[0]?.message?.content ?? ''; - if (content) return content; - } catch (err) { - console.warn(`[Sasquatch] model ${model} threw:`, err); - lastError = String(err); } } + throw new Error(`All AI models failed. Last error: ${lastError}`); } diff --git a/src/pages/GlossaryPage.tsx b/src/pages/GlossaryPage.tsx new file mode 100644 index 0000000..cfc9760 --- /dev/null +++ b/src/pages/GlossaryPage.tsx @@ -0,0 +1,217 @@ +import { useState, useMemo } from 'react'; +import { useSeoMeta } from '@unhead/react'; +import { Layout } from '@/components/Layout'; +import { Input } from '@/components/ui/input'; +import { Badge } from '@/components/ui/badge'; +import { cn } from '@/lib/utils'; +import { + GLOSSARY, + TOTAL_TRICKS, + searchTricks, + type FlatTrick, +} from '@/lib/glossary'; +import { BookOpen, Search, X } from 'lucide-react'; + +const LEVEL_STYLES: Record = { + beginner: 'bg-green-500/15 text-green-400 border-green-500/30', + intermediate: 'bg-amber-500/15 text-amber-400 border-amber-500/30', + advanced: 'bg-red-500/15 text-red-400 border-red-500/30', +}; + +function TrickRow({ trick }: { trick: { name: string; aka?: string[]; desc: string; level?: string } }) { + return ( +
+
+

{trick.name}

+ {trick.level && ( + + {trick.level} + + )} +
+ {trick.aka && trick.aka.length > 0 && ( +

+ a.k.a. + {trick.aka.join(' · ')} +

+ )} +

{trick.desc}

+
+ ); +} + +export default function GlossaryPage() { + const [query, setQuery] = useState(''); + const [activeCat, setActiveCat] = useState(null); + + useSeoMeta({ + title: 'Trick Glossary · SkateSpots', + description: `The complete skateboarding trick glossary — ${TOTAL_TRICKS}+ flips, grinds, slides, grabs, airs, lip tricks, stalls, handplants, freestyle & more, with alternate names.`, + }); + + const isSearching = query.trim().length > 0; + const searchResults: FlatTrick[] = useMemo( + () => (isSearching ? searchTricks(query) : []), + [query, isSearching], + ); + + const visibleCategories = useMemo( + () => (activeCat ? GLOSSARY.filter((c) => c.id === activeCat) : GLOSSARY), + [activeCat], + ); + + return ( + +
+ {/* Hero */} +
+
+
+
+ +
+
+

+ TRICK GLOSSARY +

+

+ {TOTAL_TRICKS} tricks · flips, grinds, slides, grabs, airs, lip & more +

+
+
+ + {/* Search */} +
+ + setQuery(e.target.value)} + placeholder="Search tricks or nicknames — tre flip, crooks, method…" + className="h-11 rounded-xl border-white/10 bg-black/40 pl-9 pr-9 text-white placeholder:text-gray-500 focus-visible:ring-red-500/40" + /> + {query && ( + + )} +
+
+
+ +
+ {/* Category filter pills — hidden while searching */} + {!isSearching && ( +
+ + {GLOSSARY.map((cat) => ( + + ))} +
+ )} + + {/* Search results */} + {isSearching ? ( +
+

+ {searchResults.length} result{searchResults.length === 1 ? '' : 's'} for + “{query}” +

+ {searchResults.length === 0 ? ( +
+

+ No tricks match that. Try a nickname like “tre flip”, “crooks”, or “melon”. +

+
+ ) : ( +
+ {searchResults.map((t) => ( +
+

+ {t.categoryGlyph} {t.categoryTitle} +

+ +
+ ))} +
+ )} +
+ ) : ( + /* Category sections */ +
+ {visibleCategories.map((cat) => ( +
+
+

+ {cat.glyph} + {cat.title} +

+ {cat.tricks.length} tricks +
+

{cat.blurb}

+
+ {cat.tricks.map((t) => ( + + ))} +
+
+ ))} +
+ )} + + {/* Footer note */} +
+

+ Know a trick or nickname we missed? Tricks pick up different names in every + scene — hit up{' '} + {' '} + and let us know. +

+
+
+
+
+ ); +}