Compare commits

..

No commits in common. "archive/legacy-vite-2026-07-20" and "main" have entirely different histories.

363 changed files with 40972 additions and 12139 deletions

View file

@ -1,8 +1,6 @@
.git
.github
.forgejo
.astro
.DS_Store
dist
node_modules
npm-debug.log*
Dockerfile*
README.md

18
.forgejo/workflows/ci.yml Normal file
View file

@ -0,0 +1,18 @@
name: CI
on:
push:
branches: [main]
pull_request:
jobs:
validate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- run: npm ci
- run: npm run ci

51
.gitignore vendored
View file

@ -1,24 +1,33 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
# Dependencies
node_modules/
node_modules
dist
dist-ssr
*.local
# Build output
dist/
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
# Astro
.astro/
# Vite
node_modules/.vite/
# Environment
.env
.env.*
# OS
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
Thumbs.db
# IDE
.vscode/
.idea/
*.swp
*.swo
# Lock files (keep package-lock.json, ignore others)
bun.lockb
yarn.lock
pnpm-lock.yaml
# Local Netlify folder
.netlify

123
CLAUDE.md Normal file
View file

@ -0,0 +1,123 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Commands
```bash
npm run dev # astro dev (local dev server)
npm run build # astro build → dist/ (static output)
npm run preview # serve built site
npm run lint # ESLint over .js/.jsx/.ts/.tsx/.astro
npm run lint:fix # auto-fix lint issues
npm run format # prettier --write .
npm run format:check # prettier --check .
```
No test suite is configured. Requires Node >= 22.12.0.
`src/components/ui/**` is ignored by ESLint (shadcn-generated code).
## Architecture
Astro 6 + React 19 + Tailwind 4 + shadcn/ui. Output is `static` (`astro.config.mjs`). The site is deployed to Netlify; a single serverless function (`netlify/functions/admin.mts`, routed at `/api/admin`) backs the admin UI. The root README and `components.json` still reference the Hatch template this was forked from — marketing/sections components (`src/components/sections/hatch-*`) and the work/services MDX collections are leftover from that template.
### The interactives system (core content)
Each interactive is a self-contained React component in `src/components/interactives/<Name>.tsx` — they freely import shadcn UI, framer-motion, katex, recharts, canvas-confetti, three/@react-three/fiber, etc. Many are 5002000+ LOC single-file components with their own state machines.
Three registrations must be kept in sync when adding or renaming an interactive:
1. **`src/lib/interactives.ts`** — the canonical registry. Exports `allInteractives` (with `slug`, `title`, `description`, `tags`, `themes`, optional `subcategory`, `dateAdded`, `hasGreenScreen`, `status: "published" | "draft" | "idea"`) and `allThemes`. `publishedInteractives` filters out drafts/ideas; this is what public pages use. `getStaticPaths()` here drives the `/i/[slug]` and `/embed/[slug]` routes and excludes `idea` entries.
2. **`src/components/InteractiveRenderer.tsx`** — a `componentMap` of `slug → () => import(...)` that lazy-loads the component. `<InteractiveRenderer client:load slug={...} />` is the single entry point used by both the full page and embed routes.
3. **`src/lib/interactive-components.ts`** — a second near-duplicate componentMap. Both must be updated together (the duplication is historical; grep for the slug before editing).
The admin API (`applyStatusChange` / `applyDelete` in `netlify/functions/admin.mts`) parses `src/lib/interactives.ts` with string matching against `slug: "..."` and `status: "..." as const,`. Preserve that exact formatting when editing entries — otherwise admin-driven status commits and deletes will silently fail.
### Routing
- `/``HomepageHero` (`src/pages/index.astro`)
- `/i/[slug]` → full interactive page with navbar, breadcrumb, admin bar, share bar (`InteractiveLayout`)
- `/embed/[slug]` → same interactive in a bare shell (`EmbedLayout`) for iframe embedding; generated for every non-idea interactive
- `/themes` and `/themes/[theme]` → theme landing pages. "Discrete Math" is the one theme with subcategories (rendered via `ThemeContent` and `/themes/discrete-math/[sub]`); other themes list interactives directly and show an `AddIdeaForm` when the admin is logged in.
- `/admin` → unlinked dashboard (`<meta name="robots" content="noindex">`)
- Blog (`/blog`, `/blog/[...slug]`) reads from the `blog` content collection (`src/content.config.ts`). `scripts/sync-blog.sh` copies posts from a hardcoded Obsidian vault path — it only works on the author's machine.
### Admin system
Client code in `src/lib/admin.ts` keeps state in `localStorage` (auth flag, notes, status overrides, ideas, todos) and mirrors every write to `POST /api/admin`. The admin password is hard-coded in `src/lib/admin.ts` and echoed in the Netlify function (override via the `ADMIN_PASSWORD` env var in production) — this is a personal tool, not a real auth boundary. Don't add secrets here.
The Netlify function persists state by committing to this same repo via the GitHub Contents API:
- `src/data/admin-data.json` — notes, status overrides, ideas, todos. Bundled at build time and imported as a fallback when the API is unreachable (e.g. under `astro dev`).
- `src/lib/interactives.ts` — for `commitStatus` and `deleteInteractive`, the function text-edits this source file and commits the result. Commits from the admin UI use descriptive messages like `admin: set <slug> to <status>`.
Requires `GITHUB_TOKEN` env var on Netlify; without it, writes silently no-op.
### Todo & idea workflow (Fizzy kanban mirror)
The admin UI lets the author create **todos** (per-interactive, stored in `src/data/admin-data.json` under `todos[slug]`) and **ideas** (proposals for new interactives, under `ideas[]`). When an agent is asked to review or work on these, mirror the work to the author's Fizzy kanban board.
**Board**
- URL: `https://fizzy.neeldhara.cloud/7/boards/03fziwyb6y5amnepbensjq0sv/`
- Account: `7`
- Auth: Fizzy personal access token. **Do not commit it to this repo.** Read it from the `FIZZY_PAT` env var (or whatever secret store the author provides). If the token is unavailable, skip the mirror step and flag it to the user — never hardcode a fallback.
**Card conventions**
- One card per todo (title = the todo text) or per idea (title = idea title).
- The interactive **slug** (e.g. `parity-bits-game`, `three-bank-accounts`) becomes a **tag** on the card, grouping cards by interactive. For ideas not yet tied to a slug, use the idea's working title as a tag.
- **Column placement:**
- Todo the agent has made progress on → **Review** column.
- Idea the agent has implemented → **Review** column, with an implementation overview in the card body (what was built, files touched, trade-offs).
- Todos/ideas without progress → whatever the board's inbox/backlog column is. Confirm with the user if unclear.
- **Card description must include a review link** — the GitHub URL for the staging branch (compare view vs. main) or, if a PR exists, the PR URL.
**Commit flow**
- Progress on todos/ideas goes to a **staging branch**, not directly to `main` and not to a per-session `claude/*` branch (unless the user explicitly says so).
- Prefer a stable branch name (e.g. `staging`) so card links stay valid as more work lands, rather than a new branch per todo.
### Green screen mode
Interactives marked `hasGreenScreen: true` can be wrapped in `GreenScreenWrapper` to render on a pure `#00ff00` background with forced black/white foreground colors (for chroma-keying into videos). The wrapper injects `!important` CSS overrides; be aware when debugging styling inside one.
### Aliases and conventions
TS path aliases from `tsconfig.json`: `@/*``src/*`, plus `@components/*`, `@layouts/*`, `@lib/*`. shadcn aliases in `components.json` point components to `@/components`, utils to `@/lib/utils` (the `cn()` helper). Tailwind 4 is wired via `@tailwindcss/vite` (no tailwind.config.js); design tokens live in `src/styles/global.css`. Icon library is `lucide-react`.
ESLint enforces `simple-import-sort` — imports and exports get auto-sorted on `lint:fix`.
## Design & aesthetics
The site has a distinctive editorial-meets-playful feel: serif display type over restrained neutrals, one warm red-orange accent, and generous whitespace. Interactives are the payoff — they should feel polished and a little delightful, not enterprise-CRUD.
**Reach for shadcn before hand-rolling.** `Card` / `CardHeader` / `CardTitle` / `CardContent` is the canonical container for an interactive's sections. `Button`, `Badge`, `Alert`, `Tabs`, `Dialog`, `Slider`, `Switch`, `Tooltip`, `Input`, `Label` are already themed and wired up — use them. Edit `src/components/ui/*` only with deliberate reason (ESLint ignores this dir because it's shadcn-generated).
**Always use design tokens, never raw hex.** Colors live as CSS variables in `src/styles/global.css` and are exposed as Tailwind utilities:
- Surfaces: `bg-background`, `bg-card`, `bg-muted`, `bg-accent`
- Text: `text-foreground`, `text-muted-foreground`
- Brand accent: `bg-primary` / `text-primary` / `text-primary-foreground` — use sparingly, for true calls-to-action
- Borders: `border-border`
- Status: `text-destructive`, `text-success`, chart colors `chart-1`..`chart-5`
Colors are authored in OKLCH. If you need a shade that isn't in the palette, add a token rather than inlining.
**Dark mode is mandatory.** The `.dark` class on `<html>` flips the tokens. Any literal tailwind color (`bg-green-100`, `text-amber-700`, etc.) *must* come with a `dark:` counterpart — see `ThreeBankAccounts.tsx`'s `RANK_COLORS` for the pattern. Better yet, lean on design tokens so dark mode is automatic.
**Typography.** Castoro (serif) for `h1`/`h2` via `.font-display`; Imprima/Geist (sans) for body and `h3+`. This pairing is load-bearing for the editorial feel — don't swap `h1` to sans on a whim. The `container` utility caps at 1200px; `container-sm` caps at 960px for denser reading layouts.
**Radii.** Base `--radius` is `0.625rem`, exposed as `rounded-sm``rounded-4xl` on the Tailwind scale. Cards and panels are typically `rounded-xl`; pill buttons/badges are `rounded-full`; small chips are `rounded-md`.
**Motion and delight.** `framer-motion` for layout/presence animations, `canvas-confetti` for win states and discovery moments, `katex` for math (`katex/dist/katex.min.css` must be imported where used). `prefers-reduced-motion` is honored globally in `global.css` — you don't need to guard individual transitions, but don't gate critical state changes behind animation either.
**Interactive layout patterns.** Looking across the folder, published interactives tend to:
- Open with a short framing `Alert` or intro card explaining the premise
- Put the play area center stage, controls below or to the side
- Use `Tabs` to separate modes (e.g. *Play / Explore / Solution* in `ThreeBankAccounts`)
- Use `Badge` for status, tags, and hint chips
- Reset with a `RotateCcw` icon button; celebrate wins with confetti + a toast (`sonner` is wired up)
- Stay responsive — test at mobile widths, since these embed into blog posts and videos
**Taste defaults.** Prefer fewer, cleaner controls over exhaustive settings. If a new feature needs an options panel, that's a signal to reconsider the UX. When in doubt, match the visual weight and density of an existing, well-regarded interactive in the same theme rather than inventing a new idiom.
### Build gotchas
`astro.config.mjs` sanitizes Rollup chunk filenames (`[^\w./-]``_`) and pins esbuild to `es2022` / `utf8` — these work around Netlify's esbuild choking on `!` and `~` in chunk names. Don't revert without retesting on Netlify. `netlify.toml` also disables Netlify's own JS/CSS bundling so Vite's output is shipped as-is.

View file

@ -8,12 +8,22 @@ RUN npm ci
COPY . .
RUN npm run ci
FROM nginx:1.27-alpine AS runtime
FROM node:22-alpine AS runtime
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/dist /usr/share/nginx/html
WORKDIR /app
ENV NODE_ENV=production
ENV PORT=80
COPY package.json package-lock.json ./
RUN npm ci --omit=dev && npm cache clean --force
COPY --from=build /app/dist ./dist
COPY server ./server
EXPOSE 80
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD wget -q --spider http://127.0.0.1/ || exit 1
CMD wget -q --spider http://127.0.0.1/api/health || exit 1
CMD ["node", "server/index.mjs"]

18
LICENSE Normal file
View file

@ -0,0 +1,18 @@
Shadcnblocks.com License
Copyright (c) 2024 Shadcnblocks.com
This license grants you, the purchaser, a non-exclusive, worldwide, and perpetual right to use the Shadcnblocks.com template under the following terms:
✅ What You Can Do
- Use the template to build unlimited custom websites or digital products for yourself, your company or your clients.
- Use the template in both personal and commercial projects.
- Sell or transfer finished end products to clients. An “end product” must be a customized implementation requiring your own skill and effort (e.g., a launched website or app), not the template in its original or minimally modified form.
❌ What You Cant Do
- You may not use any part of this template in a resellable template, UI kit, design system, competing product, derivative work, or similar offering.
- You may not redistribute or publicly share the source code of this template, including uploading it to any public or open-source code repository.
- You may not offer or provide this template through any website builder, online platform, or tool where it could be accessed or resold to end users or third parties.
- You may not use this template as input or training data for large language models (LLMs) or AI tools to generate derivate templates and works that may resold, redistributed, or made publicly available.

View file

@ -1,79 +1,36 @@
# Welcome to your Lovable project
# Hatch Astro Template
## Project info
Hatch Astro Template is a premium template built by https://www.shadcnblocks.com
**URL**: https://lovable.dev/projects/5ee9d1e7-af25-47e9-ab4c-bdbca4554e32
- [Demo](https://hatch-astro-template.vercel.app/)
- [Documentation](https://docs.shadcnblocks.com/templates/getting-started)
## How can I edit this code?
## Screenshot
There are several ways of editing your application.
![Hatch Astro Template screenshot](./public/og-image.jpg)
**Use Lovable**
## Getting Started
Simply visit the [Lovable Project](https://lovable.dev/projects/5ee9d1e7-af25-47e9-ab4c-bdbca4554e32) and start prompting.
```bash
npm install
```
Changes made via Lovable will be committed automatically to this repo.
**Use your preferred IDE**
If you want to work locally using your own IDE, you can clone this repo and push changes. Pushed changes will also be reflected in Lovable.
The only requirement is having Node.js & npm installed - [install with nvm](https://github.com/nvm-sh/nvm#installing-and-updating)
Follow these steps:
```sh
# Step 1: Clone the repository using the project's Git URL.
git clone <YOUR_GIT_URL>
# Step 2: Navigate to the project directory.
cd <YOUR_PROJECT_NAME>
# Step 3: Install the necessary dependencies.
npm i
# Step 4: Start the development server with auto-reloading and an instant preview.
```bash
npm run dev
```
**Edit a file directly in GitHub**
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
- Navigate to the desired file(s).
- Click the "Edit" button (pencil icon) at the top right of the file view.
- Make your changes and commit the changes.
## Forgejo and Dokploy
**Use GitHub Codespaces**
The `forgejo` remote points to `Websites/interactives`. Forgejo Actions runs lint, the classroom synchronization test, and the Astro production build on every push to `main`. Dokploy builds the checked-in `Dockerfile`; the container serves the static Astro site and the `/sync` WebSocket endpoint on port 80.
- Navigate to the main page of your repository.
- Click on the "Code" button (green button) near the top right.
- Select the "Codespaces" tab.
- Click on "New codespace" to launch a new Codespace environment.
- Edit files directly within the Codespace and commit and push your changes once you're done.
## Tech Stack
## What technologies are used for this project?
- Astro
- Tailwind 4
- shadcn/ui
This project is built with:
## Deploy on Vercel
- Vite
- TypeScript
- React
- shadcn-ui
- Tailwind CSS
## How can I deploy this project?
Simply open [Lovable](https://lovable.dev/projects/5ee9d1e7-af25-47e9-ab4c-bdbca4554e32) and click on Share -> Publish.
### Forgejo and Dokploy
The production container is built from `Dockerfile` and serves the Vite output with NGINX, including React Router fallback handling. During the image build, `npm ci` installs the locked dependencies and `npm run ci` runs lint plus the production Vite build.
Dokploy is connected to the `Websites/interactives` Forgejo repository through a push webhook with autodeploy enabled. A push to `main` triggers the Dockerfile CI checks and replaces the running service only after every build step succeeds.
## Can I connect a custom domain to my Lovable project?
Yes, you can!
To connect a domain, navigate to Project > Settings > Domains and click Connect Domain.
Read more here: [Setting up a custom domain](https://docs.lovable.dev/tips-tricks/custom-domain#step-by-step-guide)
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com)

59
astro.config.mjs Normal file
View file

@ -0,0 +1,59 @@
// @ts-check
import { defineConfig, fontProviders } from "astro/config";
import mdx from "@astrojs/mdx";
import sitemap from "@astrojs/sitemap";
import react from "@astrojs/react";
import tailwindcss from "@tailwindcss/vite";
import { attachDiceSyncServer } from "./server/dice-sync.mjs";
// Admin API runs as a standalone Netlify Function (see netlify/functions/admin.mts)
const attachedServers = new WeakSet();
const classroomSync = () => ({
name: "classroom-dice-sync",
configureServer(server) {
if (server.httpServer && !attachedServers.has(server.httpServer)) {
attachDiceSyncServer(server.httpServer);
attachedServers.add(server.httpServer);
}
},
configurePreviewServer(server) {
if (server.httpServer && !attachedServers.has(server.httpServer)) {
attachDiceSyncServer(server.httpServer);
attachedServers.add(server.httpServer);
}
},
});
// https://astro.build/config
export default defineConfig({
site: "https://interactives.neeldhara.com",
integrations: [mdx(), sitemap(), react()],
output: "static",
fonts: [
{
provider: fontProviders.fontsource(),
name: "Imprima",
cssVariable: "--font-imprima",
},
],
vite: {
plugins: [tailwindcss(), classroomSync()],
build: {
target: "es2022",
minify: "esbuild",
// Sanitize chunk filenames — Netlify esbuild chokes on ! and ~ in names
rollupOptions: {
output: {
sanitizeFileName: (name) => name.replace(/[^\w./-]/g, "_"),
},
},
},
esbuild: {
target: "es2022",
charset: "utf8",
},
},
});

BIN
bun.lockb

Binary file not shown.

View file

@ -1,20 +1,28 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "default",
"style": "radix-nova",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "tailwind.config.ts",
"css": "src/index.css",
"baseColor": "slate",
"config": "",
"css": "src/styles/global.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"rtl": false,
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"menuColor": "default",
"menuAccent": "subtle",
"registries": {
"@magicui": "https://magicui.design/r/{name}",
"@aceternity": "https://ui.aceternity.com/registry/{name}.json"
}
}

28
components.json.bak Normal file
View file

@ -0,0 +1,28 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "radix-nova",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/styles/global.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"rtl": false,
"menuColor": "default",
"menuAccent": "subtle",
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"registries": {
"@magicui": "https://magicui.design/r/{name}",
"@aceternity": "https://ui.aceternity.com/registry/{name}.json"
}
}

View file

@ -1,29 +0,0 @@
import js from "@eslint/js";
import globals from "globals";
import reactHooks from "eslint-plugin-react-hooks";
import reactRefresh from "eslint-plugin-react-refresh";
import tseslint from "typescript-eslint";
export default tseslint.config(
{ ignores: ["dist"] },
{
extends: [js.configs.recommended, ...tseslint.configs.recommended],
files: ["**/*.{ts,tsx}"],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
plugins: {
"react-hooks": reactHooks,
"react-refresh": reactRefresh,
},
rules: {
...reactHooks.configs.recommended.rules,
"react-refresh/only-export-components": [
"warn",
{ allowConstantExport: true },
],
"@typescript-eslint/no-unused-vars": "off",
},
}
);

62
eslint.config.mjs Normal file
View file

@ -0,0 +1,62 @@
import astroPlugin from "eslint-plugin-astro";
import astroParser from "astro-eslint-parser";
import tsPlugin from "@typescript-eslint/eslint-plugin";
import tsParser from "@typescript-eslint/parser";
import simpleImportSort from "eslint-plugin-simple-import-sort";
export default [
{
ignores: [
"node_modules/**",
"dist/**",
".astro/**",
"src/components/ui/**",
],
},
// Lint .astro files
{
files: ["**/*.astro"],
languageOptions: {
parser: astroParser,
parserOptions: {
parser: tsParser,
ecmaVersion: "latest",
sourceType: "module",
},
},
plugins: { astro: astroPlugin, "simple-import-sort": simpleImportSort },
rules: {
...astroPlugin.configs.recommended.rules,
"simple-import-sort/imports": "error",
"simple-import-sort/exports": "error",
},
},
// Lint TS/JS/React files
{
files: ["**/*.{ts,tsx,js,jsx}"],
languageOptions: {
parser: tsParser,
parserOptions: {
ecmaVersion: "latest",
sourceType: "module",
ecmaFeatures: { jsx: true },
},
},
plugins: {
"@typescript-eslint": tsPlugin,
"simple-import-sort": simpleImportSort,
},
rules: {
...tsPlugin.configs.recommended.rules,
"simple-import-sort/imports": "error",
"simple-import-sort/exports": "error",
// Optional: relax some noisy rules if needed
"@typescript-eslint/no-unused-vars": [
"warn",
{ argsIgnorePattern: "^_", varsIgnorePattern: "^_" },
],
},
},
];

View file

@ -1,27 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>interactive-intellectual-vault</title>
<meta name="description" content="Lovable Generated Project" />
<meta name="author" content="Lovable" />
<!-- Google Fonts -->
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Lato:wght@300;400;700;900&family=Nunito:wght@400;600;700;800;900&display=swap">
<meta property="og:title" content="interactive-intellectual-vault" />
<meta property="og:description" content="Lovable Generated Project" />
<meta property="og:type" content="website" />
<meta property="og:image" content="https://lovable.dev/opengraph-image-p98pqg.png" />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:site" content="@lovable_dev" />
<meta name="twitter:image" content="https://lovable.dev/opengraph-image-p98pqg.png" />
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

18
netlify.toml Normal file
View file

@ -0,0 +1,18 @@
[build]
command = "npm run build"
publish = "dist"
functions = "netlify/functions"
[build.environment]
NODE_VERSION = "22"
# Disable Netlify's asset post-processing — Astro/Vite handles bundling
[build.processing]
skip_processing = false
[build.processing.js]
bundle = false
minify = false
[build.processing.css]
bundle = false
minify = false

274
netlify/functions/admin.mts Normal file
View file

@ -0,0 +1,274 @@
import type { Context } from "@netlify/functions";
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || "yukti2025";
const GITHUB_TOKEN = process.env.GITHUB_TOKEN || "";
const GITHUB_REPO = "nm-static/interactives";
const GITHUB_BRANCH = "main";
const DEFAULT_DATA = { statusOverrides: {}, notes: {}, ideas: [], todos: {} };
// --- GitHub API helpers ---
async function githubGetFile(path: string): Promise<{ content: string; sha: string } | null> {
if (!GITHUB_TOKEN) return null;
try {
const res = await fetch(
`https://api.github.com/repos/${GITHUB_REPO}/contents/${path}?ref=${GITHUB_BRANCH}`,
{ headers: { Authorization: `Bearer ${GITHUB_TOKEN}`, Accept: "application/vnd.github.v3+json" } }
);
if (!res.ok) return null;
const data = await res.json();
const content = Buffer.from(data.content, "base64").toString("utf-8");
return { content, sha: data.sha };
} catch { return null; }
}
async function githubPutFile(path: string, content: string, message: string, sha?: string): Promise<boolean> {
if (!GITHUB_TOKEN) return false;
try {
const body: any = {
message,
content: Buffer.from(content).toString("base64"),
branch: GITHUB_BRANCH,
};
if (sha) body.sha = sha;
const res = await fetch(
`https://api.github.com/repos/${GITHUB_REPO}/contents/${path}`,
{
method: "PUT",
headers: { Authorization: `Bearer ${GITHUB_TOKEN}`, Accept: "application/vnd.github.v3+json", "Content-Type": "application/json" },
body: JSON.stringify(body),
}
);
return res.ok;
} catch { return false; }
}
// --- Data helpers ---
async function readData() {
const file = await githubGetFile("src/data/admin-data.json");
if (file) return { ...DEFAULT_DATA, ...JSON.parse(file.content), _sha: file.sha };
return { ...DEFAULT_DATA };
}
async function writeData(data: any, message = "admin: update admin data") {
const { _sha, ...cleanData } = data;
const content = JSON.stringify(cleanData, null, 2) + "\n";
await githubPutFile("src/data/admin-data.json", content, message, _sha);
}
type TodoLike = { id: string; refId?: string; done?: boolean; remark?: string };
/** Describe what changed between two whole-todos snapshots. */
function describeTodoChanges(
oldTodos: Record<string, TodoLike[]> = {},
newTodos: Record<string, TodoLike[]> = {}
): string | null {
const perSlug: string[] = [];
const slugs = new Set([...Object.keys(oldTodos), ...Object.keys(newTodos)]);
for (const slug of slugs) {
const oldList = oldTodos[slug] || [];
const newList = newTodos[slug] || [];
const oldById = new Map(oldList.map((t) => [t.id, t]));
const newById = new Map(newList.map((t) => [t.id, t]));
const added: TodoLike[] = [];
const removed: TodoLike[] = [];
const markedDone: TodoLike[] = [];
const reopened: TodoLike[] = [];
const remarkChanged: TodoLike[] = [];
for (const [id, t] of newById) {
if (!oldById.has(id)) added.push(t);
}
for (const [id, t] of oldById) {
if (!newById.has(id)) removed.push(t);
}
for (const [id, newT] of newById) {
const oldT = oldById.get(id);
if (!oldT) continue;
if ((oldT.done ?? false) !== (newT.done ?? false)) {
(newT.done ? markedDone : reopened).push(newT);
} else if ((oldT.remark || "") !== (newT.remark || "")) {
remarkChanged.push(newT);
}
}
const refs = (list: TodoLike[]) =>
list.map((t) => t.refId || t.id.slice(0, 8)).join(", ");
const parts: string[] = [];
if (added.length) parts.push(`add ${refs(added)}`);
if (removed.length) parts.push(`remove ${refs(removed)}`);
if (markedDone.length) parts.push(`mark ${refs(markedDone)} done`);
if (reopened.length) parts.push(`reopen ${refs(reopened)}`);
if (remarkChanged.length) parts.push(`remark on ${refs(remarkChanged)}`);
if (parts.length) perSlug.push(`${slug}: ${parts.join(", ")}`);
}
return perSlug.length ? perSlug.join("; ") : null;
}
// --- Source file helpers ---
function applyStatusChange(source: string, slug: string, newStatus: string): string | null {
const slugPattern = `slug: "${slug}"`;
const slugIndex = source.indexOf(slugPattern);
if (slugIndex === -1) return null;
const closingIndex = source.indexOf("\n },", slugIndex);
if (closingIndex === -1) return null;
const block = source.substring(slugIndex, closingIndex);
const statusMatch = block.match(/status: "(\w+)" as const,/);
if (!statusMatch) return null;
const oldStatusStr = `status: "${statusMatch[1]}" as const,`;
const newStatusStr = `status: "${newStatus}" as const,`;
const statusOffset = block.indexOf(oldStatusStr);
if (statusOffset === -1) return null;
const absOffset = slugIndex + statusOffset;
return source.substring(0, absOffset) + newStatusStr + source.substring(absOffset + oldStatusStr.length);
}
function applyDelete(source: string, slug: string): string | null {
const slugPattern = `slug: "${slug}"`;
const slugIndex = source.indexOf(slugPattern);
if (slugIndex === -1) return null;
let braceStart = source.lastIndexOf(" {", slugIndex);
if (braceStart === -1) return null;
const closingPattern = "\n },";
let braceEnd = source.indexOf(closingPattern, slugIndex);
if (braceEnd === -1) return null;
braceEnd += closingPattern.length;
const beforeBrace = source.substring(0, braceStart);
const lastNewline = beforeBrace.lastIndexOf("\n");
const lineBefore = beforeBrace.substring(lastNewline + 1).trim();
const deleteFrom = lineBefore.startsWith("//") ? lastNewline + 1 : braceStart;
return source.substring(0, deleteFrom) + source.substring(braceEnd).replace(/\n{3,}/g, "\n\n");
}
async function updateInteractivesSource(slug: string, newStatus: string): Promise<boolean> {
const file = await githubGetFile("src/lib/interactives.ts");
if (!file) return false;
const updated = applyStatusChange(file.content, slug, newStatus);
if (!updated) return false;
return await githubPutFile("src/lib/interactives.ts", updated, `admin: set ${slug} to ${newStatus}`, file.sha);
}
async function deleteInteractiveFromSource(slug: string): Promise<boolean> {
const file = await githubGetFile("src/lib/interactives.ts");
if (!file) return false;
const updated = applyDelete(file.content, slug);
if (!updated) return false;
return await githubPutFile("src/lib/interactives.ts", updated, `admin: delete ${slug}`, file.sha);
}
// --- Auth ---
function checkAuth(request: Request): boolean {
const auth = request.headers.get("x-admin-password");
return auth === ADMIN_PASSWORD;
}
function json(data: any, status = 200) {
return new Response(JSON.stringify(data), {
status,
headers: { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*" },
});
}
// --- Handler ---
export default async (request: Request, context: Context) => {
// Handle CORS preflight
if (request.method === "OPTIONS") {
return new Response(null, {
headers: {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type, x-admin-password",
},
});
}
if (!checkAuth(request)) {
return json({ error: "Unauthorized" }, 401);
}
if (request.method === "GET") {
const data = await readData();
const { _sha, ...cleanData } = data;
return json(cleanData);
}
if (request.method === "POST") {
const body = await request.json();
const data = await readData();
let message = "admin: update admin data";
if (body.action === "setStatus") {
data.statusOverrides[body.slug] = body.status;
message = `admin: override ${body.slug} status to ${body.status}`;
} else if (body.action === "clearStatus") {
delete data.statusOverrides[body.slug];
message = `admin: clear status override on ${body.slug}`;
} else if (body.action === "commitStatus") {
const success = await updateInteractivesSource(body.slug, body.status);
if (success) {
delete data.statusOverrides[body.slug];
await writeData(data, `admin: clear override after committing ${body.slug} status`);
return json({ ok: true, committed: true });
}
return json({ ok: false, error: "Failed to update source file" }, 500);
} else if (body.action === "setNote") {
if (body.note?.trim()) {
data.notes[body.slug] = body.note;
message = `admin: update note on ${body.slug}`;
} else {
delete data.notes[body.slug];
message = `admin: clear note on ${body.slug}`;
}
} else if (body.action === "addIdea") {
const idea = body.idea;
data.ideas.push({
...idea,
id: idea.id || crypto.randomUUID(),
createdAt: idea.createdAt || new Date().toISOString().split("T")[0],
});
message = `admin: add idea "${idea.title || idea.id?.slice(0, 8) || ""}"`.trim();
} else if (body.action === "removeIdea") {
const removed = data.ideas.find((i: any) => i.id === body.id);
data.ideas = data.ideas.filter((i: any) => i.id !== body.id);
message = `admin: remove idea${removed?.title ? ` "${removed.title}"` : ""}`;
} else if (body.action === "updateIdea") {
const existing = data.ideas.find((i: any) => i.id === body.id);
data.ideas = data.ideas.map((i: any) =>
i.id === body.id ? { ...i, ...body.updates } : i
);
message = `admin: update idea${existing?.title ? ` "${existing.title}"` : ""}`;
} else if (body.action === "deleteInteractive") {
const success = await deleteInteractiveFromSource(body.slug);
delete data.statusOverrides[body.slug];
delete data.notes[body.slug];
delete data.todos?.[body.slug];
await writeData(data, `admin: clean up admin data for deleted ${body.slug}`);
return json({ ok: success, deleted: success });
} else if (body.action === "setTodos") {
const desc = describeTodoChanges(data.todos, body.todos);
data.todos = body.todos;
if (desc) message = `admin: ${desc}`;
} else if (body.action === "saveAll") {
if (body.statusOverrides) data.statusOverrides = body.statusOverrides;
if (body.notes) data.notes = body.notes;
if (body.ideas) data.ideas = body.ideas;
if (body.todos) data.todos = body.todos;
message = "admin: bulk sync (saveAll)";
}
await writeData(data, message);
return json({ ok: true });
}
return json({ error: "Method not allowed" }, 405);
};
export const config = {
path: "/api/admin",
};

View file

@ -1,22 +0,0 @@
server {
listen 80;
listen [::]:80;
server_name _;
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
location = /index.html {
add_header Cache-Control "no-cache";
}
location ~* \.(?:css|js|mjs|jpg|jpeg|png|gif|ico|svg|webp|woff|woff2)$ {
expires 1y;
add_header Cache-Control "public, immutable";
try_files $uri =404;
}
}

20957
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -1,90 +1,85 @@
{
"name": "vite_react_shadcn_ts",
"private": true,
"version": "0.0.0",
"name": "hatch-astro-template",
"type": "module",
"version": "1.0.0",
"author": "Shadcnblocks.com",
"scripts": {
"dev": "vite",
"build": "vite build",
"build:dev": "vite build --mode development",
"ci": "npm run lint && npm run build",
"lint": "eslint .",
"preview": "vite preview"
"dev": "astro dev",
"build": "astro build",
"ci": "npm run test:sync && npm run build",
"preview": "astro preview",
"astro": "astro",
"lint": "eslint . --ext .js,.jsx,.ts,.tsx,.astro",
"lint:fix": "eslint . --ext .js,.jsx,.ts,.tsx,.astro --fix",
"format": "prettier --write .",
"format:check": "prettier --check .",
"start": "node server/index.mjs",
"test:sync": "node --test server/dice-sync.test.mjs"
},
"engines": {
"node": ">=22.12.0"
},
"dependencies": {
"@hookform/resolvers": "^3.9.0",
"@radix-ui/react-accordion": "^1.2.0",
"@radix-ui/react-alert-dialog": "^1.1.1",
"@radix-ui/react-aspect-ratio": "^1.1.0",
"@radix-ui/react-avatar": "^1.1.0",
"@radix-ui/react-checkbox": "^1.1.1",
"@radix-ui/react-collapsible": "^1.1.0",
"@radix-ui/react-context-menu": "^2.2.1",
"@radix-ui/react-dialog": "^1.1.2",
"@radix-ui/react-dropdown-menu": "^2.1.1",
"@radix-ui/react-hover-card": "^1.1.1",
"@radix-ui/react-label": "^2.1.0",
"@radix-ui/react-menubar": "^1.1.1",
"@radix-ui/react-navigation-menu": "^1.2.0",
"@radix-ui/react-popover": "^1.1.1",
"@radix-ui/react-progress": "^1.1.0",
"@radix-ui/react-radio-group": "^1.2.0",
"@radix-ui/react-scroll-area": "^1.1.0",
"@radix-ui/react-select": "^2.1.1",
"@radix-ui/react-separator": "^1.1.0",
"@radix-ui/react-slider": "^1.2.0",
"@radix-ui/react-slot": "^1.1.0",
"@radix-ui/react-switch": "^1.1.0",
"@radix-ui/react-tabs": "^1.1.0",
"@radix-ui/react-toast": "^1.2.1",
"@radix-ui/react-toggle": "^1.1.0",
"@radix-ui/react-toggle-group": "^1.1.0",
"@radix-ui/react-tooltip": "^1.1.4",
"@react-three/drei": "^9.122.0",
"@react-three/fiber": "^8.18.0",
"@tanstack/react-query": "^5.56.2",
"@types/qrcode": "^1.5.5",
"canvas-confetti": "^1.9.3",
"@astrojs/netlify": "^7.0.7",
"@astrojs/react": "^5.0.2",
"@astrojs/rss": "^4.0.18",
"@fontsource-variable/geist": "^5.2.8",
"@fontsource/castoro": "^5.2.7",
"@fontsource/imprima": "^5.2.8",
"@fontsource/inter": "^5.2.8",
"@react-three/drei": "^10.7.7",
"@react-three/fiber": "^9.5.0",
"@tailwindcss/typography": "^0.5.16",
"@types/katex": "^0.16.8",
"@types/qrcode": "^1.5.6",
"@types/three": "^0.183.1",
"astro": "^6.1.7",
"canvas-confetti": "^1.9.4",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.0.0",
"date-fns": "^3.6.0",
"embla-carousel-react": "^8.3.0",
"input-otp": "^1.2.4",
"lucide-react": "^0.462.0",
"next-themes": "^0.3.0",
"cmdk": "^1.1.1",
"embla-carousel-autoplay": "^8.5.2",
"embla-carousel-react": "^8.6.0",
"framer-motion": "^11.18.2",
"gifenc": "^1.0.3",
"gray-matter": "^4.0.3",
"input-otp": "^1.4.2",
"katex": "^0.16.45",
"lucide-react": "^0.479.0",
"next-themes": "^0.4.6",
"qrcode": "^1.5.4",
"react": "^18.3.1",
"react-day-picker": "^8.10.1",
"react-dom": "^18.3.1",
"react-hook-form": "^7.53.0",
"react-resizable-panels": "^2.1.3",
"react-router-dom": "^6.26.2",
"recharts": "^2.12.7",
"sonner": "^1.5.0",
"tailwind-merge": "^2.5.2",
"tailwindcss-animate": "^1.0.7",
"three": "^0.160.1",
"vaul": "^0.9.3",
"zod": "^3.23.8"
"radix-ui": "^1.4.3",
"react": "^19.2.4",
"react-day-picker": "^9.14.0",
"react-dom": "^19.2.4",
"react-resizable-panels": "^4.8.0",
"recharts": "^3.8.0",
"shadcn": "^4.1.1",
"sonner": "^2.0.7",
"tailwind-merge": "^3.5.0",
"three": "^0.183.2",
"tw-animate-css": "^1.4.0",
"vaul": "^1.1.2",
"ws": "^8.21.1"
},
"devDependencies": {
"@eslint/js": "^9.9.0",
"@tailwindcss/typography": "^0.5.15",
"@types/node": "^22.5.5",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react-swc": "^3.5.0",
"autoprefixer": "^10.4.20",
"eslint": "^9.9.0",
"eslint-plugin-react-hooks": "^5.1.0-rc.0",
"eslint-plugin-react-refresh": "^0.4.9",
"globals": "^15.9.0",
"lovable-tagger": "^1.1.7",
"postcss": "^8.4.47",
"tailwindcss": "^3.4.11",
"typescript": "^5.5.3",
"typescript-eslint": "^8.0.1",
"vite": "^5.4.1"
"@astrojs/mdx": "^5.0.3",
"@astrojs/sitemap": "^3.7.2",
"@tailwindcss/vite": "^4.1.18",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@typescript-eslint/eslint-plugin": "^8.39.0",
"@typescript-eslint/parser": "^8.39.0",
"astro-eslint-parser": "^1.2.2",
"autoprefixer": "^10.4.21",
"eslint": "^9.32.0",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-astro": "^1.3.1",
"eslint-plugin-simple-import-sort": "^12.1.1",
"postcss": "^8.5.6",
"prettier": "^3.6.2",
"prettier-plugin-astro": "^0.14.1",
"prettier-plugin-tailwindcss": "^0.6.11",
"tailwindcss": "^4.1.11"
}
}

View file

@ -1,6 +0,0 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

View file

@ -1,7 +0,0 @@
/* /index.html 200
X-Frame-Options: DENY
X-Content-Type-Options: nosniff
X-XSS-Protection: 1; mode=block
Referrer-Policy: strict-origin-when-cross-origin
Permissions-Policy: geolocation=(), microphone=(), camera=()

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

BIN
public/favicon/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 609 B

View file

@ -0,0 +1,10 @@
<svg width="38" height="38" viewBox="0 0 38 38" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_25400_136)">
<path d="M28.1875 33.25C32.7709 33.1566 36.4563 29.4077 36.4563 24.799C36.4563 22.5576 35.5665 20.408 33.9826 18.8232L19.9188 4.75V13.4974C19.9188 14.9603 20.4995 16.3634 21.5334 17.3979L24.8279 20.6946L24.8369 20.7034L30.361 26.2312C30.5404 26.4106 30.5404 26.7017 30.361 26.8812C30.1816 27.0607 29.8907 27.0607 29.7114 26.8812L27.8072 24.9758H10.1928L8.28868 26.8812C8.10928 27.0607 7.81842 27.0607 7.63903 26.8812C7.45963 26.7017 7.45963 26.4106 7.63902 26.2312L13.1631 20.7034L13.1721 20.6946L16.4666 17.3979C17.5005 16.3634 18.0812 14.9603 18.0812 13.4974V4.75L4.01738 18.8232C2.43355 20.408 1.54376 22.5576 1.54376 24.799C1.54376 29.4077 5.22918 33.1566 9.81254 33.25H28.1875Z" fill="#FF0A0A"/>
</g>
<defs>
<clipPath id="clip0_25400_136">
<rect width="34.9125" height="28.5" fill="white" transform="translate(1.54376 4.75)"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 993 B

View file

@ -0,0 +1,21 @@
{
"name": "Hatch Template - Shadcnblocks.com",
"short_name": "Hatch",
"icons": [
{
"src": "/web-app-manifest-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "maskable"
},
{
"src": "/web-app-manifest-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
],
"theme_color": "#ffffff",
"background_color": "#ffffff",
"display": "standalone"
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

View file

@ -0,0 +1,6 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12 11.5C14.4853 11.5 16.5 9.48528 16.5 7C16.5 4.51472 14.4853 2.5 12 2.5C9.51472 2.5 7.5 4.51472 7.5 7C7.5 9.48528 9.51472 11.5 12 11.5Z" stroke="currentColor" stroke-width="1.35" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M5.25 20.25V19.25C5.25 16.4886 7.48858 14.25 10.25 14.25H13.75C16.5114 14.25 18.75 16.4886 18.75 19.25V20.25" stroke="currentColor" stroke-width="1.35" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M19.25 8.25L21 10L19.25 11.75" stroke="currentColor" stroke-width="1.35" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M4.75 8.25L3 10L4.75 11.75" stroke="currentColor" stroke-width="1.35" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 819 B

View file

@ -0,0 +1,6 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="12" cy="12" r="8.625" stroke="currentColor" stroke-width="1.35"/>
<circle cx="12" cy="12" r="5.625" stroke="currentColor" stroke-width="1.35" opacity="0.45"/>
<circle cx="12" cy="12" r="2.625" stroke="currentColor" stroke-width="1.35"/>
<circle cx="12" cy="12" r="0.875" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 416 B

View file

@ -0,0 +1,10 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M3 12H21" stroke="currentColor" stroke-width="1" stroke-linecap="round"/>
<path d="M6 8.5V15.5" stroke="currentColor" stroke-width="1" stroke-linecap="round"/>
<path d="M12 6V18" stroke="currentColor" stroke-width="1" stroke-linecap="round"/>
<path d="M18 9.5V14.5" stroke="currentColor" stroke-width="1" stroke-linecap="round"/>
<circle cx="6" cy="12" r="2" stroke="currentColor" stroke-width="1"/>
<circle cx="12" cy="12" r="2.625" stroke="currentColor" stroke-width="1"/>
<circle cx="18" cy="12" r="2" stroke="currentColor" stroke-width="1"/>
<circle cx="12" cy="12" r="0.75" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 729 B

View file

@ -0,0 +1,10 @@
<svg version="1.2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 35 34" width="35" height="34">
<style>
.s0 { fill: #ff0a0a }
.s1 { fill: none;stroke: #ffffff }
</style>
<g>
<path fill-rule="evenodd" class="s0" d="m2 12.25c0-6.08 4.92-11 11-11h9.5c6.08 0 11 4.92 11 11v9.5c0 6.08-4.92 11-11 11h-9.5c-6.08 0-11-4.92-11-11zm24 4.98c0.2-0.09 0.2-0.37 0-0.46l-1.75-0.79c-2.43-1.1-4.38-3.05-5.48-5.48l-0.79-1.75c-0.09-0.19-0.37-0.2-0.46 0l-0.81 1.78c-1.1 2.41-3.04 4.34-5.45 5.44l-1.76 0.8c-0.2 0.09-0.2 0.37 0 0.46l1.81 0.83c2.38 1.1 4.29 3.02 5.39 5.4l0.82 1.79c0.09 0.2 0.37 0.2 0.46 0l0.8-1.76c1.1-2.4 3.02-4.33 5.42-5.43z"/>
<path class="s1" d="m22.5 0.75c6.35 0 11.5 5.15 11.5 11.5v9.5c0 6.35-5.15 11.5-11.5 11.5h-9.5c-6.35 0-11.5-5.15-11.5-11.5v-9.5c0-6.35 5.15-11.5 11.5-11.5zm-5.34 9.99c-1.15 2.52-3.17 4.54-5.7 5.69l-1.26 0.57 1.31 0.61c2.5 1.15 4.5 3.15 5.65 5.64l0.59 1.3 0.58-1.27c1.14-2.51 3.15-4.53 5.66-5.68l1.3-0.6-1.25-0.57c-2.54-1.15-4.57-3.18-5.72-5.72l-0.57-1.25z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1,010 B

View file

@ -0,0 +1,12 @@
<svg version="1.2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 36 35" width="36" height="35">
<style>
.s0 { fill: #4338ca }
.s1 { fill: none;stroke: #ffffff }
.s2 { fill: none;stroke: #ffffff;stroke-linecap: round;stroke-linejoin: round;stroke-width: 2.8 }
</style>
<g>
<path fill-rule="evenodd" class="s0" d="m13 2h10c6.08 0 11 4.92 11 11v10c0 6.08-4.92 11-11 11h-10c-6.08 0-11-4.92-11-11v-10c0-6.08 4.92-11 11-11z"/>
<path fill-rule="evenodd" class="s1" d="m13 1.5h10c6.35 0 11.5 5.15 11.5 11.5v10c0 6.35-5.15 11.5-11.5 11.5h-10c-6.35 0-11.5-5.15-11.5-11.5v-10c0-6.35 5.15-11.5 11.5-11.5z"/>
</g>
<path class="s2" d="m23.49 21.58c-1.47 0-2.89 0.58-3.93 1.62-1.04 1.04-1.62 2.46-1.62 3.93 0-3.07-2.21-5.55-4.94-5.55m10.49-4.94c-1.47 0-2.89 0.58-3.93 1.63-1.04 1.04-1.62 2.45-1.62 3.92 0-3.06-2.21-5.55-4.94-5.55m10.49-4.94c-1.47 0-2.89 0.59-3.93 1.63-1.04 1.04-1.62 2.45-1.62 3.93 0-3.07-2.21-5.56-4.94-5.56m4.94-3.7v7.41"/>
</svg>

After

Width:  |  Height:  |  Size: 954 B

View file

@ -0,0 +1,27 @@
<svg width="60" height="60" viewBox="0 0 60 60" fill="none" xmlns="http://www.w3.org/2000/svg">
<g filter="url(#filter0_dd_25498_516)">
<rect x="14" y="9" width="32" height="32" rx="11" fill="#FF0A0A"/>
<rect x="13.5" y="8.5" width="33" height="33" rx="11.5" stroke="white"/>
</g>
<path d="M30 33.4C28.3432 33.4 27 29.6392 27 25C27 20.3608 28.3432 16.6 30 16.6C31.3223 16.6 32.4448 18.9953 32.8441 22.3209" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M30.0001 33.4C25.3609 33.4 21.6001 29.6392 21.6001 25C21.6001 20.3608 25.3609 16.6 30.0001 16.6C34.3494 16.6 37.9266 19.9054 38.3568 24.1411" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M21.6001 25H28.2001" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M31.8 26.2L39 28.6L35.4 29.8L34.2 33.4L31.8 26.2Z" fill="white" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<defs>
<filter id="filter0_dd_25498_516" x="0" y="0" width="60" height="60" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dy="2"/>
<feGaussianBlur stdDeviation="2"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.02 0"/>
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_25498_516"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dy="5"/>
<feGaussianBlur stdDeviation="6.5"/>
<feComposite in2="hardAlpha" operator="out"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.05 0"/>
<feBlend mode="normal" in2="effect1_dropShadow_25498_516" result="effect2_dropShadow_25498_516"/>
<feBlend mode="normal" in="SourceGraphic" in2="effect2_dropShadow_25498_516" result="shape"/>
</filter>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 2 KiB

View file

@ -0,0 +1,33 @@
<svg width="60" height="60" viewBox="0 0 60 60" fill="none" xmlns="http://www.w3.org/2000/svg">
<g filter="url(#filter0_dd_25498_67)">
<rect x="14" y="9" width="32" height="32" rx="11" fill="#FF0A0A"/>
<rect x="13.5" y="8.5" width="33" height="33" rx="11.5" stroke="white"/>
</g>
<g clip-path="url(#clip0_25498_67)">
<path d="M21.25 24.3901C21.25 21.3894 22.707 18.7417 24.98 17.1531C25.796 16.5647 27.0781 16.447 27.661 17.1531C28.4186 18.1533 27.0781 19.5654 27.661 20.3891C29.2345 22.3308 32.5566 18.5063 36.4614 20.3891C39.259 21.8012 38.851 25.508 38.5013 26.8613C37.4522 30.568 34.0719 33.3333 30.0505 33.3333C25.2131 33.2745 21.25 29.3324 21.25 24.3901Z" fill="white" stroke="white" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M36.3595 25.1042C36.8773 24.2074 36.4085 22.9674 35.3124 22.3346C34.2163 21.7017 32.908 21.9157 32.3902 22.8125C31.8725 23.7093 32.3413 24.9493 33.4374 25.5822C34.5335 26.215 35.8418 26.001 36.3595 25.1042Z" fill="#FF0A0A"/>
<path d="M24.5833 25.4167C25.2736 25.4167 25.8333 24.8571 25.8333 24.1667C25.8333 23.4763 25.2736 22.9167 24.5833 22.9167C23.8929 22.9167 23.3333 23.4763 23.3333 24.1667C23.3333 24.8571 23.8929 25.4167 24.5833 25.4167Z" fill="#FF0A0A"/>
<path d="M26.4583 29.5834C27.1486 29.5834 27.7083 29.0237 27.7083 28.3334C27.7083 27.643 27.1486 27.0834 26.4583 27.0834C25.7679 27.0834 25.2083 27.643 25.2083 28.3334C25.2083 29.0237 25.7679 29.5834 26.4583 29.5834Z" fill="#FF0A0A"/>
<path d="M30.8333 31.25C31.5236 31.25 32.0833 30.6904 32.0833 30C32.0833 29.3096 31.5236 28.75 30.8333 28.75C30.1429 28.75 29.5833 29.3096 29.5833 30C29.5833 30.6904 30.1429 31.25 30.8333 31.25Z" fill="#FF0A0A"/>
</g>
<defs>
<filter id="filter0_dd_25498_67" x="0" y="0" width="60" height="60" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dy="2"/>
<feGaussianBlur stdDeviation="2"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.02 0"/>
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_25498_67"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dy="5"/>
<feGaussianBlur stdDeviation="6.5"/>
<feComposite in2="hardAlpha" operator="out"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.05 0"/>
<feBlend mode="normal" in2="effect1_dropShadow_25498_67" result="effect2_dropShadow_25498_67"/>
<feBlend mode="normal" in="SourceGraphic" in2="effect2_dropShadow_25498_67" result="shape"/>
</filter>
<clipPath id="clip0_25498_67">
<rect width="20" height="20" fill="white" transform="translate(20 15)"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 2.8 KiB

View file

@ -0,0 +1,25 @@
<svg width="60" height="60" viewBox="0 0 60 60" fill="none" xmlns="http://www.w3.org/2000/svg">
<g filter="url(#filter0_dd_25498_148)">
<rect x="14" y="9" width="32" height="32" rx="11" fill="#FF0A0A"/>
<rect x="13.5" y="8.5" width="33" height="33" rx="11.5" stroke="white"/>
</g>
<path d="M35.75 23H33.25C32.0074 23 31 24.0074 31 25.25V30.75C31 31.9926 32.0074 33 33.25 33H35.75C36.9926 33 38 31.9926 38 30.75V25.25C38 24.0074 36.9926 23 35.75 23Z" fill="white"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M25.75 18C24.2308 18 23 19.2308 23 20.75V27.375C23 27.7776 23.0906 28.1591 23.2524 28.5H22.75C22.3358 28.5 22 28.8358 22 29.25C22 29.6642 22.3358 30 22.75 30H25.625H28.75C29.1642 30 29.5 29.6642 29.5 29.25C29.5 28.8358 29.1642 28.5 28.75 28.5H25.625C25.0032 28.5 24.5 27.9968 24.5 27.375V20.75C24.5 20.0592 25.0592 19.5 25.75 19.5H34.25C34.9408 19.5 35.5 20.0592 35.5 20.75C35.5 21.1642 35.8358 21.5 36.25 21.5C36.6642 21.5 37 21.1642 37 20.75C37 19.2308 35.7692 18 34.25 18H25.75Z" fill="white"/>
<defs>
<filter id="filter0_dd_25498_148" x="0" y="0" width="60" height="60" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dy="2"/>
<feGaussianBlur stdDeviation="2"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.02 0"/>
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_25498_148"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dy="5"/>
<feGaussianBlur stdDeviation="6.5"/>
<feComposite in2="hardAlpha" operator="out"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.05 0"/>
<feBlend mode="normal" in2="effect1_dropShadow_25498_148" result="effect2_dropShadow_25498_148"/>
<feBlend mode="normal" in="SourceGraphic" in2="effect2_dropShadow_25498_148" result="shape"/>
</filter>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 2 KiB

View file

@ -0,0 +1,29 @@
<svg width="60" height="60" viewBox="0 0 60 60" fill="none" xmlns="http://www.w3.org/2000/svg">
<g filter="url(#filter0_dd_25498_597)">
<rect x="14" y="9" width="32" height="32" rx="11" fill="#FF0A0A"/>
<rect x="13.5" y="8.5" width="33" height="33" rx="11.5" stroke="white"/>
</g>
<g clip-path="url(#clip0_25498_597)">
<path d="M24.3879 16.8589C24.0655 16.5462 24.2869 16 24.736 16H36.0609C36.337 16 36.5609 16.2239 36.5609 16.5V21.8333C36.5609 22.1095 36.337 22.3333 36.0609 22.3333H30.0317L24.3879 16.8589ZM23.5024 22.8333C23.5024 22.5572 23.7263 22.3333 24.0024 22.3333H30.0317L35.6754 27.8078C35.9978 28.1205 35.7764 28.6667 35.3273 28.6667H30.5317C30.2555 28.6667 30.0317 28.8905 30.0317 29.1667V33.8184C30.0317 34.2602 29.5006 34.4849 29.1835 34.1773L23.6543 28.814C23.5572 28.7198 23.5024 28.5903 23.5024 28.4551V22.8333Z" fill="white"/>
</g>
<defs>
<filter id="filter0_dd_25498_597" x="0" y="0" width="60" height="60" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dy="2"/>
<feGaussianBlur stdDeviation="2"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.02 0"/>
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_25498_597"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dy="5"/>
<feGaussianBlur stdDeviation="6.5"/>
<feComposite in2="hardAlpha" operator="out"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.05 0"/>
<feBlend mode="normal" in2="effect1_dropShadow_25498_597" result="effect2_dropShadow_25498_597"/>
<feBlend mode="normal" in="SourceGraphic" in2="effect2_dropShadow_25498_597" result="shape"/>
</filter>
<clipPath id="clip0_25498_597">
<rect width="13.0625" height="19" fill="white" transform="translate(23.5 16)"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 2 KiB

View file

@ -0,0 +1,3 @@
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M3.54437 0H16.7089V6.66672H10.1266L3.54437 0ZM3.54437 6.66672H10.1266L16.7089 13.3333H10.1266V20L3.54437 13.3333V6.66672Z" fill="#CCCCCC"/>
</svg>

After

Width:  |  Height:  |  Size: 252 B

View file

@ -0,0 +1,3 @@
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M13.9307 1.07129C16.6911 1.07129 18.9286 3.31058 18.9286 6.06915V13.9306C18.9286 16.6909 16.6893 18.9284 13.9307 18.9284H6.06927C3.30891 18.9284 1.07141 16.6892 1.07141 13.9306V6.06915C1.07141 3.30879 3.3107 1.07129 6.06927 1.07129H13.9307ZM9.99998 5.357C7.4357 5.357 5.35713 7.43558 5.35713 9.99986C5.35713 12.5641 7.4357 14.6427 9.99998 14.6427C12.5643 14.6427 14.6428 12.5641 14.6428 9.99986C14.6428 7.43558 12.5643 5.357 9.99998 5.357ZM9.99998 6.78558C10.4221 6.78558 10.8401 6.86872 11.23 7.03025C11.62 7.19178 11.9744 7.42855 12.2728 7.72702C12.5713 8.02549 12.8081 8.37983 12.9696 8.76981C13.1311 9.15978 13.2143 9.57776 13.2143 9.99986C13.2143 10.422 13.1311 10.8399 12.9696 11.2299C12.8081 11.6199 12.5713 11.9742 12.2728 12.2727C11.9744 12.5712 11.62 12.8079 11.23 12.9695C10.8401 13.131 10.4221 13.2141 9.99998 13.2141C9.1475 13.2141 8.32994 12.8755 7.72714 12.2727C7.12435 11.6699 6.7857 10.8523 6.7857 9.99986C6.7857 9.14738 7.12435 8.32981 7.72714 7.72702C8.32994 7.12422 9.1475 6.78558 9.99998 6.78558ZM15.1786 3.57129C14.847 3.57129 14.5291 3.70299 14.2947 3.93741C14.0603 4.17183 13.9286 4.48977 13.9286 4.82129C13.9286 5.15281 14.0603 5.47075 14.2947 5.70517C14.5291 5.93959 14.847 6.07129 15.1786 6.07129C15.5101 6.07129 15.828 5.93959 16.0624 5.70517C16.2969 5.47075 16.4286 5.15281 16.4286 4.82129C16.4286 4.48977 16.2969 4.17183 16.0624 3.93741C15.828 3.70299 15.5101 3.57129 15.1786 3.57129Z" fill="#CCCCCC"/>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View file

@ -0,0 +1,10 @@
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_25444_951)">
<path fill-rule="evenodd" clip-rule="evenodd" d="M4.16671 0.833496C3.28265 0.833496 2.43481 1.18469 1.80968 1.80981C1.18456 2.43493 0.833374 3.28277 0.833374 4.16683V15.8335C0.833374 16.7176 1.18456 17.5654 1.80968 18.1905C2.43481 18.8156 3.28265 19.1668 4.16671 19.1668H15.8334C16.7174 19.1668 17.5653 18.8156 18.1904 18.1905C18.8155 17.5654 19.1667 16.7176 19.1667 15.8335V4.16683C19.1667 3.28277 18.8155 2.43493 18.1904 1.80981C17.5653 1.18469 16.7174 0.833496 15.8334 0.833496H4.16671ZM3.88837 3.75016C3.79715 3.78406 3.7151 3.8388 3.64877 3.91001C3.58244 3.98122 3.53365 4.06694 3.50629 4.16034C3.47894 4.25373 3.47377 4.35223 3.4912 4.44797C3.50864 4.54372 3.54819 4.63407 3.60671 4.71183L8.28504 10.9202L3.35587 16.2077L3.31921 16.2502H5.02504L9.05004 11.9343L12.1434 16.041C12.2151 16.1361 12.3126 16.2086 12.4242 16.2502H16.1092C16.2003 16.2161 16.2822 16.1612 16.3483 16.0899C16.4145 16.0186 16.4631 15.9329 16.4903 15.8395C16.5175 15.7461 16.5225 15.6477 16.5049 15.5521C16.4874 15.4564 16.4478 15.3661 16.3892 15.2885L11.7109 9.08016L16.6809 3.75016H14.9725L10.9475 8.06683L7.85254 3.96016C7.78089 3.8648 7.68344 3.79193 7.57171 3.75016H3.88837ZM12.955 15.0402L5.35921 4.96016H7.04171L14.6367 15.0393L12.955 15.0402Z" fill="#CCCCCC"/>
</g>
<defs>
<clipPath id="clip0_25444_951">
<rect width="20" height="20" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 837 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

View file

@ -0,0 +1,10 @@
<svg width="38" height="38" viewBox="0 0 38 38" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_25400_136)">
<path d="M28.1875 33.25C32.7709 33.1566 36.4563 29.4077 36.4563 24.799C36.4563 22.5576 35.5665 20.408 33.9826 18.8232L19.9188 4.75V13.4974C19.9188 14.9603 20.4995 16.3634 21.5334 17.3979L24.8279 20.6946L24.8369 20.7034L30.361 26.2312C30.5404 26.4106 30.5404 26.7017 30.361 26.8812C30.1816 27.0607 29.8907 27.0607 29.7114 26.8812L27.8072 24.9758H10.1928L8.28868 26.8812C8.10928 27.0607 7.81842 27.0607 7.63903 26.8812C7.45963 26.7017 7.45963 26.4106 7.63902 26.2312L13.1631 20.7034L13.1721 20.6946L16.4666 17.3979C17.5005 16.3634 18.0812 14.9603 18.0812 13.4974V4.75L4.01738 18.8232C2.43355 20.408 1.54376 22.5576 1.54376 24.799C1.54376 29.4077 5.22918 33.1566 9.81254 33.25H28.1875Z" fill="#FF0A0A"/>
</g>
<defs>
<clipPath id="clip0_25400_136">
<rect width="34.9125" height="28.5" fill="white" transform="translate(1.54376 4.75)"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 993 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 948 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 952 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 876 B

View file

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="1200pt" height="1200pt" version="1.1" viewBox="0 0 1200 1200" xmlns="http://www.w3.org/2000/svg">
<path d="m996 780c-0.046875-129.05-62.953-249.94-168.56-324h132.56c19.875 0 36-16.125 36-36 0-218.72-177.28-396-396-396s-396 177.28-396 396c0 19.875 16.125 36 36 36h132.56c-105.61 74.062-168.52 194.95-168.56 324 0 19.875 16.125 36 36 36h132.56c-105.61 74.062-168.52 194.95-168.56 324 0 19.875 16.125 36 36 36h720c19.875 0 36-16.125 36-36-0.046875-129.05-62.953-249.94-168.56-324h132.56c19.875 0 36-16.125 36-36zm-718.03-396c19.828-177.84 180.05-305.95 357.89-286.13 150.56 16.781 269.39 135.56 286.18 286.13zm644.06 720h-644.06c19.828-177.84 180.05-305.95 357.89-286.13 150.56 16.781 269.39 135.56 286.18 286.13zm-644.06-360c19.828-177.84 180.05-305.95 357.89-286.13 150.56 16.781 269.39 135.56 286.18 286.13z"/>
</svg>

After

Width:  |  Height:  |  Size: 870 B

View file

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="1200pt" height="1200pt" version="1.1" viewBox="0 0 1200 1200" xmlns="http://www.w3.org/2000/svg">
<path d="m1140 564h-260.68c46.922-26.156 90-58.688 127.97-96.703 108.38-107.77 169.13-254.44 168.71-407.29 0-19.875-16.125-36-36-36h-1080c-19.875 0-36 16.125-36 36-0.42188 152.86 60.328 299.53 168.71 407.29 37.969 38.016 81.047 70.594 127.97 96.703h-260.68c-19.875 0-36 16.125-36 36 0 318.14 257.9 576 576 576 152.76 0 299.26-60.703 407.29-168.71 108.38-107.77 169.13-254.44 168.71-407.29 0-19.875-16.125-36-36-36zm-1042.7-468h1005.5c-19.781 277.64-260.86 486.71-538.5 466.92-250.18-17.766-449.16-216.79-466.97-466.92zm502.74 1008c-264.24-0.32812-483.52-204.42-502.74-468h1005.5c-19.219 263.58-238.5 467.68-502.74 468z"/>
</svg>

After

Width:  |  Height:  |  Size: 779 B

View file

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="1200pt" height="1200pt" version="1.1" viewBox="0 0 1200 1200" xmlns="http://www.w3.org/2000/svg">
<path d="m1095.9 495.66-308.86-82.781-82.781-308.86c-15.422-57.609-74.672-91.781-132.28-76.359-37.266 9.9844-66.375 39.094-76.359 76.359l-82.734 308.86-308.86 82.781c-57.609 15.422-91.781 74.672-76.359 132.28 9.9844 37.266 39.094 66.375 76.359 76.359l308.86 82.781 82.781 308.86c15.422 57.609 74.672 91.781 132.28 76.359 37.266-9.9844 66.375-39.094 76.359-76.359l82.781-308.86 308.86-82.781c57.609-15.422 91.781-74.672 76.359-132.28-9.9844-37.266-39.094-66.375-76.359-76.359zm-18.609 139.13-328.97 88.078c-12.422 3.3281-22.125 13.031-25.453 25.453l-88.125 328.92c-5.1094 19.219-24.797 30.656-44.016 25.594-12.516-3.3281-22.266-13.078-25.594-25.594l-88.125-328.92c-3.3281-12.422-13.031-22.125-25.453-25.453l-328.92-88.125c-19.219-5.1094-30.656-24.797-25.594-44.016 3.3281-12.516 13.078-22.266 25.594-25.594l328.92-88.125c12.422-3.3281 22.125-13.031 25.453-25.453l88.125-328.92c5.1094-19.219 24.797-30.656 44.016-25.594 12.516 3.3281 22.266 13.078 25.594 25.594l88.125 328.92c3.3281 12.422 13.031 22.125 25.453 25.453l328.92 88.125c19.219 5.1094 30.656 24.797 25.594 44.016-3.2812 12.562-13.031 22.312-25.547 25.641z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View file

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="1200pt" height="1200pt" version="1.1" viewBox="0 0 1200 1200" xmlns="http://www.w3.org/2000/svg">
<path d="m1095.9 495.71-148.22-39.703 76.734-132.89c29.812-51.656 12.141-117.7-39.516-147.56-33.422-19.312-74.578-19.312-108 0l-132.94 76.734-39.703-148.22c-15.422-57.609-74.672-91.781-132.28-76.359-37.266 9.9844-66.375 39.094-76.359 76.359l-39.656 148.22-132.94-76.734c-51.656-29.812-117.7-12.094-147.52 39.609-19.266 33.375-19.266 74.531 0 107.91l76.734 132.94-148.22 39.703c-57.609 15.422-91.781 74.672-76.359 132.28 9.9844 37.266 39.094 66.375 76.359 76.359l148.22 39.656-76.734 132.94c-29.812 51.656-12.094 117.7 39.609 147.52 33.375 19.266 74.531 19.266 107.91 0l132.94-76.734 39.703 148.22c15.422 57.609 74.672 91.781 132.28 76.359 37.266-9.9844 66.375-39.094 76.359-76.359l39.656-148.22 132.89 76.734c51.656 29.812 117.7 12.141 147.56-39.516 19.312-33.422 19.312-74.578 0-108l-76.734-132.94 148.22-39.703c57.609-15.422 91.781-74.672 76.359-132.28-9.9844-37.266-39.094-66.375-76.359-76.359zm-18.609 139.08-193.55 51.844c-19.219 5.1562-30.609 24.891-25.453 44.062 0.79688 3.0469 2.0156 6 3.6094 8.7188l100.17 173.53c9.9844 17.203 4.125 39.234-13.078 49.219-11.156 6.4688-24.938 6.4688-36.094 0l-173.48-100.27c-17.203-9.9375-39.234-4.0781-49.172 13.125-1.5938 2.7188-2.8125 5.6719-3.6094 8.7188l-51.891 193.6c-5.1562 19.219-24.891 30.562-44.109 25.406-12.375-3.3281-22.078-13.031-25.406-25.406l-51.891-193.55c-5.1562-19.219-24.891-30.609-44.062-25.453-3.0469 0.79687-6 2.0156-8.7188 3.6094l-173.53 100.17c-17.203 9.9844-39.234 4.125-49.219-13.078-6.4688-11.156-6.4688-24.938 0-36.094l100.17-173.53c9.9375-17.203 4.0781-39.234-13.125-49.172-2.7188-1.5938-5.6719-2.8125-8.7188-3.6094l-193.5-51.844c-19.219-5.1562-30.562-24.891-25.406-44.109 3.3281-12.375 13.031-22.078 25.406-25.406l193.55-51.844c19.219-5.1562 30.609-24.891 25.453-44.062-0.79688-3.0469-2.0156-6-3.6094-8.7188l-100.17-173.53c-9.9844-17.203-4.125-39.234 13.078-49.219 11.156-6.4688 24.938-6.4688 36.094 0l173.53 100.17c17.203 9.9375 39.234 4.0781 49.172-13.125 1.5938-2.7188 2.8125-5.6719 3.6094-8.7188l51.844-193.55c5.1562-19.219 24.891-30.562 44.109-25.406 12.375 3.3281 22.078 13.031 25.406 25.406l51.844 193.55c5.1562 19.219 24.891 30.609 44.062 25.453 3.0469-0.79688 6-2.0156 8.7188-3.6094l173.53-100.17c17.203-9.9844 39.234-4.125 49.219 13.078 6.4688 11.156 6.4688 24.938 0 36.094l-100.22 173.48c-9.9375 17.203-4.0781 39.234 13.125 49.172 2.7188 1.5938 5.6719 2.8125 8.7188 3.6094l193.6 51.891c19.219 5.1562 30.562 24.891 25.406 44.109-3.3281 12.375-13.031 22.078-25.406 25.406z"/>
</svg>

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 558 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 283 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 394 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 938 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 200 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 307 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 349 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 496 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 165 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 363 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 186 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 241 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 175 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 532 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 323 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 188 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 569 KiB

BIN
public/og-image.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

BIN
public/og/kasuti.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

View file

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="1200" fill="none"><rect width="1200" height="1200" fill="#EAEAEA" rx="3"/><g opacity=".5"><g opacity=".5"><path fill="#FAFAFA" d="M600.709 736.5c-75.454 0-136.621-61.167-136.621-136.62 0-75.454 61.167-136.621 136.621-136.621 75.453 0 136.62 61.167 136.62 136.621 0 75.453-61.167 136.62-136.62 136.62Z"/><path stroke="#C9C9C9" stroke-width="2.418" d="M600.709 736.5c-75.454 0-136.621-61.167-136.621-136.62 0-75.454 61.167-136.621 136.621-136.621 75.453 0 136.62 61.167 136.62 136.621 0 75.453-61.167 136.62-136.62 136.62Z"/></g><path stroke="url(#a)" stroke-width="2.418" d="M0-1.209h553.581" transform="scale(1 -1) rotate(45 1163.11 91.165)"/><path stroke="url(#b)" stroke-width="2.418" d="M404.846 598.671h391.726"/><path stroke="url(#c)" stroke-width="2.418" d="M599.5 795.742V404.017"/><path stroke="url(#d)" stroke-width="2.418" d="m795.717 796.597-391.441-391.44"/><path fill="#fff" d="M600.709 656.704c-31.384 0-56.825-25.441-56.825-56.824 0-31.384 25.441-56.825 56.825-56.825 31.383 0 56.824 25.441 56.824 56.825 0 31.383-25.441 56.824-56.824 56.824Z"/><g clip-path="url(#e)"><path fill="#666" fill-rule="evenodd" d="M616.426 586.58h-31.434v16.176l3.553-3.554.531-.531h9.068l.074-.074 8.463-8.463h2.565l7.18 7.181V586.58Zm-15.715 14.654 3.698 3.699 1.283 1.282-2.565 2.565-1.282-1.283-5.2-5.199h-6.066l-5.514 5.514-.073.073v2.876a2.418 2.418 0 0 0 2.418 2.418h26.598a2.418 2.418 0 0 0 2.418-2.418v-8.317l-8.463-8.463-7.181 7.181-.071.072Zm-19.347 5.442v4.085a6.045 6.045 0 0 0 6.046 6.045h26.598a6.044 6.044 0 0 0 6.045-6.045v-7.108l1.356-1.355-1.282-1.283-.074-.073v-17.989h-38.689v23.43l-.146.146.146.147Z" clip-rule="evenodd"/></g><path stroke="#C9C9C9" stroke-width="2.418" d="M600.709 656.704c-31.384 0-56.825-25.441-56.825-56.824 0-31.384 25.441-56.825 56.825-56.825 31.383 0 56.824 25.441 56.824 56.825 0 31.383-25.441 56.824-56.824 56.824Z"/></g><defs><linearGradient id="a" x1="554.061" x2="-.48" y1=".083" y2=".087" gradientUnits="userSpaceOnUse"><stop stop-color="#C9C9C9" stop-opacity="0"/><stop offset=".208" stop-color="#C9C9C9"/><stop offset=".792" stop-color="#C9C9C9"/><stop offset="1" stop-color="#C9C9C9" stop-opacity="0"/></linearGradient><linearGradient id="b" x1="796.912" x2="404.507" y1="599.963" y2="599.965" gradientUnits="userSpaceOnUse"><stop stop-color="#C9C9C9" stop-opacity="0"/><stop offset=".208" stop-color="#C9C9C9"/><stop offset=".792" stop-color="#C9C9C9"/><stop offset="1" stop-color="#C9C9C9" stop-opacity="0"/></linearGradient><linearGradient id="c" x1="600.792" x2="600.794" y1="403.677" y2="796.082" gradientUnits="userSpaceOnUse"><stop stop-color="#C9C9C9" stop-opacity="0"/><stop offset=".208" stop-color="#C9C9C9"/><stop offset=".792" stop-color="#C9C9C9"/><stop offset="1" stop-color="#C9C9C9" stop-opacity="0"/></linearGradient><linearGradient id="d" x1="404.85" x2="796.972" y1="403.903" y2="796.02" gradientUnits="userSpaceOnUse"><stop stop-color="#C9C9C9" stop-opacity="0"/><stop offset=".208" stop-color="#C9C9C9"/><stop offset=".792" stop-color="#C9C9C9"/><stop offset="1" stop-color="#C9C9C9" stop-opacity="0"/></linearGradient><clipPath id="e"><path fill="#fff" d="M581.364 580.535h38.689v38.689h-38.689z"/></clipPath></defs></svg>

Before

Width:  |  Height:  |  Size: 3.2 KiB

View file

@ -1,14 +0,0 @@
User-agent: Googlebot
Allow: /
User-agent: Bingbot
Allow: /
User-agent: Twitterbot
Allow: /
User-agent: facebookexternalhit
Allow: /
User-agent: *
Allow: /

103
scripts/sync-blog.sh Executable file
View file

@ -0,0 +1,103 @@
#!/usr/bin/env bash
#
# sync-blog.sh
#
# Syncs blog posts from the Obsidian vault to the Astro content directory.
#
# Source: /Users/neeldhara/repos/nm-obsidian/public/subdomains/interactives/blog/
# Structure: <slug>/blog.md
#
# Destination: src/content/blog/
# Structure: <slug>.md
#
# The script:
# 1. Scans the source directory for folders containing blog.md
# 2. Copies each blog.md to src/content/blog/<slug>.md
# 3. Removes any destination files whose source folder no longer exists
# 4. Reports what changed
#
# Usage:
# ./scripts/sync-blog.sh # sync
# ./scripts/sync-blog.sh --dry-run # preview changes without writing
set -euo pipefail
OBSIDIAN_BLOG="/Users/neeldhara/repos/nm-obsidian/public/subdomains/interactives/blog"
ASTRO_BLOG="src/content/blog"
DRY_RUN=false
if [[ "${1:-}" == "--dry-run" ]]; then
DRY_RUN=true
echo "=== DRY RUN ==="
fi
# Ensure destination exists
mkdir -p "$ASTRO_BLOG"
added=0
updated=0
removed=0
unchanged=0
# --- Sync from Obsidian → Astro ---
if [[ ! -d "$OBSIDIAN_BLOG" ]]; then
echo "Error: Source directory not found: $OBSIDIAN_BLOG"
exit 1
fi
for dir in "$OBSIDIAN_BLOG"/*/; do
# Skip if not a directory
[[ -d "$dir" ]] || continue
slug=$(basename "$dir")
src="$dir/blog.md"
dest="$ASTRO_BLOG/$slug.md"
if [[ ! -f "$src" ]]; then
echo " SKIP $slug (no blog.md found)"
continue
fi
if [[ -f "$dest" ]]; then
# Compare contents
if diff -q "$src" "$dest" > /dev/null 2>&1; then
unchanged=$((unchanged + 1))
continue
else
echo " UPDATE $slug"
updated=$((updated + 1))
fi
else
echo " ADD $slug"
added=$((added + 1))
fi
if [[ "$DRY_RUN" == false ]]; then
cp "$src" "$dest"
fi
done
# --- Remove orphaned files in Astro that no longer exist in Obsidian ---
for dest in "$ASTRO_BLOG"/*.md; do
[[ -f "$dest" ]] || continue
filename=$(basename "$dest")
# Skip .gitkeep or non-md files
[[ "$filename" == ".gitkeep" ]] && continue
slug="${filename%.md}"
src_dir="$OBSIDIAN_BLOG/$slug"
if [[ ! -d "$src_dir" ]] || [[ ! -f "$src_dir/blog.md" ]]; then
echo " REMOVE $slug (source no longer exists)"
removed=$((removed + 1))
if [[ "$DRY_RUN" == false ]]; then
rm "$dest"
fi
fi
done
echo ""
echo "Done: $added added, $updated updated, $removed removed, $unchanged unchanged"

452
server/dice-sync.mjs Normal file
View file

@ -0,0 +1,452 @@
import { randomInt, randomUUID } from "node:crypto";
import { WebSocket, WebSocketServer } from "ws";
const ROOM_ALPHABET = "23456789ABCDEFGHJKLMNPQRSTUVWXYZ";
const ROOM_TTL_MS = 12 * 60 * 60 * 1000;
const MAX_ROOMS = 200;
const MAX_ROLLS = 120;
const MIN_ROLL_INTERVAL_MS = 350;
const DEFAULT_LEVEL_SETTINGS = Object.freeze({
gridSize: 5,
undoLimit: 3,
rerollLimit: 3,
});
const NAME_ADJECTIVES = [
"Bouncy",
"Brave",
"Curious",
"Dapper",
"Dizzy",
"Gentle",
"Jolly",
"Merry",
"Mighty",
"Nimble",
"Peppy",
"Quiet",
"Silly",
"Sunny",
"Wiggly",
"Wobbly",
];
const NAME_NOUNS = [
"Badger",
"Biscuit",
"Crumpet",
"Doodle",
"Kettle",
"Mango",
"Marble",
"Otter",
"Pancake",
"Penguin",
"Pickle",
"Puffin",
"Rocket",
"Teacup",
"Turnip",
"Wombat",
];
const makeRoomId = () =>
Array.from(
{ length: 6 },
() => ROOM_ALPHABET[randomInt(ROOM_ALPHABET.length)],
).join("");
const parseLevelSettings = (candidate) => {
const settings = candidate ?? DEFAULT_LEVEL_SETTINGS;
if (!settings || typeof settings !== "object") return null;
const { gridSize, undoLimit, rerollLimit } = settings;
if (
!Number.isInteger(gridSize) ||
gridSize < 5 ||
gridSize > 9 ||
!Number.isInteger(undoLimit) ||
undoLimit < 0 ||
undoLimit > 9 ||
!Number.isInteger(rerollLimit) ||
rerollLimit < 0 ||
rerollLimit > 9
) {
return null;
}
return { gridSize, undoLimit, rerollLimit };
};
const makePlayerName = (room) => {
const usedNames = new Set(
[...room.players.values()].map((player) => player.name),
);
for (let attempt = 0; attempt < 100; attempt += 1) {
const name = `${NAME_ADJECTIVES[randomInt(NAME_ADJECTIVES.length)]} ${NAME_NOUNS[randomInt(NAME_NOUNS.length)]}`;
if (!usedNames.has(name)) return name;
}
return `Jolly Marble ${room.players.size + 1}`;
};
const send = (socket, message) => {
if (socket.readyState === WebSocket.OPEN) {
socket.send(JSON.stringify(message));
}
};
const leaderboardFor = (room) =>
[...room.players.values()]
.map(({ token: _token, ...player }) => player)
.sort(
(left, right) =>
right.score - left.score ||
right.placed - left.placed ||
left.name.localeCompare(right.name),
);
const roomSnapshot = (room, socket, hostToken) => ({
type: "room-state",
roomId: room.id,
roundId: room.roundId,
isHost: hostToken === room.hostToken,
participants: room.clients.size,
history: room.history,
playerId: socket.playerToken,
playerName: room.players.get(socket.playerToken)?.name,
leaderboard: leaderboardFor(room),
settings: room.settings,
rerollsUsed: room.rerollsUsed,
});
export const attachDiceSyncServer = (httpServer) => {
const rooms = new Map();
const wss = new WebSocketServer({
server: httpServer,
path: "/sync",
maxPayload: 2048,
});
const broadcast = (room, message) => {
for (const client of room.clients) send(client, message);
};
const broadcastPresence = (room) => {
broadcast(room, { type: "presence", participants: room.clients.size });
};
const broadcastLeaderboard = (room) => {
broadcast(room, { type: "leaderboard", leaderboard: leaderboardFor(room) });
};
const leaveCurrentRoom = (socket) => {
const roomId = socket.roomId;
if (!roomId) return;
const room = rooms.get(roomId);
socket.roomId = undefined;
socket.hostToken = undefined;
if (!room) return;
room.clients.delete(socket);
const player = room.players.get(socket.playerToken);
if (player) {
player.connected = [...room.clients].some(
(client) => client.playerToken === socket.playerToken,
);
}
room.lastActiveAt = Date.now();
broadcastPresence(room);
broadcastLeaderboard(room);
};
const joinRoom = (socket, room, hostToken, requestedPlayerToken) => {
leaveCurrentRoom(socket);
let player = requestedPlayerToken
? room.players.get(requestedPlayerToken)
: null;
if (!player) {
const token = randomUUID();
player = {
id: token,
token,
name: makePlayerName(room),
score: 0,
placed: 0,
finished: false,
connected: true,
};
room.players.set(token, player);
}
player.connected = true;
socket.roomId = room.id;
socket.hostToken = hostToken;
socket.playerToken = player.token;
room.clients.add(socket);
room.lastActiveAt = Date.now();
send(socket, roomSnapshot(room, socket, hostToken));
send(socket, {
type: "player-token",
roomId: room.id,
playerToken: player.token,
});
broadcastPresence(room);
broadcastLeaderboard(room);
};
const createRoom = (socket, requestedSettings) => {
if (rooms.size >= MAX_ROOMS) {
send(socket, {
type: "error",
message: "The classroom server is full. Please try again shortly.",
});
return;
}
const settings = parseLevelSettings(requestedSettings);
if (!settings) {
send(socket, {
type: "error",
message: "Those level settings are not valid.",
});
return;
}
let id = makeRoomId();
while (rooms.has(id)) id = makeRoomId();
const hostToken = randomUUID();
const room = {
id,
hostToken,
roundId: randomUUID(),
clients: new Set(),
players: new Map(),
history: [],
settings,
rerollsUsed: 0,
createdAt: Date.now(),
lastActiveAt: Date.now(),
lastRollAt: 0,
};
rooms.set(id, room);
joinRoom(socket, room, hostToken);
send(socket, { type: "host-token", roomId: id, hostToken });
};
const requireHost = (socket) => {
const room = rooms.get(socket.roomId);
if (!room) {
send(socket, {
type: "error",
message: "This classroom room is no longer available.",
});
return null;
}
if (socket.hostToken !== room.hostToken) {
send(socket, {
type: "error",
message: "Only the room host can change the classroom game.",
});
return null;
}
return room;
};
wss.on("connection", (socket) => {
socket.on("message", (raw) => {
let message;
try {
message = JSON.parse(raw.toString());
} catch {
send(socket, {
type: "error",
message: "That classroom message could not be read.",
});
return;
}
if (message.type === "create") {
createRoom(socket, message.settings);
return;
}
if (message.type === "configure") {
const room = requireHost(socket);
if (!room) return;
if (room.history.length > 0) {
send(socket, {
type: "error",
message:
"Level settings can only be changed before the first roll.",
});
return;
}
const settings = parseLevelSettings(message.settings);
if (!settings) {
send(socket, {
type: "error",
message: "Those level settings are not valid.",
});
return;
}
room.settings = settings;
room.rerollsUsed = 0;
room.lastActiveAt = Date.now();
broadcast(room, {
type: "settings",
settings: room.settings,
rerollsUsed: room.rerollsUsed,
});
return;
}
if (message.type === "join") {
const roomId = String(message.roomId ?? "")
.trim()
.toUpperCase();
const room = rooms.get(roomId);
if (!room) {
send(socket, {
type: "error",
message: `No active classroom game was found for ${roomId || "that ID"}.`,
});
return;
}
const hostToken =
typeof message.hostToken === "string" ? message.hostToken : undefined;
const playerToken =
typeof message.playerToken === "string"
? message.playerToken
: undefined;
joinRoom(socket, room, hostToken, playerToken);
return;
}
if (message.type === "progress") {
const room = rooms.get(socket.roomId);
const player = room?.players.get(socket.playerToken);
if (!room || !player) return;
player.score = Math.max(
0,
Math.min(10000, Math.round(Number(message.score) || 0)),
);
player.placed = Math.max(
0,
Math.min(MAX_ROLLS, Math.round(Number(message.placed) || 0)),
);
player.finished = Boolean(message.finished);
room.lastActiveAt = Date.now();
broadcastLeaderboard(room);
return;
}
if (message.type === "roll") {
const room = requireHost(socket);
if (!room) return;
const now = Date.now();
if (now - room.lastRollAt < MIN_ROLL_INTERVAL_MS) return;
if (room.history.length >= MAX_ROLLS) {
send(socket, {
type: "error",
message:
"This round has reached its roll limit. Start a new round to continue.",
});
return;
}
const dice = [randomInt(1, 7), randomInt(1, 7)];
const roll = {
id: room.history.length + 1,
dice,
sum: dice[0] + dice[1],
rolledAt: now,
};
room.history.push(roll);
room.lastRollAt = now;
room.lastActiveAt = now;
broadcast(room, { type: "rolled", roundId: room.roundId, roll });
return;
}
if (message.type === "reroll") {
const room = requireHost(socket);
if (!room) return;
if (room.history.length === 0) {
send(socket, {
type: "error",
message: "Roll the dice before using a re-roll.",
});
return;
}
if (room.rerollsUsed >= room.settings.rerollLimit) {
send(socket, {
type: "error",
message: "This round has no re-rolls remaining.",
});
return;
}
if (
[...room.players.values()].some(
(player) => player.placed >= room.history.length,
)
) {
send(socket, {
type: "error",
message: "That roll has already been placed by a player.",
});
return;
}
const now = Date.now();
const dice = [randomInt(1, 7), randomInt(1, 7)];
const previousRoll = room.history.at(-1);
const roll = {
id: previousRoll.id,
dice,
sum: dice[0] + dice[1],
rolledAt: now,
};
room.history[room.history.length - 1] = roll;
room.rerollsUsed += 1;
room.lastActiveAt = now;
broadcast(room, {
type: "rerolled",
roundId: room.roundId,
roll,
rerollsUsed: room.rerollsUsed,
});
return;
}
if (message.type === "reset") {
const room = requireHost(socket);
if (!room) return;
room.roundId = randomUUID();
room.history = [];
room.rerollsUsed = 0;
for (const player of room.players.values()) {
player.score = 0;
player.placed = 0;
player.finished = false;
}
room.lastRollAt = 0;
room.lastActiveAt = Date.now();
broadcast(room, {
type: "round-reset",
roundId: room.roundId,
rerollsUsed: room.rerollsUsed,
});
broadcastLeaderboard(room);
}
});
socket.on("close", () => leaveCurrentRoom(socket));
});
const pruneTimer = setInterval(
() => {
const cutoff = Date.now() - ROOM_TTL_MS;
for (const [id, room] of rooms) {
if (room.clients.size === 0 && room.lastActiveAt < cutoff)
rooms.delete(id);
}
},
30 * 60 * 1000,
);
pruneTimer.unref();
wss.on("close", () => clearInterval(pruneTimer));
return { wss, rooms };
};

207
server/dice-sync.test.mjs Normal file
View file

@ -0,0 +1,207 @@
import assert from "node:assert/strict";
import { createServer } from "node:http";
import test from "node:test";
import { WebSocket } from "ws";
import { attachDiceSyncServer } from "./dice-sync.mjs";
const createInbox = (socket) => {
const messages = [];
const waiters = [];
socket.on("message", (raw) => {
const message = JSON.parse(raw.toString());
const waiterIndex = waiters.findIndex(
(waiter) => waiter.type === message.type && waiter.predicate(message),
);
if (waiterIndex >= 0) {
const [waiter] = waiters.splice(waiterIndex, 1);
clearTimeout(waiter.timeout);
waiter.resolve(message);
} else {
messages.push(message);
}
});
return {
next(type, predicate = () => true) {
const messageIndex = messages.findIndex(
(message) => message.type === type && predicate(message),
);
if (messageIndex >= 0)
return Promise.resolve(messages.splice(messageIndex, 1)[0]);
return new Promise((resolve, reject) => {
const timeout = setTimeout(
() => reject(new Error(`Timed out waiting for ${type}`)),
2000,
);
waiters.push({ type, predicate, resolve, timeout });
});
},
};
};
const connect = (url) =>
new Promise((resolve, reject) => {
const socket = new WebSocket(url);
socket.once("open", () => resolve(socket));
socket.once("error", reject);
});
test("a host creates a room and broadcasts one authoritative roll", async () => {
const httpServer = createServer();
const { wss } = attachDiceSyncServer(httpServer);
await new Promise((resolve) => httpServer.listen(0, "127.0.0.1", resolve));
const address = httpServer.address();
const url = `ws://127.0.0.1:${address.port}/sync`;
const host = await connect(url);
const guest = await connect(url);
const hostInbox = createInbox(host);
const guestInbox = createInbox(guest);
host.send(JSON.stringify({ type: "create" }));
const hostState = await hostInbox.next("room-state");
const tokenMessage = await hostInbox.next("host-token");
assert.equal(hostState.isHost, true);
assert.equal(tokenMessage.roomId, hostState.roomId);
guest.send(JSON.stringify({ type: "join", roomId: hostState.roomId }));
const guestState = await guestInbox.next("room-state");
assert.equal(guestState.isHost, false);
const hostRoll = hostInbox.next("rolled");
const guestRoll = guestInbox.next("rolled");
host.send(JSON.stringify({ type: "roll" }));
const [hostMessage, guestMessage] = await Promise.all([hostRoll, guestRoll]);
assert.deepEqual(guestMessage.roll, hostMessage.roll);
assert.equal(
hostMessage.roll.sum,
hostMessage.roll.dice[0] + hostMessage.roll.dice[1],
);
const forbidden = guestInbox.next("error");
guest.send(JSON.stringify({ type: "roll" }));
assert.match((await forbidden).message, /Only the room host/);
host.terminate();
guest.terminate();
await new Promise((resolve) => wss.close(resolve));
await new Promise((resolve) => httpServer.close(resolve));
});
test("level settings and re-rolls stay authoritative across a classroom", async () => {
const httpServer = createServer();
const { wss } = attachDiceSyncServer(httpServer);
await new Promise((resolve) => httpServer.listen(0, "127.0.0.1", resolve));
const address = httpServer.address();
const url = `ws://127.0.0.1:${address.port}/sync`;
const host = await connect(url);
const guest = await connect(url);
const hostInbox = createInbox(host);
const guestInbox = createInbox(guest);
host.send(
JSON.stringify({
type: "create",
settings: { gridSize: 9, undoLimit: 0, rerollLimit: 2 },
}),
);
const hostState = await hostInbox.next("room-state");
await hostInbox.next("host-token");
assert.deepEqual(hostState.settings, {
gridSize: 9,
undoLimit: 0,
rerollLimit: 2,
});
assert.equal(hostState.rerollsUsed, 0);
guest.send(JSON.stringify({ type: "join", roomId: hostState.roomId }));
const guestState = await guestInbox.next("room-state");
assert.deepEqual(guestState.settings, hostState.settings);
const configuredSettings = {
gridSize: 8,
undoLimit: 4,
rerollLimit: 1,
};
const hostSettings = hostInbox.next("settings");
const guestSettings = guestInbox.next("settings");
host.send(
JSON.stringify({ type: "configure", settings: configuredSettings }),
);
const [hostSettingsMessage, guestSettingsMessage] = await Promise.all([
hostSettings,
guestSettings,
]);
assert.deepEqual(hostSettingsMessage.settings, configuredSettings);
assert.deepEqual(guestSettingsMessage.settings, configuredSettings);
const forbiddenConfiguration = guestInbox.next("error");
guest.send(
JSON.stringify({
type: "configure",
settings: { gridSize: 5, undoLimit: 3, rerollLimit: 3 },
}),
);
assert.match((await forbiddenConfiguration).message, /Only the room host/);
const firstHostRoll = hostInbox.next("rolled");
const firstGuestRoll = guestInbox.next("rolled");
host.send(JSON.stringify({ type: "roll" }));
const [hostRollMessage] = await Promise.all([firstHostRoll, firstGuestRoll]);
const hostReroll = hostInbox.next("rerolled");
const guestReroll = guestInbox.next("rerolled");
host.send(JSON.stringify({ type: "reroll" }));
const [hostRerollMessage, guestRerollMessage] = await Promise.all([
hostReroll,
guestReroll,
]);
assert.deepEqual(guestRerollMessage.roll, hostRerollMessage.roll);
assert.equal(hostRerollMessage.roll.id, hostRollMessage.roll.id);
assert.equal(hostRerollMessage.rerollsUsed, 1);
const exhausted = hostInbox.next("error");
host.send(JSON.stringify({ type: "reroll" }));
assert.match((await exhausted).message, /no re-rolls remaining/);
const firstHostReset = hostInbox.next("round-reset");
const firstGuestReset = guestInbox.next("round-reset");
host.send(JSON.stringify({ type: "reset" }));
const [resetMessage] = await Promise.all([firstHostReset, firstGuestReset]);
assert.equal(resetMessage.rerollsUsed, 0);
const secondHostRoll = hostInbox.next("rolled");
const secondGuestRoll = guestInbox.next("rolled");
host.send(JSON.stringify({ type: "roll" }));
await Promise.all([secondHostRoll, secondGuestRoll]);
const placedUpdate = hostInbox.next("leaderboard", (message) =>
message.leaderboard.some(
(player) => player.id === guestState.playerId && player.placed === 1,
),
);
guest.send(
JSON.stringify({ type: "progress", score: 8, placed: 1, finished: false }),
);
await placedUpdate;
const alreadyPlaced = hostInbox.next("error");
host.send(JSON.stringify({ type: "reroll" }));
assert.match((await alreadyPlaced).message, /already been placed/);
const secondHostReset = hostInbox.next("round-reset");
const secondGuestReset = guestInbox.next("round-reset");
host.send(JSON.stringify({ type: "reset" }));
await Promise.all([secondHostReset, secondGuestReset]);
const newcomer = await connect(url);
const newcomerInbox = createInbox(newcomer);
newcomer.send(JSON.stringify({ type: "join", roomId: hostState.roomId }));
const newcomerState = await newcomerInbox.next("room-state");
assert.deepEqual(newcomerState.settings, configuredSettings);
assert.equal(newcomerState.rerollsUsed, 0);
host.terminate();
guest.terminate();
newcomer.terminate();
await new Promise((resolve) => wss.close(resolve));
await new Promise((resolve) => httpServer.close(resolve));
});

99
server/index.mjs Normal file
View file

@ -0,0 +1,99 @@
import { createReadStream, existsSync } from "node:fs";
import { stat } from "node:fs/promises";
import { createServer } from "node:http";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { attachDiceSyncServer } from "./dice-sync.mjs";
const serverDir = path.dirname(fileURLToPath(import.meta.url));
const distDir = path.resolve(serverDir, "../dist");
const port = Number(process.env.PORT || 3000);
const contentTypes = {
".css": "text/css; charset=utf-8",
".html": "text/html; charset=utf-8",
".ico": "image/x-icon",
".jpeg": "image/jpeg",
".jpg": "image/jpeg",
".js": "text/javascript; charset=utf-8",
".json": "application/json; charset=utf-8",
".png": "image/png",
".svg": "image/svg+xml",
".webp": "image/webp",
".woff": "font/woff",
".woff2": "font/woff2",
};
const sendFile = async (request, response, filePath) => {
const fileStat = await stat(filePath);
response.writeHead(200, {
"Content-Type":
contentTypes[path.extname(filePath)] || "application/octet-stream",
"Content-Length": fileStat.size,
"Cache-Control": filePath.endsWith("index.html")
? "no-cache"
: "public, max-age=31536000, immutable",
});
if (request.method === "HEAD") response.end();
else createReadStream(filePath).pipe(response);
};
const httpServer = createServer(async (request, response) => {
try {
const requestUrl = new URL(request.url || "/", "http://localhost");
if (requestUrl.pathname === "/api/health") {
response.writeHead(200, {
"Content-Type": "application/json; charset=utf-8",
"Cache-Control": "no-store",
});
response.end(JSON.stringify({ ok: true }));
return;
}
if (!["GET", "HEAD"].includes(request.method || "")) {
response.writeHead(405, { Allow: "GET, HEAD" });
response.end("Method not allowed");
return;
}
const decodedPath = decodeURIComponent(requestUrl.pathname);
const requestedFile = path.resolve(distDir, `.${decodedPath}`);
const safePath = requestedFile.startsWith(`${distDir}${path.sep}`)
? requestedFile
: path.join(distDir, "index.html");
let filePath = path.join(distDir, "404.html");
if (existsSync(safePath)) {
const safeStat = await stat(safePath);
if (safeStat.isFile()) filePath = safePath;
else if (
safeStat.isDirectory() &&
existsSync(path.join(safePath, "index.html"))
)
filePath = path.join(safePath, "index.html");
}
await sendFile(request, response, filePath);
} catch {
response.writeHead(500, { "Content-Type": "text/plain; charset=utf-8" });
response.end("Internal server error");
}
});
attachDiceSyncServer(httpServer);
httpServer.listen(port, "0.0.0.0", () => {
console.log(`Interactives is listening on port ${port}`);
if (
typeof process.getuid === "function" &&
process.getuid() === 0 &&
typeof process.setuid === "function"
) {
try {
process.setuid("node");
} catch {
// The container can still run safely with its filesystem mounted read-only by the platform.
}
}
});
const shutdown = () => httpServer.close(() => process.exit(0));
process.on("SIGTERM", shutdown);
process.on("SIGINT", shutdown);

View file

@ -1,42 +0,0 @@
#root {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
text-align: center;
}
.logo {
height: 6em;
padding: 1.5em;
will-change: filter;
transition: filter 300ms;
}
.logo:hover {
filter: drop-shadow(0 0 2em #646cffaa);
}
.logo.react:hover {
filter: drop-shadow(0 0 2em #61dafbaa);
}
@keyframes logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: no-preference) {
a:nth-of-type(2) .logo {
animation: logo-spin infinite 20s linear;
}
}
.card {
padding: 2em;
}
.read-the-docs {
color: #888;
}

View file

@ -1,165 +0,0 @@
import { Toaster } from "@/components/ui/toaster";
import { Toaster as Sonner } from "@/components/ui/sonner";
import { TooltipProvider } from "@/components/ui/tooltip";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import ErrorBoundary from "@/components/ErrorBoundary";
import CommandSearch from "@/components/CommandSearch";
import { BrowserRouter, Routes, Route } from "react-router-dom";
import Index from "./pages/Index";
import InteractiveIndex from "./components/InteractiveIndex";
import Themes from "./pages/Themes";
import About from "./pages/About";
import DiscreteMath from "./pages/theme-pages/DiscreteMath";
import SocialChoice from "./pages/theme-pages/SocialChoice";
import AdvancedAlgorithms from "./pages/theme-pages/AdvancedAlgorithms";
import DataStructures from "./pages/theme-pages/DataStructures";
import Games from "./pages/theme-pages/Games";
import CardsMath from "./pages/theme-pages/CardsMath";
import Puzzles from "./pages/theme-pages/Puzzles";
import Miscellany from "./pages/theme-pages/Miscellany";
import ContestProblems from "./pages/theme-pages/ContestProblems";
import BinaryNumberGamePage from "./pages/BinaryNumberGamePage";
import BinaryNumberGameGreenScreen from "./pages/BinaryNumberGameGreenScreen";
import TernaryNumberGamePage from "./pages/TernaryNumberGamePage";
import BalancedTernaryGamePage from "./pages/BalancedTernaryGamePage";
import BinarySearchTrickPage from "./pages/BinarySearchTrickPage";
import BinarySearchTrickGreenScreen from "./pages/BinarySearchTrickGreenScreen";
import GameOfSimGreenScreen from "./pages/GameOfSimGreenScreen";
import GameOfSimPage from "./pages/GameOfSimPage";
import TernarySearchTrickPage from "./pages/TernarySearchTrickPage";
import TernarySearchTrickGreenScreen from "./pages/TernarySearchTrickGreenScreen";
import NorthcottsGamePage from "./pages/NorthcottsGamePage";
import KnightsPuzzlePage from "./pages/KnightsPuzzlePage";
import NimGamePage from "./pages/NimGamePage";
import AssistedNimGamePage from "./pages/AssistedNimGamePage";
import GoldCoinGamePage from "./pages/GoldCoinGamePage";
import PlateSwapPuzzlePage from "./pages/PlateSwapPuzzlePage";
import ChessboardRepaintPuzzlePage from "./pages/ChessboardRepaintPuzzlePage";
import ZeckendorfGamePage from "./pages/ZeckendorfGamePage";
import ZeckendorfSearchTrickPage from "./pages/ZeckendorfSearchTrickPage";
import GuessingGamePage from "./pages/GuessingGamePage";
import Foundations from "./pages/theme-pages/discrete-math/Foundations";
import Proofs from "./pages/theme-pages/discrete-math/Proofs";
import Counting from "./pages/theme-pages/discrete-math/Counting";
import Uncertainty from "./pages/theme-pages/discrete-math/Uncertainty";
import Structures from "./pages/theme-pages/discrete-math/Structures";
import Numbers from "./pages/theme-pages/discrete-math/Numbers";
import Graphs from "./pages/theme-pages/discrete-math/Graphs";
import NotFound from "./pages/NotFound";
import GridTilingPuzzlePage from './pages/GridTilingPuzzlePage';
import SunnyLinesPuzzlePage from './pages/SunnyLinesPuzzlePage';
import SikiniaParliamentPuzzlePage from './pages/SikiniaParliamentPuzzlePage';
import NQueensPuzzlePage from './pages/NQueensPuzzlePage';
import RulesOfInferencePlaygroundPage from './pages/RulesOfInferencePlaygroundPage';
import PebblePlacementGamePage from './pages/PebblePlacementGamePage';
import BulgarianSolitairePage from './pages/BulgarianSolitairePage';
import SubtractionGamePage from './pages/SubtractionGamePage';
import StackingBlocksPage from './pages/StackingBlocksPage';
import ParityBitsGamePage from './pages/ParityBitsGamePage';
import CrapsGamePage from './pages/CrapsGamePage';
import NeighborSumAvoidancePage from './pages/NeighborSumAvoidancePage';
import BurnsidesLemmaPage from './pages/BurnsidesLemmaPage';
import CubeColoringPage from './pages/CubeColoringPage';
import PresentsPuzzlePage from './pages/PresentsPuzzlePage';
import FerrersRogersRamanujanPage from './pages/FerrersRogersRamanujanPage';
import DominoRetilingPuzzlePage from './pages/DominoRetilingPuzzlePage';
import RentDivisionPuzzlePage from './pages/RentDivisionPuzzlePage';
import BagchalGamePage from './pages/BagchalGamePage';
import AscDescGridPuzzlePage from './pages/AscDescGridPuzzlePage';
import EternalDominationGamePage from './pages/EternalDominationGamePage';
import LadybugClockPuzzlePage from './pages/LadybugClockPuzzlePage';
import ErdosDiscrepancyPuzzlePage from './pages/ErdosDiscrepancyPuzzlePage';
const queryClient = new QueryClient();
const App = () => (
<ErrorBoundary>
<QueryClientProvider client={queryClient}>
<TooltipProvider>
<Toaster />
<Sonner />
<BrowserRouter>
<CommandSearch />
<Routes>
<Route path="/" element={<Index />} />
<Route path="/index" element={<InteractiveIndex />} />
<Route path="/themes" element={<Themes />} />
<Route path="/about" element={<About />} />
{/* Theme-specific routes */}
<Route path="/themes/discrete-math" element={<DiscreteMath />} />
<Route path="/themes/discrete-math/foundations" element={<Foundations />} />
<Route path="/themes/discrete-math/proofs" element={<Proofs />} />
<Route path="/themes/discrete-math/counting" element={<Counting />} />
<Route path="/themes/discrete-math/uncertainty" element={<Uncertainty />} />
<Route path="/themes/discrete-math/structures" element={<Structures />} />
<Route path="/themes/discrete-math/numbers" element={<Numbers />} />
<Route path="/themes/discrete-math/graphs" element={<Graphs />} />
<Route path="/themes/discrete-math/graphs/neighbor-sum-avoidance" element={<NeighborSumAvoidancePage />} />
<Route path="/themes/discrete-math/structures/burnsides-lemma" element={<BurnsidesLemmaPage />} />
<Route path="/themes/discrete-math/structures/cube-coloring" element={<CubeColoringPage />} />
<Route path="/themes/discrete-math/uncertainty/presents-puzzle" element={<PresentsPuzzlePage />} />
<Route path="/themes/discrete-math/counting/ferrers-rogers-ramanujan" element={<FerrersRogersRamanujanPage />} />
<Route path="/themes/social-choice" element={<SocialChoice />} />
<Route path="/themes/social-choice/rent-division" element={<RentDivisionPuzzlePage />} />
<Route path="/themes/advanced-algorithms" element={<AdvancedAlgorithms />} />
<Route path="/themes/data-structures" element={<DataStructures />} />
<Route path="/themes/games" element={<Games />} />
<Route path="/themes/games/subtraction-game" element={<SubtractionGamePage />} />
<Route path="/themes/games/craps-game" element={<CrapsGamePage />} />
<Route path="/themes/games/sim" element={<GameOfSimPage />} />
<Route path="/themes/games/bagchal" element={<BagchalGamePage />} />
<Route path="/themes/cards-math" element={<CardsMath />} />
<Route path="/themes/puzzles" element={<Puzzles />} />
<Route path="/themes/puzzles/stacking-blocks" element={<StackingBlocksPage />} />
<Route path="/parity-bits" element={<ParityBitsGamePage />} />
<Route path="/parity-magic" element={<ParityBitsGamePage />} />
<Route path="/themes/miscellany" element={<Miscellany />} />
<Route path="/themes/miscellany/eternal-domination" element={<EternalDominationGamePage />} />
<Route path="/themes/contest-problems" element={<ContestProblems />} />
<Route path="/contest-problems/grid-tiling" element={<GridTilingPuzzlePage />} />
<Route path="/contest-problems/sunny-lines" element={<SunnyLinesPuzzlePage />} />
{/* Direct interactive routes */}
<Route path="/binary-number-game" element={<BinaryNumberGamePage />} />
<Route path="/binary-number-game/:mode" element={<BinaryNumberGameGreenScreen />} />
<Route path="/ternary-number-game" element={<TernaryNumberGamePage />} />
<Route path="/balanced-ternary-game" element={<BalancedTernaryGamePage />} />
<Route path="/binary-search-trick" element={<BinarySearchTrickPage />} />
<Route path="/binary-search-trick/:mode" element={<BinarySearchTrickGreenScreen />} />
<Route path="/sim" element={<GameOfSimPage />} />
<Route path="/sim/:mode" element={<GameOfSimGreenScreen />} />
<Route path="/ternary-search-trick" element={<TernarySearchTrickPage />} />
<Route path="/ternary-search-trick/:mode" element={<TernarySearchTrickGreenScreen />} />
<Route path="/northcotts-game" element={<NorthcottsGamePage />} />
<Route path="/knights-puzzle" element={<KnightsPuzzlePage />} />
<Route path="/games/nim" element={<NimGamePage />} />
<Route path="/games/assisted-nim" element={<AssistedNimGamePage />} />
<Route path="/games/gold-coin" element={<GoldCoinGamePage />} />
<Route path="/puzzles/pebble-placement" element={<PebblePlacementGamePage />} />
<Route path="/puzzles/bulgarian-solitaire" element={<BulgarianSolitairePage />} />
<Route path="/subtraction-game" element={<SubtractionGamePage />} />
<Route path="/puzzles/stacking-blocks" element={<StackingBlocksPage />} />
<Route path="/puzzles/sikinia-parliament" element={<SikiniaParliamentPuzzlePage />} />
<Route path="/puzzles/plate-swap" element={<PlateSwapPuzzlePage />} />
<Route path="/puzzles/chessboard-repaint" element={<ChessboardRepaintPuzzlePage />} />
<Route path="/puzzles/n-queens" element={<NQueensPuzzlePage />} />
<Route path="/puzzles/domino-retiling" element={<DominoRetilingPuzzlePage />} />
<Route path="/puzzles/asc-desc-grid" element={<AscDescGridPuzzlePage />} />
<Route path="/puzzles/ladybug-clock" element={<LadybugClockPuzzlePage />} />
<Route path="/puzzles/erdos-discrepancy" element={<ErdosDiscrepancyPuzzlePage />} />
<Route path="/discrete-math/foundations/zeckendorf" element={<ZeckendorfGamePage />} />
<Route path="/discrete-math/foundations/zeckendorf-search" element={<ZeckendorfSearchTrickPage />} />
<Route path="/discrete-math/foundations/rules-of-inference" element={<RulesOfInferencePlaygroundPage />} />
<Route path="/guessing-game" element={<GuessingGamePage />} />
{/* ADD ALL CUSTOM ROUTES ABOVE THE CATCH-ALL "*" ROUTE */}
<Route path="*" element={<NotFound />} />
</Routes>
</BrowserRouter>
</TooltipProvider>
</QueryClientProvider>
</ErrorBoundary>
);
export default App;

Binary file not shown.

Before

Width:  |  Height:  |  Size: 211 KiB

View file

@ -0,0 +1,104 @@
import React, { useState, useEffect } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
import { Plus } from "lucide-react";
import { isAdminLoggedIn, addIdea } from "@/lib/admin";
interface AddIdeaFormProps {
defaultTheme?: string;
defaultSubcategory?: string;
}
const AddIdeaForm: React.FC<AddIdeaFormProps> = ({
defaultTheme,
defaultSubcategory,
}) => {
const [isAdmin, setIsAdmin] = useState(false);
const [open, setOpen] = useState(false);
const [title, setTitle] = useState("");
const [description, setDescription] = useState("");
const [saved, setSaved] = useState(false);
useEffect(() => {
setIsAdmin(isAdminLoggedIn());
}, []);
if (!isAdmin) return null;
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!title.trim()) return;
addIdea({
title: title.trim(),
description: description.trim(),
themes: defaultTheme ? [defaultTheme] : [],
subcategory: defaultSubcategory,
status: "idea",
notes: "",
});
setTitle("");
setDescription("");
setSaved(true);
setTimeout(() => {
setSaved(false);
setOpen(false);
}, 1000);
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button
variant="outline"
size="sm"
className="gap-2 border-dashed border-primary/30 text-primary hover:bg-primary/5"
>
<Plus className="w-4 h-4" />
Add Idea
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>New Interactive Idea</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="text-sm font-medium mb-1.5 block">Title</label>
<Input
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="e.g., Stable Marriage Problem"
autoFocus
/>
</div>
<div>
<label className="text-sm font-medium mb-1.5 block">Description</label>
<Textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Brief description of the interactive..."
rows={3}
/>
</div>
{defaultTheme && (
<p className="text-xs text-muted-foreground">
Theme: <strong>{defaultTheme}</strong>
{defaultSubcategory && (
<> / <strong>{defaultSubcategory}</strong></>
)}
</p>
)}
<Button type="submit" className="w-full" disabled={!title.trim()}>
{saved ? "Saved!" : "Save Idea"}
</Button>
</form>
</DialogContent>
</Dialog>
);
};
export default AddIdeaForm;

344
src/components/AdminBar.tsx Normal file
View file

@ -0,0 +1,344 @@
import React, { useState, useEffect } from "react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Checkbox } from "@/components/ui/checkbox";
import {
Shield, StickyNote, ChevronDown, ChevronUp, Save, Check,
Loader2, Plus, Trash2, ListTodo, MessageSquare,
} from "lucide-react";
import {
isAdminLoggedIn,
getNote,
setNote,
getStatusOverride,
setStatusOverride,
commitStatus,
syncFromServer,
getTodos,
addTodo,
toggleTodo,
removeTodo,
updateTodoRemark,
type TodoItem,
} from "@/lib/admin";
import type { InteractiveStatus } from "@/lib/interactives";
interface AdminBarProps {
slug: string;
currentStatus: InteractiveStatus;
}
const statusOptions: { value: InteractiveStatus; label: string; color: string }[] = [
{ value: "published", label: "Published", color: "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200" },
{ value: "draft", label: "Draft", color: "bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200" },
];
const TodoRow: React.FC<{
todo: TodoItem;
slug: string;
onToggle: () => void;
onRemove: () => void;
onRemarkChange: (remark: string) => void;
}> = ({ todo, slug, onToggle, onRemove, onRemarkChange }) => {
const [showRemark, setShowRemark] = useState(false);
const [remarkText, setRemarkText] = useState(todo.remark || "");
const handleRemarkBlur = () => {
if (remarkText !== (todo.remark || "")) {
onRemarkChange(remarkText);
}
};
return (
<li className="group/todo">
<div className="flex items-start gap-2">
<Checkbox
checked={todo.done}
onCheckedChange={onToggle}
className="shrink-0 mt-0.5"
/>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5">
<span className="text-xs font-mono text-primary/60">{todo.refId}</span>
<span className={`text-sm ${todo.done ? "line-through text-muted-foreground" : ""}`}>
{todo.text}
</span>
</div>
{todo.done && todo.doneDate && (
<span className="text-xs text-green-600">Done {todo.doneDate}</span>
)}
{todo.remark && !showRemark && (
<p className="text-xs text-muted-foreground italic mt-0.5">{todo.remark}</p>
)}
{showRemark && (
<Input
value={remarkText}
onChange={(e) => setRemarkText(e.target.value)}
onBlur={handleRemarkBlur}
onKeyDown={(e) => { if (e.key === "Enter") { handleRemarkBlur(); setShowRemark(false); } }}
placeholder="Add a remark..."
className="text-xs h-7 mt-1"
autoFocus
/>
)}
</div>
<div className="flex gap-0.5 opacity-0 group-hover/todo:opacity-100 transition-opacity shrink-0">
<button
onClick={() => setShowRemark((v) => !v)}
className="p-1 text-muted-foreground hover:text-foreground"
title="Add remark"
>
<MessageSquare className="w-3 h-3" />
</button>
<button
onClick={onRemove}
className="p-1 text-muted-foreground hover:text-destructive"
>
<Trash2 className="w-3 h-3" />
</button>
</div>
</div>
</li>
);
};
const AdminBar: React.FC<AdminBarProps> = ({ slug, currentStatus }) => {
const [isAdmin, setIsAdmin] = useState(false);
const [expanded, setExpanded] = useState(false);
const [status, setStatus] = useState<InteractiveStatus>(currentStatus);
const [noteText, setNoteText] = useState("");
const [hasOverride, setHasOverride] = useState(false);
const [saving, setSaving] = useState(false);
const [saved, setSaved] = useState(false);
const [todos, setTodos] = useState<TodoItem[]>([]);
const [newTodo, setNewTodo] = useState("");
const refreshTodos = () => setTodos(getTodos(slug));
useEffect(() => {
const admin = isAdminLoggedIn();
setIsAdmin(admin);
if (admin) {
syncFromServer().finally(() => {
const override = getStatusOverride(slug);
if (override) {
setStatus(override);
setHasOverride(true);
}
setNoteText(getNote(slug));
refreshTodos();
});
}
}, [slug]);
if (!isAdmin) return null;
const handleStatusChange = (newStatus: InteractiveStatus) => {
setStatus(newStatus);
setStatusOverride(slug, newStatus);
setHasOverride(true);
setSaved(false);
};
const handleNoteChange = (value: string) => {
setNoteText(value);
setNote(slug, value);
};
const handleSave = async () => {
setSaving(true);
const success = await commitStatus(slug, status);
setSaving(false);
if (success) {
setHasOverride(false);
setSaved(true);
setTimeout(() => setSaved(false), 2000);
}
};
const handleAddTodo = (e: React.FormEvent) => {
e.preventDefault();
if (!newTodo.trim()) return;
addTodo(slug, newTodo.trim());
setNewTodo("");
refreshTodos();
};
const handleToggleTodo = (todoId: string) => {
toggleTodo(slug, todoId);
refreshTodos();
};
const handleRemoveTodo = (todoId: string) => {
removeTodo(slug, todoId);
refreshTodos();
};
const handleRemarkChange = (todoId: string, remark: string) => {
updateTodoRemark(slug, todoId, remark);
refreshTodos();
};
const isDirty = hasOverride;
const doneCount = todos.filter((t) => t.done).length;
const hasTodos = todos.length > 0;
const openTodos = todos.filter((t) => !t.done);
const doneTodos = todos.filter((t) => t.done);
return (
<div className="mb-4 rounded-lg border-2 border-dashed border-primary/15 bg-primary/[0.02]">
<button
onClick={() => setExpanded((v) => !v)}
className="w-full flex items-center justify-between px-4 py-2 text-sm"
>
<span className="flex items-center gap-2 text-primary font-medium">
<Shield className="w-4 h-4" />
Admin
{isDirty && (
<Badge variant="outline" className="text-xs">
unsaved
</Badge>
)}
{saved && (
<Badge variant="outline" className="text-xs text-green-600 border-green-300">
saved
</Badge>
)}
{noteText && (
<StickyNote className="w-3.5 h-3.5 text-yellow-600" />
)}
{hasTodos && (
<span className="flex items-center gap-1 text-xs text-muted-foreground">
<ListTodo className="w-3.5 h-3.5" />
{doneCount}/{todos.length}
</span>
)}
</span>
<span className="flex items-center gap-2">
<span className={`inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium ${
statusOptions.find((o) => o.value === status)?.color || ""
}`}>
{status}
</span>
{expanded ? (
<ChevronUp className="w-4 h-4 text-muted-foreground" />
) : (
<ChevronDown className="w-4 h-4 text-muted-foreground" />
)}
</span>
</button>
{expanded && (
<div className="px-4 pb-4 space-y-4">
{/* Status control */}
<div>
<label className="text-xs font-medium text-muted-foreground mb-2 block">
Status
</label>
<div className="flex items-center gap-2">
<div className="flex gap-2">
{statusOptions.map((opt) => (
<button
key={opt.value}
onClick={() => handleStatusChange(opt.value)}
className={`rounded-full px-3 py-1 text-xs font-medium transition-all ${
status === opt.value
? `${opt.color} ring-2 ring-offset-1 ring-current`
: "bg-muted text-muted-foreground hover:bg-muted/80"
}`}
>
{opt.label}
</button>
))}
</div>
<Button
size="sm"
onClick={handleSave}
disabled={!isDirty || saving}
className="gap-1.5 h-7 text-xs ml-2"
>
{saving ? (
<Loader2 className="w-3 h-3 animate-spin" />
) : saved ? (
<Check className="w-3 h-3" />
) : (
<Save className="w-3 h-3" />
)}
{saving ? "Saving..." : saved ? "Saved" : "Save"}
</Button>
</div>
</div>
{/* Todos */}
<div>
<label className="text-xs font-medium text-muted-foreground mb-2 block">
Todos {hasTodos && <span className="text-muted-foreground/60">({doneCount}/{todos.length} done)</span>}
</label>
{openTodos.length > 0 && (
<ul className="space-y-2 mb-2">
{openTodos.map((todo) => (
<TodoRow
key={todo.id}
todo={todo}
slug={slug}
onToggle={() => handleToggleTodo(todo.id)}
onRemove={() => handleRemoveTodo(todo.id)}
onRemarkChange={(r) => handleRemarkChange(todo.id, r)}
/>
))}
</ul>
)}
{doneTodos.length > 0 && (
<details className="mb-2">
<summary className="text-xs text-muted-foreground cursor-pointer mb-1">
{doneTodos.length} completed
</summary>
<ul className="space-y-2 opacity-60">
{doneTodos.map((todo) => (
<TodoRow
key={todo.id}
todo={todo}
slug={slug}
onToggle={() => handleToggleTodo(todo.id)}
onRemove={() => handleRemoveTodo(todo.id)}
onRemarkChange={(r) => handleRemarkChange(todo.id, r)}
/>
))}
</ul>
</details>
)}
<form onSubmit={handleAddTodo} className="flex gap-2">
<Input
value={newTodo}
onChange={(e) => setNewTodo(e.target.value)}
placeholder="Add a todo... (use T1, T2 to reference others)"
className="text-sm h-8"
/>
<Button type="submit" size="sm" variant="outline" className="h-8 px-2" disabled={!newTodo.trim()}>
<Plus className="w-4 h-4" />
</Button>
</form>
</div>
{/* Notes */}
<div>
<label className="text-xs font-medium text-muted-foreground mb-2 block">
Notes
</label>
<Textarea
value={noteText}
onChange={(e) => handleNoteChange(e.target.value)}
placeholder="Add private notes about this interactive..."
rows={3}
className="text-sm"
/>
</div>
</div>
)}
</div>
);
};
export default AdminBar;

View file

@ -0,0 +1,459 @@
import React, { useState, useEffect } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import {
Lock, Search, ExternalLink, LogOut, Trash2, StickyNote,
Lightbulb, ListTodo, CheckCircle, Circle,
} from "lucide-react";
import {
allInteractives,
type InteractiveStatus,
} from "@/lib/interactives";
import {
ADMIN_PASSWORD,
isAdminLoggedIn,
setAdminLoggedIn,
getAllStatusOverrides,
getAllNotes,
getIdeas,
removeIdea,
syncFromServer,
deleteInteractive,
getTodos,
type IdeaEntry,
type TodoItem,
} from "@/lib/admin";
const statusColors: Record<InteractiveStatus, string> = {
published: "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200",
draft: "bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200",
idea: "bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200",
};
const AdminDashboard: React.FC = () => {
const [authenticated, setAuthenticated] = useState(false);
const [password, setPassword] = useState("");
const [error, setError] = useState(false);
const [searchTerm, setSearchTerm] = useState("");
const [filterStatus, setFilterStatus] = useState<InteractiveStatus | "all">("all");
const [filterTheme, setFilterTheme] = useState<string>("all");
const [overrides, setOverrides] = useState<Record<string, InteractiveStatus>>({});
const [notes, setNotes] = useState<Record<string, string>>({});
const [ideas, setIdeas] = useState<IdeaEntry[]>([]);
const [allTodos, setAllTodos] = useState<{ slug: string; title: string; todos: TodoItem[] }[]>([]);
const [todoFilter, setTodoFilter] = useState<"open" | "done" | "all">("open");
const [deleting, setDeleting] = useState<string | null>(null);
const [interactivesPage, setInteractivesPage] = useState(1);
const [todosPage, setTodosPage] = useState(1);
const [ideasPage, setIdeasPage] = useState(1);
const PAGE_SIZE = 20;
useEffect(() => {
if (isAdminLoggedIn()) setAuthenticated(true);
}, []);
const refreshFromStorage = () => {
setOverrides(getAllStatusOverrides());
setNotes(getAllNotes());
setIdeas(getIdeas());
// Load todos for all interactives
const todosData = allInteractives
.map((i) => ({ slug: i.slug, title: i.title, todos: getTodos(i.slug) }))
.filter((t) => t.todos.length > 0);
setAllTodos(todosData);
};
useEffect(() => {
if (authenticated) {
syncFromServer().then(refreshFromStorage).catch(refreshFromStorage);
}
}, [authenticated]);
// Reset pagination when filters change
useEffect(() => { setInteractivesPage(1); }, [searchTerm, filterStatus, filterTheme]);
useEffect(() => { setTodosPage(1); }, [todoFilter]);
useEffect(() => { setIdeasPage(1); }, [searchTerm, filterStatus, filterTheme]);
const handleLogin = (e: React.FormEvent) => {
e.preventDefault();
if (password === ADMIN_PASSWORD) {
setAdminLoggedIn(true);
setAuthenticated(true);
setError(false);
} else {
setError(true);
}
};
const handleLogout = () => {
setAdminLoggedIn(false);
setAuthenticated(false);
setPassword("");
};
if (!authenticated) {
return (
<div className="flex items-center justify-center min-h-[60vh]">
<Card className="w-full max-w-sm">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Lock className="w-5 h-5" />
Admin Login
</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={handleLogin} className="space-y-4">
<Input
type="password"
placeholder="Password"
value={password}
onChange={(e) => { setPassword(e.target.value); setError(false); }}
autoFocus
/>
{error && <p className="text-sm text-destructive">Incorrect password.</p>}
<Button type="submit" className="w-full">Sign In</Button>
</form>
</CardContent>
</Card>
</div>
);
}
const effectiveStatus = (slug: string, sourceStatus: InteractiveStatus) =>
overrides[slug] || sourceStatus;
const filteredInteractives = allInteractives.filter((i) => {
const matchesSearch = !searchTerm || i.title.toLowerCase().includes(searchTerm.toLowerCase()) || i.slug.toLowerCase().includes(searchTerm.toLowerCase());
const status = effectiveStatus(i.slug, i.status);
const matchesStatus = filterStatus === "all" || status === filterStatus;
const matchesTheme = filterTheme === "all" || i.themes.includes(filterTheme);
return matchesSearch && matchesStatus && matchesTheme;
});
const filteredIdeas = ideas.filter((idea) => {
const matchesSearch = !searchTerm || idea.title.toLowerCase().includes(searchTerm.toLowerCase()) || idea.description.toLowerCase().includes(searchTerm.toLowerCase());
const matchesStatus = filterStatus === "all" || filterStatus === "idea";
const matchesTheme = filterTheme === "all" || idea.themes.includes(filterTheme);
return matchesSearch && matchesStatus && matchesTheme;
});
const counts = {
published: allInteractives.filter((i) => effectiveStatus(i.slug, i.status) === "published").length,
draft: allInteractives.filter((i) => effectiveStatus(i.slug, i.status) === "draft").length,
idea: allInteractives.filter((i) => effectiveStatus(i.slug, i.status) === "idea").length + ideas.length,
};
const uniqueThemes = Array.from(new Set([...allInteractives.flatMap((i) => i.themes), ...ideas.flatMap((i) => i.themes)])).sort();
const hasOverrides = Object.keys(overrides).length > 0;
const handleRemoveIdea = (id: string) => { removeIdea(id); refreshFromStorage(); };
const handleDeleteInteractive = async (slug: string) => {
if (!confirm(`Delete "${slug}" permanently? This removes it from interactives.ts.`)) return;
setDeleting(slug);
await deleteInteractive(slug);
setDeleting(null);
// Page needs reload since allInteractives is static
window.location.reload();
};
// Todos aggregation
const totalOpenTodos = allTodos.reduce((sum, t) => sum + t.todos.filter((x) => !x.done).length, 0);
const totalDoneTodos = allTodos.reduce((sum, t) => sum + t.todos.filter((x) => x.done).length, 0);
const filteredTodos = allTodos
.map((entry) => ({
...entry,
todos: entry.todos.filter((t) => {
if (todoFilter === "open") return !t.done;
if (todoFilter === "done") return t.done;
return true;
}),
}))
.filter((entry) => entry.todos.length > 0);
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-3xl font-bold font-display">Admin Dashboard</h1>
<Button variant="ghost" size="sm" onClick={handleLogout} className="gap-2">
<LogOut className="w-4 h-4" /> Sign Out
</Button>
</div>
{/* Status summary */}
<div className="grid gap-4 sm:grid-cols-3">
{(["published", "draft", "idea"] as const).map((s) => {
const colors = { published: "text-green-600", draft: "text-yellow-600", idea: "text-blue-600" };
const rings = { published: "ring-green-500", draft: "ring-yellow-500", idea: "ring-blue-500" };
return (
<Card key={s} className={`cursor-pointer transition-shadow hover:shadow-md ${filterStatus === s ? `ring-2 ${rings[s]}` : ""}`}
onClick={() => setFilterStatus(filterStatus === s ? "all" : s)}>
<CardContent className="pt-6">
<div className={`text-3xl font-bold ${colors[s]}`}>{counts[s]}</div>
<p className="text-sm text-muted-foreground capitalize">{s}</p>
</CardContent>
</Card>
);
})}
</div>
{hasOverrides && (
<Card className="border-yellow-300 bg-yellow-50 dark:bg-yellow-950 dark:border-yellow-800">
<CardContent className="pt-4 pb-4">
<p className="text-sm"><strong>{Object.keys(overrides).length} pending status change(s)</strong> use the Save button on each interactive's page to commit.</p>
</CardContent>
</Card>
)}
<Tabs defaultValue="interactives">
<TabsList>
<TabsTrigger value="interactives">Interactives</TabsTrigger>
<TabsTrigger value="todos" className="gap-1.5">
Todos
{totalOpenTodos > 0 && (
<Badge variant="secondary" className="text-xs ml-1">{totalOpenTodos}</Badge>
)}
</TabsTrigger>
<TabsTrigger value="ideas" className="gap-1.5">
Ideas
{ideas.length > 0 && (
<Badge variant="secondary" className="text-xs ml-1">{ideas.length}</Badge>
)}
</TabsTrigger>
</TabsList>
{/* ─── Interactives Tab ─── */}
<TabsContent value="interactives" className="space-y-4 mt-4">
<div className="flex flex-col sm:flex-row gap-3">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground w-4 h-4" />
<Input placeholder="Search..." value={searchTerm} onChange={(e) => setSearchTerm(e.target.value)} className="pl-10" />
</div>
<select value={filterStatus} onChange={(e) => setFilterStatus(e.target.value as any)} className="rounded-md border border-input bg-background px-3 py-2 text-sm">
<option value="all">All statuses</option>
<option value="published">Published</option>
<option value="draft">Draft</option>
<option value="idea">Idea</option>
</select>
<select value={filterTheme} onChange={(e) => setFilterTheme(e.target.value)} className="rounded-md border border-input bg-background px-3 py-2 text-sm">
<option value="all">All themes</option>
{uniqueThemes.map((t) => <option key={t} value={t}>{t}</option>)}
</select>
</div>
<p className="text-sm text-muted-foreground">
Showing {filteredInteractives.length} of {allInteractives.length}
</p>
{filteredInteractives.length > 0 && (() => {
const totalInteractivesPages = Math.ceil(filteredInteractives.length / PAGE_SIZE);
const paginatedInteractives = filteredInteractives.slice((interactivesPage - 1) * PAGE_SIZE, interactivesPage * PAGE_SIZE);
return (<>
<div className="border rounded-lg overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-muted/50">
<tr>
<th className="text-left px-4 py-3 font-medium">Interactive</th>
<th className="text-left px-4 py-3 font-medium hidden sm:table-cell">Theme</th>
<th className="text-left px-4 py-3 font-medium">Status</th>
<th className="text-left px-4 py-3 font-medium hidden md:table-cell">Added</th>
<th className="px-4 py-3 font-medium w-20"></th>
</tr>
</thead>
<tbody className="divide-y">
{paginatedInteractives.map((item) => {
const status = effectiveStatus(item.slug, item.status);
const hasNote = notes[item.slug];
const todoCount = getTodos(item.slug).filter((t) => !t.done).length;
return (
<tr key={item.slug} className="hover:bg-muted/30">
<td className="px-4 py-3">
<div className="flex items-center gap-2">
<div>
<p className="font-medium">{item.title}</p>
<p className="text-xs text-muted-foreground">{item.slug}</p>
</div>
{hasNote && <StickyNote className="w-3.5 h-3.5 text-yellow-500 shrink-0" />}
{todoCount > 0 && (
<span className="flex items-center gap-0.5 text-xs text-muted-foreground">
<ListTodo className="w-3 h-3" />{todoCount}
</span>
)}
</div>
</td>
<td className="px-4 py-3 hidden sm:table-cell">
<div className="flex flex-wrap gap-1">
{item.themes.map((t) => <Badge key={t} variant="outline" className="text-xs">{t}</Badge>)}
</div>
</td>
<td className="px-4 py-3">
<span className={`inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium ${statusColors[status]}`}>{status}</span>
</td>
<td className="px-4 py-3 text-muted-foreground hidden md:table-cell">{item.dateAdded}</td>
<td className="px-4 py-3">
<div className="flex items-center gap-1">
<a href={`/i/${item.slug}`} className="text-muted-foreground hover:text-foreground" target="_blank" rel="noreferrer">
<ExternalLink className="w-4 h-4" />
</a>
<button
onClick={() => handleDeleteInteractive(item.slug)}
disabled={deleting === item.slug}
className="text-muted-foreground hover:text-destructive disabled:opacity-50"
title="Delete interactive"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
{totalInteractivesPages > 1 && (
<div className="flex items-center justify-center gap-2 pt-4">
<Button variant="outline" size="sm" onClick={() => setInteractivesPage(p => p - 1)} disabled={interactivesPage === 1}>Previous</Button>
<span className="text-sm text-muted-foreground">Page {interactivesPage} of {totalInteractivesPages}</span>
<Button variant="outline" size="sm" onClick={() => setInteractivesPage(p => p + 1)} disabled={interactivesPage === totalInteractivesPages}>Next</Button>
</div>
)}
</>);
})()}
</TabsContent>
{/* ─── Todos Tab ─── */}
<TabsContent value="todos" className="space-y-4 mt-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<p className="text-sm text-muted-foreground">
{totalOpenTodos} open, {totalDoneTodos} done
</p>
</div>
<div className="flex gap-1">
{(["open", "done", "all"] as const).map((f) => (
<Button key={f} variant={todoFilter === f ? "default" : "ghost"} size="sm"
className={todoFilter === f ? "!h-8 !min-h-8 rounded-full !px-4" : ""}
onClick={() => setTodoFilter(f)}>
{f === "open" ? "Open" : f === "done" ? "Done" : "All"}
</Button>
))}
</div>
</div>
{filteredTodos.length === 0 && (
<div className="text-center py-12">
<p className="text-muted-foreground">
{todoFilter === "open" ? "No open todos." : todoFilter === "done" ? "No completed todos." : "No todos."}
</p>
</div>
)}
{(() => {
const totalTodosPages = Math.ceil(filteredTodos.length / PAGE_SIZE);
const paginatedTodos = filteredTodos.slice((todosPage - 1) * PAGE_SIZE, todosPage * PAGE_SIZE);
return (<>
{paginatedTodos.map((entry) => (
<Card key={entry.slug}>
<CardHeader className="pb-2">
<CardTitle className="text-sm flex items-center justify-between">
<a href={`/i/${entry.slug}`} className="hover:text-primary transition-colors">
{entry.title}
</a>
<Badge variant="outline" className="text-xs font-mono">{entry.slug}</Badge>
</CardTitle>
</CardHeader>
<CardContent>
<ul className="space-y-2">
{entry.todos.map((todo) => (
<li key={todo.id} className="flex items-start gap-2">
{todo.done ? (
<CheckCircle className="w-4 h-4 text-green-500 shrink-0 mt-0.5" />
) : (
<Circle className="w-4 h-4 text-muted-foreground shrink-0 mt-0.5" />
)}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5">
<span className="text-xs font-mono text-primary/60">{todo.refId}</span>
<span className={`text-sm ${todo.done ? "line-through text-muted-foreground" : ""}`}>
{todo.text}
</span>
</div>
{todo.remark && (
<p className="text-xs text-muted-foreground italic mt-0.5">{todo.remark}</p>
)}
{todo.done && todo.doneDate && (
<span className="text-xs text-green-600">Done {todo.doneDate}</span>
)}
</div>
</li>
))}
</ul>
</CardContent>
</Card>
))}
{totalTodosPages > 1 && (
<div className="flex items-center justify-center gap-2 pt-4">
<Button variant="outline" size="sm" onClick={() => setTodosPage(p => p - 1)} disabled={todosPage === 1}>Previous</Button>
<span className="text-sm text-muted-foreground">Page {todosPage} of {totalTodosPages}</span>
<Button variant="outline" size="sm" onClick={() => setTodosPage(p => p + 1)} disabled={todosPage === totalTodosPages}>Next</Button>
</div>
)}
</>);
})()}
</TabsContent>
{/* ─── Ideas Tab ─── */}
<TabsContent value="ideas" className="space-y-4 mt-4">
{ideas.length === 0 && (
<div className="text-center py-12">
<p className="text-muted-foreground">No ideas yet. Add ideas from theme or subcategory pages.</p>
</div>
)}
{(() => {
const totalIdeasPages = Math.ceil(filteredIdeas.length / PAGE_SIZE);
const paginatedIdeas = filteredIdeas.slice((ideasPage - 1) * PAGE_SIZE, ideasPage * PAGE_SIZE);
return (<>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{paginatedIdeas.map((idea) => (
<Card key={idea.id} className="relative border-dashed border-blue-300 dark:border-blue-700">
<button onClick={() => handleRemoveIdea(idea.id)} className="absolute top-3 right-3 text-muted-foreground hover:text-destructive">
<Trash2 className="w-3.5 h-3.5" />
</button>
<CardHeader className="pb-2">
<CardTitle className="text-base pr-6 flex items-center gap-2">
<Lightbulb className="w-4 h-4 text-blue-500 shrink-0" />
{idea.title}
</CardTitle>
</CardHeader>
<CardContent>
{idea.description && <p className="text-sm text-muted-foreground mb-2">{idea.description}</p>}
<div className="flex flex-wrap gap-1">
{idea.themes.map((t) => <Badge key={t} variant="outline" className="text-xs">{t}</Badge>)}
{idea.subcategory && <Badge variant="secondary" className="text-xs">{idea.subcategory}</Badge>}
</div>
<p className="text-xs text-muted-foreground mt-2">Added {idea.createdAt}</p>
</CardContent>
</Card>
))}
</div>
{totalIdeasPages > 1 && (
<div className="flex items-center justify-center gap-2 pt-4">
<Button variant="outline" size="sm" onClick={() => setIdeasPage(p => p - 1)} disabled={ideasPage === 1}>Previous</Button>
<span className="text-sm text-muted-foreground">Page {ideasPage} of {totalIdeasPages}</span>
<Button variant="outline" size="sm" onClick={() => setIdeasPage(p => p + 1)} disabled={ideasPage === totalIdeasPages}>Next</Button>
</div>
)}
</>);
})()}
</TabsContent>
</Tabs>
</div>
);
};
export default AdminDashboard;

View file

@ -0,0 +1,63 @@
import React, { useState, useEffect } from "react";
import { Badge } from "@/components/ui/badge";
import { FileEdit } from "lucide-react";
import { isAdminLoggedIn, syncFromServer, getAllStatusOverrides } from "@/lib/admin";
import { allInteractives, type InteractiveStatus } from "@/lib/interactives";
interface AdminDraftCardsProps {
theme?: string;
subcategory?: string;
}
const AdminDraftCards: React.FC<AdminDraftCardsProps> = ({ theme, subcategory }) => {
const [isAdmin, setIsAdmin] = useState(false);
const [drafts, setDrafts] = useState<typeof allInteractives>([]);
useEffect(() => {
const admin = isAdminLoggedIn();
setIsAdmin(admin);
if (admin) {
syncFromServer().finally(() => {
const overrides = getAllStatusOverrides();
const filtered = allInteractives.filter((i) => {
const effectiveStatus: InteractiveStatus = overrides[i.slug] || i.status;
if (effectiveStatus !== "draft") return false;
if (theme && !i.themes.includes(theme)) return false;
if (subcategory && i.subcategory !== subcategory) return false;
return true;
});
setDrafts(filtered);
});
}
}, [theme, subcategory]);
if (!isAdmin || drafts.length === 0) return null;
return (
<>
{drafts.map((item) => (
<a key={item.slug} href={`/i/${item.slug}`} className="group">
<div className="rounded-xl border-2 border-dashed border-yellow-400 dark:border-yellow-600 bg-yellow-50/50 dark:bg-yellow-950/20 p-6 h-full opacity-70">
<div className="flex items-start gap-2 mb-2">
<FileEdit className="w-4 h-4 text-yellow-600 mt-0.5 shrink-0" />
<h3 className="text-lg font-semibold text-yellow-800 dark:text-yellow-300 group-hover:text-yellow-600 transition-colors">
{item.title}
</h3>
</div>
<p className="text-sm text-muted-foreground line-clamp-2 mb-3">
{item.description}
</p>
<Badge
variant="outline"
className="text-xs border-yellow-400 text-yellow-700 dark:border-yellow-600 dark:text-yellow-400"
>
draft
</Badge>
</div>
</a>
))}
</>
);
};
export default AdminDraftCards;

View file

@ -0,0 +1,183 @@
import React, { useState, useEffect, useRef } from "react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Lightbulb, Pencil, Trash2, Check, X } from "lucide-react";
import {
isAdminLoggedIn,
getIdeas,
syncFromServer,
updateIdea,
removeIdea,
type IdeaEntry,
} from "@/lib/admin";
interface AdminIdeaCardsProps {
theme?: string;
subcategory?: string;
}
const IdeaCard: React.FC<{
idea: IdeaEntry;
onUpdate: (id: string, updates: Partial<IdeaEntry>) => void;
onRemove: (id: string) => void;
}> = ({ idea, onUpdate, onRemove }) => {
const [editing, setEditing] = useState(false);
const [title, setTitle] = useState(idea.title);
const [description, setDescription] = useState(idea.description);
const [notes, setNotes] = useState(idea.notes || "");
const titleRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (editing && titleRef.current) {
titleRef.current.focus();
}
}, [editing]);
const handleSave = () => {
onUpdate(idea.id, { title, description, notes });
setEditing(false);
};
const handleCancel = () => {
setTitle(idea.title);
setDescription(idea.description);
setNotes(idea.notes || "");
setEditing(false);
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Escape") handleCancel();
};
if (editing) {
return (
<div
className="rounded-xl border-2 border-dashed border-blue-400 dark:border-blue-600 bg-blue-50/50 dark:bg-blue-950/20 p-4 h-full space-y-3"
onKeyDown={handleKeyDown}
>
<Input
ref={titleRef}
value={title}
onChange={(e) => setTitle(e.target.value)}
className="text-sm font-semibold"
placeholder="Title"
/>
<Textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
className="text-sm"
placeholder="Description"
rows={2}
/>
<Textarea
value={notes}
onChange={(e) => setNotes(e.target.value)}
className="text-xs"
placeholder="Private notes..."
rows={2}
/>
<div className="flex gap-2">
<Button size="sm" onClick={handleSave} className="gap-1 h-7 text-xs">
<Check className="w-3 h-3" /> Save
</Button>
<Button size="sm" variant="ghost" onClick={handleCancel} className="gap-1 h-7 text-xs">
<X className="w-3 h-3" /> Cancel
</Button>
</div>
</div>
);
}
return (
<div className="rounded-xl border-2 border-dashed border-blue-300 dark:border-blue-700 bg-blue-50/50 dark:bg-blue-950/20 p-6 h-full opacity-70 group/idea relative">
<div className="absolute top-3 right-3 flex gap-1 opacity-0 group-hover/idea:opacity-100 transition-opacity">
<button
onClick={() => setEditing(true)}
className="p-1 rounded hover:bg-blue-200 dark:hover:bg-blue-800 text-blue-500"
>
<Pencil className="w-3.5 h-3.5" />
</button>
<button
onClick={() => onRemove(idea.id)}
className="p-1 rounded hover:bg-red-200 dark:hover:bg-red-800 text-red-400"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
<div className="flex items-start gap-2 mb-2">
<Lightbulb className="w-4 h-4 text-blue-500 mt-0.5 shrink-0" />
<h3 className="text-lg font-semibold text-blue-800 dark:text-blue-300">
{idea.title}
</h3>
</div>
{idea.description && (
<p className="text-sm text-muted-foreground line-clamp-2 mb-2">
{idea.description}
</p>
)}
{idea.notes && (
<p className="text-xs text-blue-500/70 italic mb-2 line-clamp-1">
{idea.notes}
</p>
)}
<Badge
variant="outline"
className="text-xs border-blue-300 text-blue-600 dark:border-blue-700 dark:text-blue-400"
>
idea
</Badge>
</div>
);
};
const AdminIdeaCards: React.FC<AdminIdeaCardsProps> = ({ theme, subcategory }) => {
const [isAdmin, setIsAdmin] = useState(false);
const [ideas, setIdeas] = useState<IdeaEntry[]>([]);
const loadIdeas = () => {
const all = getIdeas();
const filtered = all.filter((idea) => {
if (theme && !idea.themes.includes(theme)) return false;
if (subcategory && idea.subcategory !== subcategory) return false;
return true;
});
setIdeas(filtered);
};
useEffect(() => {
const admin = isAdminLoggedIn();
setIsAdmin(admin);
if (admin) {
syncFromServer().finally(loadIdeas);
}
}, [theme, subcategory]);
const handleUpdate = (id: string, updates: Partial<IdeaEntry>) => {
updateIdea(id, updates);
loadIdeas();
};
const handleRemove = (id: string) => {
removeIdea(id);
loadIdeas();
};
if (!isAdmin || ideas.length === 0) return null;
return (
<>
{ideas.map((idea) => (
<IdeaCard
key={idea.id}
idea={idea}
onUpdate={handleUpdate}
onRemove={handleRemove}
/>
))}
</>
);
};
export default AdminIdeaCards;

View file

@ -0,0 +1,123 @@
---
// Import the global.css file here so that it is included on
// all pages through the use of <BaseHead /> component.
import "../styles/global.css";
import { Font } from "astro:assets";
import { SITE_METADATA, SITE_TITLE } from "../consts";
interface Props {
title: string;
description: string;
image?: string;
}
const canonicalURL = new URL(Astro.url.pathname, Astro.site);
const { title, description, image } = Astro.props;
const finalTitle =
title && title !== SITE_METADATA.title.default
? `${title} | Interactives`
: title || SITE_METADATA.title.default;
const finalDescription = description || SITE_METADATA.description;
const finalImage = image || SITE_METADATA.openGraph.images[0].url;
const imageURL = new URL(finalImage, Astro.site);
---
<!-- Global Metadata -->
<meta charset="utf-8" />
<Font cssVariable="--font-imprima" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<meta
name="robots"
content={`${SITE_METADATA.robots.index ? "index" : "noindex"}, ${SITE_METADATA.robots.follow ? "follow" : "nofollow"}`}
/>
<meta name="keywords" content={SITE_METADATA.keywords.join(", ")} />
<meta name="author" content={SITE_METADATA.authors[0].name} />
<meta name="creator" content={SITE_METADATA.creator} />
<meta name="publisher" content={SITE_METADATA.publisher} />
<!-- Theme (align with next-themes: default light, supports system) -->
<script is:inline>
try {
const stored = localStorage.getItem("theme");
let resolved = "light";
if (stored === "dark" || stored === "light") {
resolved = stored;
} else if (stored === "system") {
resolved = window.matchMedia("(prefers-color-scheme: dark)").matches
? "dark"
: "light";
}
document.documentElement.classList.toggle("dark", resolved === "dark");
} catch {
document.documentElement.classList.remove("dark");
}
</script>
<!-- Favicon -->
{
SITE_METADATA.icons.icon.map((icon) => (
<link rel="icon" type={icon.type} sizes={icon.sizes} href={icon.url} />
))
}
{
SITE_METADATA.icons.apple.map((icon) => (
<link rel="apple-touch-icon" sizes={icon.sizes} href={icon.url} />
))
}
{
SITE_METADATA.icons.shortcut.map((icon) => (
<link rel="shortcut icon" href={icon.url} />
))
}
<link rel="sitemap" href="/sitemap-index.xml" />
<link
rel="alternate"
type="application/rss+xml"
title={SITE_TITLE}
href={new URL("rss.xml", Astro.site)}
/>
<meta name="generator" content={Astro.generator} />
<!-- Canonical URL -->
<link rel="canonical" href={canonicalURL} />
<!-- Primary Meta Tags -->
<title>{finalTitle}</title>
<meta name="title" content={finalTitle} />
<meta name="description" content={finalDescription} />
<!-- Open Graph / Facebook -->
<meta property="og:type" content="website" />
<meta property="og:url" content={canonicalURL} />
<meta property="og:site_name" content={SITE_METADATA.openGraph.siteName} />
<meta property="og:title" content={finalTitle} />
<meta property="og:description" content={finalDescription} />
<meta property="og:image" content={imageURL} />
<meta
property="og:image:width"
content={SITE_METADATA.openGraph.images[0].width.toString()}
/>
<meta
property="og:image:height"
content={SITE_METADATA.openGraph.images[0].height.toString()}
/>
<meta property="og:image:alt" content={SITE_METADATA.openGraph.images[0].alt} />
<!-- Twitter -->
<meta property="twitter:card" content={SITE_METADATA.twitter.card} />
<meta property="twitter:url" content={canonicalURL} />
<meta property="twitter:title" content={finalTitle} />
<meta property="twitter:description" content={finalDescription} />
<meta property="twitter:image" content={imageURL} />
<meta property="twitter:creator" content={SITE_METADATA.twitter.creator} />
<!-- Analytics -->
<script
is:inline
src="https://analytics.neeldhara.website/api/script.js"
data-site-id="667a0b3abc8a"
defer></script>

View file

@ -1,46 +0,0 @@
import Layout from "@/components/Layout";
import { Link } from "react-router-dom";
import { ArrowLeft, Wrench } from "lucide-react";
import { Button } from "@/components/ui/button";
interface ComingSoonProps {
title: string;
description?: string;
}
const ComingSoon = ({ title, description }: ComingSoonProps) => {
return (
<Layout>
<div className="flex items-center justify-center px-4 py-20">
<div className="text-center max-w-2xl mx-auto">
<div className="w-24 h-24 bg-surface-variant rounded-full flex items-center justify-center mx-auto mb-8">
<Wrench className="w-12 h-12 text-muted-foreground" />
</div>
<h1 className="text-4xl font-bold text-foreground mb-4">
{title}
</h1>
<h2 className="text-2xl font-semibold text-accent mb-6">
Coming Soon
</h2>
{description && (
<p className="text-lg text-muted-foreground mb-8">
{description}
</p>
)}
<Button asChild variant="outline">
<Link to="/" className="inline-flex items-center">
<ArrowLeft className="w-4 h-4 mr-2" />
Back to Home
</Link>
</Button>
</div>
</div>
</Layout>
);
};
export default ComingSoon;

View file

@ -1,65 +0,0 @@
import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { allInteractives } from "@/components/InteractiveIndex";
import {
CommandDialog,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
import { Badge } from "@/components/ui/badge";
const CommandSearch = () => {
const [open, setOpen] = useState(false);
const navigate = useNavigate();
useEffect(() => {
const down = (e: KeyboardEvent) => {
if (e.key === "k" && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
setOpen((prev) => !prev);
}
};
document.addEventListener("keydown", down);
return () => document.removeEventListener("keydown", down);
}, []);
const handleSelect = (path: string) => {
setOpen(false);
navigate(path);
};
return (
<CommandDialog open={open} onOpenChange={setOpen}>
<CommandInput placeholder="Search interactives by title or description..." />
<CommandList>
<CommandEmpty>No interactives found.</CommandEmpty>
<CommandGroup heading="Interactives">
{allInteractives.map((interactive) => (
<CommandItem
key={interactive.id}
value={`${interactive.title} ${interactive.description}`}
onSelect={() => handleSelect(interactive.path)}
>
<div className="flex flex-col gap-1">
<div className="flex items-center gap-2">
<span className="font-medium">{interactive.title}</span>
<Badge variant="outline" className="text-xs">
{interactive.theme}
</Badge>
</div>
<span className="text-xs text-muted-foreground line-clamp-1">
{interactive.description}
</span>
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</CommandDialog>
);
};
export default CommandSearch;

View file

@ -1,76 +0,0 @@
import React, { Component, ErrorInfo, ReactNode } from 'react';
import { AlertTriangle, RotateCcw } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
interface Props {
children: ReactNode;
fallback?: ReactNode;
}
interface State {
hasError: boolean;
error?: Error;
}
class ErrorBoundary extends Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError(error: Error): State {
// Update state so the next render will show the fallback UI
return { hasError: true, error };
}
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
// Log error to console in development
if (process.env.NODE_ENV === 'development') {
console.error('Error caught by boundary:', error, errorInfo);
}
}
handleReset = () => {
this.setState({ hasError: false, error: undefined });
};
render() {
if (this.state.hasError) {
if (this.props.fallback) {
return this.props.fallback;
}
return (
<div className="min-h-screen flex items-center justify-center p-4">
<Card className="w-full max-w-md">
<CardHeader className="text-center">
<div className="flex justify-center mb-4">
<AlertTriangle className="w-12 h-12 text-destructive" />
</div>
<CardTitle className="text-xl">Something went wrong</CardTitle>
</CardHeader>
<CardContent className="text-center space-y-4">
<p className="text-muted-foreground">
We encountered an unexpected error. Please try refreshing the page or contact support if the problem persists.
</p>
<div className="flex flex-col sm:flex-row gap-2 justify-center">
<Button onClick={this.handleReset} variant="outline">
<RotateCcw className="w-4 h-4 mr-2" />
Try Again
</Button>
<Button onClick={() => window.location.reload()}>
Refresh Page
</Button>
</div>
</CardContent>
</Card>
</div>
);
}
return this.props.children;
}
}
export default ErrorBoundary;

View file

@ -1,188 +0,0 @@
import React, { useState } from 'react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import { Search } from 'lucide-react';
import Layout from '@/components/Layout';
import GameOfSim from '@/components/GameOfSim';
import NorthcottsGame from '@/components/NorthcottsGame';
import NimGame from '@/components/NimGame';
import AssistedNimGame from '@/components/AssistedNimGame';
import GoldCoinGame from '@/components/GoldCoinGame';
import GuessingGame from '@/components/GuessingGame';
import SubtractionGame from '@/components/SubtractionGame';
import ParityBitsGame from '@/components/ParityBitsGame';
import CrapsGame from '@/components/CrapsGame';
import BagchalGame from '@/components/BagchalGame';
interface Game {
id: string;
title: string;
description: string;
tags: string[];
component: React.ComponentType<{ showSocialShare?: boolean }>;
}
const games: Game[] = [
{
id: 'bagchal',
title: 'Bagchal (Tiger and Goats)',
description: 'A strategic board game where the tiger tries to capture goats by jumping over them, while goats try to trap the tiger so it cannot move.',
tags: ['strategy', 'two-player', 'board-game', 'traditional', 'nepal', 'asymmetric'],
component: BagchalGame
},
{
id: 'craps-game',
title: 'Craps: An Exploration',
description: 'Experience the classic casino dice game! Roll two dice and learn about probability as you discover the rules of the opening throw.',
tags: ['probability', 'dice', 'casino', 'mathematics', 'statistics', 'gambling'],
component: CrapsGame
},
{
id: 'parity-magic',
title: 'Parity Magic',
description: 'Learn how parity bits detect and correct single-bit errors! Set up a grid, add parity bits, flip a bit, and watch the magic of error detection unfold.',
tags: ['error-correction', 'parity', 'computer-science', 'educational', 'interactive', 'magic-trick'],
component: ParityBitsGame
},
{
id: 'subtraction-game-uid-2024',
title: 'The Subtraction Game',
description: 'A strategic two-player game where players take turns removing circles. Click on any of the last k circles to remove it and all circles to its right!',
tags: ['strategy', 'two-player', 'logic', 'game-theory', 'subtraction'],
component: SubtractionGame
},
{
id: 'guessing-game',
title: 'Number Guessing Game',
description: 'Experience different search algorithms through interactive gameplay. Compare random, linear, and binary search strategies!',
tags: ['algorithms', 'search', 'educational', 'interactive', 'binary-search', 'computer-science'],
component: GuessingGame
},
{
id: 'gold-coin',
title: 'The Gold Coin Game',
description: 'A strategic two-player game where you move coins to the left or take the leftmost coin. The player who takes the gold coin wins!',
tags: ['strategy', 'two-player', 'logic', 'game-theory', 'movement', 'coins'],
component: GoldCoinGame
},
{
id: 'assisted-nim',
title: 'Assisted Game of Nim',
description: 'Learn the XOR strategy! Visualize how powers of 2 determine winning moves with color-coded borders.',
tags: ['strategy', 'two-player', 'math', 'logic', 'game-theory', 'xor-strategy', 'educational'],
component: AssistedNimGame
},
{
id: 'nim',
title: 'Game of Nim',
description: 'A classic two-player game of strategy. Take turns removing stones from heaps. The player to take the last stone wins!',
tags: ['strategy', 'two-player', 'math', 'logic', 'game-theory'],
component: NimGame
},
{
id: 'sim',
title: 'Game of Sim',
description: 'A strategic two-player game where you take turns coloring edges between vertices, trying to avoid creating a triangle of your own color.',
tags: ['strategy', 'graph-theory', 'two-player', 'logic', 'game-theory'],
component: GameOfSim
},
{
id: 'northcotts-game',
title: "Northcott's Game",
description: 'A classic strategy game played on a grid where players move pieces horizontally toward each other, trying to be the last one able to move.',
tags: ['strategy', 'two-player', 'grid-game', 'logic', 'movement'],
component: NorthcottsGame
}
];
const GamesGallery = () => {
const [searchTerm, setSearchTerm] = useState('');
const [selectedGame, setSelectedGame] = useState<Game | null>(null);
const filteredGames = games.filter(game =>
game.title.toLowerCase().includes(searchTerm.toLowerCase()) ||
game.description.toLowerCase().includes(searchTerm.toLowerCase()) ||
game.tags.some(tag => tag.toLowerCase().includes(searchTerm.toLowerCase()))
);
if (selectedGame) {
const GameComponent = selectedGame.component;
return (
<div className="min-h-screen bg-background">
<div className="container mx-auto px-4 py-8 space-y-6">
<div className="flex items-center gap-4">
<button
onClick={() => setSelectedGame(null)}
className="text-primary hover:text-primary/80 font-medium"
>
Back to Games
</button>
</div>
<GameComponent showSocialShare={true} />
</div>
</div>
);
}
return (
<Layout>
<div className="container mx-auto px-4 py-8">
<div className="space-y-6">
<div className="space-y-4">
<h1 className="text-3xl font-bold text-foreground">Games</h1>
<p className="text-muted-foreground text-lg">
Learn through play with interactive games that reinforce computer science and mathematical concepts.
</p>
</div>
{/* Search bar */}
<div className="relative max-w-md">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-muted-foreground w-4 h-4" />
<Input
placeholder="Search games..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-10"
/>
</div>
{/* Games Gallery */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{filteredGames.map(game => (
<Card
key={game.id}
className="cursor-pointer hover:shadow-lg transition-all duration-200 hover:scale-105"
onClick={() => setSelectedGame(game)}
>
<CardHeader className="space-y-3">
<CardTitle className="text-xl text-foreground">{game.title}</CardTitle>
<CardDescription className="text-muted-foreground line-clamp-3">
{game.description}
</CardDescription>
</CardHeader>
<CardContent>
<div className="flex flex-wrap gap-2">
{game.tags.map(tag => (
<Badge key={tag} variant="secondary" className="text-xs">
{tag}
</Badge>
))}
</div>
</CardContent>
</Card>
))}
</div>
{filteredGames.length === 0 && (
<div className="text-center py-12">
<p className="text-muted-foreground text-lg">No games found matching your search.</p>
</div>
)}
</div>
</div>
</Layout>
);
};
export default GamesGallery;

View file

@ -0,0 +1,56 @@
import React from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { publishedInteractives, primaryTheme } from '@/lib/interactives';
const HomepageHero: React.FC = () => {
const featured = [...publishedInteractives]
.sort((a, b) => b.dateAdded.localeCompare(a.dateAdded))
.slice(0, 6);
return (
<div className="space-y-16">
{/* Hero */}
<section className="text-center space-y-6 pt-8 pb-4">
<h1 className="text-4xl sm:text-5xl lg:text-6xl font-bold tracking-tight text-foreground font-display">
Interactives
</h1>
<p className="text-lg sm:text-xl text-muted-foreground max-w-2xl mx-auto leading-relaxed">
A collection of interactive games, puzzles, and mathematical explorations
for enhanced learning experiences and pure joy.
</p>
</section>
{/* Recently Added */}
<section>
<div className="flex items-baseline justify-between mb-6">
<h2 className="text-2xl font-bold text-foreground font-display">Recently Added</h2>
<a href="/i" className="text-sm text-muted-foreground hover:text-foreground transition-colors">
View all &rarr;
</a>
</div>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{featured.map((item) => (
<a key={item.slug} href={`/i/${item.slug}`} className="group">
<Card className="h-full transition-shadow hover:shadow-md">
<CardHeader className="pb-2">
<CardTitle className="text-lg group-hover:text-primary transition-colors">
{item.title}
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground line-clamp-2">{item.description}</p>
<div className="mt-3">
<Badge variant="outline" className="text-xs">{primaryTheme(item)}</Badge>
</div>
</CardContent>
</Card>
</a>
))}
</div>
</section>
</div>
);
};
export default HomepageHero;

View file

@ -0,0 +1,113 @@
import React, { useState, useEffect } from "react";
import { allThemes, type Theme } from "@/lib/interactives";
interface InteractiveBreadcrumbProps {
title: string;
themes: string[];
subcategory?: string;
}
const Chevron = () => (
<li role="presentation" aria-hidden="true" className="[&>svg]:size-3.5">
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="size-3.5"
>
<path d="m9 18 6-6-6-6" />
</svg>
</li>
);
const InteractiveBreadcrumb: React.FC<InteractiveBreadcrumbProps> = ({
title,
themes,
subcategory,
}) => {
const [activeTheme, setActiveTheme] = useState<Theme | undefined>(undefined);
const [activeSub, setActiveSub] = useState<
{ slug: string; title: string } | undefined
>(undefined);
useEffect(() => {
const params = new URLSearchParams(window.location.search);
const fromSlug = params.get("from");
let theme: Theme | undefined;
if (fromSlug) {
// Try to match the from param to a theme the interactive belongs to
theme = allThemes.find(
(t) => t.slug === fromSlug && themes.includes(t.title)
);
}
// Fall back to primary theme (first in array)
if (!theme) {
theme = allThemes.find((t) => t.title === themes[0]);
}
setActiveTheme(theme);
// Resolve subcategory if present and theme matches
if (subcategory && theme?.subcategories) {
const sub = theme.subcategories.find((s) => s.slug === subcategory);
setActiveSub(sub ? { slug: sub.slug, title: sub.title } : undefined);
}
}, [themes, subcategory]);
return (
<nav aria-label="breadcrumb" className="mb-6">
<ol className="flex flex-wrap items-center gap-1.5 text-sm text-muted-foreground">
<li className="inline-flex items-center gap-1">
<a href="/" className="transition-colors hover:text-foreground">
Home
</a>
</li>
{activeTheme && (
<>
<Chevron />
<li className="inline-flex items-center gap-1">
<a
href={`/themes/${activeTheme.slug}`}
className="transition-colors hover:text-foreground"
>
{activeTheme.title}
</a>
</li>
</>
)}
{activeSub && activeTheme && (
<>
<Chevron />
<li className="inline-flex items-center gap-1">
<a
href={`/themes/${activeTheme.slug}/${activeSub.slug}`}
className="transition-colors hover:text-foreground"
>
{activeSub.title}
</a>
</li>
</>
)}
<Chevron />
<li className="inline-flex items-center gap-1">
<span
className="font-normal text-foreground"
role="link"
aria-disabled="true"
aria-current="page"
>
{title}
</span>
</li>
</ol>
</nav>
);
};
export default InteractiveBreadcrumb;

View file

@ -1,104 +0,0 @@
import { Badge } from "@/components/ui/badge";
import { ArrowRight, Clock, Users } from "lucide-react";
import { cn } from "@/lib/utils";
interface InteractiveCardProps {
title: string;
description: string;
tags: string[];
difficulty?: "Beginner" | "Intermediate" | "Advanced";
duration?: string;
participants?: string;
imageUrl?: string;
onClick?: () => void;
className?: string;
}
const InteractiveCard = ({
title,
description,
tags,
difficulty,
duration,
participants,
imageUrl,
onClick,
className
}: InteractiveCardProps) => {
const difficultyColors = {
Beginner: "bg-green-100 text-green-800",
Intermediate: "bg-yellow-100 text-yellow-800",
Advanced: "bg-red-100 text-red-800"
};
return (
<div
onClick={onClick}
className={cn(
"group cursor-pointer bg-surface border border-outline rounded-lg overflow-hidden shadow-card hover:shadow-card-hover transition-all duration-200 hover:-translate-y-1",
className
)}
>
{imageUrl && (
<div className="h-48 bg-surface-variant overflow-hidden">
<img
src={imageUrl}
alt={title}
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-200"
/>
</div>
)}
<div className="p-6">
<div className="flex items-start justify-between mb-3">
<h3 className="text-lg font-semibold text-foreground group-hover:text-accent transition-colors flex-1">
{title}
</h3>
<ArrowRight className="w-5 h-5 text-muted-foreground group-hover:text-accent group-hover:translate-x-1 transition-all flex-shrink-0 ml-2" />
</div>
<p className="text-muted-foreground text-sm leading-relaxed mb-4">
{description}
</p>
<div className="flex items-center gap-4 mb-4 text-xs text-muted-foreground">
{duration && (
<div className="flex items-center gap-1">
<Clock className="w-3 h-3" />
<span>{duration}</span>
</div>
)}
{participants && (
<div className="flex items-center gap-1">
<Users className="w-3 h-3" />
<span>{participants}</span>
</div>
)}
</div>
<div className="flex flex-wrap gap-2 mb-3">
{difficulty && (
<Badge
variant="secondary"
className={cn("text-xs", difficultyColors[difficulty])}
>
{difficulty}
</Badge>
)}
{tags.slice(0, 3).map((tag) => (
<Badge key={tag} variant="outline" className="text-xs">
{tag}
</Badge>
))}
{tags.length > 3 && (
<Badge variant="outline" className="text-xs">
+{tags.length - 3}
</Badge>
)}
</div>
</div>
</div>
);
};
export default InteractiveCard;

View file

@ -1,106 +1,237 @@
import React, { useState } from 'react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import React, { useState, useMemo, useEffect } from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import { Search } from 'lucide-react';
import Layout from '@/components/Layout';
import BinarySearchTrick from '@/components/BinarySearchTrick';
import TernarySearchTrick from '@/components/TernarySearchTrick';
import GuessingGame from '@/components/GuessingGame';
import { Button } from '@/components/ui/button';
import { Search, SortAsc, SortDesc } from 'lucide-react';
import { publishedInteractives, primaryTheme } from '@/lib/interactives';
interface Interactive {
id: string;
title: string;
description: string;
tags: string[];
component: React.ComponentType<{ showSocialShare?: boolean }>;
type SortField = 'title' | 'dateAdded';
type SortDirection = 'asc' | 'desc';
function getInitialTheme(): string {
if (typeof window === 'undefined') return '';
return new URLSearchParams(window.location.search).get('theme') || '';
}
const interactives: Interactive[] = [
{
id: 'guessing-game',
title: 'Number Guessing Game',
description: 'Experience different search algorithms through interactive gameplay. Compare random, linear, and binary search strategies!',
tags: ['algorithms', 'search', 'educational', 'interactive', 'binary-search', 'computer-science'],
component: GuessingGame
},
{
id: 'binary-search-trick',
title: 'Binary Search Magic Trick',
description: 'Perform an amazing magic trick that reveals how binary numbers work. Think of a number and watch the magic happen!',
tags: ['binary', 'magic', 'numbers', 'trick', 'data-structures'],
component: BinarySearchTrick
},
{
id: 'ternary-search-trick',
title: 'Ternary Search Magic Trick',
description: 'Experience the magic of ternary search! Think of a number and watch as the cards reveal it through mathematical calculations.',
tags: ['ternary', 'magic', 'numbers', 'trick', 'data-structures', 'search'],
component: TernarySearchTrick
},
];
const InteractiveGallery = () => {
const InteractiveGallery: React.FC = () => {
const [searchTerm, setSearchTerm] = useState('');
const [selectedInteractive, setSelectedInteractive] = useState<Interactive | null>(null);
const filteredInteractives = interactives.filter(interactive => interactive.title.toLowerCase().includes(searchTerm.toLowerCase()) || interactive.description.toLowerCase().includes(searchTerm.toLowerCase()) || interactive.tags.some(tag => tag.toLowerCase().includes(searchTerm.toLowerCase())));
if (selectedInteractive) {
const InteractiveComponent = selectedInteractive.component;
return (
<div className="min-h-screen bg-background">
<div className="container mx-auto px-4 py-8 space-y-6">
<div className="flex items-center gap-4">
<button
onClick={() => setSelectedInteractive(null)}
className="text-primary hover:text-primary/80 font-medium"
>
Back to Data Structures and Algorithms
</button>
</div>
<InteractiveComponent showSocialShare={true} />
</div>
</div>
);
const [selectedTag, setSelectedTag] = useState<string>('');
const [selectedTheme, setSelectedTheme] = useState<string>(getInitialTheme);
const [sortField, setSortField] = useState<SortField>('title');
const [sortDirection, setSortDirection] = useState<SortDirection>('asc');
const [showTags, setShowTags] = useState(false);
const [page, setPage] = useState(1);
const PAGE_SIZE = 20;
const allTags = useMemo(() => {
const tags = new Set<string>();
publishedInteractives.forEach((i) => i.tags.forEach((t) => tags.add(t)));
return Array.from(tags).sort();
}, []);
const allThemes = useMemo(() => {
const themes = new Set<string>();
publishedInteractives.forEach((i) => i.themes.forEach((t) => themes.add(t)));
return Array.from(themes).sort();
}, []);
const filtered = useMemo(() => {
let result = publishedInteractives.filter((i) => {
const matchesSearch =
!searchTerm ||
i.title.toLowerCase().includes(searchTerm.toLowerCase()) ||
i.description.toLowerCase().includes(searchTerm.toLowerCase()) ||
i.tags.some((t) => t.toLowerCase().includes(searchTerm.toLowerCase()));
const matchesTag = !selectedTag || i.tags.includes(selectedTag);
const matchesTheme = !selectedTheme || i.themes.includes(selectedTheme);
return matchesSearch && matchesTag && matchesTheme;
});
result.sort((a, b) => {
const aVal = a[sortField].toLowerCase();
const bVal = b[sortField].toLowerCase();
if (aVal < bVal) return sortDirection === 'asc' ? -1 : 1;
if (aVal > bVal) return sortDirection === 'asc' ? 1 : -1;
return 0;
});
return result;
}, [searchTerm, selectedTag, selectedTheme, sortField, sortDirection]);
// Reset pagination when filters/sort change
useEffect(() => { setPage(1); }, [searchTerm, selectedTag, selectedTheme, sortField, sortDirection]);
const totalPages = Math.ceil(filtered.length / PAGE_SIZE);
const paginated = filtered.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE);
const handleSortChange = (field: SortField) => {
if (sortField === field) {
setSortDirection((d) => (d === 'asc' ? 'desc' : 'asc'));
} else {
setSortField(field);
setSortDirection('asc');
}
};
return (
<Layout>
<div className="max-w-7xl mx-auto p-6">
<div className="space-y-6">
<div className="space-y-4">
<h1 className="text-3xl font-bold text-foreground">Data Structures and Algorithms</h1>
<p className="text-muted-foreground text-lg">Interactive explorations of elementary data structures and algorithms.</p>
<div>
<h1 className="text-4xl font-bold text-foreground mb-8">
All Interactives ({publishedInteractives.length})
</h1>
{/* Search and Sort */}
<div className="flex flex-col sm:flex-row gap-4 mb-6">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground w-4 h-4" />
<Input
placeholder="Search interactives..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-10"
/>
</div>
<div className="flex gap-2">
<Button variant="outline" onClick={() => handleSortChange('title')} className="gap-2">
{sortField === 'title' ? (
sortDirection === 'asc' ? <SortAsc className="w-4 h-4" /> : <SortDesc className="w-4 h-4" />
) : (
<SortAsc className="w-4 h-4" />
)}
Title
</Button>
<Button variant="outline" onClick={() => handleSortChange('dateAdded')} className="gap-2">
{sortField === 'dateAdded' ? (
sortDirection === 'asc' ? <SortAsc className="w-4 h-4" /> : <SortDesc className="w-4 h-4" />
) : (
<SortAsc className="w-4 h-4" />
)}
Recency
</Button>
</div>
</div>
{/* Search bar */}
<div className="relative max-w-md">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-muted-foreground w-4 h-4" />
<Input placeholder="Search interactives..." value={searchTerm} onChange={e => setSearchTerm(e.target.value)} className="pl-10" />
{/* Theme Filter */}
<div className="flex flex-wrap gap-2 mb-4">
<Badge
variant={selectedTheme === '' ? 'default' : 'outline'}
className="cursor-pointer"
onClick={() => setSelectedTheme('')}
>
All
</Badge>
{allThemes.map((theme) => (
<Badge
key={theme}
variant={selectedTheme === theme ? 'default' : 'outline'}
className="cursor-pointer hover:bg-primary hover:text-primary-foreground"
onClick={() => setSelectedTheme(selectedTheme === theme ? '' : theme)}
>
{theme}
</Badge>
))}
</div>
{/* Gallery */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{filteredInteractives.map(interactive => <Card key={interactive.id} className="cursor-pointer hover:shadow-lg transition-all duration-200 hover:scale-105" onClick={() => setSelectedInteractive(interactive)}>
<CardHeader className="space-y-3">
<CardTitle className="text-xl text-foreground">{interactive.title}</CardTitle>
<CardDescription className="text-muted-foreground line-clamp-3">
{interactive.description}
</CardDescription>
</CardHeader>
<CardContent>
{/* Active filters */}
{(selectedTag || selectedTheme) && (
<div className="mb-4 flex items-center gap-2">
<span className="text-sm text-muted-foreground">Filtered by:</span>
{selectedTheme && (
<Badge variant="secondary" className="cursor-pointer" onClick={() => setSelectedTheme('')}>
{selectedTheme} &times;
</Badge>
)}
{selectedTag && (
<Badge variant="secondary" className="cursor-pointer" onClick={() => setSelectedTag('')}>
{selectedTag} &times;
</Badge>
)}
</div>
)}
{/* Tag Filter */}
<div className="mb-6">
<div className="flex items-center mb-2">
<Button variant="ghost" size="sm" onClick={() => setShowTags((v) => !v)}>
{showTags ? 'Hide Tags' : 'Filter by Tags'}
</Button>
</div>
{showTags && (
<div className="flex flex-wrap gap-2">
{interactive.tags.map(tag => <Badge key={tag} variant="secondary" className="text-xs">
{allTags.map((tag) => (
<Badge
key={tag}
variant={selectedTag === tag ? 'default' : 'outline'}
className="cursor-pointer hover:bg-primary hover:text-primary-foreground"
onClick={() => setSelectedTag(selectedTag === tag ? '' : tag)}
>
{tag}
</Badge>)}
</Badge>
))}
</div>
</CardContent>
</Card>)}
)}
</div>
{filteredInteractives.length === 0 && <div className="text-center py-12">
<p className="text-muted-foreground text-lg">No interactives found matching your search.</p>
</div>}
{/* Results */}
<p className="text-muted-foreground mb-4 text-sm">
Showing {filtered.length} of {publishedInteractives.length} interactives
</p>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{paginated.map((interactive) => (
<a key={interactive.slug} href={`/i/${interactive.slug}`} className="group">
<div className="rounded-xl border bg-card p-6 shadow-sm transition-shadow hover:shadow-md h-full flex flex-col">
<h3 className="text-lg font-semibold group-hover:text-primary transition-colors mb-2">
{interactive.title}
</h3>
<p className="text-sm text-muted-foreground line-clamp-2 mb-3 flex-1">
{interactive.description}
</p>
<div className="flex flex-wrap items-center gap-1.5">
<Badge
variant="outline"
className="text-xs cursor-pointer"
onClick={(e) => {
e.preventDefault();
setSelectedTheme(selectedTheme === primaryTheme(interactive) ? '' : primaryTheme(interactive));
}}
>
{primaryTheme(interactive)}
</Badge>
{interactive.tags.slice(0, 2).map((tag) => (
<Badge
key={tag}
variant="secondary"
className="text-xs cursor-pointer"
onClick={(e) => {
e.preventDefault();
setSelectedTag(selectedTag === tag ? '' : tag);
}}
>
{tag}
</Badge>
))}
</div>
</div>
</Layout>
</a>
))}
</div>
{totalPages > 1 && (
<div className="flex items-center justify-center gap-2 pt-4">
<Button variant="outline" size="sm" onClick={() => setPage(p => p - 1)} disabled={page === 1}>Previous</Button>
<span className="text-sm text-muted-foreground">Page {page} of {totalPages}</span>
<Button variant="outline" size="sm" onClick={() => setPage(p => p + 1)} disabled={page === totalPages}>Next</Button>
</div>
)}
{filtered.length === 0 && (
<div className="text-center py-12">
<p className="text-muted-foreground text-lg">No interactives found matching your criteria.</p>
</div>
)}
</div>
);
};
export default InteractiveGallery;

Some files were not shown because too many files have changed in this diff Show more