brustjs 0.1.16-alpha → 0.1.18-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.
- package/example/pokedex/actions.ts +40 -0
- package/example/pokedex/app.css +1686 -0
- package/example/pokedex/components/AddToTeamButton.tsx +93 -0
- package/example/pokedex/components/PageLayout.tsx +90 -0
- package/example/pokedex/components/TeamBuilder.tsx +214 -0
- package/example/pokedex/components/team-bus.ts +25 -0
- package/example/pokedex/index.ts +12 -0
- package/example/pokedex/lib/loaders.ts +290 -0
- package/example/pokedex/lib/pokeapi.ts +174 -0
- package/example/pokedex/lib/team-store.ts +28 -0
- package/example/pokedex/lib/types.ts +137 -0
- package/example/pokedex/pages/DetailPage.tsx +167 -0
- package/example/pokedex/pages/ListPage.tsx +83 -0
- package/example/pokedex/pages/TypeChart.tsx +51 -0
- package/example/pokedex/public/favicon.svg +1 -0
- package/example/pokedex/routes.tsx +21 -0
- package/package.json +14 -8
- package/runtime/cli/build.ts +12 -0
- package/runtime/cli/help.ts +6 -1
- package/runtime/cli/new.ts +127 -41
- package/runtime/cli/templates.ts +139 -0
- package/runtime/index.d.ts +7 -0
- package/runtime/index.js +53 -52
- package/runtime/index.ts +21 -0
- package/runtime/islands/bootstrap.ts +40 -17
|
@@ -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
|
+
})
|