@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.
Files changed (61) hide show
  1. package/README.md +126 -12
  2. package/dist/module.json +1 -1
  3. package/dist/module.mjs +46 -36
  4. package/dist/runtime/components/editor/EponymeCollectionPage.vue +80 -18
  5. package/dist/runtime/components/editor/EponymeForm.vue +2 -2
  6. package/dist/runtime/components/editor/EponymeFormPage.vue +7 -3
  7. package/dist/runtime/components/editor/EponymeSidebar.vue +24 -32
  8. package/dist/runtime/components/editor/EponymeSidebarTree.d.vue.ts +2 -0
  9. package/dist/runtime/components/editor/EponymeSidebarTree.vue +3 -1
  10. package/dist/runtime/components/editor/EponymeSidebarTree.vue.d.ts +2 -0
  11. package/dist/runtime/components/fields/RichTextField.vue +51 -10
  12. package/dist/runtime/components/ui/EPLinkDialog.d.vue.ts +15 -0
  13. package/dist/runtime/components/ui/EPLinkDialog.vue +142 -0
  14. package/dist/runtime/components/ui/EPLinkDialog.vue.d.ts +15 -0
  15. package/dist/runtime/components/ui/EPLinkEditor.vue +10 -91
  16. package/dist/runtime/composables/useEponyme.js +5 -3
  17. package/dist/runtime/composables/useEponymeCollection.js +3 -2
  18. package/dist/runtime/composables/useEponymeForm.js +4 -1
  19. package/dist/runtime/composables/useEponymeHistory.d.ts +2 -2
  20. package/dist/runtime/composables/useEponymeHistory.js +2 -1
  21. package/dist/runtime/pages/EponymeDetailPage.vue +1 -1
  22. package/dist/runtime/pages/EponymeIndexPage.vue +173 -10
  23. package/dist/runtime/server/api/eponyme/[name].patch.js +17 -1
  24. package/dist/runtime/server/api/eponyme-collections/[path].delete.js +7 -2
  25. package/dist/runtime/server/api/eponyme-export.get.d.ts +2 -0
  26. package/dist/runtime/server/api/eponyme-export.get.js +11 -0
  27. package/dist/runtime/server/api/eponyme-forms/[path].post.js +14 -1
  28. package/dist/runtime/server/api/eponyme-history/[path].patch.d.ts +1 -1
  29. package/dist/runtime/server/api/eponyme-history/[path].patch.js +14 -2
  30. package/dist/runtime/server/api/eponyme-import.post.d.ts +2 -0
  31. package/dist/runtime/server/api/eponyme-import.post.js +31 -0
  32. package/dist/runtime/server/api/eponyme-trash/[path].delete.d.ts +4 -0
  33. package/dist/runtime/server/api/eponyme-trash/[path].delete.js +16 -0
  34. package/dist/runtime/server/api/eponyme-trash/[path].get.d.ts +2 -0
  35. package/dist/runtime/server/api/eponyme-trash/[path].get.js +11 -0
  36. package/dist/runtime/server/api/eponyme-trash/[path].patch.d.ts +4 -0
  37. package/dist/runtime/server/api/eponyme-trash/[path].patch.js +16 -0
  38. package/dist/runtime/server/services/eponyme-form-store.d.ts +1 -0
  39. package/dist/runtime/server/services/eponyme-store.d.ts +75 -2
  40. package/dist/runtime/server/services/eponyme-store.js +232 -7
  41. package/dist/runtime/server/utils/eponyme-entry.d.ts +9 -0
  42. package/dist/runtime/server/utils/eponyme-entry.js +7 -0
  43. package/dist/runtime/server/utils/eponyme-hooks.d.ts +16 -0
  44. package/dist/runtime/server/utils/eponyme-hooks.js +24 -0
  45. package/dist/runtime/types/field.d.ts +1 -0
  46. package/dist/runtime/types/hooks.d.ts +67 -0
  47. package/dist/runtime/types/hooks.js +0 -0
  48. package/dist/runtime/types/nuxt.d.ts +10 -0
  49. package/dist/runtime/utils/build-navigation-tree.d.ts +22 -0
  50. package/dist/runtime/utils/build-navigation-tree.js +30 -0
  51. package/dist/runtime/utils/date-locale.d.ts +10 -0
  52. package/dist/runtime/utils/date-locale.js +1 -0
  53. package/dist/runtime/utils/downloadable-link.d.ts +2 -0
  54. package/dist/runtime/utils/downloadable-link.js +35 -0
  55. package/dist/runtime/utils/filter-navigation-tree.d.ts +7 -0
  56. package/dist/runtime/utils/filter-navigation-tree.js +39 -0
  57. package/dist/runtime/utils/get-field-default-value.js +1 -1
  58. package/dist/runtime/utils/hydration-cache.d.ts +11 -0
  59. package/dist/runtime/utils/hydration-cache.js +3 -0
  60. package/dist/runtime/utils/validate-eponyme-data.js +1 -1
  61. 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,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
- Rate limiting, CAPTCHA support and file uploads are not implemented yet.
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.0`. It is ready for controlled projects and production pilots. A few workflows still need hardening before a broad public release:
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
- - 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
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
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@karibsen/eponyme",
3
3
  "configKey": "eponyme",
4
- "version": "0.1.1",
4
+ "version": "0.2.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';
@@ -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(`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>
@@ -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
- const root = [];
91
- function addPath(pathValue, finalKind, configuredLabel) {
92
- const parts = pathValue.split("/");
93
- let children = root;
94
- for (let index = 0; index < parts.length; index++) {
95
- const part = parts[index];
96
- const path = parts.slice(0, index + 1).join("/");
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="navigationTree"
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
  };