opacacms 0.3.12 → 0.3.14
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/LICENSE +21 -0
- package/README.md +196 -173
- package/dist/admin/ui/components/ui/button.d.ts +1 -1
- package/dist/{chunk-8hhzvesq.js → chunk-5b9eqr34.js} +3 -2
- package/dist/{chunk-1vtdkx5e.js → chunk-dz5bh1bd.js} +7 -2
- package/dist/{chunk-2fm4kv2q.js → chunk-nv91gc63.js} +32 -14
- package/dist/{chunk-xdmw9vzq.js → chunk-nz6xhtja.js} +7 -2
- package/dist/{chunk-0njhbe4a.js → chunk-qsefknd3.js} +7 -2
- package/dist/{chunk-2ec9fsgr.js → chunk-tsmhn78f.js} +6 -2
- package/dist/client.d.ts +88 -10
- package/dist/client.js +1 -1
- package/dist/config.d.ts +8 -1
- package/dist/db/better-sqlite.js +1 -1
- package/dist/db/bun-sqlite.js +1 -1
- package/dist/db/d1.js +1 -1
- package/dist/db/index.js +5 -5
- package/dist/db/postgres.js +1 -1
- package/dist/db/sqlite.js +1 -1
- package/dist/index.js +1 -1
- package/dist/schema/collection.d.ts +5 -5
- package/dist/schema/global.d.ts +5 -5
- package/dist/types/access.d.ts +30 -0
- package/dist/types.d.ts +2 -24
- package/dist/validation.d.ts +7 -7
- package/package.json +3 -2
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Francy Santos (fhorray)
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -123,31 +123,32 @@ A **Collection** is a database table + a REST API. Pure magic. ✨
|
|
|
123
123
|
|
|
124
124
|
```typescript
|
|
125
125
|
// collections/posts.ts
|
|
126
|
-
import {
|
|
127
|
-
|
|
128
|
-
export const posts =
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
.
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
126
|
+
import { defineCollection, z } from 'opacacms';
|
|
127
|
+
|
|
128
|
+
export const posts = defineCollection({
|
|
129
|
+
slug: 'posts',
|
|
130
|
+
label: 'Blog Posts',
|
|
131
|
+
admin: {
|
|
132
|
+
icon: 'FileText',
|
|
133
|
+
useAsTitle: 'title',
|
|
134
|
+
},
|
|
135
|
+
schema: z.object({
|
|
136
|
+
title: z.text({ label: 'Post Title' }).required(),
|
|
137
|
+
slug: z.slug({ from: 'title' }).unique(),
|
|
138
|
+
content: z.richText({ label: 'Content' }).localized(),
|
|
139
|
+
author: z.relationship('_users', { label: 'Author' }),
|
|
140
|
+
status: z
|
|
141
|
+
.select(['draft', 'published'], { label: 'Status' })
|
|
142
|
+
.default('draft'),
|
|
143
|
+
featured: z.boolean({ label: 'Featured Post' }),
|
|
144
|
+
}),
|
|
145
|
+
access: {
|
|
145
146
|
read: () => true,
|
|
146
147
|
create: ({ user }) => !!user,
|
|
147
148
|
update: ({ user }) => user?.role === 'admin',
|
|
148
149
|
delete: ({ user }) => user?.role === 'admin',
|
|
149
|
-
}
|
|
150
|
-
|
|
150
|
+
},
|
|
151
|
+
hooks: {
|
|
151
152
|
beforeCreate: async (data) => {
|
|
152
153
|
// Mutate data before insertion
|
|
153
154
|
return { ...data, publishedAt: new Date().toISOString() };
|
|
@@ -155,104 +156,90 @@ export const posts = Collection.create('posts')
|
|
|
155
156
|
afterCreate: async (doc) => {
|
|
156
157
|
console.log('New post created:', doc.id);
|
|
157
158
|
},
|
|
158
|
-
}
|
|
159
|
+
},
|
|
160
|
+
});
|
|
159
161
|
```
|
|
160
162
|
|
|
161
|
-
### Collection
|
|
163
|
+
### Collection Configuration
|
|
162
164
|
|
|
163
|
-
|
|
|
164
|
-
|
|
|
165
|
-
|
|
|
166
|
-
|
|
|
167
|
-
|
|
|
168
|
-
|
|
|
169
|
-
|
|
|
170
|
-
|
|
|
171
|
-
|
|
|
172
|
-
|
|
|
173
|
-
|
|
|
165
|
+
| Property | Description |
|
|
166
|
+
| ------------ | --------------------------------------------------------- |
|
|
167
|
+
| `slug` | The unique identifier for the collection |
|
|
168
|
+
| `label` | Display name used in the Admin UI |
|
|
169
|
+
| `admin` | UI configuration (`icon`, `useAsTitle`, `defaultColumns`) |
|
|
170
|
+
| `schema` | A `z.object` defining the data structure |
|
|
171
|
+
| `access` | Collection-level access control |
|
|
172
|
+
| `hooks` | Lifecycle hooks (`beforeCreate`, `afterUpdate`, etc.) |
|
|
173
|
+
| `webhooks` | External webhook notifications |
|
|
174
|
+
| `versions` | Enable document versioning with history |
|
|
175
|
+
| `timestamps` | Customize or disable timestamp fields |
|
|
174
176
|
|
|
175
177
|
---
|
|
176
178
|
|
|
177
179
|
## 🧪 Field Types
|
|
178
180
|
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
|
182
|
-
|
|
|
183
|
-
| `
|
|
184
|
-
| `
|
|
185
|
-
| `
|
|
186
|
-
| `
|
|
187
|
-
| `
|
|
188
|
-
| `
|
|
189
|
-
| `
|
|
190
|
-
| `
|
|
191
|
-
| `
|
|
192
|
-
| `
|
|
193
|
-
| `
|
|
194
|
-
| `
|
|
195
|
-
| `
|
|
196
|
-
| `
|
|
181
|
+
| Field | Usage | Description |
|
|
182
|
+
| ------------------ | ------------------------------------------------------- | --------------------------------------------- |
|
|
183
|
+
| `z.text()` | `z.text({ label: 'Title' })` | Simple string input |
|
|
184
|
+
| `z.textarea()` | `z.textarea({ label: 'Bio' })` | Multi-line text area |
|
|
185
|
+
| `z.number()` | `z.number({ label: 'Price' })` | Numeric input |
|
|
186
|
+
| `z.richText()` | `z.richText({ label: 'Content' })` | Block-based Lexical editor (Notion style!) 📝 |
|
|
187
|
+
| `z.relationship()` | `z.relationship('_users', { label: 'Author' })` | Links to another collection |
|
|
188
|
+
| `z.file()` | `z.file({ label: 'Image' })` | File/image upload ☁️ |
|
|
189
|
+
| `z.blocks()` | `z.blocks([hero, gallery], { label: 'Layout' })` | Dynamic page builder 🧱 |
|
|
190
|
+
| `z.group()` | `z.group({ meta1: z.text(), meta2: z.text() })` | Nested object group |
|
|
191
|
+
| `z.array()` | `z.array(z.object({ tag: z.text() }))` | Repeatable field array |
|
|
192
|
+
| `z.select()` | `z.select(['draft', 'published'], { label: 'Status' })` | Dropdown picker |
|
|
193
|
+
| `z.boolean()` | `z.boolean({ label: 'Active' })` | Boolean toggle |
|
|
194
|
+
| `z.slug()` | `z.slug({ from: 'title', label: 'Slug' })` | Auto-generated URL slug |
|
|
195
|
+
| `z.date()` | `z.date({ label: 'Published At' })` | Date/time picker |
|
|
196
|
+
| `z.tabs()` | `z.tabs({ SEO: z.object({...}) })` | UI-only grouping for the admin |
|
|
197
|
+
| `z.join()` | `z.join('posts', { foreignKey: 'author' })` | Virtual reversed relationship (read-only) |
|
|
198
|
+
| `z.password()` | `z.password({ label: 'Password' })` | Secure, hashed input |
|
|
197
199
|
|
|
198
200
|
### Common Field Methods
|
|
199
201
|
|
|
200
202
|
Every field type inherits these chainable methods from the base builder:
|
|
201
203
|
|
|
202
204
|
```typescript
|
|
203
|
-
|
|
204
|
-
.
|
|
205
|
-
.placeholder('you@example.com') // Input placeholder
|
|
206
|
-
.required() // Mark as required
|
|
205
|
+
z.text({ label: 'Email Address', description: 'Primary email' })
|
|
206
|
+
.required() // Native Zod or custom required condition
|
|
207
207
|
.unique() // Unique constraint in the DB
|
|
208
208
|
.localized() // Enable per-locale values (i18n)
|
|
209
|
-
.
|
|
210
|
-
.
|
|
211
|
-
.
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
209
|
+
.default('hello@world.com') // Native Zod default value
|
|
210
|
+
.email('Invalid email address') // Native Zod validation
|
|
211
|
+
.cms({
|
|
212
|
+
admin: {
|
|
213
|
+
readOnly: true,
|
|
214
|
+
hidden: false,
|
|
215
|
+
components: { Field: 'my-custom-field' },
|
|
216
|
+
},
|
|
217
|
+
}); // Advanced CMS and Admin UI options
|
|
216
218
|
```
|
|
217
219
|
|
|
218
220
|
---
|
|
219
221
|
|
|
220
222
|
## ✅ Validation
|
|
221
223
|
|
|
222
|
-
OpacaCMS supports
|
|
223
|
-
|
|
224
|
-
### Custom Function Validation
|
|
225
|
-
|
|
226
|
-
Return `true` to pass, or a `string` error message to fail:
|
|
227
|
-
|
|
228
|
-
```typescript
|
|
229
|
-
Field.text('username').validate((value) => {
|
|
230
|
-
if (value === 'admin') return "Username 'admin' is reserved";
|
|
231
|
-
return true;
|
|
232
|
-
});
|
|
233
|
-
```
|
|
224
|
+
OpacaCMS natively supports validation since the entire schema is built using Zod. You can chain standard Zod validation methods directly to your fields.
|
|
234
225
|
|
|
235
226
|
### Zod Schema Validation
|
|
236
227
|
|
|
237
|
-
|
|
228
|
+
Errors are automatically mapped and handled by the API and Admin UI:
|
|
238
229
|
|
|
239
230
|
```typescript
|
|
240
231
|
import { z } from 'opacacms';
|
|
241
232
|
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
);
|
|
233
|
+
// Regular expressions
|
|
234
|
+
z.text().regex(/^\d{3}\.\d{3}\.\d{3}-\d{2}$/, 'Invalid CPF format');
|
|
245
235
|
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
.regex(/[A-Z]/, 'Must contain at least one uppercase letter'),
|
|
251
|
-
);
|
|
236
|
+
// String constraints
|
|
237
|
+
z.text()
|
|
238
|
+
.min(8, 'Password must be at least 8 characters')
|
|
239
|
+
.regex(/[A-Z]/, 'Must contain at least one uppercase letter');
|
|
252
240
|
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
.validate(z.string().email('Invalid email address'));
|
|
241
|
+
// Built-in format validators
|
|
242
|
+
z.text().required().email('Invalid email address');
|
|
256
243
|
```
|
|
257
244
|
|
|
258
245
|
> **💡 Tip:** Zod validation integrates seamlessly with `.required()`. The built-in requirement check runs first, then your Zod schema validates the value shape.
|
|
@@ -264,17 +251,24 @@ Field.text('email')
|
|
|
264
251
|
Globals are **singleton documents** — perfect for site settings, navigation, footers, and other one-of-a-kind configs.
|
|
265
252
|
|
|
266
253
|
```typescript
|
|
267
|
-
import {
|
|
254
|
+
import { defineGlobal, z } from 'opacacms';
|
|
268
255
|
|
|
269
|
-
export const siteSettings =
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
256
|
+
export const siteSettings = defineGlobal({
|
|
257
|
+
slug: 'site-settings',
|
|
258
|
+
label: 'Site Settings',
|
|
259
|
+
admin: {
|
|
260
|
+
icon: 'Settings',
|
|
261
|
+
},
|
|
262
|
+
schema: z.object({
|
|
263
|
+
siteName: z.text().required().default('My Shiny App'),
|
|
264
|
+
tagline: z.text().localized(),
|
|
265
|
+
logo: z.file(),
|
|
266
|
+
social: z.group({
|
|
267
|
+
twitter: z.text(),
|
|
268
|
+
github: z.text(),
|
|
269
|
+
}),
|
|
270
|
+
}),
|
|
271
|
+
});
|
|
278
272
|
```
|
|
279
273
|
|
|
280
274
|
API: `GET /api/globals/site-settings` and `PUT /api/globals/site-settings`
|
|
@@ -288,25 +282,31 @@ Secure your data with simple functions at both **collection** and **field** leve
|
|
|
288
282
|
### Collection-Level Access
|
|
289
283
|
|
|
290
284
|
```typescript
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
})
|
|
285
|
+
defineCollection({
|
|
286
|
+
// ...
|
|
287
|
+
access: {
|
|
288
|
+
read: ({ user }) => !!user, // Logged in? You're good.
|
|
289
|
+
create: ({ user }) => user?.role === 'admin', // Only admins please!
|
|
290
|
+
update: ({ data, user }) => data.ownerId === user.id, // Only your own stuff.
|
|
291
|
+
delete: ({ user }) => user?.role === 'admin',
|
|
292
|
+
requireApiKey: true, // Require API key for programmatic access
|
|
293
|
+
},
|
|
294
|
+
});
|
|
298
295
|
```
|
|
299
296
|
|
|
300
297
|
### Field-Level Access
|
|
301
298
|
|
|
302
|
-
Control visibility and editability per-field:
|
|
299
|
+
Control visibility and editability per-field using Zod conditional methods and CMS metadata:
|
|
303
300
|
|
|
304
301
|
```typescript
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
302
|
+
z.text({ label: 'Internal Notes' })
|
|
303
|
+
.condition((data) => data.role === 'admin') // Only show if another field matches
|
|
304
|
+
.cms({
|
|
305
|
+
admin: {
|
|
306
|
+
hidden: true, // Hide from Admin UI
|
|
307
|
+
readOnly: false,
|
|
308
|
+
},
|
|
309
|
+
});
|
|
310
310
|
```
|
|
311
311
|
|
|
312
312
|
### Role-Based Access Control (RBAC)
|
|
@@ -334,30 +334,33 @@ access: {
|
|
|
334
334
|
Hooks let you run side-effects at key points in the document lifecycle. They receive the document data and can mutate it before persistence.
|
|
335
335
|
|
|
336
336
|
```typescript
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
337
|
+
defineCollection({
|
|
338
|
+
// ...
|
|
339
|
+
hooks: {
|
|
340
|
+
beforeCreate: async (data) => {
|
|
341
|
+
// Transform or enrich data before saving
|
|
342
|
+
data.slug = data.title.toLowerCase().replace(/\s+/g, '-');
|
|
343
|
+
return data;
|
|
344
|
+
},
|
|
345
|
+
afterCreate: async (doc) => {
|
|
346
|
+
// Side-effects after the document is saved
|
|
347
|
+
await sendWelcomeEmail(doc.email);
|
|
348
|
+
},
|
|
349
|
+
beforeUpdate: async (data) => {
|
|
350
|
+
data.updatedBy = 'system';
|
|
351
|
+
return data;
|
|
352
|
+
},
|
|
353
|
+
afterUpdate: async (doc) => {
|
|
354
|
+
await invalidateCache(`/posts/${doc.slug}`);
|
|
355
|
+
},
|
|
356
|
+
beforeDelete: async (id) => {
|
|
357
|
+
await archiveDocument(id);
|
|
358
|
+
},
|
|
359
|
+
afterDelete: async (id) => {
|
|
360
|
+
console.log(`Document ${id} deleted`);
|
|
361
|
+
},
|
|
359
362
|
},
|
|
360
|
-
})
|
|
363
|
+
});
|
|
361
364
|
```
|
|
362
365
|
|
|
363
366
|
---
|
|
@@ -367,24 +370,25 @@ Hooks let you run side-effects at key points in the document lifecycle. They rec
|
|
|
367
370
|
Send HTTP notifications to external services when documents change. Webhooks run in the background using `waitUntil` on supported runtimes (Cloudflare Workers, Vercel Edge).
|
|
368
371
|
|
|
369
372
|
```typescript
|
|
370
|
-
import { defineCollection } from 'opacacms';
|
|
371
|
-
|
|
372
373
|
defineCollection({
|
|
373
374
|
slug: 'orders',
|
|
374
375
|
webhooks: [
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
]
|
|
376
|
+
{
|
|
377
|
+
type: 'outgoing',
|
|
378
|
+
url: 'https://hooks.slack.com/services/xxx',
|
|
379
|
+
events: ['after:create', 'after:update'],
|
|
380
|
+
headers: { Authorization: 'Bearer my-token' },
|
|
381
|
+
},
|
|
382
|
+
{
|
|
383
|
+
type: 'outgoing',
|
|
384
|
+
url: 'https://api.example.com/webhooks/orders',
|
|
385
|
+
events: ['after:delete'],
|
|
386
|
+
},
|
|
387
|
+
],
|
|
388
|
+
});
|
|
385
389
|
```
|
|
386
390
|
|
|
387
|
-
Supported events: `
|
|
391
|
+
Supported events: `after:create`, `after:update`, `after:delete`. OpacaCMS also supports `incoming` webhooks.
|
|
388
392
|
|
|
389
393
|
---
|
|
390
394
|
|
|
@@ -393,9 +397,11 @@ Supported events: `afterCreate`, `afterUpdate`, `afterDelete`.
|
|
|
393
397
|
Enable document versioning to keep a full history of changes. Each save creates a snapshot that can be restored later.
|
|
394
398
|
|
|
395
399
|
```typescript
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
400
|
+
defineCollection({
|
|
401
|
+
slug: 'posts',
|
|
402
|
+
versions: { drafts: true, maxRevisions: 10 },
|
|
403
|
+
schema: z.object({...})
|
|
404
|
+
});
|
|
399
405
|
```
|
|
400
406
|
|
|
401
407
|
### Version API
|
|
@@ -411,13 +417,27 @@ The admin UI provides a visual "Versions" panel where editors can browse and res
|
|
|
411
417
|
|
|
412
418
|
## 🧮 Virtual Fields
|
|
413
419
|
|
|
414
|
-
Virtual fields are **computed at read-time** and never stored in the database. Perfect for derived values, aggregated data, or API mashups.
|
|
420
|
+
Virtual fields are **computed at read-time** and never stored in the database. Perfect for derived values, aggregated data, or API mashups. Use `z.helpers()` to infer the collection types for type-safe resolving.
|
|
415
421
|
|
|
416
422
|
```typescript
|
|
417
|
-
|
|
423
|
+
const userShape = z.object({
|
|
424
|
+
firstName: z.text(),
|
|
425
|
+
lastName: z.text(),
|
|
426
|
+
});
|
|
418
427
|
|
|
419
|
-
|
|
420
|
-
|
|
428
|
+
const { meta } = z.helpers(userShape);
|
|
429
|
+
|
|
430
|
+
export const users = defineCollection({
|
|
431
|
+
slug: 'users',
|
|
432
|
+
schema: z.object({
|
|
433
|
+
...userShape.shape,
|
|
434
|
+
fullName: meta(z.text(), {
|
|
435
|
+
isVirtual: true,
|
|
436
|
+
resolve: async ({ data, user, req }) => {
|
|
437
|
+
return `${data.firstName} ${data.lastName}`;
|
|
438
|
+
},
|
|
439
|
+
}),
|
|
440
|
+
}),
|
|
421
441
|
});
|
|
422
442
|
```
|
|
423
443
|
|
|
@@ -494,12 +514,13 @@ logger.info('Custom route hit');
|
|
|
494
514
|
|
|
495
515
|
## 🗄 Database Adapters
|
|
496
516
|
|
|
497
|
-
OpacaCMS provides first-class adapters for multiple database engines.
|
|
517
|
+
OpacaCMS provides first-class adapters for multiple database engines.
|
|
498
518
|
|
|
499
519
|
| Adapter | Import | Usage |
|
|
500
520
|
| ------------- | ------------- | --------------------------------- |
|
|
501
521
|
| SQLite (Bun) | `opacacms/db` | `createSQLiteAdapter('local.db')` |
|
|
502
522
|
| Cloudflare D1 | `opacacms/db` | `createD1Adapter(env.DB)` |
|
|
523
|
+
| PostgreSQL | `opacacms/db` | `createPostgresAdapter(url)` |
|
|
503
524
|
|
|
504
525
|
---
|
|
505
526
|
|
|
@@ -552,8 +573,8 @@ i18n: {
|
|
|
552
573
|
}
|
|
553
574
|
|
|
554
575
|
// Field
|
|
555
|
-
|
|
556
|
-
|
|
576
|
+
z.text({ label: 'Title' }).localized()
|
|
577
|
+
z.richText({ label: 'Content' }).localized()
|
|
557
578
|
```
|
|
558
579
|
|
|
559
580
|
### Locale Selection
|
|
@@ -583,7 +604,7 @@ This is where OpacaCMS shines. You can replace any field UI with your own **Reac
|
|
|
583
604
|
|
|
584
605
|
```tsx
|
|
585
606
|
// MyColorPicker.tsx
|
|
586
|
-
import { defineReactField } from 'opacacms';
|
|
607
|
+
import { defineReactField } from 'opacacms/admin';
|
|
587
608
|
|
|
588
609
|
const ColorPicker = ({ value, onChange }) => (
|
|
589
610
|
<input
|
|
@@ -600,7 +621,7 @@ defineReactField('my-color-picker', ColorPicker);
|
|
|
600
621
|
|
|
601
622
|
```tsx
|
|
602
623
|
// MyVuePicker.vue
|
|
603
|
-
import { defineVueField } from 'opacacms';
|
|
624
|
+
import { defineVueField } from 'opacacms/admin';
|
|
604
625
|
import { createApp } from 'vue';
|
|
605
626
|
import MyVueComponent from './MyVueComponent.vue';
|
|
606
627
|
|
|
@@ -610,9 +631,11 @@ defineVueField('my-vue-picker', MyVueComponent, { createApp });
|
|
|
610
631
|
### 3️⃣ Reference in Schema
|
|
611
632
|
|
|
612
633
|
```typescript
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
634
|
+
z.text({ label: 'Color' }).cms({
|
|
635
|
+
admin: {
|
|
636
|
+
components: {
|
|
637
|
+
Field: 'my-color-picker',
|
|
638
|
+
},
|
|
616
639
|
},
|
|
617
640
|
});
|
|
618
641
|
```
|
|
@@ -635,11 +658,13 @@ Collections and Fields can be further customized for the Admin UI using the `.ad
|
|
|
635
658
|
Example:
|
|
636
659
|
|
|
637
660
|
```typescript
|
|
638
|
-
export const InternalData =
|
|
639
|
-
|
|
661
|
+
export const InternalData = defineCollection({
|
|
662
|
+
slug: 'internal_data',
|
|
663
|
+
admin: {
|
|
640
664
|
hidden: true, // Only accessible via direct link
|
|
641
|
-
}
|
|
642
|
-
.
|
|
665
|
+
},
|
|
666
|
+
schema: z.object({...}),
|
|
667
|
+
});
|
|
643
668
|
```
|
|
644
669
|
|
|
645
670
|
---
|
|
@@ -917,8 +942,8 @@ export const myPlugin = () =>
|
|
|
917
942
|
icon: "Activity",
|
|
918
943
|
path: "/admin/my-plugin",
|
|
919
944
|
render: (serverUrl) => \`
|
|
920
|
-
<iframe
|
|
921
|
-
src="\${serverUrl}/api/plugins/my-plugin/view"
|
|
945
|
+
<iframe
|
|
946
|
+
src="\${serverUrl}/api/plugins/my-plugin/view"
|
|
922
947
|
style="width:100%; height:calc(100vh - 100px); border:none;"
|
|
923
948
|
></iframe>
|
|
924
949
|
\`
|
|
@@ -929,14 +954,12 @@ export const myPlugin = () =>
|
|
|
929
954
|
|
|
930
955
|
// Serve the Isolated HTML View with Hono/HTML
|
|
931
956
|
app.get('/api/plugins/my-plugin/view', (c) => {
|
|
932
|
-
return c.html(
|
|
933
|
-
<body
|
|
934
|
-
style="background: #f6f9fc; padding: 40px; font-family: sans-serif;"
|
|
935
|
-
>
|
|
957
|
+
return (c.header('Content-Type', 'text/html'), c.body(\`
|
|
958
|
+
<body style="background: #f6f9fc; padding: 40px; font-family: sans-serif;">
|
|
936
959
|
<h1>Modern Plugin UI</h1>
|
|
937
960
|
<p>Isolated from CMS styles with zero boilerplate.</p>
|
|
938
961
|
</body>
|
|
939
|
-
|
|
962
|
+
\`));
|
|
940
963
|
});
|
|
941
964
|
},
|
|
942
965
|
|
|
@@ -2,7 +2,7 @@ import { type VariantProps } from "class-variance-authority";
|
|
|
2
2
|
import type * as React from "react";
|
|
3
3
|
import "../../styles/button.scss";
|
|
4
4
|
declare const buttonVariants: (props?: ({
|
|
5
|
-
variant?: "
|
|
5
|
+
variant?: "link" | "default" | "destructive" | "outline" | "secondary" | "ghost" | null | undefined;
|
|
6
6
|
size?: "default" | "icon" | "sm" | "lg" | "icon-sm" | "icon-xs" | "icon-lg" | "xs" | null | undefined;
|
|
7
7
|
} & import("class-variance-authority/types").ClassProp) | undefined) => string;
|
|
8
8
|
declare function Button({ className, variant, size, asChild, ...props }: React.ComponentProps<"button"> & VariantProps<typeof buttonVariants> & {
|
|
@@ -335,6 +335,7 @@ class D1Adapter extends BaseDatabaseAdapter {
|
|
|
335
335
|
}
|
|
336
336
|
}
|
|
337
337
|
const totalPages = Math.ceil(total / limit);
|
|
338
|
+
const hasNextPage = page * limit < total;
|
|
338
339
|
return {
|
|
339
340
|
docs: docs.filter(Boolean),
|
|
340
341
|
totalDocs: total,
|
|
@@ -342,11 +343,11 @@ class D1Adapter extends BaseDatabaseAdapter {
|
|
|
342
343
|
totalPages,
|
|
343
344
|
page,
|
|
344
345
|
pagingCounter: offset + 1,
|
|
345
|
-
hasNextPage
|
|
346
|
+
hasNextPage,
|
|
346
347
|
hasPrevPage: page > 1 || !!options?.after || !!options?.before,
|
|
347
348
|
prevPage: page > 1 ? page - 1 : null,
|
|
348
349
|
nextPage: page < totalPages ? page + 1 : null,
|
|
349
|
-
nextCursor: docs.length > 0 ? docs[docs.length - 1][cursorColumn] : null,
|
|
350
|
+
nextCursor: docs.length > 0 && hasNextPage ? docs[docs.length - 1][cursorColumn] : null,
|
|
350
351
|
prevCursor: docs.length > 0 && (page > 1 || !!options?.after || !!options?.before) ? docs[0][cursorColumn] : null
|
|
351
352
|
};
|
|
352
353
|
}
|
|
@@ -126,6 +126,10 @@ class BetterSQLiteAdapter extends BaseDatabaseAdapter {
|
|
|
126
126
|
filteredData[col] = flatData[col];
|
|
127
127
|
}
|
|
128
128
|
}
|
|
129
|
+
if (!filteredData.id) {
|
|
130
|
+
filteredData.id = crypto.randomUUID();
|
|
131
|
+
flatData.id = filteredData.id;
|
|
132
|
+
}
|
|
129
133
|
await tx.insertInto(tableName).values(filteredData).execute();
|
|
130
134
|
for (const [key, values] of Object.entries(hasManyData)) {
|
|
131
135
|
const joinTableName = `${tableName}_${toSnakeCase(key)}_relations`.toLowerCase();
|
|
@@ -327,6 +331,7 @@ class BetterSQLiteAdapter extends BaseDatabaseAdapter {
|
|
|
327
331
|
}
|
|
328
332
|
}
|
|
329
333
|
const totalPages = Math.ceil(total / limit);
|
|
334
|
+
const hasNextPage = page * limit < total;
|
|
330
335
|
return {
|
|
331
336
|
docs: docs.filter(Boolean),
|
|
332
337
|
totalDocs: total,
|
|
@@ -334,11 +339,11 @@ class BetterSQLiteAdapter extends BaseDatabaseAdapter {
|
|
|
334
339
|
totalPages,
|
|
335
340
|
page,
|
|
336
341
|
pagingCounter: offset + 1,
|
|
337
|
-
hasNextPage
|
|
342
|
+
hasNextPage,
|
|
338
343
|
hasPrevPage: page > 1 || !!options?.after || !!options?.before,
|
|
339
344
|
prevPage: page > 1 ? page - 1 : null,
|
|
340
345
|
nextPage: page < totalPages ? page + 1 : null,
|
|
341
|
-
nextCursor: docs.length > 0 ? docs[docs.length - 1][cursorColumn] : null,
|
|
346
|
+
nextCursor: hasNextPage && docs.length > 0 ? docs[docs.length - 1][cursorColumn] : null,
|
|
342
347
|
prevCursor: docs.length > 0 && (page > 1 || !!options?.after || !!options?.before) ? docs[0][cursorColumn] : null
|
|
343
348
|
};
|
|
344
349
|
}
|