@vibes.diy/prompts 14.3.77 → 14.3.78
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/access.js +1 -1
- package/llms/access.js.map +1 -1
- package/llms/access.md +43 -22
- package/llms/backend.js.map +1 -1
- package/llms/backend.md +8 -1
- package/llms/callai.js +0 -1
- package/llms/callai.js.map +1 -1
- package/llms/callai.md +4 -4
- package/llms/fireproof.js +0 -1
- package/llms/fireproof.js.map +1 -1
- package/llms/fireproof.md +25 -25
- package/llms/image-gen.js +0 -1
- package/llms/image-gen.js.map +1 -1
- package/llms/pouf-event.md +2 -2
- package/llms/pouf-login.md +1 -1
- package/llms/pouf-quiz.md +1 -1
- package/llms/pouf-settings.md +1 -1
- package/llms/pouf-todo.md +1 -1
- package/llms/types.d.ts +0 -1
- package/llms/use-vibe.js +0 -1
- package/llms/use-vibe.js.map +1 -1
- package/llms/use-viewer.js +0 -1
- package/llms/use-viewer.js.map +1 -1
- package/llms/web-audio.md +1 -1
- package/mcp-footer.md +4 -1
- package/package.json +4 -4
- package/prompts.js +1 -1
- package/prompts.js.map +1 -1
- package/system-prompt-initial-oneshot.md +27 -16
- package/system-prompt-initial.md +28 -15
- package/system-prompt.md +19 -9
- package/llms/callai.initial.md +0 -265
- package/llms/fireproof.initial.md +0 -869
- package/llms/image-gen.initial.md +0 -220
- package/llms/use-vibe.initial.md +0 -59
- package/llms/use-viewer.initial.md +0 -319
|
@@ -1,869 +0,0 @@
|
|
|
1
|
-
# Fireproof Database API Guide
|
|
2
|
-
|
|
3
|
-
Fireproof is a document database with live sync, designed to make browser apps easy. On vibes.diy it runs against Firefly: each app holds a local replica (IndexedDB) that IS the database, so writes succeed locally and instantly and then sync in the background. The Firefly server validates every synced write on ingest and can still reject it (a conflict, or a write the platform does not admit); accepted writes stream live to every viewer. Use it in any JavaScript environment with a unified API that works both in React (with hooks) and as a standalone core API.
|
|
4
|
-
|
|
5
|
-
## Key Features
|
|
6
|
-
|
|
7
|
-
- **Apps run anywhere:** Bundle UI, data, and logic together.
|
|
8
|
-
- **Real-Time, local-first:** Writes land in the local replica instantly and sync in the background; the server validates each on ingest and streams the accepted ones live to every viewer. `useLiveQuery` keeps the UI in sync as data arrives, so you render empty states rather than loading spinners — but a synced write can still be rejected on ingest (a refusal, a conflict): the local optimistic revision converges **server-wins**, and the platform surfaces a reason only when it has one; a reason-less rejection converges silently. Keep the UI reactive to the store rather than depending on a toast; don't assume every write lands, but don't hand-roll denial errors either.
|
|
9
|
-
- **Unified API:** TypeScript works with Deno, Bun, Node.js, and the browser.
|
|
10
|
-
- **React Hooks:** Leverage `useLiveQuery` and `useDocument` for live collaboration. Note: these are NOT top-level exports — they are returned by the `useFireproof()` hook. Always destructure from `const { useLiveQuery, useDocument, database } = useFireproof("dbName")`. A child component that was handed `database` may call `useFireproof(database)` instead of repeating the name; it is the same database.
|
|
11
|
-
|
|
12
|
-
**File structure:** A vibe's source is one or more files. `/App.jsx` is the entry point (React component).
|
|
13
|
-
|
|
14
|
-
Fireproof enforces cryptographic causal consistency and ledger integrity using hash history, providing git-like versioning with lightweight blockchain-style verification. On vibes.diy, a write commits to the local replica immediately and syncs to the Firefly server in the background; the server is the authority on acceptance: it stores each document in a per-document append-only sequence, validates and routes it on ingest, and then syncs it to viewers. Because every synced write is validated server-side on ingest, it can be rejected.
|
|
15
|
-
|
|
16
|
-
## Installation
|
|
17
|
-
|
|
18
|
-
The `use-fireproof` package provides both the core API and React hooks. React hooks are the recommended way to use Fireproof in LLM code generation contexts. Fireproof databases persist data through the Firefly server and sync it live to every viewer. Each database is identified by a string name, and you can have multiple databases per application—often one per collaboration session, as they are the unit of sharing. Database names are the app's own to choose — pick a plain camelCase name for every database the app creates; prefix-namespaced names like `media:*` are platform-reserved for the databases platform components bring with them.
|
|
19
|
-
|
|
20
|
-
Each document has an `_id`, which can be auto-generated or set explicitly. Auto-generation is recommended to ensure uniqueness and avoid conflicts. The server keeps a per-document sequence, so two clients writing the same `_id` at the same time can collide and one write will be rejected — see the note on continuous updates below. Prefer one document per event over many rapid writes to a single hot document.
|
|
21
|
-
|
|
22
|
-
Use granular documents, e.g. one document per user action, so saving a form or clicking a button should typically create or update a single document, or just a few documents. Avoid patterns that require a single document to grow without bound.
|
|
23
|
-
|
|
24
|
-
`useLiveQuery` populates and refreshes the UI reactively as data arrives, so you usually render empty states rather than loading spinners. Writes are optimistic and local: `put()`/`save()`/`del()` land in the local replica instantly, and transport or offline failures queue and retry automatically. If the server rejects a synced write, the local optimistic revision converges **server-wins** (it is overwritten by the server's version) — and the **platform** surfaces the reason in a write-fail toast whenever it has one: the app's own `forbidden("…")` text, or the platform's message when a signed-in visitor has no write access to the app (both also reach the app as `writeRejection`, below); a reason-less rejection converges silently. So do **not** wrap plain writes in `try/catch` to show denial errors to the user (a save carrying `_files` is the one exception, covered with the image uploader below); keep the UI reactive to the store (`useLiveQuery`/`useDocument`) so the converged state is the feedback, and gate write surfaces up front with `useVibe(dbName).can.create/edit/delete` so a disallowed action never renders in the first place.
|
|
25
|
-
|
|
26
|
-
When the server later refuses a write the visitor can act on — a signed-in visitor saving to a private app, say — the platform shows a notice and `useFireproof` hands the app the same reason as `writeRejection` (`{ docId, message }`, or `null` until a save is refused). Render `writeRejection.message` beside the form it came from and call `clearWriteRejection()` when the visitor dismisses it:
|
|
27
|
-
|
|
28
|
-
```jsx
|
|
29
|
-
function NoteForm({ text }) {
|
|
30
|
-
const { database, writeRejection, clearWriteRejection } = useFireproof("notes");
|
|
31
|
-
return (
|
|
32
|
-
<div>
|
|
33
|
-
<button onClick={() => database.put({ type: "note", text })}>Save</button>
|
|
34
|
-
{writeRejection && (
|
|
35
|
-
<p role="status">
|
|
36
|
-
{writeRejection.message} <button onClick={clearWriteRejection}>Dismiss</button>
|
|
37
|
-
</p>
|
|
38
|
-
)}
|
|
39
|
-
</div>
|
|
40
|
-
);
|
|
41
|
-
}
|
|
42
|
-
```
|
|
43
|
-
|
|
44
|
-
### Basic Example
|
|
45
|
-
|
|
46
|
-
Wire these visible actions in the first working app, using its own record names. One composer has Add and Ask actions. Add extracts and normalizes facts, saves each record immediately, and shows saved records with edit actions; one item and a pasted batch share this action. Each saved row has Change by sentence: selecting it changes Add to Apply correction, which saves onto that same record id. Ask sends the typed question with the relevant saved records and displays a read-only answer beside the composer. Edit opens the selected row’s populated fields. One parent-owned Undo restores the latest successful creation batch or the previous document from either a sentence correction or a field edit.
|
|
47
|
-
|
|
48
|
-
With the callai skill available, submitting words extracts and normalizes every task they name, then immediately saves one document per task. The saved task is the confirmation and remains editable, with Undo beside the intake after each save. The parent keeps the ids from the last creation or the previous version from an edit, so intake and saved-detail editors share one Undo action. `useLiveQuery` keeps saved tasks visible; `useDocument` supplies the detail editor only after a record exists. For a plain message whose text is already the whole record, save that text directly.
|
|
49
|
-
|
|
50
|
-
### `App.jsx`
|
|
51
|
-
```jsx
|
|
52
|
-
import React, { useState } from "react";
|
|
53
|
-
import { callAI } from "call-ai";
|
|
54
|
-
import { useFireproof } from "use-fireproof";
|
|
55
|
-
import { useVibe } from "use-vibes";
|
|
56
|
-
|
|
57
|
-
const taskSchema = {
|
|
58
|
-
type: "object",
|
|
59
|
-
properties: {
|
|
60
|
-
title: { type: "string", description: "The task in the person's words" },
|
|
61
|
-
details: { type: "string", description: "Supporting details in the person’s words" },
|
|
62
|
-
},
|
|
63
|
-
required: ["title", "details"],
|
|
64
|
-
};
|
|
65
|
-
|
|
66
|
-
function normalizeTask(raw) {
|
|
67
|
-
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) return undefined;
|
|
68
|
-
const title = typeof raw.title === "string" ? raw.title.trim() : "";
|
|
69
|
-
const details = typeof raw.details === "string" ? raw.details.trim() : "";
|
|
70
|
-
return title.length > 0 ? { title, details } : undefined;
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
// Single-record extraction and edit-by-sentence share a schema and normalizer.
|
|
74
|
-
async function readTask(sentence, current) {
|
|
75
|
-
const facts = current === undefined ? {} : { title: current.title, details: current.details };
|
|
76
|
-
const response = await callAI(
|
|
77
|
-
`Extract one task’s title and supporting details from the person's words. Update only what the correction mentions when a current task is supplied. Today: ${new Date().toISOString().slice(0, 10)}. Current: ${JSON.stringify(facts)}. Person: ${sentence}`,
|
|
78
|
-
{ schema: taskSchema }
|
|
79
|
-
);
|
|
80
|
-
const task = normalizeTask(JSON.parse(response));
|
|
81
|
-
if (task === undefined) throw new Error("Add a task in your own words and try again.");
|
|
82
|
-
return task;
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
// A pasted list uses an array of the same flat document fields.
|
|
86
|
-
async function readTasks(sentence) {
|
|
87
|
-
const response = await callAI(
|
|
88
|
-
`Extract every task from these words as a separate item, including when the person names just one task. Keep their title and supporting details. Today: ${new Date().toISOString().slice(0, 10)}. List: ${sentence}`,
|
|
89
|
-
{ schema: { properties: { items: { type: "array", items: taskSchema } }, required: ["items"] } }
|
|
90
|
-
);
|
|
91
|
-
const raw = JSON.parse(response);
|
|
92
|
-
const items = Array.isArray(raw?.items) ? raw.items.map(normalizeTask) : [];
|
|
93
|
-
if (items.length === 0 || items.some((item) => item === undefined)) {
|
|
94
|
-
throw new Error("Describe the tasks you want to add and try again.");
|
|
95
|
-
}
|
|
96
|
-
return items;
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
function TaskCard({ task, database, can, onCorrect, onSaved, disabled }) {
|
|
100
|
-
const { useDocument } = useFireproof(database);
|
|
101
|
-
const { doc, merge, save } = useDocument(task);
|
|
102
|
-
const [editing, setEditing] = useState(false);
|
|
103
|
-
return <article>
|
|
104
|
-
<h2>{task.title}</h2><p>{task.details}</p><p>{task.dueDate}</p>
|
|
105
|
-
{can.edit(task).ok && <>
|
|
106
|
-
<button type="button" disabled={disabled} onClick={() => onCorrect(task)}>Change by sentence</button>
|
|
107
|
-
<button type="button" disabled={disabled} onClick={() => setEditing(true)}>Edit details</button>
|
|
108
|
-
{editing && <form onSubmit={async (event) => { event.preventDefault(); if (disabled) return; await save(); onSaved(task); setEditing(false); }}>
|
|
109
|
-
<input disabled={disabled} aria-label="Task" value={doc.title} onChange={(event) => merge({ title: event.target.value })} />
|
|
110
|
-
<textarea disabled={disabled} aria-label="Details" value={doc.details} onChange={(event) => merge({ details: event.target.value })} />
|
|
111
|
-
<input disabled={disabled} aria-label="Due date" type="date" value={doc.dueDate} onChange={(event) => merge({ dueDate: event.target.value })} />
|
|
112
|
-
<button type="submit" disabled={disabled}>Save details</button>
|
|
113
|
-
</form>}
|
|
114
|
-
</>}
|
|
115
|
-
</article>;
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
export default function App() {
|
|
119
|
-
const { database, useLiveQuery, writeRejection, clearWriteRejection } = useFireproof("tasks");
|
|
120
|
-
const { me, can, ready } = useVibe("tasks");
|
|
121
|
-
const { docs } = useLiveQuery("type", { key: "task" });
|
|
122
|
-
const [sentence, setSentence] = useState("");
|
|
123
|
-
const [current, setCurrent] = useState(undefined);
|
|
124
|
-
const [pending, setPending] = useState(undefined);
|
|
125
|
-
const [answer, setAnswer] = useState("");
|
|
126
|
-
const [busy, setBusy] = useState(false);
|
|
127
|
-
const [message, setMessage] = useState("");
|
|
128
|
-
// Creation, sentence correction and detail editing replace this one Undo receipt after a successful write.
|
|
129
|
-
const [lastWrite, setLastWrite] = useState(undefined);
|
|
130
|
-
async function submitSentence(event) {
|
|
131
|
-
event.preventDefault();
|
|
132
|
-
if (busy || !ready) return;
|
|
133
|
-
setBusy(true); setMessage("");
|
|
134
|
-
try {
|
|
135
|
-
// A failed local batch resumes its unsaved tail without extracting or duplicating earlier records.
|
|
136
|
-
let operation = pending;
|
|
137
|
-
if (operation === undefined) {
|
|
138
|
-
const target = current === undefined ? undefined : docs.find((doc) => doc._id === current._id);
|
|
139
|
-
if (current !== undefined && target === undefined) throw new Error("Choose a saved task to correct.");
|
|
140
|
-
const items = target === undefined ? await readTasks(sentence) : [await readTask(sentence, target)];
|
|
141
|
-
const remaining = items.map((item) => target === undefined
|
|
142
|
-
? { _id: crypto.randomUUID(), type: "task", title: item.title, details: item.details, dueDate: "", authorHandle: me?.userHandle }
|
|
143
|
-
: { ...target, title: item.title, details: item.details });
|
|
144
|
-
operation = { remaining, created: [], replaced: target };
|
|
145
|
-
}
|
|
146
|
-
if (operation.remaining.some((doc) => operation.replaced === undefined ? can.create(doc).ok === false : can.edit(doc).ok === false)) {
|
|
147
|
-
throw new Error("This task is not editable right now.");
|
|
148
|
-
}
|
|
149
|
-
setPending(operation);
|
|
150
|
-
while (operation.remaining.length > 0) {
|
|
151
|
-
const doc = operation.remaining[0];
|
|
152
|
-
await database.put(doc);
|
|
153
|
-
operation = { ...operation, remaining: operation.remaining.slice(1), created: operation.replaced === undefined ? [...operation.created, doc._id] : [] };
|
|
154
|
-
setPending(operation);
|
|
155
|
-
setLastWrite({ created: operation.created, replaced: operation.replaced });
|
|
156
|
-
}
|
|
157
|
-
setPending(undefined);
|
|
158
|
-
setSentence(""); setCurrent(undefined);
|
|
159
|
-
} catch (error) {
|
|
160
|
-
// Preserve the words and any unsaved tail on local errors. Synced refusals converge through useLiveQuery.
|
|
161
|
-
setMessage(error.reason === "refusal" ? "Try describing the task another way." : error.message);
|
|
162
|
-
} finally { setBusy(false); }
|
|
163
|
-
}
|
|
164
|
-
async function askRecords() {
|
|
165
|
-
if (busy || !ready || pending !== undefined || sentence.trim().length === 0) return;
|
|
166
|
-
setBusy(true); setMessage("");
|
|
167
|
-
try {
|
|
168
|
-
const response = await callAI(
|
|
169
|
-
`Answer from these records. Today: ${new Date().toISOString().slice(0, 10)}. Records: ${JSON.stringify(docs.map(({ title, details, dueDate }) => ({ title, details, dueDate })))}. Question: ${sentence}`,
|
|
170
|
-
{ schema: { properties: { answer: { type: "string" } }, required: ["answer"] } }
|
|
171
|
-
);
|
|
172
|
-
const raw = JSON.parse(response);
|
|
173
|
-
if (typeof raw?.answer !== "string") throw new Error("Ask your question again.");
|
|
174
|
-
setAnswer(raw.answer); setSentence("");
|
|
175
|
-
} catch (error) {
|
|
176
|
-
setMessage(error.message);
|
|
177
|
-
} finally { setBusy(false); }
|
|
178
|
-
}
|
|
179
|
-
// A new record is one del away; a corrected one is one put of its previous version away.
|
|
180
|
-
async function undoLast() {
|
|
181
|
-
if (busy || lastWrite === undefined) return;
|
|
182
|
-
setBusy(true); setMessage("");
|
|
183
|
-
try {
|
|
184
|
-
for (const id of lastWrite.created) await database.del(id);
|
|
185
|
-
if (lastWrite.replaced !== undefined) await database.put(lastWrite.replaced);
|
|
186
|
-
setLastWrite(undefined); setPending(undefined); setCurrent(undefined);
|
|
187
|
-
} catch (error) {
|
|
188
|
-
setMessage(error.message);
|
|
189
|
-
} finally { setBusy(false); }
|
|
190
|
-
}
|
|
191
|
-
return <main>
|
|
192
|
-
<h1>Tasks in your own words</h1>
|
|
193
|
-
<form onSubmit={submitSentence}>
|
|
194
|
-
<textarea aria-label="Describe tasks, a correction, or ask a question" value={sentence} disabled={busy || pending !== undefined} onChange={(event) => setSentence(event.target.value)} />
|
|
195
|
-
{current !== undefined && <p>Changing: {current.title} <button type="button" disabled={busy || pending !== undefined} onClick={() => { setCurrent(undefined); setSentence(""); }}>Add a new task</button></p>}
|
|
196
|
-
<button type="submit" disabled={!ready || busy || sentence.trim().length === 0}>{busy ? "Adding your changes…" : pending !== undefined ? "Retry remaining tasks" : current === undefined ? "Add tasks" : "Apply correction"}</button>
|
|
197
|
-
<button type="button" disabled={!ready || busy || pending !== undefined || sentence.trim().length === 0} onClick={askRecords}>Ask about tasks</button>
|
|
198
|
-
</form>
|
|
199
|
-
<p>{answer}</p>
|
|
200
|
-
<p role="status">{message}</p>
|
|
201
|
-
{writeRejection && <p role="status">{writeRejection.message} <button type="button" onClick={clearWriteRejection}>Dismiss</button></p>}
|
|
202
|
-
{lastWrite !== undefined && <button type="button" disabled={busy} onClick={undoLast}>Undo</button>}
|
|
203
|
-
{pending !== undefined && !busy && <button type="button" onClick={() => { setPending(undefined); setSentence(""); setCurrent(undefined); }}>Discard unsaved tasks</button>}
|
|
204
|
-
{docs.map((task) => <TaskCard key={task._id} task={task} database={database} can={can} disabled={busy || pending !== undefined} onSaved={(previous) => setLastWrite({ created: [], replaced: previous })} onCorrect={(doc) => { if (busy || pending !== undefined) return; setCurrent(doc); setSentence(""); }} />)}
|
|
205
|
-
</main>;
|
|
206
|
-
}
|
|
207
|
-
```
|
|
208
|
-
|
|
209
|
-
### Editing Documents
|
|
210
|
-
|
|
211
|
-
Address an existing record by its `_id`. `useDocument(task)` above holds the selected record's editable fields: `merge(updates)` changes the draft, `save()` writes it, and `reset()` restores its initial state. `submit(event)` combines prevent-default, save and reset. Choose the operation that matches the editor's lifecycle. For correction by sentence, pass the current editable fields and the person's correction to the same extraction schema, normalize the result and immediately update the existing document in that submit action with its trusted `_id` and identity fields intact. The callai skill's Text to records example includes that path and pasted lists.
|
|
212
|
-
|
|
213
|
-
## Required starting records
|
|
214
|
-
|
|
215
|
-
When a request names records the app must already have — the three projects in a
|
|
216
|
-
studio, a template's starter rows, the rooms in a planner — those records are
|
|
217
|
-
created by the app's own first render through the ordinary write path. That is
|
|
218
|
-
the mechanism, and it is the same one described below for starter content.
|
|
219
|
-
`seed.json` is a separate thing: an optional import a person runs by hand from
|
|
220
|
-
the Data tab. Its sample records also populate the temporary, view-only preview
|
|
221
|
-
while saving is being prepared; they become real saved records only through an
|
|
222
|
-
explicit import. In that file, give each item a stable `key`; its derived identity
|
|
223
|
-
is `seed:<key>`. References to another item use that derived identity. An `_id`
|
|
224
|
-
that repeats the item's own derived identity is equivalent to leaving it out.
|
|
225
|
-
Explicit singleton identities such as `config:board` remain available.
|
|
226
|
-
|
|
227
|
-
Four rules make it hold:
|
|
228
|
-
|
|
229
|
-
- **Each required record gets a fixed `_id` you choose** (`"project:atlas"`), so
|
|
230
|
-
a second write of it lands on the same document instead of minting another.
|
|
231
|
-
- **Create them after `ready` and after `can.create` says yes**, so a write the
|
|
232
|
-
platform does not admit yet is skipped quietly and retried on a later run.
|
|
233
|
-
- **Write the object before the records that belong to it**, so a dependent
|
|
234
|
-
document never lands against a container that does not exist yet.
|
|
235
|
-
|
|
236
|
-
Fixed ids are also what makes two tabs safe. Two browsers opening the app for the
|
|
237
|
-
first time at the same moment both see an empty query and both write; with fixed
|
|
238
|
-
ids those are two writes of the same document, and last-writer-wins converges on
|
|
239
|
-
one copy. Reading the query first and then deciding whether to write is the shape
|
|
240
|
-
that races — the read is empty in both tabs before either write lands.
|
|
241
|
-
|
|
242
|
-
### How records are shaped
|
|
243
|
-
|
|
244
|
-
Every record a person writes carries two things beyond its own content: **the id of the object it belongs to**, and **`authorHandle`, the handle of whoever wrote it**. The object itself carries **`creatorHandle`**. Write them at create and leave them alone afterwards — a later editor's handle then always finds the ids already on the doc rather than supplying one.
|
|
245
|
-
|
|
246
|
-
Each signed-in person also gets **an object of their own with no doc to create**: its id is derived from their handle, `` `default-${me.userHandle}` ``. So someone who has just arrived types once and their first record lands, with nothing for them to set up first — and an app one person keeps for themselves is just this, with one object in it.
|
|
247
|
-
|
|
248
|
-
```jsx
|
|
249
|
-
import { useFireproof } from "use-fireproof";
|
|
250
|
-
import { useVibe } from "use-vibes";
|
|
251
|
-
|
|
252
|
-
function Board({ pickedBoardId }) {
|
|
253
|
-
const { useLiveQuery, database } = useFireproof("boards");
|
|
254
|
-
const { me, can } = useVibe("boards");
|
|
255
|
-
// No board doc needed to start: every person has one derived from their handle.
|
|
256
|
-
const boardId = pickedBoardId ?? `default-${me?.userHandle}`;
|
|
257
|
-
const { docs: cards } = useLiveQuery("boardId", { key: boardId });
|
|
258
|
-
|
|
259
|
-
// A card names its board and its author, both at create.
|
|
260
|
-
const addCard = (text) => database.put({ type: "card", boardId, authorHandle: me?.userHandle, text });
|
|
261
|
-
// A board someone explicitly makes names the person who made it.
|
|
262
|
-
const addBoard = (title) => database.put({ type: "board", creatorHandle: me.userHandle, title });
|
|
263
|
-
|
|
264
|
-
return (
|
|
265
|
-
<div>
|
|
266
|
-
{cards.map((card) => (
|
|
267
|
-
<p key={card._id}>{card.text}</p>
|
|
268
|
-
))}
|
|
269
|
-
{can.create({ type: "card", boardId, authorHandle: me?.userHandle }).ok && <AddCardForm onAdd={addCard} />}
|
|
270
|
-
</div>
|
|
271
|
-
);
|
|
272
|
-
}
|
|
273
|
-
```
|
|
274
|
-
|
|
275
|
-
Queries read off those same ids — `useLiveQuery("boardId", { key: boardId })` above — so the fields earn their place immediately rather than being bookkeeping for later.
|
|
276
|
-
|
|
277
|
-
### Match sample records to the maker's opening query
|
|
278
|
-
|
|
279
|
-
The initial build's preview-maker context supplies the actual maker handle.
|
|
280
|
-
Use it to evaluate the opening query's group ID when writing `seed.json`.
|
|
281
|
-
For a maker named `mira`, the runtime expression below selects `default-mira`,
|
|
282
|
-
so every sample card in this opening board carries that exact `boardId`:
|
|
283
|
-
|
|
284
|
-
```jsx
|
|
285
|
-
const boardId = pickedBoardId ?? `default-${me?.userHandle}`;
|
|
286
|
-
const { docs: cards } = useLiveQuery("boardId", { key: boardId });
|
|
287
|
-
```
|
|
288
|
-
|
|
289
|
-
```json
|
|
290
|
-
{
|
|
291
|
-
"boards": [
|
|
292
|
-
{ "key": "tent", "type": "card", "boardId": "default-mira", "text": "Blue tent, sleeps four" },
|
|
293
|
-
{ "key": "stove", "type": "card", "boardId": "default-mira", "text": "Two-burner camp stove" },
|
|
294
|
-
{ "key": "lantern", "type": "card", "boardId": "default-mira", "text": "Two rechargeable lanterns" },
|
|
295
|
-
{ "key": "cooler", "type": "card", "boardId": "default-mira", "text": "Wheeled cooler" }
|
|
296
|
-
]
|
|
297
|
-
}
|
|
298
|
-
```
|
|
299
|
-
|
|
300
|
-
Substitute the supplied maker handle in the sample values; keep the app's query
|
|
301
|
-
and real writes driven by the current viewer and selected object. The database
|
|
302
|
-
name, record type, parent references and query filters agree across the sample
|
|
303
|
-
file and opening view. Later edits preserve those parent references.
|
|
304
|
-
The platform labels the temporary preview as sample data; those records reach
|
|
305
|
-
persistent storage only through the person's explicit import.
|
|
306
|
-
|
|
307
|
-
### Seeding starter data — give each seed a deterministic `_id`
|
|
308
|
-
|
|
309
|
-
When an app ships with default content — starter slides, template rows, example cards — and writes it into the database on first load, give **each seed document a deterministic `_id`** (`"seed:intro"`, `"seed:" + key`). This makes the seeding write **idempotent**: if it ever runs again, it overwrites the same documents instead of creating fresh copies.
|
|
310
|
-
|
|
311
|
-
The trap is the live-query empty state. `useLiveQuery` renders empty before data arrives, so `docs.length === 0` is briefly true on **every** fresh load — and a `useRef`/`useState` "already seeded" flag only guards the current session, never the next reload, a second device, or another collaborator. With **auto-generated** `_id`s, each of those re-seeds the full set and duplicate copies pile up without bound. Deterministic `_id`s collapse every re-seed back onto the same N documents.
|
|
312
|
-
|
|
313
|
-
```jsx
|
|
314
|
-
import React from "react";
|
|
315
|
-
import { useFireproof } from "use-fireproof";
|
|
316
|
-
import { useVibe } from "use-vibes";
|
|
317
|
-
|
|
318
|
-
// Stable _id per seed, and no Date.now()/random fields — keep the payload
|
|
319
|
-
// constant so a redundant seed is a true no-op, not a churning rewrite.
|
|
320
|
-
const SEED = [
|
|
321
|
-
{ _id: "seed:intro", type: "slide", title: "Welcome", position: 1000 },
|
|
322
|
-
{ _id: "seed:problem", type: "slide", title: "The problem", position: 2000 },
|
|
323
|
-
{ _id: "seed:ask", type: "slide", title: "The ask", position: 3000 },
|
|
324
|
-
];
|
|
325
|
-
|
|
326
|
-
export default function App() {
|
|
327
|
-
const { useLiveQuery, database } = useFireproof("deck");
|
|
328
|
-
const { ready, can } = useVibe("deck");
|
|
329
|
-
const { docs: slides } = useLiveQuery("position");
|
|
330
|
-
|
|
331
|
-
React.useEffect(() => {
|
|
332
|
-
if (slides.length > 0) return; // already has content — don't seed
|
|
333
|
-
// Ask `can.create` before writing. A doc type the platform doesn't admit
|
|
334
|
-
// yet (e.g. one this very edit just introduced — the update binds only
|
|
335
|
-
// after the whole turn lands) would be rejected: skip quietly now and seed
|
|
336
|
-
// on a later run instead of spamming errors.
|
|
337
|
-
if (!ready || !can.create(SEED[0]).ok) return;
|
|
338
|
-
// Deterministic _id → idempotent. Even if this runs again (another load or
|
|
339
|
-
// device before the query resolves), it overwrites the same docs instead of
|
|
340
|
-
// minting new ones, so seed slides never duplicate.
|
|
341
|
-
SEED.forEach((s) => database.put(s).catch((e) => console.error(e)));
|
|
342
|
-
}, [slides.length, ready, can, database]);
|
|
343
|
-
|
|
344
|
-
return <Deck slides={slides} />;
|
|
345
|
-
}
|
|
346
|
-
```
|
|
347
|
-
|
|
348
|
-
Don't stamp `Date.now()` or other changing values into seed documents — that makes each re-seed rewrite the doc with new content, churning revisions even though the `_id` is stable. Resource-like docs — categories, profiles, **seeds** — get deterministic `_id`s; event/content docs let `_id` auto-generate.
|
|
349
|
-
|
|
350
|
-
### Updating Documents in Event Handlers
|
|
351
|
-
|
|
352
|
-
To update an existing document from a click handler or callback, use `database.put()` directly. Never call `useDocument` inside an event handler — that violates React's Rules of Hooks. Adding a toggle to list items:
|
|
353
|
-
|
|
354
|
-
### `App.jsx`
|
|
355
|
-
```jsx
|
|
356
|
-
<<<<<<< SEARCH
|
|
357
|
-
const { useDocument, useLiveQuery } = useFireproof("myLedger");
|
|
358
|
-
=======
|
|
359
|
-
const { useDocument, useLiveQuery, database } = useFireproof("myLedger");
|
|
360
|
-
>>>>>>> REPLACE
|
|
361
|
-
```
|
|
362
|
-
|
|
363
|
-
### `App.jsx`
|
|
364
|
-
```jsx
|
|
365
|
-
<<<<<<< SEARCH
|
|
366
|
-
<li key={doc._id}>{doc.text}</li>
|
|
367
|
-
=======
|
|
368
|
-
<li key={doc._id}>
|
|
369
|
-
{doc.text}
|
|
370
|
-
<button onClick={() => database.put({ ...doc, favorite: !doc.favorite })}>
|
|
371
|
-
{doc.favorite ? "★" : "☆"}
|
|
372
|
-
</button>
|
|
373
|
-
</li>
|
|
374
|
-
>>>>>>> REPLACE
|
|
375
|
-
```
|
|
376
|
-
|
|
377
|
-
Never call hooks inside handlers — `const { doc, save } = useDocument({ _id: id })` inside an onClick BREAKS the Rules of Hooks.
|
|
378
|
-
|
|
379
|
-
### Continuous Controls — `merge()` on every event, `save()` once on commit
|
|
380
|
-
|
|
381
|
-
A continuous control (slider, drag, color picker, live-typed text bound to one doc) fires many events in quick succession. **Never call `database.put()` or `save()` on every `onChange`** — that floods the server with rapid concurrent writes to the same `_id`, which collide on the per-document sequence and get rejected (`Failed to put document …`). Instead, `merge()` locally on each event to keep the UI live, and `save()` once when the interaction commits (`onPointerUp`, `onBlur`, `onChange` for a range input's final value, or a debounced trailing call). Editing a single doc with a slider:
|
|
382
|
-
|
|
383
|
-
### `App.jsx`
|
|
384
|
-
```jsx
|
|
385
|
-
<<<<<<< SEARCH
|
|
386
|
-
const { useDocument, useLiveQuery, database } = useFireproof("myLedger");
|
|
387
|
-
=======
|
|
388
|
-
const { useDocument, useLiveQuery, database } = useFireproof("myLedger");
|
|
389
|
-
|
|
390
|
-
// One shared mix doc; slider updates the UI on every event, persists on commit
|
|
391
|
-
const { doc: mix, merge: mergeMix, save: saveMix } = useDocument({ _id: "mix:current", level: 50 });
|
|
392
|
-
>>>>>>> REPLACE
|
|
393
|
-
```
|
|
394
|
-
|
|
395
|
-
### `App.jsx`
|
|
396
|
-
```jsx
|
|
397
|
-
<<<<<<< SEARCH
|
|
398
|
-
<h3>Recent Documents</h3>
|
|
399
|
-
=======
|
|
400
|
-
<input
|
|
401
|
-
type="range"
|
|
402
|
-
min="0"
|
|
403
|
-
max="100"
|
|
404
|
-
value={mix.level}
|
|
405
|
-
onChange={(e) => mergeMix({ level: Number(e.target.value) })} // live, no write
|
|
406
|
-
onPointerUp={() => saveMix().catch((err) => console.error("save failed", err))} // one write on commit
|
|
407
|
-
/>
|
|
408
|
-
|
|
409
|
-
<h3>Recent Documents</h3>
|
|
410
|
-
>>>>>>> REPLACE
|
|
411
|
-
```
|
|
412
|
-
|
|
413
|
-
If you genuinely need to persist mid-drag, debounce the `save()` so at most one write is in flight, and still `.catch()` the rejection. Better yet, prefer one document per event (see the Counter Pattern) over hammering a single hot document.
|
|
414
|
-
|
|
415
|
-
### Query Data
|
|
416
|
-
|
|
417
|
-
Data is queried by sorted indexes defined by the application. Sort by strings, numbers, or booleans, as well as arrays for grouping. Use numbers when possible for sorting continuous data. You can use the `_id` field for temporal sorting so you don't have to write code to get simple recent document lists, as in the basic example above.
|
|
418
|
-
|
|
419
|
-
#### Query by Key Range
|
|
420
|
-
|
|
421
|
-
Passing a string to `useLiveQuery` will index by that field. Use the key argument to filter by a specific value, or range for bounded queries. Switching from temporal to filtered query:
|
|
422
|
-
|
|
423
|
-
### `App.jsx`
|
|
424
|
-
```jsx
|
|
425
|
-
<<<<<<< SEARCH
|
|
426
|
-
const { docs } = useLiveQuery("_id", { descending: true, limit: 100 });
|
|
427
|
-
=======
|
|
428
|
-
// all docs where doc.agentName === "agent-1", sorted by _id
|
|
429
|
-
const { docs } = useLiveQuery("agentName", { key: "agent-1" });
|
|
430
|
-
>>>>>>> REPLACE
|
|
431
|
-
```
|
|
432
|
-
|
|
433
|
-
Or query a numeric range:
|
|
434
|
-
|
|
435
|
-
### `App.jsx`
|
|
436
|
-
```jsx
|
|
437
|
-
<<<<<<< SEARCH
|
|
438
|
-
const { docs } = useLiveQuery("agentName", { key: "agent-1" });
|
|
439
|
-
=======
|
|
440
|
-
// docs with agentRating between 3 and 5
|
|
441
|
-
const { docs } = useLiveQuery("agentRating", { range: [3, 5] });
|
|
442
|
-
>>>>>>> REPLACE
|
|
443
|
-
```
|
|
444
|
-
|
|
445
|
-
#### Counter Pattern
|
|
446
|
-
|
|
447
|
-
Documents can be updated by multiple clients, and synced later. To create an event counter, don't increment a number on a single doc, instead write a small document per counted event, and query them with an index:
|
|
448
|
-
|
|
449
|
-
### `App.jsx`
|
|
450
|
-
```jsx
|
|
451
|
-
<<<<<<< SEARCH
|
|
452
|
-
const { docs } = useLiveQuery("agentRating", { range: [3, 5] });
|
|
453
|
-
=======
|
|
454
|
-
// The counter's scope: the board being viewed and who is counting (useVibe from "use-vibes", as above).
|
|
455
|
-
const { me } = useVibe("boards");
|
|
456
|
-
const boardId = `default-${me?.userHandle}`;
|
|
457
|
-
// Keyed on the board AND the event name, so one board's count never includes another board's events.
|
|
458
|
-
const { docs } = useLiveQuery((doc) => [doc.boardId, doc.counter], { key: [boardId, "my-event-name"] });
|
|
459
|
-
const counterValue = docs.length;
|
|
460
|
-
|
|
461
|
-
// Each counted event is its own record, and it names the board it belongs to and who wrote it.
|
|
462
|
-
function countEvent() {
|
|
463
|
-
database.put({ type: "count", counter: "my-event-name", boardId, authorHandle: me?.userHandle });
|
|
464
|
-
}
|
|
465
|
-
>>>>>>> REPLACE
|
|
466
|
-
```
|
|
467
|
-
|
|
468
|
-
This pattern ensures the count is accurate even during sync — each event is its own document, so concurrent writes never conflict.
|
|
469
|
-
|
|
470
|
-
### Custom Indexes
|
|
471
|
-
|
|
472
|
-
Use a custom index function to normalize and transform document data, for instance if you have both new and old document versions in your app:
|
|
473
|
-
|
|
474
|
-
### `App.jsx`
|
|
475
|
-
```jsx
|
|
476
|
-
<<<<<<< SEARCH
|
|
477
|
-
// The counter's scope: the board being viewed and who is counting (useVibe from "use-vibes", as above).
|
|
478
|
-
const { me } = useVibe("boards");
|
|
479
|
-
const boardId = `default-${me?.userHandle}`;
|
|
480
|
-
// Keyed on the board AND the event name, so one board's count never includes another board's events.
|
|
481
|
-
const { docs } = useLiveQuery((doc) => [doc.boardId, doc.counter], { key: [boardId, "my-event-name"] });
|
|
482
|
-
const counterValue = docs.length;
|
|
483
|
-
|
|
484
|
-
// Each counted event is its own record, and it names the board it belongs to and who wrote it.
|
|
485
|
-
function countEvent() {
|
|
486
|
-
database.put({ type: "count", counter: "my-event-name", boardId, authorHandle: me?.userHandle });
|
|
487
|
-
}
|
|
488
|
-
=======
|
|
489
|
-
const { docs } = useLiveQuery(
|
|
490
|
-
(doc) => {
|
|
491
|
-
if (doc.type == "listing_v1") return doc.sellerId;
|
|
492
|
-
else if (doc.type == "listing") return doc.userId;
|
|
493
|
-
},
|
|
494
|
-
{ key: routeParams.sellerId }
|
|
495
|
-
);
|
|
496
|
-
>>>>>>> REPLACE
|
|
497
|
-
```
|
|
498
|
-
|
|
499
|
-
#### Array Indexes and Prefix Queries
|
|
500
|
-
|
|
501
|
-
When you want to group rows easily, you can use an array index key. This is great for grouping records by year/month/day or other paths. The prefix query is a shorthand for a key range:
|
|
502
|
-
|
|
503
|
-
### `App.jsx`
|
|
504
|
-
```jsx
|
|
505
|
-
<<<<<<< SEARCH
|
|
506
|
-
const { docs } = useLiveQuery(
|
|
507
|
-
(doc) => {
|
|
508
|
-
if (doc.type == "listing_v1") return doc.sellerId;
|
|
509
|
-
else if (doc.type == "listing") return doc.userId;
|
|
510
|
-
},
|
|
511
|
-
{ key: routeParams.sellerId }
|
|
512
|
-
);
|
|
513
|
-
=======
|
|
514
|
-
const { docs } = useLiveQuery(
|
|
515
|
-
(doc) => {
|
|
516
|
-
const date = new Date(doc.date);
|
|
517
|
-
if (Number.isNaN(date.getTime())) return; // return nothing to skip docs without a valid date
|
|
518
|
-
return [date.getFullYear(), date.getMonth(), date.getDate()];
|
|
519
|
-
},
|
|
520
|
-
{ prefix: [2024, 11] } // everything from November 2024
|
|
521
|
-
);
|
|
522
|
-
>>>>>>> REPLACE
|
|
523
|
-
```
|
|
524
|
-
|
|
525
|
-
#### Sortable Lists
|
|
526
|
-
|
|
527
|
-
Sortable lists are a common pattern. Use evenly spaced positions and insert between items using midpoint calculation:
|
|
528
|
-
|
|
529
|
-
### `App.jsx`
|
|
530
|
-
```jsx
|
|
531
|
-
<<<<<<< SEARCH
|
|
532
|
-
const { docs } = useLiveQuery(
|
|
533
|
-
(doc) => {
|
|
534
|
-
const date = new Date(doc.date);
|
|
535
|
-
if (Number.isNaN(date.getTime())) return;
|
|
536
|
-
return [date.getFullYear(), date.getMonth(), date.getDate()];
|
|
537
|
-
},
|
|
538
|
-
{ prefix: [2024, 11] }
|
|
539
|
-
);
|
|
540
|
-
=======
|
|
541
|
-
// Query items on list xyz, sorted by position
|
|
542
|
-
// Note: useLiveQuery('listId', { key:'xyz' }) would be the same docs, sorted chronologically by _id
|
|
543
|
-
const { docs } = useLiveQuery((doc) => [doc.listId, doc.position], { prefix: ["xyz"] });
|
|
544
|
-
|
|
545
|
-
// Each item names the list it belongs to and who wrote it, both at create.
|
|
546
|
-
async function initializeList() {
|
|
547
|
-
await database.put({ type: "item", listId: "xyz", authorHandle: me?.userHandle, position: 1000 });
|
|
548
|
-
await database.put({ type: "item", listId: "xyz", authorHandle: me?.userHandle, position: 2000 });
|
|
549
|
-
await database.put({ type: "item", listId: "xyz", authorHandle: me?.userHandle, position: 3000 });
|
|
550
|
-
}
|
|
551
|
-
|
|
552
|
-
async function insertBetween(beforeDoc, afterDoc) {
|
|
553
|
-
const newPosition = (beforeDoc.position + afterDoc.position) / 2;
|
|
554
|
-
await database.put({ type: "item", listId: "xyz", authorHandle: me?.userHandle, position: newPosition });
|
|
555
|
-
}
|
|
556
|
-
>>>>>>> REPLACE
|
|
557
|
-
```
|
|
558
|
-
|
|
559
|
-
#### Emit a Row Value
|
|
560
|
-
|
|
561
|
-
An index function can call `emit(key, value)` with a second argument to store a value beside the key. `rows[].value` is the value you emitted when you pass one, and the whole document when you call `emit(key)` alone. Emit a small summary when a long list renders only a field or two, and read `docs` when you want the documents themselves — `docs` holds the indexed documents whatever the rows carry.
|
|
562
|
-
|
|
563
|
-
### `App.jsx`
|
|
564
|
-
```jsx
|
|
565
|
-
<<<<<<< SEARCH
|
|
566
|
-
const { docs } = useLiveQuery((doc) => [doc.listId, doc.position], { prefix: ["xyz"] });
|
|
567
|
-
=======
|
|
568
|
-
// Rows carry just what this list renders; docs still holds the whole documents
|
|
569
|
-
const { rows } = useLiveQuery((doc, emit) => emit([doc.listId, doc.position], { label: doc.label }), {
|
|
570
|
-
prefix: ["xyz"],
|
|
571
|
-
});
|
|
572
|
-
const labels = rows.map((row) => row.value.label);
|
|
573
|
-
>>>>>>> REPLACE
|
|
574
|
-
```
|
|
575
|
-
|
|
576
|
-
## Offline writes are on by default (`offlineQueue`)
|
|
577
|
-
|
|
578
|
-
For signed-in users, writes are **local-first by default**: a `put`/`del` that fails on the network is durably queued on the device (it resolves, stays visible, and syncs to the cloud when you're back online) instead of rolling back. You don't opt in — every signed-in vibe gets this. A refusal or validation rejection is **not** queued: it converges **server-wins** (the local optimistic revision is overwritten by the server's version); the platform surfaces the reason only when it has one, and a reason-less rejection converges silently — so keep the UI reactive to the store rather than depending on a toast. Only transport failures queue and retry.
|
|
579
|
-
|
|
580
|
-
Pass `{ offlineQueue: false }` for **server-first, fail-fast** writes: a `put` resolves only when the server accepts it, and a network failure rejects immediately with nothing queued. Choose this for **collaborative multi-writer apps** where two people may edit the same doc — sync is blind last-arrival-wins (Firefly has no `_rev`), so a stale write replayed on reconnect can silently overwrite a newer one. When a lost write is safer than a surprise overwrite, opt out.
|
|
581
|
-
|
|
582
|
-
```js
|
|
583
|
-
// Default (single-user apps): durable offline writes, no config needed.
|
|
584
|
-
const { useLiveQuery } = useFireproof("todos");
|
|
585
|
-
|
|
586
|
-
// Collaborative app: fail fast instead of queueing a stale write.
|
|
587
|
-
const { useLiveQuery } = useFireproof("shared-board", { offlineQueue: false });
|
|
588
|
-
```
|
|
589
|
-
|
|
590
|
-
## Anonymous writes (local-first by default)
|
|
591
|
-
|
|
592
|
-
Letting a logged-out visitor try the app and build a little state before signing in needs **no opt-in** — it is the universal default on every vibe. While logged out, `put`/`del`/`useLiveQuery`/`useDocument` run against the local replica with the identical API — no auth branching in your code — so favorites, a draft, or a scratch list just work. On first sign-in that local state drains automatically into the user's cloud database and then syncs live; nothing is lost if a drain is interrupted. The sign-in and drain-confirmation toast ("saved to your account") is platform-provided chrome — do not build your own "saved"/"synced" notice for it.
|
|
593
|
-
|
|
594
|
-
The old `{ anonymousLocal: true }` option (and its `migrate` hook) is **deprecated and ignored** — its behavior is now the default, so the runtime warns once and drains any legacy data on its own. Do not emit the flag in new code.
|
|
595
|
-
|
|
596
|
-
---
|
|
597
|
-
|
|
598
|
-
## Architecture: Where's My Data?
|
|
599
|
-
|
|
600
|
-
Data lives in a local replica (IndexedDB) that the app reads and writes instantly; that replica syncs to the Firefly server in the background. The server is the authority on acceptance — each synced write is validated on ingest, persisted, and then synced to every viewer who may read it. A write that fails validation or hits a conflict is rejected on ingest: the local optimistic revision converges **server-wins**, and the platform surfaces the reason only when it has one — a reason-less rejection converges silently, so keep the UI reactive to the store rather than depending on a toast. Don't assume the write always lands, but don't hand-roll your own denial error UI — let it converge.
|
|
601
|
-
|
|
602
|
-
## Using Fireproof in JavaScript
|
|
603
|
-
|
|
604
|
-
You can use the core API outside a React component — in a plain script or a helper module. Instead of hooks, import the core API directly:
|
|
605
|
-
|
|
606
|
-
### `App.jsx`
|
|
607
|
-
```jsx
|
|
608
|
-
<<<<<<< SEARCH
|
|
609
|
-
import React from "react";
|
|
610
|
-
import { useFireproof } from "use-fireproof";
|
|
611
|
-
|
|
612
|
-
export default function App() {
|
|
613
|
-
const { useDocument, useLiveQuery, database } = useFireproof("myLedger");
|
|
614
|
-
=======
|
|
615
|
-
import { fireproof } from "use-fireproof";
|
|
616
|
-
|
|
617
|
-
const database = fireproof("myLedger");
|
|
618
|
-
|
|
619
|
-
// The document API is async — reads and writes both await the server.
|
|
620
|
-
// Writes can fail (refused, conflict, network), so wrap them.
|
|
621
|
-
async function main() {
|
|
622
|
-
try {
|
|
623
|
-
const ok = await database.put({ text: "Sample Data" });
|
|
624
|
-
const doc = await database.get(ok.id);
|
|
625
|
-
const latest = await database.query("_id", { limit: 10, descending: true });
|
|
626
|
-
console.log("Latest documents:", latest.docs);
|
|
627
|
-
} catch (err) {
|
|
628
|
-
console.error("write failed (refused, conflict, or network):", err);
|
|
629
|
-
}
|
|
630
|
-
}
|
|
631
|
-
>>>>>>> REPLACE
|
|
632
|
-
```
|
|
633
|
-
|
|
634
|
-
### Working with Files
|
|
635
|
-
|
|
636
|
-
Fireproof documents carry attachments under `_files`. Save a `File` (or `Blob`) by assigning it to a key on `_files`, and Fireproof handles upload, durable storage, and URL minting for you. After a doc round-trips through the database, each `_files.<key>` entry carries a stable `url` you can drop straight into `<img>`, `<video>`, `<audio>`, CSS `background-image`, etc.
|
|
637
|
-
|
|
638
|
-
Each `_files.<key>` entry shape after save + round-trip: `{ url: string, type: string, size: number, lastModified?: number, file: () => Promise<File> }`. The platform-minted `url` is stable for the lifetime of that file, so the browser cache works normally. For plain `<img>` rendering, prefer `meta.url` — it skips one fetch and lets the browser handle cache and decoding. Use `meta.file()` only when you need the bytes themselves (transcoding, hashing, ML features).
|
|
639
|
-
|
|
640
|
-
A save that carries `_files` uploads the bytes before the doc lands, so unlike a plain write it rejects instead of converging. Await it inside `try`/`catch` and show `err.message` next to the upload button: the message says in plain words how to get the photo saved (for example, on a private app, sign in as the owner or ask them for write access). Gate the uploader with `useVibe(dbName).can.create` as you would any write surface.
|
|
641
|
-
|
|
642
|
-
Building an image uploader with `_files`:
|
|
643
|
-
|
|
644
|
-
### `App.jsx`
|
|
645
|
-
```jsx
|
|
646
|
-
import React from "react";
|
|
647
|
-
import { useFireproof } from "use-fireproof";
|
|
648
|
-
import { Button, Card, CardContent, Input, Label } from "@vibes.diy/look";
|
|
649
|
-
|
|
650
|
-
export default function App() {
|
|
651
|
-
const { useDocument, useLiveQuery } = useFireproof("imageUploads");
|
|
652
|
-
|
|
653
|
-
const { doc, merge, save, reset } = useDocument({
|
|
654
|
-
_files: {},
|
|
655
|
-
caption: "",
|
|
656
|
-
type: "upload",
|
|
657
|
-
createdAt: Date.now(),
|
|
658
|
-
});
|
|
659
|
-
|
|
660
|
-
const { docs } = useLiveQuery("type", { key: "upload", descending: true, limit: 12 });
|
|
661
|
-
const [saveError, setSaveError] = React.useState("");
|
|
662
|
-
|
|
663
|
-
const onPickFile = (e) => {
|
|
664
|
-
const f = e.target.files?.[0];
|
|
665
|
-
if (f) merge({ _files: { photo: f } });
|
|
666
|
-
};
|
|
667
|
-
|
|
668
|
-
const onSubmit = async (e) => {
|
|
669
|
-
e.preventDefault();
|
|
670
|
-
if (!doc._files?.photo) return;
|
|
671
|
-
setSaveError("");
|
|
672
|
-
try {
|
|
673
|
-
// The save uploads the photo first, so it can reject; show why.
|
|
674
|
-
await save();
|
|
675
|
-
reset();
|
|
676
|
-
} catch (err) {
|
|
677
|
-
setSaveError(err.message);
|
|
678
|
-
}
|
|
679
|
-
};
|
|
680
|
-
|
|
681
|
-
const c = {
|
|
682
|
-
page: "bg-background text-foreground font-sans",
|
|
683
|
-
meta: "text-muted-foreground",
|
|
684
|
-
};
|
|
685
|
-
|
|
686
|
-
return (
|
|
687
|
-
<div className={`min-h-screen p-6 max-w-lg mx-auto ${c.page}`}>
|
|
688
|
-
<h2 className="text-2xl font-bold mb-4">Image Uploader</h2>
|
|
689
|
-
<form onSubmit={onSubmit} className="space-y-3">
|
|
690
|
-
<Label htmlFor="photo">Photo</Label>
|
|
691
|
-
<Input id="photo" type="file" accept="image/*" onChange={onPickFile} className="w-full" />
|
|
692
|
-
<Input
|
|
693
|
-
type="text"
|
|
694
|
-
placeholder="Caption"
|
|
695
|
-
value={doc.caption}
|
|
696
|
-
onChange={(e) => merge({ caption: e.target.value })}
|
|
697
|
-
className="w-full"
|
|
698
|
-
/>
|
|
699
|
-
<Button type="submit" size="lg">
|
|
700
|
-
Upload
|
|
701
|
-
</Button>
|
|
702
|
-
{saveError && <p role="alert" className="text-destructive">{saveError}</p>}
|
|
703
|
-
</form>
|
|
704
|
-
|
|
705
|
-
<h3 className="text-lg font-bold mt-8">Recent Uploads</h3>
|
|
706
|
-
<div className="grid grid-cols-2 gap-4 mt-2">
|
|
707
|
-
{docs.map((d) => (
|
|
708
|
-
<Card key={d._id}>
|
|
709
|
-
<CardContent>
|
|
710
|
-
{d._files?.photo?.url && <img src={d._files.photo.url} alt={d.caption || "upload"} className="w-full h-auto rounded-md" />}
|
|
711
|
-
<p className={`${c.meta} mt-2`}>{d.caption || "No caption"}</p>
|
|
712
|
-
</CardContent>
|
|
713
|
-
</Card>
|
|
714
|
-
))}
|
|
715
|
-
</div>
|
|
716
|
-
</div>
|
|
717
|
-
);
|
|
718
|
-
}
|
|
719
|
-
```
|
|
720
|
-
|
|
721
|
-
For multi-file uploads (e.g. `<input multiple>`), build the `_files` map keyed by filename and iterate with `Object.entries(doc._files)` to render each entry.
|
|
722
|
-
|
|
723
|
-
Adding multi-file support:
|
|
724
|
-
|
|
725
|
-
### `App.jsx`
|
|
726
|
-
```jsx
|
|
727
|
-
<<<<<<< SEARCH
|
|
728
|
-
const onPickFile = (e) => {
|
|
729
|
-
const f = e.target.files?.[0];
|
|
730
|
-
if (f) merge({ _files: { photo: f } });
|
|
731
|
-
};
|
|
732
|
-
|
|
733
|
-
const onSubmit = async (e) => {
|
|
734
|
-
e.preventDefault();
|
|
735
|
-
if (!doc._files?.photo) return;
|
|
736
|
-
=======
|
|
737
|
-
const onPickFile = (e) => {
|
|
738
|
-
const next = {};
|
|
739
|
-
for (const f of e.target.files) next[f.name] = f;
|
|
740
|
-
merge({ _files: next });
|
|
741
|
-
};
|
|
742
|
-
|
|
743
|
-
const onSubmit = async (e) => {
|
|
744
|
-
e.preventDefault();
|
|
745
|
-
if (!Object.keys(doc._files || {}).length) return;
|
|
746
|
-
>>>>>>> REPLACE
|
|
747
|
-
```
|
|
748
|
-
|
|
749
|
-
### Form Validation
|
|
750
|
-
|
|
751
|
-
You can use React's `useState` to manage validation states and error messages. Validate inputs at the UI level before allowing submission. Adding validation to the uploader:
|
|
752
|
-
|
|
753
|
-
### `App.jsx`
|
|
754
|
-
```jsx
|
|
755
|
-
<<<<<<< SEARCH
|
|
756
|
-
const onSubmit = async (e) => {
|
|
757
|
-
e.preventDefault();
|
|
758
|
-
if (!Object.keys(doc._files || {}).length) return;
|
|
759
|
-
=======
|
|
760
|
-
const [errors, setErrors] = React.useState({});
|
|
761
|
-
|
|
762
|
-
function validateForm() {
|
|
763
|
-
const newErrors = {};
|
|
764
|
-
if (!doc.caption.trim()) newErrors.caption = "Caption is required.";
|
|
765
|
-
if (!Object.keys(doc._files || {}).length) newErrors.file = "Pick a file.";
|
|
766
|
-
setErrors(newErrors);
|
|
767
|
-
return Object.keys(newErrors).length === 0;
|
|
768
|
-
}
|
|
769
|
-
|
|
770
|
-
const onSubmit = async (e) => {
|
|
771
|
-
e.preventDefault();
|
|
772
|
-
if (!validateForm()) return;
|
|
773
|
-
>>>>>>> REPLACE
|
|
774
|
-
```
|
|
775
|
-
|
|
776
|
-
## Example React Application
|
|
777
|
-
|
|
778
|
-
Code listing for todo tracker App.jsx. Note the code ordering: hooks, then handlers, then classNames right before JSX.
|
|
779
|
-
|
|
780
|
-
```js
|
|
781
|
-
import React from "react";
|
|
782
|
-
import { useFireproof } from "use-fireproof";
|
|
783
|
-
import { useVibe } from "use-vibes";
|
|
784
|
-
import { Button, Card, CardContent, Input, Label } from "@vibes.diy/look";
|
|
785
|
-
|
|
786
|
-
export default function App() {
|
|
787
|
-
// 1. Hooks and document shapes
|
|
788
|
-
const { useLiveQuery, useDocument, database } = useFireproof("todoList");
|
|
789
|
-
const { me } = useVibe("todoList");
|
|
790
|
-
|
|
791
|
-
// Every person has a list of their own without creating one first.
|
|
792
|
-
const listId = `default-${me?.userHandle}`;
|
|
793
|
-
|
|
794
|
-
const {
|
|
795
|
-
doc: newTodo,
|
|
796
|
-
merge: mergeNewTodo,
|
|
797
|
-
submit: submitNewTodo,
|
|
798
|
-
} = useDocument({
|
|
799
|
-
todo: "",
|
|
800
|
-
type: "todo",
|
|
801
|
-
listId, // the list this todo belongs to, on the todo itself
|
|
802
|
-
authorHandle: me?.userHandle,
|
|
803
|
-
completed: false,
|
|
804
|
-
createdAt: Date.now(),
|
|
805
|
-
});
|
|
806
|
-
|
|
807
|
-
const { docs: todos } = useLiveQuery("listId", {
|
|
808
|
-
key: listId,
|
|
809
|
-
descending: true,
|
|
810
|
-
});
|
|
811
|
-
|
|
812
|
-
// 2. Event handlers
|
|
813
|
-
const handleInputChange = (e) => {
|
|
814
|
-
mergeNewTodo({ todo: e.target.value });
|
|
815
|
-
};
|
|
816
|
-
|
|
817
|
-
const handleSubmit = (e) => {
|
|
818
|
-
e.preventDefault();
|
|
819
|
-
submitNewTodo();
|
|
820
|
-
};
|
|
821
|
-
|
|
822
|
-
// 3. ClassNames — right before JSX, for the plain elements only; the look's
|
|
823
|
-
// components carry their own surface, ink and shape.
|
|
824
|
-
const c = {
|
|
825
|
-
page: "bg-background text-foreground font-sans",
|
|
826
|
-
meta: "text-muted-foreground",
|
|
827
|
-
};
|
|
828
|
-
|
|
829
|
-
// 4. JSX return
|
|
830
|
-
return (
|
|
831
|
-
<div className={`min-h-screen max-w-md mx-auto p-6 ${c.page}`}>
|
|
832
|
-
<h2 className="text-2xl font-bold mb-4">Todo List</h2>
|
|
833
|
-
<form onSubmit={handleSubmit} className="mb-4">
|
|
834
|
-
<Label htmlFor="todo" className="mb-2 block">
|
|
835
|
-
Todo
|
|
836
|
-
</Label>
|
|
837
|
-
<Input className="w-full" id="todo" type="text" onChange={handleInputChange} value={newTodo.todo} />
|
|
838
|
-
</form>
|
|
839
|
-
<ul className="space-y-3">
|
|
840
|
-
{todos.map((doc) => (
|
|
841
|
-
<li key={doc._id}>
|
|
842
|
-
<Card>
|
|
843
|
-
<CardContent className="flex flex-col items-start">
|
|
844
|
-
<div className="flex items-center justify-between w-full">
|
|
845
|
-
<div className="flex items-center">
|
|
846
|
-
<input
|
|
847
|
-
className="mr-2"
|
|
848
|
-
type="checkbox"
|
|
849
|
-
checked={doc.completed}
|
|
850
|
-
onChange={() => database.put({ ...doc, completed: !doc.completed })}
|
|
851
|
-
/>
|
|
852
|
-
<span className="font-medium">{doc.todo}</span>
|
|
853
|
-
</div>
|
|
854
|
-
<Button variant="destructive" size="sm" onClick={() => database.del(doc._id)}>
|
|
855
|
-
Delete
|
|
856
|
-
</Button>
|
|
857
|
-
</div>
|
|
858
|
-
<div className={`${c.meta} mt-1`}>{new Date(doc.createdAt).toISOString()}</div>
|
|
859
|
-
</CardContent>
|
|
860
|
-
</Card>
|
|
861
|
-
</li>
|
|
862
|
-
))}
|
|
863
|
-
</ul>
|
|
864
|
-
</div>
|
|
865
|
-
);
|
|
866
|
-
}
|
|
867
|
-
```
|
|
868
|
-
|
|
869
|
-
IMPORTANT: Don't use `useState()` on form data, instead use `merge()` and `submit()` from `useDocument`. Only use `useState` for ephemeral UI state (active tabs, open/closed panels, cursor positions). Keep your data model in Fireproof.
|