brustjs 0.1.16-alpha → 0.1.17-alpha

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.
@@ -0,0 +1,93 @@
1
+ // ISLAND — the "Add to team / In your team" toggle on the detail page.
2
+ //
3
+ // Islands run REAL React in the browser (and, when `ssr`, in a Bun worker too),
4
+ // so unlike the native page shells they have NO jinja constraints: hooks, inline
5
+ // `style={{…}}`, event handlers all work normally.
6
+ import { useEffect, useState } from 'react'
7
+ import { client } from 'brustjs/client'
8
+ import type { Actions } from '../actions'
9
+ import type { AddToTeamProps, TeamMember } from '../lib/types'
10
+ import { emitTeam, onTeam } from './team-bus'
11
+
12
+ const api = client<Actions>()
13
+
14
+ export default function AddToTeamButton(p: AddToTeamProps) {
15
+ const [team, setTeam] = useState<TeamMember[]>([])
16
+ const [busy, setBusy] = useState(false)
17
+ const [toast, setToast] = useState<string | null>(null)
18
+
19
+ useEffect(() => {
20
+ api.team.get().then((r) => {
21
+ if (r.data) setTeam(r.data.team)
22
+ })
23
+ return onTeam(setTeam)
24
+ }, [])
25
+
26
+ const inTeam = team.some((m) => m.id === p.id)
27
+
28
+ async function toggle() {
29
+ setBusy(true)
30
+ try {
31
+ if (inTeam) {
32
+ // NOTE: `.delete({})` — passing an empty body is REQUIRED. A bodyless
33
+ // DELETE from the browser sends no Content-Length, and brust's action
34
+ // dispatch returns 411 on non-GET/HEAD without one. See GAPS S12.
35
+ const { data } = await api.team({ id: p.id }).delete({})
36
+ if (data) emitTeam(data.team)
37
+ } else {
38
+ const { data } = await api.team.post({
39
+ id: p.id,
40
+ name: p.name,
41
+ displayName: p.displayName,
42
+ num: p.num,
43
+ types: p.types,
44
+ artwork: p.artwork,
45
+ })
46
+ if (data?.full) {
47
+ setToast('ทีมเต็มแล้ว · สูงสุด 6 ตัว')
48
+ setTimeout(() => setToast(null), 2200)
49
+ } else if (data) {
50
+ emitTeam(data.team)
51
+ }
52
+ }
53
+ } finally {
54
+ setBusy(false)
55
+ }
56
+ }
57
+
58
+ return (
59
+ <div style={{ position: 'relative' }}>
60
+ <button
61
+ type="button"
62
+ className={`aa-btn aa-btn--full${inTeam ? ' aa-btn--secondary' : ''}`}
63
+ style={{ width: '100%' }}
64
+ onClick={toggle}
65
+ disabled={busy}
66
+ >
67
+ {inTeam ? '✓ In your team' : '+ Add to team'}
68
+ </button>
69
+ {toast && (
70
+ <div
71
+ style={{
72
+ position: 'absolute',
73
+ top: 'calc(100% + 8px)',
74
+ left: 0,
75
+ right: 0,
76
+ zIndex: 50,
77
+ padding: '8px 12px',
78
+ borderRadius: 'var(--radius-md)',
79
+ background: 'var(--danger-50)',
80
+ color: 'var(--danger-700)',
81
+ border: '1px solid rgba(212,28,89,0.25)',
82
+ fontSize: 'var(--text-xs)',
83
+ fontWeight: 600,
84
+ textAlign: 'center',
85
+ boxShadow: 'var(--shadow-md)',
86
+ }}
87
+ >
88
+ {toast}
89
+ </div>
90
+ )}
91
+ </div>
92
+ )
93
+ }
@@ -0,0 +1,90 @@
1
+ // Shared native layout shell. INLINED into each route at build time (T6): the
2
+ // route's root is <PageLayout native …>, whose expansion roots in <BrustPage>,
3
+ // which the compiler now promotes to the document shell. Pages supply only
4
+ // title/active/crumb + their aa-content inner.
5
+ //
6
+ // MUST stay single-return with no local bindings above it — a local `const`
7
+ // would make the compiler soft-fall-back to an SSR component (no <html> shell).
8
+ // active-nav uses conditional ELEMENTS (S11), not a className ternary
9
+ // (unsupported). See ../FRAMEWORK-GAPS.md.
10
+ import { BrustPage, Island } from 'brustjs'
11
+ import type { ReactNode } from 'react'
12
+ import type { TeamMember } from '../lib/types'
13
+ import TeamBuilder from './TeamBuilder'
14
+
15
+ export default function PageLayout({
16
+ title,
17
+ active,
18
+ crumb,
19
+ teamProps,
20
+ children,
21
+ }: {
22
+ title: string
23
+ active: 'list' | 'typechart'
24
+ crumb: string
25
+ teamProps: { teamInitial: TeamMember[] }
26
+ children: ReactNode
27
+ }) {
28
+ return (
29
+ <BrustPage lang="en" className="dark" title={title}>
30
+ <div className="aa-app">
31
+ <aside className="aa-sidebar">
32
+ <div className="aa-sidebar__brand">
33
+ <div className="aa-sidebar__brand-mark">P</div>
34
+ <div className="grow truncate">
35
+ <div className="aa-sidebar__brand-name">PokéDex</div>
36
+ <div className="aa-sidebar__brand-sub">brust example app</div>
37
+ </div>
38
+ <span className="aa-sidebar__env">native</span>
39
+ </div>
40
+ <nav className="aa-sidebar__nav">
41
+ <div className="aa-sidebar__group-title">Pokédex</div>
42
+ {active === 'list' ? (
43
+ <a className="aa-nav-item is-active" href="/">
44
+ <span>All Pokémon</span>
45
+ </a>
46
+ ) : (
47
+ <a className="aa-nav-item" href="/">
48
+ <span>All Pokémon</span>
49
+ </a>
50
+ )}
51
+ {active === 'typechart' ? (
52
+ <a className="aa-nav-item is-active" href="/type-chart">
53
+ <span>Type chart</span>
54
+ <span className="aa-nav-item__count">native</span>
55
+ </a>
56
+ ) : (
57
+ <a className="aa-nav-item" href="/type-chart">
58
+ <span>Type chart</span>
59
+ <span className="aa-nav-item__count">native</span>
60
+ </a>
61
+ )}
62
+ </nav>
63
+ <div className="aa-sidebar__user">
64
+ <span className="aa-avatar aa-avatar--sm dex-brand-avatar">B</span>
65
+ <div className="grow truncate dex-user">
66
+ <div className="dex-user__name">brust dev</div>
67
+ <div className="dex-user__host">localhost</div>
68
+ </div>
69
+ </div>
70
+ </aside>
71
+
72
+ <main className="aa-main">
73
+ <header className="aa-topbar">
74
+ <div className="aa-topbar__crumb">
75
+ <a href="/" className="dex-crumb__root">
76
+ PokéDex
77
+ </a>
78
+ <span className="dex-crumb__sep">›</span>
79
+ <b>{crumb}</b>
80
+ </div>
81
+ </header>
82
+
83
+ <div className="aa-content">{children}</div>
84
+ </main>
85
+
86
+ <Island component={TeamBuilder} props={teamProps} ssr hydrate="load" />
87
+ </div>
88
+ </BrustPage>
89
+ )
90
+ }
@@ -0,0 +1,214 @@
1
+ // ISLAND — the floating "My team" dock + expandable roster panel.
2
+ //
3
+ // Rendered with `ssr` so the initial team (from the loader) ships in the HTML
4
+ // and the dock count is correct on first paint, then hydrates. Stays in sync
5
+ // with AddToTeamButton through the window-event bus (see team-bus.ts / GAPS S4).
6
+ import { useEffect, useState } from 'react'
7
+ import { client } from 'brustjs/client'
8
+ import type { Actions } from '../actions'
9
+ import type { TeamMember } from '../lib/types'
10
+ import { emitTeam, onTeam } from './team-bus'
11
+
12
+ const api = client<Actions>()
13
+ const MAX = 6
14
+
15
+ export default function TeamBuilder({ teamInitial }: { teamInitial: TeamMember[] }) {
16
+ const [team, setTeam] = useState<TeamMember[]>(teamInitial ?? [])
17
+ const [open, setOpen] = useState(false)
18
+
19
+ useEffect(() => {
20
+ // Re-sync from the server on mount (the SSR snapshot may be stale by now),
21
+ // then subscribe to mutations from the other island.
22
+ api.team.get().then((r) => {
23
+ if (r.data) setTeam(r.data.team)
24
+ })
25
+ return onTeam(setTeam)
26
+ }, [])
27
+
28
+ async function remove(id: number) {
29
+ // `.delete({})` — empty body is required (bodyless DELETE → 411). See GAPS S12.
30
+ const { data } = await api.team({ id }).delete({})
31
+ if (data) emitTeam(data.team)
32
+ }
33
+
34
+ const coverage = [...new Set(team.flatMap((m) => m.types))]
35
+
36
+ return (
37
+ <div style={{ position: 'fixed', right: 20, bottom: 20, zIndex: 200 }}>
38
+ {open && (
39
+ <div
40
+ className="aa-card"
41
+ style={{
42
+ width: 320,
43
+ marginBottom: 12,
44
+ boxShadow: 'var(--shadow-xl)',
45
+ border: '1px solid var(--border-default)',
46
+ }}
47
+ >
48
+ <div style={{ height: 3, background: 'var(--aa-gradient)' }} />
49
+ <div
50
+ style={{
51
+ padding: '14px 16px',
52
+ borderBottom: '1px solid var(--border-subtle)',
53
+ display: 'flex',
54
+ alignItems: 'center',
55
+ gap: 8,
56
+ }}
57
+ >
58
+ <span
59
+ style={{
60
+ fontFamily: 'var(--font-display)',
61
+ fontWeight: 800,
62
+ fontSize: 'var(--text-sm)',
63
+ }}
64
+ >
65
+ My team
66
+ </span>
67
+ <span className="aa-pill aa-pill--muted aa-pill--no-dot" style={{ marginLeft: 'auto' }}>
68
+ {team.length} / {MAX}
69
+ </span>
70
+ </div>
71
+
72
+ {team.length === 0 ? (
73
+ <div
74
+ style={{
75
+ padding: '28px 20px',
76
+ textAlign: 'center',
77
+ color: 'var(--text-tertiary)',
78
+ fontSize: 'var(--text-xs)',
79
+ lineHeight: 1.6,
80
+ }}
81
+ >
82
+ ยังไม่มี Pokémon ในทีม
83
+ <br />
84
+ เปิดหน้า detail แล้วกด <b style={{ color: 'var(--text-secondary)' }}>Add to team</b>
85
+ </div>
86
+ ) : (
87
+ <>
88
+ <div style={{ maxHeight: 300, overflowY: 'auto' }}>
89
+ {team.map((m) => (
90
+ <div
91
+ key={m.id}
92
+ style={{
93
+ display: 'flex',
94
+ alignItems: 'center',
95
+ gap: 10,
96
+ padding: '9px 14px',
97
+ borderBottom: '1px solid var(--border-subtle)',
98
+ }}
99
+ >
100
+ <div
101
+ style={{
102
+ width: 36,
103
+ height: 36,
104
+ borderRadius: 'var(--radius-md)',
105
+ background: 'var(--surface-sunken)',
106
+ display: 'grid',
107
+ placeItems: 'center',
108
+ flex: 'none',
109
+ }}
110
+ >
111
+ <img
112
+ src={m.artwork}
113
+ alt={m.displayName}
114
+ style={{ width: 30, height: 30, objectFit: 'contain' }}
115
+ />
116
+ </div>
117
+ <a
118
+ href={`/pokemon/${m.name}`}
119
+ style={{ flex: 1, minWidth: 0, textDecoration: 'none' }}
120
+ >
121
+ <div
122
+ style={{
123
+ fontWeight: 600,
124
+ fontSize: 'var(--text-xs)',
125
+ color: 'var(--text-primary)',
126
+ }}
127
+ >
128
+ {m.displayName}
129
+ </div>
130
+ <div
131
+ style={{
132
+ fontFamily: 'var(--font-mono)',
133
+ fontSize: 'var(--text-3xs)',
134
+ color: 'var(--text-tertiary)',
135
+ }}
136
+ >
137
+ {m.num}
138
+ </div>
139
+ </a>
140
+ <div style={{ display: 'flex', gap: 4 }}>
141
+ {m.types.map((t) => (
142
+ <span key={t} className={`dex-type dex-type--${t} dex-type--sm`}>
143
+ {t}
144
+ </span>
145
+ ))}
146
+ </div>
147
+ <button
148
+ type="button"
149
+ className="aa-btn aa-btn--ghost aa-btn--icon aa-btn--xs"
150
+ onClick={() => remove(m.id)}
151
+ aria-label="Remove"
152
+ >
153
+
154
+ </button>
155
+ </div>
156
+ ))}
157
+ </div>
158
+ {coverage.length > 0 && (
159
+ <div
160
+ style={{
161
+ padding: '11px 14px',
162
+ background: 'var(--ink-25)',
163
+ borderTop: '1px solid var(--border-subtle)',
164
+ }}
165
+ >
166
+ <div
167
+ style={{
168
+ fontSize: 'var(--text-3xs)',
169
+ fontWeight: 700,
170
+ color: 'var(--text-tertiary)',
171
+ textTransform: 'uppercase',
172
+ letterSpacing: '0.08em',
173
+ marginBottom: 7,
174
+ }}
175
+ >
176
+ Type coverage
177
+ </div>
178
+ <div style={{ display: 'flex', flexWrap: 'wrap', gap: 5 }}>
179
+ {coverage.map((t) => (
180
+ <span key={t} className={`dex-type dex-type--${t} dex-type--sm`}>
181
+ {t}
182
+ </span>
183
+ ))}
184
+ </div>
185
+ </div>
186
+ )}
187
+ </>
188
+ )}
189
+ </div>
190
+ )}
191
+
192
+ <button
193
+ type="button"
194
+ className="aa-btn aa-btn--lg"
195
+ onClick={() => setOpen((o) => !o)}
196
+ style={{ boxShadow: 'var(--shadow-brand)', paddingInline: 18 }}
197
+ >
198
+ My team
199
+ <span
200
+ style={{
201
+ marginLeft: 6,
202
+ padding: '1px 8px',
203
+ borderRadius: 'var(--radius-pill)',
204
+ background: 'rgba(255,255,255,0.22)',
205
+ fontWeight: 800,
206
+ fontSize: 'var(--text-xs)',
207
+ }}
208
+ >
209
+ {team.length}
210
+ </span>
211
+ </button>
212
+ </div>
213
+ )
214
+ }
@@ -0,0 +1,25 @@
1
+ // Cross-island sync bus.
2
+ //
3
+ // GAP S4: brust has no cross-island shared-state primitive. AddToTeamButton and
4
+ // TeamBuilder are two SEPARATE island chunks (each its own Bun.build bundle), so
5
+ // a module-scope store imported by both would be DUPLICATED — two instances that
6
+ // never see each other. The one thing both chunks genuinely share is the
7
+ // `window` object, so we coordinate through a window CustomEvent. This works,
8
+ // but it's a hand-rolled workaround for a pattern (cart / team / selection) that
9
+ // most apps need. See ../FRAMEWORK-GAPS.md S4.
10
+
11
+ import type { TeamMember } from '../lib/types'
12
+
13
+ export const TEAM_EVENT = 'brust-pokedex:team'
14
+
15
+ export function emitTeam(team: TeamMember[]): void {
16
+ if (typeof window === 'undefined') return
17
+ window.dispatchEvent(new CustomEvent<TeamMember[]>(TEAM_EVENT, { detail: team }))
18
+ }
19
+
20
+ export function onTeam(fn: (team: TeamMember[]) => void): () => void {
21
+ if (typeof window === 'undefined') return () => {}
22
+ const handler = (e: Event) => fn((e as CustomEvent<TeamMember[]>).detail)
23
+ window.addEventListener(TEAM_EVENT, handler)
24
+ return () => window.removeEventListener(TEAM_EVENT, handler)
25
+ }
@@ -0,0 +1,12 @@
1
+ import { brust } from 'brustjs'
2
+ import { actions } from './actions'
3
+ import { routes } from './routes'
4
+
5
+ // Boot: register the (native) routes + the team-store actions, build island
6
+ // chunks, compile native templates, and serve. Zero config — PokeAPI is public
7
+ // and keyless, so this runs out of the box.
8
+ await brust.run({
9
+ routes,
10
+ entry: import.meta.url,
11
+ actions,
12
+ })