@enbox/create-dapp 0.0.1 → 0.0.2

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 CHANGED
@@ -8,17 +8,22 @@ Requires Node.js 22.12.0 or newer. Create an app with either command:
8
8
  ```sh
9
9
  npm create @enbox/dapp@latest my-app
10
10
  # or
11
- npx @enbox/create-dapp@latest my-app
11
+ bun create @enbox/dapp@latest my-app
12
12
  ```
13
13
 
14
14
  Then run:
15
15
 
16
16
  ```sh
17
17
  cd my-app
18
- npm ci
18
+ npm install
19
19
  npm run dev
20
20
  ```
21
21
 
22
+ The CLI detects npm or Bun from the invoking package manager and prints its
23
+ commands. Use `--package-manager npm` or `--package-manager bun` to override
24
+ that choice. The template includes no lockfile: your first install creates one
25
+ for your package manager. Commit it and use frozen installs thereafter.
26
+
22
27
  The generator copies its bundled template into a new or empty directory. It
23
28
  does not install packages, run Git, or make network requests. It refuses
24
29
  nonempty destinations and symbolic-link targets; there is no force option.
@@ -27,7 +32,7 @@ interactive terminal; scripts must provide it explicitly.
27
32
 
28
33
  The directory's basename becomes the display name in `src/app.json`. Its
29
34
  normalized lowercase npm name becomes both the package name and the stable
30
- `app.json` ID, and the bundled lockfile is updated to match. The ID identifies
35
+ `app.json` ID. The ID identifies
31
36
  the app to the wallet and defines its private demo protocol namespace. Keep
32
37
  the ID when changing the display name so saved data remains in the same
33
38
  namespace. Source code is copied unchanged. Run `--help` for usage or
@@ -11,17 +11,20 @@ const manifest = JSON.parse(fs.readFileSync(join(packageRoot, 'package.json'), '
11
11
  const excluded = new Set([
12
12
  'node_modules', 'dist', 'build', '.git', '.artifacts', '.vite', '.cache',
13
13
  'coverage', 'test-results', 'playwright-report', '.DS_Store',
14
+ 'package-lock.json', 'bun.lock', 'bun.lockb', 'npm-shrinkwrap.json', 'yarn.lock', 'pnpm-lock.yaml',
14
15
  ]);
15
16
 
16
17
  const usage = `Create a minimal Enbox React application.
17
18
 
18
19
  Usage:
19
20
  npm create @enbox/dapp@latest [directory]
21
+ bun create @enbox/dapp@latest [directory]
20
22
  npx @enbox/create-dapp@latest [directory]
21
23
 
22
24
  Options:
23
25
  -h, --help Show this help
24
26
  -v, --version Show the generator version
27
+ --package-manager <npm|bun> Override the invoking package manager
25
28
 
26
29
  The destination must be empty. No dependencies are installed and no Git
27
30
  repository is created. Use "." to create an app in the current empty directory.
@@ -78,11 +81,6 @@ function createProject(destination) {
78
81
  value.id = name;
79
82
  value.name = displayName;
80
83
  });
81
- customizeJson(files, 'package-lock.json', (value) => {
82
- if (value.packages?.[''] === undefined) throw new Error('root package metadata is missing');
83
- value.name = name;
84
- value.packages[''].name = name;
85
- });
86
84
 
87
85
  const createdFiles = [];
88
86
  const createdDirectories = [];
@@ -128,11 +126,16 @@ function quoteDirectory(directory) {
128
126
 
129
127
  async function main() {
130
128
  const { values, positionals } = parseArgs({
131
- options: { help: { type: 'boolean', short: 'h' }, version: { type: 'boolean', short: 'v' } },
129
+ options: {
130
+ help: { type: 'boolean', short: 'h' }, version: { type: 'boolean', short: 'v' },
131
+ 'package-manager': { type: 'string' },
132
+ },
132
133
  allowPositionals: true,
133
134
  });
134
135
  if (values.help) { console.log(usage); return; }
135
136
  if (values.version) { console.log(manifest.version); return; }
137
+ const manager = values['package-manager'] ?? (process.env.npm_config_user_agent?.startsWith('bun/') ? 'bun' : 'npm');
138
+ if (manager !== 'npm' && manager !== 'bun') throw new Error('The package manager must be npm or bun.');
136
139
  if (positionals.length > 1) throw new Error('Provide one target directory. Run with --help for usage.');
137
140
  let directory = positionals[0];
138
141
  if (directory === undefined) {
@@ -149,7 +152,7 @@ async function main() {
149
152
  const destination = resolve(directory);
150
153
  createProject(destination);
151
154
  const changeDirectory = process.platform === 'win32' ? 'Set-Location -LiteralPath' : 'cd';
152
- console.log(`Created ${basename(destination)}.\n\n${process.platform === 'win32' ? 'In PowerShell' : 'Next'}:\n ${changeDirectory} ${quoteDirectory(destination)}\n npm ci\n npm run dev`);
155
+ console.log(`Created ${basename(destination)}.\n\n${process.platform === 'win32' ? 'In PowerShell' : 'Next'}:\n ${changeDirectory} ${quoteDirectory(destination)}\n ${manager} install\n ${manager} run dev`);
153
156
  }
154
157
 
155
158
  main().catch((cause) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@enbox/create-dapp",
3
- "version": "0.0.1",
3
+ "version": "0.0.2",
4
4
  "description": "Create a minimal Enbox React application",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -17,7 +17,6 @@
17
17
  "template/src",
18
18
  "template/index.html",
19
19
  "template/package.json",
20
- "template/package-lock.json",
21
20
  "template/tsconfig.json",
22
21
  "template/vite.config.ts",
23
22
  "template/README.md",
@@ -1,20 +1,22 @@
1
1
  # Hello, Enbox
2
2
 
3
- A small React app with a wallet connection, a read-only public profile, and one
4
- private choice saved through Enbox. No application server is required.
3
+ A small React app with a wallet connection, a read-only public profile, and
4
+ a private task board with projects and tasks. No application server is required.
5
5
 
6
6
  ## Run it
7
7
 
8
8
  Use Node 22.12 or newer:
9
9
 
10
10
  ```sh
11
- npm ci
11
+ npm install
12
12
  npm run dev
13
13
  ```
14
14
 
15
15
  Open the local URL and choose **Connect wallet**. Enbox handles wallet selection
16
- and approval. Once connected, choose an emoji. The displayed choice comes from
17
- the live record view and is restored when this profile returns.
16
+ and approval. Create a project, add tasks, and check them off as you finish.
17
+ Select a task to edit its title or delete it. Completed tasks can be reopened.
18
+ Switching projects selects that project’s own tasks. Saved values come from native
19
+ live record views and are restored when this profile returns.
18
20
 
19
21
  ```sh
20
22
  npm run typecheck
@@ -28,9 +30,11 @@ toolchain. Production builds precache the app shell for
28
30
  later offline visits; `npm run preview` serves that built app locally. Deploy
29
31
  the generated `dist` directory on an HTTPS origin with service worker support.
30
32
 
31
- Direct dependencies use exact versions; `npm ci` installs the committed
32
- dependency tree. Update the manifest and lockfile together when accepting
33
- dependency or security fixes.
33
+ You can also use `bun install` and `bun run dev`. No lockfile is generated
34
+ until your first install. Commit the resulting `package-lock.json` or `bun.lock`;
35
+ then use `npm ci` or `bun install --frozen-lockfile` in CI. Direct dependencies
36
+ use exact versions. Update the manifest and your lockfile together when
37
+ accepting dependency or security fixes.
34
38
 
35
39
  ## Make it yours
36
40
 
@@ -48,23 +52,28 @@ the expanded details still show which name is publicly published.
48
52
 
49
53
  The removable demo lives entirely in `src/demo`:
50
54
 
51
- - `protocol.ts` declares an encrypted singleton record and validates its emoji.
52
- - `Demo.tsx` reads through `useRecords` and writes absolute choices through
53
- `useEnboxMutation` and the typed `records.set` API.
55
+ - `protocol.ts` declares encrypted `project` and `project/task` records with
56
+ payload validators. Tasks store a title and completion state.
57
+ - `use-task-board.ts` defines `useProjects`, `useTasks`, and `useTask` with
58
+ `useEnboxRecords` and `useEnboxRecord`. A child selection takes its direct
59
+ parent context ID; task details also select an exact record ID.
60
+ - `TaskBoard.tsx` renders the board and calls typed create, patch, and delete commands.
61
+ Simple event handlers use `mutate`. Forms await `mutateAsync` and check
62
+ `isCurrent()` before clearing drafts or closing details. Native observation updates the displayed data.
54
63
  - `demo.css` styles the demo. Shared app and profile styles live in `src/style.css`.
55
64
 
56
65
  To replace the demo, register your own typed application protocol in
57
- `application.ts` in place of `DemoProtocol`. Then delete `src/demo` and remove
58
- the `Demo` import and component from `App.tsx`. Enbox requires at least one
66
+ `application.ts` in place of `TaskBoardProtocol`. Then delete `src/demo` and remove
67
+ the `TaskBoard` import and component from `App.tsx`. Enbox requires at least one
59
68
  application protocol, so keep your replacement registered. The profile card,
60
69
  connection shell, and service worker remain the base for your app.
61
70
 
62
71
  The demo's default protocol URI is
63
- `https://example.com/<encoded app ID>/demo/v1`. Replace the URI expression in
72
+ `https://example.com/<encoded app ID>/demo/v2`. Replace the URI expression in
64
73
  `src/demo/protocol.ts` with a stable URI
65
74
  you control before publishing. This URI identifies saved records and wallet
66
75
  grants: changing it creates a different data namespace. Once deployed, keep
67
76
  that identifier fixed when changing the app's display name.
68
77
 
69
- Public profile information and the encrypted demo choice are separate. Choosing
70
- an emoji updates this app's private record; it does not edit the public profile.
78
+ Public profile information and the encrypted task board are separate. Task
79
+ updates change this app’s private records; they do not edit the public profile.
@@ -6,12 +6,12 @@
6
6
  "scripts": {
7
7
  "dev": "vite",
8
8
  "typecheck": "tsc --noEmit",
9
- "build": "npm run typecheck && vite build",
9
+ "build": "tsc --noEmit && vite build",
10
10
  "preview": "vite preview"
11
11
  },
12
12
  "dependencies": {
13
13
  "@enbox/browser": "0.3.82",
14
- "@enbox/react": "0.0.6",
14
+ "@enbox/react": "0.0.7",
15
15
  "react": "19.3.0",
16
16
  "react-dom": "19.3.0"
17
17
  },
@@ -2,7 +2,7 @@ import { useEnboxConnection } from '@enbox/react';
2
2
  import { useEffect } from 'react';
3
3
 
4
4
  import app from './app.json' with { type: 'json' };
5
- import { Demo } from './demo/Demo.js';
5
+ import { TaskBoard } from './demo/TaskBoard.js';
6
6
  import { ProfileCard } from './ProfileCard.js';
7
7
 
8
8
  export function App() {
@@ -53,7 +53,7 @@ export function App() {
53
53
  {connection.isConnected && connection.did ? (
54
54
  <div className="connected-content">
55
55
  <ProfileCard enbox={connection.enbox} did={connection.did} identityName={connection.identityName} />
56
- <Demo disabled={busy || connection.walletRepair !== undefined} />
56
+ <TaskBoard disabled={busy || connection.walletRepair !== undefined} />
57
57
  </div>
58
58
  ) : (
59
59
  <section className="panel connect-panel" aria-labelledby="connect-title">
@@ -2,9 +2,9 @@ import { defineApplicationManifest } from '@enbox/react/config';
2
2
  import type { CreateEnboxClientOptions } from '@enbox/react/config';
3
3
 
4
4
  import app from './app.json' with { type: 'json' };
5
- import { DemoProtocol } from './demo/protocol.js';
5
+ import { TaskBoardProtocol } from './demo/protocol.js';
6
6
 
7
7
  export const config = {
8
- application: defineApplicationManifest({ protocols: [DemoProtocol] }),
8
+ application: defineApplicationManifest({ protocols: [TaskBoardProtocol] }),
9
9
  wallet: { appName: app.name, applicationId: app.id },
10
10
  } satisfies CreateEnboxClientOptions;
@@ -0,0 +1,136 @@
1
+ import { useState } from 'react';
2
+ import type { FormEvent } from 'react';
3
+
4
+ import { useProjects, useTask, useTasks } from './use-task-board.js';
5
+ import './demo.css';
6
+
7
+ export function TaskBoard({ disabled = false }: { disabled?: boolean }) {
8
+ const projects = useProjects();
9
+ const [projectContextId, setProjectContextId] = useState<string>();
10
+ const [name, setName] = useState('');
11
+ const prompt = projects.isLoading ? 'Loading projects…'
12
+ : projects.isReady && projects.records.length === 0 ? 'Create your first project to get started.'
13
+ : 'Select a project to see its tasks.';
14
+ async function addProject(event: FormEvent) {
15
+ event.preventDefault();
16
+ try {
17
+ const record = await projects.create.mutateAsync({ name: name.trim() });
18
+ if (projects.create.isCurrent()) { setProjectContextId(record.contextId); setName(''); }
19
+ } catch { /* The mutation error is rendered below. */ }
20
+ }
21
+ return (
22
+ <section className="panel demo" aria-labelledby="demo-title">
23
+ <h2 id="demo-title">Your projects</h2>
24
+ <p>A private task board. Create a project, add tasks, and make a little progress.</p>
25
+ <form className="demo-form" onSubmit={addProject}>
26
+ <label htmlFor="project-name">Project name</label>
27
+ <input id="project-name" value={name} maxLength={60} required
28
+ disabled={disabled || projects.create.isPending} onChange={(event) => setName(event.target.value)} />
29
+ <button className="button button-secondary" disabled={disabled || projects.create.isPending || !name.trim()}>
30
+ {projects.create.isPending ? 'Creating…' : 'Create project'}
31
+ </button>
32
+ </form>
33
+ {projects.create.error && <p role="alert">Couldn’t create the project: {projects.create.error.message}</p>}
34
+ {projects.status === 'error' && <div role="alert">
35
+ <p>Couldn’t load projects: {projects.error.message}</p>
36
+ <button type="button" disabled={disabled} onClick={() => projects.retry()}>Try again</button>
37
+ </div>}
38
+ <div className="project-picker" role="group" aria-label="Projects">
39
+ {projects.records.map((project) => <button type="button" className="button button-quiet" key={project.id} disabled={disabled}
40
+ aria-pressed={project.contextId === projectContextId} onClick={() => setProjectContextId(project.contextId)}>{project.data.name}</button>)}
41
+ </div>
42
+ {projects.hasMore && <button type="button" disabled={disabled || projects.isLoadingMore}
43
+ onClick={() => projects.loadMore()}>More projects</button>}
44
+ {projectContextId !== undefined
45
+ ? <ProjectTasks key={projectContextId} within={projectContextId} disabled={disabled} />
46
+ : <p role="status">{prompt}</p>}
47
+ </section>
48
+ );
49
+ }
50
+
51
+ function ProjectTasks({ within, disabled }: { within: string; disabled: boolean }) {
52
+ const tasks = useTasks(within);
53
+ const [title, setTitle] = useState('');
54
+ const [selectedId, setSelectedId] = useState<string>();
55
+ async function addTask(event: FormEvent) {
56
+ event.preventDefault();
57
+ try {
58
+ const record = await tasks.create.mutateAsync({ title: title.trim(), completed: false });
59
+ if (tasks.create.isCurrent()) { setSelectedId(record.id); setTitle(''); }
60
+ } catch { /* The mutation error is rendered below. */ }
61
+ }
62
+ return <div className="project-content">
63
+ <h3>Tasks</h3>
64
+ <form className="demo-form" onSubmit={addTask}>
65
+ <label htmlFor="task-title">New task</label>
66
+ <input id="task-title" value={title} maxLength={120} required
67
+ disabled={disabled || tasks.create.isPending} onChange={(event) => setTitle(event.target.value)} />
68
+ <button className="button button-secondary" disabled={disabled || tasks.create.isPending || !title.trim()}>Add task</button>
69
+ </form>
70
+ {tasks.create.error && <p role="alert">Couldn’t add the task: {tasks.create.error.message}</p>}
71
+ {tasks.patch.error && <p role="alert">Couldn’t update the task: {tasks.patch.error.message}</p>}
72
+ {tasks.status === 'error' && <p role="alert">Couldn’t load tasks. <button type="button"
73
+ disabled={disabled} onClick={() => tasks.retry()}>Retry tasks</button></p>}
74
+ <ul className="task-list">
75
+ {tasks.records.map((task) => <li key={task.id} data-completed={task.data.completed}>
76
+ <input type="checkbox" aria-label={`Complete ${task.data.title}`} checked={task.data.completed}
77
+ disabled={disabled || tasks.patch.isPendingFor(task.id)}
78
+ onChange={(event) => tasks.patch.mutate({ id: task.id, patch: { completed: event.currentTarget.checked } })} />
79
+ <button type="button" className="button button-quiet" aria-label={`Open task ${task.data.title}`}
80
+ disabled={disabled} aria-pressed={task.id === selectedId} onClick={() => setSelectedId(task.id)}>{task.data.title}</button>
81
+ </li>)}
82
+ </ul>
83
+ {tasks.hasMore && <button type="button" disabled={disabled || tasks.isLoadingMore}
84
+ onClick={() => tasks.loadMore()}>More tasks</button>}
85
+ {selectedId !== undefined
86
+ ? <TaskDetails key={selectedId} within={within} recordId={selectedId} disabled={disabled} onClose={() => setSelectedId(undefined)} />
87
+ : <p role="status">Select a task to see its details.</p>}
88
+ </div>;
89
+ }
90
+
91
+ function TaskDetails({ within, recordId, disabled, onClose }: {
92
+ within: string; recordId: string; disabled: boolean; onClose: () => void;
93
+ }) {
94
+ const task = useTask(within, recordId);
95
+ const [draft, setDraft] = useState<string>();
96
+ const pending = task.patch.isPending || task.delete.isPending;
97
+ async function saveTitle(event: FormEvent) {
98
+ event.preventDefault();
99
+ if (draft === undefined) return;
100
+ try {
101
+ await task.patch.mutateAsync({ title: draft.trim() });
102
+ if (task.patch.isCurrent()) setDraft(undefined);
103
+ } catch { /* The mutation error is rendered below. */ }
104
+ }
105
+ async function deleteTask() {
106
+ try {
107
+ await task.delete.mutateAsync();
108
+ if (task.delete.isCurrent()) onClose();
109
+ } catch { /* The mutation error is rendered below. */ }
110
+ }
111
+ const data = task.data;
112
+ return <section className="task-details" aria-label="Task details">
113
+ <h3>Task details</h3>
114
+ {task.status === 'error' && <p role="alert">Couldn’t load the task. <button type="button"
115
+ disabled={disabled} onClick={() => task.retry()}>Retry task</button></p>}
116
+ {data === undefined && task.isLoading && <p role="status">Loading task…</p>}
117
+ {data === undefined && task.isReady && <p role="status">This task is no longer available.</p>}
118
+ {data !== undefined && <>
119
+ <p>{data.title} · {data.completed ? 'Completed' : 'To do'}</p>
120
+ {draft === undefined
121
+ ? <button type="button" className="button button-quiet" disabled={disabled || pending}
122
+ onClick={() => setDraft(data.title)}>Edit title</button>
123
+ : <form className="demo-form" onSubmit={saveTitle}>
124
+ <label htmlFor="edit-task-title">Task title</label>
125
+ <input id="edit-task-title" value={draft} maxLength={120} required disabled={disabled || pending}
126
+ onChange={(event) => setDraft(event.target.value)} />
127
+ <button className="button button-secondary" disabled={disabled || pending || !draft.trim()}>Save title</button>
128
+ <button type="button" className="button button-quiet" disabled={disabled || pending} onClick={() => setDraft(undefined)}>Cancel</button>
129
+ </form>}
130
+ <button type="button" className="button button-quiet" disabled={disabled || pending} onClick={deleteTask}>Delete task</button>
131
+ </>}
132
+ {task.patch.error && <p role="alert">Couldn’t update the task: {task.patch.error.message}</p>}
133
+ {task.delete.error && <p role="alert">Couldn’t delete the task: {task.delete.error.message}</p>}
134
+ <button type="button" className="button button-quiet" onClick={onClose}>Close details</button>
135
+ </section>;
136
+ }
@@ -1,17 +1,15 @@
1
1
  .demo { padding: 30px; }
2
2
  .demo > p { margin-top: 12px; color: var(--muted); font-size: .85rem; }
3
- .choice-options { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 11px; padding: 0; margin: 24px 0 0; border: 0; min-width: 0; }
4
- .choice-option { display: grid; place-items: center; gap: 10px; padding: 20px 8px 17px; border: 1px solid var(--border); border-radius: 13px; background: #fff; color: #59645a; font-size: .75rem; transition: background .15s, border-color .15s; }
5
- .choice-option:hover:not(:disabled) { background: #f8faf5; border-color: #a6bea0; }
6
- .choice-option[aria-pressed="true"] { background: #edf3e7; border-color: #709865; color: #315b46; }
7
- .choice-emoji { font-size: 1.9rem; line-height: 1.3; }
8
- .demo .demo-status { min-height: 20px; margin-top: 18px; font-size: .74rem; text-align: center; }
9
- .demo > p[role="alert"] { color: #7d4533; }
10
- .demo .notice { margin: 16px 0 0; }
3
+ .demo [role="alert"] { color: #7d4533; }
4
+ .demo-form { display: grid; gap: .5rem; margin: 1rem 0; }
5
+ .demo-form input { padding: .65rem; border: 1px solid var(--border); border-radius: .5rem; font: inherit; background: transparent; color: inherit; }
6
+ .project-picker { display: flex; flex-wrap: wrap; gap: .5rem; margin: 1rem 0; }
7
+ .project-picker button[aria-pressed="true"], .task-list button[aria-pressed="true"] { background: #edf3e7; border-color: #709865; color: var(--accent); }
8
+ .project-content, .task-details { border-top: 1px solid var(--border); padding-top: 1rem; margin-top: 1rem; }
9
+ .task-list { padding: 0; list-style: none; }
10
+ .task-list li { display: flex; align-items: center; gap: .5rem; padding: .5rem 0; }
11
+ .task-list li[data-completed="true"] button { text-decoration: line-through; color: var(--muted); }
12
+ .task-list button { text-align: left; overflow-wrap: anywhere; }
11
13
  @media (max-width: 600px) {
12
14
  .demo { padding: 26px 20px; }
13
- .choice-options { gap: 8px; }
14
- }
15
- @media (prefers-reduced-motion: reduce) {
16
- .choice-option { transition: none; }
17
15
  }
@@ -1,28 +1,26 @@
1
1
  import { defineProtocol, recordCodecs } from '@enbox/react/config';
2
- import type { RecordValidator } from '@enbox/react/config';
3
2
 
4
3
  import app from '../app.json' with { type: 'json' };
5
4
 
6
- export const CHOICES = [
7
- { emoji: '🌱', label: 'Grow' },
8
- { emoji: '✨', label: 'Spark' },
9
- { emoji: '🚀', label: 'Explore' },
10
- ] as const;
11
-
12
- export type ChoiceEmoji = typeof CHOICES[number]['emoji'];
13
- export type Choice = { emoji: ChoiceEmoji };
14
-
15
- const validateChoice: RecordValidator = (value: unknown): boolean =>
16
- value !== null && typeof value === 'object' && !Array.isArray(value)
17
- && 'emoji' in value && CHOICES.some(({ emoji }) => emoji === value.emoji);
5
+ export type Project = { name: string };
6
+ export type Task = { title: string; completed: boolean };
18
7
 
19
8
  // Replace this example URI with one you own before publishing your app.
20
9
  // Keep it stable after launch: it identifies the app's saved data and grants.
21
- export const DemoProtocol = defineProtocol({
22
- protocol: `https://example.com/${encodeURIComponent(app.id)}/demo/v1`,
10
+ export const TaskBoardProtocol = defineProtocol({
11
+ protocol: `https://example.com/${encodeURIComponent(app.id)}/demo/v2`,
23
12
  published: false,
24
13
  types: {
25
- choice: { dataFormats: ['application/json'], encryptionRequired: true },
14
+ project: { dataFormats: ['application/json'], encryptionRequired: true },
15
+ task: { dataFormats: ['application/json'], encryptionRequired: true },
26
16
  },
27
- structure: { choice: { $recordLimit: { max: 1 } } },
28
- } as const, { choice: recordCodecs.json<Choice>({ validator: validateChoice }) });
17
+ structure: { project: { task: {} } },
18
+ } as const, {
19
+ project: recordCodecs.json<Project>({ validator: (value: unknown) =>
20
+ value !== null && typeof value === 'object' && !Array.isArray(value) && 'name' in value
21
+ && typeof value.name === 'string' && value.name.trim().length > 0 && value.name.length <= 60 }),
22
+ task: recordCodecs.json<Task>({ validator: (value: unknown) =>
23
+ value !== null && typeof value === 'object' && !Array.isArray(value) && 'title' in value && 'completed' in value
24
+ && typeof value.title === 'string' && value.title.trim().length > 0 && value.title.length <= 120
25
+ && typeof value.completed === 'boolean' }),
26
+ });
@@ -0,0 +1,16 @@
1
+ import { useEnboxRecord, useEnboxRecords } from '@enbox/react';
2
+ import { DateSort } from '@enbox/react/config';
3
+
4
+ import { TaskBoardProtocol } from './protocol.js';
5
+
6
+ export function useProjects() {
7
+ return useEnboxRecords(TaskBoardProtocol, 'project', { dateSort: DateSort.CreatedDescending });
8
+ }
9
+
10
+ export function useTasks(within: string) {
11
+ return useEnboxRecords(TaskBoardProtocol, 'project/task', { within });
12
+ }
13
+
14
+ export function useTask(within: string, recordId: string) {
15
+ return useEnboxRecord(TaskBoardProtocol, 'project/task', { within, recordId });
16
+ }
@@ -1,4 +1,4 @@
1
- import { activatePolyfills } from '@enbox/browser';
1
+ import { prepareBrowser } from '@enbox/browser';
2
2
  import { EnboxProvider } from '@enbox/react';
3
3
  import { StrictMode } from 'react';
4
4
  import { createRoot } from 'react-dom/client';
@@ -8,43 +8,9 @@ import { config } from './application.js';
8
8
  import workerPath from './sw.ts?worker&url';
9
9
  import './style.css';
10
10
 
11
- /** Wait for this app's worker to control the page before initializing Enbox. */
12
- async function prepareBrowser(): Promise<void> {
13
- if (!('serviceWorker' in navigator)) throw new Error('This browser needs service worker support.');
14
- const workerUrl = new URL(workerPath, window.location.href).href;
15
- let timer: ReturnType<typeof setTimeout> | undefined;
16
- let finished = false;
17
- let stopListening = (): void => {};
18
- try {
19
- await Promise.race([
20
- (async () => {
21
- await navigator.serviceWorker.register(workerUrl, { type: 'module', scope: import.meta.env.BASE_URL });
22
- if (finished) throw new Error('Service worker startup wait ended.');
23
- await navigator.serviceWorker.ready;
24
- if (finished) throw new Error('Service worker startup wait ended.');
25
- // A ready registration does not guarantee that our worker controls this page.
26
- await new Promise<void>((resolve) => {
27
- const resolveWhenControlled = (): void => {
28
- if (navigator.serviceWorker.controller?.scriptURL === workerUrl) resolve();
29
- };
30
- navigator.serviceWorker.addEventListener('controllerchange', resolveWhenControlled);
31
- stopListening = () => navigator.serviceWorker.removeEventListener('controllerchange', resolveWhenControlled);
32
- resolveWhenControlled();
33
- });
34
- })(),
35
- new Promise<never>((_resolve, reject) => {
36
- timer = setTimeout(() => reject(new Error('The app service worker did not become ready. Reload to try again.')), 20_000);
37
- }),
38
- ]);
39
- activatePolyfills({ serviceWorker: false });
40
- } finally {
41
- finished = true;
42
- clearTimeout(timer);
43
- stopListening();
44
- }
45
- }
46
-
47
- void prepareBrowser().then(() => {
11
+ void prepareBrowser(workerPath, {
12
+ registrationOptions: { type: 'module', scope: import.meta.env.BASE_URL },
13
+ }).then(() => {
48
14
  createRoot(document.getElementById('root')!).render(<StrictMode><EnboxProvider config={config}><App /></EnboxProvider></StrictMode>);
49
15
  }).catch((cause: unknown) => {
50
16
  const root = document.getElementById('root')!;