create-questpie 2.0.0 → 2.0.2

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.
Files changed (45) hide show
  1. package/README.md +10 -6
  2. package/dist/index.mjs +140 -25
  3. package/package.json +5 -3
  4. package/skills/questpie/AGENTS.md +2664 -0
  5. package/skills/questpie/SKILL.md +181 -0
  6. package/skills/questpie/references/auth.md +121 -0
  7. package/skills/questpie/references/business-logic.md +550 -0
  8. package/skills/questpie/references/codegen-plugin-api.md +382 -0
  9. package/skills/questpie/references/crud-api.md +378 -0
  10. package/skills/questpie/references/data-modeling.md +489 -0
  11. package/skills/questpie/references/extend.md +493 -0
  12. package/skills/questpie/references/field-types.md +386 -0
  13. package/skills/questpie/references/infrastructure-adapters.md +545 -0
  14. package/skills/questpie/references/multi-tenancy.md +364 -0
  15. package/skills/questpie/references/production.md +475 -0
  16. package/skills/questpie/references/query-operators.md +125 -0
  17. package/skills/questpie/references/quickstart.md +549 -0
  18. package/skills/questpie/references/rules.md +327 -0
  19. package/skills/questpie/references/tanstack-query.md +520 -0
  20. package/skills/questpie-admin/AGENTS.md +1442 -0
  21. package/skills/questpie-admin/SKILL.md +410 -0
  22. package/skills/questpie-admin/references/blocks.md +307 -0
  23. package/skills/questpie-admin/references/custom-ui.md +305 -0
  24. package/skills/questpie-admin/references/views.md +433 -0
  25. package/templates/tanstack-start/AGENTS.md +71 -62
  26. package/templates/tanstack-start/CLAUDE.md +26 -23
  27. package/templates/tanstack-start/README.md +32 -20
  28. package/templates/tanstack-start/env.example +1 -1
  29. package/templates/tanstack-start/package.json +20 -6
  30. package/templates/tanstack-start/src/lib/client.ts +2 -2
  31. package/templates/tanstack-start/src/lib/env.ts +1 -1
  32. package/templates/tanstack-start/src/questpie/admin/.generated/client.ts +13 -0
  33. package/templates/tanstack-start/src/questpie/admin/modules.ts +1 -0
  34. package/templates/tanstack-start/src/questpie/server/.generated/factories.ts +117 -241
  35. package/templates/tanstack-start/src/questpie/server/.generated/index.ts +129 -81
  36. package/templates/tanstack-start/src/questpie/server/app.ts +1 -1
  37. package/templates/tanstack-start/src/questpie/server/config/admin.ts +27 -30
  38. package/templates/tanstack-start/src/questpie/server/globals/site-settings.global.ts +1 -1
  39. package/templates/tanstack-start/src/questpie/server/questpie.config.ts +1 -1
  40. package/templates/tanstack-start/src/routeTree.gen.ts +138 -0
  41. package/templates/tanstack-start/src/routes/__root.tsx +0 -2
  42. package/templates/tanstack-start/src/routes/admin.tsx +8 -1
  43. package/templates/tanstack-start/src/tanstack-start.d.ts +1 -0
  44. package/templates/tanstack-start/src/vite-env.d.ts +1 -0
  45. package/templates/tanstack-start/vite.config.ts +1 -3
@@ -0,0 +1,1442 @@
1
+ # QUESTPIE Admin Panel
2
+
3
+ The QUESTPIE admin panel is a **projection of your server schema** — not the framework itself. It reads collections, globals, and config via introspection and generates a full admin interface. Your backend works without it.
4
+
5
+ ## Reference Topics
6
+
7
+ | Topic | File | Covers |
8
+ | --------- | ------------------------- | -------------------------------------------------------------------------------------- |
9
+ | Views | `references/views.md` | List views, form views, dashboard, sidebar, filters, bulk actions, visibility, history |
10
+ | Blocks | `references/blocks.md` | Block definitions, fields, prefetch, renderers, block picker |
11
+ | Custom UI | `references/custom-ui.md` | Custom fields, custom views, registries, reactive fields, widgets |
12
+
13
+ ## Full Compiled Document
14
+
15
+ For the complete admin reference with all topics expanded: `AGENTS.md`
16
+
17
+ ## Tech Stack
18
+
19
+ - **React** + **Tailwind CSS v4** + **shadcn** components
20
+ - **@base-ui/react** primitives (NOT @radix-ui)
21
+ - **@iconify/react** with Phosphor icon set (`ph:icon-name`)
22
+ - **sonner** for toasts — `toast.error()`, `toast.success()`
23
+ - Brutalist flat design: `--radius: 0px`, no shadows
24
+
25
+ ## Setup
26
+
27
+ ### 1. Install
28
+
29
+ ```bash
30
+ bun add @questpie/admin
31
+ ```
32
+
33
+ ### 2. Runtime Config
34
+
35
+ ```ts title="questpie.config.ts"
36
+ import { runtimeConfig } from "questpie";
37
+
38
+ export default runtimeConfig({
39
+ app: { url: process.env.APP_URL || "http://localhost:3000" },
40
+ db: { url: process.env.DATABASE_URL },
41
+ secret: process.env.APP_SECRET,
42
+ });
43
+ ```
44
+
45
+ The admin module contributes the codegen plugin automatically. It discovers `config/admin.ts`, `blocks/`, views, components, and admin client modules.
46
+
47
+ ### 3. Modules
48
+
49
+ ```ts title="modules.ts"
50
+ import { adminModule, auditModule } from "@questpie/admin/server";
51
+
52
+ export default [adminModule, auditModule] as const;
53
+ ```
54
+
55
+ | Module | Provides |
56
+ | ------------- | ------------------------------------- |
57
+ | `adminModule` | User collection, auth pages, admin UI |
58
+ | `auditModule` | Audit log collection, timeline widget |
59
+
60
+ ### 4. Admin Config
61
+
62
+ ```ts title="config/admin.ts"
63
+ import { adminConfig } from "#questpie/factories";
64
+
65
+ export default adminConfig({
66
+ branding: {
67
+ name: { en: "My App Admin" },
68
+ },
69
+ sidebar: {
70
+ sections: [
71
+ { id: "overview", title: { en: "Overview" } },
72
+ { id: "content", title: { en: "Content" } },
73
+ ],
74
+ items: [
75
+ {
76
+ sectionId: "overview",
77
+ type: "link",
78
+ label: { en: "Dashboard" },
79
+ href: "/admin",
80
+ icon: { type: "icon", props: { name: "ph:house" } },
81
+ },
82
+ {
83
+ sectionId: "content",
84
+ type: "collection",
85
+ collection: "posts",
86
+ },
87
+ ],
88
+ },
89
+ });
90
+ ```
91
+
92
+ ### 5. Codegen
93
+
94
+ ```bash
95
+ bunx questpie generate
96
+ ```
97
+
98
+ ### 6. Mount the Admin
99
+
100
+ ```ts title="routes/admin/$.tsx"
101
+ import { AdminRouter } from "@questpie/admin/client";
102
+ import { admin } from "@/questpie/admin/admin";
103
+
104
+ export default function AdminPage() {
105
+ return <AdminRouter admin={admin} />;
106
+ }
107
+ ```
108
+
109
+ The admin client config is auto-generated by codegen at `admin/.generated/client.ts`. No manual builder setup needed.
110
+
111
+ ## Branding
112
+
113
+ ```ts title="config/admin.ts"
114
+ import { adminConfig } from "#questpie/factories";
115
+
116
+ export default adminConfig({
117
+ branding: {
118
+ name: { en: "Barbershop Control", sk: "Riadenie barbershopu" },
119
+ },
120
+ });
121
+ ```
122
+
123
+ | Option | Type | Description |
124
+ | ------ | ---------------- | -------------------------------- |
125
+ | `name` | `string \| i18n` | App name shown in sidebar header |
126
+ | `logo` | `string` | Logo URL or path |
127
+
128
+ ## Theming (CSS Variables)
129
+
130
+ The admin uses CSS variables for all theming. Override them in your own CSS file.
131
+
132
+ ### Light Theme (`:root`)
133
+
134
+ | Variable | Default | Purpose |
135
+ | ---------------------- | --------- | -------------------------------- |
136
+ | `--background` | `#FFFFFF` | Page background |
137
+ | `--foreground` | `#0A0A0A` | Primary text |
138
+ | `--card` | `#F8F8F8` | Cards, panels, sidebar |
139
+ | `--popover` | `#FFFFFF` | Dropdowns, tooltips, dialogs |
140
+ | `--muted` | `#F0F0F0` | Hover states, table headers |
141
+ | `--muted-foreground` | `#666666` | Secondary text, placeholders |
142
+ | `--primary` | `#B700FF` | Brand accent (CTAs, focus rings) |
143
+ | `--primary-foreground` | `#FFFFFF` | Text on primary backgrounds |
144
+ | `--destructive` | `#FF3D57` | Errors, delete actions |
145
+ | `--success` | `#00E676` | Positive states |
146
+ | `--warning` | `#FFB300` | Caution states |
147
+ | `--info` | `#40C4FF` | Informational emphasis |
148
+ | `--border` | `#E0E0E0` | All structural borders |
149
+ | `--ring` | `#B700FF` | Focus ring color |
150
+ | `--radius` | `0px` | Border radius (0 = brutalist) |
151
+
152
+ ### Dark Theme (`.dark` class)
153
+
154
+ Dark mode uses the `.dark` class on the root element. Key overrides:
155
+
156
+ | Variable | Dark Value |
157
+ | -------------- | ---------- |
158
+ | `--background` | `#0A0A0A` |
159
+ | `--foreground` | `#FFFFFF` |
160
+ | `--card` | `#111111` |
161
+ | `--border` | `#333333` |
162
+ | `--muted` | `#1A1A1A` |
163
+
164
+ ### Typography
165
+
166
+ | Variable | Value |
167
+ | ------------- | ------------------------------------------------------------------- |
168
+ | `--font-sans` | `"Geist Variable"` — body text, descriptions |
169
+ | `--font-mono` | `"JetBrains Mono Variable"` — UI chrome: nav, buttons, tabs, badges |
170
+
171
+ ### Sidebar Variables
172
+
173
+ Separate tokens for independent sidebar theming: `--sidebar`, `--sidebar-foreground`, `--sidebar-primary`, `--sidebar-accent`, `--sidebar-border`, `--sidebar-ring`.
174
+
175
+ ### Custom Theme
176
+
177
+ 1. Copy the admin CSS file
178
+ 2. Change variable values
179
+ 3. Import your copy instead
180
+ 4. Zero component changes needed
181
+
182
+ ## Content Localization
183
+
184
+ When collections have `.localized()` fields, the admin shows a locale switcher:
185
+
186
+ ```ts title="config/app.ts"
187
+ import { appConfig } from "questpie";
188
+
189
+ export default appConfig({
190
+ locale: {
191
+ locales: [
192
+ { code: "en", label: { en: "English" }, flagCountryCode: "gb" },
193
+ { code: "sk", label: { en: "Slovak" } },
194
+ ],
195
+ defaultLocale: "en",
196
+ },
197
+ });
198
+ ```
199
+
200
+ The admin tracks content locale separately from UI locale. Only localized field values change when switching.
201
+
202
+ ## Media & Uploads
203
+
204
+ ```ts
205
+ avatar: f.upload({
206
+ to: "assets",
207
+ mimeTypes: ["image/*"],
208
+ maxSize: 5_000_000,
209
+ }),
210
+ ```
211
+
212
+ The admin renders drag-and-drop upload, image preview, file info, and remove button.
213
+
214
+ ## Live Preview
215
+
216
+ Live Preview uses a split-screen iframe. The current implementation refreshes the iframe after save/autosave and uses `postMessage` for field/block focus sync.
217
+
218
+ Preview V2 patch-based docs are design notes until `useQuestpiePreview`, `PreviewRoot`, and `PreviewBlock` are exported.
219
+
220
+ ### Server Config
221
+
222
+ Add `.preview()` to a collection to enable split-screen editing:
223
+
224
+ ```ts title="collections/pages.ts"
225
+ export const pages = collection("pages")
226
+ .fields(({ f }) => ({
227
+ title: f.text().required().localized(),
228
+ slug: f.text().required(),
229
+ content: f.blocks().localized(),
230
+ }))
231
+ .preview({
232
+ enabled: true,
233
+ position: "right",
234
+ defaultWidth: 50,
235
+ url: ({ record }) => {
236
+ const slug = record.slug as string;
237
+ return slug === "home" ? "/?preview=true" : `/${slug}?preview=true`;
238
+ },
239
+ });
240
+ ```
241
+
242
+ Current preview refreshes the iframe after save/autosave and supports field focus through `postMessage`.
243
+
244
+ ### Frontend Integration
245
+
246
+ Use `useCollectionPreview` with `PreviewProvider` and `PreviewField`:
247
+
248
+ ```tsx
249
+ import {
250
+ PreviewField,
251
+ PreviewProvider,
252
+ useCollectionPreview,
253
+ } from "@questpie/admin/client";
254
+
255
+ function PagePreview({ initialData }) {
256
+ const router = useRouter();
257
+ const preview = useCollectionPreview({
258
+ initialData,
259
+ onRefresh: () => router.invalidate(),
260
+ });
261
+
262
+ return (
263
+ <PreviewProvider
264
+ isPreviewMode={preview.isPreviewMode}
265
+ focusedField={preview.focusedField}
266
+ onFieldClick={preview.handleFieldClick}
267
+ >
268
+ <PreviewField field="title" as="h1">
269
+ {preview.data.title}
270
+ </PreviewField>
271
+ </PreviewProvider>
272
+ );
273
+ }
274
+ ```
275
+
276
+ ### Key Principles
277
+
278
+ - Current preview = save/autosave refresh plus field/block focus sync
279
+ - `useCollectionPreview` sends `PREVIEW_READY`, `FIELD_CLICKED`, and `BLOCK_CLICKED`
280
+ - `PreviewProvider` supplies preview context to `PreviewField`
281
+ - Each message carries `sessionId`, `seq`, `timestamp`, `protocolVersion`
282
+ - Preview wrappers must prevent accidental navigation in the iframe
283
+
284
+ ## History & Versions
285
+
286
+ Enable `auditModule` for activity timeline. Enable versioning on collections for snapshot restore:
287
+
288
+ ```ts
289
+ export const pages = collection("pages")
290
+ .fields(({ f }) => ({ ... }))
291
+ .options({ versioning: true });
292
+ ```
293
+
294
+ ## Scope (Multi-Tenancy)
295
+
296
+ The admin provides scope primitives for multi-tenant applications. Import from `@questpie/admin/client`.
297
+
298
+ ### ScopeProvider
299
+
300
+ Wraps the admin to enable scope selection. Manages scope ID in React state and persists to localStorage.
301
+
302
+ ```tsx
303
+ import { ScopeProvider } from "@questpie/admin/client";
304
+
305
+ <ScopeProvider
306
+ headerName="x-selected-workspace" // HTTP header for scope ID
307
+ storageKey="admin-workspace" // localStorage key
308
+ defaultScope={null} // default value
309
+ >
310
+ <AdminLayout>...</AdminLayout>
311
+ </ScopeProvider>;
312
+ ```
313
+
314
+ ### ScopePicker
315
+
316
+ Dropdown for selecting the current scope. Place in sidebar via `slots.afterBrand`:
317
+
318
+ ```tsx
319
+ import { ScopePicker } from "@questpie/admin/client";
320
+
321
+ <AdminLayout
322
+ admin={admin}
323
+ basePath="/admin"
324
+ slots={{
325
+ afterBrand: (
326
+ <div className="px-3 py-2 border-b">
327
+ <ScopePicker
328
+ collection="workspaces"
329
+ labelField="name"
330
+ allowClear
331
+ compact
332
+ />
333
+ </div>
334
+ ),
335
+ }}
336
+ />;
337
+ ```
338
+
339
+ Three data sources: `collection` (queries a collection), `options` (static array), `loadOptions` (async function).
340
+
341
+ ### useScopedFetch / createScopedFetch
342
+
343
+ Inject scope header into all API calls:
344
+
345
+ ```tsx
346
+ import { useScopedFetch, createScopedFetch } from "@questpie/admin/client";
347
+
348
+ // React hook
349
+ const scopedFetch = useScopedFetch();
350
+ const client = useMemo(
351
+ () => createClient({ baseURL: "/api", fetch: scopedFetch }),
352
+ [scopedFetch],
353
+ );
354
+
355
+ // Non-React
356
+ const scopedFetch = createScopedFetch("x-selected-workspace", () =>
357
+ getScopeId(),
358
+ );
359
+ ```
360
+
361
+ ### useScope / useScopeSafe
362
+
363
+ Access current scope state in any component:
364
+
365
+ ```tsx
366
+ import { useScope, useScopeSafe } from "@questpie/admin/client";
367
+
368
+ const { scopeId, setScope, clearScope, headerName } = useScope(); // throws outside ScopeProvider
369
+ const scope = useScopeSafe(); // returns null outside ScopeProvider
370
+ ```
371
+
372
+ For the full server-side setup (context resolver, type augmentation, access rules), see the `questpie` skill's `references/multi-tenancy.md`.
373
+
374
+ ## Common Mistakes
375
+
376
+ 1. **CRITICAL: Using `asChild` prop** — QUESTPIE admin uses `@base-ui/react`, which uses the `render` prop. `asChild` is a Radix pattern and does NOT work here.
377
+
378
+ ```tsx
379
+ // WRONG
380
+ <DialogTrigger asChild><Button>Open</Button></DialogTrigger>
381
+ // CORRECT
382
+ <DialogTrigger render={<Button>Open</Button>} />
383
+ ```
384
+
385
+ 2. **CRITICAL: Importing from `@radix-ui/*`** — use `@base-ui/react` instead.
386
+
387
+ 3. **HIGH: Using `@phosphor-icons/react`** — use `@iconify/react` with `ph:` prefix.
388
+
389
+ ```tsx
390
+ // WRONG
391
+ import { CaretDown } from "@phosphor-icons/react";
392
+ // CORRECT
393
+ import { Icon } from "@iconify/react";
394
+ <Icon icon="ph:caret-down" width={16} height={16} />;
395
+ ```
396
+
397
+ 4. **HIGH: Using lucide-react icons** — use `@iconify/react` with Phosphor icon set.
398
+
399
+ 5. **MEDIUM: Custom `<button>` or `<div>` instead of shadcn components** — use `<Button>`, `<Card>`, etc.
400
+
401
+ 6. **MEDIUM: `console.error` for user errors** — use `toast.error()` from `sonner`.
402
+
403
+ ---
404
+
405
+ # QUESTPIE Admin Views
406
+
407
+ This skill builds on questpie-admin.
408
+
409
+ Views control how data appears in the QUESTPIE admin panel. They are configured **server-side** on collections and globals, then rendered by the admin client via registries.
410
+
411
+ ```text
412
+ Server Config Admin UI
413
+ .list(({ v }) => v.collectionTable({})) -> Table with columns, sort, search
414
+ .form(({ v, f }) => v.collectionForm({})) -> Form with sections, sidebar, tabs
415
+ sidebar({...}) -> Navigation sidebar
416
+ dashboard({...}) -> Dashboard with widgets
417
+ ```
418
+
419
+ ## List Views
420
+
421
+ Configure table views with `.list()` on a collection.
422
+
423
+ ### Basic Table
424
+
425
+ ```ts
426
+ .list(({ v }) => v.collectionTable({}))
427
+ ```
428
+
429
+ Shows all fields as columns with default rendering.
430
+
431
+ ### Custom Columns and Search
432
+
433
+ ```ts
434
+ .list(({ v, f }) =>
435
+ v.collectionTable({
436
+ columns: [f.name, f.email, f.isActive, f.createdAt],
437
+ searchableFields: [f.name, f.email],
438
+ defaultSort: { field: f.createdAt, direction: "desc" },
439
+ }),
440
+ )
441
+ ```
442
+
443
+ | Option | Type | Description |
444
+ | ------------------ | ---------------------- | ------------------------------ |
445
+ | `columns` | `Field[]` | Fields to show as columns |
446
+ | `searchableFields` | `Field[]` | Fields included in text search |
447
+ | `defaultSort` | `{ field, direction }` | Default sort order |
448
+
449
+ ## Form Views
450
+
451
+ Configure edit forms with `.form()`.
452
+
453
+ ### Basic Form
454
+
455
+ ```ts
456
+ .form(({ v, f }) => v.collectionForm({}))
457
+ ```
458
+
459
+ Renders all fields in a single column.
460
+
461
+ ### Sections
462
+
463
+ Group fields into labeled sections with optional grid layout:
464
+
465
+ ```ts
466
+ .form(({ v, f }) =>
467
+ v.collectionForm({
468
+ fields: [
469
+ {
470
+ type: "section",
471
+ label: { en: "Contact Information" },
472
+ layout: "grid",
473
+ columns: 2,
474
+ fields: [f.name, f.email, f.phone],
475
+ },
476
+ {
477
+ type: "section",
478
+ label: { en: "Profile" },
479
+ fields: [f.bio],
480
+ },
481
+ ],
482
+ }),
483
+ )
484
+ ```
485
+
486
+ | Option | Type | Description |
487
+ | ------------- | ------------------- | ------------------------------------ |
488
+ | `type` | `"section"` | Required |
489
+ | `label` | `string \| i18n` | Section heading |
490
+ | `description` | `string \| i18n` | Section description |
491
+ | `layout` | `"grid" \| "stack"` | Field layout |
492
+ | `columns` | `number` | Grid columns (with `layout: "grid"`) |
493
+ | `fields` | `Field[]` | Fields in this section |
494
+
495
+ ### Form Sidebar
496
+
497
+ Place fields in a right sidebar panel:
498
+
499
+ ```ts
500
+ .form(({ v, f }) =>
501
+ v.collectionForm({
502
+ sidebar: {
503
+ position: "right",
504
+ fields: [f.isActive, f.avatar, f.status],
505
+ },
506
+ fields: [ /* main content sections */ ],
507
+ }),
508
+ )
509
+ ```
510
+
511
+ ### Computed Fields
512
+
513
+ Auto-compute values from other fields:
514
+
515
+ ```ts
516
+ {
517
+ field: f.slug,
518
+ compute: {
519
+ handler: ({ data }) => {
520
+ if (data.name && !data.slug?.trim()) {
521
+ return slugify(data.name);
522
+ }
523
+ return undefined;
524
+ },
525
+ deps: ({ data }) => [data.name, data.slug],
526
+ debounce: 300,
527
+ },
528
+ }
529
+ ```
530
+
531
+ | Option | Type | Description |
532
+ | ---------- | ---------------- | ------------------------ |
533
+ | `handler` | `(ctx) => value` | Compute function |
534
+ | `deps` | `(ctx) => any[]` | Reactive dependencies |
535
+ | `debounce` | `number` | Debounce in milliseconds |
536
+
537
+ ### Conditional Visibility
538
+
539
+ Show or hide fields based on other field values:
540
+
541
+ ```ts
542
+ {
543
+ field: f.cancellationReason,
544
+ hidden: ({ data }) => data.status !== "cancelled",
545
+ }
546
+ ```
547
+
548
+ Read-only fields:
549
+
550
+ ```ts
551
+ {
552
+ field: f.customerName,
553
+ readOnly: ({ data }) => !!data.customer,
554
+ }
555
+ ```
556
+
557
+ Section-level visibility:
558
+
559
+ ```ts
560
+ {
561
+ type: "section",
562
+ label: { en: "SEO" },
563
+ hidden: ({ data }) => !data.isPublished,
564
+ fields: [f.metaTitle, f.metaDescription],
565
+ }
566
+ ```
567
+
568
+ ## Dashboard
569
+
570
+ Configure in `config/admin.ts` under the `dashboard` key:
571
+
572
+ ```ts title="config/admin.ts"
573
+ import { adminConfig } from "#questpie/factories";
574
+
575
+ export default adminConfig({
576
+ dashboard: {
577
+ title: { en: "Dashboard" },
578
+ description: { en: "Overview of your app" },
579
+ columns: 4,
580
+ actions: [
581
+ {
582
+ id: "new-post",
583
+ href: "/admin/collections/posts?create=true",
584
+ label: { en: "New Post" },
585
+ icon: { type: "icon", props: { name: "ph:plus" } },
586
+ variant: "primary",
587
+ },
588
+ ],
589
+ sections: [
590
+ { id: "today", label: { en: "Today" }, layout: "grid", columns: 4 },
591
+ { id: "business", label: { en: "Business" }, layout: "grid", columns: 4 },
592
+ ],
593
+ items: [
594
+ /* widget items — see widget types below */
595
+ ],
596
+ },
597
+ });
598
+ ```
599
+
600
+ ### Widget Types
601
+
602
+ **Stats** — count records with optional filter:
603
+
604
+ ```ts
605
+ {
606
+ sectionId: "today",
607
+ id: "pending",
608
+ type: "stats",
609
+ collection: "appointments",
610
+ label: { en: "Pending" },
611
+ filter: { status: "pending" },
612
+ span: 1,
613
+ }
614
+ ```
615
+
616
+ **Value** — custom-loaded value with trend:
617
+
618
+ ```ts
619
+ {
620
+ sectionId: "business",
621
+ id: "revenue",
622
+ type: "value",
623
+ span: 2,
624
+ refreshInterval: 1000 * 60 * 5,
625
+ loader: async ({ app }) => ({
626
+ value: 42000,
627
+ formatted: "42,000 EUR",
628
+ label: { en: "Monthly Revenue" },
629
+ trend: { value: "+12%" },
630
+ }),
631
+ }
632
+ ```
633
+
634
+ **Progress** — progress bar toward a goal:
635
+
636
+ ```ts
637
+ {
638
+ sectionId: "business",
639
+ id: "goal",
640
+ type: "progress",
641
+ span: 1,
642
+ showPercentage: true,
643
+ label: { en: "Monthly Goal" },
644
+ loader: async ({ app }) => ({ current: 350, target: 500 }),
645
+ }
646
+ ```
647
+
648
+ **Chart** — chart from field values:
649
+
650
+ ```ts
651
+ {
652
+ sectionId: "business",
653
+ id: "by-status",
654
+ type: "chart",
655
+ collection: "appointments",
656
+ field: "status",
657
+ chartType: "pie",
658
+ label: { en: "By Status" },
659
+ span: 1,
660
+ }
661
+ ```
662
+
663
+ **Recent Items** — list recent records:
664
+
665
+ ```ts
666
+ {
667
+ sectionId: "ops",
668
+ id: "recent",
669
+ type: "recentItems",
670
+ collection: "appointments",
671
+ label: { en: "Recent" },
672
+ limit: 6,
673
+ dateField: "scheduledAt",
674
+ span: 2,
675
+ }
676
+ ```
677
+
678
+ **Timeline** — activity stream:
679
+
680
+ ```ts
681
+ {
682
+ sectionId: "ops",
683
+ id: "activity",
684
+ type: "timeline",
685
+ label: { en: "Activity" },
686
+ maxItems: 8,
687
+ showTimestamps: true,
688
+ timestampFormat: "relative",
689
+ loader: async ({ app }) => {
690
+ const res = await app.collections.appointments.find({
691
+ limit: 8,
692
+ orderBy: { updatedAt: "desc" },
693
+ });
694
+ return res.docs.map((apt) => ({
695
+ id: apt.id,
696
+ title: apt.displayTitle,
697
+ description: `Status: ${apt.status}`,
698
+ timestamp: apt.updatedAt,
699
+ variant: apt.status === "completed" ? "success" : "warning",
700
+ href: `/admin/collections/appointments/${apt.id}`,
701
+ }));
702
+ },
703
+ span: 2,
704
+ }
705
+ ```
706
+
707
+ ## Sidebar
708
+
709
+ Configure in `config/admin.ts` under the `sidebar` key:
710
+
711
+ ```ts title="config/admin.ts"
712
+ import { adminConfig } from "#questpie/factories";
713
+
714
+ export default adminConfig({
715
+ sidebar: {
716
+ sections: [
717
+ { id: "overview", title: { en: "Overview" } },
718
+ { id: "content", title: { en: "Content" } },
719
+ { id: "external", title: { en: "External" } },
720
+ ],
721
+ items: [
722
+ {
723
+ sectionId: "overview",
724
+ type: "link",
725
+ label: { en: "Dashboard" },
726
+ href: "/admin",
727
+ icon: { type: "icon", props: { name: "ph:house" } },
728
+ },
729
+ { sectionId: "overview", type: "global", global: "siteSettings" },
730
+ { sectionId: "content", type: "collection", collection: "posts" },
731
+ {
732
+ sectionId: "external",
733
+ type: "link",
734
+ label: { en: "Open Website" },
735
+ href: "/",
736
+ external: true,
737
+ icon: { type: "icon", props: { name: "ph:arrow-square-out" } },
738
+ },
739
+ ],
740
+ },
741
+ });
742
+ ```
743
+
744
+ Items appear in definition order within their section.
745
+
746
+ Modules contribute sidebar items automatically. `adminModule` adds "Administration" with user management. `auditModule` adds an audit log item.
747
+
748
+ ## Filters & Saved Views
749
+
750
+ Filters are auto-generated from field definitions:
751
+
752
+ | Field Type | Filter Operators |
753
+ | ------------------- | ---------------------------------------- |
754
+ | `text` | Contains, equals, starts with, ends with |
755
+ | `number` | Equals, greater than, less than, between |
756
+ | `boolean` | Is true, is false |
757
+ | `select` | Is, is not, in |
758
+ | `date` / `datetime` | Before, after, between |
759
+ | `relation` | Is (picker) |
760
+
761
+ Users can save filter + sort + column combinations as named views.
762
+
763
+ ## Bulk Actions
764
+
765
+ List views support multi-select. Check rows, then use the floating toolbar. Built-in: **Delete** (with confirmation). Soft-delete collections soft-delete instead of permanent removal.
766
+
767
+ ## History & Versions
768
+
769
+ Click the clock icon in the form toolbar. Two tabs:
770
+
771
+ | Tab | Shows | Requires |
772
+ | ------------ | ---------------------------------------------- | ----------------------------- |
773
+ | **Activity** | Audit log (create, update, delete, transition) | `auditModule` |
774
+ | **Versions** | Full document snapshots with restore | `.versioning()` on collection |
775
+
776
+ Enable versioning:
777
+
778
+ ```ts
779
+ export const pages = collection("pages")
780
+ .fields(({ f }) => ({ ... }))
781
+ .options({ versioning: true });
782
+ ```
783
+
784
+ Disable audit for a specific collection:
785
+
786
+ ```ts
787
+ export const logs = collection("logs")
788
+ .admin(() => ({ audit: false }))
789
+ .fields(({ f }) => ({ ... }));
790
+ ```
791
+
792
+ ## Common Mistakes
793
+
794
+ 1. **HIGH: Defining columns that don't match field names** — `columns: [f.name]` requires a `name` field in the collection's `.fields()`. Mismatches cause empty columns.
795
+
796
+ 2. **MEDIUM: Not specifying `searchableFields`** — table search bar won't work unless you explicitly list which fields to search.
797
+
798
+ 3. **MEDIUM: Forgetting sidebar section ordering** — items appear in definition order. If you want "Dashboard" at the top, define it first in the `items` array.
799
+
800
+ 4. **MEDIUM: Missing `sectionId` on sidebar items** — every item must reference an existing section ID.
801
+
802
+ 5. **LOW: Not setting `defaultSort`** — records appear in database insertion order which is usually not what users expect.
803
+
804
+ ## Form Views and Live Preview
805
+
806
+ Form views connect to the Live Preview V2 system when the collection has `.preview()` configured. The form editor becomes the source of `postMessage` patches — every field change emits a patch through the bus, giving the preview iframe instant updates.
807
+
808
+ ### Enabling Preview on a Collection
809
+
810
+ Add `.preview()` to the collection definition:
811
+
812
+ ```ts
813
+ export const pages = collection("pages")
814
+ .fields(({ f }) => ({
815
+ title: f.text().required().localized(),
816
+ slug: f.text().required(),
817
+ content: f.blocks().localized(),
818
+ }))
819
+ .preview({
820
+ enabled: true,
821
+ position: "right",
822
+ defaultWidth: 50,
823
+ url: ({ record }) => `/${record.slug}?preview=true`,
824
+ });
825
+ ```
826
+
827
+ ### How It Works
828
+
829
+ 1. The form view detects `.preview()` config and opens a split-screen layout
830
+ 2. Save/autosave sends a `PREVIEW_REFRESH` message to the preview iframe
831
+ 3. The preview page handles refreshes through `useCollectionPreview({ initialData, onRefresh })`
832
+ 4. `PreviewProvider` and `PreviewField` wire field focus and click-to-focus messages
833
+
834
+ ---
835
+
836
+ # QUESTPIE Blocks
837
+
838
+ This skill builds on questpie-admin.
839
+
840
+ Blocks are reusable content components for page builders. Define them server-side with fields and admin metadata, then render them client-side with React components.
841
+
842
+ ```text
843
+ Server: block("hero") Client: HeroRenderer
844
+ .fields({ title, image }) -> Receives { values, data }
845
+ .admin({ label, icon }) Returns JSX
846
+ .prefetch({ with: {...} })
847
+ ```
848
+
849
+ ## Defining Blocks
850
+
851
+ Blocks are defined in `blocks/` using the `block()` factory:
852
+
853
+ ```ts title="blocks/hero.ts"
854
+ import { block } from "#questpie/factories";
855
+
856
+ export const heroBlock = block("hero")
857
+ .admin(({ c }) => ({
858
+ label: { en: "Hero Section", sk: "Hero sekcia" },
859
+ icon: c.icon("ph:image"),
860
+ category: "sections",
861
+ }))
862
+ .fields(({ f }) => ({
863
+ title: f.text().localized().required(),
864
+ subtitle: f.textarea().localized(),
865
+ backgroundImage: f.upload({ to: "assets" }),
866
+ overlayOpacity: f.number().default(60),
867
+ alignment: f
868
+ .select([
869
+ { value: "left", label: "Left" },
870
+ { value: "center", label: "Center" },
871
+ { value: "right", label: "Right" },
872
+ ])
873
+ .default("center"),
874
+ ctaText: f.text().localized(),
875
+ ctaLink: f.text(),
876
+ }))
877
+ .prefetch({ with: { backgroundImage: true } });
878
+ ```
879
+
880
+ ### Admin Metadata
881
+
882
+ ```ts
883
+ .admin(({ c }) => ({
884
+ label: { en: "Hero Section" }, // Display name in block picker
885
+ icon: c.icon("ph:image"), // Icon in block picker (Phosphor set)
886
+ category: "sections", // Group in block picker
887
+ }))
888
+ ```
889
+
890
+ ### Multiple Blocks Per File
891
+
892
+ Export multiple named blocks from one file:
893
+
894
+ ```ts title="blocks/layout.ts"
895
+ import { block } from "#questpie/factories";
896
+
897
+ export const twoColumnBlock = block("twoColumn")
898
+ .admin(({ c }) => ({
899
+ label: { en: "Two Columns" },
900
+ icon: c.icon("ph:columns"),
901
+ category: "layout",
902
+ }))
903
+ .fields(({ f }) => ({
904
+ left: f.blocks(),
905
+ right: f.blocks(),
906
+ }));
907
+
908
+ export const spacerBlock = block("spacer")
909
+ .admin(({ c }) => ({
910
+ label: { en: "Spacer" },
911
+ icon: c.icon("ph:arrows-out-line-vertical"),
912
+ category: "layout",
913
+ }))
914
+ .fields(({ f }) => ({
915
+ height: f
916
+ .select([
917
+ { value: "sm", label: "Small" },
918
+ { value: "md", label: "Medium" },
919
+ { value: "lg", label: "Large" },
920
+ { value: "xl", label: "Extra Large" },
921
+ ])
922
+ .default("md"),
923
+ }));
924
+ ```
925
+
926
+ ## Using Blocks in Collections
927
+
928
+ Add a `blocks` field to any collection:
929
+
930
+ ```ts title="collections/pages.ts"
931
+ import { collection } from "#questpie/factories";
932
+
933
+ export const pages = collection("pages").fields(({ f }) => ({
934
+ title: f.text().required().localized(),
935
+ slug: f.text().required(),
936
+ content: f.blocks().localized(),
937
+ }));
938
+ ```
939
+
940
+ The admin renders a visual block editor for this field.
941
+
942
+ ## Prefetch
943
+
944
+ Blocks often reference related data (images, linked records). Use `.prefetch()` to load them alongside block values.
945
+
946
+ ### Declarative Prefetch
947
+
948
+ ```ts
949
+ .prefetch({
950
+ with: {
951
+ backgroundImage: true, // Load the full image record
952
+ },
953
+ })
954
+ ```
955
+
956
+ ### Nested Prefetch
957
+
958
+ ```ts
959
+ .prefetch({
960
+ with: {
961
+ featuredBarber: {
962
+ with: {
963
+ avatar: true,
964
+ services: true,
965
+ },
966
+ },
967
+ },
968
+ })
969
+ ```
970
+
971
+ ### Functional Prefetch
972
+
973
+ For complex queries, use a function. The `ctx` parameter provides fully typed `collections` and `globals` via `AppContext` augmentation — no imports needed:
974
+
975
+ ```ts title="blocks/featured.ts"
976
+ import { block } from "#questpie/factories";
977
+
978
+ export const featuredBlock = block("featured")
979
+ .fields(({ f }) => ({
980
+ heading: f.text().required(),
981
+ }))
982
+ .prefetch(async ({ values, ctx }) => {
983
+ return {
984
+ posts: (await ctx.collections.posts.find({ limit: 5 })).docs,
985
+ };
986
+ });
987
+ ```
988
+
989
+ ### Using Prefetched Data in Renderers
990
+
991
+ ```tsx
992
+ function HeroRenderer({ values, data }: BlockProps<"hero">) {
993
+ // values.backgroundImage = "asset-id-123" (just the ID)
994
+ // data.backgroundImage = { url: "/api/assets/...", filename: "hero.jpg", ... }
995
+
996
+ return (
997
+ <section>
998
+ {data?.backgroundImage?.url && (
999
+ <img src={data.backgroundImage.url} alt="" />
1000
+ )}
1001
+ <h1>{values.title}</h1>
1002
+ </section>
1003
+ );
1004
+ }
1005
+ ```
1006
+
1007
+ ## Block Renderers
1008
+
1009
+ React components that receive block data and return JSX.
1010
+
1011
+ ### Defining a Renderer
1012
+
1013
+ ```tsx title="admin/blocks/hero.tsx"
1014
+ import type { BlockProps } from "../.generated/client";
1015
+
1016
+ export function HeroRenderer({ values, data }: BlockProps<"hero">) {
1017
+ return (
1018
+ <section
1019
+ className="relative flex items-center justify-center"
1020
+ style={{ minHeight: "60vh" }}
1021
+ >
1022
+ {data?.backgroundImage?.url && (
1023
+ <img
1024
+ src={data.backgroundImage.url}
1025
+ alt=""
1026
+ className="absolute inset-0 w-full h-full object-cover"
1027
+ />
1028
+ )}
1029
+ <div className="relative text-center">
1030
+ <h1 className="text-5xl font-bold">{values.title}</h1>
1031
+ {values.subtitle && <p className="text-xl mt-4">{values.subtitle}</p>}
1032
+ {values.ctaText && (
1033
+ <a href={values.ctaLink} className="mt-6 inline-block btn">
1034
+ {values.ctaText}
1035
+ </a>
1036
+ )}
1037
+ </div>
1038
+ </section>
1039
+ );
1040
+ }
1041
+ ```
1042
+
1043
+ ### BlockProps
1044
+
1045
+ | Property | Type | Description |
1046
+ | ---------- | ----------- | -------------------------------------------------- |
1047
+ | `values` | `object` | Block field values (title, subtitle, etc.) |
1048
+ | `data` | `object` | Prefetched relation data (images, related records) |
1049
+ | `children` | `ReactNode` | Nested block content |
1050
+
1051
+ ### Registering Renderers
1052
+
1053
+ ```tsx title="admin/blocks/index.tsx"
1054
+ import { HeroRenderer } from "./hero";
1055
+ import { GalleryRenderer } from "./gallery";
1056
+ import { CTARenderer } from "./cta";
1057
+
1058
+ export const renderers = {
1059
+ hero: HeroRenderer,
1060
+ gallery: GalleryRenderer,
1061
+ cta: CTARenderer,
1062
+ };
1063
+ ```
1064
+
1065
+ ### Frontend Rendering
1066
+
1067
+ Use block renderers on the public frontend:
1068
+
1069
+ ```tsx title="components/page-renderer.tsx"
1070
+ import { renderers } from "@/questpie/admin/blocks";
1071
+
1072
+ function PageRenderer({ page }) {
1073
+ return (
1074
+ <div>
1075
+ {page.content?.map((block, i) => {
1076
+ const Renderer = renderers[block.type];
1077
+ if (!Renderer) return null;
1078
+ return <Renderer key={i} values={block.values} data={block.data} />;
1079
+ })}
1080
+ </div>
1081
+ );
1082
+ }
1083
+ ```
1084
+
1085
+ ## Common Mistakes
1086
+
1087
+ 1. **HIGH: Not using `ctx.collections.*` in functional prefetch** — use the context-injected collections directly. Do NOT import `app` from `#questpie` inside block files (causes circular dependencies).
1088
+
1089
+ ```ts
1090
+ // WRONG — importing app creates circular dependency
1091
+ import { app } from "#questpie";
1092
+ .prefetch(async ({ values, ctx }) => {
1093
+ const posts = await app.collections.posts.find({});
1094
+ })
1095
+
1096
+ // CORRECT — use ctx.collections directly
1097
+ .prefetch(async ({ values, ctx }) => {
1098
+ const posts = await ctx.collections.posts.find({});
1099
+ })
1100
+ ```
1101
+
1102
+ 2. **HIGH: Importing from `.generated/` inside block files** — block files are imported BY `.generated/index.ts`, so importing from it back creates circular dependencies. Use the `ctx` parameter instead.
1103
+
1104
+ 3. **MEDIUM: Block renderer not exported as default or named export** — codegen discovers named exports from block renderer files. Ensure the component is exported.
1105
+
1106
+ 4. **MEDIUM: Using `{ with: { field: true } }` prefetch for complex queries** — declarative prefetch only loads related records by ID. For filtered/sorted/limited queries, use functional prefetch instead.
1107
+
1108
+ 5. **MEDIUM: Forgetting `.prefetch()` for upload fields** — without prefetch, `values.backgroundImage` is just an ID string. Add `{ with: { backgroundImage: true } }` to get the full asset record with `url`.
1109
+
1110
+ 6. **LOW: Missing `category` in `.admin()`** — blocks without a category won't be grouped in the block picker, making it harder to find them.
1111
+
1112
+ ## Blocks in Live Preview
1113
+
1114
+ When a collection has `.preview()` configured, blocks can participate in preview focus by combining `BlockScopeProvider` with `PreviewField`.
1115
+
1116
+ ### BlockScopeProvider Wrapper
1117
+
1118
+ Use `BlockScopeProvider` in your frontend to scope field paths inside a block:
1119
+
1120
+ ```tsx
1121
+ import { BlockScopeProvider } from "@questpie/admin/client";
1122
+
1123
+ function PageRenderer({ blocks, previewData }) {
1124
+ return blocks.map((block) => {
1125
+ const Renderer = renderers[block.type];
1126
+ return (
1127
+ <BlockScopeProvider key={block.id} blockId={block.id}>
1128
+ <Renderer values={block.values} data={block.data} />
1129
+ </BlockScopeProvider>
1130
+ );
1131
+ });
1132
+ }
1133
+ ```
1134
+
1135
+ `PreviewField` components inside the provider resolve paths like `content._values.{blockId}.title`.
1136
+
1137
+ Blocks with declarative prefetch (`{ with: { image: true } }`) resolve relations during reconcile — the preview shows the image URL immediately after the server round-trip completes, not just the asset ID.
1138
+
1139
+ ---
1140
+
1141
+ # QUESTPIE Custom UI
1142
+
1143
+ This skill builds on questpie-admin.
1144
+
1145
+ Extend the QUESTPIE admin with custom field types, custom view types, custom components, and reactive field behaviors.
1146
+
1147
+ ## Registries
1148
+
1149
+ Registries connect server-side schema to client-side rendering. When the admin encounters a field type, it looks up the renderer in the field registry.
1150
+
1151
+ ```text
1152
+ Server: f.text().required()
1153
+ |
1154
+ Generated: { type: "text", options: {...} }
1155
+ |
1156
+ Admin Client: fieldRegistry.get("text")
1157
+ |
1158
+ React: <TextFieldRenderer value={...} onChange={...} />
1159
+ ```
1160
+
1161
+ ### Built-in Field Registry
1162
+
1163
+ ```
1164
+ text -> TextInput
1165
+ textarea -> TextareaInput
1166
+ richText -> RichTextEditor (TipTap)
1167
+ number -> NumberInput
1168
+ boolean -> Checkbox / Switch
1169
+ date -> DatePicker
1170
+ datetime -> DateTimePicker
1171
+ select -> SelectDropdown
1172
+ relation -> RelationPicker
1173
+ upload -> FileUpload
1174
+ object -> NestedForm
1175
+ array -> RepeatableItems
1176
+ blocks -> BlockEditor
1177
+ json -> JSONEditor
1178
+ ```
1179
+
1180
+ ### Extending Registries
1181
+
1182
+ Place files in the admin directory. Codegen discovers them automatically:
1183
+
1184
+ ```
1185
+ questpie/admin/
1186
+ fields/
1187
+ color.tsx # Custom color field renderer
1188
+ currency.tsx # Custom currency field renderer
1189
+ views/
1190
+ kanban.tsx # Custom kanban list view
1191
+ ```
1192
+
1193
+ These are merged with built-in defaults during codegen and exported in `.generated/client.ts`.
1194
+
1195
+ ## Custom Fields
1196
+
1197
+ ### Server-Side Registration
1198
+
1199
+ Register custom fields through modules:
1200
+
1201
+ ```ts
1202
+ const myModule = module({
1203
+ name: "custom-fields",
1204
+ fields: {
1205
+ color: colorField,
1206
+ currency: currencyField,
1207
+ phone: phoneField,
1208
+ },
1209
+ });
1210
+ ```
1211
+
1212
+ Once registered and codegen runs, the field becomes available on the `f` builder:
1213
+
1214
+ ```ts
1215
+ .fields(({ f }) => ({
1216
+ brandColor: f.color().default("#000000"),
1217
+ price: f.currency({ currency: "USD" }),
1218
+ }))
1219
+ ```
1220
+
1221
+ ### Admin Field Renderer
1222
+
1223
+ Create a React component for the field's edit form:
1224
+
1225
+ ```tsx title="admin/fields/color.tsx"
1226
+ import { Icon } from "@iconify/react";
1227
+
1228
+ function ColorFieldRenderer({ value, onChange }) {
1229
+ return (
1230
+ <div className="flex items-center gap-2">
1231
+ <input
1232
+ type="color"
1233
+ value={value || "#000000"}
1234
+ onChange={(e) => onChange(e.target.value)}
1235
+ className="w-10 h-10 border border-border cursor-pointer"
1236
+ />
1237
+ <span className="font-mono text-sm text-muted-foreground">
1238
+ {value || "#000000"}
1239
+ </span>
1240
+ </div>
1241
+ );
1242
+ }
1243
+ ```
1244
+
1245
+ ### Cell Renderer
1246
+
1247
+ For custom table column rendering, provide a `cell` component alongside the field renderer:
1248
+
1249
+ ```tsx title="admin/fields/color.tsx"
1250
+ // Cell component for list view table
1251
+ export function ColorCell({ value }) {
1252
+ return (
1253
+ <div className="flex items-center gap-2">
1254
+ <div
1255
+ className="w-4 h-4 border border-border"
1256
+ style={{ backgroundColor: value || "transparent" }}
1257
+ />
1258
+ <span className="text-xs font-mono">{value}</span>
1259
+ </div>
1260
+ );
1261
+ }
1262
+ ```
1263
+
1264
+ ## Custom Views
1265
+
1266
+ Create view types beyond built-in table and form — kanban boards, calendars, galleries.
1267
+
1268
+ ### Server-Side Declaration
1269
+
1270
+ ```ts
1271
+ const myModule = module({
1272
+ name: "custom-views",
1273
+ views: {
1274
+ kanban: kanbanViewDefinition,
1275
+ calendar: calendarViewDefinition,
1276
+ },
1277
+ });
1278
+ ```
1279
+
1280
+ ### Usage in Collections
1281
+
1282
+ ```ts
1283
+ .list(({ v }) => v.kanban({
1284
+ columns: "status",
1285
+ cardTitle: "title",
1286
+ }))
1287
+ ```
1288
+
1289
+ ### Client Rendering
1290
+
1291
+ ```tsx title="admin/views/kanban.tsx"
1292
+ function KanbanView({ data, columns, onDrop }) {
1293
+ return (
1294
+ <div className="flex gap-4">
1295
+ {columns.map((col) => (
1296
+ <div key={col.id} className="flex-1">
1297
+ <h3 className="font-mono text-sm font-semibold mb-2">{col.label}</h3>
1298
+ {data
1299
+ .filter((item) => item.status === col.id)
1300
+ .map((item) => (
1301
+ <div
1302
+ key={item.id}
1303
+ className="border border-border bg-card p-3 mb-2"
1304
+ >
1305
+ {item.title}
1306
+ </div>
1307
+ ))}
1308
+ </div>
1309
+ ))}
1310
+ </div>
1311
+ );
1312
+ }
1313
+ ```
1314
+
1315
+ ## Reactive Field System
1316
+
1317
+ Fields support reactive behaviors configured in the collection's `.form()` view or on the field definition itself.
1318
+
1319
+ ### Conditional Visibility
1320
+
1321
+ ```ts
1322
+ {
1323
+ field: f.cancellationReason,
1324
+ hidden: ({ data }) => data.status !== "cancelled",
1325
+ }
1326
+ ```
1327
+
1328
+ ### Read-Only
1329
+
1330
+ ```ts
1331
+ {
1332
+ field: f.customerName,
1333
+ readOnly: ({ data }) => !!data.customer,
1334
+ }
1335
+ ```
1336
+
1337
+ ### Computed Values
1338
+
1339
+ ```ts
1340
+ {
1341
+ field: f.slug,
1342
+ compute: {
1343
+ handler: ({ data }) => {
1344
+ if (data.name && !data.slug?.trim()) {
1345
+ return slugify(data.name);
1346
+ }
1347
+ return undefined;
1348
+ },
1349
+ deps: ({ data }) => [data.name, data.slug],
1350
+ debounce: 300,
1351
+ },
1352
+ }
1353
+ ```
1354
+
1355
+ ### Dynamic Options (Server-Side)
1356
+
1357
+ For select/relation fields with options that depend on other field values:
1358
+
1359
+ ```ts
1360
+ city: f.relation("cities").admin({
1361
+ options: {
1362
+ handler: async ({ data, search, ctx }) => {
1363
+ const cities = await ctx.db.query.cities.findMany({
1364
+ where: { countryId: data.country },
1365
+ });
1366
+ return {
1367
+ options: cities.map((c) => ({ value: c.id, label: c.name })),
1368
+ };
1369
+ },
1370
+ deps: ({ data }) => [data.country],
1371
+ },
1372
+ }),
1373
+ ```
1374
+
1375
+ The `handler` runs **server-side** with full access to `ctx.db`, `ctx.user`, `ctx.req`. It re-executes when any value in `deps` changes.
1376
+
1377
+ ## UI Component Reference
1378
+
1379
+ When building custom admin UI, use these patterns:
1380
+
1381
+ ### Icons
1382
+
1383
+ ```tsx
1384
+ import { Icon } from "@iconify/react";
1385
+
1386
+ // Phosphor icon set with ph: prefix
1387
+ <Icon icon="ph:house" width={20} height={20} />
1388
+ <Icon icon="ph:caret-down-bold" width={16} height={16} /> // bold weight
1389
+ <Icon icon="ph:heart-fill" width={16} height={16} /> // fill weight
1390
+ ```
1391
+
1392
+ ### Toasts
1393
+
1394
+ ```tsx
1395
+ import { toast } from "sonner";
1396
+
1397
+ toast.success("Record saved");
1398
+ toast.error("Failed to save");
1399
+ ```
1400
+
1401
+ ### Primitives (base-ui)
1402
+
1403
+ ```tsx
1404
+ // CORRECT — render prop
1405
+ <DialogTrigger render={<Button>Open</Button>} />
1406
+
1407
+ // WRONG — asChild is Radix, not base-ui
1408
+ <DialogTrigger asChild><Button>Open</Button></DialogTrigger>
1409
+ ```
1410
+
1411
+ ### Responsive Components
1412
+
1413
+ - `ResponsivePopover` — Popover on desktop, Drawer on mobile
1414
+ - `ResponsiveDialog` — Dialog on desktop, fullscreen Drawer on mobile
1415
+ - Hooks: `useIsMobile()`, `useIsDesktop()`, `useMediaQuery()`
1416
+
1417
+ ## Common Mistakes
1418
+
1419
+ 1. **HIGH: Not registering custom field in the field registry** — if codegen doesn't discover the field renderer file, the admin will render nothing for that field type. Place it in `questpie/admin/fields/<name>.tsx`.
1420
+
1421
+ 2. **HIGH: Missing `cell` component for custom fields** — without a cell component, the list view table shows raw values for your custom field instead of a formatted display.
1422
+
1423
+ 3. **MEDIUM: Reactive field handlers running client-side** — `options.handler`, `compute.handler`, and other reactive handlers run **SERVER-SIDE** with access to `ctx.db`, `ctx.user`. Do not import client-side modules or use browser APIs in them.
1424
+
1425
+ 4. **MEDIUM: Using `onChange` wrong in field components** — the field renderer receives `onChange` that expects the **value directly**, not a DOM event.
1426
+
1427
+ ```tsx
1428
+ // WRONG
1429
+ onChange={(e) => onChange(e)}
1430
+ // CORRECT
1431
+ onChange={(e) => onChange(e.target.value)}
1432
+ // Or for non-DOM values:
1433
+ onChange={newValue}
1434
+ ```
1435
+
1436
+ 5. **MEDIUM: Importing from `@radix-ui/*`** — QUESTPIE admin uses `@base-ui/react`. Never import Radix primitives.
1437
+
1438
+ 6. **MEDIUM: Using `@phosphor-icons/react` or `lucide-react`** — use `@iconify/react` with `ph:` prefix for all icons.
1439
+
1440
+ 7. **LOW: Not using shadcn components** — prefer `<Button>`, `<Card>`, `<Input>` from the shadcn component library instead of raw HTML elements. The admin has a consistent brutalist design system.
1441
+
1442
+ ---