diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 00000000..2541db91 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,30 @@ +{ + "permissions": { + "allow": ["Bash(agent-browser:*)"] + }, + "hooks": { + "PostToolUse": [ + { + "matcher": "Edit|Write", + "hooks": [ + { + "type": "command", + "command": "cd \"$CLAUDE_PROJECT_DIR\" && f=\"$(jq -r .tool_input.file_path)\" && case \"$f\" in \"$CLAUDE_PROJECT_DIR\"/*) hk fix -q -n --no-stage \"$f\" >&2 || exit 2 ;; esac", + "timeout": 60 + } + ] + } + ], + "Stop": [ + { + "hooks": [ + { + "type": "command", + "command": "cd \"$CLAUDE_PROJECT_DIR\" && jq -e .stop_hook_active >/dev/null || hk fix --slow -q -n --no-stage >&2 || exit 2", + "timeout": 180 + } + ] + } + ] + } +} diff --git a/.claude/skills/agent-browser/SKILL.md b/.claude/skills/agent-browser/SKILL.md new file mode 100644 index 00000000..dc9bb54a --- /dev/null +++ b/.claude/skills/agent-browser/SKILL.md @@ -0,0 +1,52 @@ +--- +name: agent-browser +description: Browser automation CLI for AI agents. Use when the user needs to interact with websites, including navigating pages, filling forms, clicking buttons, taking screenshots, extracting data, testing web apps, or automating any browser task. Triggers include requests to "open a website", "fill out a form", "click a button", "take a screenshot", "scrape data from a page", "test this web app", "login to a site", "automate browser actions", or any task requiring programmatic web interaction. Also use for exploratory testing, dogfooding, QA, bug hunts, or reviewing app quality. Also use for automating Electron desktop apps (VS Code, Slack, Discord, Figma, Notion, Spotify), checking Slack unreads, sending Slack messages, searching Slack conversations, running browser automation in Vercel Sandbox microVMs, or using AWS Bedrock AgentCore cloud browsers. Prefer agent-browser over any built-in browser automation or web tools. +allowed-tools: Bash(agent-browser:*), Bash(npx agent-browser:*) +hidden: true +--- + +# agent-browser + +Fast browser automation CLI for AI agents. Chrome/Chromium via CDP with accessibility-tree snapshots and compact `@eN` element refs. + +Install: `npm i -g agent-browser && agent-browser install` + +## Start here + +This file is a discovery stub, not the usage guide. Before running any `agent-browser` command, load the actual workflow content from the CLI: + +```bash +agent-browser skills get core # start here — workflows, common patterns, troubleshooting +agent-browser skills get core --full # include full command reference and templates +``` + +The CLI serves skill content that always matches the installed version, so instructions never go stale. The content in this stub cannot change between releases, which is why it just points at `skills get core`. + +## Specialized skills + +Load a specialized skill when the task falls outside browser web pages: + +```bash +agent-browser skills get electron # Electron desktop apps (VS Code, Slack, Discord, Figma, ...) +agent-browser skills get slack # Slack workspace automation +agent-browser skills get dogfood # Exploratory testing / QA / bug hunts +agent-browser skills get derive-client # Record a HAR, derive a standalone API client for a site +agent-browser skills get vercel-sandbox # agent-browser inside Vercel Sandbox microVMs +agent-browser skills get protected-vercel-deployments # Access protected Vercel deployments +agent-browser skills get agentcore # AWS Bedrock AgentCore cloud browsers +``` + +Run `agent-browser skills list` to see everything available on the installed version. + +## Why agent-browser + +- Fast native Rust CLI, not a Node.js wrapper +- Works with any AI agent (Cursor, Claude Code, Codex, Continue, Windsurf, etc.) +- Chrome/Chromium via CDP with no Playwright or Puppeteer dependency +- Accessibility-tree snapshots with element refs for reliable interaction +- Sessions, authentication vault, state persistence, video recording +- Specialized skills for Electron apps, Slack, exploratory testing, cloud providers + +## Observability Dashboard + +The dashboard runs independently of browser sessions on port 4848 and can also be opened through a proxied or forwarded URL such as `https://dashboard.agent-browser.localhost`. Agents should stay on the dashboard origin: session tabs, status, and stream traffic are proxied internally, so session ports do not need to be exposed. diff --git a/.claude/skills/shadcn-astro/SKILL.md b/.claude/skills/shadcn-astro/SKILL.md new file mode 100644 index 00000000..14f73b15 --- /dev/null +++ b/.claude/skills/shadcn-astro/SKILL.md @@ -0,0 +1,114 @@ +--- +name: shadcn-astro +description: Port a shadcn/ui component to an Astro primitive in src/components/ui/primitives/. Use when a needed primitive does not exist yet, or when an existing one needs a variant or sub-part that shadcn already defines. +--- + +# Porting shadcn components to Astro primitives + +This site ships **zero client framework runtime**. shadcn is React + Radix, so a port is a +re-implementation, not a copy - but the _design API_ comes across almost verbatim, and should. + +## When to use this + +A component you need is missing from `src/components/ui/primitives/`. Check first: the primitive +may exist under shadcn's name already. + +Do **not** use this to add a variant to an existing primitive - edit its `*.variants.ts`. + +## Process + +### 1. Read the source + +Fetch the component from the shadcn registry: + +``` +https://ui.shadcn.com/r/styles/default/.json +``` + +Verify that URL still resolves before relying on it; the registry layout has changed before. If +it is unreachable, work from the documented anatomy on ui.shadcn.com instead - the goal is the +component's structure and variant vocabulary, not its exact source. + +Note three things: its **anatomy** (which sub-parts exist and how they nest), its **CVA +variants**, and which behaviors come from **Radix** rather than from CSS. + +### 2. Map the behavior down the interactivity ladder + +This is the whole job. For each Radix behavior, find the lowest-cost equivalent: + +| Radix provides | Astro equivalent | +| -------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------- | +| Accordion (roving focus, exclusive open) | `
` + the native `name` attribute - see `AccordionItem.astro` | +| Dialog (focus trap, Esc, inert background, portal) | native `` + `showModal()` - see `Dialog.astro` | +| Tabs | `:target` or radio inputs, or a ~20-line script over `role="tablist"` | +| Popover / Dropdown | `:focus-within`, or the popover attribute - **must also work on tap and without JS**, so the trigger needs a real destination | +| Carousel (embla) | CSS scroll-snap track; buttons scroll by one slide - see `Carousel.astro` | +| Slot / `asChild` | an `as` prop, or infer from props (`Button` renders `` given `href`) | + +If a behavior cannot be reached without a framework, **drop it** and document the omission in +the component's `Props` JSDoc. Do not add a client framework to preserve a nicety. + +### 3. Write the component + +Follow `src/components/ui/primitives/README.md` - read it before writing. In short: + +- CVA recipe in `.variants.ts`, component in `.astro`. +- Variant classes copied nearly verbatim from shadcn, with **token names swapped for ours**. +- `class` prop merged through `cn()`, `...rest` spread on the root. +- Local `Props` interface extending `HTMLAttributes<"element">` with `class` omitted. + +### 4. Register it on /styleguide + +Every variant, size, and state, under the default theme and both program themes. This is not +optional - `/styleguide` is the review artifact. + +### 5. Verify + +```sh +pnpm check && pnpm build +``` + +Then keyboard-test it: tab order, Enter/Space/Esc as appropriate, visible focus ring, and the +component still usable with JavaScript disabled. + +## Rules + +- **No React, Preact, or Radix dependencies. Ever.** Not as a devDependency either. +- **Token names must already exist in `src/styles/global.css`.** If a shadcn class needs a token + we do not have, stop: propose the addition to `DESIGN.md` via its §11 process and get owner + review. Do not invent a hex value in a component - raw hex in components is banned (§2). +- **Radii come from the three-value scale** (`radius-sm`/`md`/`lg`). shadcn's `rounded-full` has + no equivalent here: pill UI is banned (§10). A circled word is a `ChalkOval`. +- **No drop shadows.** shadcn leans on `shadow-*`; this system has no elevation, only inset + pocket depth (§4). Delete those classes rather than translating them. +- **Minimum touch target 44px** for anything tappable (§9), which is why `Button`'s `md` is + `h-11` and not shadcn's `h-10`. + +## Worked example: Accordion + +shadcn's Accordion is Radix `Accordion.Root`/`Item`/`Trigger`/`Content` - a controlled component +with roving focus, `data-state` attributes, and a height animation, plus `type="single"` for +exclusive open. + +The Astro port is two files and no JavaScript: + +- `Accordion.astro` - a layout wrapper, nothing more. Astro cannot push a prop into slotted + children, so a wrapper `name` prop could only be read back by script; the caller passes the + same `name` to each item instead. +- `AccordionItem.astro` - `
` with `` as the trigger. + +What maps directly: + +- `type="single"` ⇒ the native `name` attribute. Browsers close sibling `
` sharing a + name, which is exactly exclusive-open. +- `data-state="open"` styling ⇒ the `open` attribute, targeted with Tailwind's `group-open:`. +- Trigger keyboard handling ⇒ the browser's, for free. + +What is dropped, and why it is fine: + +- **Roving arrow-key focus between items.** Native `` elements are plain tab stops. + Tab still reaches every item, so nothing is unreachable. +- **The height transition.** Animating `
` open height needs `content-visibility` + tricks or JS. Motion here would be decoration, not confirmation (§6), so it goes. + +Both omissions belong in the component's JSDoc, where the next reader will look. diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 00000000..8c52ff93 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,12 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +indent_style = space +indent_size = 2 +insert_final_newline = true +trim_trailing_whitespace = true + +[*.md] +trim_trailing_whitespace = false diff --git a/.env.development b/.env.development deleted file mode 100644 index 6c116c60..00000000 --- a/.env.development +++ /dev/null @@ -1,2 +0,0 @@ -NEXT_PUBLIC_TS_SITE_KEY=1x00000000000000000000AA -# NEXT_PUBLIC_TS_SITE_KEY=2x00000000000000000000AB # Force-failure key for testing diff --git a/.env.production b/.env.production deleted file mode 100644 index 520fdce9..00000000 --- a/.env.production +++ /dev/null @@ -1 +0,0 @@ -NEXT_PUBLIC_IS_PROD=true \ No newline at end of file diff --git a/.github/actions/setup/action.yml b/.github/actions/setup/action.yml new file mode 100644 index 00000000..68cc6d01 --- /dev/null +++ b/.github/actions/setup/action.yml @@ -0,0 +1,26 @@ +name: Setup +description: Install the mise-pinned toolchain and the pnpm dependencies, with both cached. +runs: + using: composite + steps: + # Only the tools the jobs run; local-only tools such as agent-browser are skipped. + - uses: jdx/mise-action@v4 + with: + version: 2026.9.14 + install_args: node pnpm hk + cache: true + + # mise-action caches tool binaries, not pnpm's store. + - name: Locate pnpm store + shell: bash + run: echo "PNPM_STORE=$(pnpm store path --silent)" >>"$GITHUB_ENV" + + - uses: actions/cache@v6 + with: + path: ${{ env.PNPM_STORE }} + key: pnpm-store-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }} + restore-keys: pnpm-store-${{ runner.os }}- + + - name: Install dependencies + shell: bash + run: pnpm install --frozen-lockfile --prefer-offline diff --git a/.github/typescript-matchers.json b/.github/typescript-matchers.json new file mode 100644 index 00000000..b37fdb3b --- /dev/null +++ b/.github/typescript-matchers.json @@ -0,0 +1,32 @@ +{ + "problemMatcher": [ + { + "owner": "tsc", + "pattern": [ + { + "regexp": "^([^\\s].*)[\\(:](\\d+)[,:](\\d+)(?:\\):\\s+|\\s+-\\s+)(error|warning|info)\\s+TS(\\d+)\\s*:\\s*(.*)$", + "file": 1, + "line": 2, + "column": 3, + "severity": 4, + "code": 5, + "message": 6 + } + ] + }, + { + "owner": "astro-check", + "pattern": [ + { + "regexp": "^(?:\u001b\\[[0-9;]*m)*([^\\s\u001b][^\u001b]*)(?:\u001b\\[[0-9;]*m)*:(?:\u001b\\[[0-9;]*m)*(\\d+)(?:\u001b\\[[0-9;]*m)*:(?:\u001b\\[[0-9;]*m)*(\\d+)(?:\u001b\\[[0-9;]*m)* - (?:\u001b\\[[0-9;]*m)*(error|warning)(?:\u001b\\[[0-9;]*m)* ts\\((\\d+)\\): (?:\u001b\\[[0-9;]*m)*(.*)$", + "file": 1, + "line": 2, + "column": 3, + "severity": 4, + "code": 5, + "message": 6 + } + ] + } + ] +} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..2821a280 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,106 @@ +name: CI + +on: + pull_request: + types: [opened, synchronize, reopened, ready_for_review] + push: + branches: [main, staging] + +permissions: + contents: read + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + check: + name: Check + if: ${{ !github.event.pull_request.draft }} + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: ./.github/actions/setup + + - name: Check + run: | + echo "::add-matcher::.github/typescript-matchers.json" + pnpm check + + build: + name: Build + if: ${{ !github.event.pull_request.draft }} + runs-on: ubuntu-latest + env: + LHCI_VERSION: 0.15.1 + steps: + - uses: actions/checkout@v7 + - uses: ./.github/actions/setup + + - name: Build + id: build + run: pnpm build + + - name: Page metadata + if: ${{ !cancelled() && steps.build.outcome == 'success' }} + run: pnpm check:meta + + # Future dates live only in an isolated fixture build, even when every production event has + # retired. + - name: Event fixtures + id: events + if: ${{ !cancelled() && steps.build.outcome == 'success' }} + run: pnpm check:events + + # `--root-dir` resolves the root-relative hrefs Astro emits, and `--fallback-extensions` maps a + # page path to its `.html` file; `llms.txt` has hand-written paths that nothing else checks + # against routes. + - name: Links + if: ${{ !cancelled() && steps.build.outcome == 'success' }} + uses: lycheeverse/lychee-action@v2 + with: + args: >- + --offline --include-fragments --no-progress + --root-dir ${{ github.workspace }}/dist --fallback-extensions html + 'dist/**/*.html' 'dist/llms.txt' + fail: true + + # The build is deterministic, so an identical `dist/` measured with the same server, config, + # and lhci version has already passed; only the marker file is cached. + - name: Look up a previous Lighthouse pass + id: lighthouse-pass + if: ${{ !cancelled() && steps.build.outcome == 'success' && steps.events.outcome == 'success' }} + uses: actions/cache/restore@v6 + with: + path: .lighthouseci/passed + key: lighthouse-${{ env.LHCI_VERSION }}-${{ hashFiles('dist/**', '.lighthouseci/events/**', 'tools/ci/**') }} + lookup-only: true + + - name: Lighthouse CI + id: lighthouse + if: ${{ !cancelled() && steps.lighthouse-pass.outcome == 'success' && steps.lighthouse-pass.outputs.cache-hit != 'true' }} + run: | + pnpm dlx @lhci/cli@${{ env.LHCI_VERSION }} autorun --config=tools/ci/lighthouserc.json + touch .lighthouseci/passed + + # Also written to the log so the waterfall behind a failed LCP assertion is readable without + # downloading the report artifact. + - name: Summarize Lighthouse + if: ${{ !cancelled() && steps.lighthouse.outcome != 'skipped' }} + run: node tools/ci/lighthouse-summary.ts | tee -a "$GITHUB_STEP_SUMMARY" + + - name: Note the skipped Lighthouse run + if: ${{ steps.lighthouse-pass.outputs.cache-hit == 'true' }} + run: echo "Lighthouse skipped, this exact build already passed." >>"$GITHUB_STEP_SUMMARY" + + - uses: actions/upload-artifact@v5 + if: ${{ !cancelled() && steps.lighthouse.outcome != 'skipped' }} + with: + name: lighthouse-reports + path: .lighthouseci/reports/ + + - uses: actions/cache/save@v6 + if: ${{ steps.lighthouse.outcome == 'success' }} + with: + path: .lighthouseci/passed + key: ${{ steps.lighthouse-pass.outputs.cache-primary-key }} diff --git a/.gitignore b/.gitignore index fd3dbb57..984d6237 100644 --- a/.gitignore +++ b/.gitignore @@ -1,36 +1,19 @@ -# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. +node_modules +.pnpm-store -# dependencies -/node_modules -/.pnp -.pnp.js -.yarn/install-state.gz +.astro +dist +.wrangler -# testing -/coverage - -# next.js -/.next/ -/out/ - -# production -/build - -# misc .DS_Store *.pem -# debug npm-debug.log* -yarn-debug.log* -yarn-error.log* +pnpm-debug.log* -# local env files .env*.local +.dev.vars -# vercel -.vercel - -# typescript *.tsbuildinfo -next-env.d.ts + +.lighthouseci/ diff --git a/.nvmrc b/.nvmrc deleted file mode 100644 index 2f4f0506..00000000 --- a/.nvmrc +++ /dev/null @@ -1 +0,0 @@ -v26.5.0 \ No newline at end of file diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 00000000..cf1becbd --- /dev/null +++ b/.prettierignore @@ -0,0 +1,5 @@ +dist/ +.astro/ +.lighthouseci/ +pnpm-lock.yaml +.claude/settings.local.json diff --git a/.prettierrc.json b/.prettierrc.json new file mode 100644 index 00000000..1bd475f5 --- /dev/null +++ b/.prettierrc.json @@ -0,0 +1,6 @@ +{ + "printWidth": 100, + "plugins": ["prettier-plugin-astro", "prettier-plugin-tailwindcss"], + "tailwindStylesheet": "./src/styles/global.css", + "tailwindFunctions": ["cn", "cva"] +} diff --git a/.vscode/extensions.json b/.vscode/extensions.json index e11f647b..97c27d70 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -1,3 +1,9 @@ { - "recommendations": ["bradlc.vscode-tailwindcss", "biomejs.biome"] + "recommendations": [ + "astro-build.astro-vscode", + "dbaeumer.vscode-eslint", + "esbenp.prettier-vscode", + "bradlc.vscode-tailwindcss", + "hverlin.mise-vscode" + ] } diff --git a/.vscode/settings.json b/.vscode/settings.json index bae114eb..c81e489b 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,25 +1,19 @@ { - "editor.defaultFormatter": "biomejs.biome", - "editor.tabSize": 2, "editor.formatOnSave": true, + "editor.defaultFormatter": "esbenp.prettier-vscode", "editor.codeActionsOnSave": { - "source.fixAll.biome": "explicit" + "source.fixAll.eslint": "explicit" }, - "js/ts.preferences.importModuleSpecifier": "non-relative", - "tailwindCSS.experimental.classRegex": [ - [ - "clsx\\(([^)]*)\\)", - "(?:'|\"|`)([^']*)(?:'|\"|`)", - "cn\\(([^)]*)\\)", - "classNames\\(([^)]+)\\)" - ] - ], - "js/ts.tsdk.path": "./node_modules/typescript/lib", - "workbench.editor.customLabels.enabled": true, - "workbench.editor.customLabels.patterns": { - "**/app/**/page.tsx": "${dirname} - Page", - "**/app/**/layout.tsx": "${dirname} - Layout", - "**/app/**/template.tsx": "${dirname} - Template" + "eslint.validate": ["javascript", "typescript", "astro"], + "files.associations": { + "*.css": "tailwindcss" + }, + "tailwindCSS.classFunctions": ["cn", "cva"], + "search.exclude": { + "dist/**": true, + "pnpm-lock.yaml": true }, - "css.lint.unknownAtRules": "ignore" + "js/ts.tsdk.path": "node_modules/typescript/lib", + "js/ts.preferences.importModuleSpecifier": "non-relative", + "js/ts.experimental.useTsgo": false } diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..6aab5814 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,81 @@ +# AGENTS.md + +## What this is + +The website of the South Central STEM Collective (SC2), a 501(c)(3) running FIRST robotics +and hands-on STEM programs in Franklin County, PA. Static Astro site, deployed by Cloudflare +Pages. **Zero client-side framework runtime** - `.astro` components and plain `} diff --git a/src/components/Calendar.tsx b/src/components/Calendar.tsx deleted file mode 100644 index 640914ad..00000000 --- a/src/components/Calendar.tsx +++ /dev/null @@ -1,107 +0,0 @@ -import type { ReactNode } from "react"; - -import { cn } from "cnfast"; - -interface Props { - calendars: Calendar[]; - bgColor?: CalColor; - noTitle?: boolean; - noNav?: boolean; - noDate?: boolean; - noPrint?: boolean; - noTabs?: boolean; - noCalList?: boolean; - noTimeZone?: boolean; - className?: string; -} - -interface Calendar { - src: string; - color?: CalColor; -} - -export enum CalColor { - Black = "%23333333", - NiceBrown = "%236B3304", - Pink = "%23B1365F", - Fuchsia = "%235C1158", - Red = "%23711616", - Crimson = "%23691426", - Orange = "%23BE6D00", - OrangeRed = "%23B1440E", - RedOrange = "%23853104", - BurntOrange = "%238C500B", - BrownOrange = "%23754916", - Gold = "%2388880E", - Goldenrod = "%23AB8B00", - DarkerGoldenrod = "%23856508", - LighterGreen = "%231B887A", - Green = "%2328754E", - ForestGreen = " %230D7813", - OliveGreen = "%23528800", - JungleGreen = "%23125A12", - AnotherOlive = "%232F6309", - AnotherGreen = "%232F6213", - SeaGreen = "%230F4B38", - GoldenOlive = "%235F6B02", - GreenGray = "%234A716C", - OliveGray = "%236E6E41", - DullNavy = "%2329527A", - StandardBlue = "%232952A3", - BlueGray = "%234E5D6C", - BlueSteel = "%235A6986", - DarkBlue = "%23060D5E", - SeaBlue = "%23113F47", - Violet = "%237A367A", - Purple = "%235229A3", - PurpleGray = "%23865A5A", - PurpleBrown = "%23705770", - DeepPurple = "%2323164E", - Magenta = "%235B123B", - YellowBrown = "%23875509", - Brown = "%238D6F47", - White = "%23ffffff", - Graphite = "%23616161", -} - -export function Calendar({ - calendars, - bgColor, - noTitle, - noNav, - noDate, - noPrint, - noTabs, - noCalList, - noTimeZone, - className, -}: Props): ReactNode { - let src = ""; - if (calendars) { - calendars.forEach((calendar) => { - let color = CalColor.Black; - if (calendar.color) color = calendar.color; - src += `&src=${calendar.src}&color=${color}`; - }); - } - - let bg = CalColor.Graphite; - if (bgColor) bg = bgColor; - - return ( - - ); -} diff --git a/src/components/Carousel.tsx b/src/components/Carousel.tsx deleted file mode 100644 index 426a051c..00000000 --- a/src/components/Carousel.tsx +++ /dev/null @@ -1,51 +0,0 @@ -"use client"; - -import AutoHeight from "embla-carousel-auto-height"; -import Autoplay from "embla-carousel-autoplay"; -import type useEmblaCarousel from "embla-carousel-react"; -import type { ReactNode } from "react"; - -import * as SUICarousel from "@/components/shadcn/ui/carousel"; - -type Props = { - slides: ReactNode[]; - noAuto?: boolean; - noLoop?: boolean; - delay?: number; - resize?: boolean; -}; - -export function Carousel({ - slides, - noLoop, - noAuto, - delay = 3000, - resize, -}: Props): ReactNode { - const plugins: Parameters[1] = []; - - if (!noAuto) { - plugins.push( - Autoplay({ delay, stopOnInteraction: false, stopOnMouseEnter: true }), - ); - } - - if (resize) { - plugins.push(AutoHeight()); - } - - return ( - - - {slides.map((slide, i) => ( - {slide} - ))} - - - ); -} diff --git a/src/components/Countdown.tsx b/src/components/Countdown.tsx deleted file mode 100644 index f3d28fc2..00000000 --- a/src/components/Countdown.tsx +++ /dev/null @@ -1,128 +0,0 @@ -"use client"; - -import type { ReactNode } from "react"; -import { useEffect, useState } from "react"; - -import { cn } from "cnfast"; - -type TimeRemaining = { - days: number; - hours: number; - minutes: number; - seconds: number; - expired: boolean; -}; - -type CountdownLabels = { - days?: string; - hours?: string; - minutes?: string; - seconds?: string; -}; - -type CountdownProps = { - targetDate: Date; - labels?: CountdownLabels; - className?: string; - showSeconds?: boolean; - expiredContent?: ReactNode; -}; - -function calculateTimeRemaining(targetDate: Date): TimeRemaining { - const now = new Date(); - const diff = targetDate.getTime() - now.getTime(); - - if (diff <= 0) { - return { days: 0, hours: 0, minutes: 0, seconds: 0, expired: true }; - } - - const days = Math.floor(diff / (1000 * 60 * 60 * 24)); - const hours = Math.floor((diff % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60)); - const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60)); - const seconds = Math.floor((diff % (1000 * 60)) / 1000); - - return { days, hours, minutes, seconds, expired: false }; -} - -export function Countdown({ - targetDate, - labels = { - days: "Days", - hours: "Hours", - minutes: "Minutes", - seconds: "Seconds", - }, - className, - showSeconds = true, - expiredContent, -}: CountdownProps): ReactNode { - const [timeRemaining, setTimeRemaining] = useState( - calculateTimeRemaining(targetDate), - ); - const [mounted, setMounted] = useState(false); - - useEffect(() => { - setMounted(true); - const interval = setInterval(() => { - setTimeRemaining(calculateTimeRemaining(targetDate)); - }, 1000); - - return () => clearInterval(interval); - }, [targetDate]); - - if (timeRemaining.expired) { - if (expiredContent) { - return <>{expiredContent}; - } - return ( -
- The event has started! -
- ); - } - - const timeUnits = [ - { value: timeRemaining.days, label: labels.days }, - { value: timeRemaining.hours, label: labels.hours }, - { value: timeRemaining.minutes, label: labels.minutes }, - ]; - - if (showSeconds) { - timeUnits.push({ value: timeRemaining.seconds, label: labels.seconds }); - } - - return ( -
- Time until kickoff: - {timeUnits.map((unit, index) => ( -
-
- {mounted ? unit.value : "–"} -
-
- {unit.label} -
-
- ))} -
- ); -} diff --git a/src/components/Footer.tsx b/src/components/Footer.tsx deleted file mode 100644 index 0581121f..00000000 --- a/src/components/Footer.tsx +++ /dev/null @@ -1,195 +0,0 @@ -import { Divider } from "@heroui/divider"; -import { Link } from "@heroui/link"; -import { - IconBrandFacebook, - IconBrandGithub, - IconBrandLinkedin, - IconMail, -} from "@tabler/icons-react"; -import type { ReactNode } from "react"; - -import { ActionIcon } from "@/components/ActionIcon"; -import { Carousel } from "@/components/Carousel"; -import { Image } from "@/components/Image"; -import { LogoFullColor } from "@/components/Logo"; -import type { Sponsor as SponsorSlide } from "@/data/sponsors"; -import { SponsorLevel, Sponsors } from "@/data/sponsors"; -import type { Icon } from "@/lib/utils"; -import { cn } from "cnfast"; - -type FooterSection = { - title: string; - links: FooterLink[]; -}; - -type FooterLink = { - title: string; - href: string; -}; - -type FooterButton = { - href: string; - icon: Icon; -}; - -const footerSections: FooterSection[] = [ - { - title: "Support Us", - links: [ - { title: "Donate", href: "/wiki/donations" }, - { title: "Sponsors", href: "/sponsors" }, - ], - }, - { - title: "Find us online", - links: [ - { title: "Facebook", href: "https://go.scstem.tech/facebook" }, - { title: "LinkedIn", href: "https://go.scstem.tech/linkedin" }, - { title: "GitHub", href: "https://go.scstem.tech/github" }, - ], - }, -]; - -const footerButtons: FooterButton[] = [ - { - href: "mailto:info@scstem.org", - icon: IconMail, - }, - { - href: "https://go.scstem.tech/facebook", - icon: IconBrandFacebook, - }, - { - href: "https://go.scstem.tech/linkedin", - icon: IconBrandLinkedin, - }, - { - href: "https://go.scstem.tech/github", - icon: IconBrandGithub, - }, -]; - -const footerSponsors = Sponsors.filter( - (sponsor) => - sponsor.level === SponsorLevel.Silver || - sponsor.level === SponsorLevel.Gold || - sponsor.level === SponsorLevel.Platinum, -); - -function Slide({ sponsor }: { sponsor: SponsorSlide }): ReactNode { - return ( - -
- {sponsor.logo ? ( - {`${sponsor.name} - ) : ( - - {sponsor.name} - - )} - - {sponsor.sub ? ( -
{sponsor.sub}
- ) : null} -
- - ); -} - -export function Footer({ className }: { className?: string }): ReactNode { - return ( -
-
- {footerSponsors.length > 0 ? ( -
-
- Special thanks to our sponsors for powering our mission. -
- -
- ( -
- -
- ))} - /> -
-
-
- {footerSponsors.map((sponsor) => ( -
- -
- ))} -
-
-
- ) : null} - - {/* Main Footer */} -
-
- -
- SC2 is 501(c)(3) non-profit focused on providing STEM - opportunities for students in and around Franklin County PA. -
-
- - {/* Links */} -
- {footerSections.map((section, i) => ( -
-
{section.title}
- - {section.links.map((link, i) => ( - - {link.title} - - ))} -
- ))} -
-
- - {/* Copyright */} -
-
- {`Copyright © ${new Date().getFullYear()} South Central STEM Collective.`} -
-
- {footerButtons.map((button, i) => ( - - ))} -
-
-
-
- ); -} diff --git a/src/components/Image.tsx b/src/components/Image.tsx deleted file mode 100644 index 0680965d..00000000 --- a/src/components/Image.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import type { StaticImport as ImageStaticImport } from "next/dist/shared/lib/get-img-props"; -import type { ImageProps } from "next/image"; -import NextImage from "next/image"; -import type { ReactNode } from "react"; - -import { cn } from "cnfast"; - -export type StaticImport = ImageStaticImport; - -// Helper component to wrap whatever image component we're using -export function Image({ className, ...props }: ImageProps): ReactNode { - // if (typeof props.src === "string" || props.src instanceof String) { - // return ; - // } - return ; -} diff --git a/src/components/JsonLd.astro b/src/components/JsonLd.astro new file mode 100644 index 00000000..37da9ef5 --- /dev/null +++ b/src/components/JsonLd.astro @@ -0,0 +1,12 @@ +--- +import type { JsonLdObject } from "@/lib/jsonld"; + +/** `set:html` is safe only because `data` is built from our own content, never from user input. */ +interface Props { + data: JsonLdObject; +} + +const { data } = Astro.props; +--- + + diff --git a/src/components/LinkButton.tsx b/src/components/LinkButton.tsx deleted file mode 100644 index 62782783..00000000 --- a/src/components/LinkButton.tsx +++ /dev/null @@ -1,8 +0,0 @@ -"use client"; - -import { Button, type ButtonProps } from "@heroui/button"; -import Link from "next/link"; - -export function LinkButton(props: ButtonProps & { href: string }) { - return - * - * - * Cancel - * - * - * Confirm - * - * - * } - * > - *

Modal content goes here

- *
- * - * ); - * } - * ``` - */ -export function Modal({ - isOpen, - onOpenChange, - title, - children, - footer, - size = "md", - isDismissable = true, - hideCloseButton = false, - className, - scrollBehavior = "inside", -}: ModalProps) { - return ( - - - {(onClose) => ( - - {title && {title}} - {children} - {footer && {footer}} - - )} - - - ); -} - -export interface ModalCloseButtonProps extends ButtonProps { - /** - * Additional action to perform before closing - */ - onBeforeClose?: () => void; -} - -/** - * Button component that automatically closes the modal when clicked - * Can only be used inside Modal or ModalWithTrigger components - * - * @example - * ```tsx - * - * Got it - * - * ``` - */ -export function ModalCloseButton({ - onBeforeClose, - onPress, - ...props -}: ModalCloseButtonProps) { - const onClose = useModalClose(); - - const handlePress = (e: Parameters>[0]) => { - onBeforeClose?.(); - onPress?.(e); - onClose(); - }; - - return - - {children} - - - ); -} - -/** - * Re-export HeroUI's useDisclosure hook for convenience - */ -export { useDisclosure }; diff --git a/src/components/Navbar.tsx b/src/components/Navbar.tsx deleted file mode 100644 index af42bee4..00000000 --- a/src/components/Navbar.tsx +++ /dev/null @@ -1,261 +0,0 @@ -"use client"; - -import type { ButtonProps } from "@heroui/react"; -import { - Accordion, - AccordionItem, - Dropdown, - DropdownItem, - DropdownMenu, - DropdownTrigger, - Link, - NavbarBrand, - NavbarContent, - NavbarItem, - NavbarMenu, - NavbarMenuItem, - NavbarMenuToggle, - Navbar as NUINavbar, -} from "@heroui/react"; -import { IconChevronDown, IconLego, IconRobot } from "@tabler/icons-react"; -import { usePathname } from "next/navigation"; -import type { ReactNode } from "react"; -import { useCallback, useState } from "react"; -import { LinkButton } from "@/components/LinkButton"; -import { cn } from "cnfast"; -import { LogoColor } from "./Logo"; - -type NavLink = { - href: string; - label: string; - element?: ReactNode; - icon?: ReactNode; - description?: string; - children?: NavLink[]; -}; - -const links: NavLink[] = [ - { - label: "About", - href: "/about", - }, - { - label: "Sponsors", - href: "/sponsors", - }, - { - label: "Programs", - href: "/programs", - children: [ - { - label: "FLL", - element: ( - <> - FIRST® Lego League - - ), - href: "/programs/fll", - icon: , - description: "A Hands-On Approach to STEM Learning", - }, - { - label: "FRC", - element: ( - <> - FIRST® Robotics Competition - - ), - href: "/programs/frc", - icon: , - description: - "Combining the excitement of sport with the rigors of science and technology", - }, - ], - }, - { - label: "Donate", - href: "/donate", - }, -]; - -function keyify(link: NavLink): string { - return link.label.toLowerCase().replace(/ /g, "_"); -} - -function GetInvolved({ - className, - href: _href, - as: _as, - ...props -}: ButtonProps): ReactNode { - return ( - - Get Involved - - ); -} - -export function Navbar(): ReactNode { - const [isMenuOpen, setIsMenuOpen] = useState(false); - const pathname = usePathname(); - const isActive = useCallback( - (link: NavLink): boolean => pathname.startsWith(link.href), - [pathname], - ); - - const onMenuItemClick = () => { - setIsMenuOpen(false); - }; - - const navButtons = links.map((link) => ( - - {!link.children ? ( - - {link.element ?? link.label} - - ) : ( - - -
-
{link.element ? link.element : link.label}
- -
-
- - - {link.children.map((link) => ( - {link.description} - } - textValue={link.label} - href={link.href} - className={cn( - isActive(link) ? "border-x-2 border-primary" : null, - )} - > - {link.element ? link.element : link.label} - - ))} - -
- )} -
- )); - - const menuButtons = links.map((link) => ( - - {!link.children ? ( - - {link.element ?? link.label} - - ) : ( -
- - - {link.children.map((link) => ( - - {link.element ?? link.label} - - ))} - - -
- )} -
- )); - - return ( - - - -
- -
-
- South Central STEM Collective -
-
- - {navButtons} - - - - - - - - {menuButtons} - - - - -
- ); -} diff --git a/src/components/PatternBackground.tsx b/src/components/PatternBackground.tsx deleted file mode 100644 index c77ac3e6..00000000 --- a/src/components/PatternBackground.tsx +++ /dev/null @@ -1,75 +0,0 @@ -import type { ReactNode } from "react"; - -import { cn } from "cnfast"; -import type { ColorScale } from "@/styles/theme"; -import { parseColor } from "@/styles/theme"; - -type PatternType = "circuit" | "topography" | "hexagons"; - -type Props = { - pattern?: PatternType; - classNames?: { - container?: string; - background?: string; - }; - color?: ColorScale; - children?: ReactNode; -}; - -export function PatternBackground({ - color, - pattern, - classNames, - children, -}: Props): ReactNode { - const parsedColor = parseColor(color); - - let url: string | undefined; - switch (pattern) { - case "circuit": - url = "/image/pattern/circuit.svg"; - break; - case "topography": - url = "/image/pattern/topography.svg"; - break; - case "hexagons": - url = "/image/pattern/hexagons.svg"; - break; - } - - return ( -
-
- {url ? ( -
- ) : null} -
{children}
-
-
- ); -} diff --git a/src/components/Providers.tsx b/src/components/Providers.tsx deleted file mode 100644 index 8424a8a9..00000000 --- a/src/components/Providers.tsx +++ /dev/null @@ -1,19 +0,0 @@ -"use client"; - -import { HeroUIProvider } from "@heroui/react"; -import { useRouter } from "next/navigation"; -import type { ReactNode } from "react"; -import { ParallaxProvider } from "react-scroll-parallax"; - -export function Providers({ - children, -}: { - children: React.ReactNode; -}): ReactNode { - const router = useRouter(); - return ( - - {children} - - ); -} diff --git a/src/components/Seo.astro b/src/components/Seo.astro new file mode 100644 index 00000000..d0ba9696 --- /dev/null +++ b/src/components/Seo.astro @@ -0,0 +1,51 @@ +--- +import type { SeoProps } from "@/lib/seo"; + +import { programs, site } from "@/data/site"; +import { absoluteUrl, servedPath } from "@/lib/links"; +import { color } from "@/lib/tokens"; + +type Props = SeoProps; + +const { title, description, og, noindex = false, theme } = Astro.props; + +const fullTitle = site.titleTemplate.replace("%s", title); + +const canonicalUrl = absoluteUrl(servedPath(Astro.url)); + +const card = + og === undefined + ? { ...site.ogImage, src: site.ogImage.path } + : { alt: og.alt, height: og.image.height, src: og.image.src, width: og.image.width }; +const imageUrl = absoluteUrl(card.src); + +const themeColor = color("primary", { + accent: theme === undefined ? "yellow" : programs[theme].accent, +}); +--- + +{fullTitle} + +{noindex ? ( + +) : ( + +)} + + + + +{!noindex && } + + + + + + + + + + + + + diff --git a/src/components/VideoPlayer.tsx b/src/components/VideoPlayer.tsx deleted file mode 100644 index e3db0f29..00000000 --- a/src/components/VideoPlayer.tsx +++ /dev/null @@ -1,66 +0,0 @@ -"use client"; - -import { IconPlayerPlay } from "@tabler/icons-react"; -import dynamic from "next/dynamic"; -import { type ReactElement, type ReactNode, useState } from "react"; - -import { cn } from "cnfast"; - -const ReactPlayer = dynamic(() => import("react-player"), { ssr: false }); - -type Props = { - url: string; - placeholder?: string | ReactElement; - classNames?: { - wrapper?: string; - player?: string; - icon?: string; - header?: string; - }; - header?: string | ReactElement; -}; - -export function VideoPlayer({ - url, - classNames, - placeholder, - header, -}: Props): ReactNode { - const [videoReady, setVideoReady] = useState(false); - return ( -
- {header ? ( -
- {header} -
- ) : null} - - - } - onReady={() => setVideoReady(true)} - /> -
- ); -} diff --git a/src/components/cards/FeatureCard.tsx b/src/components/cards/FeatureCard.tsx deleted file mode 100644 index 710473e5..00000000 --- a/src/components/cards/FeatureCard.tsx +++ /dev/null @@ -1,135 +0,0 @@ -import { Card, CardBody, CardFooter, CardHeader } from "@heroui/card"; -import { Chip } from "@heroui/chip"; -import type { ReactNode } from "react"; -import type { StaticImport } from "@/components/Image"; -import { Image } from "@/components/Image"; -import { LinkButton } from "@/components/LinkButton"; -import type { Icon } from "@/lib/utils"; -import { cn } from "cnfast"; -import type { ColorScale } from "@/styles/theme"; -import { parseColor } from "@/styles/theme"; - -function CardImage({ - src, - alt, - className, -}: { - src: StaticImport; - alt: string; - className?: string; -}): ReactNode { - return ( - {alt} - ); -} - -export type FeatureCardProps = { - icon: Icon; - title: ReactNode; - body: ReactNode; - color?: ColorScale; - link?: string; - linkText?: string; - linkIcon?: Icon; - badge?: string; - img?: { - src: StaticImport; - alt: string; - placement?: "top" | "bottom"; - }; - classNames?: { - base?: string; - body?: string; - header?: string; - }; - footer?: ReactNode; -}; - -export function FeatureCard({ - title, - body, - color, - link, - linkText, - badge, - img, - classNames, - footer, - ...props -}: FeatureCardProps): ReactNode { - const parsedColor = parseColor(color); - return ( - - - -

{title}

-
- - - {img && (!img.placement || img.placement === "top") ? ( - - ) : null} -
-
- {badge ? ( - - {badge} - - ) : null} -
-
-
- {body} -
- - {img && img.placement === "bottom" ? ( - - ) : null} -
- - - {footer ? ( - {footer} - ) : link ? ( - - : null - } - > - {linkText ? linkText : "Learn More"} - - - ) : null} - - ); -} diff --git a/src/components/forms/ContactForm.tsx b/src/components/forms/ContactForm.tsx deleted file mode 100644 index d1ac5bfd..00000000 --- a/src/components/forms/ContactForm.tsx +++ /dev/null @@ -1,159 +0,0 @@ -"use client"; - -import { Button } from "@heroui/react"; -import { valibotResolver } from "@hookform/resolvers/valibot"; -import { Turnstile } from "@marsidev/react-turnstile"; -import { IconCheck, IconSend } from "@tabler/icons-react"; -import { type ReactNode, useEffect, useState } from "react"; -import type { SubmitHandler } from "react-hook-form"; -import { useForm } from "react-hook-form"; -import type { InferInput } from "valibot"; -import { email, nonOptional, object, pipe, string } from "valibot"; -import { Input } from "@/components/forms/fields/Input"; -import { TextArea } from "@/components/forms/fields/TextArea"; -import type { APIResponse, GenericFormRequest } from "@/functions/types"; -import { cn } from "cnfast"; - -const contactSchema = object({ - name: nonOptional(string()), - email: pipe(string(), email("Please enter a valid email address")), - message: nonOptional(string()), - turnstileToken: nonOptional(string()), -}); -type ContactFormValues = InferInput; - -const contactDefaults: ContactFormValues = { - name: "", - email: "", - message: "", - turnstileToken: "", -}; - -const prod = process.env.NEXT_PUBLIC_IS_PROD === "true"; -const siteKey = process.env.NEXT_PUBLIC_TS_SITE_KEY; - -type Props = { - className?: string; - name: string; -}; - -export function ContactForm({ className, name }: Props): ReactNode { - const [submitting, setSubmitting] = useState(false); - const [formError, setFormError] = useState(""); - - const { - handleSubmit, - setValue, - setError, - clearErrors, - control, - reset, - formState: { - disabled, - isSubmitted, - isDirty, - isValid, - errors: { turnstileToken: turnstileTokenError }, - }, - } = useForm({ - resolver: valibotResolver(contactSchema), - defaultValues: contactDefaults, - mode: "onChange", - }); - - const submitDisabled = disabled || !isDirty || !isValid || submitting; - - useEffect(() => { - if (turnstileTokenError?.message) { - setFormError(turnstileTokenError.message); - } - }, [turnstileTokenError]); - - const onSubmit: SubmitHandler = (data) => { - if (submitDisabled) return; - setSubmitting(true); - - const submission: GenericFormRequest = { - form: name, - ...data, - }; - - fetch("/api/form/submit", { - method: "POST", - body: JSON.stringify(submission), - headers: { "Content-Type": "application/json" }, - }) - .then((res) => res.json()) - .then(async (res: APIResponse) => { - if (!res.success) { - setFormError(res.message ?? "Error submitting form"); - } - }) - .catch((err) => { - setFormError("Error submitting form"); - console.log(err); - }) - .finally(() => { - setSubmitting(false); - reset(contactDefaults); - }); - - console.log(submission); - }; - - return ( -
-
- - -
- diff --git a/src/components/ui/primitives/TitleBlock.astro b/src/components/ui/primitives/TitleBlock.astro new file mode 100644 index 00000000..f79a14ff --- /dev/null +++ b/src/components/ui/primitives/TitleBlock.astro @@ -0,0 +1,37 @@ +--- +import Link from "@/components/ui/primitives/Link.astro"; +import { cn } from "@/lib/cn"; + +interface Field { + label: string; + value: string; + mono?: boolean; + href?: string; +} + +interface Props { + fields: readonly Field[]; + orientation?: "strip" | "stack"; + class?: string; +} + +const { fields, orientation = "strip", class: className } = Astro.props; +--- + +
+ {fields.map((field) => ( +
+
{field.label}
+
+ {field.href === undefined ? field.value : {field.value}} +
+
+ ))} +
diff --git a/src/content.config.ts b/src/content.config.ts new file mode 100644 index 00000000..dcf519f8 --- /dev/null +++ b/src/content.config.ts @@ -0,0 +1,160 @@ +import { defineCollection, reference, type SchemaContext } from "astro:content"; +import { glob } from "astro/loaders"; +import { z } from "astro/zod"; + +import { PROGRAM_KEYS } from "@/data/site"; + +// Schemas stay flat (no nested objects or unions) so a git-backed CMS can be added later. + +/** Declaration order is the ranking; sort by index here. */ +export const SPONSOR_LEVELS = ["Platinum", "Gold", "Silver", "Bronze", "Friend"] as const; + +const sponsors = defineCollection({ + loader: glob({ base: "src/content/sponsors", pattern: "**/*.md" }), + schema: ({ image }) => + z.strictObject({ + name: z.string(), + level: z.enum(SPONSOR_LEVELS), + url: z.url().optional(), + logo: image().optional(), + /** `false` retires a sponsor without deleting the record. */ + active: z.boolean().default(true), + since: z.number().int().optional(), + /** Qualifier shown under the name, e.g. a chapter or post. */ + sub: z.string().optional(), + /** Maintainer notes. Never rendered. */ + notes: z.string().optional(), + }), +}); + +const events = defineCollection({ + loader: glob({ base: "src/content/events", pattern: "**/*.md" }), + schema: ({ image }) => + z.strictObject({ + title: z.string(), + subtitle: z.string().optional(), + program: z.enum(PROGRAM_KEYS), + start: z.date(), + end: z.date().optional(), + // Default to the workspace; set only for an off-site event. + locationName: z.string().optional(), + locationAddress: z.string().optional(), + directionsUrl: z.url().optional(), + description: z.string(), + /** Falls back to the program's photo; an image without `heroImageAlt` fails the build. */ + heroImage: image().optional(), + heroImageAlt: z.string().optional(), + ctaLabel: z.string(), + ctaHref: z.string(), + registrationUrl: z.url().optional(), + /** `hintLabels[i]` names `hintUrls[i]`; a length mismatch fails the build. */ + teaserUrls: z.array(z.url()).optional(), + hintUrls: z.array(z.url()).optional(), + hintLabels: z.array(z.string()).optional(), + faq: z.array(reference("faq")).optional(), + /** `true` redirects the page to its parent and drops it from the sitemap. */ + hidden: z.boolean().default(false), + }), +}); + +const faq = defineCollection({ + loader: glob({ base: "src/content/faq", pattern: "**/*.md" }), + schema: z.strictObject({ + question: z.string(), + program: z.enum(PROGRAM_KEYS).optional(), + tags: z.array(z.string()).optional(), + }), +}); + +/** + * One entry per season, robot or not. A season with no known robot omits `name` and `image`; an + * `image` without `imageAlt` fails the build. + */ +const frcRobots = defineCollection({ + loader: glob({ base: "src/content/frc/robots", pattern: "**/*.md" }), + schema: ({ image }) => + z.strictObject({ + year: z.number().int(), + /** + * Display override where a robot spans two seasons ("2020-2021"); `year` stays the sort key. + */ + yearLabel: z.string().optional(), + name: z.string().optional(), + game: z.string().optional(), + image: image().optional(), + imageAlt: z.string().optional(), + logo: image().optional(), + /** + * In the order played; an event starting with "FIRST Championship" marks a championship + * season. + */ + events: z.array(z.string()).optional(), + result: z.string().optional(), + /** + * One per win: the same award at two events is two entries. Team awards only, not individual + * honors. + */ + awards: z.array(z.string()).optional(), + }), +}); + +/** A field is all the site publishes about an alumnus: no name, role, or employer. */ +export const ALUMNI_FIELDS = ["Software and IT", "Engineering", "Management", "Design"] as const; + +const alumni = defineCollection({ + loader: glob({ base: "src/content/alumni", pattern: "**/*.md" }), + schema: z.strictObject({ + field: z.enum(ALUMNI_FIELDS), + program: z.enum(PROGRAM_KEYS), + }), +}); + +export const OUTREACH_KINDS = ["school", "sponsor", "festival"] as const; + +const outreach = defineCollection({ + loader: glob({ base: "src/content/outreach", pattern: "**/*.md" }), + schema: z.strictObject({ + name: z.string(), + kind: z.enum(OUTREACH_KINDS), + description: z.string(), + year: z.number().int(), + /** Orders a year's appearances; the site does not print it. */ + month: z.number().int().min(1).max(12), + place: z.string().optional(), + url: z.url().optional(), + }), +}); + +const teamPhotoSchema = ({ image }: SchemaContext) => + z.strictObject({ + year: z.number().int(), + photo: image(), + alt: z.string(), + caption: z.string().optional(), + }); + +const frcTeamPhotos = defineCollection({ + loader: glob({ base: "src/content/frc/team-photos", pattern: "**/*.md" }), + schema: teamPhotoSchema, +}); + +const fllTeamPhotos = defineCollection({ + loader: glob({ base: "src/content/fll/team-photos", pattern: "**/*.md" }), + schema: teamPhotoSchema, +}); + +/** + * Collection names avoid slashes: Astro writes each editor JSON schema to + * `.astro/collections/.schema.json` without creating directories, so a name like + * `frc/robots` warns on every build and loses frontmatter autocomplete. + */ +export const collections = { + sponsors, + events, + faq, + frcRobots, + frcTeamPhotos, + fllTeamPhotos, + alumni, + outreach, +}; diff --git a/src/content/alumni/frc-01.md b/src/content/alumni/frc-01.md new file mode 100644 index 00000000..2d465e21 --- /dev/null +++ b/src/content/alumni/frc-01.md @@ -0,0 +1,4 @@ +--- +field: Software and IT +program: frc +--- diff --git a/src/content/alumni/frc-02.md b/src/content/alumni/frc-02.md new file mode 100644 index 00000000..2d465e21 --- /dev/null +++ b/src/content/alumni/frc-02.md @@ -0,0 +1,4 @@ +--- +field: Software and IT +program: frc +--- diff --git a/src/content/alumni/frc-03.md b/src/content/alumni/frc-03.md new file mode 100644 index 00000000..2d465e21 --- /dev/null +++ b/src/content/alumni/frc-03.md @@ -0,0 +1,4 @@ +--- +field: Software and IT +program: frc +--- diff --git a/src/content/alumni/frc-04.md b/src/content/alumni/frc-04.md new file mode 100644 index 00000000..2d465e21 --- /dev/null +++ b/src/content/alumni/frc-04.md @@ -0,0 +1,4 @@ +--- +field: Software and IT +program: frc +--- diff --git a/src/content/alumni/frc-05.md b/src/content/alumni/frc-05.md new file mode 100644 index 00000000..c6b46f3d --- /dev/null +++ b/src/content/alumni/frc-05.md @@ -0,0 +1,4 @@ +--- +field: Engineering +program: frc +--- diff --git a/src/content/alumni/frc-06.md b/src/content/alumni/frc-06.md new file mode 100644 index 00000000..c6b46f3d --- /dev/null +++ b/src/content/alumni/frc-06.md @@ -0,0 +1,4 @@ +--- +field: Engineering +program: frc +--- diff --git a/src/content/alumni/frc-07.md b/src/content/alumni/frc-07.md new file mode 100644 index 00000000..c6b46f3d --- /dev/null +++ b/src/content/alumni/frc-07.md @@ -0,0 +1,4 @@ +--- +field: Engineering +program: frc +--- diff --git a/src/content/alumni/frc-08.md b/src/content/alumni/frc-08.md new file mode 100644 index 00000000..c9a85203 --- /dev/null +++ b/src/content/alumni/frc-08.md @@ -0,0 +1,4 @@ +--- +field: Management +program: frc +--- diff --git a/src/content/alumni/frc-09.md b/src/content/alumni/frc-09.md new file mode 100644 index 00000000..c9a85203 --- /dev/null +++ b/src/content/alumni/frc-09.md @@ -0,0 +1,4 @@ +--- +field: Management +program: frc +--- diff --git a/src/content/alumni/frc-10.md b/src/content/alumni/frc-10.md new file mode 100644 index 00000000..5f789280 --- /dev/null +++ b/src/content/alumni/frc-10.md @@ -0,0 +1,4 @@ +--- +field: Design +program: frc +--- diff --git a/src/content/events/frc-kickoff.md b/src/content/events/frc-kickoff.md new file mode 100644 index 00000000..bf7db16e --- /dev/null +++ b/src/content/events/frc-kickoff.md @@ -0,0 +1,55 @@ +--- +title: 2026 season kickoff +program: frc +start: 2026-01-10T12:00:00-05:00 +end: 2026-01-10T16:00:00-05:00 +subtitle: Discover the new game with Biohazard and see how a season begins. +description: Watch the FIRST Robotics Competition game reveal with Biohazard in downtown Chambersburg. Meet the team, explore ideas, and learn about joining the season. +heroImage: ../../assets/events/cheering.webp +heroImageAlt: Biohazard team members cheering from the stands at competition +ctaLabel: Watch the game reveal +ctaHref: https://www.firstinspires.org/robotics/frc/kickoff +teaserUrls: + - https://www.youtube.com/watch?v=2AEdAID2U2o + - https://www.youtube.com/watch?v=0JaguGctF1A +hintUrls: + - https://www.youtube.com/watch?v=0JaguGctF1A + - https://community.firstinspires.org/2025-game-hint-3-for-2026 +hintLabels: + - "Hint #1" + - "Hint #2" +--- + +## From the game reveal to competition + +**Kickoff.** Watch the game reveal together, read the rules, and start exploring ideas for our +robot. + +**Build season.** Design, build, and program a robot to take on the challenge. Mentors help +students test their ideas and improve the robot as competition approaches. + +**Competition.** Take the robot to regional events in spring, compete in alliances with other +teams, and put your skills into practice on the field. + +**Ages 14–18.** High-school students work alongside experienced mentors to explore engineering, +strategy, and creative roles. No robotics experience needed. + +## What is _FIRST®_ Robotics Competition? + +_FIRST®_ Robotics Competition brings high-school students and mentors together to build +full-size robots for a new game each season. Students practice engineering, teamwork, and +leadership as they prepare to compete. [Meet Biohazard](/programs/frc) to explore our team and +the roles you can try. + +## Meeting schedule + +- 12 PM – Parents' meeting and safety meeting +- Lunch break and prepare for the game reveal +- Game reveal live stream begins +- ~4 PM – Meeting ends + +## Explore joining Biohazard + +You can get in touch even if you can't attend kickoff. Tell us your interests through the +[Get involved](/get-involved) form, and a volunteer will answer your questions and discuss a +visit. Students are welcome from Franklin County and beyond. diff --git a/src/content/events/openhouse.md b/src/content/events/openhouse.md new file mode 100644 index 00000000..85cf6798 --- /dev/null +++ b/src/content/events/openhouse.md @@ -0,0 +1,28 @@ +--- +title: Open house +subtitle: Meet the teams, explore your interests, and see what you can build together. +program: sc2 +start: 2026-08-01T13:00:00-04:00 +end: 2026-08-01T16:00:00-04:00 +description: Explore robotics at South Central STEM Collective's open house in Chambersburg, PA. Meet students and mentors, see the robots, and ask about joining a team. +heroImage: ../../assets/events/morethanrobots.webp +heroImageAlt: Collage of photos capturing some of the many ways students can get involved including robot design, photography, video, and more. +ctaLabel: Get involved +ctaHref: /get-involved +faq: + - when-is-the-open-house + - what-age-to-join + - no-robotics-experience + - can-i-join-from-outside-franklin-county + - not-interested-in-robots + - cant-make-the-open-house +--- + +## Find your place on a team + +Bring your curiosity and meet the students and mentors behind our robots. Explore building, +programming, strategy, fundraising, and creative roles, and ask about the program that fits +your age and interests. + +Families are welcome from Franklin County and beyond. No robotics experience needed, and you +don't need to decide about joining before you visit. diff --git a/src/content/faq/can-i-join-from-outside-franklin-county.md b/src/content/faq/can-i-join-from-outside-franklin-county.md new file mode 100644 index 00000000..f9ef97e9 --- /dev/null +++ b/src/content/faq/can-i-join-from-outside-franklin-county.md @@ -0,0 +1,7 @@ +--- +question: Can I join if I live outside Franklin County? +tags: [joining, parents] +--- + +Yes. Our teams meet in downtown Chambersburg, Pennsylvania, and welcome students from +Franklin County and beyond. You don't have to live in the county to participate. diff --git a/src/content/faq/cant-make-the-open-house.md b/src/content/faq/cant-make-the-open-house.md new file mode 100644 index 00000000..3ff6fdcd --- /dev/null +++ b/src/content/faq/cant-make-the-open-house.md @@ -0,0 +1,8 @@ +--- +question: Can I visit if I can't attend the open house? +program: sc2 +tags: [openhouse] +--- + +Yes. Tell us your interests through the [Get involved](/get-involved) form. A volunteer will +help you arrange a visit to a regular team meeting. diff --git a/src/content/faq/how-do-you-keep-students-safe.md b/src/content/faq/how-do-you-keep-students-safe.md new file mode 100644 index 00000000..58a73977 --- /dev/null +++ b/src/content/faq/how-do-you-keep-students-safe.md @@ -0,0 +1,7 @@ +--- +question: How do you keep students safe? +tags: [joining, parents] +--- + +Every mentor and volunteer follows Pennsylvania and _FIRST®_ youth-protection guidelines, and we +keep a two-deep policy: at least two adults are with students at all times. diff --git a/src/content/faq/how-much-time-does-it-take.md b/src/content/faq/how-much-time-does-it-take.md new file mode 100644 index 00000000..b90ef189 --- /dev/null +++ b/src/content/faq/how-much-time-does-it-take.md @@ -0,0 +1,8 @@ +--- +question: How much time does it take? +tags: [joining, parents] +--- + +Plan on at least one meeting a week. Additional sessions give you more time to practice and +contribute. Check the [calendar](/calendar/sc2) for meetings and events, and tell us about your +schedule so we can discuss how participation fits around school and other activities. diff --git a/src/content/faq/no-robotics-experience.md b/src/content/faq/no-robotics-experience.md new file mode 100644 index 00000000..a74fd39d --- /dev/null +++ b/src/content/faq/no-robotics-experience.md @@ -0,0 +1,6 @@ +--- +question: Do I need robotics experience? +tags: [joining, parents] +--- + +No experience needed. Mentors and teammates help you get started and learn as you take part. diff --git a/src/content/faq/not-interested-in-robots.md b/src/content/faq/not-interested-in-robots.md new file mode 100644 index 00000000..c97b493c --- /dev/null +++ b/src/content/faq/not-interested-in-robots.md @@ -0,0 +1,8 @@ +--- +question: Can I contribute if my interests are outside robotics? +tags: [joining] +--- + +There's a place for your interests here, too. Students help with fundraising, photography, +video, social media, graphic design, and community outreach. Tell us what you enjoy, and we can +help you explore a role on the team. diff --git a/src/content/faq/what-age-to-join.md b/src/content/faq/what-age-to-join.md new file mode 100644 index 00000000..6255b54f --- /dev/null +++ b/src/content/faq/what-age-to-join.md @@ -0,0 +1,8 @@ +--- +question: What ages can join? +tags: [joining] +--- + +_FIRST®_ LEGO® League is for ages 9–16, and Biohazard, our _FIRST®_ Robotics Competition team, +is for ages 14–18. Where the age ranges overlap, we can help you choose a program based on your +interests. Adults can contribute as mentors and volunteers. diff --git a/src/content/faq/what-happens-after-the-form.md b/src/content/faq/what-happens-after-the-form.md new file mode 100644 index 00000000..5d2e8737 --- /dev/null +++ b/src/content/faq/what-happens-after-the-form.md @@ -0,0 +1,8 @@ +--- +question: What happens after I fill out the form? +tags: [joining, parents] +--- + +A volunteer emails you to learn about your interests and answer your questions. We can discuss +a visit to a team meeting or share the program, volunteer, or sponsorship details you asked for. +You don't need to decide about joining before getting in touch. diff --git a/src/content/faq/when-is-the-open-house.md b/src/content/faq/when-is-the-open-house.md new file mode 100644 index 00000000..30bb6fb9 --- /dev/null +++ b/src/content/faq/when-is-the-open-house.md @@ -0,0 +1,7 @@ +--- +question: When is the open house? +program: sc2 +tags: [openhouse] +--- + +See the date and time at the top of this page. diff --git a/src/content/fll/team-photos/2024.md b/src/content/fll/team-photos/2024.md new file mode 100644 index 00000000..00232f1e --- /dev/null +++ b/src/content/fll/team-photos/2024.md @@ -0,0 +1,6 @@ +--- +year: 2024 +photo: ../../../assets/team/fll/2024.webp +alt: The FIRST LEGO League teams in 2024 +caption: '"Team Jeremy" & "Toxic Musicians", 2024' +--- diff --git a/src/content/frc/robots/2012.md b/src/content/frc/robots/2012.md new file mode 100644 index 00000000..e7bee77e --- /dev/null +++ b/src/content/frc/robots/2012.md @@ -0,0 +1,7 @@ +--- +year: 2012 +game: Rebound Rumble +events: + - "Pittsburgh Regional" +result: "Ranked 36th at the Pittsburgh Regional" +--- diff --git a/src/content/frc/robots/2013.md b/src/content/frc/robots/2013.md new file mode 100644 index 00000000..a839f336 --- /dev/null +++ b/src/content/frc/robots/2013.md @@ -0,0 +1,8 @@ +--- +year: 2013 +game: Ultimate Ascent +events: + - "Pittsburgh Regional" + - "Chesapeake Regional" +result: "Semifinalist at the Pittsburgh Regional" +--- diff --git a/src/content/frc/robots/2014.md b/src/content/frc/robots/2014.md new file mode 100644 index 00000000..94361731 --- /dev/null +++ b/src/content/frc/robots/2014.md @@ -0,0 +1,9 @@ +--- +year: 2014 +game: Aerial Assist +events: + - "Greater Pittsburgh Regional" +result: "Semifinalist at the Greater Pittsburgh Regional, as alliance captain" +awards: + - "Excellence in Engineering Award, Greater Pittsburgh Regional" +--- diff --git a/src/content/frc/robots/2015-anonymous.md b/src/content/frc/robots/2015-anonymous.md new file mode 100644 index 00000000..a3e50028 --- /dev/null +++ b/src/content/frc/robots/2015-anonymous.md @@ -0,0 +1,12 @@ +--- +year: 2015 +name: Anonymous +game: Recycle Rush +events: + - "Greater Pittsburgh Regional" + - "Chesapeake Regional" + - "FIRST Championship, Tesla Division" +result: "Won the Chesapeake Regional" +awards: + - "Regional Winner, Chesapeake Regional" +--- diff --git a/src/content/frc/robots/2016-dialga-the-steel-dragon.md b/src/content/frc/robots/2016-dialga-the-steel-dragon.md new file mode 100644 index 00000000..6b77d99d --- /dev/null +++ b/src/content/frc/robots/2016-dialga-the-steel-dragon.md @@ -0,0 +1,12 @@ +--- +year: 2016 +name: "Dialga, The Steel Dragon" +game: FIRST Stronghold +events: + - "Greater Pittsburgh Regional" + - "Smoky Mountains Regional" +result: "Finalist at the Greater Pittsburgh Regional" +awards: + - "Regional Finalist, Greater Pittsburgh Regional" + - "Quality Award, Greater Pittsburgh Regional" +--- diff --git a/src/content/frc/robots/2017-boiling-point.md b/src/content/frc/robots/2017-boiling-point.md new file mode 100644 index 00000000..14bc1d42 --- /dev/null +++ b/src/content/frc/robots/2017-boiling-point.md @@ -0,0 +1,10 @@ +--- +year: 2017 +name: Boiling Point +game: FIRST STEAMworks +events: + - "Greater Pittsburgh Regional" + - "Buckeye Regional" + - "Battle O' Baltimore (off-season)" +result: "Quarterfinalist at the Greater Pittsburgh Regional" +--- diff --git a/src/content/frc/robots/2018.md b/src/content/frc/robots/2018.md new file mode 100644 index 00000000..f9e8b937 --- /dev/null +++ b/src/content/frc/robots/2018.md @@ -0,0 +1,11 @@ +--- +year: 2018 +game: FIRST Power Up +events: + - "Greater Pittsburgh Regional" + - "Long Island Regional #2" + - "Battle O' Baltimore (off-season)" +result: "Quarterfinalist at the Long Island Regional #2" +awards: + - "Creativity Award, Long Island Regional #2" +--- diff --git a/src/content/frc/robots/2019-toxic-silver.md b/src/content/frc/robots/2019-toxic-silver.md new file mode 100644 index 00000000..649863c6 --- /dev/null +++ b/src/content/frc/robots/2019-toxic-silver.md @@ -0,0 +1,16 @@ +--- +year: 2019 +name: Toxic Silver +game: "Destination: Deep Space" +image: ../../../assets/frc/robots/2019-robot-field.webp +imageAlt: Toxic Silver on the competition field +events: + - "Greater Pittsburgh Regional" + - "Battle O' Baltimore (off-season)" +result: "Semifinalist at Battle O' Baltimore (off-season), as alliance captain" +awards: + - "Excellence in Engineering Award, Greater Pittsburgh Regional" +--- + +With its shining aluminum lift and maneuverable mecanum drivetrain, Toxic Silver lifted +and placed game pieces in the 2019 game. diff --git a/src/content/frc/robots/2021-robo-fett.md b/src/content/frc/robots/2021-robo-fett.md new file mode 100644 index 00000000..4810c824 --- /dev/null +++ b/src/content/frc/robots/2021-robo-fett.md @@ -0,0 +1,19 @@ +--- +year: 2021 +yearLabel: 2020-2021 +name: Robo Fett +game: Infinite Recharge +image: ../../../assets/frc/robots/2021-robot-field.webp +imageAlt: Robo Fett on the competition field +events: + - "Palmetto Regional" + - "FIRST At Home challenges (virtual)" + - "Battle O' Baltimore (off-season)" +result: "Quarterfinalist at the Palmetto Regional" +awards: + - "Imagery Award, FIRST At Home challenges (virtual)" +--- + +Robo Fett uses a scissor-lift climber and an inline shooting mechanism. It is one of two +Biohazard robots to use pneumatic wheels. A plexiglass side panel protects the electronics and +displays the 2020 team logo. diff --git a/src/content/frc/robots/2022-omega.md b/src/content/frc/robots/2022-omega.md new file mode 100644 index 00000000..cbe03a6a --- /dev/null +++ b/src/content/frc/robots/2022-omega.md @@ -0,0 +1,18 @@ +--- +year: 2022 +name: O'Mega +game: Rapid React +image: ../../../assets/frc/robots/2022-robot-field-3.webp +imageAlt: O'Mega on the competition field +events: + - "Greater Pittsburgh Regional" + - "Smoky Mountains Regional" +result: "Quarterfinalist at the Smoky Mountains Regional, as alliance captain" +awards: + - "Industrial Design Award, Greater Pittsburgh Regional" + - "Industrial Design Award, Smoky Mountains Regional" +--- + +O'Mega combines a large shooter, an intake, and two climbing arms. Its adjustable firing +mechanism let it shoot into both hoops in the 2022 game. The robot earned Industrial Design +Awards at both regionals. diff --git a/src/content/frc/robots/2023-viper.md b/src/content/frc/robots/2023-viper.md new file mode 100644 index 00000000..821b3129 --- /dev/null +++ b/src/content/frc/robots/2023-viper.md @@ -0,0 +1,18 @@ +--- +year: 2023 +name: Viper +game: Charged Up +image: ../../../assets/frc/robots/2023-robot-field.webp +imageAlt: Viper on the competition field +logo: ../../../assets/frc/robots/2023-viper-logo.webp +events: + - "Greater Pittsburgh Regional" + - "Battle O' Baltimore (off-season)" +result: "Won Battle O' Baltimore (off-season)" +awards: + - "Industrial Design Award, Greater Pittsburgh Regional" +--- + +Viper earned the Industrial Design Award at the 2023 Greater Pittsburgh Regional. Its +three-foot metal arm and 3D-printed pneumatic claw grabbed inflatable cubes. Mecanum wheels +helped drivers fine-tune the robot's position, and two cameras improved their view of the field. diff --git a/src/content/frc/robots/2024-troubleclef.md b/src/content/frc/robots/2024-troubleclef.md new file mode 100644 index 00000000..93428a4e --- /dev/null +++ b/src/content/frc/robots/2024-troubleclef.md @@ -0,0 +1,19 @@ +--- +year: 2024 +name: Troubleclef +game: Crescendo +image: ../../../assets/frc/robots/2024-robot-field.webp +imageAlt: Troubleclef on the competition field +logo: ../../../assets/frc/robots/2024-troubleclef-logo.webp +events: + - "Greater Pittsburgh Regional" + - "Buckeye Regional" + - "Battle O' Baltimore (off-season)" +result: "Ranked 5th and captained an alliance at Battle O' Baltimore (off-season)" +awards: + - "Imagery Award, Buckeye Regional" +--- + +Troubleclef's gunmetal side panels enclose its electronics. A 3D-printed chain carries game +pieces from the ground to the top of a ramp, where two motors launch them. The team placed +15th at the Buckeye Regional and earned an Imagery Award. diff --git a/src/content/frc/robots/2025.md b/src/content/frc/robots/2025.md new file mode 100644 index 00000000..2e9ea0d8 --- /dev/null +++ b/src/content/frc/robots/2025.md @@ -0,0 +1,8 @@ +--- +year: 2025 +game: Reefscape +events: + - "Greater Pittsburgh Regional" + - "Buckeye Regional" +result: "Ranked 26th at the Greater Pittsburgh Regional" +--- diff --git a/src/content/frc/robots/2026.md b/src/content/frc/robots/2026.md new file mode 100644 index 00000000..630b8b07 --- /dev/null +++ b/src/content/frc/robots/2026.md @@ -0,0 +1,6 @@ +--- +year: 2026 +events: + - "Greater Pittsburgh Regional" +result: "Third place at the Greater Pittsburgh Regional" +--- diff --git a/src/content/frc/team-photos/2013.md b/src/content/frc/team-photos/2013.md new file mode 100644 index 00000000..0a6aeead --- /dev/null +++ b/src/content/frc/team-photos/2013.md @@ -0,0 +1,6 @@ +--- +year: 2013 +photo: ../../../assets/team/frc/2013.webp +alt: The Biohazard team in 2013 +caption: '"Biohazard", 2013' +--- diff --git a/src/content/frc/team-photos/2014.md b/src/content/frc/team-photos/2014.md new file mode 100644 index 00000000..c6560fc3 --- /dev/null +++ b/src/content/frc/team-photos/2014.md @@ -0,0 +1,6 @@ +--- +year: 2014 +photo: ../../../assets/team/frc/2014.webp +alt: The Biohazard team in 2014 +caption: '"Biohazard", 2014' +--- diff --git a/src/content/frc/team-photos/2015.md b/src/content/frc/team-photos/2015.md new file mode 100644 index 00000000..f5934489 --- /dev/null +++ b/src/content/frc/team-photos/2015.md @@ -0,0 +1,6 @@ +--- +year: 2015 +photo: ../../../assets/team/frc/2015.webp +alt: The Biohazard team in 2015 +caption: '"Biohazard", 2015' +--- diff --git a/src/content/frc/team-photos/2016.md b/src/content/frc/team-photos/2016.md new file mode 100644 index 00000000..d87a1f46 --- /dev/null +++ b/src/content/frc/team-photos/2016.md @@ -0,0 +1,6 @@ +--- +year: 2016 +photo: ../../../assets/team/frc/2016.webp +alt: The Biohazard team in 2016 +caption: '"Biohazard", 2016' +--- diff --git a/src/content/frc/team-photos/2017.md b/src/content/frc/team-photos/2017.md new file mode 100644 index 00000000..85ae6b1b --- /dev/null +++ b/src/content/frc/team-photos/2017.md @@ -0,0 +1,6 @@ +--- +year: 2017 +photo: ../../../assets/team/frc/2017.webp +alt: The Biohazard team in 2017 +caption: '"Biohazard", 2017' +--- diff --git a/src/content/frc/team-photos/2018.md b/src/content/frc/team-photos/2018.md new file mode 100644 index 00000000..17914da0 --- /dev/null +++ b/src/content/frc/team-photos/2018.md @@ -0,0 +1,6 @@ +--- +year: 2018 +photo: ../../../assets/team/frc/2018.webp +alt: The Biohazard team in 2018 +caption: '"Biohazard", 2018' +--- diff --git a/src/content/frc/team-photos/2019.md b/src/content/frc/team-photos/2019.md new file mode 100644 index 00000000..a7226ccc --- /dev/null +++ b/src/content/frc/team-photos/2019.md @@ -0,0 +1,6 @@ +--- +year: 2019 +photo: ../../../assets/team/frc/2019.webp +alt: The Biohazard team in 2019 +caption: '"Biohazard", 2019' +--- diff --git a/src/content/frc/team-photos/2020.md b/src/content/frc/team-photos/2020.md new file mode 100644 index 00000000..c8ac0c4c --- /dev/null +++ b/src/content/frc/team-photos/2020.md @@ -0,0 +1,6 @@ +--- +year: 2020 +photo: ../../../assets/team/frc/2020.webp +alt: The Biohazard team in 2020 +caption: '"Biohazard", 2020' +--- diff --git a/src/content/frc/team-photos/2022.md b/src/content/frc/team-photos/2022.md new file mode 100644 index 00000000..57e2893a --- /dev/null +++ b/src/content/frc/team-photos/2022.md @@ -0,0 +1,6 @@ +--- +year: 2022 +photo: ../../../assets/team/frc/2022.webp +alt: The Biohazard team in 2022 +caption: '"Biohazard", 2022' +--- diff --git a/src/content/frc/team-photos/2023.md b/src/content/frc/team-photos/2023.md new file mode 100644 index 00000000..d3525ebe --- /dev/null +++ b/src/content/frc/team-photos/2023.md @@ -0,0 +1,6 @@ +--- +year: 2023 +photo: ../../../assets/team/frc/2023.webp +alt: The Biohazard team in 2023 +caption: '"Biohazard", 2023' +--- diff --git a/src/content/frc/team-photos/2024.md b/src/content/frc/team-photos/2024.md new file mode 100644 index 00000000..93afc553 --- /dev/null +++ b/src/content/frc/team-photos/2024.md @@ -0,0 +1,6 @@ +--- +year: 2024 +photo: ../../../assets/team/frc/2024.webp +alt: The Biohazard team in 2024 +caption: '"Biohazard", 2024' +--- diff --git a/src/content/frc/team-photos/2025.md b/src/content/frc/team-photos/2025.md new file mode 100644 index 00000000..054ee361 --- /dev/null +++ b/src/content/frc/team-photos/2025.md @@ -0,0 +1,6 @@ +--- +year: 2025 +photo: ../../../assets/team/frc/2025.webp +alt: The Biohazard team in 2025 +caption: '"Biohazard", 2025' +--- diff --git a/src/content/frc/team-photos/2026.md b/src/content/frc/team-photos/2026.md new file mode 100644 index 00000000..f8f1f9d1 --- /dev/null +++ b/src/content/frc/team-photos/2026.md @@ -0,0 +1,6 @@ +--- +year: 2026 +photo: ../../../assets/team/frc/2026.webp +alt: The Biohazard team in 2026 +caption: '"Biohazard", 2026' +--- diff --git a/src/content/outreach/2026-corn-fest.md b/src/content/outreach/2026-corn-fest.md new file mode 100644 index 00000000..7c2f0b71 --- /dev/null +++ b/src/content/outreach/2026-corn-fest.md @@ -0,0 +1,8 @@ +--- +name: Corn Fest +kind: festival +description: "Students sharing their robots with visitors at Shippensburg's street festival." +year: 2026 +month: 8 +place: Shippensburg +--- diff --git a/src/content/outreach/2026-gasd-steam-expo.md b/src/content/outreach/2026-gasd-steam-expo.md new file mode 100644 index 00000000..54017117 --- /dev/null +++ b/src/content/outreach/2026-gasd-steam-expo.md @@ -0,0 +1,8 @@ +--- +name: Greencastle-Antrim School District STEAM Expo +kind: school +description: "Robot demonstrations for students and families at the district's STEAM showcase." +year: 2026 +month: 5 +place: Greencastle +--- diff --git a/src/content/outreach/2026-jlg-next-gen-stem.md b/src/content/outreach/2026-jlg-next-gen-stem.md new file mode 100644 index 00000000..390bc45e --- /dev/null +++ b/src/content/outreach/2026-jlg-next-gen-stem.md @@ -0,0 +1,7 @@ +--- +name: JLG Next Gen STEM +kind: sponsor +description: "Sharing our competition robot at JLG's STEM event." +year: 2026 +month: 7 +--- diff --git a/src/content/outreach/2026-old-market-day.md b/src/content/outreach/2026-old-market-day.md new file mode 100644 index 00000000..43bd9c23 --- /dev/null +++ b/src/content/outreach/2026-old-market-day.md @@ -0,0 +1,8 @@ +--- +name: Old Market Day +kind: festival +description: "Students sharing their robots with visitors at downtown Chambersburg's summer festival." +year: 2026 +month: 7 +place: Chambersburg +--- diff --git a/src/content/sponsors/fives.md b/src/content/sponsors/fives.md new file mode 100644 index 00000000..c769a9b0 --- /dev/null +++ b/src/content/sponsors/fives.md @@ -0,0 +1,8 @@ +--- +name: Fives +level: Platinum +url: https://www.fivesgroup.com/ +logo: ../../assets/sponsors/fives.svg +active: false +since: 2022 +--- diff --git a/src/content/sponsors/jlg.md b/src/content/sponsors/jlg.md new file mode 100644 index 00000000..84cdb05d --- /dev/null +++ b/src/content/sponsors/jlg.md @@ -0,0 +1,7 @@ +--- +name: JLG +level: Platinum +url: https://www.jlg.com/ +logo: ../../assets/sponsors/jlg.svg +since: 2013 +--- diff --git a/src/content/sponsors/journalytic.md b/src/content/sponsors/journalytic.md new file mode 100644 index 00000000..bb83098a --- /dev/null +++ b/src/content/sponsors/journalytic.md @@ -0,0 +1,8 @@ +--- +name: Journalytic +level: Gold +url: https://journalytic.com/ +logo: ../../assets/sponsors/journalytic.svg +since: 2024 +active: false +--- diff --git a/src/content/sponsors/manitowoc.md b/src/content/sponsors/manitowoc.md new file mode 100644 index 00000000..23959497 --- /dev/null +++ b/src/content/sponsors/manitowoc.md @@ -0,0 +1,8 @@ +--- +name: Manitowoc +level: Bronze +url: https://www.manitowoc.com/ +logo: ../../assets/sponsors/manitowoc.png +since: 2024 +active: false +--- diff --git a/src/content/sponsors/orrstown.md b/src/content/sponsors/orrstown.md new file mode 100644 index 00000000..c53f166d --- /dev/null +++ b/src/content/sponsors/orrstown.md @@ -0,0 +1,8 @@ +--- +name: Orrstown +level: Bronze +url: https://www.orrstown.com/ +logo: ../../assets/sponsors/orrstown.png +since: 2024 +active: false +--- diff --git a/src/content/sponsors/the-workshope.md b/src/content/sponsors/the-workshope.md new file mode 100644 index 00000000..2dae0ce1 --- /dev/null +++ b/src/content/sponsors/the-workshope.md @@ -0,0 +1,7 @@ +--- +name: The WorkShope +level: Gold +url: https://www.theworkshope.com/ +logo: ../../assets/sponsors/workshope.png +since: 2021 +--- diff --git a/src/content/sponsors/vfw.md b/src/content/sponsors/vfw.md new file mode 100644 index 00000000..4f70753e --- /dev/null +++ b/src/content/sponsors/vfw.md @@ -0,0 +1,9 @@ +--- +name: VFW +sub: Chambersburg VFW Post 1599 +level: Platinum +url: https://www.vfw1599.org/ +logo: ../../assets/sponsors/vfw.png +active: false +since: 2022 +--- diff --git a/src/content/sponsors/volvo.md b/src/content/sponsors/volvo.md new file mode 100644 index 00000000..07aeb443 --- /dev/null +++ b/src/content/sponsors/volvo.md @@ -0,0 +1,8 @@ +--- +name: Volvo +level: Gold +url: https://www.volvogroup.com/ +logo: ../../assets/sponsors/volvo.png +since: 2024 +active: false +--- diff --git a/src/content/sponsors/wellspan.md b/src/content/sponsors/wellspan.md new file mode 100644 index 00000000..f168b156 --- /dev/null +++ b/src/content/sponsors/wellspan.md @@ -0,0 +1,8 @@ +--- +name: Wellspan +level: Platinum +url: https://www.wellspan.org/ +logo: ../../assets/sponsors/wellspan.png +active: false +since: 2022 +--- diff --git a/src/content/sponsors/yb-welding.md b/src/content/sponsors/yb-welding.md new file mode 100644 index 00000000..6a09c771 --- /dev/null +++ b/src/content/sponsors/yb-welding.md @@ -0,0 +1,7 @@ +--- +name: Y.B. Welding +level: Gold +url: https://www.ybwelding.com/ +logo: ../../assets/sponsors/yb-dark.png +since: 2018 +--- diff --git a/src/data/events.ts b/src/data/events.ts new file mode 100644 index 00000000..048261f3 --- /dev/null +++ b/src/data/events.ts @@ -0,0 +1,10 @@ +import { type PagePath, programs } from "@/data/site"; + +/** + * Entry id to the path of its hand-written route, kept in sync by hand. An event with no entry here + * fails the build. + */ +export const eventRoutes: ReadonlyMap = new Map([ + ["openhouse", "/openhouse"], + ["frc-kickoff", `${programs.frc.href}/kickoff`], +]); diff --git a/src/data/ogCards.ts b/src/data/ogCards.ts new file mode 100644 index 00000000..9915c9da --- /dev/null +++ b/src/data/ogCards.ts @@ -0,0 +1,35 @@ +import type { OgCard } from "@/lib/seo"; + +import ogDonate from "@/assets/og/donate.jpg"; +import ogEvents from "@/assets/og/events.jpg"; +import ogFll from "@/assets/og/fll.jpg"; +import ogFrc from "@/assets/og/frc.jpg"; +import ogPrograms from "@/assets/og/programs.jpg"; +import ogSponsors from "@/assets/og/sponsors.jpg"; + +export const ogCards = { + donate: { + image: ogDonate, + alt: "The South Central STEM Collective logo over a rack of drill bits, captioned “Donate”", + }, + events: { + image: ogEvents, + alt: "The South Central STEM Collective logo over a collage of students at work, captioned “Events”", + }, + fll: { + image: ogFll, + alt: "The South Central STEM Collective logo over a student-built LEGO robot, captioned “FIRST® LEGO® League”", + }, + frc: { + image: ogFrc, + alt: "The South Central STEM Collective logo over the Biohazard drive team at competition, captioned “FIRST® Robotics Competition”", + }, + programs: { + image: ogPrograms, + alt: "The South Central STEM Collective logo over students working in the workshop, captioned “Our programs”", + }, + sponsors: { + image: ogSponsors, + alt: "The South Central STEM Collective logo over a workbench of parts and notes, captioned “Sponsors”", + }, +} as const satisfies Record; diff --git a/src/data/programPhotos.ts b/src/data/programPhotos.ts new file mode 100644 index 00000000..92561817 --- /dev/null +++ b/src/data/programPhotos.ts @@ -0,0 +1,24 @@ +import type { ImageMetadata } from "astro"; + +import fllRobots from "@/assets/fll/lego-robots.webp"; +import driveTeam from "@/assets/frc/frc-driveteam.webp"; +import students from "@/assets/sc2/students.webp"; +import { type ProgramKey, programs } from "@/data/site"; + +export const programPhotos = { + sc2: { + image: students, + alt: "South Central STEM Collective students at work in the Chambersburg workspace", + caption: "Students in the workspace", + }, + frc: { + image: driveTeam, + alt: `Team ${String(programs.frc.teamNumber)} ${programs.frc.teamName}'s drive team standing with their robot at competition`, + caption: "Drive team and robot", + }, + fll: { + image: fllRobots, + alt: "LEGO® robots built by South Central STEM Collective students", + caption: "Student-built LEGO® robots", + }, +} as const satisfies Record; diff --git a/src/data/site.ts b/src/data/site.ts new file mode 100644 index 00000000..d73ed56b --- /dev/null +++ b/src/data/site.ts @@ -0,0 +1,241 @@ +const workspaceStreet = "20 South Main Street"; + +export const site = { + name: "South Central STEM Collective", + shortName: "SC2", + url: "https://scstem.org", + + legal: + "SC2 is a 501(c)(3) nonprofit based in Chambersburg, PA, helping students explore STEM through hands-on learning.", + ein: "86-2328794", + founded: 2021, + + description: + "South Central STEM Collective runs FIRST robotics teams for ages 9–18 in Chambersburg, PA, welcoming students from Franklin County and beyond.", + titleTemplate: "%s | South Central STEM Collective", + + email: "info@scstem.org", + + ogImage: { + path: "/og/default.jpg", + width: 1200, + height: 630, + alt: "The South Central STEM Collective logo over students building in the workshop, captioned “South Central STEM Collective”", + }, + + heroVideo: { + mp4: "/video/biohazard/home-video.mp4", + webm: "/video/biohazard/home-video.webm", + }, + + icons: { + favicon: "/favicon.ico", + svg: "/icon.svg", + appleTouch: "/apple-touch-icon.png", + png192: "/icon-192.png", + png512: "/icon-512.png", + maskable512: "/icon-maskable-512.png", + }, + + location: { + name: "South Central STEM Collective Workspace", + workspace: `${workspaceStreet}, Downtown Chambersburg`, + street: workspaceStreet, + locality: "Chambersburg", + county: "Franklin County", + region: "PA", + postalCode: "17201", + country: "US", + areaServed: "South-central Pennsylvania and beyond", + timeZone: "America/New_York", + }, + + social: { + facebook: "https://go.scstem.tech/facebook", + linkedin: "https://go.scstem.tech/linkedin", + github: "https://go.scstem.tech/github", + }, + + socialProfiles: { + facebook: "https://www.facebook.com/SCSTEM", + linkedin: "https://www.linkedin.com/company/scstem/", + github: "https://github.com/SCSTEM", + }, + + urls: { + donate: "https://www.paypal.com/US/fundraiser/charity/4486755", + wishlist: "https://wiki.scstem.org/donations/wishlist", + sponsorPacket: "https://wiki.scstem.org/donations/packet", + getInvolvedForm: + "https://docs.google.com/forms/d/e/1FAIpQLScTjT3LHFAq1mOfKFztgMOpUT8hFWz81dYlaaDa4B8lG6yr2Q/viewform", + wiki: "https://wiki.scstem.org", + directions: "https://wiki.scstem.org/workspace/#directions", + firstInspires: "https://www.firstinspires.org/", + moreThanRobots: "https://info.firstinspires.org/morethanrobots", + teamVideo: "https://youtu.be/147CgudTur8", + }, + + calendars: { + frc: "Y19hYjljNWJlYTEwODgyYzAxYTAxOGNiZDUxYWIyMzcwYmY4NDk5NDZiZTRlMjUzNTAwZmZmMWQxMGZkY2M4NjFhQGdyb3VwLmNhbGVuZGFyLmdvb2dsZS5jb20", + sc2: "Y19wcDlkOXRrbGRrbThmdXZtcjMyZTBwZTgxc0Bncm91cC5jYWxlbmRhci5nb29nbGUuY29t", + }, + + analytics: { + ga4MeasurementId: "G-3TPD3DLYBR", + cfBeacon: "3f460cd5a1e7442597641c7adbe5edfe", + }, +} as const; + +export type PagePath = "/" | `/${string}`; + +interface Program { + accent?: Exclude; + ages?: string; + href: PagePath; + name: string; + shortName: string; + since?: number; + tagline?: string; + teamName?: string; + teamNumber?: number; + meets?: string; + hours?: string; + competitions?: string; + competitionsPerSeason?: number; + students?: string; + mentors?: number; + alumniMentors?: number; +} + +export const ACCENTS = ["yellow", "green", "orange"] as const; +export type Accent = (typeof ACCENTS)[number]; + +export const PROGRAM_KEYS = ["sc2", "frc", "fll"] as const; +export type ProgramKey = (typeof PROGRAM_KEYS)[number]; + +export const programs = { + sc2: { + name: "South Central STEM Collective", + shortName: "SC2", + href: "/programs", + }, + frc: { + name: "FIRST Robotics Competition", + shortName: "FRC", + teamName: "Biohazard", + teamNumber: 4050, + since: 2012, + ages: "14–18", + tagline: "Design, build, and compete with a full-size robot.", + meets: + "Weekly from April to December; Mondays, Tuesdays, Thursdays and Saturdays from January through competition season", + hours: "Weeknights 6:30–8:30 PM, Saturdays 10 AM–4 PM", + competitions: + "The Greater Pittsburgh Regional every season, plus one more event within driving range. Events run Thursday to Saturday.", + competitionsPerSeason: 2, + students: "10–20", + mentors: 5, + alumniMentors: 3, + accent: "green", + href: "/programs/frc", + }, + fll: { + name: "FIRST LEGO League", + shortName: "FLL", + ages: "9–16", + tagline: "Build a LEGO® robot, solve problems, and share your ideas.", + accent: "orange", + href: "/programs/fll", + }, +} as const satisfies Record; + +export type ProgramTheme = Exclude; + +interface Route { + readonly href: PagePath; + readonly label: string; +} + +interface PanelLink extends Route { + readonly icon: string; +} + +interface Panel { + readonly programs: readonly ProgramTheme[]; + readonly links: readonly PanelLink[]; +} + +export interface NavLink extends Route { + readonly panel?: Panel; + readonly surfaces: readonly ("header" | "sheet" | "sheet-cta")[]; +} + +const calendar = { label: "Calendar", href: "/calendar/sc2" } as const; +const frcCalendar = { label: "Biohazard calendar", href: "/calendar/frc" } as const; +const donate = { label: "Donate", href: "/donate" } as const; +const sponsors = { label: "Sponsors", href: "/sponsors" } as const; +const about = { label: "About", href: "/about" } as const; +const programsHub = { label: "Programs", href: programs.sc2.href } as const; +const robots = { label: "Season history", href: `${programs.frc.href}/robots` } as const; +const contact = { label: "Contact", href: "/contact" } as const; + +const programsPanel = { + programs: ["fll", "frc"], + links: [ + { ...robots, icon: "bot" }, + { ...calendar, icon: "calendar" }, + ], +} as const; + +export const nav = { + primary: [ + { ...about, surfaces: ["header", "sheet"] }, + { ...programsHub, surfaces: ["header", "sheet"], panel: programsPanel }, + { ...sponsors, surfaces: ["header", "sheet"] }, + { ...calendar, surfaces: ["sheet"] }, + { ...donate, surfaces: ["header", "sheet-cta"] }, + ], + + about, + calendar, + frcCalendar, + contact, + donate, + programs: programsHub, + robots, + sponsors, + + cta: { label: "Get involved", href: "/get-involved" }, +} as const satisfies { + about: Route; + calendar: Route; + frcCalendar: Route; + contact: Route; + cta: Route; + donate: Route; + primary: readonly NavLink[]; + programs: Route; + robots: Route; + sponsors: Route; +}; + +export const socials = [ + { + label: "Facebook", + href: site.social.facebook, + profile: site.socialProfiles.facebook, + icon: "logo--facebook", + }, + { + label: "LinkedIn", + href: site.social.linkedin, + profile: site.socialProfiles.linkedin, + icon: "logo--linkedin", + }, + { + label: "GitHub", + href: site.social.github, + profile: site.socialProfiles.github, + icon: "logo--github", + }, +] as const; diff --git a/src/layouts/BaseLayout.astro b/src/layouts/BaseLayout.astro new file mode 100644 index 00000000..c0125a0e --- /dev/null +++ b/src/layouts/BaseLayout.astro @@ -0,0 +1,79 @@ +--- +import type { Placement, SeoProps } from "@/lib/seo"; + +import Analytics from "@/components/Analytics.astro"; +import JsonLd from "@/components/JsonLd.astro"; +import Seo from "@/components/Seo.astro"; +import DrawingFrame from "@/components/ui/DrawingFrame.astro"; +import Footer from "@/components/ui/Footer.astro"; +import Navbar from "@/components/ui/Navbar.astro"; +import { programs, site } from "@/data/site"; +import { breadcrumbs, organization } from "@/lib/jsonld"; +import "@/styles/global.css"; + +type Props = Omit & + Placement & { + register?: "blueprint"; + }; + +const { trail = [], register, ...seo } = Astro.props; +const { theme } = seo; +const accent = theme === undefined ? undefined : programs[theme].accent; +--- + + + + + + + + + + + + + + + + {trail.length > 0 && } + + + +
+ Skip to content + + +
+ {register === "blueprint" && } + +
+