@brust/create-app 0.0.0-stage → 2.0.0-alpha.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +19 -2
- package/bin/create-app.ts +4 -0
- package/package.json +31 -4
- package/src/args.ts +64 -0
- package/src/cli.ts +69 -0
- package/src/copy.ts +44 -0
- package/src/dep.ts +61 -0
- package/src/tailwind.ts +10 -0
- package/src/templates.ts +64 -0
- package/templates/blank/README.md.tmpl +43 -0
- package/templates/blank/_gitignore +4 -0
- package/templates/blank/actions.ts +13 -0
- package/templates/blank/app.css +32 -0
- package/templates/blank/brust.toml +6 -0
- package/templates/blank/components/Counter.tsx +15 -0
- package/templates/blank/components/Layout.tsx +22 -0
- package/templates/blank/package.json.tmpl +30 -0
- package/templates/blank/pages/HomePage.tsx +35 -0
- package/templates/blank/public/favicon.svg +1 -0
- package/templates/blank/routes.tsx +5 -0
- package/templates/blank/tsconfig.json +37 -0
- package/templates/blank/wrangler.jsonc.tmpl +9 -0
- package/templates/pokedex/README.md.tmpl +59 -0
- package/templates/pokedex/_gitignore +5 -0
- package/templates/pokedex/actions.ts +37 -0
- package/templates/pokedex/brust.toml +28 -0
- package/templates/pokedex/components/AddToTeamButton.tsx +37 -0
- package/templates/pokedex/components/AppLayout.tsx +32 -0
- package/templates/pokedex/components/Breadcrumb.tsx +4 -0
- package/templates/pokedex/components/DexCard.tsx +11 -0
- package/templates/pokedex/components/DexFilter.tsx +26 -0
- package/templates/pokedex/components/HeroSearch.tsx +14 -0
- package/templates/pokedex/components/NavLink.tsx +6 -0
- package/templates/pokedex/components/TeamBuilder.tsx +32 -0
- package/templates/pokedex/components/ThemeToggle.tsx +16 -0
- package/templates/pokedex/components/TypeBadge.tsx +5 -0
- package/templates/pokedex/components/UnifiedIsland.tsx +28 -0
- package/templates/pokedex/components/UnifiedNative.tsx +20 -0
- package/templates/pokedex/components/UnifiedStoreNode.tsx +14 -0
- package/templates/pokedex/data/pokedex.json +8767 -0
- package/templates/pokedex/lib/api.ts +5 -0
- package/templates/pokedex/lib/cloudflare.d.ts +24 -0
- package/templates/pokedex/lib/filter.ts +6 -0
- package/templates/pokedex/lib/format.ts +4 -0
- package/templates/pokedex/lib/loaders.ts +192 -0
- package/templates/pokedex/lib/pokeapi.ts +43 -0
- package/templates/pokedex/lib/team-client.ts +26 -0
- package/templates/pokedex/lib/team-db.d1.ts +47 -0
- package/templates/pokedex/lib/team-db.sqlite.ts +49 -0
- package/templates/pokedex/lib/team-db.ts +13 -0
- package/templates/pokedex/lib/team.ts +2 -0
- package/templates/pokedex/lib/types.ts +126 -0
- package/templates/pokedex/migrations/0001_team.sql +2 -0
- package/templates/pokedex/package.json.tmpl +30 -0
- package/templates/pokedex/pages/BrowsePage.tsx +15 -0
- package/templates/pokedex/pages/DetailPage.tsx +233 -0
- package/templates/pokedex/pages/HomePage.tsx +129 -0
- package/templates/pokedex/pages/NotFoundPage.tsx +7 -0
- package/templates/pokedex/pages/TypeChart.tsx +72 -0
- package/templates/pokedex/pages/UnifiedPage.tsx +24 -0
- package/templates/pokedex/public/app.css +3 -0
- package/templates/pokedex/public/favicon.svg +1 -0
- package/templates/pokedex/routes.tsx +27 -0
- package/templates/pokedex/scripts/snapshot.ts +53 -0
- package/templates/pokedex/stores/team.ts +6 -0
- package/templates/pokedex/stores/unified.ts +4 -0
- package/templates/pokedex/styles/app.css +14 -0
- package/templates/pokedex/tsconfig.json +37 -0
- package/templates/pokedex/wrangler.jsonc +9 -0
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// Your Cloudflare config for `bun run dev:cf` / `bun run deploy:cf`.
|
|
2
|
+
// `brust build --target cloudflare` merges this file into dist/cf/wrangler.jsonc and owns these keys:
|
|
3
|
+
// main, assets (the ASSETS binding), compatibility_date, no_bundle, build, rules, site.
|
|
4
|
+
{
|
|
5
|
+
"name": "__PROJECT_NAME__"
|
|
6
|
+
// "d1_databases": [{ "binding": "DB", "database_name": "__PROJECT_NAME__", "database_id": "<id>" }],
|
|
7
|
+
// "kv_namespaces": [{ "binding": "KV", "id": "<id>" }],
|
|
8
|
+
// "vars": { "GREETING": "hello" }
|
|
9
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# __PROJECT_NAME__
|
|
2
|
+
|
|
3
|
+
A [brust](https://github.com/AssetsArt/brust) Pokédex: loaders over an offline dataset, L1 page cache, native components,
|
|
4
|
+
React islands, and a persistent team built with Elysia actions + SQLite and one unified store.
|
|
5
|
+
|
|
6
|
+
## Run
|
|
7
|
+
|
|
8
|
+
```bash
|
|
9
|
+
bun install
|
|
10
|
+
bun run dev # brust dev routes.tsx (hot reload) -> http://127.0.0.1:1337
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Scripts: `dev` (build then start), `build`, `start`, `typecheck`, `snapshot` (needs network; rewrites `data/pokedex.json`).
|
|
14
|
+
|
|
15
|
+
## The team
|
|
16
|
+
|
|
17
|
+
The team lives in `data/team.sqlite` (created on first use, gitignored). Delete the file to reset it, or set
|
|
18
|
+
`POKEDEX_TEAM_DB=/path/to/team.sqlite` to put it elsewhere. A fresh database is empty.
|
|
19
|
+
|
|
20
|
+
The actions are served by Rust under `/_brust/action` (`GET /team`, `POST /team {id}`, `DELETE /team/:id`); the team holds
|
|
21
|
+
at most 6 members and a seventh add answers `409` (`TeamFull`). The team store (`stores/team.ts`) is read by the native
|
|
22
|
+
`AddToTeamButton` and the `TeamBuilder` island, and seeded by the layout loader, so the first paint is already right.
|
|
23
|
+
`/unified` shows the same idea with a counter (`/unified?start=5` seeds it on the server).
|
|
24
|
+
|
|
25
|
+
## Layout
|
|
26
|
+
|
|
27
|
+
- `routes.tsx` — `/`, `/pokedex`, `/pokemon/{name}` (L1 60 s, `?nocache` bypass), `/type-chart` (L1 1 h), `/unified`, and a
|
|
28
|
+
root-level `*` 404 outside the layout. Pages that show the team carry the cache tag `team`.
|
|
29
|
+
- `lib/` — loaders over the snapshot, `team-db.ts` (the team interface: sqlite natively, D1 on Cloudflare via `[cloudflare.alias]`; server-only), `team-client.ts` + `api.ts` (the typed actions client).
|
|
30
|
+
- `actions.ts` — the Elysia 2 team app on `brust()`; `stores/` — `team` and `unified` (`defineStore`).
|
|
31
|
+
- `components/`, `pages/` — plain React functions.
|
|
32
|
+
|
|
33
|
+
## Cloudflare Workers (preview)
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
bun run cf:migrate:local # create the local D1 table (state in .wrangler/)
|
|
37
|
+
bun run dev:cf # build for Workers and run `wrangler dev` (rebuilds on change)
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
`brust build --target cloudflare` swaps `lib/team-db.ts` for the D1 version (`lib/team-db.d1.ts`) through `[cloudflare.alias]`
|
|
41
|
+
in `brust.toml`; your bindings live in `wrangler.jsonc`. To deploy: `wrangler d1 create __PROJECT_NAME__-team`, paste the
|
|
42
|
+
`database_id` into the **app-root** `wrangler.jsonc` (not `dist/cf/`, which is regenerated), `bun run cf:migrate:remote`,
|
|
43
|
+
then `bun run deploy:cf`.
|
|
44
|
+
|
|
45
|
+
Preview note: a team change refreshes cached team pages only in the isolate that served the action; other isolates catch up
|
|
46
|
+
within the route `ttl` (60 s for `/pokemon/*`, 1 h for `/type-chart`) until the cross-isolate invalidation lands.
|
|
47
|
+
|
|
48
|
+
## Regenerating the stylesheet (never hand-edit `public/app.css`)
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
bunx @tailwindcss/cli@4 -i styles/app.css -o public/app.css --minify
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
(Tailwind must resolve from the input's directory: install `tailwindcss` in the project first.)
|
|
55
|
+
|
|
56
|
+
## Docs
|
|
57
|
+
|
|
58
|
+
- Actions: https://github.com/AssetsArt/brust/blob/main/docs/actions.md
|
|
59
|
+
- The unified store: https://github.com/AssetsArt/brust/blob/main/docs/store.md
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// examples/pokedex/actions.ts — the team RPC surface (M3-F E7). One Elysia 2 app behind the Rust server
|
|
2
|
+
// (brust.toml [actions]); never imported by browser code except as `import type { App }`.
|
|
3
|
+
import { cache, type InvalidateArgs } from '@brust/core'
|
|
4
|
+
import { brust } from '@brust/core/elysia'
|
|
5
|
+
import { Elysia, problem, t } from 'elysia'
|
|
6
|
+
import { pokemonById } from './lib/pokeapi'
|
|
7
|
+
import { MAX_TEAM } from './lib/team'
|
|
8
|
+
import { type TeamDb, teamDb } from './lib/team-db'
|
|
9
|
+
|
|
10
|
+
export interface TeamDeps {
|
|
11
|
+
db: () => TeamDb
|
|
12
|
+
invalidate: (a: InvalidateArgs) => unknown
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function teamApp({ db, invalidate }: TeamDeps = { db: teamDb, invalidate: cache.invalidate }) {
|
|
16
|
+
const reply = async () => ({ team: await db().list(), max: MAX_TEAM })
|
|
17
|
+
// R5: cached pages that render the team carry the tag `team`; drop them after a real change.
|
|
18
|
+
const changed = () => invalidate({ tags: ['team'] })
|
|
19
|
+
return new Elysia({ adapter: brust() })
|
|
20
|
+
.get('/team', () => reply())
|
|
21
|
+
.post('/team', { body: t.Object({ id: t.Integer({ minimum: 1 }) }) }, async ({ body }) => {
|
|
22
|
+
const p = pokemonById(body.id)
|
|
23
|
+
if (!p) return problem(404, { title: 'UnknownPokemon', detail: `no Pokémon #${body.id}` })
|
|
24
|
+
const r = await db().add(p)
|
|
25
|
+
if (r === 'full') return problem(409, { title: 'TeamFull', detail: `the team already has ${MAX_TEAM} members`, max: MAX_TEAM })
|
|
26
|
+
if (r === 'added') changed()
|
|
27
|
+
return reply()
|
|
28
|
+
})
|
|
29
|
+
.delete('/team/:id', { params: t.Object({ id: t.Integer({ minimum: 1 }) }) }, async ({ params }) => {
|
|
30
|
+
if (await db().remove(params.id)) changed()
|
|
31
|
+
return reply()
|
|
32
|
+
})
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const app = teamApp()
|
|
36
|
+
export default app
|
|
37
|
+
export type App = typeof app
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
[server]
|
|
2
|
+
address = "127.0.0.1"
|
|
3
|
+
port = 1337
|
|
4
|
+
|
|
5
|
+
[build]
|
|
6
|
+
server_only = ["lib/team-db"]
|
|
7
|
+
|
|
8
|
+
[actions]
|
|
9
|
+
|
|
10
|
+
# `brust build --target cloudflare` swaps the sqlite roster for the D1 one (the whole facade module).
|
|
11
|
+
[cloudflare.alias]
|
|
12
|
+
"lib/team-db.ts" = "lib/team-db.d1.ts"
|
|
13
|
+
|
|
14
|
+
# A shared second cache tier (needs `bun add @brust/cache-redis`; see the brust docs, "Redis / Dragonfly"):
|
|
15
|
+
# [cache]
|
|
16
|
+
# adapter = "redis" # or "dragonfly"
|
|
17
|
+
# [cache.redis]
|
|
18
|
+
# url = "env:REDIS_URL"
|
|
19
|
+
|
|
20
|
+
# Or, on Cloudflare Workers: a shared second cache tier (KV bodies + a Durable Object invalidation broadcast). Off by default.
|
|
21
|
+
# Needs: bun add @brust/cache-cloudflare, plus the KV and Durable Object bindings in wrangler.jsonc (see the comment there).
|
|
22
|
+
# [cache]
|
|
23
|
+
# adapter = "cloudflare-kv"
|
|
24
|
+
# broadcast = "cloudflare-do"
|
|
25
|
+
# epoch_interval_ms = 5000
|
|
26
|
+
#
|
|
27
|
+
# [cache.cloudflare]
|
|
28
|
+
# kv_min_ttl_s = 300
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// components/AddToTeamButton.tsx — NATIVE (M3-F D7). Render reads the team store through useStore: the server paints
|
|
2
|
+
// __store.team.*, the chunk reads team.members() live. Writes go through lib/team-client (handlers only, compiler rule
|
|
3
|
+
// store-write-in-render).
|
|
4
|
+
import { useStore } from '@brust/core/store/react'
|
|
5
|
+
import { useState } from 'react'
|
|
6
|
+
import { toggleMember } from '../lib/team-client'
|
|
7
|
+
import { team } from '../stores/team'
|
|
8
|
+
|
|
9
|
+
export default function AddToTeamButton({ id }: { id: number }) {
|
|
10
|
+
const s = useStore(team)
|
|
11
|
+
const [busy, setBusy] = useState(false)
|
|
12
|
+
const inTeam = s.members.some((m) => m.id === id)
|
|
13
|
+
const full = !inTeam && s.members.length >= s.max
|
|
14
|
+
return (
|
|
15
|
+
<button
|
|
16
|
+
type="button"
|
|
17
|
+
data-testid="add-to-team"
|
|
18
|
+
data-in-team={inTeam ? 'true' : 'false'}
|
|
19
|
+
disabled={busy || full}
|
|
20
|
+
className={
|
|
21
|
+
inTeam
|
|
22
|
+
? 'inline-flex w-full items-center justify-center rounded-lg bg-slate-200 px-4 py-2.5 text-sm font-semibold text-slate-700 transition-colors disabled:opacity-50 dark:bg-slate-700 dark:text-slate-100'
|
|
23
|
+
: 'inline-flex w-full items-center justify-center rounded-lg bg-brand-500 px-4 py-2.5 text-sm font-semibold text-white transition-colors hover:bg-brand-600 disabled:opacity-50'
|
|
24
|
+
}
|
|
25
|
+
onClick={async () => {
|
|
26
|
+
setBusy(true)
|
|
27
|
+
try {
|
|
28
|
+
await toggleMember(id, inTeam)
|
|
29
|
+
} finally {
|
|
30
|
+
setBusy(false)
|
|
31
|
+
}
|
|
32
|
+
}}
|
|
33
|
+
>
|
|
34
|
+
{inTeam ? 'In your team' : full ? 'Team Full' : 'Add to team'}
|
|
35
|
+
</button>
|
|
36
|
+
)
|
|
37
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// components/AppLayout.tsx — the document (S9): plain <html>, <Outlet/> for the leaf, TeamBuilder as a react child.
|
|
2
|
+
import { Outlet } from '@brust/core/routes'
|
|
3
|
+
import type { NavItem } from '../lib/types'
|
|
4
|
+
import NavLink from './NavLink'
|
|
5
|
+
import TeamBuilder from './TeamBuilder'
|
|
6
|
+
import ThemeToggle from './ThemeToggle'
|
|
7
|
+
|
|
8
|
+
export default function AppLayout(props: { title: string; mode: 'dark' | 'light'; nav: NavItem[] }) {
|
|
9
|
+
return (
|
|
10
|
+
<html lang="en" data-mode={props.mode}>
|
|
11
|
+
<head>
|
|
12
|
+
<meta charSet="utf-8" />
|
|
13
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
14
|
+
<title>{props.title}</title>
|
|
15
|
+
<link rel="icon" href="/public/favicon.svg" />
|
|
16
|
+
<link rel="stylesheet" href="/public/app.css" />
|
|
17
|
+
</head>
|
|
18
|
+
<body className="min-h-screen bg-slate-50 text-slate-900 dark:bg-slate-950 dark:text-slate-100">
|
|
19
|
+
<header className="sticky top-0 z-50 border-b border-slate-200 bg-white/80 dark:border-slate-800 dark:bg-slate-950/80">
|
|
20
|
+
<nav className="mx-auto flex h-16 max-w-6xl items-center gap-2 px-4">
|
|
21
|
+
<a href="/" className="mr-2 flex items-center gap-2 no-underline"><span className="grid h-8 w-8 place-items-center rounded-lg bg-brand-500 text-sm font-extrabold text-white">P</span><span className="text-base font-extrabold">PokéDex</span></a>
|
|
22
|
+
{props.nav.map((n) => <NavLink key={n.href} href={n.href} label={n.label} active={n.active} />)}
|
|
23
|
+
<div className="ml-auto"><ThemeToggle mode={props.mode} /></div>
|
|
24
|
+
</nav>
|
|
25
|
+
</header>
|
|
26
|
+
<main className="mx-auto max-w-6xl px-4 py-8"><Outlet /></main>
|
|
27
|
+
<footer className="border-t border-slate-200 py-6 text-center text-xs text-slate-400 dark:border-slate-800">Built with brust · data: PokeAPI snapshot</footer>
|
|
28
|
+
<TeamBuilder />
|
|
29
|
+
</body>
|
|
30
|
+
</html>
|
|
31
|
+
)
|
|
32
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// components/DexCard.tsx — static per-row child (props only, no job: its list is state-derived, D4).
|
|
2
|
+
import type { DexCard as Card } from '../lib/types'
|
|
3
|
+
export default function DexCard(props: { card: Card }) {
|
|
4
|
+
return (
|
|
5
|
+
<a href={props.card.detailHref} data-dex={props.card.num} className="group flex flex-col items-center rounded-2xl border border-slate-200 bg-white p-3 no-underline shadow-sm dark:border-slate-800 dark:bg-slate-900">
|
|
6
|
+
<span className="self-start text-[11px] font-semibold tabular-nums text-slate-400">{props.card.num}</span>
|
|
7
|
+
<img src={props.card.artwork} alt={props.card.displayName} loading="lazy" className="h-24 w-24 object-contain" />
|
|
8
|
+
<div className="mt-1 text-sm font-semibold">{props.card.displayName}</div>
|
|
9
|
+
</a>
|
|
10
|
+
)
|
|
11
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// components/DexFilter.tsx — useState filter/sort; `filterSort` is a module helper so the first paint is a
|
|
2
|
+
// precompute job seeded with the initial state and the client recomputes on change (battery e-precompute-state).
|
|
3
|
+
import { useState } from 'react'
|
|
4
|
+
import { filterSort } from '../lib/filter'
|
|
5
|
+
import type { DexCard as Card } from '../lib/types'
|
|
6
|
+
import DexCard from './DexCard'
|
|
7
|
+
export default function DexFilter(props: { items: Card[] }) {
|
|
8
|
+
const [q, setQ] = useState('')
|
|
9
|
+
const [az, setAz] = useState(false)
|
|
10
|
+
const shown = filterSort(props.items, q, az)
|
|
11
|
+
return (
|
|
12
|
+
<section>
|
|
13
|
+
<div className="mb-6 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
|
14
|
+
<input type="search" placeholder="Search Pokémon…" value={q} onChange={(e) => setQ(e.target.value)} className="w-full rounded-xl border border-slate-200 bg-white px-4 py-2.5 text-sm sm:max-w-xs dark:border-slate-700 dark:bg-slate-900" />
|
|
15
|
+
<div className="flex items-center gap-3">
|
|
16
|
+
<button type="button" onClick={() => setAz(false)} className="rounded-l-xl border border-slate-200 px-3 py-1.5 text-sm dark:border-slate-700">Dex#</button>
|
|
17
|
+
<button type="button" onClick={() => setAz(true)} className="rounded-r-xl border border-slate-200 px-3 py-1.5 text-sm dark:border-slate-700">A–Z</button>
|
|
18
|
+
<span data-testid="count" className="rounded-full bg-slate-100 px-3 py-1 text-xs font-semibold tabular-nums dark:bg-slate-800">{shown.length} / {props.items.length}</span>
|
|
19
|
+
</div>
|
|
20
|
+
</div>
|
|
21
|
+
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6">
|
|
22
|
+
{shown.map((c) => <DexCard key={c.id} card={c} />)}
|
|
23
|
+
</div>
|
|
24
|
+
</section>
|
|
25
|
+
)
|
|
26
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// components/HeroSearch.tsx — controlled input + useId; a plain GET form to /pokedex?q= (no SPA navigate).
|
|
2
|
+
import { useId, useState } from 'react'
|
|
3
|
+
export default function HeroSearch() {
|
|
4
|
+
const id = useId()
|
|
5
|
+
const [q, setQ] = useState('')
|
|
6
|
+
return (
|
|
7
|
+
<form action="/pokedex" method="get" className="mx-auto mt-8 flex w-full max-w-md items-center gap-2 rounded-2xl bg-white/95 p-2 shadow-lg dark:bg-slate-900/90">
|
|
8
|
+
<label htmlFor={id} className="sr-only">Search the Pokédex</label>
|
|
9
|
+
<input id={id} name="q" type="search" placeholder="Search the Pokédex…" value={q} onChange={(e) => setQ(e.target.value)}
|
|
10
|
+
className="min-w-0 flex-1 rounded-xl bg-transparent px-3 py-2 text-sm text-slate-900 dark:text-white" />
|
|
11
|
+
<button type="submit" className="rounded-xl bg-brand-500 px-4 py-2 text-sm font-semibold text-white">Search</button>
|
|
12
|
+
</form>
|
|
13
|
+
)
|
|
14
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
// components/NavLink.tsx — static; `active` is computed in the loader from `path` (no client nav in M2).
|
|
2
|
+
const BASE = 'inline-flex items-center rounded-lg px-3 py-1.5 text-sm font-medium text-slate-600 hover:bg-slate-100 dark:text-slate-300 dark:hover:bg-slate-800'
|
|
3
|
+
const ACTIVE = 'inline-flex items-center rounded-lg px-3 py-1.5 text-sm font-semibold text-brand-600 bg-brand-50 dark:text-brand-50 dark:bg-brand-600/20'
|
|
4
|
+
export default function NavLink(props: { href: string; label: string; active: boolean }) {
|
|
5
|
+
return <a href={props.href} data-active={props.active ? '1' : '0'} className={props.active ? ACTIVE : BASE}>{props.label}</a>
|
|
6
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// components/TeamBuilder.tsx — react tier on purpose (useReducer for `open`): SSR + idle hydration (S12). The roster is the
|
|
2
|
+
// unified `team` store: the SSR scope is seeded from the snapshot, so the first client snapshot equals the server paint (D8).
|
|
3
|
+
import { useStore } from '@brust/core/store/react'
|
|
4
|
+
import { useReducer } from 'react'
|
|
5
|
+
import { removeMember } from '../lib/team-client'
|
|
6
|
+
import { team } from '../stores/team'
|
|
7
|
+
|
|
8
|
+
const flip = (open: boolean): boolean => !open
|
|
9
|
+
|
|
10
|
+
export default function TeamBuilder() {
|
|
11
|
+
const { members, max } = useStore(team)
|
|
12
|
+
const [open, toggle] = useReducer(flip, false)
|
|
13
|
+
return (
|
|
14
|
+
<div className="fixed bottom-5 right-5 z-[200]">
|
|
15
|
+
{open && (
|
|
16
|
+
<div data-testid="team-panel" className="mb-3 w-80 overflow-hidden rounded-xl border border-slate-200 bg-white shadow-2xl dark:border-slate-700 dark:bg-slate-900">
|
|
17
|
+
<div className="flex items-center gap-2 border-b border-slate-100 px-4 py-3 dark:border-slate-800"><span className="text-sm font-extrabold">My team</span><span className="ml-auto text-xs font-semibold">{members.length} / {max}</span></div>
|
|
18
|
+
{members.length === 0 ? <div className="px-5 py-7 text-center text-xs text-slate-400">No Pokémon on your team yet.</div> : members.map((m) => (
|
|
19
|
+
<div key={m.id} className="flex items-center gap-2.5 border-b border-slate-100 px-3.5 py-2.5 dark:border-slate-800">
|
|
20
|
+
<img src={m.artwork} alt={m.displayName} className="h-7 w-7 object-contain" />
|
|
21
|
+
<a href={`/pokemon/${m.name}`} className="min-w-0 flex-1 text-xs font-semibold no-underline">{m.displayName}</a>
|
|
22
|
+
<button type="button" aria-label="Remove" onClick={() => removeMember(m.id)} className="rounded p-1 text-slate-400">×</button>
|
|
23
|
+
</div>
|
|
24
|
+
))}
|
|
25
|
+
</div>
|
|
26
|
+
)}
|
|
27
|
+
<button type="button" onClick={() => toggle()} className="inline-flex items-center gap-1.5 rounded-full bg-brand-500 px-5 py-2.5 text-sm font-semibold text-white shadow-lg">
|
|
28
|
+
My team <span data-testid="team-count" className="rounded-full bg-white/25 px-2 py-0.5 text-xs font-extrabold">{members.length}</span>
|
|
29
|
+
</button>
|
|
30
|
+
</div>
|
|
31
|
+
)
|
|
32
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// components/ThemeToggle.tsx — useState + useEffect (spec §9 example). No cookie write (actions are M3): the
|
|
2
|
+
// loader still reads `mode` from the cookie for the first paint; the toggle is per page view.
|
|
3
|
+
import { useEffect, useId, useState } from 'react'
|
|
4
|
+
export default function ThemeToggle(props: { mode: 'dark' | 'light' }) {
|
|
5
|
+
// `useId` makes this instance server-fed, which is what puts its client chunk in the manifest's
|
|
6
|
+
// `children[]` (ledger F66: a native child with no job and no useId never gets its chunk linked).
|
|
7
|
+
const id = useId()
|
|
8
|
+
const [mode, setMode] = useState(props.mode)
|
|
9
|
+
useEffect(() => { document.documentElement.dataset.mode = mode }, [mode])
|
|
10
|
+
return (
|
|
11
|
+
<button type="button" id={id} aria-label="Toggle theme" data-testid="theme-toggle" onClick={() => setMode(mode === 'dark' ? 'light' : 'dark')}
|
|
12
|
+
className="inline-flex items-center gap-1.5 rounded-lg border border-slate-200 px-3 py-1.5 text-sm font-medium dark:border-slate-700">
|
|
13
|
+
{mode === 'dark' ? 'Light' : 'Dark'}
|
|
14
|
+
</button>
|
|
15
|
+
)
|
|
16
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
// components/TypeBadge.tsx — one helper-backed job; rendered per row on HomePage AND DetailPage (D4).
|
|
2
|
+
import { label, tint } from '../lib/format'
|
|
3
|
+
export default function TypeBadge(props: { type: string }) {
|
|
4
|
+
return <span data-type={props.type} style={{ background: tint(props.type) }} className="rounded-full px-3 py-1 text-xs font-semibold uppercase tracking-wide text-white">{label(props.type)}</span>
|
|
5
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// components/UnifiedIsland.tsx — React island (SSR + hydration). `useStore` alone compiles native, so the "clicks from
|
|
2
|
+
// React" tally (useReducer) is what puts this on the react tier (D10 asks for an island).
|
|
3
|
+
import { useStore } from '@brust/core/store/react'
|
|
4
|
+
import { useReducer } from 'react'
|
|
5
|
+
import { unified } from '../stores/unified'
|
|
6
|
+
|
|
7
|
+
const tick = (n: number): number => n + 1
|
|
8
|
+
|
|
9
|
+
export default function UnifiedIsland() {
|
|
10
|
+
const { count } = useStore(unified)
|
|
11
|
+
const [mine, tally] = useReducer(tick, 0)
|
|
12
|
+
return (
|
|
13
|
+
<div className="flex flex-col items-center gap-4">
|
|
14
|
+
<p data-testid="unified-island-count" className="text-4xl font-bold tabular-nums">{count}</p>
|
|
15
|
+
<button
|
|
16
|
+
type="button"
|
|
17
|
+
data-testid="unified-island-inc"
|
|
18
|
+
onClick={() => {
|
|
19
|
+
tally()
|
|
20
|
+
unified.count.set(count + 1)
|
|
21
|
+
}}
|
|
22
|
+
className="inline-flex h-10 items-center rounded-lg bg-brand-500 px-4 text-sm font-semibold text-white transition-opacity hover:opacity-90"
|
|
23
|
+
>
|
|
24
|
+
Increment from React ({mine})
|
|
25
|
+
</button>
|
|
26
|
+
</div>
|
|
27
|
+
)
|
|
28
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// components/UnifiedNative.tsx — NATIVE (0 jobs): render reads the `unified` store, the handler writes it. The chunk ships zero React.
|
|
2
|
+
import { useStore } from '@brust/core/store/react'
|
|
3
|
+
import { unified } from '../stores/unified'
|
|
4
|
+
|
|
5
|
+
export default function UnifiedNative() {
|
|
6
|
+
const s = useStore(unified)
|
|
7
|
+
return (
|
|
8
|
+
<div className="flex flex-col items-center gap-4">
|
|
9
|
+
<p data-testid="unified-native-count" className="text-4xl font-bold tabular-nums">{s.count}</p>
|
|
10
|
+
<button
|
|
11
|
+
type="button"
|
|
12
|
+
data-testid="unified-native-inc"
|
|
13
|
+
onClick={() => unified.count.set(s.count + 1)}
|
|
14
|
+
className="inline-flex h-10 items-center rounded-lg border border-slate-300 px-4 text-sm font-semibold transition-colors hover:border-slate-500 dark:border-slate-600"
|
|
15
|
+
>
|
|
16
|
+
Increment — zero React
|
|
17
|
+
</button>
|
|
18
|
+
</div>
|
|
19
|
+
)
|
|
20
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// components/UnifiedStoreNode.tsx — the centre node: a read-only native view of the same store both panels write (0 jobs).
|
|
2
|
+
import { useStore } from '@brust/core/store/react'
|
|
3
|
+
import { unified } from '../stores/unified'
|
|
4
|
+
|
|
5
|
+
export default function UnifiedStoreNode() {
|
|
6
|
+
const { count } = useStore(unified)
|
|
7
|
+
return (
|
|
8
|
+
<div className="flex min-w-36 flex-col items-center gap-1 rounded-2xl border-2 border-brand-500 px-6 py-5 text-center">
|
|
9
|
+
<span className="text-sm font-semibold">one store</span>
|
|
10
|
+
<span data-testid="unified-node-count" className="text-3xl font-bold tabular-nums text-brand-500">{count}</span>
|
|
11
|
+
<code className="text-sm text-slate-500 dark:text-slate-400">unified</code>
|
|
12
|
+
</div>
|
|
13
|
+
)
|
|
14
|
+
}
|