@vibes.diy/prompts 14.3.64 → 14.3.66
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/llms/index.d.ts +29 -1
- package/llms/index.js +84 -0
- package/llms/index.js.map +1 -1
- package/llms/pouf-blog.d.ts +2 -0
- package/llms/pouf-blog.js +6 -0
- package/llms/pouf-blog.js.map +1 -0
- package/llms/pouf-blog.md +305 -0
- package/llms/pouf-booking.d.ts +2 -0
- package/llms/pouf-booking.js +6 -0
- package/llms/pouf-booking.js.map +1 -0
- package/llms/pouf-booking.md +397 -0
- package/llms/pouf-calendar.d.ts +2 -0
- package/llms/pouf-calendar.js +6 -0
- package/llms/pouf-calendar.js.map +1 -0
- package/llms/pouf-calendar.md +352 -0
- package/llms/pouf-chat.d.ts +2 -0
- package/llms/pouf-chat.js +6 -0
- package/llms/pouf-chat.js.map +1 -0
- package/llms/pouf-chat.md +220 -0
- package/llms/pouf-contact.d.ts +2 -0
- package/llms/pouf-contact.js +6 -0
- package/llms/pouf-contact.js.map +1 -0
- package/llms/pouf-contact.md +231 -0
- package/llms/pouf-course.d.ts +2 -0
- package/llms/pouf-course.js +6 -0
- package/llms/pouf-course.js.map +1 -0
- package/llms/pouf-course.md +299 -0
- package/llms/pouf-crm.d.ts +2 -0
- package/llms/pouf-crm.js +6 -0
- package/llms/pouf-crm.js.map +1 -0
- package/llms/pouf-crm.md +330 -0
- package/llms/pouf-dashboard.d.ts +2 -0
- package/llms/pouf-dashboard.js +6 -0
- package/llms/pouf-dashboard.js.map +1 -0
- package/llms/pouf-dashboard.md +269 -0
- package/llms/pouf-editorial.d.ts +2 -0
- package/llms/pouf-editorial.js +6 -0
- package/llms/pouf-editorial.js.map +1 -0
- package/llms/pouf-editorial.md +247 -0
- package/llms/pouf-event.d.ts +2 -0
- package/llms/pouf-event.js +6 -0
- package/llms/pouf-event.js.map +1 -0
- package/llms/pouf-event.md +237 -0
- package/llms/pouf-feed.d.ts +2 -0
- package/llms/pouf-feed.js +6 -0
- package/llms/pouf-feed.js.map +1 -0
- package/llms/pouf-feed.md +249 -0
- package/llms/pouf-game.d.ts +2 -0
- package/llms/pouf-game.js +6 -0
- package/llms/pouf-game.js.map +1 -0
- package/llms/pouf-game.md +273 -0
- package/llms/pouf-inbox.d.ts +2 -0
- package/llms/pouf-inbox.js +6 -0
- package/llms/pouf-inbox.js.map +1 -0
- package/llms/pouf-inbox.md +317 -0
- package/llms/pouf-inventory.d.ts +2 -0
- package/llms/pouf-inventory.js +6 -0
- package/llms/pouf-inventory.js.map +1 -0
- package/llms/pouf-inventory.md +345 -0
- package/llms/pouf-kanban.d.ts +2 -0
- package/llms/pouf-kanban.js +6 -0
- package/llms/pouf-kanban.js.map +1 -0
- package/llms/pouf-kanban.md +235 -0
- package/llms/pouf-landing.d.ts +2 -0
- package/llms/pouf-landing.js +6 -0
- package/llms/pouf-landing.js.map +1 -0
- package/llms/pouf-landing.md +222 -0
- package/llms/pouf-login.d.ts +2 -0
- package/llms/pouf-login.js +6 -0
- package/llms/pouf-login.js.map +1 -0
- package/llms/pouf-login.md +162 -0
- package/llms/pouf-music.d.ts +2 -0
- package/llms/pouf-music.js +6 -0
- package/llms/pouf-music.js.map +1 -0
- package/llms/pouf-music.md +190 -0
- package/llms/pouf-onboarding.d.ts +2 -0
- package/llms/pouf-onboarding.js +6 -0
- package/llms/pouf-onboarding.js.map +1 -0
- package/llms/pouf-onboarding.md +227 -0
- package/llms/pouf-pricing.d.ts +2 -0
- package/llms/pouf-pricing.js +6 -0
- package/llms/pouf-pricing.js.map +1 -0
- package/llms/pouf-pricing.md +193 -0
- package/llms/pouf-profile.d.ts +2 -0
- package/llms/pouf-profile.js +6 -0
- package/llms/pouf-profile.js.map +1 -0
- package/llms/pouf-profile.md +177 -0
- package/llms/pouf-quiz.d.ts +2 -0
- package/llms/pouf-quiz.js +6 -0
- package/llms/pouf-quiz.js.map +1 -0
- package/llms/pouf-quiz.md +267 -0
- package/llms/pouf-settings.d.ts +2 -0
- package/llms/pouf-settings.js +6 -0
- package/llms/pouf-settings.js.map +1 -0
- package/llms/pouf-settings.md +220 -0
- package/llms/pouf-storefront.d.ts +2 -0
- package/llms/pouf-storefront.js +6 -0
- package/llms/pouf-storefront.js.map +1 -0
- package/llms/pouf-storefront.md +293 -0
- package/llms/pouf-support.d.ts +2 -0
- package/llms/pouf-support.js +6 -0
- package/llms/pouf-support.js.map +1 -0
- package/llms/pouf-support.md +322 -0
- package/llms/pouf-testimonials.d.ts +2 -0
- package/llms/pouf-testimonials.js +6 -0
- package/llms/pouf-testimonials.js.map +1 -0
- package/llms/pouf-testimonials.md +156 -0
- package/llms/pouf-todo.d.ts +2 -0
- package/llms/pouf-todo.js +6 -0
- package/llms/pouf-todo.js.map +1 -0
- package/llms/pouf-todo.md +179 -0
- package/llms/pouf-weather.d.ts +2 -0
- package/llms/pouf-weather.js +6 -0
- package/llms/pouf-weather.js.map +1 -0
- package/llms/pouf-weather.md +186 -0
- package/package.json +4 -4
- package/prompts.js +2 -2
- package/prompts.js.map +1 -1
- package/themes/colorsets.d.ts +1 -1
- package/themes/colorsets.js +63 -1
- package/themes/colorsets.js.map +1 -1
- package/themes/index.d.ts +1 -0
- package/themes/index.js +1 -0
- package/themes/index.js.map +1 -1
- package/themes/looks/index.js +6 -2
- package/themes/looks/index.js.map +1 -1
- package/themes/looks/pouf/brief.md +144 -0
- package/themes/looks/pouf/catalog.generated.d.ts +16 -0
- package/themes/looks/pouf/catalog.generated.js +764 -0
- package/themes/looks/pouf/catalog.generated.js.map +1 -0
- package/themes/looks/pouf/variety.d.ts +4 -0
- package/themes/looks/pouf/variety.js +36 -0
- package/themes/looks/pouf/variety.js.map +1 -0
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
# Pouf Contact
|
|
2
|
+
|
|
3
|
+
A get-in-touch section where someone leaves their name, email, a topic and a message for the people behind the app: a photography studio's enquiry form, a community garden's question box, a band's booking request. Reach for it whenever the app's job is to collect a note and say clearly that it arrived. Every message persists as its own document, so the team can read and answer them later (the inbox block is the reading side).
|
|
4
|
+
|
|
5
|
+
Built from @vibes.diy/pouf components; persisted state lives in Fireproof. Every write surface is gated on `useVibe(dbName).can`.
|
|
6
|
+
|
|
7
|
+
## The block
|
|
8
|
+
|
|
9
|
+
One view with one job: send a note. A `Grid cols="sidebar"` puts the pitch beside the form: an `info` `Badge` with the reply time, an `Eyebrow`, the `Heading` and a muted line, then two tight `Card`s that tilt, each fronted by a small `Blob` (`info` mail, `idle` clock). The form sits on its own `Card`: name and email `Input`s, a topic `Select` and a message `Textarea`, each in a `Field`. It is checked in plain state on submit, with each error on its own `Field`, its control `invalid` and focus moved to the first one. `Send message` is the view's one lead and shows `loading` while the write lands. Each message is one document stamped with the sender's handle. The form waits on its `can` verdict from `useVibe("contact")`: a `Skeleton` inside an `aria-busy` wrapper while it resolves, a `Sign in to send a message` lead wired to `requestLogin()` when sending is closed to a signed-out visitor, and a sentence in the app's own words when it is closed to someone already signed in. Where `access.js` lets a signed-out visitor write, they can still send; their note is kept on this device until they sign in. So their confirmation says that honestly, and its lead is `Sign in to send it`, wired to `useViewer().requestLogin()`. A signed-in sender sees `Message sent` in an `Empty`, with a quiet `Send another message`.
|
|
10
|
+
|
|
11
|
+
### `App.jsx`
|
|
12
|
+
```jsx
|
|
13
|
+
import React, { useRef, useState } from "react";
|
|
14
|
+
import { useFireproof } from "use-fireproof";
|
|
15
|
+
import { useViewer, useVibe } from "use-vibes";
|
|
16
|
+
import { Badge, Blob, Button, Card, Empty, Eyebrow, Field, Grid, Heading, Input, Row, Skeleton, Stack, Text, Textarea } from "@vibes.diy/pouf";
|
|
17
|
+
import { Select } from "@vibes.diy/pouf/interactive";
|
|
18
|
+
|
|
19
|
+
const TOPICS = [
|
|
20
|
+
{ value: "booking", label: "Book a shoot" },
|
|
21
|
+
{ value: "prints", label: "Prints and licensing" },
|
|
22
|
+
{ value: "other", label: "Something else" },
|
|
23
|
+
];
|
|
24
|
+
const BLANK = { name: "", email: "", topic: "booking", message: "" };
|
|
25
|
+
const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
26
|
+
|
|
27
|
+
function check(form) {
|
|
28
|
+
return {
|
|
29
|
+
name: form.name.trim() === "" ? "Add your name so the reply has somewhere to land." : undefined,
|
|
30
|
+
email: EMAIL.test(form.email.trim()) ? undefined : "Enter a complete email, like you@example.com.",
|
|
31
|
+
message: form.message.trim().length < 20 ? "Share at least 20 characters so we can help." : undefined,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export default function App() {
|
|
36
|
+
const { database } = useFireproof("contact");
|
|
37
|
+
const { can, me } = useVibe("contact");
|
|
38
|
+
const { requestLogin } = useViewer();
|
|
39
|
+
|
|
40
|
+
const [form, setForm] = useState(BLANK);
|
|
41
|
+
const [errors, setErrors] = useState({});
|
|
42
|
+
const [sending, setSending] = useState(false);
|
|
43
|
+
const [sent, setSent] = useState(undefined);
|
|
44
|
+
const refs = { name: useRef(null), email: useRef(null), message: useRef(null) };
|
|
45
|
+
// The verdict for the document send() writes, carrying the same fields it stamps.
|
|
46
|
+
const draft = { type: "message", authorHandle: me?.userHandle };
|
|
47
|
+
const canSend = can.create(draft);
|
|
48
|
+
|
|
49
|
+
const set = (key) => (value) => {
|
|
50
|
+
setForm((f) => ({ ...f, [key]: value }));
|
|
51
|
+
if (errors[key]) setErrors((e) => ({ ...e, [key]: undefined }));
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
async function send(e) {
|
|
55
|
+
e.preventDefault();
|
|
56
|
+
const found = check(form);
|
|
57
|
+
setErrors(found);
|
|
58
|
+
const first = ["name", "email", "message"].find((k) => found[k]);
|
|
59
|
+
if (first) {
|
|
60
|
+
refs[first].current?.focus();
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
setSending(true);
|
|
64
|
+
try {
|
|
65
|
+
await database.put({
|
|
66
|
+
...draft,
|
|
67
|
+
name: form.name.trim(),
|
|
68
|
+
email: form.email.trim(),
|
|
69
|
+
topic: form.topic,
|
|
70
|
+
message: form.message.trim(),
|
|
71
|
+
createdAt: Date.now(),
|
|
72
|
+
});
|
|
73
|
+
setSent({ name: form.name.trim(), email: form.email.trim() });
|
|
74
|
+
// Cleared only once the write lands, so a local failure keeps every word.
|
|
75
|
+
setForm(BLANK);
|
|
76
|
+
} finally {
|
|
77
|
+
setSending(false);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
let panel;
|
|
82
|
+
if (canSend.pending)
|
|
83
|
+
panel = (
|
|
84
|
+
<div aria-busy="true">
|
|
85
|
+
<Skeleton variant="card" />
|
|
86
|
+
</div>
|
|
87
|
+
);
|
|
88
|
+
else if (!canSend.ok && !me)
|
|
89
|
+
panel = (
|
|
90
|
+
<Stack gap={3}>
|
|
91
|
+
<Text muted>Sign in and your note goes straight to the studio.</Text>
|
|
92
|
+
<Button block onClick={requestLogin}>
|
|
93
|
+
Sign in to send a message
|
|
94
|
+
</Button>
|
|
95
|
+
</Stack>
|
|
96
|
+
);
|
|
97
|
+
else if (!canSend.ok) panel = <Text muted>This form is closed for now. Email us at the address beside it instead.</Text>;
|
|
98
|
+
else if (sent && !me)
|
|
99
|
+
// Signed-out writes stay on this device until sign-in, so say exactly that.
|
|
100
|
+
panel = (
|
|
101
|
+
<Stack gap={4}>
|
|
102
|
+
<Empty icon="lock" title="Saved on this device">
|
|
103
|
+
Sign in and your note goes straight to the studio, nothing to retype.
|
|
104
|
+
</Empty>
|
|
105
|
+
<Button block onClick={requestLogin}>
|
|
106
|
+
Sign in to send it
|
|
107
|
+
</Button>
|
|
108
|
+
<Button block variant="quiet" onClick={() => setSent(undefined)}>
|
|
109
|
+
Write another
|
|
110
|
+
</Button>
|
|
111
|
+
</Stack>
|
|
112
|
+
);
|
|
113
|
+
else if (sent)
|
|
114
|
+
panel = (
|
|
115
|
+
<Stack gap={4}>
|
|
116
|
+
<Empty icon="send" title="Message sent">
|
|
117
|
+
Thanks, {sent.name}. A reply will go to {sent.email}.
|
|
118
|
+
</Empty>
|
|
119
|
+
<Button block variant="quiet" onClick={() => setSent(undefined)}>
|
|
120
|
+
Send another message
|
|
121
|
+
</Button>
|
|
122
|
+
</Stack>
|
|
123
|
+
);
|
|
124
|
+
else
|
|
125
|
+
panel = (
|
|
126
|
+
<form onSubmit={send} noValidate>
|
|
127
|
+
<Stack gap={4}>
|
|
128
|
+
<Field label="Name" error={errors.name}>
|
|
129
|
+
{(id, describedBy) => (
|
|
130
|
+
<Input
|
|
131
|
+
ref={refs.name}
|
|
132
|
+
id={id}
|
|
133
|
+
describedBy={describedBy}
|
|
134
|
+
value={form.name}
|
|
135
|
+
onChange={set("name")}
|
|
136
|
+
autoComplete="name"
|
|
137
|
+
invalid={errors.name !== undefined}
|
|
138
|
+
/>
|
|
139
|
+
)}
|
|
140
|
+
</Field>
|
|
141
|
+
<Field label="Email" error={errors.email}>
|
|
142
|
+
{(id, describedBy) => (
|
|
143
|
+
<Input
|
|
144
|
+
ref={refs.email}
|
|
145
|
+
id={id}
|
|
146
|
+
describedBy={describedBy}
|
|
147
|
+
type="email"
|
|
148
|
+
inputMode="email"
|
|
149
|
+
value={form.email}
|
|
150
|
+
onChange={set("email")}
|
|
151
|
+
autoComplete="email"
|
|
152
|
+
spellCheck={false}
|
|
153
|
+
invalid={errors.email !== undefined}
|
|
154
|
+
/>
|
|
155
|
+
)}
|
|
156
|
+
</Field>
|
|
157
|
+
<Field label="What can we help with?">
|
|
158
|
+
{(id, describedBy) => <Select id={id} describedBy={describedBy} value={form.topic} onChange={set("topic")} options={TOPICS} />}
|
|
159
|
+
</Field>
|
|
160
|
+
<Field label="Message" hint="A few sentences is plenty." error={errors.message}>
|
|
161
|
+
{(id, describedBy) => (
|
|
162
|
+
<Textarea
|
|
163
|
+
ref={refs.message}
|
|
164
|
+
id={id}
|
|
165
|
+
describedBy={describedBy}
|
|
166
|
+
value={form.message}
|
|
167
|
+
onChange={set("message")}
|
|
168
|
+
rows={6}
|
|
169
|
+
placeholder="The date, the place and what you have in mind."
|
|
170
|
+
invalid={errors.message !== undefined}
|
|
171
|
+
/>
|
|
172
|
+
)}
|
|
173
|
+
</Field>
|
|
174
|
+
<Button block type="submit" loading={sending}>
|
|
175
|
+
Send message
|
|
176
|
+
</Button>
|
|
177
|
+
</Stack>
|
|
178
|
+
</form>
|
|
179
|
+
);
|
|
180
|
+
|
|
181
|
+
return (
|
|
182
|
+
<main className="mx-auto max-w-5xl px-4 py-10">
|
|
183
|
+
<Grid cols="sidebar" gap={6}>
|
|
184
|
+
<Stack gap={5}>
|
|
185
|
+
<Stack gap={3}>
|
|
186
|
+
<Row>
|
|
187
|
+
<Badge tone="info">Usually replies in a day</Badge>
|
|
188
|
+
</Row>
|
|
189
|
+
<Eyebrow>Contact the studio</Eyebrow>
|
|
190
|
+
<Heading level={1}>Tell us what you're planning.</Heading>
|
|
191
|
+
<Text muted>A short first note is enough: the occasion, the date and anything we should know.</Text>
|
|
192
|
+
</Stack>
|
|
193
|
+
<Card variant="tight" motion="tilt-left">
|
|
194
|
+
<Row gap={3} wrap={false} align="top">
|
|
195
|
+
<Blob icon="mail" tone="info" size="sm" />
|
|
196
|
+
<Stack gap={1}>
|
|
197
|
+
<Text>Prefer email?</Text>
|
|
198
|
+
<Text size="sm" muted>
|
|
199
|
+
hello@example.com
|
|
200
|
+
</Text>
|
|
201
|
+
</Stack>
|
|
202
|
+
</Row>
|
|
203
|
+
</Card>
|
|
204
|
+
<Card variant="tight" motion="tilt-right">
|
|
205
|
+
<Row gap={3} wrap={false} align="top">
|
|
206
|
+
<Blob icon="clock" tone="idle" size="sm" />
|
|
207
|
+
<Stack gap={1}>
|
|
208
|
+
<Text>Read by a person</Text>
|
|
209
|
+
<Text size="sm" muted>
|
|
210
|
+
Every message gets a real reply, Monday to Friday.
|
|
211
|
+
</Text>
|
|
212
|
+
</Stack>
|
|
213
|
+
</Row>
|
|
214
|
+
</Card>
|
|
215
|
+
</Stack>
|
|
216
|
+
<Card>{panel}</Card>
|
|
217
|
+
</Grid>
|
|
218
|
+
</main>
|
|
219
|
+
);
|
|
220
|
+
}
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
## Adapting
|
|
224
|
+
|
|
225
|
+
- Rename the topics and copy to the app's own world (`Book a session`, `Volunteer`, `Press`); a topic is one more `SelectOption`, not a second form.
|
|
226
|
+
- Messages hold people's emails, so `access.js` should let a sender create their own and only the team read them all; read them with the inbox block, never on this page.
|
|
227
|
+
- Add a field (a phone number, a preferred date) as one more `Field`, one more line in `check` and one more property on the message.
|
|
228
|
+
- Keep the honest signed-out confirmation: a signed-out note stays on the device until sign-in, so never tell that visitor it was sent.
|
|
229
|
+
- A new write surface gets its own verdict: `can.create(draft)` with the fields the write stamps, `can.edit(doc)` or `can.delete(doc)` on the document it changes, always on `useVibe` of the database it writes.
|
|
230
|
+
|
|
231
|
+
Adapted from 1st-Pouf (MIT) https://1st-pouf.worksonmy.dev/r/contact.json
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"pouf-course.js","sourceRoot":"","sources":["../../jsr/llms/pouf-course.ts"],"names":[],"mappings":"AAMA,MAAM,CAAC,MAAM,gBAAgB,GAAc;IACzC,IAAI,EAAE,aAAa;IACnB,KAAK,EAAE,mBAAmB;IAC1B,WAAW,EAAE,4DAA4D;CAC1E,CAAC"}
|
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
# Pouf Course
|
|
2
|
+
|
|
3
|
+
A course outline someone works through lesson by lesson: a beginner pottery class, new staff training at a café, a study plan for an exam, a coding bootcamp's first week. Reach for it whenever the app's core is an ordered set of lessons, each with a rough length, that a person marks complete while a bar shows how far along they are. The lessons and each person's completions persist, so progress is waiting on the next visit and on every device, and every learner keeps their own.
|
|
4
|
+
|
|
5
|
+
Built from @vibes.diy/pouf components; persisted state lives in Fireproof. Every write surface is gated on `useVibe(dbName).can`.
|
|
6
|
+
|
|
7
|
+
## The block
|
|
8
|
+
|
|
9
|
+
Two views, one job each. The outline opens with the `Heading` and a muted count beside a quiet `Add lesson` button that opens a `Dialog` with the lesson form. A `Card` holds the person's progress: a `Progress` bar toned `up`, the percent and minutes left in `Text`, a `Blob` (`info` target while lessons remain, `up` trophy once they are all done) and a `Continue` button, the outline's one lead, that opens the next unfinished lesson. Each lesson is a clickable `RowCard` with a small `Blob`, a muted line for its kind and length, its title and a `Badge` (`up` done, `idle` to do); an `Empty` stands in before the first lesson. Opening a lesson swaps in the lesson view: a quiet `IconButton` to go back, the kind as an `info` `Badge`, the title as the `Heading`, `Metric`s for the time and the lesson's place in the course, and `Mark complete` as that view's lead (or, once done, a quiet `Mark not done` beside a `Next lesson` lead). A completion is its own document with a deterministic `_id` per lesson and person, so each learner's progress is theirs and marking twice lands on one document. Every write waits on its `can` verdict from `useVibe("course")`: the lesson form and the completion buttons show a `Skeleton` inside an `aria-busy` wrapper while the verdict resolves and a sentence in the app's own words when that write is closed to this visitor. Progress belongs to a person, so a visitor who has not signed in sees a `Sign in to track progress` button wired to `useViewer().requestLogin()` instead of the completion buttons.
|
|
10
|
+
|
|
11
|
+
### `App.jsx`
|
|
12
|
+
```jsx
|
|
13
|
+
import React, { useState } from "react";
|
|
14
|
+
import { useFireproof } from "use-fireproof";
|
|
15
|
+
import { useViewer, useVibe } from "use-vibes";
|
|
16
|
+
import {
|
|
17
|
+
Badge,
|
|
18
|
+
Blob,
|
|
19
|
+
Button,
|
|
20
|
+
Card,
|
|
21
|
+
Empty,
|
|
22
|
+
Field,
|
|
23
|
+
Heading,
|
|
24
|
+
Icon,
|
|
25
|
+
IconButton,
|
|
26
|
+
Input,
|
|
27
|
+
Metric,
|
|
28
|
+
NumberInput,
|
|
29
|
+
Row,
|
|
30
|
+
RowCard,
|
|
31
|
+
Skeleton,
|
|
32
|
+
Spacer,
|
|
33
|
+
Stack,
|
|
34
|
+
Text,
|
|
35
|
+
} from "@vibes.diy/pouf";
|
|
36
|
+
import { Dialog, Progress, Select, Toaster, toast } from "@vibes.diy/pouf/interactive";
|
|
37
|
+
|
|
38
|
+
const KINDS = [
|
|
39
|
+
{ value: "Lesson", label: "Lesson" },
|
|
40
|
+
{ value: "Exercise", label: "Exercise" },
|
|
41
|
+
{ value: "Quiz", label: "Quiz" },
|
|
42
|
+
];
|
|
43
|
+
|
|
44
|
+
const MINUTES = new Intl.NumberFormat(undefined, { style: "unit", unit: "minute" });
|
|
45
|
+
|
|
46
|
+
/** Three states for any write surface: resolving, closed to this visitor, open. */
|
|
47
|
+
function Gate({ verdict, closed, children }) {
|
|
48
|
+
if (verdict.pending)
|
|
49
|
+
return (
|
|
50
|
+
<div aria-busy="true">
|
|
51
|
+
<Skeleton variant="row" />
|
|
52
|
+
</div>
|
|
53
|
+
);
|
|
54
|
+
if (!verdict.ok) return <Text muted>{closed}</Text>;
|
|
55
|
+
return children;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function LessonView({ lesson, position, mine, next, database, can, me, onBack, onOpen }) {
|
|
59
|
+
const { requestLogin } = useViewer();
|
|
60
|
+
// The completion this visitor would write: one id per lesson and person, written only once `me` is known.
|
|
61
|
+
const doneDraft = { _id: `done:${lesson._id}:${me?.userHandle}`, type: "completion", lessonId: lesson._id, authorHandle: me?.userHandle };
|
|
62
|
+
const verdict = mine ? can.delete(mine) : can.create(doneDraft);
|
|
63
|
+
|
|
64
|
+
async function markComplete() {
|
|
65
|
+
await database.put(doneDraft);
|
|
66
|
+
toast.success("Lesson complete");
|
|
67
|
+
if (next) onOpen(next._id);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async function markNotDone() {
|
|
71
|
+
await database.del(mine._id);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return (
|
|
75
|
+
<Stack gap={5}>
|
|
76
|
+
<Stack gap={2}>
|
|
77
|
+
<Row gap={2} wrap={false}>
|
|
78
|
+
<IconButton size="sm" onClick={onBack} label="Back to the outline" icon={<Icon name="prev" size="sm" />} />
|
|
79
|
+
<Badge tone="info">{lesson.kind}</Badge>
|
|
80
|
+
{mine && <Badge tone="up">Done</Badge>}
|
|
81
|
+
</Row>
|
|
82
|
+
<Heading level={1}>{lesson.title}</Heading>
|
|
83
|
+
</Stack>
|
|
84
|
+
|
|
85
|
+
<Card variant="tight">
|
|
86
|
+
<Row gap={4}>
|
|
87
|
+
<Metric label="Time" value={MINUTES.format(lesson.minutes)} />
|
|
88
|
+
<Metric label="Lesson" value={position} />
|
|
89
|
+
</Row>
|
|
90
|
+
</Card>
|
|
91
|
+
|
|
92
|
+
{!verdict.pending && !me ? (
|
|
93
|
+
// Progress belongs to a person, so a visitor who hasn't signed in is asked to, in the app's words.
|
|
94
|
+
<Stack gap={2}>
|
|
95
|
+
<Text muted>Sign in to keep your progress on every device.</Text>
|
|
96
|
+
<Row justify="end">
|
|
97
|
+
<Button onClick={requestLogin}>Sign in to track progress</Button>
|
|
98
|
+
</Row>
|
|
99
|
+
</Stack>
|
|
100
|
+
) : (
|
|
101
|
+
<Gate verdict={verdict} closed="Tracking progress opens up once you've joined the course.">
|
|
102
|
+
<Row justify="end">
|
|
103
|
+
{mine ? (
|
|
104
|
+
<Button variant="quiet" onClick={markNotDone}>
|
|
105
|
+
Mark not done
|
|
106
|
+
</Button>
|
|
107
|
+
) : (
|
|
108
|
+
<Button onClick={markComplete}>
|
|
109
|
+
<Icon name="ok" size="sm" /> Mark complete
|
|
110
|
+
</Button>
|
|
111
|
+
)}
|
|
112
|
+
</Row>
|
|
113
|
+
</Gate>
|
|
114
|
+
)}
|
|
115
|
+
{/* Moving on is not a write, so it sits outside the gate. */}
|
|
116
|
+
{mine && next && (
|
|
117
|
+
<Row justify="end">
|
|
118
|
+
<Button onClick={() => onOpen(next._id)}>Next lesson</Button>
|
|
119
|
+
</Row>
|
|
120
|
+
)}
|
|
121
|
+
</Stack>
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export default function App() {
|
|
126
|
+
const { database, useLiveQuery } = useFireproof("course");
|
|
127
|
+
const { can, me } = useVibe("course");
|
|
128
|
+
const { docs: lessonDocs } = useLiveQuery("type", { key: "lesson" });
|
|
129
|
+
const { docs: completions } = useLiveQuery("type", { key: "completion" });
|
|
130
|
+
const lessons = [...lessonDocs].sort((a, b) => a.createdAt - b.createdAt);
|
|
131
|
+
|
|
132
|
+
const [openId, setOpenId] = useState(undefined);
|
|
133
|
+
const [adding, setAdding] = useState(false);
|
|
134
|
+
const [form, setForm] = useState({ title: "", kind: "Lesson", minutes: "10" });
|
|
135
|
+
const [errors, setErrors] = useState({});
|
|
136
|
+
// The verdict for the doc addLesson writes, carrying the same fields it stamps.
|
|
137
|
+
const canAdd = can.create({ type: "lesson", authorHandle: me?.userHandle });
|
|
138
|
+
|
|
139
|
+
const set = (key) => (value) => setForm((f) => ({ ...f, [key]: value }));
|
|
140
|
+
// Only this visitor's completions count toward their bar.
|
|
141
|
+
// Nobody signed in has no completions: progress is tracked per person.
|
|
142
|
+
const mineFor = (lesson) => (me ? completions.find((c) => c.lessonId === lesson._id && c.authorHandle === me.userHandle) : undefined);
|
|
143
|
+
const doneCount = lessons.filter(mineFor).length;
|
|
144
|
+
const percent = lessons.length === 0 ? 0 : Math.round((doneCount / lessons.length) * 100);
|
|
145
|
+
const left = lessons.filter((l) => !mineFor(l)).reduce((sum, l) => sum + l.minutes, 0);
|
|
146
|
+
const finished = lessons.length > 0 && doneCount === lessons.length;
|
|
147
|
+
const current = lessons.find((l) => l._id === openId);
|
|
148
|
+
// The next unfinished lesson after `from`, wrapping round to any earlier one still open.
|
|
149
|
+
const nextAfter = (from) => {
|
|
150
|
+
const at = lessons.findIndex((l) => l._id === from?._id);
|
|
151
|
+
return [...lessons.slice(at + 1), ...lessons.slice(0, Math.max(at, 0))].find((l) => l._id !== from?._id && !mineFor(l));
|
|
152
|
+
};
|
|
153
|
+
const upNext = nextAfter(undefined);
|
|
154
|
+
|
|
155
|
+
async function addLesson(e) {
|
|
156
|
+
e.preventDefault();
|
|
157
|
+
const minutes = Number(form.minutes);
|
|
158
|
+
const found = {
|
|
159
|
+
title: form.title.trim() === "" ? "Give the lesson a title." : undefined,
|
|
160
|
+
minutes: Number.isInteger(minutes) && minutes > 0 ? undefined : "Enter a whole number of minutes.",
|
|
161
|
+
};
|
|
162
|
+
setErrors(found);
|
|
163
|
+
if (found.title || found.minutes) return;
|
|
164
|
+
await database.put({ type: "lesson", title: form.title.trim(), kind: form.kind, minutes, authorHandle: me?.userHandle, createdAt: Date.now() });
|
|
165
|
+
setForm({ title: "", kind: "Lesson", minutes: "10" });
|
|
166
|
+
setAdding(false);
|
|
167
|
+
toast.success("Lesson added");
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return (
|
|
171
|
+
<main className="mx-auto max-w-2xl px-4 py-8">
|
|
172
|
+
{current ? (
|
|
173
|
+
<LessonView
|
|
174
|
+
lesson={current}
|
|
175
|
+
position={`${lessons.indexOf(current) + 1} of ${lessons.length}`}
|
|
176
|
+
mine={mineFor(current)}
|
|
177
|
+
next={nextAfter(current)}
|
|
178
|
+
database={database}
|
|
179
|
+
can={can}
|
|
180
|
+
me={me}
|
|
181
|
+
onBack={() => setOpenId(undefined)}
|
|
182
|
+
onOpen={setOpenId}
|
|
183
|
+
/>
|
|
184
|
+
) : (
|
|
185
|
+
<Stack gap={5}>
|
|
186
|
+
<Row justify="between" align="top">
|
|
187
|
+
<Stack gap={1}>
|
|
188
|
+
<Heading level={1}>Your course</Heading>
|
|
189
|
+
<Text muted>{lessons.length === 1 ? "1 lesson" : `${lessons.length} lessons`}</Text>
|
|
190
|
+
</Stack>
|
|
191
|
+
<Dialog
|
|
192
|
+
title="Add a lesson"
|
|
193
|
+
open={adding}
|
|
194
|
+
onOpenChange={setAdding}
|
|
195
|
+
trigger={
|
|
196
|
+
<Button variant="quiet" size="sm">
|
|
197
|
+
Add lesson
|
|
198
|
+
</Button>
|
|
199
|
+
}
|
|
200
|
+
>
|
|
201
|
+
<Gate verdict={canAdd} closed="Adding lessons is for the people who run this course.">
|
|
202
|
+
<form onSubmit={addLesson} noValidate>
|
|
203
|
+
<Stack gap={3}>
|
|
204
|
+
<Field label="Title" error={errors.title}>
|
|
205
|
+
{(id, describedBy) => (
|
|
206
|
+
<Input id={id} describedBy={describedBy} value={form.title} onChange={set("title")} invalid={errors.title !== undefined} />
|
|
207
|
+
)}
|
|
208
|
+
</Field>
|
|
209
|
+
<Field label="Kind">
|
|
210
|
+
{(id, describedBy) => <Select id={id} describedBy={describedBy} value={form.kind} onChange={set("kind")} options={KINDS} />}
|
|
211
|
+
</Field>
|
|
212
|
+
<Field label="Minutes" error={errors.minutes}>
|
|
213
|
+
{(id, describedBy) => (
|
|
214
|
+
<NumberInput
|
|
215
|
+
id={id}
|
|
216
|
+
describedBy={describedBy}
|
|
217
|
+
value={form.minutes}
|
|
218
|
+
onChange={set("minutes")}
|
|
219
|
+
min={1}
|
|
220
|
+
invalid={errors.minutes !== undefined}
|
|
221
|
+
/>
|
|
222
|
+
)}
|
|
223
|
+
</Field>
|
|
224
|
+
<Row justify="end">
|
|
225
|
+
<Button type="submit">Save lesson</Button>
|
|
226
|
+
</Row>
|
|
227
|
+
</Stack>
|
|
228
|
+
</form>
|
|
229
|
+
</Gate>
|
|
230
|
+
</Dialog>
|
|
231
|
+
</Row>
|
|
232
|
+
|
|
233
|
+
{lessons.length > 0 && (
|
|
234
|
+
<Card>
|
|
235
|
+
<Row gap={4} wrap={false}>
|
|
236
|
+
<Stack gap={3}>
|
|
237
|
+
<Progress value={percent} tone="up" label="Course completion" />
|
|
238
|
+
<Row justify="between">
|
|
239
|
+
<Text size="sm">{percent}% complete</Text>
|
|
240
|
+
<Text size="sm" muted>
|
|
241
|
+
{finished ? "All done" : `${MINUTES.format(left)} left`}
|
|
242
|
+
</Text>
|
|
243
|
+
</Row>
|
|
244
|
+
{upNext && (
|
|
245
|
+
<Row>
|
|
246
|
+
<Button onClick={() => setOpenId(upNext._id)}>Continue</Button>
|
|
247
|
+
</Row>
|
|
248
|
+
)}
|
|
249
|
+
</Stack>
|
|
250
|
+
<Blob icon={finished ? "trophy" : "target"} tone={finished ? "up" : "info"} size="md" />
|
|
251
|
+
</Row>
|
|
252
|
+
</Card>
|
|
253
|
+
)}
|
|
254
|
+
|
|
255
|
+
{lessons.length === 0 ? (
|
|
256
|
+
<Empty icon="play" title="No lessons yet">
|
|
257
|
+
Add the first lesson to build the outline.
|
|
258
|
+
</Empty>
|
|
259
|
+
) : (
|
|
260
|
+
<Stack gap={2}>
|
|
261
|
+
{lessons.map((lesson) => {
|
|
262
|
+
const done = Boolean(mineFor(lesson));
|
|
263
|
+
return (
|
|
264
|
+
<RowCard key={lesson._id} onClick={() => setOpenId(lesson._id)}>
|
|
265
|
+
<Row gap={3} wrap={false}>
|
|
266
|
+
<Blob icon={done ? "ok" : lesson.kind === "Quiz" ? "wand" : "play"} tone={done ? "up" : "idle"} size="sm" />
|
|
267
|
+
<Stack gap={1}>
|
|
268
|
+
<Text size="sm" muted>
|
|
269
|
+
{lesson.kind} · {MINUTES.format(lesson.minutes)}
|
|
270
|
+
</Text>
|
|
271
|
+
<Text truncate>{lesson.title}</Text>
|
|
272
|
+
</Stack>
|
|
273
|
+
<Spacer />
|
|
274
|
+
<Badge tone={done ? "up" : "idle"}>{done ? "Done" : "To do"}</Badge>
|
|
275
|
+
</Row>
|
|
276
|
+
</RowCard>
|
|
277
|
+
);
|
|
278
|
+
})}
|
|
279
|
+
</Stack>
|
|
280
|
+
)}
|
|
281
|
+
</Stack>
|
|
282
|
+
)}
|
|
283
|
+
<div className="pouf-toasts">
|
|
284
|
+
<Toaster />
|
|
285
|
+
</div>
|
|
286
|
+
</main>
|
|
287
|
+
);
|
|
288
|
+
}
|
|
289
|
+
```
|
|
290
|
+
|
|
291
|
+
## Adapting
|
|
292
|
+
|
|
293
|
+
- Rename lessons to the app's own noun (`module`, `step`, `drill`) in the `type` key, the headings and the copy; keep one document per lesson and one per completion, keyed `done:<lesson>:<person>`.
|
|
294
|
+
- A lesson's content is one more `Field` (a `Textarea`) on the lesson form and a `Text` on the lesson view; a video or picture is a `Figure`.
|
|
295
|
+
- Several courses become a `Segmented` over a `course` property on each lesson, not a second database; the bar then counts only that course's lessons.
|
|
296
|
+
- Keep the bar per person: count only completions whose `authorHandle` is `me.userHandle`, and tone it `up`, which says progress is good news.
|
|
297
|
+
- A new write surface gets its own verdict: `can.create(draft)` with the fields the write stamps, `can.edit(doc)` or `can.delete(doc)` on the document it changes, always on `useVibe` of the database it writes.
|
|
298
|
+
|
|
299
|
+
Adapted from 1st-Pouf (MIT) https://1st-pouf.worksonmy.dev/r/course.json
|
package/llms/pouf-crm.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"pouf-crm.js","sourceRoot":"","sources":["../../jsr/llms/pouf-crm.ts"],"names":[],"mappings":"AAMA,MAAM,CAAC,MAAM,aAAa,GAAc;IACtC,IAAI,EAAE,UAAU;IAChB,KAAK,EAAE,gBAAgB;IACvB,WAAW,EAAE,kEAAkE;CAChF,CAAC"}
|