create-coline-app 2.3.0 → 2.5.0
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/AGENTS.md +26 -2
- package/README.md +51 -4
- package/assets/preview-host.ts +130 -0
- package/bin/_run.mjs +11 -2
- package/package.json +8 -4
- package/src/check.ts +46 -0
- package/src/cli.test.ts +112 -6
- package/src/cli.ts +12 -1
- package/src/client-build.ts +50 -0
- package/src/dev.ts +6 -5
- package/src/preview-capture.ts +101 -0
- package/src/preview.ts +189 -0
- package/src/push.ts +8 -47
- package/src/scaffold.ts +6 -0
- package/templates/backendless/.agents/skills/coline-app-development/SKILL.md +30 -64
- package/templates/backendless/.agents/skills/coline-app-development/references/live-records.md +53 -0
- package/templates/backendless/.agents/skills/coline-ui-design/SKILL.md +67 -0
- package/templates/backendless/.agents/skills/coline-ui-design/assets/board.png +0 -0
- package/templates/backendless/.agents/skills/coline-ui-design/assets/settings.png +0 -0
- package/templates/backendless/.agents/skills/coline-ui-design/assets/triage.png +0 -0
- package/templates/backendless/.agents/skills/coline-ui-review/SKILL.md +39 -0
- package/templates/backendless/AGENTS.md +70 -158
- package/templates/backendless/CLAUDE.md +1 -6
- package/templates/backendless/README.md +22 -29
- package/templates/backendless/app.config.ts +12 -8
- package/templates/backendless/app.css +84 -0
- package/templates/backendless/examples/board.tsx +173 -0
- package/templates/backendless/examples/settings.tsx +117 -0
- package/templates/backendless/examples/triage.tsx +253 -0
- package/templates/backendless/main.tsx +198 -66
- package/templates/backendless/package.json +10 -5
- package/templates/backendless/preview.seed.ts +26 -0
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import {
|
|
3
|
+
AppPage,
|
|
4
|
+
SettingsSection,
|
|
5
|
+
Input,
|
|
6
|
+
Textarea,
|
|
7
|
+
Button,
|
|
8
|
+
AppLoadingState,
|
|
9
|
+
AppErrorState,
|
|
10
|
+
} from "@colineapp/ui";
|
|
11
|
+
export function Example({ state = "ready" }: { state?: string }) {
|
|
12
|
+
const initial = {
|
|
13
|
+
name: state === "empty" ? "" : "Studio journal",
|
|
14
|
+
description: state === "empty" ? "" : "Stories about thoughtful work and the people behind it.",
|
|
15
|
+
digest: true,
|
|
16
|
+
cadence: "Weekly",
|
|
17
|
+
};
|
|
18
|
+
const [form, setForm] = React.useState(initial);
|
|
19
|
+
const [saved, setSaved] = React.useState(initial);
|
|
20
|
+
const [notice, setNotice] = React.useState("");
|
|
21
|
+
const [failure, setFailure] = React.useState(state === "error" || state === "denied");
|
|
22
|
+
const dirty = JSON.stringify(form) !== JSON.stringify(saved);
|
|
23
|
+
return (
|
|
24
|
+
<AppPage
|
|
25
|
+
title="Journal settings"
|
|
26
|
+
eyebrow="Studio journal"
|
|
27
|
+
width="reading"
|
|
28
|
+
description="The essentials for your publication and the people following it."
|
|
29
|
+
>
|
|
30
|
+
{state === "loading" ? (
|
|
31
|
+
<AppLoadingState label="Loading settings" />
|
|
32
|
+
) : failure ? (
|
|
33
|
+
<AppErrorState
|
|
34
|
+
message={
|
|
35
|
+
state === "denied"
|
|
36
|
+
? "Only editors can change journal settings."
|
|
37
|
+
: "Settings couldn’t be loaded."
|
|
38
|
+
}
|
|
39
|
+
onRetry={state === "denied" ? undefined : () => setFailure(false)}
|
|
40
|
+
/>
|
|
41
|
+
) : (
|
|
42
|
+
<form
|
|
43
|
+
onSubmit={(event) => {
|
|
44
|
+
event.preventDefault();
|
|
45
|
+
setSaved({ ...form });
|
|
46
|
+
setNotice("Settings saved for this preview session.");
|
|
47
|
+
}}
|
|
48
|
+
>
|
|
49
|
+
<SettingsSection
|
|
50
|
+
title="Publication"
|
|
51
|
+
description="A clear name and description help readers know what to expect."
|
|
52
|
+
>
|
|
53
|
+
<label className="example-field">
|
|
54
|
+
Name
|
|
55
|
+
<Input
|
|
56
|
+
required
|
|
57
|
+
value={form.name}
|
|
58
|
+
onChange={(event) => setForm({ ...form, name: event.target.value })}
|
|
59
|
+
/>
|
|
60
|
+
</label>
|
|
61
|
+
<label className="example-field">
|
|
62
|
+
Description
|
|
63
|
+
<Textarea
|
|
64
|
+
value={form.description}
|
|
65
|
+
onChange={(event) => setForm({ ...form, description: event.target.value })}
|
|
66
|
+
/>
|
|
67
|
+
</label>
|
|
68
|
+
</SettingsSection>
|
|
69
|
+
<SettingsSection
|
|
70
|
+
title="Reader updates"
|
|
71
|
+
description="Set a comfortable rhythm. Readers can always manage their own subscription."
|
|
72
|
+
>
|
|
73
|
+
<label className="example-toggle">
|
|
74
|
+
<input
|
|
75
|
+
type="checkbox"
|
|
76
|
+
checked={form.digest}
|
|
77
|
+
onChange={(event) => setForm({ ...form, digest: event.target.checked })}
|
|
78
|
+
/>
|
|
79
|
+
Send a journal digest
|
|
80
|
+
</label>
|
|
81
|
+
<label className="example-field">
|
|
82
|
+
Frequency
|
|
83
|
+
<select
|
|
84
|
+
className="example-select"
|
|
85
|
+
disabled={!form.digest}
|
|
86
|
+
value={form.cadence}
|
|
87
|
+
onChange={(event) => setForm({ ...form, cadence: event.target.value })}
|
|
88
|
+
>
|
|
89
|
+
<option>Weekly</option>
|
|
90
|
+
<option>Monthly</option>
|
|
91
|
+
</select>
|
|
92
|
+
</label>
|
|
93
|
+
</SettingsSection>
|
|
94
|
+
<div className="flex items-center justify-end gap-3 py-6">
|
|
95
|
+
<span role="status" className="example-meta">
|
|
96
|
+
{dirty ? "Unsaved changes" : notice}
|
|
97
|
+
</span>
|
|
98
|
+
<Button
|
|
99
|
+
type="button"
|
|
100
|
+
variant="outline"
|
|
101
|
+
disabled={!dirty}
|
|
102
|
+
onClick={() => {
|
|
103
|
+
setForm(saved);
|
|
104
|
+
setNotice("");
|
|
105
|
+
}}
|
|
106
|
+
>
|
|
107
|
+
Discard
|
|
108
|
+
</Button>
|
|
109
|
+
<Button type="submit" disabled={!dirty}>
|
|
110
|
+
Save changes
|
|
111
|
+
</Button>
|
|
112
|
+
</div>
|
|
113
|
+
</form>
|
|
114
|
+
)}
|
|
115
|
+
</AppPage>
|
|
116
|
+
);
|
|
117
|
+
}
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import {
|
|
3
|
+
AppPage,
|
|
4
|
+
PageToolbar,
|
|
5
|
+
Input,
|
|
6
|
+
Button,
|
|
7
|
+
RecordTable,
|
|
8
|
+
SplitView,
|
|
9
|
+
DetailPanel,
|
|
10
|
+
StatusBadge,
|
|
11
|
+
EmptyState,
|
|
12
|
+
AppErrorState,
|
|
13
|
+
AppLoadingState,
|
|
14
|
+
} from "@colineapp/ui";
|
|
15
|
+
|
|
16
|
+
const initial = [
|
|
17
|
+
{
|
|
18
|
+
id: "REQ-108",
|
|
19
|
+
title: "Invite links expire before the team joins",
|
|
20
|
+
team: "Juniper",
|
|
21
|
+
category: "Access",
|
|
22
|
+
status: "Needs review",
|
|
23
|
+
detail:
|
|
24
|
+
"Our contractors often join a few days after an invitation. Could we extend the window or resend an invitation from the same screen?",
|
|
25
|
+
date: "Today",
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
id: "REQ-107",
|
|
29
|
+
title: "Export a filtered view",
|
|
30
|
+
team: "Forma",
|
|
31
|
+
category: "Workflow",
|
|
32
|
+
status: "In progress",
|
|
33
|
+
detail:
|
|
34
|
+
"We share a weekly report with partners. It would help to export just the rows in the current view, with the same column order.",
|
|
35
|
+
date: "Today",
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
id: "REQ-106",
|
|
39
|
+
title: "Remember my sidebar width",
|
|
40
|
+
team: "Juniper",
|
|
41
|
+
category: "Experience",
|
|
42
|
+
status: "Needs review",
|
|
43
|
+
detail:
|
|
44
|
+
"The sidebar resets every morning. Keep the width I chose so longer project names stay readable.",
|
|
45
|
+
date: "Yesterday",
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
id: "REQ-105",
|
|
49
|
+
title: "Include context in email notifications",
|
|
50
|
+
team: "Acorn",
|
|
51
|
+
category: "Notifications",
|
|
52
|
+
status: "Planned",
|
|
53
|
+
detail:
|
|
54
|
+
"Show a short excerpt with each notification so we know whether a reply is urgent before opening the app.",
|
|
55
|
+
date: "Yesterday",
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
id: "REQ-104",
|
|
59
|
+
title: "Bulk archive completed requests",
|
|
60
|
+
team: "Forma",
|
|
61
|
+
category: "Workflow",
|
|
62
|
+
status: "Needs review",
|
|
63
|
+
detail: "Let us select completed requests and archive them together at the end of the week.",
|
|
64
|
+
date: "Sep 3",
|
|
65
|
+
},
|
|
66
|
+
];
|
|
67
|
+
export function Example({ state = "ready" }: { state?: string }) {
|
|
68
|
+
const [records, setRecords] = React.useState(state === "empty" ? [] : initial);
|
|
69
|
+
const [selected, setSelected] = React.useState<string | null>(
|
|
70
|
+
state === "ready" ? initial[0]!.id : null,
|
|
71
|
+
);
|
|
72
|
+
const [search, setSearch] = React.useState("");
|
|
73
|
+
const [status, setStatus] = React.useState("All requests");
|
|
74
|
+
const [failure, setFailure] = React.useState(state === "error" || state === "denied");
|
|
75
|
+
const detail = records.find((record) => record.id === selected);
|
|
76
|
+
const visible = records.filter(
|
|
77
|
+
(record) =>
|
|
78
|
+
`${record.title} ${record.team}`.toLowerCase().includes(search.toLowerCase()) &&
|
|
79
|
+
(status === "All requests" || record.status === status),
|
|
80
|
+
);
|
|
81
|
+
const panel = React.useRef<HTMLDivElement>(null);
|
|
82
|
+
const opener = React.useRef<HTMLElement | null>(null);
|
|
83
|
+
function close() {
|
|
84
|
+
setSelected(null);
|
|
85
|
+
requestAnimationFrame(() => opener.current?.focus());
|
|
86
|
+
}
|
|
87
|
+
function open(id: string) {
|
|
88
|
+
opener.current = document.activeElement instanceof HTMLElement ? document.activeElement : null;
|
|
89
|
+
setSelected(id);
|
|
90
|
+
requestAnimationFrame(() => {
|
|
91
|
+
panel.current?.focus();
|
|
92
|
+
panel.current?.scrollIntoView({ block: "nearest" });
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
function add() {
|
|
96
|
+
const id = `REQ-${109 + records.length}`;
|
|
97
|
+
setRecords((current) => [
|
|
98
|
+
...current,
|
|
99
|
+
{
|
|
100
|
+
id,
|
|
101
|
+
title: "New request",
|
|
102
|
+
team: "Your team",
|
|
103
|
+
category: "Workflow",
|
|
104
|
+
status: "Needs review",
|
|
105
|
+
detail: "Describe the problem, who it affects, and what a good outcome looks like.",
|
|
106
|
+
date: "Today",
|
|
107
|
+
},
|
|
108
|
+
]);
|
|
109
|
+
open(id);
|
|
110
|
+
}
|
|
111
|
+
return (
|
|
112
|
+
<AppPage
|
|
113
|
+
title="Requests"
|
|
114
|
+
eyebrow="Customer feedback"
|
|
115
|
+
description="Understand what matters. Turn the next useful idea into a decision."
|
|
116
|
+
actions={<Button onClick={add}>New request</Button>}
|
|
117
|
+
>
|
|
118
|
+
<PageToolbar>
|
|
119
|
+
<Input
|
|
120
|
+
aria-label="Search requests"
|
|
121
|
+
placeholder="Search requests…"
|
|
122
|
+
className="max-w-xs"
|
|
123
|
+
value={search}
|
|
124
|
+
onChange={(event) => setSearch(event.target.value)}
|
|
125
|
+
/>
|
|
126
|
+
<select
|
|
127
|
+
aria-label="Filter by status"
|
|
128
|
+
className="example-select"
|
|
129
|
+
value={status}
|
|
130
|
+
onChange={(event) => setStatus(event.target.value)}
|
|
131
|
+
>
|
|
132
|
+
{["All requests", "Needs review", "In progress", "Planned"].map((value) => (
|
|
133
|
+
<option key={value}>{value}</option>
|
|
134
|
+
))}
|
|
135
|
+
</select>
|
|
136
|
+
<span className="example-meta">{visible.length} requests</span>
|
|
137
|
+
</PageToolbar>
|
|
138
|
+
{state === "loading" ? (
|
|
139
|
+
<AppLoadingState label="Loading requests" />
|
|
140
|
+
) : failure ? (
|
|
141
|
+
<AppErrorState
|
|
142
|
+
message={
|
|
143
|
+
state === "denied"
|
|
144
|
+
? "You don’t have access to these requests. Ask a workspace owner for access."
|
|
145
|
+
: "Requests couldn’t be loaded. Your changes are safe."
|
|
146
|
+
}
|
|
147
|
+
onRetry={state === "denied" ? undefined : () => setFailure(false)}
|
|
148
|
+
/>
|
|
149
|
+
) : (
|
|
150
|
+
<SplitView
|
|
151
|
+
list={
|
|
152
|
+
<RecordTable
|
|
153
|
+
records={visible}
|
|
154
|
+
rowKey={(record) => record.id}
|
|
155
|
+
onOpen={(record) => open(record.id)}
|
|
156
|
+
label="Customer requests"
|
|
157
|
+
columns={[
|
|
158
|
+
{
|
|
159
|
+
key: "title",
|
|
160
|
+
label: "Request",
|
|
161
|
+
render: (record) => (
|
|
162
|
+
<>
|
|
163
|
+
<span className="example-meta">{record.id}</span>
|
|
164
|
+
<span>{record.title}</span>
|
|
165
|
+
</>
|
|
166
|
+
),
|
|
167
|
+
},
|
|
168
|
+
{ key: "team", label: "Team", render: (record) => record.team, secondary: true },
|
|
169
|
+
{
|
|
170
|
+
key: "status",
|
|
171
|
+
label: "Status",
|
|
172
|
+
render: (record) => (
|
|
173
|
+
<StatusBadge
|
|
174
|
+
tone={
|
|
175
|
+
record.status === "In progress"
|
|
176
|
+
? "info"
|
|
177
|
+
: record.status === "Planned"
|
|
178
|
+
? "success"
|
|
179
|
+
: "warning"
|
|
180
|
+
}
|
|
181
|
+
>
|
|
182
|
+
{record.status}
|
|
183
|
+
</StatusBadge>
|
|
184
|
+
),
|
|
185
|
+
},
|
|
186
|
+
]}
|
|
187
|
+
empty={
|
|
188
|
+
<EmptyState
|
|
189
|
+
title={search ? "No matching requests" : "A clear queue"}
|
|
190
|
+
description="Capture feedback when it arrives. Every request gets one place for context and a decision."
|
|
191
|
+
action={<Button onClick={add}>Add a request</Button>}
|
|
192
|
+
/>
|
|
193
|
+
}
|
|
194
|
+
/>
|
|
195
|
+
}
|
|
196
|
+
detail={
|
|
197
|
+
detail && (
|
|
198
|
+
<div ref={panel} tabIndex={-1}>
|
|
199
|
+
<DetailPanel
|
|
200
|
+
title={detail.title}
|
|
201
|
+
subtitle={detail.id}
|
|
202
|
+
onClose={close}
|
|
203
|
+
actions={
|
|
204
|
+
<Button
|
|
205
|
+
variant="outline"
|
|
206
|
+
onClick={() =>
|
|
207
|
+
setRecords((current) =>
|
|
208
|
+
current.map((record) =>
|
|
209
|
+
record.id === detail.id ? { ...record, status: "Planned" } : record,
|
|
210
|
+
),
|
|
211
|
+
)
|
|
212
|
+
}
|
|
213
|
+
>
|
|
214
|
+
Mark as planned
|
|
215
|
+
</Button>
|
|
216
|
+
}
|
|
217
|
+
>
|
|
218
|
+
<div className="example-meta">
|
|
219
|
+
<span>{detail.team}</span>
|
|
220
|
+
<span>·</span>
|
|
221
|
+
<span>{detail.date}</span>
|
|
222
|
+
</div>
|
|
223
|
+
<p className="example-description">{detail.detail}</p>
|
|
224
|
+
<label className="example-field">
|
|
225
|
+
Status
|
|
226
|
+
<select
|
|
227
|
+
className="example-select"
|
|
228
|
+
value={detail.status}
|
|
229
|
+
onChange={(event) =>
|
|
230
|
+
setRecords((current) =>
|
|
231
|
+
current.map((record) =>
|
|
232
|
+
record.id === detail.id
|
|
233
|
+
? { ...record, status: event.target.value }
|
|
234
|
+
: record,
|
|
235
|
+
),
|
|
236
|
+
)
|
|
237
|
+
}
|
|
238
|
+
>
|
|
239
|
+
{["Needs review", "In progress", "Planned"].map((value) => (
|
|
240
|
+
<option key={value}>{value}</option>
|
|
241
|
+
))}
|
|
242
|
+
</select>
|
|
243
|
+
</label>
|
|
244
|
+
<div className="example-meta">{detail.category}</div>
|
|
245
|
+
</DetailPanel>
|
|
246
|
+
</div>
|
|
247
|
+
)
|
|
248
|
+
}
|
|
249
|
+
/>
|
|
250
|
+
)}
|
|
251
|
+
</AppPage>
|
|
252
|
+
);
|
|
253
|
+
}
|
|
@@ -1,100 +1,232 @@
|
|
|
1
|
+
import * as React from "react";
|
|
1
2
|
import { createRoot } from "react-dom/client";
|
|
2
3
|
import {
|
|
4
|
+
Input,
|
|
5
|
+
Textarea,
|
|
6
|
+
useColineContext,
|
|
3
7
|
Button,
|
|
4
|
-
Card,
|
|
5
|
-
CardContent,
|
|
6
|
-
CardDescription,
|
|
7
|
-
CardHeader,
|
|
8
|
-
CardTitle,
|
|
9
8
|
ColineAppProvider,
|
|
10
|
-
Skeleton,
|
|
11
9
|
useColine,
|
|
12
10
|
useColineQuery,
|
|
11
|
+
AppPage,
|
|
12
|
+
PageToolbar,
|
|
13
|
+
RecordTable,
|
|
14
|
+
EmptyState,
|
|
15
|
+
AppErrorState,
|
|
16
|
+
AppLoadingState,
|
|
17
|
+
StatusBadge,
|
|
13
18
|
} from "@colineapp/ui";
|
|
14
19
|
import type { ColineFileSummaryV2 } from "@colineapp/sdk/v2";
|
|
15
20
|
import "@colineapp/ui/styles.css";
|
|
21
|
+
import "./app.css";
|
|
16
22
|
|
|
17
23
|
function StarterHome() {
|
|
18
24
|
const coline = useColine();
|
|
19
25
|
const files = useColineQuery(
|
|
20
|
-
(capabilities) =>
|
|
21
|
-
capabilities.files.list({ typeKey: "__APP_KEY__.note", limit: 50 }),
|
|
26
|
+
(capabilities) => capabilities.files.list({ typeKey: "__APP_KEY__.note", limit: 50 }),
|
|
22
27
|
[],
|
|
23
28
|
);
|
|
24
|
-
const
|
|
29
|
+
const [search, setSearch] = React.useState("");
|
|
30
|
+
const notes = (files.data?.files ?? []).filter((file) =>
|
|
31
|
+
file.name.toLowerCase().includes(search.toLowerCase()),
|
|
32
|
+
);
|
|
33
|
+
const [creating, setCreating] = React.useState(false);
|
|
34
|
+
const [error, setError] = React.useState<string | null>(null);
|
|
25
35
|
|
|
26
36
|
async function createNote(): Promise<void> {
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
37
|
+
setCreating(true);
|
|
38
|
+
setError(null);
|
|
39
|
+
try {
|
|
40
|
+
const file = await coline.files.create({
|
|
41
|
+
typeKey: "__APP_KEY__.note",
|
|
42
|
+
name: "Untitled note",
|
|
43
|
+
document: { title: "Untitled note", body: "" },
|
|
44
|
+
});
|
|
45
|
+
await coline.navigation.openFile(file.fileId);
|
|
46
|
+
} catch (cause) {
|
|
47
|
+
setError(cause instanceof Error ? cause.message : String(cause));
|
|
48
|
+
} finally {
|
|
49
|
+
setCreating(false);
|
|
50
|
+
}
|
|
33
51
|
}
|
|
34
52
|
|
|
35
53
|
return (
|
|
36
|
-
<
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
</
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
54
|
+
<AppPage
|
|
55
|
+
title="Notes"
|
|
56
|
+
eyebrow="__APP_NAME__"
|
|
57
|
+
description="A shared place for ideas, decisions, and the details worth keeping."
|
|
58
|
+
actions={
|
|
59
|
+
<Button disabled={creating} onClick={() => void createNote()}>
|
|
60
|
+
{creating ? "Creating…" : "New note"}
|
|
61
|
+
</Button>
|
|
62
|
+
}
|
|
63
|
+
>
|
|
64
|
+
<PageToolbar>
|
|
65
|
+
<Input
|
|
66
|
+
aria-label="Search notes"
|
|
67
|
+
placeholder="Search notes…"
|
|
68
|
+
value={search}
|
|
69
|
+
onChange={(event) => setSearch(event.target.value)}
|
|
70
|
+
className="max-w-xs"
|
|
71
|
+
/>
|
|
72
|
+
<span className="text-xs text-muted-foreground">{notes.length} notes</span>
|
|
73
|
+
</PageToolbar>
|
|
74
|
+
{error && <AppErrorState message={error} onRetry={() => void createNote()} />}
|
|
47
75
|
{files.isLoading ? (
|
|
48
|
-
<
|
|
49
|
-
<Skeleton className="h-16 w-full" />
|
|
50
|
-
<Skeleton className="h-16 w-full" />
|
|
51
|
-
</div>
|
|
76
|
+
<AppLoadingState label="Loading notes" />
|
|
52
77
|
) : files.error ? (
|
|
53
|
-
<
|
|
54
|
-
<CardHeader>
|
|
55
|
-
<CardTitle>Couldn't load notes</CardTitle>
|
|
56
|
-
<CardDescription>{files.error}</CardDescription>
|
|
57
|
-
</CardHeader>
|
|
58
|
-
<CardContent>
|
|
59
|
-
<Button variant="outline" onClick={files.refetch}>
|
|
60
|
-
Retry
|
|
61
|
-
</Button>
|
|
62
|
-
</CardContent>
|
|
63
|
-
</Card>
|
|
64
|
-
) : notes.length === 0 ? (
|
|
65
|
-
<Card>
|
|
66
|
-
<CardHeader>
|
|
67
|
-
<CardTitle>Edit me</CardTitle>
|
|
68
|
-
<CardDescription>
|
|
69
|
-
This is a real React surface. Replace this starter with your app,
|
|
70
|
-
then ask Kairo to create a note called Hello.
|
|
71
|
-
</CardDescription>
|
|
72
|
-
</CardHeader>
|
|
73
|
-
</Card>
|
|
78
|
+
<AppErrorState message={files.error} onRetry={files.refetch} />
|
|
74
79
|
) : (
|
|
75
|
-
<
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
<
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
80
|
+
<RecordTable<ColineFileSummaryV2>
|
|
81
|
+
label="Notes"
|
|
82
|
+
records={notes}
|
|
83
|
+
rowKey={(file) => file.fileId}
|
|
84
|
+
onOpen={(file) => void coline.navigation.openFile(file.fileId)}
|
|
85
|
+
columns={[
|
|
86
|
+
{
|
|
87
|
+
key: "name",
|
|
88
|
+
label: "Name",
|
|
89
|
+
render: (file) => <span className="font-medium">{file.name}</span>,
|
|
90
|
+
},
|
|
91
|
+
{
|
|
92
|
+
key: "type",
|
|
93
|
+
label: "Type",
|
|
94
|
+
render: () => <StatusBadge>Note</StatusBadge>,
|
|
95
|
+
secondary: true,
|
|
96
|
+
},
|
|
97
|
+
{
|
|
98
|
+
key: "action",
|
|
99
|
+
label: "",
|
|
100
|
+
render: () => <span className="text-muted-foreground text-xs">Open →</span>,
|
|
101
|
+
align: "end",
|
|
102
|
+
},
|
|
103
|
+
]}
|
|
104
|
+
empty={
|
|
105
|
+
<EmptyState
|
|
106
|
+
title={search ? "No matching notes" : "Your next idea starts here"}
|
|
107
|
+
description={
|
|
108
|
+
search
|
|
109
|
+
? "Try another name or clear your search."
|
|
110
|
+
: "Create a note to capture a decision or share an idea."
|
|
111
|
+
}
|
|
112
|
+
action={
|
|
113
|
+
!search && (
|
|
114
|
+
<Button disabled={creating} onClick={() => void createNote()}>
|
|
115
|
+
Create a note
|
|
116
|
+
</Button>
|
|
117
|
+
)
|
|
118
|
+
}
|
|
119
|
+
/>
|
|
120
|
+
}
|
|
121
|
+
/>
|
|
88
122
|
)}
|
|
89
|
-
</
|
|
123
|
+
</AppPage>
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function NoteEditor() {
|
|
128
|
+
const coline = useColine();
|
|
129
|
+
const context = useColineContext();
|
|
130
|
+
const [title, setTitle] = React.useState(
|
|
131
|
+
String(context.document?.title ?? context.file?.name ?? ""),
|
|
90
132
|
);
|
|
133
|
+
const [body, setBody] = React.useState(String(context.document?.body ?? ""));
|
|
134
|
+
const version = React.useRef(context.file?.version ?? 1);
|
|
135
|
+
const [saved, setSaved] = React.useState({ title, body });
|
|
136
|
+
const [saving, setSaving] = React.useState(false);
|
|
137
|
+
const [error, setError] = React.useState<string | null>(null);
|
|
138
|
+
const dirty = title !== saved.title || body !== saved.body;
|
|
139
|
+
React.useEffect(() => {
|
|
140
|
+
function warn(event: BeforeUnloadEvent) {
|
|
141
|
+
if (dirty) {
|
|
142
|
+
event.preventDefault();
|
|
143
|
+
event.returnValue = "";
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
window.addEventListener("beforeunload", warn);
|
|
147
|
+
return () => window.removeEventListener("beforeunload", warn);
|
|
148
|
+
}, [dirty]);
|
|
149
|
+
async function save() {
|
|
150
|
+
if (!context.file) return;
|
|
151
|
+
setSaving(true);
|
|
152
|
+
setError(null);
|
|
153
|
+
try {
|
|
154
|
+
const result = await coline.files.updateDocument(
|
|
155
|
+
context.file.fileId,
|
|
156
|
+
{ title, body },
|
|
157
|
+
{ expectedVersion: version.current },
|
|
158
|
+
);
|
|
159
|
+
version.current = result.version;
|
|
160
|
+
await coline.files.update(context.file.fileId, { name: title.trim() || "Untitled note" });
|
|
161
|
+
setSaved({ title, body });
|
|
162
|
+
} catch (cause) {
|
|
163
|
+
setError(cause instanceof Error ? cause.message : String(cause));
|
|
164
|
+
} finally {
|
|
165
|
+
setSaving(false);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
async function back() {
|
|
169
|
+
if (
|
|
170
|
+
dirty &&
|
|
171
|
+
!(await coline.pickers.confirm({
|
|
172
|
+
title: "Leave without saving?",
|
|
173
|
+
description: "Your edits will be lost.",
|
|
174
|
+
confirmLabel: "Leave",
|
|
175
|
+
danger: true,
|
|
176
|
+
}))
|
|
177
|
+
)
|
|
178
|
+
return;
|
|
179
|
+
await coline.navigation.openAppHome();
|
|
180
|
+
}
|
|
181
|
+
return (
|
|
182
|
+
<AppPage title="Edit note" width="reading" description="Keep your thoughts together.">
|
|
183
|
+
<div className="note-editor">
|
|
184
|
+
<header className="flex items-center justify-between gap-4">
|
|
185
|
+
<Button variant="outline" onClick={() => void back()}>
|
|
186
|
+
All notes
|
|
187
|
+
</Button>
|
|
188
|
+
<Button disabled={!dirty || saving} onClick={() => void save()}>
|
|
189
|
+
{saving ? "Saving…" : "Save"}
|
|
190
|
+
</Button>
|
|
191
|
+
</header>
|
|
192
|
+
<label>
|
|
193
|
+
Title
|
|
194
|
+
<Input
|
|
195
|
+
value={title}
|
|
196
|
+
disabled={saving}
|
|
197
|
+
onChange={(event: React.ChangeEvent<HTMLInputElement>) => setTitle(event.target.value)}
|
|
198
|
+
/>
|
|
199
|
+
</label>
|
|
200
|
+
<label>
|
|
201
|
+
Note
|
|
202
|
+
<Textarea
|
|
203
|
+
value={body}
|
|
204
|
+
disabled={saving}
|
|
205
|
+
onChange={(event: React.ChangeEvent<HTMLTextAreaElement>) =>
|
|
206
|
+
setBody(event.target.value)
|
|
207
|
+
}
|
|
208
|
+
className="min-h-64"
|
|
209
|
+
/>
|
|
210
|
+
</label>
|
|
211
|
+
{error ? (
|
|
212
|
+
<p role="alert">{error}</p>
|
|
213
|
+
) : (
|
|
214
|
+
<p role="status">{dirty ? "Unsaved changes" : "Saved"}</p>
|
|
215
|
+
)}
|
|
216
|
+
</div>
|
|
217
|
+
</AppPage>
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
function AppSurface() {
|
|
221
|
+
const context = useColineContext();
|
|
222
|
+
return context.surface === "editor" ? <NoteEditor key={context.file?.fileId} /> : <StarterHome />;
|
|
91
223
|
}
|
|
92
224
|
|
|
93
225
|
const root = document.getElementById("root");
|
|
94
226
|
if (root) {
|
|
95
227
|
createRoot(root).render(
|
|
96
228
|
<ColineAppProvider>
|
|
97
|
-
<
|
|
229
|
+
<AppSurface />
|
|
98
230
|
</ColineAppProvider>,
|
|
99
231
|
);
|
|
100
232
|
}
|