@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,352 @@
|
|
|
1
|
+
# Pouf Calendar
|
|
2
|
+
|
|
3
|
+
An on-screen calendar of timed events: a studio's class timetable, a family's appointments, a club's practice schedule, a shared room's bookings for the week. Reach for it when the app shows a month at a glance and one day's agenda in time order, with the free gaps between events and a line where "now" falls, and lets people add and remove events. This is the calendar people look at inside the app; a subscribable phone-calendar feed (webcal or `.ics`) is a different job, served from the backend. Each event is its own document, so everyone who opens the app sees the same schedule.
|
|
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: see what is on and add to it. The `Heading` and a muted line open the page beside `Add event`, the view's one lead, which opens a `Dialog` with the event form (title, start and length, each in its own `Field`, the title's error on its own). The month comes first, on a tight `Card` so its seven columns fit a phone, and the day's agenda follows on a `Card` of its own: the date as a `Heading level={2}`, an `info` `Badge` on today, a muted event count, then the timeline: one `RowCard` per event (start and end in numeric muted `Text`, the title, its length) with a quiet `IconButton` toned `down` to remove it behind a `Confirm` wearing `tone="down"`; a muted row with a clock `Icon` for every free gap of an hour or more; and an `info` `Badge` with the time where now falls on today. An `Empty` stands in for a day with nothing on, its hint held back until the add verdict resolves. On the month `Card`, quiet `IconButton`s step between months, a quiet `Today` jumps back, and a seven-column grid of small square `IconButton`s, each labelled with its date and event count, picks the day: the chosen day solid `info`, a day with events solid `idle`, an empty day quiet. Every write waits on its `can` verdict from `useVibe("calendar")`, the database the events live in: `Add event` gives way to a `Skeleton` inside an `aria-busy` wrapper while the verdict resolves, to a `Sign in to add events` lead wired to `useViewer().requestLogin()` for a visitor who has not signed in, and to a sentence in the app's own words when adding is closed; the remove button renders only where `can.delete(event).ok`.
|
|
10
|
+
|
|
11
|
+
### `App.jsx`
|
|
12
|
+
```jsx
|
|
13
|
+
import React, { useEffect, useState } from "react";
|
|
14
|
+
import { useFireproof } from "use-fireproof";
|
|
15
|
+
import { useViewer, useVibe } from "use-vibes";
|
|
16
|
+
import { Badge, Button, Card, Empty, Field, Grid, Heading, Icon, IconButton, Input, Row, RowCard, Skeleton, Stack, Text } from "@vibes.diy/pouf";
|
|
17
|
+
import { Confirm, Dialog, Select, Toaster, toast } from "@vibes.diy/pouf/interactive";
|
|
18
|
+
|
|
19
|
+
const pad = (n) => String(n).padStart(2, "0");
|
|
20
|
+
// Dates are kept as local YYYY-MM-DD strings, so an event never drifts a day across time zones.
|
|
21
|
+
const keyOf = (d) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
|
22
|
+
const dateOf = (key) => new Date(`${key}T12:00:00`);
|
|
23
|
+
const longDate = (key) => dateOf(key).toLocaleDateString(undefined, { weekday: "long", month: "long", day: "numeric" });
|
|
24
|
+
function toMinutes(t) {
|
|
25
|
+
const [h, m] = t.split(":").map(Number);
|
|
26
|
+
return h * 60 + m;
|
|
27
|
+
}
|
|
28
|
+
function fromMinutes(mins) {
|
|
29
|
+
return `${pad(Math.floor(mins / 60))}:${pad(mins % 60)}`;
|
|
30
|
+
}
|
|
31
|
+
const plural = (n, one, many) => (n === 1 ? `1 ${one}` : `${n} ${many}`);
|
|
32
|
+
|
|
33
|
+
// The agenda's working hours and the shortest gap worth calling free.
|
|
34
|
+
const DAY_START = 8 * 60;
|
|
35
|
+
const DAY_END = 20 * 60;
|
|
36
|
+
const FREE_MIN = 60;
|
|
37
|
+
const WEEKDAYS = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
|
|
38
|
+
const START_OPTIONS = Array.from({ length: (DAY_END - DAY_START) / 30 }, (_, i) => {
|
|
39
|
+
const t = fromMinutes(DAY_START + i * 30);
|
|
40
|
+
return { value: t, label: t };
|
|
41
|
+
});
|
|
42
|
+
const LENGTH_OPTIONS = [
|
|
43
|
+
{ value: "15", label: "15 min" },
|
|
44
|
+
{ value: "30", label: "30 min" },
|
|
45
|
+
{ value: "45", label: "45 min" },
|
|
46
|
+
{ value: "60", label: "1 hour" },
|
|
47
|
+
{ value: "90", label: "1 hour 30" },
|
|
48
|
+
{ value: "120", label: "2 hours" },
|
|
49
|
+
];
|
|
50
|
+
const EMPTY_DRAFT = { title: "", start: "09:00", minutes: "60" };
|
|
51
|
+
|
|
52
|
+
// A day's events become a full timeline: free gaps of an hour or more, and on today a "now" marker.
|
|
53
|
+
function buildTimeline(events, nowMinutes) {
|
|
54
|
+
const items = [];
|
|
55
|
+
let cursor = DAY_START;
|
|
56
|
+
let nowPlaced = nowMinutes === undefined;
|
|
57
|
+
const placeNow = (before) => {
|
|
58
|
+
if (!nowPlaced && nowMinutes < before) {
|
|
59
|
+
items.push({ kind: "now" });
|
|
60
|
+
nowPlaced = true;
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
for (const event of events) {
|
|
64
|
+
const start = toMinutes(event.start);
|
|
65
|
+
if (start - cursor >= FREE_MIN) {
|
|
66
|
+
placeNow(cursor);
|
|
67
|
+
items.push({ kind: "free", start: cursor, end: start });
|
|
68
|
+
}
|
|
69
|
+
placeNow(start);
|
|
70
|
+
items.push({ kind: "event", event });
|
|
71
|
+
cursor = Math.max(cursor, start + event.minutes);
|
|
72
|
+
}
|
|
73
|
+
if (DAY_END - cursor >= FREE_MIN) {
|
|
74
|
+
placeNow(cursor);
|
|
75
|
+
items.push({ kind: "free", start: cursor, end: DAY_END });
|
|
76
|
+
}
|
|
77
|
+
if (!nowPlaced) items.push({ kind: "now" });
|
|
78
|
+
return items;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// The cells of a month grid, Monday first: blanks before the 1st, then each day's key.
|
|
82
|
+
function monthCells(year, month) {
|
|
83
|
+
const lead = (new Date(year, month, 1).getDay() + 6) % 7;
|
|
84
|
+
const days = new Date(year, month + 1, 0).getDate();
|
|
85
|
+
return [...Array(lead).fill(null), ...Array.from({ length: days }, (_, i) => keyOf(new Date(year, month, i + 1)))];
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export default function App() {
|
|
89
|
+
const { database, useLiveQuery } = useFireproof("calendar");
|
|
90
|
+
const { docs } = useLiveQuery("type", { key: "event" });
|
|
91
|
+
const { can, me } = useVibe("calendar");
|
|
92
|
+
const { requestLogin } = useViewer();
|
|
93
|
+
|
|
94
|
+
const [now, setNow] = useState(() => new Date());
|
|
95
|
+
const today = keyOf(now);
|
|
96
|
+
const [selected, setSelected] = useState(today);
|
|
97
|
+
const [shown, setShown] = useState(() => ({ year: now.getFullYear(), month: now.getMonth() }));
|
|
98
|
+
const [adding, setAdding] = useState(false);
|
|
99
|
+
const [draft, setDraft] = useState(EMPTY_DRAFT);
|
|
100
|
+
const [errors, setErrors] = useState({});
|
|
101
|
+
const [saving, setSaving] = useState(false);
|
|
102
|
+
const [removingId, setRemovingId] = useState(undefined);
|
|
103
|
+
|
|
104
|
+
// The now marker moves with the clock; one tick a minute is all it needs.
|
|
105
|
+
useEffect(() => {
|
|
106
|
+
const id = setInterval(() => setNow(new Date()), 60_000);
|
|
107
|
+
return () => clearInterval(id);
|
|
108
|
+
}, []);
|
|
109
|
+
|
|
110
|
+
const countOn = new Map();
|
|
111
|
+
for (const e of docs) countOn.set(e.date, (countOn.get(e.date) ?? 0) + 1);
|
|
112
|
+
const dayEvents = docs.filter((e) => e.date === selected).sort((a, b) => toMinutes(a.start) - toMinutes(b.start));
|
|
113
|
+
const isToday = selected === today;
|
|
114
|
+
const timeline = buildTimeline(dayEvents, isToday ? now.getHours() * 60 + now.getMinutes() : undefined);
|
|
115
|
+
const selectedDate = dateOf(selected);
|
|
116
|
+
const monthName = new Date(shown.year, shown.month, 1).toLocaleDateString(undefined, { month: "long", year: "numeric" });
|
|
117
|
+
|
|
118
|
+
// The verdict for the doc addEvent writes, carrying the same fields it stamps.
|
|
119
|
+
const canAdd = can.create({ type: "event", date: selected, authorHandle: me?.userHandle });
|
|
120
|
+
|
|
121
|
+
function step(by) {
|
|
122
|
+
setShown(({ year, month }) => {
|
|
123
|
+
const d = new Date(year, month + by, 1);
|
|
124
|
+
return { year: d.getFullYear(), month: d.getMonth() };
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function goToday() {
|
|
129
|
+
setSelected(today);
|
|
130
|
+
setShown({ year: now.getFullYear(), month: now.getMonth() });
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function edit(field, value) {
|
|
134
|
+
setDraft((d) => ({ ...d, [field]: value }));
|
|
135
|
+
if (errors[field]) setErrors((e) => ({ ...e, [field]: undefined }));
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async function addEvent(e) {
|
|
139
|
+
e.preventDefault();
|
|
140
|
+
const next = {};
|
|
141
|
+
if (draft.title.trim() === "") next.title = "Give the event a name.";
|
|
142
|
+
if (toMinutes(draft.start) + Number(draft.minutes) > 24 * 60) next.minutes = "That runs past midnight; pick a shorter length.";
|
|
143
|
+
setErrors(next);
|
|
144
|
+
if (Object.keys(next).length > 0) return;
|
|
145
|
+
setSaving(true);
|
|
146
|
+
try {
|
|
147
|
+
await database.put({
|
|
148
|
+
type: "event",
|
|
149
|
+
date: selected,
|
|
150
|
+
start: draft.start,
|
|
151
|
+
minutes: Number(draft.minutes),
|
|
152
|
+
title: draft.title.trim(),
|
|
153
|
+
authorHandle: me?.userHandle,
|
|
154
|
+
createdAt: Date.now(),
|
|
155
|
+
});
|
|
156
|
+
// Cleared only once the write lands, so a failed save keeps what was typed.
|
|
157
|
+
setDraft(EMPTY_DRAFT);
|
|
158
|
+
setAdding(false);
|
|
159
|
+
toast.success("Event added");
|
|
160
|
+
} finally {
|
|
161
|
+
setSaving(false);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
async function removeEvent(event) {
|
|
166
|
+
setRemovingId(event._id);
|
|
167
|
+
try {
|
|
168
|
+
await database.del(event._id);
|
|
169
|
+
toast.success("Event removed");
|
|
170
|
+
} finally {
|
|
171
|
+
setRemovingId(undefined);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
let addAction;
|
|
176
|
+
if (canAdd.pending)
|
|
177
|
+
addAction = (
|
|
178
|
+
<div aria-busy="true">
|
|
179
|
+
<Skeleton variant="text" />
|
|
180
|
+
</div>
|
|
181
|
+
);
|
|
182
|
+
else if (!me) addAction = <Button onClick={requestLogin}>Sign in to add events</Button>;
|
|
183
|
+
else if (!canAdd.ok) addAction = <Text muted>The schedule is set by the organisers.</Text>;
|
|
184
|
+
else
|
|
185
|
+
addAction = (
|
|
186
|
+
<Dialog title="Add event" description={longDate(selected)} open={adding} onOpenChange={setAdding} trigger={<Button>Add event</Button>}>
|
|
187
|
+
<form onSubmit={addEvent} noValidate>
|
|
188
|
+
<Stack gap={3}>
|
|
189
|
+
<Field label="Title" error={errors.title}>
|
|
190
|
+
{(id, describedBy) => (
|
|
191
|
+
<Input id={id} describedBy={describedBy} value={draft.title} onChange={(v) => edit("title", v)} placeholder="Wheel-throwing, beginners" invalid={errors.title !== undefined} />
|
|
192
|
+
)}
|
|
193
|
+
</Field>
|
|
194
|
+
<Grid cols={2}>
|
|
195
|
+
<Field label="Starts">
|
|
196
|
+
{(id, describedBy) => <Select id={id} describedBy={describedBy} value={draft.start} onChange={(v) => edit("start", v)} options={START_OPTIONS} />}
|
|
197
|
+
</Field>
|
|
198
|
+
<Field label="Length" error={errors.minutes}>
|
|
199
|
+
{(id, describedBy) => <Select id={id} describedBy={describedBy} value={draft.minutes} onChange={(v) => edit("minutes", v)} options={LENGTH_OPTIONS} />}
|
|
200
|
+
</Field>
|
|
201
|
+
</Grid>
|
|
202
|
+
<Row justify="end">
|
|
203
|
+
<Button type="submit" loading={saving}>
|
|
204
|
+
Add to {selectedDate.toLocaleDateString(undefined, { month: "short", day: "numeric" })}
|
|
205
|
+
</Button>
|
|
206
|
+
</Row>
|
|
207
|
+
</Stack>
|
|
208
|
+
</form>
|
|
209
|
+
</Dialog>
|
|
210
|
+
);
|
|
211
|
+
|
|
212
|
+
return (
|
|
213
|
+
<main className="mx-auto max-w-2xl px-4 py-8">
|
|
214
|
+
<Stack gap={5}>
|
|
215
|
+
<Row justify="between">
|
|
216
|
+
<Stack gap={1}>
|
|
217
|
+
<Heading level={1}>Studio calendar</Heading>
|
|
218
|
+
<Text muted>Classes and bookings, in time order.</Text>
|
|
219
|
+
</Stack>
|
|
220
|
+
{addAction}
|
|
221
|
+
</Row>
|
|
222
|
+
|
|
223
|
+
<Card variant="tight">
|
|
224
|
+
{/* tight: 16px padding a side leaves room for 7 × 38px days + 6 × 4px gaps = 290px on a 360px phone. */}
|
|
225
|
+
<Stack gap={3}>
|
|
226
|
+
<Row justify="between" wrap={false}>
|
|
227
|
+
<IconButton size="sm" label="Previous month" icon={<Icon name="prev" size="sm" />} onClick={() => step(-1)} />
|
|
228
|
+
<Text>{monthName}</Text>
|
|
229
|
+
<IconButton size="sm" label="Next month" icon={<Icon name="next" size="sm" />} onClick={() => step(1)} />
|
|
230
|
+
</Row>
|
|
231
|
+
<div className="grid grid-cols-7 justify-items-center gap-1">
|
|
232
|
+
{WEEKDAYS.map((w) => (
|
|
233
|
+
<Text key={w} size="sm" muted>
|
|
234
|
+
{w}
|
|
235
|
+
</Text>
|
|
236
|
+
))}
|
|
237
|
+
{monthCells(shown.year, shown.month).map((key, i) => {
|
|
238
|
+
if (key === null) return <span key={`blank-${i}`} />;
|
|
239
|
+
const n = countOn.get(key) ?? 0;
|
|
240
|
+
// Chosen day: solid info. A day with events: solid idle. An empty day: quiet.
|
|
241
|
+
return (
|
|
242
|
+
<IconButton
|
|
243
|
+
key={key}
|
|
244
|
+
size="sm"
|
|
245
|
+
variant={key === selected || n > 0 ? "solid" : "quiet"}
|
|
246
|
+
tone={key === selected ? "info" : "idle"}
|
|
247
|
+
label={`${longDate(key)}, ${plural(n, "event", "events")}`}
|
|
248
|
+
icon={Number(key.slice(8))}
|
|
249
|
+
onClick={() => setSelected(key)}
|
|
250
|
+
/>
|
|
251
|
+
);
|
|
252
|
+
})}
|
|
253
|
+
</div>
|
|
254
|
+
<Row justify="end">
|
|
255
|
+
<Button size="sm" variant="quiet" disabled={isToday && shown.year === now.getFullYear() && shown.month === now.getMonth()} onClick={goToday}>
|
|
256
|
+
Today
|
|
257
|
+
</Button>
|
|
258
|
+
</Row>
|
|
259
|
+
</Stack>
|
|
260
|
+
</Card>
|
|
261
|
+
|
|
262
|
+
<Card>
|
|
263
|
+
<Stack gap={4}>
|
|
264
|
+
<Row justify="between">
|
|
265
|
+
<Stack gap={1}>
|
|
266
|
+
<Row gap={2} wrap={false}>
|
|
267
|
+
<Heading level={2}>{longDate(selected)}</Heading>
|
|
268
|
+
{isToday && <Badge tone="info">Today</Badge>}
|
|
269
|
+
</Row>
|
|
270
|
+
<Text size="sm" muted>
|
|
271
|
+
{plural(dayEvents.length, "event", "events")}
|
|
272
|
+
</Text>
|
|
273
|
+
</Stack>
|
|
274
|
+
</Row>
|
|
275
|
+
|
|
276
|
+
{dayEvents.length === 0 ? (
|
|
277
|
+
<Empty icon="calendar" title="Nothing on this day">
|
|
278
|
+
{canAdd.pending ? undefined : canAdd.ok ? "Add an event and it shows up here in time order." : "A free day."}
|
|
279
|
+
</Empty>
|
|
280
|
+
) : (
|
|
281
|
+
<Stack gap={2}>
|
|
282
|
+
{timeline.map((item, i) => {
|
|
283
|
+
if (item.kind === "now")
|
|
284
|
+
return (
|
|
285
|
+
<Row key="now">
|
|
286
|
+
<Badge tone="info">Now {fromMinutes(now.getHours() * 60 + now.getMinutes())}</Badge>
|
|
287
|
+
</Row>
|
|
288
|
+
);
|
|
289
|
+
if (item.kind === "free")
|
|
290
|
+
return (
|
|
291
|
+
<Row key={`free-${i}`} gap={2} wrap={false}>
|
|
292
|
+
<Icon name="clock" size="sm" />
|
|
293
|
+
<Text size="sm" muted>
|
|
294
|
+
Free {fromMinutes(item.start)} to {fromMinutes(item.end)}
|
|
295
|
+
</Text>
|
|
296
|
+
</Row>
|
|
297
|
+
);
|
|
298
|
+
const ev = item.event;
|
|
299
|
+
const end = fromMinutes(Math.min(24 * 60 - 1, toMinutes(ev.start) + ev.minutes));
|
|
300
|
+
return (
|
|
301
|
+
<RowCard key={ev._id}>
|
|
302
|
+
<Row justify="between" wrap={false}>
|
|
303
|
+
<Row gap={3} wrap={false}>
|
|
304
|
+
<Text size="sm" num muted>
|
|
305
|
+
{ev.start}–{end}
|
|
306
|
+
</Text>
|
|
307
|
+
<Stack gap={1}>
|
|
308
|
+
<Text>{ev.title}</Text>
|
|
309
|
+
<Text size="sm" muted>
|
|
310
|
+
{ev.minutes} min
|
|
311
|
+
</Text>
|
|
312
|
+
</Stack>
|
|
313
|
+
</Row>
|
|
314
|
+
{can.delete(ev).ok && (
|
|
315
|
+
<Confirm
|
|
316
|
+
title="Remove this event?"
|
|
317
|
+
body={`"${ev.title}" comes off the calendar for everyone. This can't be undone.`}
|
|
318
|
+
confirmLabel="Remove event"
|
|
319
|
+
cancelLabel="Keep it"
|
|
320
|
+
tone="down"
|
|
321
|
+
loading={removingId === ev._id}
|
|
322
|
+
onConfirm={() => removeEvent(ev)}
|
|
323
|
+
>
|
|
324
|
+
<IconButton size="sm" tone="down" label={`Remove "${ev.title}"`} loading={removingId === ev._id} icon={<Icon name="remove" size="sm" />} />
|
|
325
|
+
</Confirm>
|
|
326
|
+
)}
|
|
327
|
+
</Row>
|
|
328
|
+
</RowCard>
|
|
329
|
+
);
|
|
330
|
+
})}
|
|
331
|
+
</Stack>
|
|
332
|
+
)}
|
|
333
|
+
</Stack>
|
|
334
|
+
</Card>
|
|
335
|
+
</Stack>
|
|
336
|
+
<div className="pouf-toasts">
|
|
337
|
+
<Toaster />
|
|
338
|
+
</div>
|
|
339
|
+
</main>
|
|
340
|
+
);
|
|
341
|
+
}
|
|
342
|
+
```
|
|
343
|
+
|
|
344
|
+
## Adapting
|
|
345
|
+
|
|
346
|
+
- Keep one document per event with a local `date` (`YYYY-MM-DD`), a `start` (`HH:MM`) and a length in `minutes`; the agenda, free gaps and month dots are all computed from them at render, so nothing is stored twice.
|
|
347
|
+
- Change the hours, not the shape: `DAY_START`, `DAY_END` and `FREE_MIN` set the agenda's working day and the smallest gap worth showing as free. A field such as `room` or `teacher` on the event shows as a muted line under its title, or as an `info` `Badge` when it is a category. Keep the month grid full width on a `variant="tight"` `Card`: seven 38px day buttons and their gaps need 290px, which a default `Card` (32px padding a side) or a `Grid cols="sidebar"` side column does not leave.
|
|
348
|
+
- Everything in this database is readable by anyone who opens the app. A family calendar with private appointments needs an access function that limits who can read them; never put a health note or a phone number in an event title on a shared calendar.
|
|
349
|
+
- To let people subscribe to the schedule on their phones, add the `calendar` skill's webcal/.ics feed from `backend.js` over these same event documents; this block stays the on-screen view.
|
|
350
|
+
- 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. Editing an event in place is `can.edit(event)` behind the same `Dialog`.
|
|
351
|
+
|
|
352
|
+
Adapted from 1st-Pouf (MIT) https://1st-pouf.worksonmy.dev/r/calendar.json
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"pouf-chat.js","sourceRoot":"","sources":["../../jsr/llms/pouf-chat.ts"],"names":[],"mappings":"AAMA,MAAM,CAAC,MAAM,cAAc,GAAc;IACvC,IAAI,EAAE,WAAW;IACjB,KAAK,EAAE,iBAAiB;IACxB,WAAW,EAAE,4DAA4D;CAC1E,CAAC"}
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
# Pouf Chat
|
|
2
|
+
|
|
3
|
+
A group conversation split into rooms: a crew's chat with a room per trip, a band's thread per song, a household's rooms for groceries and plans, a class's channel per project. Reach for it whenever the app's core is people sending each other short messages that read top to bottom as they arrive. Rooms and messages persist and arrive live, so everyone in the app sees a new message as soon as it is sent, on every device.
|
|
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 room list opens with the `Heading` and a muted count; each room is a clickable `RowCard` with its name and a muted line from its latest message, an `Empty` stands in before the first room, and a small form on a `Card` starts a new room with `Start room` as that view's one lead. Opening a room swaps in its thread: a quiet `IconButton` to go back, the room's `Heading`, the messages in a `ScrollArea` (each one a `RowCard` with its author as a `ViewerTag` and a muted time, the visitor's own pushed to the right), and a composer whose solid `Send` `IconButton` is that view's lead. The list scrolls to the newest message as messages arrive. Every write waits on its `can` verdict from `useVibe("chat")`, the database the rooms and messages live in: both forms 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.
|
|
10
|
+
|
|
11
|
+
### `App.jsx`
|
|
12
|
+
```jsx
|
|
13
|
+
import React, { useEffect, useRef, useState } from "react";
|
|
14
|
+
import { useFireproof } from "use-fireproof";
|
|
15
|
+
import { useViewer, useVibe } from "use-vibes";
|
|
16
|
+
import { Button, Card, Empty, Field, Heading, Icon, IconButton, Input, Row, RowCard, Skeleton, Stack, Text } from "@vibes.diy/pouf";
|
|
17
|
+
import { ScrollArea } from "@vibes.diy/pouf/interactive";
|
|
18
|
+
|
|
19
|
+
const when = (ms) => new Date(ms).toLocaleTimeString([], { hour: "numeric", minute: "2-digit" });
|
|
20
|
+
|
|
21
|
+
function Author({ handle }) {
|
|
22
|
+
const { ViewerTag } = useViewer();
|
|
23
|
+
return handle ? <ViewerTag userHandle={handle} /> : <Text size="sm">A guest</Text>;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function Thread({ room, onBack }) {
|
|
27
|
+
const { database, useLiveQuery } = useFireproof("chat");
|
|
28
|
+
const { can, me } = useVibe("chat");
|
|
29
|
+
const { docs } = useLiveQuery("roomId", { key: room._id });
|
|
30
|
+
const messages = [...docs].sort((a, b) => a.createdAt - b.createdAt);
|
|
31
|
+
const [draft, setDraft] = useState("");
|
|
32
|
+
const endRef = useRef(null);
|
|
33
|
+
// The verdict for the doc send writes, carrying the same fields it stamps.
|
|
34
|
+
const canSend = can.create({ type: "message", roomId: room._id, authorHandle: me?.userHandle });
|
|
35
|
+
|
|
36
|
+
useEffect(() => {
|
|
37
|
+
endRef.current?.scrollIntoView({ block: "nearest" });
|
|
38
|
+
}, [messages.length]);
|
|
39
|
+
|
|
40
|
+
async function send(e) {
|
|
41
|
+
e.preventDefault();
|
|
42
|
+
const text = draft.trim();
|
|
43
|
+
if (text === "") return;
|
|
44
|
+
await database.put({ type: "message", roomId: room._id, text, authorHandle: me?.userHandle, createdAt: Date.now() });
|
|
45
|
+
// Cleared only once the write lands, so a local failure keeps the typed words.
|
|
46
|
+
setDraft("");
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return (
|
|
50
|
+
<Stack gap={4}>
|
|
51
|
+
<Row gap={2} wrap={false}>
|
|
52
|
+
<IconButton size="sm" onClick={onBack} label="Back to rooms" icon={<Icon name="prev" size="sm" />} />
|
|
53
|
+
<Heading level={1}>{room.name}</Heading>
|
|
54
|
+
</Row>
|
|
55
|
+
|
|
56
|
+
<Card>
|
|
57
|
+
<Stack gap={4}>
|
|
58
|
+
<ScrollArea maxHeight="420px">
|
|
59
|
+
{messages.length === 0 ? (
|
|
60
|
+
<Empty icon="comment" title="No messages yet">
|
|
61
|
+
Say hello to get the room going.
|
|
62
|
+
</Empty>
|
|
63
|
+
) : (
|
|
64
|
+
<Stack gap={3}>
|
|
65
|
+
{messages.map((m) => (
|
|
66
|
+
<Row key={m._id} justify={m.authorHandle && m.authorHandle === me?.userHandle ? "end" : "start"}>
|
|
67
|
+
<div className="max-w-[80%]">
|
|
68
|
+
<RowCard>
|
|
69
|
+
<Stack gap={1}>
|
|
70
|
+
<Row gap={2} wrap={false}>
|
|
71
|
+
<Author handle={m.authorHandle} />
|
|
72
|
+
<Text size="sm" muted>
|
|
73
|
+
{when(m.createdAt)}
|
|
74
|
+
</Text>
|
|
75
|
+
</Row>
|
|
76
|
+
<Text>{m.text}</Text>
|
|
77
|
+
</Stack>
|
|
78
|
+
</RowCard>
|
|
79
|
+
</div>
|
|
80
|
+
</Row>
|
|
81
|
+
))}
|
|
82
|
+
<div ref={endRef} />
|
|
83
|
+
</Stack>
|
|
84
|
+
)}
|
|
85
|
+
</ScrollArea>
|
|
86
|
+
|
|
87
|
+
{canSend.pending ? (
|
|
88
|
+
<div aria-busy="true">
|
|
89
|
+
<Skeleton variant="row" />
|
|
90
|
+
</div>
|
|
91
|
+
) : !canSend.ok ? (
|
|
92
|
+
<Text muted>You're reading along. Sending opens up once you're in the crew.</Text>
|
|
93
|
+
) : (
|
|
94
|
+
<form onSubmit={send}>
|
|
95
|
+
<Row gap={2} wrap={false}>
|
|
96
|
+
<div className="min-w-0 flex-1">
|
|
97
|
+
<Input value={draft} onChange={setDraft} placeholder={`Message ${room.name}`} label={`Message ${room.name}`} autoComplete="off" />
|
|
98
|
+
</div>
|
|
99
|
+
<IconButton type="submit" variant="solid" disabled={draft.trim() === ""} label="Send" icon={<Icon name="send" size="sm" />} />
|
|
100
|
+
</Row>
|
|
101
|
+
</form>
|
|
102
|
+
)}
|
|
103
|
+
</Stack>
|
|
104
|
+
</Card>
|
|
105
|
+
</Stack>
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export default function App() {
|
|
110
|
+
const { database, useLiveQuery } = useFireproof("chat");
|
|
111
|
+
const { can, me } = useVibe("chat");
|
|
112
|
+
const { docs: roomDocs } = useLiveQuery("type", { key: "room" });
|
|
113
|
+
const { docs: messages } = useLiveQuery("type", { key: "message" });
|
|
114
|
+
const rooms = [...roomDocs].sort((a, b) => a.createdAt - b.createdAt);
|
|
115
|
+
|
|
116
|
+
const [activeId, setActiveId] = useState(undefined);
|
|
117
|
+
const [name, setName] = useState("");
|
|
118
|
+
const [error, setError] = useState(undefined);
|
|
119
|
+
const active = rooms.find((r) => r._id === activeId);
|
|
120
|
+
// The verdict for the doc startRoom writes, carrying the same fields it stamps.
|
|
121
|
+
const canStart = can.create({ type: "room", authorHandle: me?.userHandle });
|
|
122
|
+
|
|
123
|
+
async function startRoom(e) {
|
|
124
|
+
e.preventDefault();
|
|
125
|
+
const text = name.trim();
|
|
126
|
+
if (text === "") {
|
|
127
|
+
setError("Name the room first.");
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
setError(undefined);
|
|
131
|
+
const { id } = await database.put({ type: "room", name: text, authorHandle: me?.userHandle, createdAt: Date.now() });
|
|
132
|
+
setName("");
|
|
133
|
+
setActiveId(id);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function latest(roomId) {
|
|
137
|
+
const inRoom = messages.filter((m) => m.roomId === roomId);
|
|
138
|
+
if (inRoom.length === 0) return "No messages yet";
|
|
139
|
+
return inRoom.reduce((a, b) => (b.createdAt > a.createdAt ? b : a)).text;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return (
|
|
143
|
+
<main className="mx-auto max-w-2xl px-4 py-8">
|
|
144
|
+
{active ? (
|
|
145
|
+
<Thread room={active} onBack={() => setActiveId(undefined)} />
|
|
146
|
+
) : (
|
|
147
|
+
<Stack gap={5}>
|
|
148
|
+
<Stack gap={1}>
|
|
149
|
+
<Heading level={1}>Crew chat</Heading>
|
|
150
|
+
<Text muted>{rooms.length === 1 ? "1 room" : `${rooms.length} rooms`}</Text>
|
|
151
|
+
</Stack>
|
|
152
|
+
|
|
153
|
+
{rooms.length === 0 ? (
|
|
154
|
+
<Empty icon="comment" title="No rooms yet">
|
|
155
|
+
Start one below for the next trip.
|
|
156
|
+
</Empty>
|
|
157
|
+
) : (
|
|
158
|
+
<Stack gap={2}>
|
|
159
|
+
{rooms.map((room) => (
|
|
160
|
+
<RowCard key={room._id} onClick={() => setActiveId(room._id)}>
|
|
161
|
+
<Stack gap={1}>
|
|
162
|
+
<Text truncate>{room.name}</Text>
|
|
163
|
+
<Text size="sm" muted truncate>
|
|
164
|
+
{latest(room._id)}
|
|
165
|
+
</Text>
|
|
166
|
+
</Stack>
|
|
167
|
+
</RowCard>
|
|
168
|
+
))}
|
|
169
|
+
</Stack>
|
|
170
|
+
)}
|
|
171
|
+
|
|
172
|
+
{canStart.pending ? (
|
|
173
|
+
<div aria-busy="true">
|
|
174
|
+
<Skeleton variant="card" />
|
|
175
|
+
</div>
|
|
176
|
+
) : !canStart.ok ? (
|
|
177
|
+
<Text muted>New rooms open up once you're in the crew.</Text>
|
|
178
|
+
) : (
|
|
179
|
+
<Card>
|
|
180
|
+
<form onSubmit={startRoom} noValidate>
|
|
181
|
+
<Stack gap={3}>
|
|
182
|
+
<Field label="New room" error={error}>
|
|
183
|
+
{(id, describedBy) => (
|
|
184
|
+
<Input
|
|
185
|
+
id={id}
|
|
186
|
+
describedBy={describedBy}
|
|
187
|
+
value={name}
|
|
188
|
+
onChange={(value) => {
|
|
189
|
+
setName(value);
|
|
190
|
+
if (error) setError(undefined);
|
|
191
|
+
}}
|
|
192
|
+
placeholder="Squamish, June"
|
|
193
|
+
autoComplete="off"
|
|
194
|
+
invalid={error !== undefined}
|
|
195
|
+
/>
|
|
196
|
+
)}
|
|
197
|
+
</Field>
|
|
198
|
+
<Row justify="end">
|
|
199
|
+
<Button type="submit">Start room</Button>
|
|
200
|
+
</Row>
|
|
201
|
+
</Stack>
|
|
202
|
+
</form>
|
|
203
|
+
</Card>
|
|
204
|
+
)}
|
|
205
|
+
</Stack>
|
|
206
|
+
)}
|
|
207
|
+
</main>
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
## Adapting
|
|
213
|
+
|
|
214
|
+
- Rename rooms and messages to the app's own nouns (`trip`, `song`, `channel`) in the `type` key, the headings and the copy; keep one document per message, linked to its room by `roomId`.
|
|
215
|
+
- An unread marker is per person, not per message: keep it as a `lastRead:<room>:<person>` document with a time, and show the count as a `Badge tone="info"`.
|
|
216
|
+
- A side-by-side list and thread on a wide screen is a `Grid cols="sidebar"` with the thread first; keep the back button for phones.
|
|
217
|
+
- Authors are always `ViewerTag` with the stored handle; the visitor's own messages are told apart by `me.userHandle`, never by a stored name.
|
|
218
|
+
- 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.
|
|
219
|
+
|
|
220
|
+
Adapted from 1st-Pouf (MIT) https://1st-pouf.worksonmy.dev/r/chat.json
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"pouf-contact.js","sourceRoot":"","sources":["../../jsr/llms/pouf-contact.ts"],"names":[],"mappings":"AAMA,MAAM,CAAC,MAAM,iBAAiB,GAAc;IAC1C,IAAI,EAAE,cAAc;IACpB,KAAK,EAAE,oBAAoB;IAC3B,WAAW,EAAE,mEAAmE;CACjF,CAAC"}
|