@karibsen/eponyme 0.1.1 → 0.2.0
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 +126 -12
- package/dist/module.json +1 -1
- package/dist/module.mjs +46 -36
- package/dist/runtime/components/editor/EponymeCollectionPage.vue +80 -18
- package/dist/runtime/components/editor/EponymeForm.vue +2 -2
- package/dist/runtime/components/editor/EponymeFormPage.vue +7 -3
- package/dist/runtime/components/editor/EponymeSidebar.vue +24 -32
- package/dist/runtime/components/editor/EponymeSidebarTree.d.vue.ts +2 -0
- package/dist/runtime/components/editor/EponymeSidebarTree.vue +3 -1
- package/dist/runtime/components/editor/EponymeSidebarTree.vue.d.ts +2 -0
- package/dist/runtime/components/fields/RichTextField.vue +51 -10
- package/dist/runtime/components/ui/EPLinkDialog.d.vue.ts +15 -0
- package/dist/runtime/components/ui/EPLinkDialog.vue +142 -0
- package/dist/runtime/components/ui/EPLinkDialog.vue.d.ts +15 -0
- package/dist/runtime/components/ui/EPLinkEditor.vue +10 -91
- package/dist/runtime/composables/useEponyme.js +5 -3
- package/dist/runtime/composables/useEponymeCollection.js +3 -2
- package/dist/runtime/composables/useEponymeForm.js +4 -1
- package/dist/runtime/composables/useEponymeHistory.d.ts +2 -2
- package/dist/runtime/composables/useEponymeHistory.js +2 -1
- package/dist/runtime/pages/EponymeDetailPage.vue +1 -1
- package/dist/runtime/pages/EponymeIndexPage.vue +173 -10
- package/dist/runtime/server/api/eponyme/[name].patch.js +17 -1
- package/dist/runtime/server/api/eponyme-collections/[path].delete.js +7 -2
- package/dist/runtime/server/api/eponyme-export.get.d.ts +2 -0
- package/dist/runtime/server/api/eponyme-export.get.js +11 -0
- package/dist/runtime/server/api/eponyme-forms/[path].post.js +14 -1
- package/dist/runtime/server/api/eponyme-history/[path].patch.d.ts +1 -1
- package/dist/runtime/server/api/eponyme-history/[path].patch.js +14 -2
- package/dist/runtime/server/api/eponyme-import.post.d.ts +2 -0
- package/dist/runtime/server/api/eponyme-import.post.js +31 -0
- package/dist/runtime/server/api/eponyme-trash/[path].delete.d.ts +4 -0
- package/dist/runtime/server/api/eponyme-trash/[path].delete.js +16 -0
- package/dist/runtime/server/api/eponyme-trash/[path].get.d.ts +2 -0
- package/dist/runtime/server/api/eponyme-trash/[path].get.js +11 -0
- package/dist/runtime/server/api/eponyme-trash/[path].patch.d.ts +4 -0
- package/dist/runtime/server/api/eponyme-trash/[path].patch.js +16 -0
- package/dist/runtime/server/services/eponyme-form-store.d.ts +1 -0
- package/dist/runtime/server/services/eponyme-store.d.ts +75 -2
- package/dist/runtime/server/services/eponyme-store.js +232 -7
- package/dist/runtime/server/utils/eponyme-entry.d.ts +9 -0
- package/dist/runtime/server/utils/eponyme-entry.js +7 -0
- package/dist/runtime/server/utils/eponyme-hooks.d.ts +16 -0
- package/dist/runtime/server/utils/eponyme-hooks.js +24 -0
- package/dist/runtime/types/field.d.ts +1 -0
- package/dist/runtime/types/hooks.d.ts +67 -0
- package/dist/runtime/types/hooks.js +0 -0
- package/dist/runtime/types/nuxt.d.ts +10 -0
- package/dist/runtime/utils/build-navigation-tree.d.ts +22 -0
- package/dist/runtime/utils/build-navigation-tree.js +30 -0
- package/dist/runtime/utils/date-locale.d.ts +10 -0
- package/dist/runtime/utils/date-locale.js +1 -0
- package/dist/runtime/utils/downloadable-link.d.ts +2 -0
- package/dist/runtime/utils/downloadable-link.js +35 -0
- package/dist/runtime/utils/filter-navigation-tree.d.ts +7 -0
- package/dist/runtime/utils/filter-navigation-tree.js +39 -0
- package/dist/runtime/utils/get-field-default-value.js +1 -1
- package/dist/runtime/utils/hydration-cache.d.ts +11 -0
- package/dist/runtime/utils/hydration-cache.js +3 -0
- package/dist/runtime/utils/validate-eponyme-data.js +1 -1
- package/package.json +3 -1
package/README.md
CHANGED
|
@@ -15,11 +15,13 @@ Define your content in `eponyme.config.ts`. Eponyme provides defaults, validatio
|
|
|
15
15
|
- Declarative defaults and validation
|
|
16
16
|
- Conditional fields, character counters, and sortable arrays
|
|
17
17
|
- Private drafts with explicit publishing
|
|
18
|
-
- Collections for articles, pages, and other repeatable content
|
|
18
|
+
- Collections for articles, pages, and other repeatable content, with sorting, limiting and pagination
|
|
19
19
|
- Public forms with typed schemas, server-side validation, and stored submissions
|
|
20
|
+
- Content variables such as `{{ currentYear }}`, resolved when the page is served
|
|
20
21
|
- Persistent version history with dashboard restoration
|
|
21
22
|
- Draft previews for configured public routes
|
|
22
23
|
- A general sitemap metadata endpoint for configured public routes
|
|
24
|
+
- Content export and import between environments, guarded by a schema fingerprint
|
|
23
25
|
- A generated dashboard at `/__eponyme`
|
|
24
26
|
- Server sessions with `owner`, `editor`, and `viewer` roles
|
|
25
27
|
- PostgreSQL and Prisma persistence using JSONB
|
|
@@ -89,6 +91,7 @@ export default defineEponymeConfig({
|
|
|
89
91
|
href: '/contact',
|
|
90
92
|
type: 'internal',
|
|
91
93
|
openInNewTab: false,
|
|
94
|
+
download: false,
|
|
92
95
|
},
|
|
93
96
|
}),
|
|
94
97
|
published: field.boolean({
|
|
@@ -171,12 +174,14 @@ Add the required models to your Prisma schema:
|
|
|
171
174
|
|
|
172
175
|
```prisma
|
|
173
176
|
model Eponyme {
|
|
174
|
-
name String
|
|
175
|
-
data Json
|
|
176
|
-
createdAt DateTime
|
|
177
|
-
updatedAt DateTime
|
|
177
|
+
name String @id
|
|
178
|
+
data Json @db.JsonB
|
|
179
|
+
createdAt DateTime @default(now())
|
|
180
|
+
updatedAt DateTime @updatedAt
|
|
181
|
+
deletedAt DateTime?
|
|
178
182
|
versions EponymeVersion[]
|
|
179
183
|
|
|
184
|
+
@@index([deletedAt])
|
|
180
185
|
@@map("eponyme_entries")
|
|
181
186
|
}
|
|
182
187
|
|
|
@@ -241,6 +246,9 @@ model EponymeFormSubmission {
|
|
|
241
246
|
|
|
242
247
|
Run the migration with the workflow used by your application.
|
|
243
248
|
|
|
249
|
+
Upgrading from 0.1.1 requires one migration: `Eponyme.deletedAt`, which backs the trash.
|
|
250
|
+
Deleting a collection entry no longer destroys it.
|
|
251
|
+
|
|
244
252
|
The playground includes the same models in `playground/prisma/schema.prisma`. Copy `playground/.env.example` to `playground/.env`, set `DATABASE_URL`, then run:
|
|
245
253
|
|
|
246
254
|
```bash
|
|
@@ -346,6 +354,27 @@ GET /api/eponyme-collections/articles?orderBy=date&order=desc&take=4
|
|
|
346
354
|
`take` is capped at 200, and an unknown `orderBy` answers `400` with the accepted keys
|
|
347
355
|
rather than returning an arbitrary order.
|
|
348
356
|
|
|
357
|
+
### Trash
|
|
358
|
+
|
|
359
|
+
Deleting a collection entry moves it to a trash rather than destroying it. The entry
|
|
360
|
+
disappears from the dashboard, from the public API and from the sitemap, but its content
|
|
361
|
+
and its whole version history are kept, so it can be brought back.
|
|
362
|
+
|
|
363
|
+
The collection page lists the trash behind a **Trash** button and offers **Restore** and
|
|
364
|
+
**Delete for good**. Restoring is available to editors and owners; deleting for good is
|
|
365
|
+
reserved to owners, since it is the only irreversible content operation — the entry's
|
|
366
|
+
history goes with it.
|
|
367
|
+
|
|
368
|
+
```http
|
|
369
|
+
GET /api/eponyme-trash/articles # trashed entries of a collection
|
|
370
|
+
PATCH /api/eponyme-trash/articles/my-article # restore
|
|
371
|
+
DELETE /api/eponyme-trash/articles/my-article # delete for good, owner only
|
|
372
|
+
```
|
|
373
|
+
|
|
374
|
+
A trashed entry keeps its row, so it keeps its slug. Creating an entry with that slug is
|
|
375
|
+
refused with a message pointing at the trash rather than silently overwriting what is
|
|
376
|
+
waiting there. Restore the entry or delete it for good to free its slug.
|
|
377
|
+
|
|
349
378
|
## Entries
|
|
350
379
|
|
|
351
380
|
`useEponyme()` is auto-imported and infers its data from `eponyme.config.ts`:
|
|
@@ -379,6 +408,33 @@ The dashboard uses explicit saves. `Ctrl+S` or `Cmd+S` stores a draft. Publish r
|
|
|
379
408
|
|
|
380
409
|
The `errors` ref contains field errors returned with HTTP `422`. Eponyme creates missing singleton rows from configured defaults. It also reconciles stored JSONB data when fields are added or removed from the schema.
|
|
381
410
|
|
|
411
|
+
### Export and import
|
|
412
|
+
|
|
413
|
+
The dashboard overview at `/__eponyme` carries **Export** and **Import**, so content
|
|
414
|
+
prepared on one environment can be moved to another instead of being retyped.
|
|
415
|
+
|
|
416
|
+
Export downloads a JSON file holding every singleton and every live collection entry
|
|
417
|
+
with its complete state — draft, published version, status and publication date — plus a
|
|
418
|
+
fingerprint of the schema each entry was written against. The trash, form submissions and
|
|
419
|
+
users are never part of it.
|
|
420
|
+
|
|
421
|
+
Import applies that file on top of the current content: each entry it carries overwrites
|
|
422
|
+
its counterpart, entries it does not mention are left untouched, and nothing is ever
|
|
423
|
+
deleted. Before the first write, the fingerprints are compared with the local
|
|
424
|
+
configuration; a single divergence refuses the whole file and names what diverged, so an
|
|
425
|
+
import can never land half of its entries into a schema that no longer matches. The
|
|
426
|
+
dashboard first runs the import as a dry run and shows what it would create, overwrite
|
|
427
|
+
and skip. An entry whose slug waits in the trash is skipped rather than resurrected.
|
|
428
|
+
|
|
429
|
+
Every imported entry is written to the version history, so an import stays reversible
|
|
430
|
+
entry by entry from the timeline.
|
|
431
|
+
|
|
432
|
+
```http
|
|
433
|
+
GET /api/eponyme-export # editors and owners
|
|
434
|
+
POST /api/eponyme-import # owners only
|
|
435
|
+
POST /api/eponyme-import?dryRun=1 # report the counts without writing
|
|
436
|
+
```
|
|
437
|
+
|
|
382
438
|
## Content variables
|
|
383
439
|
|
|
384
440
|
Editors can drop `{{ currentYear }}` into any text or rich-text field. Variables are
|
|
@@ -612,17 +668,75 @@ Two protections apply to the public submission route:
|
|
|
612
668
|
- a **body size limit**, 64 KB by default, configurable with `maxBodyBytes`. An oversized
|
|
613
669
|
body is refused with `413` before it is parsed.
|
|
614
670
|
|
|
615
|
-
|
|
671
|
+
A captcha is not part of the module. A public form is a normal POST route, so adding
|
|
672
|
+
Turnstile, hCaptcha or anything else is done in the host application: render the widget
|
|
673
|
+
on the page, send its token, and verify it in your own handler before calling
|
|
674
|
+
`validateEponymeForm()`.
|
|
675
|
+
|
|
676
|
+
Rate limiting and file uploads are not implemented yet.
|
|
677
|
+
|
|
678
|
+
## Hooks
|
|
679
|
+
|
|
680
|
+
Eponyme emits Nitro hooks around every content write and form submission. Listen to
|
|
681
|
+
them from a server plugin:
|
|
682
|
+
|
|
683
|
+
```ts
|
|
684
|
+
// server/plugins/eponyme.ts
|
|
685
|
+
export default defineNitroPlugin((nitroApp) => {
|
|
686
|
+
nitroApp.hooks.hook('eponyme:entry:published', async ({ name, collection, data }) => {
|
|
687
|
+
await purgeCdnCache(collection ? `/${collection.name}/${collection.slug}` : `/${name}`)
|
|
688
|
+
})
|
|
689
|
+
|
|
690
|
+
nitroApp.hooks.hook('eponyme:form:submitted', async ({ form, data, id }) => {
|
|
691
|
+
if (form === 'contact') await sendEmail(data)
|
|
692
|
+
})
|
|
693
|
+
})
|
|
694
|
+
```
|
|
695
|
+
|
|
696
|
+
| Hook | When |
|
|
697
|
+
|---|---|
|
|
698
|
+
| `eponyme:entry:beforeSave` | Before a draft save or a publication |
|
|
699
|
+
| `eponyme:entry:saved` | After a draft save |
|
|
700
|
+
| `eponyme:entry:published` | After a publication |
|
|
701
|
+
| `eponyme:entry:restored` | After a history version was restored |
|
|
702
|
+
| `eponyme:entry:trashed` | After a collection entry was moved to the trash |
|
|
703
|
+
| `eponyme:entry:untrashed` | After a collection entry was taken back out of the trash |
|
|
704
|
+
| `eponyme:entry:purged` | After a collection entry and its history were deleted for good |
|
|
705
|
+
| `eponyme:form:beforeSubmit` | Before a managed submission is stored |
|
|
706
|
+
| `eponyme:form:submitted` | After a managed submission was stored |
|
|
707
|
+
|
|
708
|
+
The `before` hooks can **reject or amend** the operation. Throwing rejects it with a
|
|
709
|
+
`422` carrying your message, which is the way to enforce a rule the schema cannot
|
|
710
|
+
express. Mutating `context.data` changes what gets written:
|
|
711
|
+
|
|
712
|
+
```ts
|
|
713
|
+
nitroApp.hooks.hook('eponyme:entry:beforeSave', ({ data, collection }) => {
|
|
714
|
+
if (collection?.name === 'articles' && !data.excerpt) throw new Error('An excerpt is required.')
|
|
715
|
+
data.updatedBy = 'automation'
|
|
716
|
+
})
|
|
717
|
+
```
|
|
718
|
+
|
|
719
|
+
`eponyme:form:beforeSubmit` runs **after** validation, so a listener never sees a
|
|
720
|
+
payload the schema would have rejected.
|
|
721
|
+
|
|
722
|
+
The other hooks are notifications: the write already happened, so a listener that
|
|
723
|
+
throws is logged and swallowed. A failing webhook must not report a successful save as
|
|
724
|
+
an error the editor cannot act on.
|
|
725
|
+
|
|
726
|
+
Entries belonging to a collection carry `collection: { name, slug }`, so a listener can
|
|
727
|
+
branch without re-parsing the entry name. The three trash hooks only ever concern
|
|
728
|
+
collection entries, so their `collection` is always present; they carry no content, since
|
|
729
|
+
a trash move leaves the entry itself untouched.
|
|
616
730
|
|
|
617
731
|
## Current status
|
|
618
732
|
|
|
619
|
-
Eponyme is at version `0.1.
|
|
733
|
+
Eponyme is at version `0.1.1`. It is ready for controlled projects and production pilots. A few workflows still need hardening before a broad public release:
|
|
620
734
|
|
|
621
|
-
-
|
|
622
|
-
-
|
|
623
|
-
-
|
|
624
|
-
-
|
|
625
|
-
-
|
|
735
|
+
- Client revision tokens for long-running concurrent edits: the current `updatedAt` check protects overlapping writes within one request, not two editors who loaded the same older revision. A deletion is not guarded by a revision either
|
|
736
|
+
- Retention controls for the trash: entries stay there until someone empties it
|
|
737
|
+
- Rate limiting and file uploads for public forms
|
|
738
|
+
- Filtering, export and retention controls for stored submissions
|
|
739
|
+
- Pagination or a sitemap index beyond 50,000 URLs
|
|
626
740
|
|
|
627
741
|
## Development
|
|
628
742
|
|
package/dist/module.json
CHANGED
package/dist/module.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { defineNuxtModule, useLogger, createResolver,
|
|
1
|
+
import { defineNuxtModule, useLogger, createResolver, findPath, addImports, addImportsDir, addServerImports, addRouteMiddleware, addTypeTemplate, addServerHandler, addServerPlugin } from '@nuxt/kit';
|
|
2
2
|
import pc from 'picocolors';
|
|
3
3
|
import tailwindcss from '@tailwindcss/vite';
|
|
4
4
|
import { resolve } from 'node:path';
|
|
@@ -17,6 +17,46 @@ const module$1 = defineNuxtModule({
|
|
|
17
17
|
sessionDurationDays: 7
|
|
18
18
|
}
|
|
19
19
|
},
|
|
20
|
+
moduleDependencies: {
|
|
21
|
+
"@nuxt/icon": {
|
|
22
|
+
overrides: {
|
|
23
|
+
serverBundle: { collections: ["mingcute"] },
|
|
24
|
+
clientBundle: {
|
|
25
|
+
icons: [
|
|
26
|
+
"mingcute:history-anticlockwise-line",
|
|
27
|
+
"mingcute:eye-2-line",
|
|
28
|
+
"mingcute:layout-leftbar-close-line",
|
|
29
|
+
"mingcute:layout-leftbar-open-line",
|
|
30
|
+
"mingcute:down-small-line",
|
|
31
|
+
"mingcute:search-line",
|
|
32
|
+
"mingcute:close-line",
|
|
33
|
+
"mingcute:arrow-right-line",
|
|
34
|
+
"mingcute:align-arrow-right-line",
|
|
35
|
+
"mingcute:link-2-line",
|
|
36
|
+
"mingcute:settings-3-line",
|
|
37
|
+
"mingcute:code-line",
|
|
38
|
+
"mingcute:refresh-2-line",
|
|
39
|
+
"mingcute:external-link-line",
|
|
40
|
+
"mingcute:paragraph-line",
|
|
41
|
+
"mingcute:heading-2-line",
|
|
42
|
+
"mingcute:heading-3-line",
|
|
43
|
+
"mingcute:bold-line",
|
|
44
|
+
"mingcute:italic-line",
|
|
45
|
+
"mingcute:strikethrough-line",
|
|
46
|
+
"mingcute:link-line",
|
|
47
|
+
"mingcute:pic-line",
|
|
48
|
+
"mingcute:list-check-line",
|
|
49
|
+
"mingcute:list-ordered-line",
|
|
50
|
+
"mingcute:quote-left-line",
|
|
51
|
+
"mingcute:back-2-line",
|
|
52
|
+
"mingcute:forward-2-line",
|
|
53
|
+
"mingcute:download-2-line",
|
|
54
|
+
"mingcute:upload-2-line"
|
|
55
|
+
]
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
},
|
|
20
60
|
async setup(options, nuxt) {
|
|
21
61
|
const logger = useLogger("eponyme");
|
|
22
62
|
const resolver = createResolver(import.meta.url);
|
|
@@ -30,41 +70,6 @@ const module$1 = defineNuxtModule({
|
|
|
30
70
|
nuxt.options.runtimeConfig.eponymeAuth = {
|
|
31
71
|
sessionDurationDays: options.auth?.sessionDurationDays ?? 7
|
|
32
72
|
};
|
|
33
|
-
await installModule("@nuxt/icon", {
|
|
34
|
-
serverBundle: { collections: ["mingcute"] },
|
|
35
|
-
clientBundle: {
|
|
36
|
-
// Explicit list: an icon missing here is not bundled and renders blank in production.
|
|
37
|
-
icons: [
|
|
38
|
-
"mingcute:history-anticlockwise-line",
|
|
39
|
-
"mingcute:eye-2-line",
|
|
40
|
-
"mingcute:layout-leftbar-close-line",
|
|
41
|
-
"mingcute:layout-leftbar-open-line",
|
|
42
|
-
"mingcute:down-small-line",
|
|
43
|
-
"mingcute:search-line",
|
|
44
|
-
"mingcute:close-line",
|
|
45
|
-
"mingcute:arrow-right-line",
|
|
46
|
-
"mingcute:align-arrow-right-line",
|
|
47
|
-
"mingcute:link-2-line",
|
|
48
|
-
"mingcute:settings-3-line",
|
|
49
|
-
"mingcute:code-line",
|
|
50
|
-
"mingcute:refresh-2-line",
|
|
51
|
-
"mingcute:external-link-line",
|
|
52
|
-
"mingcute:paragraph-line",
|
|
53
|
-
"mingcute:heading-2-line",
|
|
54
|
-
"mingcute:heading-3-line",
|
|
55
|
-
"mingcute:bold-line",
|
|
56
|
-
"mingcute:italic-line",
|
|
57
|
-
"mingcute:strikethrough-line",
|
|
58
|
-
"mingcute:link-line",
|
|
59
|
-
"mingcute:pic-line",
|
|
60
|
-
"mingcute:list-check-line",
|
|
61
|
-
"mingcute:list-ordered-line",
|
|
62
|
-
"mingcute:quote-left-line",
|
|
63
|
-
"mingcute:back-2-line",
|
|
64
|
-
"mingcute:forward-2-line"
|
|
65
|
-
]
|
|
66
|
-
}
|
|
67
|
-
}, nuxt);
|
|
68
73
|
const preparing = Boolean(nuxt.options._prepare);
|
|
69
74
|
if (!options.prismaClient) {
|
|
70
75
|
if (!preparing) throw new Error(`${pc.red("[Eponyme]")} eponyme.prismaClient is required.`);
|
|
@@ -141,11 +146,16 @@ declare module '#eponyme/variables' {
|
|
|
141
146
|
addServerHandler({ route: "/api/eponyme/**", method: "patch", handler: resolver.resolve("./runtime/server/api/eponyme/[name].patch") });
|
|
142
147
|
addServerHandler({ route: "/api/eponyme-statuses", handler: resolver.resolve("./runtime/server/api/eponyme-statuses.get") });
|
|
143
148
|
addServerHandler({ route: "/api/eponyme-sitemap", handler: resolver.resolve("./runtime/server/api/eponyme-sitemap.get") });
|
|
149
|
+
addServerHandler({ route: "/api/eponyme-export", handler: resolver.resolve("./runtime/server/api/eponyme-export.get") });
|
|
150
|
+
addServerHandler({ route: "/api/eponyme-import", method: "post", handler: resolver.resolve("./runtime/server/api/eponyme-import.post") });
|
|
144
151
|
addServerHandler({ route: "/api/eponyme-history/**", handler: resolver.resolve("./runtime/server/api/eponyme-history/[path].get") });
|
|
145
152
|
addServerHandler({ route: "/api/eponyme-history/**", method: "patch", handler: resolver.resolve("./runtime/server/api/eponyme-history/[path].patch") });
|
|
146
153
|
addServerHandler({ route: "/api/eponyme-collections/**", handler: resolver.resolve("./runtime/server/api/eponyme-collections/[path].get") });
|
|
147
154
|
addServerHandler({ route: "/api/eponyme-collections/**", method: "post", handler: resolver.resolve("./runtime/server/api/eponyme-collections/[path].post") });
|
|
148
155
|
addServerHandler({ route: "/api/eponyme-collections/**", method: "delete", handler: resolver.resolve("./runtime/server/api/eponyme-collections/[path].delete") });
|
|
156
|
+
addServerHandler({ route: "/api/eponyme-trash/**", handler: resolver.resolve("./runtime/server/api/eponyme-trash/[path].get") });
|
|
157
|
+
addServerHandler({ route: "/api/eponyme-trash/**", method: "patch", handler: resolver.resolve("./runtime/server/api/eponyme-trash/[path].patch") });
|
|
158
|
+
addServerHandler({ route: "/api/eponyme-trash/**", method: "delete", handler: resolver.resolve("./runtime/server/api/eponyme-trash/[path].delete") });
|
|
149
159
|
addServerHandler({ route: "/api/eponyme-forms/**", handler: resolver.resolve("./runtime/server/api/eponyme-forms/[path].get") });
|
|
150
160
|
addServerHandler({ route: "/api/eponyme-forms/**", method: "post", handler: resolver.resolve("./runtime/server/api/eponyme-forms/[path].post") });
|
|
151
161
|
addServerHandler({ route: "/api/eponyme-forms/**", method: "delete", handler: resolver.resolve("./runtime/server/api/eponyme-forms/[path].delete") });
|
|
@@ -3,11 +3,11 @@ import { useAsyncData, useRequestFetch, useRoute, useRouter, useState } from "#a
|
|
|
3
3
|
import { computed, ref, watch } from "vue";
|
|
4
4
|
import { normalizeSlug } from "../../utils/normalize-slug";
|
|
5
5
|
import { useEponymeAuth } from "../../composables/useEponymeAuth";
|
|
6
|
-
import EPBadge from "../ui/EPBadge.vue";
|
|
7
6
|
import EPButton from "../ui/EPButton.vue";
|
|
8
7
|
import EPDialog from "../ui/EPDialog.vue";
|
|
9
8
|
import EPFormField from "../ui/EPFormField.vue";
|
|
10
9
|
import EPInputText from "../ui/EPInputText.vue";
|
|
10
|
+
import { EPONYME_DATE_LOCALE } from "../../utils/date-locale";
|
|
11
11
|
const props = defineProps({
|
|
12
12
|
basePath: { type: String, required: true },
|
|
13
13
|
name: { type: String, required: true },
|
|
@@ -24,12 +24,19 @@ const slugTouched = ref(false);
|
|
|
24
24
|
const errors = ref({});
|
|
25
25
|
const collectionEntries = useState("eponyme:collection-entries", () => ({}));
|
|
26
26
|
const endpoint = computed(() => `/api/eponyme-collections/${props.name}`);
|
|
27
|
+
const trashEndpoint = computed(() => `/api/eponyme-trash/${props.name}`);
|
|
27
28
|
const auth = useEponymeAuth();
|
|
29
|
+
const view = ref("entries");
|
|
28
30
|
const { data: response, pending, refresh } = useAsyncData(
|
|
29
31
|
`eponyme:collection:${props.name}`,
|
|
30
32
|
() => requestFetch(endpoint.value, { query: { version: "draft", raw: 1 } })
|
|
31
33
|
);
|
|
34
|
+
const { data: trashResponse, refresh: refreshTrash } = useAsyncData(
|
|
35
|
+
`eponyme:collection-trash:${props.name}`,
|
|
36
|
+
() => auth.canEdit.value ? requestFetch(trashEndpoint.value) : Promise.resolve({ entries: [] })
|
|
37
|
+
);
|
|
32
38
|
const entries = computed(() => response.value?.entries ?? []);
|
|
39
|
+
const trashEntries = computed(() => trashResponse.value?.entries ?? []);
|
|
33
40
|
const label = computed(() => props.definition.label || humanize(props.name.split("/").at(-1) || props.name));
|
|
34
41
|
watch(title, (value) => {
|
|
35
42
|
if (!slugTouched.value) slug.value = normalizeSlug(value);
|
|
@@ -87,12 +94,23 @@ async function createEntry() {
|
|
|
87
94
|
}
|
|
88
95
|
}
|
|
89
96
|
async function deleteEntry(entry) {
|
|
90
|
-
if (!confirm(`
|
|
97
|
+
if (!confirm(`Move \u201C${entry.title}\u201D to the trash? Its slug stays reserved until you restore it or delete it for good.`)) return;
|
|
91
98
|
await requestFetch(`${endpoint.value}/${entry.slug}`, { method: "DELETE" });
|
|
92
|
-
await refresh();
|
|
99
|
+
await Promise.all([refresh(), refreshTrash()]);
|
|
100
|
+
}
|
|
101
|
+
async function restoreEntry(entry) {
|
|
102
|
+
await requestFetch(`${trashEndpoint.value}/${entry.slug}`, { method: "PATCH" });
|
|
103
|
+
await Promise.all([refresh(), refreshTrash()]);
|
|
104
|
+
if (!trashEntries.value.length) view.value = "entries";
|
|
105
|
+
}
|
|
106
|
+
async function purgeEntry(entry) {
|
|
107
|
+
if (!confirm(`Permanently delete \u201C${entry.title}\u201D? Its content and its whole history are lost. This cannot be undone.`)) return;
|
|
108
|
+
await requestFetch(`${trashEndpoint.value}/${entry.slug}`, { method: "DELETE" });
|
|
109
|
+
await refreshTrash();
|
|
110
|
+
if (!trashEntries.value.length) view.value = "entries";
|
|
93
111
|
}
|
|
94
112
|
function formatDate(value) {
|
|
95
|
-
return value ? new Intl.DateTimeFormat(
|
|
113
|
+
return value ? new Intl.DateTimeFormat(EPONYME_DATE_LOCALE, { dateStyle: "medium", timeStyle: "short" }).format(new Date(value)) : "";
|
|
96
114
|
}
|
|
97
115
|
</script>
|
|
98
116
|
|
|
@@ -113,17 +131,65 @@ function formatDate(value) {
|
|
|
113
131
|
{{ definition.description }}
|
|
114
132
|
</p>
|
|
115
133
|
</div>
|
|
116
|
-
<
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
134
|
+
<div class="ep:flex ep:flex-wrap ep:items-center ep:gap-2">
|
|
135
|
+
<EPButton
|
|
136
|
+
v-if="auth.canEdit.value && trashEntries.length"
|
|
137
|
+
size="md"
|
|
138
|
+
:variant="view === 'trash' ? 'primary' : 'secondary'"
|
|
139
|
+
@click="view = view === 'trash' ? 'entries' : 'trash'"
|
|
140
|
+
>
|
|
141
|
+
Trash ({{ trashEntries.length }})
|
|
142
|
+
</EPButton>
|
|
143
|
+
<EPButton
|
|
144
|
+
v-if="auth.canEdit.value"
|
|
145
|
+
variant="primary"
|
|
146
|
+
@click="createOpen = true"
|
|
147
|
+
>
|
|
148
|
+
+ New {{ humanize(name.split("/").at(-1) || "entry").replace(/s$/, "") }}
|
|
149
|
+
</EPButton>
|
|
150
|
+
</div>
|
|
123
151
|
</header>
|
|
124
152
|
|
|
153
|
+
<div
|
|
154
|
+
v-if="view === 'trash'"
|
|
155
|
+
class="ep:mt-8 ep:grid ep:gap-3"
|
|
156
|
+
>
|
|
157
|
+
<p class="ep:m-0 ep:text-sm ep:text-muted-ep">
|
|
158
|
+
Trashed entries keep their history and their slug. Restore one to bring it back.
|
|
159
|
+
</p>
|
|
160
|
+
<article
|
|
161
|
+
v-for="entry in trashEntries"
|
|
162
|
+
:key="entry.slug"
|
|
163
|
+
class="ep:flex ep:flex-wrap ep:items-center ep:justify-between ep:gap-3 ep:rounded-xl ep:bg-selected-ep/50 ep:p-4"
|
|
164
|
+
>
|
|
165
|
+
<div
|
|
166
|
+
class="ep:min-w-0 ep:flex-1"
|
|
167
|
+
:title="entry.title"
|
|
168
|
+
>
|
|
169
|
+
<span class="ep:block ep:min-w-0 ep:overflow-hidden ep:text-ellipsis ep:whitespace-nowrap ep:text-sm ep:font-semibold ep:text-white">{{ truncateTitle(entry.title) }}</span>
|
|
170
|
+
<span class="ep:mt-1 ep:block ep:text-xs ep:text-muted-ep">{{ entry.deletedAt ? `Deleted ${formatDate(entry.deletedAt)}` : "Deleted" }}</span>
|
|
171
|
+
</div>
|
|
172
|
+
<div class="ep:flex ep:shrink-0 ep:gap-2">
|
|
173
|
+
<EPButton
|
|
174
|
+
size="sm"
|
|
175
|
+
@click="restoreEntry(entry)"
|
|
176
|
+
>
|
|
177
|
+
Restore
|
|
178
|
+
</EPButton>
|
|
179
|
+
<EPButton
|
|
180
|
+
v-if="auth.isOwner.value"
|
|
181
|
+
size="sm"
|
|
182
|
+
variant="danger"
|
|
183
|
+
@click="purgeEntry(entry)"
|
|
184
|
+
>
|
|
185
|
+
Delete for good
|
|
186
|
+
</EPButton>
|
|
187
|
+
</div>
|
|
188
|
+
</article>
|
|
189
|
+
</div>
|
|
190
|
+
|
|
125
191
|
<p
|
|
126
|
-
v-if="pending"
|
|
192
|
+
v-else-if="pending"
|
|
127
193
|
class="ep:mt-8 ep:text-sm ep:text-muted-ep"
|
|
128
194
|
>
|
|
129
195
|
Loading…
|
|
@@ -144,18 +210,14 @@ function formatDate(value) {
|
|
|
144
210
|
>
|
|
145
211
|
<span class="ep:flex ep:min-w-0 ep:items-center ep:gap-2">
|
|
146
212
|
<span class="ep:min-w-0 ep:flex-1 ep:overflow-hidden ep:text-ellipsis ep:whitespace-nowrap ep:text-sm ep:font-semibold ep:text-white">{{ truncateTitle(entry.title) }}</span>
|
|
147
|
-
<EPBadge
|
|
148
|
-
class="ep:shrink-0"
|
|
149
|
-
:variant="entry.status === 'draft' ? 'warning' : 'success'"
|
|
150
|
-
>{{ entry.status }}</EPBadge>
|
|
151
213
|
</span>
|
|
152
|
-
<span class="ep:mt-1 ep:block ep:text-xs ep:text-muted-ep"
|
|
214
|
+
<span class="ep:mt-1 ep:block ep:text-xs ep:text-muted-ep ep:capitalize">{{ entry.status }}<template v-if="entry.updatedAt"> · {{ formatDate(entry.updatedAt) }}</template></span>
|
|
153
215
|
</NuxtLink>
|
|
154
216
|
<EPButton
|
|
155
217
|
v-if="auth.canEdit.value"
|
|
156
218
|
size="sm"
|
|
157
219
|
variant="danger"
|
|
158
|
-
class="ep:opacity-0 ep:group-hover:opacity-100 ep:focus-visible:opacity-100"
|
|
220
|
+
class="ep:shrink-0 ep:md:opacity-0 ep:md:group-hover:opacity-100 ep:md:focus-visible:opacity-100"
|
|
159
221
|
@click="deleteEntry(entry)"
|
|
160
222
|
>
|
|
161
223
|
Delete
|
|
@@ -64,10 +64,10 @@ watch(eponymeData, (value) => {
|
|
|
64
64
|
data.value = next;
|
|
65
65
|
savedData.value = cloneData(next);
|
|
66
66
|
}
|
|
67
|
-
}, { immediate: true });
|
|
67
|
+
}, { immediate: true, flush: "sync" });
|
|
68
68
|
watch(status, (value) => {
|
|
69
69
|
entryStatuses.value = { ...entryStatuses.value, [props.name]: value };
|
|
70
|
-
}, { immediate: true });
|
|
70
|
+
}, { immediate: true, flush: "sync" });
|
|
71
71
|
function cloneData(value) {
|
|
72
72
|
return JSON.parse(JSON.stringify(value));
|
|
73
73
|
}
|
|
@@ -7,6 +7,7 @@ import { useEponymeAuth } from "../../composables/useEponymeAuth";
|
|
|
7
7
|
import EPBadge from "../ui/EPBadge.vue";
|
|
8
8
|
import EPButton from "../ui/EPButton.vue";
|
|
9
9
|
import EPDialog from "../ui/EPDialog.vue";
|
|
10
|
+
import { EPONYME_DATE_LOCALE } from "../../utils/date-locale";
|
|
10
11
|
const props = defineProps({
|
|
11
12
|
name: { type: String, required: true },
|
|
12
13
|
definition: { type: Object, required: true }
|
|
@@ -35,7 +36,10 @@ const columns = computed(() => [
|
|
|
35
36
|
{
|
|
36
37
|
id: fieldName,
|
|
37
38
|
header: humanizeLabel(fieldName, field.options.label),
|
|
38
|
-
|
|
39
|
+
// Never empty: SSR emits nothing for an empty string while the client still
|
|
40
|
+
// builds a text node, which hydrates as a missing child. The dash also makes
|
|
41
|
+
// an unanswered optional field readable instead of looking like a broken cell.
|
|
42
|
+
cell: (info) => truncate(info.getValue()) || "\u2014"
|
|
39
43
|
}
|
|
40
44
|
)),
|
|
41
45
|
columnHelper.accessor((row) => row.createdAt, {
|
|
@@ -79,7 +83,7 @@ function truncate(value, maxLength = 60) {
|
|
|
79
83
|
return characters.length <= maxLength ? value : `${characters.slice(0, maxLength - 1).join("")}\u2026`;
|
|
80
84
|
}
|
|
81
85
|
function formatDate(value) {
|
|
82
|
-
return new Intl.DateTimeFormat(
|
|
86
|
+
return new Intl.DateTimeFormat(EPONYME_DATE_LOCALE, { dateStyle: "medium", timeStyle: "short" }).format(new Date(value));
|
|
83
87
|
}
|
|
84
88
|
async function clearAll() {
|
|
85
89
|
if (!confirm(`Delete all ${total.value} submissions of \u201C${label.value}\u201D? This cannot be undone.`)) return;
|
|
@@ -184,7 +188,7 @@ async function deleteSubmission(submission) {
|
|
|
184
188
|
</th>
|
|
185
189
|
<th
|
|
186
190
|
scope="col"
|
|
187
|
-
class="ep:border-b ep:border-border-ep ep:p-3"
|
|
191
|
+
class="ep:relative ep:border-b ep:border-border-ep ep:p-3"
|
|
188
192
|
>
|
|
189
193
|
<span class="ep:sr-only">Actions</span>
|
|
190
194
|
</th>
|
|
@@ -6,6 +6,8 @@ import EponymeNavigationLink from "./EponymeNavigationLink.vue";
|
|
|
6
6
|
import EponymeSidebarTree from "./EponymeSidebarTree.vue";
|
|
7
7
|
import { useEponymeConfig } from "../../composables/useEponymeConfig";
|
|
8
8
|
import { getEponymeCollections, getEponymeForms, getEponymeSchemas } from "../../utils/get-eponyme-schemas";
|
|
9
|
+
import { buildEponymeNavigationTree } from "../../utils/build-navigation-tree";
|
|
10
|
+
import { filterEponymeNavigationTree } from "../../utils/filter-navigation-tree";
|
|
9
11
|
import { useEponymeAuth } from "../../composables/useEponymeAuth";
|
|
10
12
|
import EPAvatar from "../ui/EPAvatar.vue";
|
|
11
13
|
import EPButton from "../ui/EPButton.vue";
|
|
@@ -55,6 +57,12 @@ onKeyStroke("k", (event) => {
|
|
|
55
57
|
if (!event.metaKey && !event.ctrlKey) return;
|
|
56
58
|
event.preventDefault();
|
|
57
59
|
searchInput.value?.$el?.focus();
|
|
60
|
+
searchInput.value?.$el?.select();
|
|
61
|
+
});
|
|
62
|
+
onKeyStroke("Escape", () => {
|
|
63
|
+
if (!isFiltering.value) return;
|
|
64
|
+
searchQuery.value = "";
|
|
65
|
+
searchInput.value?.$el?.blur();
|
|
58
66
|
});
|
|
59
67
|
const navigation = computed(() => {
|
|
60
68
|
const items = [];
|
|
@@ -86,38 +94,15 @@ const navigation = computed(() => {
|
|
|
86
94
|
}
|
|
87
95
|
return items;
|
|
88
96
|
});
|
|
89
|
-
const navigationTree = computed(() => {
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
const kind = index === parts.length - 1 ? finalKind : "folder";
|
|
98
|
-
let node = children.find((item) => item.path === path);
|
|
99
|
-
if (!node) {
|
|
100
|
-
node = { kind, path, label: index === parts.length - 1 && configuredLabel ? configuredLabel : label(part), children: [] };
|
|
101
|
-
children.push(node);
|
|
102
|
-
} else if (index === parts.length - 1) {
|
|
103
|
-
node.kind = finalKind;
|
|
104
|
-
if (configuredLabel) node.label = configuredLabel;
|
|
105
|
-
}
|
|
106
|
-
children = node.children;
|
|
107
|
-
}
|
|
108
|
-
}
|
|
109
|
-
for (const entry of Object.keys(getEponymeSchemas(config))) addPath(entry, "entry");
|
|
110
|
-
for (const [name, definition] of Object.entries(collections)) {
|
|
111
|
-
addPath(name, "collection", definition.label);
|
|
112
|
-
for (const entry of collectionEntries.value[name] ?? []) addPath(`${name}/${entry.slug}`, "entry", entry.title);
|
|
113
|
-
}
|
|
114
|
-
for (const [name, definition] of Object.entries(forms)) addPath(name, "form", definition.label);
|
|
115
|
-
return root;
|
|
116
|
-
});
|
|
97
|
+
const navigationTree = computed(() => buildEponymeNavigationTree({
|
|
98
|
+
schemas: getEponymeSchemas(config),
|
|
99
|
+
collections,
|
|
100
|
+
forms,
|
|
101
|
+
collectionEntries: collectionEntries.value
|
|
102
|
+
}));
|
|
103
|
+
const isFiltering = computed(() => searchQuery.value.trim().length > 0);
|
|
104
|
+
const filteredTree = computed(() => filterEponymeNavigationTree(navigationTree.value, searchQuery.value));
|
|
117
105
|
const openFolders = useState(`eponyme:open-folders:${normalizedBasePath.value}`, () => navigation.value.filter((item) => item.kind === "folder").map((item) => item.path));
|
|
118
|
-
function label(name) {
|
|
119
|
-
return name.replace(/[-_]/g, " ").replace(/\b\w/g, (char) => char.toUpperCase());
|
|
120
|
-
}
|
|
121
106
|
function entryPath(name) {
|
|
122
107
|
return `${normalizedBasePath.value}/${name}`;
|
|
123
108
|
}
|
|
@@ -204,14 +189,21 @@ watch(() => route.path, () => {
|
|
|
204
189
|
Editables
|
|
205
190
|
</div>
|
|
206
191
|
<EponymeSidebarTree
|
|
207
|
-
:nodes="
|
|
192
|
+
:nodes="filteredTree"
|
|
208
193
|
:base-path="normalizedBasePath"
|
|
209
194
|
:current-path="route.path"
|
|
210
195
|
:statuses="statuses"
|
|
211
196
|
:open-folders="openFolders"
|
|
197
|
+
:force-open="isFiltering"
|
|
212
198
|
:can-edit="auth.canEdit.value"
|
|
213
199
|
@update:open-folders="openFolders = $event"
|
|
214
200
|
/>
|
|
201
|
+
<p
|
|
202
|
+
v-if="isFiltering && !filteredTree.length"
|
|
203
|
+
class="ep:m-0 ep:px-3 ep:py-2 ep:text-xs ep:text-muted-ep"
|
|
204
|
+
>
|
|
205
|
+
No match for “{{ searchQuery }}”.
|
|
206
|
+
</p>
|
|
215
207
|
</nav>
|
|
216
208
|
<div
|
|
217
209
|
v-if="!collapsed && auth.user.value"
|
|
@@ -6,6 +6,8 @@ type __VLS_Props = {
|
|
|
6
6
|
currentPath: string;
|
|
7
7
|
statuses: Record<string, EponymeStatus>;
|
|
8
8
|
openFolders: string[];
|
|
9
|
+
/** Reveals every folder while a search is active, without touching `openFolders`. */
|
|
10
|
+
forceOpen?: boolean;
|
|
9
11
|
canEdit?: boolean;
|
|
10
12
|
depth?: number;
|
|
11
13
|
};
|
|
@@ -8,6 +8,7 @@ const props = defineProps({
|
|
|
8
8
|
currentPath: { type: String, required: true },
|
|
9
9
|
statuses: { type: Object, required: true },
|
|
10
10
|
openFolders: { type: Array, required: true },
|
|
11
|
+
forceOpen: { type: Boolean, required: false, default: false },
|
|
11
12
|
canEdit: { type: Boolean, required: false, default: false },
|
|
12
13
|
depth: { type: Number, required: false, default: 0 }
|
|
13
14
|
});
|
|
@@ -16,7 +17,7 @@ function entryPath(path) {
|
|
|
16
17
|
return `${props.basePath}/${path}`;
|
|
17
18
|
}
|
|
18
19
|
function isOpen(path) {
|
|
19
|
-
return props.openFolders.includes(path);
|
|
20
|
+
return props.forceOpen || props.openFolders.includes(path);
|
|
20
21
|
}
|
|
21
22
|
function setOpen(path, open) {
|
|
22
23
|
const next = new Set(props.openFolders);
|
|
@@ -66,6 +67,7 @@ function setOpen(path, open) {
|
|
|
66
67
|
:current-path="currentPath"
|
|
67
68
|
:statuses="statuses"
|
|
68
69
|
:open-folders="openFolders"
|
|
70
|
+
:force-open="forceOpen"
|
|
69
71
|
:can-edit="canEdit"
|
|
70
72
|
:depth="depth + 1"
|
|
71
73
|
@update:open-folders="emit('update:openFolders', $event)"
|
|
@@ -6,6 +6,8 @@ type __VLS_Props = {
|
|
|
6
6
|
currentPath: string;
|
|
7
7
|
statuses: Record<string, EponymeStatus>;
|
|
8
8
|
openFolders: string[];
|
|
9
|
+
/** Reveals every folder while a search is active, without touching `openFolders`. */
|
|
10
|
+
forceOpen?: boolean;
|
|
9
11
|
canEdit?: boolean;
|
|
10
12
|
depth?: number;
|
|
11
13
|
};
|