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.
- package/README.md +10 -6
- package/dist/index.mjs +140 -25
- package/package.json +5 -3
- package/skills/questpie/AGENTS.md +2664 -0
- package/skills/questpie/SKILL.md +181 -0
- package/skills/questpie/references/auth.md +121 -0
- package/skills/questpie/references/business-logic.md +550 -0
- package/skills/questpie/references/codegen-plugin-api.md +382 -0
- package/skills/questpie/references/crud-api.md +378 -0
- package/skills/questpie/references/data-modeling.md +489 -0
- package/skills/questpie/references/extend.md +493 -0
- package/skills/questpie/references/field-types.md +386 -0
- package/skills/questpie/references/infrastructure-adapters.md +545 -0
- package/skills/questpie/references/multi-tenancy.md +364 -0
- package/skills/questpie/references/production.md +475 -0
- package/skills/questpie/references/query-operators.md +125 -0
- package/skills/questpie/references/quickstart.md +549 -0
- package/skills/questpie/references/rules.md +327 -0
- package/skills/questpie/references/tanstack-query.md +520 -0
- package/skills/questpie-admin/AGENTS.md +1442 -0
- package/skills/questpie-admin/SKILL.md +410 -0
- package/skills/questpie-admin/references/blocks.md +307 -0
- package/skills/questpie-admin/references/custom-ui.md +305 -0
- package/skills/questpie-admin/references/views.md +433 -0
- package/templates/tanstack-start/AGENTS.md +71 -62
- package/templates/tanstack-start/CLAUDE.md +26 -23
- package/templates/tanstack-start/README.md +32 -20
- package/templates/tanstack-start/env.example +1 -1
- package/templates/tanstack-start/package.json +20 -6
- package/templates/tanstack-start/src/lib/client.ts +2 -2
- package/templates/tanstack-start/src/lib/env.ts +1 -1
- package/templates/tanstack-start/src/questpie/admin/.generated/client.ts +13 -0
- package/templates/tanstack-start/src/questpie/admin/modules.ts +1 -0
- package/templates/tanstack-start/src/questpie/server/.generated/factories.ts +117 -241
- package/templates/tanstack-start/src/questpie/server/.generated/index.ts +129 -81
- package/templates/tanstack-start/src/questpie/server/app.ts +1 -1
- package/templates/tanstack-start/src/questpie/server/config/admin.ts +27 -30
- package/templates/tanstack-start/src/questpie/server/globals/site-settings.global.ts +1 -1
- package/templates/tanstack-start/src/questpie/server/questpie.config.ts +1 -1
- package/templates/tanstack-start/src/routeTree.gen.ts +138 -0
- package/templates/tanstack-start/src/routes/__root.tsx +0 -2
- package/templates/tanstack-start/src/routes/admin.tsx +8 -1
- package/templates/tanstack-start/src/tanstack-start.d.ts +1 -0
- package/templates/tanstack-start/src/vite-env.d.ts +1 -0
- 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
|
+
---
|