@karibsen/eponyme 0.1.1 → 0.3.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.
Files changed (77) hide show
  1. package/README.md +159 -12
  2. package/dist/eponyme.mjs +2 -1
  3. package/dist/module.d.mts +6 -0
  4. package/dist/module.json +1 -1
  5. package/dist/module.mjs +55 -36
  6. package/dist/runtime/components/editor/EponymeCollectionPage.vue +80 -18
  7. package/dist/runtime/components/editor/EponymeForm.vue +2 -2
  8. package/dist/runtime/components/editor/EponymeFormPage.vue +7 -3
  9. package/dist/runtime/components/editor/EponymeSidebar.vue +24 -32
  10. package/dist/runtime/components/editor/EponymeSidebarTree.d.vue.ts +2 -0
  11. package/dist/runtime/components/editor/EponymeSidebarTree.vue +3 -1
  12. package/dist/runtime/components/editor/EponymeSidebarTree.vue.d.ts +2 -0
  13. package/dist/runtime/components/fields/RichTextField.vue +51 -10
  14. package/dist/runtime/components/ui/EPLinkDialog.d.vue.ts +15 -0
  15. package/dist/runtime/components/ui/EPLinkDialog.vue +142 -0
  16. package/dist/runtime/components/ui/EPLinkDialog.vue.d.ts +15 -0
  17. package/dist/runtime/components/ui/EPLinkEditor.vue +10 -91
  18. package/dist/runtime/composables/useEponyme.js +5 -3
  19. package/dist/runtime/composables/useEponymeCollection.js +3 -2
  20. package/dist/runtime/composables/useEponymeForm.d.ts +4 -0
  21. package/dist/runtime/composables/useEponymeForm.js +11 -1
  22. package/dist/runtime/composables/useEponymeHistory.d.ts +2 -2
  23. package/dist/runtime/composables/useEponymeHistory.js +2 -1
  24. package/dist/runtime/pages/EponymeDetailPage.vue +1 -1
  25. package/dist/runtime/pages/EponymeIndexPage.vue +173 -10
  26. package/dist/runtime/server/api/eponyme/[name].patch.js +17 -1
  27. package/dist/runtime/server/api/eponyme-collections/[path].delete.js +7 -2
  28. package/dist/runtime/server/api/eponyme-export.get.d.ts +2 -0
  29. package/dist/runtime/server/api/eponyme-export.get.js +11 -0
  30. package/dist/runtime/server/api/eponyme-forms/[path].post.js +29 -1
  31. package/dist/runtime/server/api/eponyme-history/[path].patch.d.ts +1 -1
  32. package/dist/runtime/server/api/eponyme-history/[path].patch.js +14 -2
  33. package/dist/runtime/server/api/eponyme-import.post.d.ts +2 -0
  34. package/dist/runtime/server/api/eponyme-import.post.js +31 -0
  35. package/dist/runtime/server/api/eponyme-trash/[path].delete.d.ts +4 -0
  36. package/dist/runtime/server/api/eponyme-trash/[path].delete.js +16 -0
  37. package/dist/runtime/server/api/eponyme-trash/[path].get.d.ts +2 -0
  38. package/dist/runtime/server/api/eponyme-trash/[path].get.js +11 -0
  39. package/dist/runtime/server/api/eponyme-trash/[path].patch.d.ts +4 -0
  40. package/dist/runtime/server/api/eponyme-trash/[path].patch.js +16 -0
  41. package/dist/runtime/server/services/eponyme-form-store.js +2 -1
  42. package/dist/runtime/server/services/eponyme-service.js +8 -1
  43. package/dist/runtime/server/services/eponyme-store.d.ts +118 -5
  44. package/dist/runtime/server/services/eponyme-store.js +344 -45
  45. package/dist/runtime/server/utils/eponyme-captcha.d.ts +6 -0
  46. package/dist/runtime/server/utils/eponyme-captcha.js +14 -0
  47. package/dist/runtime/server/utils/eponyme-entry.d.ts +9 -0
  48. package/dist/runtime/server/utils/eponyme-entry.js +7 -0
  49. package/dist/runtime/server/utils/eponyme-hooks.d.ts +16 -0
  50. package/dist/runtime/server/utils/eponyme-hooks.js +24 -0
  51. package/dist/runtime/types/captcha.d.ts +16 -0
  52. package/dist/runtime/types/captcha.js +0 -0
  53. package/dist/runtime/types/eponyme-config.d.ts +7 -0
  54. package/dist/runtime/types/eponyme.d.ts +3 -0
  55. package/dist/runtime/types/field.d.ts +1 -0
  56. package/dist/runtime/types/hooks.d.ts +67 -0
  57. package/dist/runtime/types/hooks.js +0 -0
  58. package/dist/runtime/types/index.d.ts +1 -0
  59. package/dist/runtime/types/index.js +1 -0
  60. package/dist/runtime/types/nuxt.d.ts +10 -0
  61. package/dist/runtime/utils/build-navigation-tree.d.ts +22 -0
  62. package/dist/runtime/utils/build-navigation-tree.js +30 -0
  63. package/dist/runtime/utils/date-locale.d.ts +10 -0
  64. package/dist/runtime/utils/date-locale.js +1 -0
  65. package/dist/runtime/utils/downloadable-link.d.ts +2 -0
  66. package/dist/runtime/utils/downloadable-link.js +35 -0
  67. package/dist/runtime/utils/filter-navigation-tree.d.ts +7 -0
  68. package/dist/runtime/utils/filter-navigation-tree.js +39 -0
  69. package/dist/runtime/utils/get-field-default-value.js +1 -1
  70. package/dist/runtime/utils/hydration-cache.d.ts +11 -0
  71. package/dist/runtime/utils/hydration-cache.js +3 -0
  72. package/dist/runtime/utils/no-captcha.d.ts +7 -0
  73. package/dist/runtime/utils/no-captcha.js +5 -0
  74. package/dist/runtime/utils/validate-eponyme-data.js +1 -1
  75. package/dist/runtime/utils/variables.d.ts +8 -1
  76. package/dist/runtime/utils/variables.js +17 -4
  77. 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 @id
175
- data Json @db.JsonB
176
- createdAt DateTime @default(now())
177
- updatedAt DateTime @updatedAt
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,108 @@ 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
- Rate limiting, CAPTCHA support and file uploads are not implemented yet.
671
+ ### Captcha
672
+
673
+ Set `captcha: true` on the form, then install an adapter — Eponyme defines the contract
674
+ but ships no provider:
675
+
676
+ ```bash
677
+ pnpm add @karibsen/eponyme-captcha
678
+ ```
679
+
680
+ ```ts
681
+ export default defineNuxtConfig({
682
+ modules: ['@karibsen/eponyme', '@karibsen/eponyme-captcha'],
683
+ eponymeCaptcha: { siteKey: '<your-turnstile-site-key>' },
684
+ })
685
+ ```
686
+
687
+ Render the widget and bind its token; `useEponymeForm` sends it alongside the fields and
688
+ the server verifies it before validating anything:
689
+
690
+ ```vue
691
+ <script setup lang="ts">
692
+ const { captchaToken, requiresCaptcha, submit } = useEponymeForm('contact')
693
+ </script>
694
+
695
+ <template>
696
+ <NuxtTurnstile v-if="requiresCaptcha" v-model="captchaToken" />
697
+ </template>
698
+ ```
699
+
700
+ **It fails closed.** Without an adapter, a form declaring `captcha: true` answers `500`
701
+ rather than accepting submissions unprotected. A rejected token answers `422` with a
702
+ `_form` error; the provider's reason stays in the server log, since telling a bot which
703
+ check it failed only helps it.
704
+
705
+ Writing an adapter for another provider means supplying a `name` and an
706
+ `async verify(token, { ip })` returning `{ success, reason? }`, then pointing the
707
+ `#eponyme/captcha` alias at it.
708
+
709
+ Rate limiting and file uploads are not implemented yet.
710
+
711
+ ## Hooks
712
+
713
+ Eponyme emits Nitro hooks around every content write and form submission. Listen to
714
+ them from a server plugin:
715
+
716
+ ```ts
717
+ // server/plugins/eponyme.ts
718
+ export default defineNitroPlugin((nitroApp) => {
719
+ nitroApp.hooks.hook('eponyme:entry:published', async ({ name, collection, data }) => {
720
+ await purgeCdnCache(collection ? `/${collection.name}/${collection.slug}` : `/${name}`)
721
+ })
722
+
723
+ nitroApp.hooks.hook('eponyme:form:submitted', async ({ form, data, id }) => {
724
+ if (form === 'contact') await sendEmail(data)
725
+ })
726
+ })
727
+ ```
728
+
729
+ | Hook | When |
730
+ |---|---|
731
+ | `eponyme:entry:beforeSave` | Before a draft save or a publication |
732
+ | `eponyme:entry:saved` | After a draft save |
733
+ | `eponyme:entry:published` | After a publication |
734
+ | `eponyme:entry:restored` | After a history version was restored |
735
+ | `eponyme:entry:trashed` | After a collection entry was moved to the trash |
736
+ | `eponyme:entry:untrashed` | After a collection entry was taken back out of the trash |
737
+ | `eponyme:entry:purged` | After a collection entry and its history were deleted for good |
738
+ | `eponyme:form:beforeSubmit` | Before a managed submission is stored |
739
+ | `eponyme:form:submitted` | After a managed submission was stored |
740
+
741
+ The `before` hooks can **reject or amend** the operation. Throwing rejects it with a
742
+ `422` carrying your message, which is the way to enforce a rule the schema cannot
743
+ express. Mutating `context.data` changes what gets written:
744
+
745
+ ```ts
746
+ nitroApp.hooks.hook('eponyme:entry:beforeSave', ({ data, collection }) => {
747
+ if (collection?.name === 'articles' && !data.excerpt) throw new Error('An excerpt is required.')
748
+ data.updatedBy = 'automation'
749
+ })
750
+ ```
751
+
752
+ `eponyme:form:beforeSubmit` runs **after** validation, so a listener never sees a
753
+ payload the schema would have rejected.
754
+
755
+ The other hooks are notifications: the write already happened, so a listener that
756
+ throws is logged and swallowed. A failing webhook must not report a successful save as
757
+ an error the editor cannot act on.
758
+
759
+ Entries belonging to a collection carry `collection: { name, slug }`, so a listener can
760
+ branch without re-parsing the entry name. The three trash hooks only ever concern
761
+ collection entries, so their `collection` is always present; they carry no content, since
762
+ a trash move leaves the entry itself untouched.
616
763
 
617
764
  ## Current status
618
765
 
619
- Eponyme is at version `0.1.0`. It is ready for controlled projects and production pilots. A few workflows still need hardening before a broad public release:
766
+ Eponyme is at version `0.3.0`. It is ready for controlled projects and production pilots. A few workflows still need hardening before a broad public release:
620
767
 
621
- - Dynamic preview routes for collection entries
622
- - Reliable preview version switching and cache invalidation
623
- - Client revision tokens for long-running concurrent edits
624
- - Recoverable deletion
625
- - Public form submissions
768
+ - 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
769
+ - Retention controls for the trash: entries stay there until someone empties it
770
+ - Rate limiting and file uploads for public forms
771
+ - Filtering, export and retention controls for stored submissions
772
+ - Pagination or a sitemap index beyond 50,000 URLs
626
773
 
627
774
  ## Development
628
775
 
package/dist/eponyme.mjs CHANGED
@@ -31,7 +31,8 @@ function form(options) {
31
31
  // Storing a submission has to be an explicit decision, so `custom` is the default.
32
32
  submission: { mode: options.submission?.mode ?? "custom" },
33
33
  honeypot,
34
- maxBodyBytes: options.maxBodyBytes ?? DEFAULT_MAX_BODY_BYTES
34
+ maxBodyBytes: options.maxBodyBytes ?? DEFAULT_MAX_BODY_BYTES,
35
+ captcha: options.captcha ?? false
35
36
  };
36
37
  }
37
38
 
package/dist/module.d.mts CHANGED
@@ -20,6 +20,12 @@ interface ModuleOptions {
20
20
  /** Fixed lifetime of an authenticated session. */
21
21
  sessionDurationDays?: number;
22
22
  };
23
+ /**
24
+ * How long a server instance may reuse content it has already read, in seconds.
25
+ * A save clears what it changed on its own instance, so this only bounds how long
26
+ * another instance can still serve the previous content. `0` disables the cache.
27
+ */
28
+ cacheSeconds?: number;
23
29
  }
24
30
  declare const _default: _nuxt_schema.NuxtModule<ModuleOptions, ModuleOptions, false>;
25
31
 
package/dist/module.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@karibsen/eponyme",
3
3
  "configKey": "eponyme",
4
- "version": "0.1.1",
4
+ "version": "0.3.0",
5
5
  "builder": {
6
6
  "@nuxt/module-builder": "1.0.2",
7
7
  "unbuild": "3.6.1"
package/dist/module.mjs CHANGED
@@ -1,4 +1,4 @@
1
- import { defineNuxtModule, useLogger, createResolver, installModule, findPath, addImports, addImportsDir, addServerImports, addRouteMiddleware, addTypeTemplate, addServerHandler, addServerPlugin } from '@nuxt/kit';
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';
@@ -15,6 +15,47 @@ const module$1 = defineNuxtModule({
15
15
  colorPresets: [],
16
16
  auth: {
17
17
  sessionDurationDays: 7
18
+ },
19
+ cacheSeconds: 5
20
+ },
21
+ moduleDependencies: {
22
+ "@nuxt/icon": {
23
+ overrides: {
24
+ serverBundle: { collections: ["mingcute"] },
25
+ clientBundle: {
26
+ icons: [
27
+ "mingcute:history-anticlockwise-line",
28
+ "mingcute:eye-2-line",
29
+ "mingcute:layout-leftbar-close-line",
30
+ "mingcute:layout-leftbar-open-line",
31
+ "mingcute:down-small-line",
32
+ "mingcute:search-line",
33
+ "mingcute:close-line",
34
+ "mingcute:arrow-right-line",
35
+ "mingcute:align-arrow-right-line",
36
+ "mingcute:link-2-line",
37
+ "mingcute:settings-3-line",
38
+ "mingcute:code-line",
39
+ "mingcute:refresh-2-line",
40
+ "mingcute:external-link-line",
41
+ "mingcute:paragraph-line",
42
+ "mingcute:heading-2-line",
43
+ "mingcute:heading-3-line",
44
+ "mingcute:bold-line",
45
+ "mingcute:italic-line",
46
+ "mingcute:strikethrough-line",
47
+ "mingcute:link-line",
48
+ "mingcute:pic-line",
49
+ "mingcute:list-check-line",
50
+ "mingcute:list-ordered-line",
51
+ "mingcute:quote-left-line",
52
+ "mingcute:back-2-line",
53
+ "mingcute:forward-2-line",
54
+ "mingcute:download-2-line",
55
+ "mingcute:upload-2-line"
56
+ ]
57
+ }
58
+ }
18
59
  }
19
60
  },
20
61
  async setup(options, nuxt) {
@@ -30,41 +71,9 @@ const module$1 = defineNuxtModule({
30
71
  nuxt.options.runtimeConfig.eponymeAuth = {
31
72
  sessionDurationDays: options.auth?.sessionDurationDays ?? 7
32
73
  };
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);
74
+ nuxt.options.runtimeConfig.eponymeContent = {
75
+ cacheSeconds: options.cacheSeconds ?? 5
76
+ };
68
77
  const preparing = Boolean(nuxt.options._prepare);
69
78
  if (!options.prismaClient) {
70
79
  if (!preparing) throw new Error(`${pc.red("[Eponyme]")} eponyme.prismaClient is required.`);
@@ -125,6 +134,7 @@ Note that '~/' points at ${nuxt.options.srcDir}, so a server module needs '~~/'.
125
134
  nuxt.options.alias["#eponyme/prisma"] = prismaPath;
126
135
  const variablesPath = await findPath(resolve(nuxt.options.rootDir, "eponyme.variables.ts"));
127
136
  nuxt.options.alias["#eponyme/variables"] = variablesPath ?? resolver.resolve("./runtime/utils/empty-variables");
137
+ nuxt.options.alias["#eponyme/captcha"] ??= resolver.resolve("./runtime/utils/no-captcha");
128
138
  addTypeTemplate({
129
139
  filename: "types/eponyme-config.d.ts",
130
140
  getContents: () => `declare module '#eponyme/config' {
@@ -135,17 +145,26 @@ declare module '#eponyme/variables' {
135
145
  const variables: typeof import(${JSON.stringify(nuxt.options.alias["#eponyme/variables"])})['default']
136
146
  export default variables
137
147
  }
148
+ declare module '#eponyme/captcha' {
149
+ const verifier: import('${resolver.resolve("./runtime/types/captcha")}').EponymeCaptchaVerifier
150
+ export default verifier
151
+ }
138
152
  `
139
153
  });
140
154
  addServerHandler({ route: "/api/eponyme/**", handler: resolver.resolve("./runtime/server/api/eponyme/[name].get") });
141
155
  addServerHandler({ route: "/api/eponyme/**", method: "patch", handler: resolver.resolve("./runtime/server/api/eponyme/[name].patch") });
142
156
  addServerHandler({ route: "/api/eponyme-statuses", handler: resolver.resolve("./runtime/server/api/eponyme-statuses.get") });
143
157
  addServerHandler({ route: "/api/eponyme-sitemap", handler: resolver.resolve("./runtime/server/api/eponyme-sitemap.get") });
158
+ addServerHandler({ route: "/api/eponyme-export", handler: resolver.resolve("./runtime/server/api/eponyme-export.get") });
159
+ addServerHandler({ route: "/api/eponyme-import", method: "post", handler: resolver.resolve("./runtime/server/api/eponyme-import.post") });
144
160
  addServerHandler({ route: "/api/eponyme-history/**", handler: resolver.resolve("./runtime/server/api/eponyme-history/[path].get") });
145
161
  addServerHandler({ route: "/api/eponyme-history/**", method: "patch", handler: resolver.resolve("./runtime/server/api/eponyme-history/[path].patch") });
146
162
  addServerHandler({ route: "/api/eponyme-collections/**", handler: resolver.resolve("./runtime/server/api/eponyme-collections/[path].get") });
147
163
  addServerHandler({ route: "/api/eponyme-collections/**", method: "post", handler: resolver.resolve("./runtime/server/api/eponyme-collections/[path].post") });
148
164
  addServerHandler({ route: "/api/eponyme-collections/**", method: "delete", handler: resolver.resolve("./runtime/server/api/eponyme-collections/[path].delete") });
165
+ addServerHandler({ route: "/api/eponyme-trash/**", handler: resolver.resolve("./runtime/server/api/eponyme-trash/[path].get") });
166
+ addServerHandler({ route: "/api/eponyme-trash/**", method: "patch", handler: resolver.resolve("./runtime/server/api/eponyme-trash/[path].patch") });
167
+ addServerHandler({ route: "/api/eponyme-trash/**", method: "delete", handler: resolver.resolve("./runtime/server/api/eponyme-trash/[path].delete") });
149
168
  addServerHandler({ route: "/api/eponyme-forms/**", handler: resolver.resolve("./runtime/server/api/eponyme-forms/[path].get") });
150
169
  addServerHandler({ route: "/api/eponyme-forms/**", method: "post", handler: resolver.resolve("./runtime/server/api/eponyme-forms/[path].post") });
151
170
  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(`Delete \u201C${entry.title}\u201D? This cannot be undone.`)) return;
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(void 0, { dateStyle: "medium", timeStyle: "short" }).format(new Date(value)) : "";
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
- <EPButton
117
- v-if="auth.canEdit.value"
118
- variant="primary"
119
- @click="createOpen = true"
120
- >
121
- + New {{ humanize(name.split("/").at(-1) || "entry").replace(/s$/, "") }}
122
- </EPButton>
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">/{{ entry.slug }}<template v-if="entry.updatedAt"> · {{ formatDate(entry.updatedAt) }}</template></span>
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
- cell: (info) => truncate(info.getValue())
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(void 0, { dateStyle: "medium", timeStyle: "short" }).format(new Date(value));
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>