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