@vibes.diy/prompts 2.4.13 → 2.4.15
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/cli-footer.md +1 -0
- package/llms/fireproof.md +386 -432
- package/llms/image-gen.md +59 -36
- package/llms/use-viewer.md +89 -55
- package/mcp-footer.md +39 -0
- package/package.json +3 -3
- package/prompts.d.ts +1 -0
- package/prompts.js +12 -0
- package/prompts.js.map +1 -1
- package/system-prompt-initial.md +2 -2
- package/system-prompt.md +3 -8
package/llms/fireproof.md
CHANGED
|
@@ -15,25 +15,7 @@ Fireproof enforces cryptographic causal consistency and ledger integrity using h
|
|
|
15
15
|
|
|
16
16
|
## Installation
|
|
17
17
|
|
|
18
|
-
The `use-fireproof` package provides both the core API and React hooks.
|
|
19
|
-
|
|
20
|
-
```js
|
|
21
|
-
import { useFireproof } from "use-fireproof";
|
|
22
|
-
```
|
|
23
|
-
|
|
24
|
-
React hooks are the recommended way to use Fireproof in LLM code generation contexts.
|
|
25
|
-
|
|
26
|
-
#### Create or Load a Database
|
|
27
|
-
|
|
28
|
-
Fireproof databases store data across sessions and can sync in real-time. 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.
|
|
29
|
-
|
|
30
|
-
```js
|
|
31
|
-
import { useFireproof } from "use-fireproof";
|
|
32
|
-
|
|
33
|
-
const { database, useLiveQuery, useDocument } = useFireproof("myLedger");
|
|
34
|
-
```
|
|
35
|
-
|
|
36
|
-
#### Put and Get Documents
|
|
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 store data across sessions and can sync in real-time. 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.
|
|
37
19
|
|
|
38
20
|
Each document has an `_id`, which can be auto-generated or set explicitly. Auto-generation is recommended to ensure uniqueness and avoid conflicts. If multiple replicas update the same database, Fireproof merges them via CRDTs, deterministically choosing the winner for each `_id`.
|
|
39
21
|
|
|
@@ -43,15 +25,20 @@ Fireproof is a local database, no loading states required, just empty data state
|
|
|
43
25
|
|
|
44
26
|
### Basic Example
|
|
45
27
|
|
|
46
|
-
This
|
|
28
|
+
This complete app shows Fireproof's core: `useFireproof` gives you hooks, `useDocument` manages form state, and `useLiveQuery` sorts by `_id` for temporal ordering.
|
|
47
29
|
|
|
48
|
-
|
|
49
|
-
|
|
30
|
+
App.jsx
|
|
31
|
+
|
|
32
|
+
```jsx
|
|
33
|
+
import React from "react";
|
|
34
|
+
import { useFireproof } from "use-fireproof";
|
|
35
|
+
|
|
36
|
+
export default function App() {
|
|
50
37
|
const { useDocument, useLiveQuery } = useFireproof("myLedger");
|
|
51
38
|
|
|
52
39
|
const { doc, merge, submit } = useDocument({ text: "" });
|
|
53
40
|
|
|
54
|
-
// _id is roughly temporal, this is most recent
|
|
41
|
+
// _id is roughly temporal, this is most recent first
|
|
55
42
|
const { docs } = useLiveQuery("_id", { descending: true, limit: 100 });
|
|
56
43
|
|
|
57
44
|
return (
|
|
@@ -69,7 +56,7 @@ const App = () => {
|
|
|
69
56
|
</ul>
|
|
70
57
|
</div>
|
|
71
58
|
);
|
|
72
|
-
}
|
|
59
|
+
}
|
|
73
60
|
```
|
|
74
61
|
|
|
75
62
|
The access function lives in a separate file. Even simple apps include one — it's the server-side authority for who can write:
|
|
@@ -85,166 +72,190 @@ export default function (doc, oldDoc, user) {
|
|
|
85
72
|
|
|
86
73
|
### Editing Documents
|
|
87
74
|
|
|
88
|
-
Address documents by a known `_id` if you want to force conflict resolution or work with a real world resource, like a schedule slot or a user profile. In a complex app this might come from a route parameter or correspond to an outside identifier.
|
|
75
|
+
Address documents by a known `_id` if you want to force conflict resolution or work with a real world resource, like a schedule slot or a user profile. In a complex app this might come from a route parameter or correspond to an outside identifier. To add a profile editor to the app above:
|
|
89
76
|
|
|
90
|
-
|
|
91
|
-
const { useDocument } = useFireproof("myLedger");
|
|
92
|
-
|
|
93
|
-
const { doc, merge, submit, save, reset } = useDocument({ _id: "user-profile:abc@example.com" });
|
|
94
|
-
```
|
|
95
|
-
|
|
96
|
-
The `useDocument` hook provides several methods:
|
|
97
|
-
|
|
98
|
-
- `merge(updates)`: Update the document with new fields, without saving. Use this instead of keeping a `useState` for document data.
|
|
99
|
-
- `submit(e)`: Handles form submission by preventing default, saving, and resetting
|
|
100
|
-
- `save()`: Save the current document state
|
|
101
|
-
- `reset()`: Reset to initial state
|
|
77
|
+
App.jsx
|
|
102
78
|
|
|
103
|
-
|
|
79
|
+
```jsx
|
|
80
|
+
<<<<<<< SEARCH
|
|
81
|
+
const { doc, merge, submit } = useDocument({ text: "" });
|
|
82
|
+
=======
|
|
83
|
+
const { doc, merge, submit } = useDocument({ text: "" });
|
|
104
84
|
|
|
105
|
-
|
|
106
|
-
|
|
85
|
+
const { doc: profile, merge: mergeProfile, save: saveProfile } = useDocument({ _id: "user-profile:abc@example.com" });
|
|
86
|
+
>>>>>>> REPLACE
|
|
107
87
|
```
|
|
108
88
|
|
|
109
|
-
When you call submit, the document is reset, so if you didn't provide an `_id` then you can use the form to create a stream of new documents as in the basic example above.
|
|
89
|
+
The `useDocument` hook provides several methods: `merge(updates)` updates the document with new fields without saving (use this instead of keeping a `useState` for document data), `submit(e)` handles form submission by preventing default, saving, and resetting, `save()` saves the current document state, and `reset()` resets to initial state. When you call submit, the document is reset, so if you didn't provide an `_id` then you can use the form to create a stream of new documents as in the basic example above.
|
|
110
90
|
|
|
111
91
|
### Updating Documents in Event Handlers
|
|
112
92
|
|
|
113
|
-
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.
|
|
93
|
+
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:
|
|
114
94
|
|
|
115
|
-
|
|
116
|
-
// ✅ Correct — use database.put() in handlers
|
|
117
|
-
onClick={() => database.put({ ...doc, favorite: !doc.favorite })}
|
|
95
|
+
App.jsx
|
|
118
96
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
const {
|
|
122
|
-
|
|
97
|
+
```jsx
|
|
98
|
+
<<<<<<< SEARCH
|
|
99
|
+
const { useDocument, useLiveQuery } = useFireproof("myLedger");
|
|
100
|
+
=======
|
|
101
|
+
const { useDocument, useLiveQuery, database } = useFireproof("myLedger");
|
|
102
|
+
>>>>>>> REPLACE
|
|
123
103
|
```
|
|
124
104
|
|
|
125
|
-
|
|
105
|
+
App.jsx
|
|
106
|
+
|
|
107
|
+
```jsx
|
|
108
|
+
<<<<<<< SEARCH
|
|
109
|
+
<li key={doc._id}>{doc.text}</li>
|
|
110
|
+
=======
|
|
111
|
+
<li key={doc._id}>
|
|
112
|
+
{doc.text}
|
|
113
|
+
<button onClick={() => database.put({ ...doc, favorite: !doc.favorite })}>
|
|
114
|
+
{doc.favorite ? "★" : "☆"}
|
|
115
|
+
</button>
|
|
116
|
+
</li>
|
|
117
|
+
>>>>>>> REPLACE
|
|
118
|
+
```
|
|
126
119
|
|
|
127
|
-
|
|
120
|
+
Never call hooks inside handlers — `const { doc, save } = useDocument({ _id: id })` inside an onClick BREAKS the Rules of Hooks.
|
|
128
121
|
|
|
129
|
-
|
|
122
|
+
### Query Data
|
|
130
123
|
|
|
131
|
-
|
|
124
|
+
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.
|
|
132
125
|
|
|
133
126
|
#### Query by Key Range
|
|
134
127
|
|
|
135
|
-
Passing a string to `useLiveQuery` will index by that field.
|
|
128
|
+
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:
|
|
136
129
|
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
130
|
+
App.jsx
|
|
131
|
+
|
|
132
|
+
```jsx
|
|
133
|
+
<<<<<<< SEARCH
|
|
134
|
+
const { docs } = useLiveQuery("_id", { descending: true, limit: 100 });
|
|
135
|
+
=======
|
|
136
|
+
// all docs where doc.agentName === "agent-1", sorted by _id
|
|
137
|
+
const { docs } = useLiveQuery("agentName", { key: "agent-1" });
|
|
138
|
+
>>>>>>> REPLACE
|
|
141
139
|
```
|
|
142
140
|
|
|
143
|
-
|
|
141
|
+
Or query a numeric range:
|
|
144
142
|
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
143
|
+
App.jsx
|
|
144
|
+
|
|
145
|
+
```jsx
|
|
146
|
+
<<<<<<< SEARCH
|
|
147
|
+
const { docs } = useLiveQuery("agentName", { key: "agent-1" });
|
|
148
|
+
=======
|
|
149
|
+
// docs with agentRating between 3 and 5
|
|
150
|
+
const { docs } = useLiveQuery("agentRating", { range: [3, 5] });
|
|
151
|
+
>>>>>>> REPLACE
|
|
149
152
|
```
|
|
150
153
|
|
|
151
154
|
#### Counter Pattern
|
|
152
155
|
|
|
153
|
-
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
|
|
156
|
+
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:
|
|
154
157
|
|
|
155
|
-
|
|
156
|
-
const App = () => {
|
|
157
|
-
const { useLiveQuery, database } = useFireproof("myLedger");
|
|
158
|
+
App.jsx
|
|
158
159
|
|
|
160
|
+
```jsx
|
|
161
|
+
<<<<<<< SEARCH
|
|
162
|
+
const { docs } = useLiveQuery("agentRating", { range: [3, 5] });
|
|
163
|
+
=======
|
|
159
164
|
const { docs } = useLiveQuery("counter", { key: "my-event-name" });
|
|
160
165
|
const counterValue = docs.length;
|
|
161
166
|
|
|
162
167
|
function countEvent() {
|
|
163
|
-
database.put({
|
|
164
|
-
counter: "my-event-name",
|
|
165
|
-
});
|
|
168
|
+
database.put({ counter: "my-event-name" });
|
|
166
169
|
}
|
|
167
|
-
|
|
168
|
-
// Call countEvent() to count each event, and render counterValue in the UI.
|
|
169
|
-
};
|
|
170
|
+
>>>>>>> REPLACE
|
|
170
171
|
```
|
|
171
172
|
|
|
172
|
-
This pattern ensures the count is accurate even during sync.
|
|
173
|
+
This pattern ensures the count is accurate even during sync — each event is its own document, so concurrent writes never conflict.
|
|
173
174
|
|
|
174
175
|
### Custom Indexes
|
|
175
176
|
|
|
176
|
-
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
|
|
177
|
+
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:
|
|
177
178
|
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
179
|
+
App.jsx
|
|
180
|
+
|
|
181
|
+
```jsx
|
|
182
|
+
<<<<<<< SEARCH
|
|
183
|
+
const { docs } = useLiveQuery("counter", { key: "my-event-name" });
|
|
184
|
+
const counterValue = docs.length;
|
|
185
|
+
|
|
186
|
+
function countEvent() {
|
|
187
|
+
database.put({ counter: "my-event-name" });
|
|
188
|
+
}
|
|
189
|
+
=======
|
|
190
|
+
const { docs } = useLiveQuery(
|
|
191
|
+
(doc) => {
|
|
192
|
+
if (doc.type == "listing_v1") return doc.sellerId;
|
|
193
|
+
else if (doc.type == "listing") return doc.userId;
|
|
194
|
+
},
|
|
195
|
+
{ key: routeParams.sellerId }
|
|
196
|
+
);
|
|
197
|
+
>>>>>>> REPLACE
|
|
189
198
|
```
|
|
190
199
|
|
|
191
200
|
#### Array Indexes and Prefix Queries
|
|
192
201
|
|
|
193
|
-
When you want to group rows easily, you can use an array index key. This is great for grouping records
|
|
202
|
+
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:
|
|
194
203
|
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
+
App.jsx
|
|
205
|
+
|
|
206
|
+
```jsx
|
|
207
|
+
<<<<<<< SEARCH
|
|
208
|
+
const { docs } = useLiveQuery(
|
|
209
|
+
(doc) => {
|
|
210
|
+
if (doc.type == "listing_v1") return doc.sellerId;
|
|
211
|
+
else if (doc.type == "listing") return doc.userId;
|
|
212
|
+
},
|
|
213
|
+
{ key: routeParams.sellerId }
|
|
214
|
+
);
|
|
215
|
+
=======
|
|
216
|
+
const { docs } = useLiveQuery(
|
|
217
|
+
(doc) => {
|
|
218
|
+
const date = new Date(doc.date);
|
|
219
|
+
if (Number.isNaN(date.getTime())) return; // return nothing to skip docs without a valid date
|
|
220
|
+
return [date.getFullYear(), date.getMonth(), date.getDate()];
|
|
221
|
+
},
|
|
222
|
+
{ prefix: [2024, 11] } // everything from November 2024
|
|
223
|
+
);
|
|
224
|
+
>>>>>>> REPLACE
|
|
204
225
|
```
|
|
205
226
|
|
|
206
227
|
#### Sortable Lists
|
|
207
228
|
|
|
208
|
-
Sortable lists are a common pattern.
|
|
229
|
+
Sortable lists are a common pattern. Use evenly spaced positions and insert between items using midpoint calculation:
|
|
209
230
|
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
231
|
+
App.jsx
|
|
232
|
+
|
|
233
|
+
```jsx
|
|
234
|
+
<<<<<<< SEARCH
|
|
235
|
+
const { docs } = useLiveQuery(
|
|
236
|
+
(doc) => {
|
|
237
|
+
const date = new Date(doc.date);
|
|
238
|
+
if (Number.isNaN(date.getTime())) return;
|
|
239
|
+
return [date.getFullYear(), date.getMonth(), date.getDate()];
|
|
240
|
+
},
|
|
241
|
+
{ prefix: [2024, 11] }
|
|
242
|
+
);
|
|
243
|
+
=======
|
|
244
|
+
// Query items on list xyz, sorted by position
|
|
245
|
+
// Note: useLiveQuery('list', { key:'xyz' }) would be the same docs, sorted chronologically by _id
|
|
246
|
+
const { docs } = useLiveQuery((doc) => [doc.list, doc.position], { prefix: ["xyz"] });
|
|
213
247
|
|
|
214
|
-
// Initialize list with evenly spaced positions
|
|
215
248
|
async function initializeList() {
|
|
216
249
|
await database.put({ list: "xyz", position: 1000 });
|
|
217
250
|
await database.put({ list: "xyz", position: 2000 });
|
|
218
251
|
await database.put({ list: "xyz", position: 3000 });
|
|
219
252
|
}
|
|
220
253
|
|
|
221
|
-
// Query items on list xyz, sorted by position. Note that useLiveQuery('list', { key:'xyz' }) would be the same docs, sorted chronologically by _id
|
|
222
|
-
const queryResult = useLiveQuery((doc) => [doc.list, doc.position], { prefix: ["xyz"] });
|
|
223
|
-
|
|
224
|
-
// Insert between existing items using midpoint calculation
|
|
225
254
|
async function insertBetween(beforeDoc, afterDoc) {
|
|
226
255
|
const newPosition = (beforeDoc.position + afterDoc.position) / 2;
|
|
227
|
-
await database.put({
|
|
228
|
-
list: "xyz",
|
|
229
|
-
position: newPosition,
|
|
230
|
-
});
|
|
256
|
+
await database.put({ list: "xyz", position: newPosition });
|
|
231
257
|
}
|
|
232
|
-
|
|
233
|
-
return (
|
|
234
|
-
<div>
|
|
235
|
-
<h3>List xyz (Sorted)</h3>
|
|
236
|
-
<ul>
|
|
237
|
-
{queryResult.docs.map((doc) => (
|
|
238
|
-
<li key={doc._id}>
|
|
239
|
-
{doc._id}: position {doc.position}
|
|
240
|
-
</li>
|
|
241
|
-
))}
|
|
242
|
-
</ul>
|
|
243
|
-
<button onClick={initializeList}>Initialize List</button>
|
|
244
|
-
<button onClick={() => insertBetween(queryResult.docs[1], queryResult.docs[2])}>Insert new doc at 3rd position</button>
|
|
245
|
-
</div>
|
|
246
|
-
);
|
|
247
|
-
}
|
|
258
|
+
>>>>>>> REPLACE
|
|
248
259
|
```
|
|
249
260
|
|
|
250
261
|
## Per-Database Access Control (`acl` option)
|
|
@@ -252,21 +263,17 @@ function App() {
|
|
|
252
263
|
On vibes.diy, `useFireproof` accepts an optional `acl` argument that declares who can read, write, or delete documents in that database. The ACL is stored server-side and enforced on every operation — no separate API call needed.
|
|
253
264
|
Only use the `acl` option when the user explicitly asks for fine-grained access control (or equivalent permission constraints).
|
|
254
265
|
|
|
255
|
-
|
|
256
|
-
import { useFireproof } from "use-vibes";
|
|
257
|
-
|
|
258
|
-
// Anyone with a grant can post; only editors can delete
|
|
259
|
-
const { useLiveQuery, database } = useFireproof("announcements", {
|
|
260
|
-
acl: { write: ["members"], delete: ["editors"] },
|
|
261
|
-
});
|
|
262
|
-
|
|
263
|
-
// Editors-only space — viewers cannot read at all
|
|
264
|
-
const { useLiveQuery, database } = useFireproof("drafts", {
|
|
265
|
-
acl: { read: ["editors"], write: ["editors"], delete: ["editors"] },
|
|
266
|
-
});
|
|
266
|
+
App.jsx
|
|
267
267
|
|
|
268
|
-
|
|
269
|
-
|
|
268
|
+
```jsx
|
|
269
|
+
<<<<<<< SEARCH
|
|
270
|
+
const { useDocument, useLiveQuery, database } = useFireproof("myLedger");
|
|
271
|
+
=======
|
|
272
|
+
// Anyone with a grant can post; only editors can delete
|
|
273
|
+
const { useLiveQuery, database } = useFireproof("announcements", {
|
|
274
|
+
acl: { write: ["members"], delete: ["editors"] },
|
|
275
|
+
});
|
|
276
|
+
>>>>>>> REPLACE
|
|
270
277
|
```
|
|
271
278
|
|
|
272
279
|
**Subject groups** — who each name covers:
|
|
@@ -282,60 +289,53 @@ Owner is always implicitly included — never list `owner` explicitly in an ACL.
|
|
|
282
289
|
|
|
283
290
|
Each capability (`read`, `write`, `delete`) is independent. Omitting one falls back to the app-level role gate for that operation. The `acl` is sent once on first database open and persists across sessions (last-write-wins). Only the **app owner** can set ACLs; non-owner apps opening a database with an `acl` option have it silently ignored — the database still opens and works normally.
|
|
284
291
|
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
`useFireproof()` returns an `access` property — the viewer's resolved roles and channels for that database, computed server-side from the access function's `members` and `grant` declarations.
|
|
288
|
-
|
|
289
|
-
```jsx
|
|
290
|
-
const { database, useLiveQuery, access } = useFireproof("comments");
|
|
292
|
+
Other `acl` variants: `useFireproof("drafts", { acl: { read: ["editors"], write: ["editors"], delete: ["editors"] } })` for editors-only space, or omit `acl` entirely to fall back to app-level role gates (existing behavior, always safe).
|
|
291
293
|
|
|
292
|
-
|
|
293
|
-
access.channels; // ReadonlySet<string> — channels the viewer can access
|
|
294
|
+
## Reading Resolved Grants (`access`)
|
|
294
295
|
|
|
295
|
-
access.hasRole(
|
|
296
|
-
access.hasChannel("engineering"); // boolean convenience
|
|
297
|
-
```
|
|
296
|
+
`useFireproof()` returns an `access` property — the viewer's resolved roles and channels for that database, computed server-side from the access function's `members` and `grant` declarations. Use `access.roles` (ReadonlySet), `access.channels` (ReadonlySet), `access.hasRole(name)`, and `access.hasChannel(name)`.
|
|
298
297
|
|
|
299
298
|
For databases without an access function export, `access` has empty roles and channels. No separate pending flag — grants arrive alongside the viewer identity, so `useViewer().isViewerPending` covers both.
|
|
300
299
|
|
|
300
|
+
App.jsx
|
|
301
|
+
|
|
301
302
|
```jsx
|
|
302
|
-
|
|
303
|
-
const {
|
|
303
|
+
<<<<<<< SEARCH
|
|
304
|
+
const { useLiveQuery, database } = useFireproof("announcements", {
|
|
305
|
+
acl: { write: ["members"], delete: ["editors"] },
|
|
306
|
+
});
|
|
307
|
+
=======
|
|
304
308
|
const { database, useLiveQuery, access } = useFireproof("comments");
|
|
309
|
+
>>>>>>> REPLACE
|
|
310
|
+
```
|
|
305
311
|
|
|
306
|
-
|
|
312
|
+
App.jsx
|
|
307
313
|
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
<
|
|
314
|
+
```jsx
|
|
315
|
+
<<<<<<< SEARCH
|
|
316
|
+
<h3>Recent Documents</h3>
|
|
317
|
+
<ul>
|
|
318
|
+
{docs.map((doc) => (
|
|
319
|
+
<li key={doc._id}>
|
|
320
|
+
{doc.text}
|
|
321
|
+
<button onClick={() => database.put({ ...doc, favorite: !doc.favorite })}>
|
|
322
|
+
{doc.favorite ? "★" : "☆"}
|
|
323
|
+
</button>
|
|
324
|
+
</li>
|
|
325
|
+
))}
|
|
326
|
+
</ul>
|
|
327
|
+
=======
|
|
311
328
|
{access.hasRole("poster") && <CommentForm database={database} />}
|
|
312
329
|
{access.hasRole("moderator") && <ModTools database={database} />}
|
|
313
330
|
{access.hasChannel("announcements") && <Announcements />}
|
|
314
|
-
|
|
315
|
-
);
|
|
316
|
-
}
|
|
331
|
+
>>>>>>> REPLACE
|
|
317
332
|
```
|
|
318
333
|
|
|
319
334
|
The AI agent writes the access function (so it knows the role names) and writes the UI (so it knows which roles gate which components). The `access` object is the bridge — it lets the UI reflect server-enforced permissions without duplicating the logic.
|
|
320
335
|
|
|
321
336
|
The access function is the single source of truth for permissions. The UI reads its verdict from `access` — gate with `access.hasChannel(name)` and `access.hasRole(name)`. Grants may reflect logic the UI can't see (other documents, role memberships, owner state), so `access` is always the right check.
|
|
322
337
|
|
|
323
|
-
`access.hasChannel()` covers every grant path — public channels, restricted channels, role-expanded channels. The access function decides who gets access and how; the UI just asks `access.hasChannel(name)
|
|
324
|
-
|
|
325
|
-
```jsx
|
|
326
|
-
// Channel list — access.hasChannel covers both public and restricted channels
|
|
327
|
-
const visibleChannels = channels.filter(ch => access.hasChannel(ch._id));
|
|
328
|
-
|
|
329
|
-
// Compose gate — access.hasChannel is the only check needed
|
|
330
|
-
{viewer && access.hasChannel(activeChannel) && <ComposeForm />}
|
|
331
|
-
|
|
332
|
-
// Channel with mixed access modes — the UI doesn't need to know which mode
|
|
333
|
-
// The access function granted public channels via grant.public and
|
|
334
|
-
// restricted channels via grant.users — access.hasChannel handles both
|
|
335
|
-
{visibleChannels.map(ch => (
|
|
336
|
-
<li key={ch._id} onClick={() => setActive(ch._id)}>#{ch.name}</li>
|
|
337
|
-
))}
|
|
338
|
-
```
|
|
338
|
+
`access.hasChannel()` covers every grant path — public channels, restricted channels, role-expanded channels. The access function decides who gets access and how; the UI just asks `access.hasChannel(name)`.
|
|
339
339
|
|
|
340
340
|
### Complete example: Team announcements with channels
|
|
341
341
|
|
|
@@ -478,30 +478,32 @@ export function chat(doc, oldDoc, user, ctx) {
|
|
|
478
478
|
}
|
|
479
479
|
```
|
|
480
480
|
|
|
481
|
-
App.jsx — the UI uses `access.hasChannel()` for everything. It has no idea which channels are restricted — that's the access function's job
|
|
481
|
+
App.jsx — the UI uses `access.hasChannel()` for everything. It has no idea which channels are restricted — that's the access function's job:
|
|
482
482
|
|
|
483
483
|
```jsx
|
|
484
|
-
|
|
485
|
-
const {
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
const visible = channels.filter((ch) => isOwner || access.hasChannel(ch._id));
|
|
484
|
+
<<<<<<< SEARCH
|
|
485
|
+
const { database, useLiveQuery, access } = useFireproof("announcements");
|
|
486
|
+
=======
|
|
487
|
+
const { database, useLiveQuery, access } = useFireproof("chat");
|
|
488
|
+
const { docs: channels } = useLiveQuery("type", { key: "channel" });
|
|
489
|
+
>>>>>>> REPLACE
|
|
490
|
+
```
|
|
492
491
|
|
|
493
|
-
|
|
494
|
-
|
|
492
|
+
```jsx
|
|
493
|
+
<<<<<<< SEARCH
|
|
494
|
+
{viewer && access.hasChannel(channel) && (
|
|
495
|
+
=======
|
|
496
|
+
{/* filter to channels the viewer can see — use _id as channel identifier */}
|
|
497
|
+
{channels.filter((ch) => isOwner || access.hasChannel(ch._id)).map((ch) => (
|
|
498
|
+
<button key={ch._id} onClick={() => setChannel(ch._id)}>{ch.name}</button>
|
|
499
|
+
))}
|
|
495
500
|
|
|
496
|
-
|
|
497
|
-
{
|
|
498
|
-
|
|
499
|
-
) : (
|
|
500
|
-
<p>{viewer ? "Read-only in this channel." : "Sign in to post."}</p>
|
|
501
|
-
)}
|
|
501
|
+
{/* can post? just check channel access */}
|
|
502
|
+
{viewer && (isOwner || access.hasChannel(channel)) && (
|
|
503
|
+
>>>>>>> REPLACE
|
|
502
504
|
```
|
|
503
505
|
|
|
504
|
-
Channel `_id` is the channel identifier everywhere. The
|
|
506
|
+
Channel `_id` is the channel identifier everywhere. The access function uses `doc._id` for routing and grants. A deterministic `_id` like `"ch:" + name` enforces uniqueness — two users can't create duplicate channels.
|
|
505
507
|
|
|
506
508
|
---
|
|
507
509
|
|
|
@@ -511,48 +513,13 @@ Access functions are **the room** — they govern what members can do with data
|
|
|
511
513
|
|
|
512
514
|
Access functions live in `/access.js`, a separate file in the vibe's filesystem alongside `/App.jsx`. **Always emit the access function as a block preceded by the filename `access.js` on its own line — never inside an `App.jsx` block.** Each **named export** maps to a database name — `export function chat(...)` gates `useFireproof("chat")`. An `export default` function acts as a catch-all: it gates any database that doesn't have its own named export. Named exports always take precedence over the default.
|
|
513
515
|
|
|
514
|
-
access.js
|
|
515
|
-
```js
|
|
516
|
-
export function chat(doc, oldDoc, user, ctx) {
|
|
517
|
-
if (!user) throw { forbidden: "authentication required" };
|
|
518
|
-
if (doc.type === "message") {
|
|
519
|
-
if (doc.userHandle !== user.userHandle) throw { forbidden: "not author" };
|
|
520
|
-
ctx.requireAccess(doc.channelId);
|
|
521
|
-
return { channels: [doc.channelId] };
|
|
522
|
-
}
|
|
523
|
-
return {};
|
|
524
|
-
}
|
|
525
|
-
```
|
|
526
|
-
|
|
527
|
-
App.jsx
|
|
528
|
-
```jsx
|
|
529
|
-
const { useLiveQuery, database } = useFireproof("chat");
|
|
530
|
-
```
|
|
531
|
-
|
|
532
516
|
### Function signature
|
|
533
517
|
|
|
534
|
-
|
|
535
|
-
(doc, oldDoc, user: UserContext | null, ctx: Helpers) => AccessDescriptor;
|
|
536
|
-
```
|
|
537
|
-
|
|
538
|
-
- `doc` — the document being written
|
|
539
|
-
- `oldDoc` — the previous version (null for new documents)
|
|
540
|
-
- `user` — the authenticated user, or `null` for anonymous requests
|
|
541
|
-
- `ctx` — server-provided helpers for checking materialized state
|
|
542
|
-
|
|
543
|
-
**UserContext:**
|
|
518
|
+
`(doc, oldDoc, user: UserContext | null, ctx: Helpers) => AccessDescriptor` where `doc` is the document being written, `oldDoc` is the previous version (null for new documents), `user` is the authenticated user or `null` for anonymous requests, and `ctx` provides server helpers for checking materialized state.
|
|
544
519
|
|
|
545
|
-
|
|
546
|
-
{
|
|
547
|
-
userHandle: string // stable unique id — use for all auth checks
|
|
548
|
-
displayName?: string // display only — never use for identity checks
|
|
549
|
-
}
|
|
550
|
-
```
|
|
520
|
+
**UserContext:** `{ userHandle: string, displayName?: string }` — `userHandle` is stable unique id (use for all auth checks), `displayName` is display only (never use for identity checks).
|
|
551
521
|
|
|
552
|
-
**Helpers (`ctx`):** Opaque closures over the materialized grant state. They throw or pass — you cannot enumerate channels, list members, or iterate grants. Both helpers also throw when `user` is null.
|
|
553
|
-
|
|
554
|
-
- `ctx.requireAccess(channelId)` — throws if user is not in the channel
|
|
555
|
-
- `ctx.requireRole(roleName)` — throws if user is not in the role
|
|
522
|
+
**Helpers (`ctx`):** Opaque closures over the materialized grant state. They throw or pass — you cannot enumerate channels, list members, or iterate grants. Both helpers also throw when `user` is null: `ctx.requireAccess(channelId)` throws if user is not in the channel, `ctx.requireRole(roleName)` throws if user is not in the role.
|
|
556
523
|
|
|
557
524
|
### AccessDescriptor return type
|
|
558
525
|
|
|
@@ -589,6 +556,7 @@ type AccessDescriptor = {
|
|
|
589
556
|
### Example: Workspace chat with channels
|
|
590
557
|
|
|
591
558
|
access.js
|
|
559
|
+
|
|
592
560
|
```js
|
|
593
561
|
export function chat(doc, oldDoc, user, ctx) {
|
|
594
562
|
if (!user) throw { forbidden: "authentication required" };
|
|
@@ -623,15 +591,12 @@ export function chat(doc, oldDoc, user, ctx) {
|
|
|
623
591
|
}
|
|
624
592
|
```
|
|
625
593
|
|
|
626
|
-
This single access function handles three document types:
|
|
627
|
-
|
|
628
|
-
- **channel-meta** — owner creates a channel and grants access to listed members
|
|
629
|
-
- **message** — only the author can post, must already have channel access
|
|
630
|
-
- **channel-invite** — any channel member can invite others; deleting the invite revokes the grant
|
|
594
|
+
This single access function handles three document types: **channel-meta** — owner creates a channel and grants access to listed members, **message** — only the author can post, must already have channel access, **channel-invite** — any channel member can invite others; deleting the invite revokes the grant.
|
|
631
595
|
|
|
632
596
|
### Example: Anonymous survey with role-gated results
|
|
633
597
|
|
|
634
598
|
access.js
|
|
599
|
+
|
|
635
600
|
```js
|
|
636
601
|
export function survey(doc, oldDoc, user, ctx) {
|
|
637
602
|
if (doc.type === "survey-response") {
|
|
@@ -660,18 +625,14 @@ export function survey(doc, oldDoc, user, ctx) {
|
|
|
660
625
|
}
|
|
661
626
|
```
|
|
662
627
|
|
|
663
|
-
Key patterns:
|
|
664
|
-
|
|
665
|
-
- `allowAnonymous: true` on survey-response lets unauthenticated visitors submit
|
|
666
|
-
- Requiring `doc._id` to be falsy prevents clients from choosing or overwriting response IDs
|
|
667
|
-
- `grant.public` on final-results makes them readable by any member without a specific channel grant
|
|
668
|
-
- The **singleton grant doc** pattern (survey-config) wires role-to-channel access in one place
|
|
628
|
+
Key patterns: `allowAnonymous: true` on survey-response lets unauthenticated visitors submit, `grant.public` on final-results makes them readable by any member without a specific channel grant, and the **singleton grant doc** pattern (survey-config) wires role-to-channel access in one place.
|
|
669
629
|
|
|
670
630
|
### Multiple databases in one file
|
|
671
631
|
|
|
672
632
|
Each named export gates its own database. A single `/access.js` can gate all databases the app uses:
|
|
673
633
|
|
|
674
634
|
access.js
|
|
635
|
+
|
|
675
636
|
```js
|
|
676
637
|
export function chat(doc, oldDoc, user, ctx) {
|
|
677
638
|
if (!user) throw { forbidden: "authentication required" };
|
|
@@ -690,6 +651,7 @@ Databases without a matching named export fall through to `export default` if on
|
|
|
690
651
|
**Hyphenated database names** are rare — prefer camelCase (`useFireproof("crewChat")`). If you inherit a hyphenated name, use `export { localName as "db-name" }` to map a local function:
|
|
691
652
|
|
|
692
653
|
access.js
|
|
654
|
+
|
|
693
655
|
```js
|
|
694
656
|
function crewChat(doc, oldDoc, user, ctx) {
|
|
695
657
|
if (!user) throw { forbidden: "authentication required" };
|
|
@@ -704,6 +666,7 @@ export { crewChat as "crew-chat" }
|
|
|
704
666
|
Use `export default` to gate every database without writing a named export for each one. Named exports (including `as` exports) still take precedence for databases that need custom logic:
|
|
705
667
|
|
|
706
668
|
access.js
|
|
669
|
+
|
|
707
670
|
```js
|
|
708
671
|
export function chat(doc, oldDoc, user, ctx) {
|
|
709
672
|
if (!user) throw { forbidden: "authentication required" };
|
|
@@ -722,52 +685,69 @@ This is especially useful when an app has many databases.
|
|
|
722
685
|
|
|
723
686
|
### Roles via `members` reduce
|
|
724
687
|
|
|
725
|
-
Roles are not a fixed registry. They are materialized from document contributions:
|
|
688
|
+
Roles are not a fixed registry. They are materialized from document contributions. A team-meta doc contributes members to a role:
|
|
689
|
+
|
|
690
|
+
access.js
|
|
726
691
|
|
|
727
692
|
```js
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
if (!user
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
grant: { roles: { [doc.teamId]: doc.channels } },
|
|
734
|
-
};
|
|
693
|
+
<<<<<<< SEARCH
|
|
694
|
+
export function chat(doc, oldDoc, user, ctx) {
|
|
695
|
+
if (!user) throw { forbidden: "authentication required" };
|
|
696
|
+
ctx.requireAccess(doc.channelId);
|
|
697
|
+
return { channels: [doc.channelId] };
|
|
735
698
|
}
|
|
699
|
+
=======
|
|
700
|
+
export function chat(doc, oldDoc, user, ctx) {
|
|
701
|
+
if (!user) throw { forbidden: "authentication required" };
|
|
702
|
+
|
|
703
|
+
if (doc.type === "team-meta") {
|
|
704
|
+
if (!user.isOwner) throw { forbidden: "owner only" };
|
|
705
|
+
return {
|
|
706
|
+
members: { [doc.teamId]: doc.memberHandles },
|
|
707
|
+
grant: { roles: { [doc.teamId]: doc.channels } },
|
|
708
|
+
};
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
if (doc.type === "membership") {
|
|
712
|
+
return { members: { [doc.role]: [doc.userHandle] } };
|
|
713
|
+
}
|
|
736
714
|
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
return { members: { [doc.role]: [doc.userHandle] } };
|
|
715
|
+
ctx.requireAccess(doc.channelId);
|
|
716
|
+
return { channels: [doc.channelId] };
|
|
740
717
|
}
|
|
718
|
+
>>>>>>> REPLACE
|
|
741
719
|
```
|
|
742
720
|
|
|
743
721
|
Both patterns produce identical reduced state. Deleting a membership doc removes the user from the role automatically.
|
|
744
722
|
|
|
745
723
|
### Common `oldDoc` patterns
|
|
746
724
|
|
|
747
|
-
Use `oldDoc` (the previous version of the document) to enforce invariants across updates:
|
|
748
|
-
|
|
749
|
-
```js
|
|
750
|
-
// New document (create)
|
|
751
|
-
if (oldDoc === null) {
|
|
752
|
-
// create-only logic
|
|
753
|
-
}
|
|
754
|
-
|
|
755
|
-
// Immutable-after-create fields
|
|
756
|
-
if (oldDoc && doc.createdBy !== oldDoc.createdBy) {
|
|
757
|
-
throw { forbidden: "createdBy is immutable" };
|
|
758
|
-
}
|
|
725
|
+
Use `oldDoc` (the previous version of the document) to enforce invariants across updates. Adding update guards to an access function:
|
|
759
726
|
|
|
760
|
-
|
|
761
|
-
if (oldDoc && oldDoc.ownerHandle !== user.userHandle) {
|
|
762
|
-
throw { forbidden: "not owner" };
|
|
763
|
-
}
|
|
727
|
+
access.js
|
|
764
728
|
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
}
|
|
729
|
+
```js
|
|
730
|
+
<<<<<<< SEARCH
|
|
731
|
+
if (doc.type === "team-meta") {
|
|
732
|
+
if (!user.isOwner) throw { forbidden: "owner only" };
|
|
733
|
+
return {
|
|
734
|
+
=======
|
|
735
|
+
if (doc.type === "team-meta") {
|
|
736
|
+
if (!user.isOwner) throw { forbidden: "owner only" };
|
|
737
|
+
// Immutable-after-create fields
|
|
738
|
+
if (oldDoc && doc.createdBy !== oldDoc.createdBy) {
|
|
739
|
+
throw { forbidden: "createdBy is immutable" };
|
|
740
|
+
}
|
|
741
|
+
// Prevent unauthorized ownership transfer
|
|
742
|
+
if (oldDoc && oldDoc.ownerHandle !== user.userHandle) {
|
|
743
|
+
throw { forbidden: "not owner" };
|
|
744
|
+
}
|
|
745
|
+
return {
|
|
746
|
+
>>>>>>> REPLACE
|
|
769
747
|
```
|
|
770
748
|
|
|
749
|
+
Other common `oldDoc` patterns: `if (oldDoc === null) { /* create-only logic */ }` for new documents, and `if (oldDoc && doc.version <= oldDoc.version) { throw { forbidden: "version must increase" } }` for monotonic versions.
|
|
750
|
+
|
|
771
751
|
---
|
|
772
752
|
|
|
773
753
|
## Architecture: Where's My Data?
|
|
@@ -778,30 +758,34 @@ Data is stored in the browser, and is automatically synced with all invited user
|
|
|
778
758
|
|
|
779
759
|
You can use the core API in HTML or on the backend. Instead of hooks, import the core API directly:
|
|
780
760
|
|
|
781
|
-
|
|
761
|
+
App.jsx
|
|
762
|
+
|
|
763
|
+
```jsx
|
|
764
|
+
<<<<<<< SEARCH
|
|
765
|
+
import React from "react";
|
|
766
|
+
import { useFireproof } from "use-fireproof";
|
|
767
|
+
|
|
768
|
+
export default function App() {
|
|
769
|
+
const { useDocument, useLiveQuery, database } = useFireproof("myLedger");
|
|
770
|
+
=======
|
|
782
771
|
import { fireproof } from "use-fireproof";
|
|
783
772
|
|
|
784
773
|
const database = fireproof("myLedger");
|
|
785
|
-
```
|
|
786
774
|
|
|
787
|
-
The document API is async, but doesn't require loading states or error handling.
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
const
|
|
791
|
-
const
|
|
792
|
-
|
|
793
|
-
|
|
775
|
+
// The document API is async, but doesn't require loading states or error handling.
|
|
776
|
+
async function main() {
|
|
777
|
+
const ok = await database.put({ text: "Sample Data" });
|
|
778
|
+
const doc = await database.get(ok.id);
|
|
779
|
+
const latest = await database.query("_id", { limit: 10, descending: true });
|
|
780
|
+
console.log("Latest documents:", latest.docs);
|
|
781
|
+
}
|
|
782
|
+
>>>>>>> REPLACE
|
|
794
783
|
```
|
|
795
784
|
|
|
796
|
-
To subscribe to real-time updates, use the `subscribe` method. This is useful for building backend event handlers or other server-side logic
|
|
785
|
+
To subscribe to real-time updates, use the `subscribe` method. This is useful for building backend event handlers or other server-side logic — for instance to send an email when the user completes a todo:
|
|
797
786
|
|
|
798
787
|
```js
|
|
799
|
-
import { fireproof } from "use-firproof";
|
|
800
|
-
|
|
801
|
-
const database = fireproof("todoList");
|
|
802
|
-
|
|
803
788
|
database.subscribe((changes) => {
|
|
804
|
-
console.log("Recent changes:", changes);
|
|
805
789
|
changes.forEach((change) => {
|
|
806
790
|
if (change.completed) {
|
|
807
791
|
sendEmail(change.email, "Todo completed", "You have completed a todo.");
|
|
@@ -812,98 +796,151 @@ database.subscribe((changes) => {
|
|
|
812
796
|
|
|
813
797
|
### Working with Files
|
|
814
798
|
|
|
815
|
-
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.
|
|
799
|
+
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.
|
|
816
800
|
|
|
817
|
-
|
|
801
|
+
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).
|
|
818
802
|
|
|
819
|
-
|
|
820
|
-
const { useDocument } = useFireproof("photoAlbum");
|
|
821
|
-
const { doc, merge, submit } = useDocument({ _files: {}, caption: "" });
|
|
803
|
+
Building an image uploader with `_files`:
|
|
822
804
|
|
|
823
|
-
|
|
824
|
-
const onPick = (e) => merge({ _files: { photo: e.target.files[0] } });
|
|
825
|
-
```
|
|
805
|
+
App.jsx
|
|
826
806
|
|
|
827
|
-
|
|
807
|
+
```jsx
|
|
808
|
+
import React from "react";
|
|
809
|
+
import { useFireproof } from "use-fireproof";
|
|
828
810
|
|
|
829
|
-
|
|
811
|
+
export default function App() {
|
|
812
|
+
const { useDocument, useLiveQuery } = useFireproof("imageUploads");
|
|
830
813
|
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
};
|
|
837
|
-
```
|
|
814
|
+
const { doc, merge, submit } = useDocument({
|
|
815
|
+
_files: {},
|
|
816
|
+
caption: "",
|
|
817
|
+
type: "upload",
|
|
818
|
+
createdAt: Date.now(),
|
|
819
|
+
});
|
|
838
820
|
|
|
839
|
-
|
|
821
|
+
const { docs } = useLiveQuery("type", { key: "upload", descending: true, limit: 12 });
|
|
840
822
|
|
|
841
|
-
|
|
823
|
+
const onPickFile = (e) => {
|
|
824
|
+
const f = e.target.files?.[0];
|
|
825
|
+
if (f) merge({ _files: { photo: f } });
|
|
826
|
+
};
|
|
842
827
|
|
|
843
|
-
|
|
828
|
+
const onSubmit = (e) => {
|
|
829
|
+
e.preventDefault();
|
|
830
|
+
if (!doc._files?.photo) return;
|
|
831
|
+
submit();
|
|
832
|
+
};
|
|
844
833
|
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
834
|
+
const c = {
|
|
835
|
+
bg: "bg-white",
|
|
836
|
+
card: "bg-gray-50",
|
|
837
|
+
border: "border-gray-200",
|
|
838
|
+
accent: "bg-blue-500 hover:bg-blue-600",
|
|
839
|
+
text: "text-gray-700",
|
|
840
|
+
};
|
|
850
841
|
|
|
851
|
-
|
|
842
|
+
return (
|
|
843
|
+
<div className={`p-6 max-w-lg mx-auto ${c.bg} shadow-lg rounded-lg`}>
|
|
844
|
+
<h2 className="text-2xl font-bold mb-4">Image Uploader</h2>
|
|
845
|
+
<form onSubmit={onSubmit} className="space-y-3">
|
|
846
|
+
<input type="file" accept="image/*" onChange={onPickFile} className={`w-full ${c.border} border rounded p-2`} />
|
|
847
|
+
<input
|
|
848
|
+
type="text"
|
|
849
|
+
placeholder="Caption"
|
|
850
|
+
value={doc.caption}
|
|
851
|
+
onChange={(e) => merge({ caption: e.target.value })}
|
|
852
|
+
className={`w-full ${c.border} border rounded p-2`}
|
|
853
|
+
/>
|
|
854
|
+
<button type="submit" className={`px-4 py-2 ${c.accent} text-white rounded`}>
|
|
855
|
+
Upload
|
|
856
|
+
</button>
|
|
857
|
+
</form>
|
|
852
858
|
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
859
|
+
<h3 className="text-lg font-semibold mt-6">Recent Uploads</h3>
|
|
860
|
+
<div className="grid grid-cols-2 gap-4 mt-2">
|
|
861
|
+
{docs.map((d) => (
|
|
862
|
+
<div key={d._id} className={`${c.border} border p-2 rounded shadow-sm ${c.card}`}>
|
|
863
|
+
{d._files?.photo?.url && <img src={d._files.photo.url} alt={d.caption || "upload"} className="w-full h-auto rounded" />}
|
|
864
|
+
<p className={`text-sm ${c.text} mt-2`}>{d.caption || "No caption"}</p>
|
|
865
|
+
</div>
|
|
866
|
+
))}
|
|
867
|
+
</div>
|
|
868
|
+
</div>
|
|
869
|
+
);
|
|
856
870
|
}
|
|
857
871
|
```
|
|
858
872
|
|
|
859
|
-
|
|
873
|
+
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.
|
|
860
874
|
|
|
861
|
-
|
|
875
|
+
Adding multi-file support:
|
|
862
876
|
|
|
863
|
-
|
|
877
|
+
App.jsx
|
|
864
878
|
|
|
865
879
|
```jsx
|
|
866
|
-
|
|
867
|
-
const
|
|
868
|
-
const
|
|
869
|
-
|
|
880
|
+
<<<<<<< SEARCH
|
|
881
|
+
const onPickFile = (e) => {
|
|
882
|
+
const f = e.target.files?.[0];
|
|
883
|
+
if (f) merge({ _files: { photo: f } });
|
|
884
|
+
};
|
|
870
885
|
|
|
871
|
-
|
|
886
|
+
const onSubmit = (e) => {
|
|
887
|
+
e.preventDefault();
|
|
888
|
+
if (!doc._files?.photo) return;
|
|
889
|
+
submit();
|
|
890
|
+
};
|
|
891
|
+
=======
|
|
892
|
+
const onPickFile = (e) => {
|
|
893
|
+
const next = {};
|
|
894
|
+
for (const f of e.target.files) next[f.name] = f;
|
|
895
|
+
merge({ _files: next });
|
|
896
|
+
};
|
|
872
897
|
|
|
873
|
-
|
|
898
|
+
const onSubmit = (e) => {
|
|
899
|
+
e.preventDefault();
|
|
900
|
+
if (!Object.keys(doc._files || {}).length) return;
|
|
901
|
+
submit();
|
|
902
|
+
};
|
|
903
|
+
>>>>>>> REPLACE
|
|
904
|
+
```
|
|
874
905
|
|
|
875
|
-
|
|
906
|
+
access.js
|
|
876
907
|
|
|
877
|
-
```
|
|
878
|
-
{
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
size: number; // bytes
|
|
882
|
-
lastModified?: number; // epoch ms (for File only)
|
|
883
|
-
file: () => Promise<File>; // bytes on demand
|
|
908
|
+
```js
|
|
909
|
+
export function imageUploads(doc, oldDoc, user) {
|
|
910
|
+
if (!user) throw { forbidden: "sign in to upload" };
|
|
911
|
+
return {};
|
|
884
912
|
}
|
|
885
913
|
```
|
|
886
914
|
|
|
887
915
|
### Form Validation
|
|
888
916
|
|
|
889
|
-
You can use React's `useState` to manage validation states and error messages. Validate inputs at the UI level before allowing submission.
|
|
917
|
+
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:
|
|
890
918
|
|
|
891
|
-
|
|
892
|
-
const [errors, setErrors] = useState({});
|
|
919
|
+
App.jsx
|
|
893
920
|
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
921
|
+
```jsx
|
|
922
|
+
<<<<<<< SEARCH
|
|
923
|
+
const onSubmit = (e) => {
|
|
924
|
+
e.preventDefault();
|
|
925
|
+
if (!Object.keys(doc._files || {}).length) return;
|
|
926
|
+
submit();
|
|
927
|
+
};
|
|
928
|
+
=======
|
|
929
|
+
const [errors, setErrors] = React.useState({});
|
|
930
|
+
|
|
931
|
+
function validateForm() {
|
|
932
|
+
const newErrors = {};
|
|
933
|
+
if (!doc.caption.trim()) newErrors.caption = "Caption is required.";
|
|
934
|
+
if (!Object.keys(doc._files || {}).length) newErrors.file = "Pick a file.";
|
|
935
|
+
setErrors(newErrors);
|
|
936
|
+
return Object.keys(newErrors).length === 0;
|
|
937
|
+
}
|
|
902
938
|
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
}
|
|
939
|
+
const onSubmit = (e) => {
|
|
940
|
+
e.preventDefault();
|
|
941
|
+
if (validateForm()) submit();
|
|
942
|
+
};
|
|
943
|
+
>>>>>>> REPLACE
|
|
907
944
|
```
|
|
908
945
|
|
|
909
946
|
## Example React Application
|
|
@@ -1010,86 +1047,3 @@ export function todoList(doc, oldDoc, user) {
|
|
|
1010
1047
|
return {};
|
|
1011
1048
|
}
|
|
1012
1049
|
```
|
|
1013
|
-
|
|
1014
|
-
## Example Image Uploader
|
|
1015
|
-
|
|
1016
|
-
This pattern uses `_files` end-to-end: save a `File` directly, render thumbnails with `<img src={meta.url}>`.
|
|
1017
|
-
|
|
1018
|
-
```jsx
|
|
1019
|
-
import React from "react";
|
|
1020
|
-
import { useFireproof } from "use-fireproof";
|
|
1021
|
-
|
|
1022
|
-
export default function App() {
|
|
1023
|
-
// 1. Hooks and document shapes
|
|
1024
|
-
const { useDocument, useLiveQuery } = useFireproof("imageUploads");
|
|
1025
|
-
|
|
1026
|
-
const { doc, merge, submit } = useDocument({
|
|
1027
|
-
_files: {},
|
|
1028
|
-
caption: "",
|
|
1029
|
-
type: "upload",
|
|
1030
|
-
createdAt: Date.now(),
|
|
1031
|
-
});
|
|
1032
|
-
|
|
1033
|
-
const { docs } = useLiveQuery("type", { key: "upload", descending: true, limit: 12 });
|
|
1034
|
-
|
|
1035
|
-
// 2. Event handlers
|
|
1036
|
-
const onPickFile = (e) => {
|
|
1037
|
-
const f = e.target.files?.[0];
|
|
1038
|
-
if (f) merge({ _files: { photo: f } });
|
|
1039
|
-
};
|
|
1040
|
-
|
|
1041
|
-
const onSubmit = (e) => {
|
|
1042
|
-
e.preventDefault();
|
|
1043
|
-
if (!doc._files?.photo) return;
|
|
1044
|
-
submit();
|
|
1045
|
-
};
|
|
1046
|
-
|
|
1047
|
-
// 3. ClassNames
|
|
1048
|
-
const c = {
|
|
1049
|
-
bg: "bg-white",
|
|
1050
|
-
card: "bg-gray-50",
|
|
1051
|
-
border: "border-gray-200",
|
|
1052
|
-
accent: "bg-blue-500 hover:bg-blue-600",
|
|
1053
|
-
text: "text-gray-700",
|
|
1054
|
-
};
|
|
1055
|
-
|
|
1056
|
-
// 4. JSX return
|
|
1057
|
-
return (
|
|
1058
|
-
<div className={`p-6 max-w-lg mx-auto ${c.bg} shadow-lg rounded-lg`}>
|
|
1059
|
-
<h2 className="text-2xl font-bold mb-4">Image Uploader</h2>
|
|
1060
|
-
<form onSubmit={onSubmit} className="space-y-3">
|
|
1061
|
-
<input type="file" accept="image/*" onChange={onPickFile} className={`w-full ${c.border} border rounded p-2`} />
|
|
1062
|
-
<input
|
|
1063
|
-
type="text"
|
|
1064
|
-
placeholder="Caption"
|
|
1065
|
-
value={doc.caption}
|
|
1066
|
-
onChange={(e) => merge({ caption: e.target.value })}
|
|
1067
|
-
className={`w-full ${c.border} border rounded p-2`}
|
|
1068
|
-
/>
|
|
1069
|
-
<button type="submit" className={`px-4 py-2 ${c.accent} text-white rounded`}>
|
|
1070
|
-
Upload
|
|
1071
|
-
</button>
|
|
1072
|
-
</form>
|
|
1073
|
-
|
|
1074
|
-
<h3 className="text-lg font-semibold mt-6">Recent Uploads</h3>
|
|
1075
|
-
<div className="grid grid-cols-2 gap-4 mt-2">
|
|
1076
|
-
{docs.map((d) => (
|
|
1077
|
-
<div key={d._id} className={`${c.border} border p-2 rounded shadow-sm ${c.card}`}>
|
|
1078
|
-
{d._files?.photo?.url && <img src={d._files.photo.url} alt={d.caption || "upload"} className="w-full h-auto rounded" />}
|
|
1079
|
-
<p className={`text-sm ${c.text} mt-2`}>{d.caption || "No caption"}</p>
|
|
1080
|
-
</div>
|
|
1081
|
-
))}
|
|
1082
|
-
</div>
|
|
1083
|
-
</div>
|
|
1084
|
-
);
|
|
1085
|
-
}
|
|
1086
|
-
```
|
|
1087
|
-
|
|
1088
|
-
access.js
|
|
1089
|
-
|
|
1090
|
-
```js
|
|
1091
|
-
export function imageUploads(doc, oldDoc, user) {
|
|
1092
|
-
if (!user) throw { forbidden: "sign in to upload" };
|
|
1093
|
-
return {};
|
|
1094
|
-
}
|
|
1095
|
-
```
|