@enbox/create-dapp 0.0.1 → 0.0.3
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 +8 -3
- package/bin/create-dapp.mjs +10 -7
- package/package.json +1 -2
- package/template/README.md +75 -25
- package/template/package.json +3 -3
- package/template/src/{App.tsx → app/App.tsx} +4 -4
- package/template/src/app/application.ts +7 -0
- package/template/src/app/runtime.ts +17 -0
- package/template/src/features/tasks/components/TaskBoard.tsx +163 -0
- package/template/src/features/tasks/components/tasks.css +15 -0
- package/template/src/features/tasks/protocol.ts +26 -0
- package/template/src/features/tasks/store.ts +31 -0
- package/template/src/main.tsx +7 -41
- package/template/package-lock.json +0 -3211
- package/template/src/application.ts +0 -10
- package/template/src/demo/Demo.tsx +0 -48
- package/template/src/demo/demo.css +0 -17
- package/template/src/demo/protocol.ts +0 -28
- /package/template/src/{ProfileCard.tsx → shared/components/ProfileCard.tsx} +0 -0
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
|
-
|
|
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
|
|
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
|
|
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
|
package/bin/create-dapp.mjs
CHANGED
|
@@ -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: {
|
|
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
|
|
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.
|
|
3
|
+
"version": "0.0.3",
|
|
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",
|
package/template/README.md
CHANGED
|
@@ -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
|
|
4
|
-
private
|
|
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
|
|
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.
|
|
17
|
-
|
|
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,17 +30,41 @@ 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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
|
|
37
41
|
`src/app.json` contains the display name used in the page, browser title, and
|
|
38
42
|
wallet approval screen, plus a stable app ID. Changing the display name leaves
|
|
39
|
-
the app ID and
|
|
40
|
-
|
|
41
|
-
|
|
43
|
+
the app ID and task-board data namespace intact.
|
|
44
|
+
|
|
45
|
+
The application follows the same feature-store structure as notesd:
|
|
46
|
+
|
|
47
|
+
```text
|
|
48
|
+
src/
|
|
49
|
+
app/
|
|
50
|
+
application.ts # feature registration and wallet identity
|
|
51
|
+
runtime.ts # one browser-lifetime application owner
|
|
52
|
+
App.tsx # connection shell and feature composition
|
|
53
|
+
features/tasks/
|
|
54
|
+
protocol.ts # typed encrypted records and validation
|
|
55
|
+
store.ts # shared resources, commands, and domain defaults
|
|
56
|
+
components/ # task board and its styles
|
|
57
|
+
shared/components/
|
|
58
|
+
ProfileCard.tsx
|
|
59
|
+
main.tsx
|
|
60
|
+
sw.ts
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
`defineApplication` derives the protocol manifest from registered stores.
|
|
64
|
+
`getApplication()` creates one headless runtime over a native connection.
|
|
65
|
+
`main.tsx` waits for worker control before passing that runtime to
|
|
66
|
+
`EnboxProvider application={...}`. The provider initializes the borrowed runtime;
|
|
67
|
+
the application owns its lifetime. Development module replacement disposes it.
|
|
42
68
|
|
|
43
69
|
The profile card reads published profile information through `useProfile`.
|
|
44
70
|
Names, images, settlement state, and image URL lifetimes belong to the SDK.
|
|
@@ -46,25 +72,49 @@ Missing profiles and unavailable images have local display fallbacks.
|
|
|
46
72
|
The wallet's profile label is used when a public display name is unavailable;
|
|
47
73
|
the expanded details still show which name is publicly published.
|
|
48
74
|
|
|
49
|
-
The
|
|
75
|
+
The replaceable task feature lives in `src/features/tasks`:
|
|
76
|
+
|
|
77
|
+
- `protocol.ts` declares encrypted `project` and `project/task` records with
|
|
78
|
+
payload validators. Tasks store a title and completion state.
|
|
79
|
+
- `store.ts` declares one shared project collection and a task collection scoped
|
|
80
|
+
with `tasks.within(projectContextId)`. Creation and rename defaults live here.
|
|
81
|
+
Native resource commands share pending/error state across consumers.
|
|
82
|
+
- `components/TaskBoard.tsx` resolves the feature with `useFeature(TaskBoardStore)`
|
|
83
|
+
and reads resources or commands with `useStore`. Task details reuse loaded list
|
|
84
|
+
rows and acquire a bounded single-record observation for a selection outside
|
|
85
|
+
the retained window. Input drafts and UI selection remain in React.
|
|
86
|
+
Awaited handlers check command authorization before applying their UI result.
|
|
87
|
+
- `components/tasks.css` styles the task feature. Shared app and profile styles
|
|
88
|
+
live in `src/style.css`.
|
|
89
|
+
|
|
90
|
+
Application controllers use the same store instance as React:
|
|
91
|
+
|
|
92
|
+
```ts
|
|
93
|
+
const board = getApplication().store(TaskBoardStore);
|
|
94
|
+
const release = board.projects.retain();
|
|
95
|
+
try {
|
|
96
|
+
const state = await board.projects.ready();
|
|
97
|
+
// Use state.records in a controller or indexer.
|
|
98
|
+
} finally {
|
|
99
|
+
release();
|
|
100
|
+
}
|
|
101
|
+
```
|
|
50
102
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
`useEnboxMutation` and the typed `records.set` API.
|
|
54
|
-
- `demo.css` styles the demo. Shared app and profile styles live in `src/style.css`.
|
|
103
|
+
Handles belong to their authorization session. Resolve a fresh feature when
|
|
104
|
+
that session changes. React's `useFeature` handles that replacement automatically.
|
|
55
105
|
|
|
56
|
-
To replace the
|
|
57
|
-
`
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
106
|
+
To replace the task feature, register your own store in `app/application.ts`,
|
|
107
|
+
delete `src/features/tasks`, and change the feature rendered by `app/App.tsx`.
|
|
108
|
+
Enbox requires at least one application protocol, so keep your replacement
|
|
109
|
+
registered. The profile card, connection shell, and service worker remain the
|
|
110
|
+
base for your app.
|
|
61
111
|
|
|
62
112
|
The demo's default protocol URI is
|
|
63
|
-
`https://example.com/<encoded app ID>/demo/
|
|
64
|
-
`src/
|
|
113
|
+
`https://example.com/<encoded app ID>/demo/v2`. Replace the URI expression in
|
|
114
|
+
`src/features/tasks/protocol.ts` with a stable URI
|
|
65
115
|
you control before publishing. This URI identifies saved records and wallet
|
|
66
116
|
grants: changing it creates a different data namespace. Once deployed, keep
|
|
67
117
|
that identifier fixed when changing the app's display name.
|
|
68
118
|
|
|
69
|
-
Public profile information and the encrypted
|
|
70
|
-
|
|
119
|
+
Public profile information and the encrypted task board are separate. Task
|
|
120
|
+
updates change this app’s private records; they do not edit the public profile.
|
package/template/package.json
CHANGED
|
@@ -6,12 +6,12 @@
|
|
|
6
6
|
"scripts": {
|
|
7
7
|
"dev": "vite",
|
|
8
8
|
"typecheck": "tsc --noEmit",
|
|
9
|
-
"build": "
|
|
9
|
+
"build": "tsc --noEmit && vite build",
|
|
10
10
|
"preview": "vite preview"
|
|
11
11
|
},
|
|
12
12
|
"dependencies": {
|
|
13
|
-
"@enbox/browser": "0.3.
|
|
14
|
-
"@enbox/react": "0.0.
|
|
13
|
+
"@enbox/browser": "0.3.83",
|
|
14
|
+
"@enbox/react": "0.0.8",
|
|
15
15
|
"react": "19.3.0",
|
|
16
16
|
"react-dom": "19.3.0"
|
|
17
17
|
},
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { useEnboxConnection } from '@enbox/react';
|
|
2
2
|
import { useEffect } from 'react';
|
|
3
3
|
|
|
4
|
-
import app from '
|
|
5
|
-
import {
|
|
6
|
-
import { ProfileCard } from '
|
|
4
|
+
import app from '../app.json' with { type: 'json' };
|
|
5
|
+
import { TaskBoard } from '../features/tasks/components/TaskBoard.js';
|
|
6
|
+
import { ProfileCard } from '../shared/components/ProfileCard.js';
|
|
7
7
|
|
|
8
8
|
export function App() {
|
|
9
9
|
const connection = useEnboxConnection();
|
|
@@ -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
|
-
<
|
|
56
|
+
<TaskBoard disabled={busy || connection.walletRepair !== undefined} />
|
|
57
57
|
</div>
|
|
58
58
|
) : (
|
|
59
59
|
<section className="panel connect-panel" aria-labelledby="connect-title">
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { defineApplication } from '@enbox/browser';
|
|
2
|
+
|
|
3
|
+
import app from '../app.json' with { type: 'json' };
|
|
4
|
+
import { TaskBoardStore } from '../features/tasks/store.js';
|
|
5
|
+
|
|
6
|
+
export const applicationDefinition = defineApplication({ stores: { tasks: TaskBoardStore } });
|
|
7
|
+
export const wallet = { appName: app.name, applicationId: app.id };
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { BrowserConnectHandler, createApplication, createConnectionStore } from '@enbox/browser';
|
|
2
|
+
|
|
3
|
+
import { applicationDefinition, wallet } from './application.js';
|
|
4
|
+
|
|
5
|
+
let runtime: ReturnType<typeof createApplication> | undefined;
|
|
6
|
+
|
|
7
|
+
/** One browser-lifetime owner shared by React and application controllers. */
|
|
8
|
+
export function getApplication() {
|
|
9
|
+
return runtime ??= createApplication(applicationDefinition, {
|
|
10
|
+
connection: createConnectionStore({
|
|
11
|
+
application: applicationDefinition.manifest,
|
|
12
|
+
connectHandler: BrowserConnectHandler(wallet),
|
|
13
|
+
}),
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
if (import.meta.hot) import.meta.hot.dispose(() => { void runtime?.dispose(); });
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { useFeature, useStore } from '@enbox/react';
|
|
2
|
+
import { useState } from 'react';
|
|
3
|
+
import type { FormEvent } from 'react';
|
|
4
|
+
|
|
5
|
+
import { TaskBoardStore } from '../store.js';
|
|
6
|
+
import './tasks.css';
|
|
7
|
+
|
|
8
|
+
export function TaskBoard({ disabled = false }: { disabled?: boolean }) {
|
|
9
|
+
const board = useFeature(TaskBoardStore);
|
|
10
|
+
const projects = useStore(board.projects);
|
|
11
|
+
const creation = useStore(board.createProject);
|
|
12
|
+
const paging = useStore(board.moreProjects);
|
|
13
|
+
const [projectContextId, setProjectContextId] = useState<string>();
|
|
14
|
+
const [name, setName] = useState('');
|
|
15
|
+
const creating = creation.pendingCount > 0;
|
|
16
|
+
const prompt = projects.status === 'loading' ? 'Loading projects…'
|
|
17
|
+
: projects.status === 'ready' && projects.records.length === 0 ? 'Create your first project to get started.'
|
|
18
|
+
: 'Select a project to see its tasks.';
|
|
19
|
+
async function addProject(event: FormEvent) {
|
|
20
|
+
event.preventDefault();
|
|
21
|
+
try {
|
|
22
|
+
const record = await board.createProject.run(name);
|
|
23
|
+
if (board.createProject.isCurrent()) { setProjectContextId(record.contextId); setName(''); }
|
|
24
|
+
} catch { /* The command exposes the error below. */ }
|
|
25
|
+
}
|
|
26
|
+
return (
|
|
27
|
+
<section className="panel demo" aria-labelledby="demo-title">
|
|
28
|
+
<h2 id="demo-title">Your projects</h2>
|
|
29
|
+
<p>A private task board. Create a project, add tasks, and make a little progress.</p>
|
|
30
|
+
<form className="demo-form" onSubmit={addProject}>
|
|
31
|
+
<label htmlFor="project-name">Project name</label>
|
|
32
|
+
<input id="project-name" value={name} maxLength={60} required
|
|
33
|
+
disabled={disabled || creating} onChange={(event) => setName(event.target.value)} />
|
|
34
|
+
<button className="button button-secondary" disabled={disabled || creating || !name.trim()}>
|
|
35
|
+
{creating ? 'Creating…' : 'Create project'}
|
|
36
|
+
</button>
|
|
37
|
+
</form>
|
|
38
|
+
{creation.error && <p role="alert">Couldn’t create the project: {creation.error.message}</p>}
|
|
39
|
+
{projects.status === 'error' && <div role="alert">
|
|
40
|
+
<p>Couldn’t load projects: {projects.error.message}</p>
|
|
41
|
+
<button type="button" disabled={disabled} onClick={() => { void board.retryProjects.run(); }}>Try again</button>
|
|
42
|
+
</div>}
|
|
43
|
+
<div className="project-picker" role="group" aria-label="Projects">
|
|
44
|
+
{projects.records.map(({ record, value }) => <button type="button" className="button button-quiet" key={record.id} disabled={disabled}
|
|
45
|
+
aria-pressed={record.contextId === projectContextId} onClick={() => setProjectContextId(record.contextId)}>{value.name}</button>)}
|
|
46
|
+
</div>
|
|
47
|
+
{projects.hasMore && <button type="button" disabled={disabled || paging.pendingCount > 0}
|
|
48
|
+
onClick={() => { void board.moreProjects.run(); }}>More projects</button>}
|
|
49
|
+
{projectContextId !== undefined
|
|
50
|
+
? <ProjectTasks key={projectContextId} within={projectContextId} disabled={disabled} />
|
|
51
|
+
: <p role="status">{prompt}</p>}
|
|
52
|
+
</section>
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function ProjectTasks({ within, disabled }: { within: string; disabled: boolean }) {
|
|
57
|
+
const board = useFeature(TaskBoardStore);
|
|
58
|
+
const resource = board.tasks.within(within);
|
|
59
|
+
const tasks = useStore(resource);
|
|
60
|
+
const creation = useStore(resource.create);
|
|
61
|
+
const updates = useStore(resource.patch);
|
|
62
|
+
const paging = useStore(board.moreTasks);
|
|
63
|
+
const [title, setTitle] = useState('');
|
|
64
|
+
// Reopening the same record creates a new UI target for completion guards.
|
|
65
|
+
const [selection, setSelection] = useState<{ id: string }>();
|
|
66
|
+
const creating = creation.pendingCount > 0;
|
|
67
|
+
async function addTask(event: FormEvent) {
|
|
68
|
+
event.preventDefault();
|
|
69
|
+
try {
|
|
70
|
+
const record = await board.createTask({ within, title });
|
|
71
|
+
if (resource.create.isCurrent()) { setSelection({ id: record.id }); setTitle(''); }
|
|
72
|
+
} catch { /* The scoped create command exposes the error below. */ }
|
|
73
|
+
}
|
|
74
|
+
return <div className="project-content">
|
|
75
|
+
<h3>Tasks</h3>
|
|
76
|
+
<form className="demo-form" onSubmit={addTask}>
|
|
77
|
+
<label htmlFor="task-title">New task</label>
|
|
78
|
+
<input id="task-title" value={title} maxLength={120} required
|
|
79
|
+
disabled={disabled || creating} onChange={(event) => setTitle(event.target.value)} />
|
|
80
|
+
<button className="button button-secondary" disabled={disabled || creating || !title.trim()}>Add task</button>
|
|
81
|
+
</form>
|
|
82
|
+
{creation.error && <p role="alert">Couldn’t add the task: {creation.error.message}</p>}
|
|
83
|
+
{updates.error && <p role="alert">Couldn’t update the task: {updates.error.message}</p>}
|
|
84
|
+
{tasks.status === 'error' && <p role="alert">Couldn’t load tasks. <button type="button"
|
|
85
|
+
disabled={disabled} onClick={() => { void board.retryTasks.run(within); }}>Retry tasks</button></p>}
|
|
86
|
+
<ul className="task-list">
|
|
87
|
+
{tasks.records.map(({ record, value }) => <li key={record.id} data-completed={value.completed}>
|
|
88
|
+
<input type="checkbox" aria-label={`Complete ${value.title}`} checked={value.completed}
|
|
89
|
+
disabled={disabled || updates.pendingVariables.some((input) => input.id === record.id)}
|
|
90
|
+
onChange={(event) => { void resource.patch.run({ id: record.id, patch: { completed: event.currentTarget.checked } }); }} />
|
|
91
|
+
<button type="button" className="button button-quiet" aria-label={`Open task ${value.title}`}
|
|
92
|
+
disabled={disabled} aria-pressed={record.id === selection?.id} onClick={() => setSelection({ id: record.id })}>{value.title}</button>
|
|
93
|
+
</li>)}
|
|
94
|
+
</ul>
|
|
95
|
+
{tasks.hasMore && <button type="button" disabled={disabled || paging.pendingVariables.includes(within)}
|
|
96
|
+
onClick={() => { void board.moreTasks.run(within); }}>More tasks</button>}
|
|
97
|
+
{selection !== undefined
|
|
98
|
+
? <TaskDetails key={selection.id} within={within} recordId={selection.id} disabled={disabled}
|
|
99
|
+
onClose={() => setSelection((current) => current === selection ? undefined : current)} />
|
|
100
|
+
: <p role="status">Select a task to see its details.</p>}
|
|
101
|
+
</div>;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function TaskDetails({ within, recordId, disabled, onClose }: {
|
|
105
|
+
within: string; recordId: string; disabled: boolean; onClose: () => void;
|
|
106
|
+
}) {
|
|
107
|
+
const board = useFeature(TaskBoardStore);
|
|
108
|
+
const resource = board.tasks.within(within);
|
|
109
|
+
const list = useStore(resource);
|
|
110
|
+
const listed = list.records.some((row) => row.record.id === recordId);
|
|
111
|
+
// A bounded window cannot prove an unlisted record is missing. Reuse loaded
|
|
112
|
+
// rows, and acquire a single-record view only while that proof is unavailable.
|
|
113
|
+
const needsLookup = !listed && (list.status !== 'ready' || list.hasMore);
|
|
114
|
+
const selection = useStore(needsLookup ? board.taskById(within, recordId) : resource);
|
|
115
|
+
const task = selection.records.find((row) => row.record.id === recordId);
|
|
116
|
+
const status = selection.status;
|
|
117
|
+
const updates = useStore(resource.patch);
|
|
118
|
+
const deletion = useStore(resource.delete);
|
|
119
|
+
const [draft, setDraft] = useState<string>();
|
|
120
|
+
const pending = updates.pendingVariables.some((input) => input.id === recordId) || deletion.pendingVariables.includes(recordId);
|
|
121
|
+
async function saveTitle(event: FormEvent) {
|
|
122
|
+
event.preventDefault();
|
|
123
|
+
if (draft === undefined) return;
|
|
124
|
+
try {
|
|
125
|
+
await board.renameTask({ within, id: recordId, title: draft });
|
|
126
|
+
if (resource.patch.isCurrent()) setDraft(undefined);
|
|
127
|
+
} catch { /* The scoped patch command exposes the error below. */ }
|
|
128
|
+
}
|
|
129
|
+
async function deleteTask() {
|
|
130
|
+
try {
|
|
131
|
+
await resource.delete.run(recordId);
|
|
132
|
+
if (resource.delete.isCurrent()) onClose();
|
|
133
|
+
} catch { /* The scoped delete command exposes the error below. */ }
|
|
134
|
+
}
|
|
135
|
+
const data = task?.value;
|
|
136
|
+
return <section className="task-details" aria-label="Task details">
|
|
137
|
+
<h3>Task details</h3>
|
|
138
|
+
{status === 'error' && <p role="alert">Couldn’t load the task. <button type="button"
|
|
139
|
+
disabled={disabled} onClick={() => {
|
|
140
|
+
if (needsLookup) void board.retryTask.run({ within, recordId });
|
|
141
|
+
else void board.retryTasks.run(within);
|
|
142
|
+
}}>Retry task</button></p>}
|
|
143
|
+
{data === undefined && status === 'loading' && <p role="status">Loading task…</p>}
|
|
144
|
+
{data === undefined && status === 'ready' && <p role="status">This task is no longer available.</p>}
|
|
145
|
+
{data !== undefined && <>
|
|
146
|
+
<p>{data.title} · {data.completed ? 'Completed' : 'To do'}</p>
|
|
147
|
+
{draft === undefined
|
|
148
|
+
? <button type="button" className="button button-quiet" disabled={disabled || pending}
|
|
149
|
+
onClick={() => setDraft(data.title)}>Edit title</button>
|
|
150
|
+
: <form className="demo-form" onSubmit={saveTitle}>
|
|
151
|
+
<label htmlFor="edit-task-title">Task title</label>
|
|
152
|
+
<input id="edit-task-title" value={draft} maxLength={120} required disabled={disabled || pending}
|
|
153
|
+
onChange={(event) => setDraft(event.target.value)} />
|
|
154
|
+
<button className="button button-secondary" disabled={disabled || pending || !draft.trim()}>Save title</button>
|
|
155
|
+
<button type="button" className="button button-quiet" disabled={disabled || pending} onClick={() => setDraft(undefined)}>Cancel</button>
|
|
156
|
+
</form>}
|
|
157
|
+
<button type="button" className="button button-quiet" disabled={disabled || pending} onClick={deleteTask}>Delete task</button>
|
|
158
|
+
</>}
|
|
159
|
+
{updates.error && <p role="alert">Couldn’t update the task: {updates.error.message}</p>}
|
|
160
|
+
{deletion.error && <p role="alert">Couldn’t delete the task: {deletion.error.message}</p>}
|
|
161
|
+
<button type="button" className="button button-quiet" onClick={onClose}>Close details</button>
|
|
162
|
+
</section>;
|
|
163
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
.demo { padding: 30px; }
|
|
2
|
+
.demo > p { margin-top: 12px; color: var(--muted); font-size: .85rem; }
|
|
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; }
|
|
13
|
+
@media (max-width: 600px) {
|
|
14
|
+
.demo { padding: 26px 20px; }
|
|
15
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { defineProtocol, recordCodecs } from '@enbox/browser';
|
|
2
|
+
|
|
3
|
+
import app from '../../app.json' with { type: 'json' };
|
|
4
|
+
|
|
5
|
+
export type Project = { name: string };
|
|
6
|
+
export type Task = { title: string; completed: boolean };
|
|
7
|
+
|
|
8
|
+
// Replace this example URI with one you own before publishing your app.
|
|
9
|
+
// Keep it stable after launch: it identifies the app's saved data and grants.
|
|
10
|
+
export const TaskBoardProtocol = defineProtocol({
|
|
11
|
+
protocol: `https://example.com/${encodeURIComponent(app.id)}/demo/v2`,
|
|
12
|
+
published: false,
|
|
13
|
+
types: {
|
|
14
|
+
project: { dataFormats: ['application/json'], encryptionRequired: true },
|
|
15
|
+
task: { dataFormats: ['application/json'], encryptionRequired: true },
|
|
16
|
+
},
|
|
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,31 @@
|
|
|
1
|
+
import { DateSort, defineStore } from '@enbox/browser';
|
|
2
|
+
|
|
3
|
+
import { TaskBoardProtocol } from './protocol.js';
|
|
4
|
+
|
|
5
|
+
/** Protocol resources and domain defaults are shared with every consumer. */
|
|
6
|
+
export const TaskBoardStore = defineStore({
|
|
7
|
+
protocols: { board: TaskBoardProtocol },
|
|
8
|
+
create({ protocols, command }) {
|
|
9
|
+
const projects = protocols.board.collection('project', { dateSort: DateSort.CreatedDescending });
|
|
10
|
+
const tasks = protocols.board.collection('project/task');
|
|
11
|
+
const taskById = (within: string, recordId: string) => protocols.board.collection('project/task', {
|
|
12
|
+
filter: { recordId }, pagination: { limit: 1 },
|
|
13
|
+
}).within(within);
|
|
14
|
+
|
|
15
|
+
return {
|
|
16
|
+
projects,
|
|
17
|
+
tasks,
|
|
18
|
+
taskById,
|
|
19
|
+
createProject: command((name: string) => projects.create.run({ name: name.trim() })),
|
|
20
|
+
createTask: ({ within, title }: { within: string; title: string }) =>
|
|
21
|
+
tasks.within(within).create.run({ title: title.trim(), completed: false }),
|
|
22
|
+
renameTask: ({ within, id, title }: { within: string; id: string; title: string }) =>
|
|
23
|
+
tasks.within(within).patch.run({ id, patch: { title: title.trim() } }),
|
|
24
|
+
moreProjects: command(() => projects.loadMore()),
|
|
25
|
+
moreTasks: command((within: string) => tasks.within(within).loadMore()),
|
|
26
|
+
retryProjects: command(() => projects.retry()),
|
|
27
|
+
retryTasks: command((within: string) => tasks.within(within).retry()),
|
|
28
|
+
retryTask: command(({ within, recordId }: { within: string; recordId: string }) => taskById(within, recordId).retry()),
|
|
29
|
+
};
|
|
30
|
+
},
|
|
31
|
+
});
|
package/template/src/main.tsx
CHANGED
|
@@ -1,51 +1,17 @@
|
|
|
1
|
-
import {
|
|
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';
|
|
5
5
|
|
|
6
|
-
import { App } from './App.js';
|
|
7
|
-
import {
|
|
6
|
+
import { App } from './app/App.js';
|
|
7
|
+
import { getApplication } from './app/runtime.js';
|
|
8
8
|
import workerPath from './sw.ts?worker&url';
|
|
9
9
|
import './style.css';
|
|
10
10
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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(() => {
|
|
48
|
-
createRoot(document.getElementById('root')!).render(<StrictMode><EnboxProvider config={config}><App /></EnboxProvider></StrictMode>);
|
|
11
|
+
void prepareBrowser(workerPath, {
|
|
12
|
+
registrationOptions: { type: 'module', scope: import.meta.env.BASE_URL },
|
|
13
|
+
}).then(() => {
|
|
14
|
+
createRoot(document.getElementById('root')!).render(<StrictMode><EnboxProvider application={getApplication()}><App /></EnboxProvider></StrictMode>);
|
|
49
15
|
}).catch((cause: unknown) => {
|
|
50
16
|
const root = document.getElementById('root')!;
|
|
51
17
|
root.textContent = cause instanceof Error ? cause.message : 'Could not open the app.';
|