@karibsen/eponyme 0.1.0 → 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 (84) hide show
  1. package/README.md +198 -16
  2. package/dist/eponyme.d.mts +22 -2
  3. package/dist/eponyme.mjs +5 -1
  4. package/dist/module.d.mts +3 -2
  5. package/dist/module.json +1 -1
  6. package/dist/module.mjs +73 -43
  7. package/dist/runtime/components/editor/EponymeCollectionPage.vue +81 -19
  8. package/dist/runtime/components/editor/EponymeForm.vue +3 -3
  9. package/dist/runtime/components/editor/EponymeFormPage.vue +7 -3
  10. package/dist/runtime/components/editor/EponymeSidebar.vue +25 -33
  11. package/dist/runtime/components/editor/EponymeSidebarTree.d.vue.ts +2 -0
  12. package/dist/runtime/components/editor/EponymeSidebarTree.vue +3 -1
  13. package/dist/runtime/components/editor/EponymeSidebarTree.vue.d.ts +2 -0
  14. package/dist/runtime/components/fields/RichTextField.vue +80 -8
  15. package/dist/runtime/components/ui/EPLinkDialog.d.vue.ts +15 -0
  16. package/dist/runtime/components/ui/EPLinkDialog.vue +142 -0
  17. package/dist/runtime/components/ui/EPLinkDialog.vue.d.ts +15 -0
  18. package/dist/runtime/components/ui/EPLinkEditor.vue +10 -91
  19. package/dist/runtime/composables/useEponyme.d.ts +2 -0
  20. package/dist/runtime/composables/useEponyme.js +7 -5
  21. package/dist/runtime/composables/useEponymeCollection.d.ts +12 -2
  22. package/dist/runtime/composables/useEponymeCollection.js +15 -5
  23. package/dist/runtime/composables/useEponymeForm.js +4 -1
  24. package/dist/runtime/composables/useEponymeHistory.d.ts +2 -2
  25. package/dist/runtime/composables/useEponymeHistory.js +2 -1
  26. package/dist/runtime/composables/useEponymeVariables.d.ts +3 -0
  27. package/dist/runtime/composables/useEponymeVariables.js +5 -0
  28. package/dist/runtime/pages/EponymeDetailPage.vue +1 -1
  29. package/dist/runtime/pages/EponymeIndexPage.vue +173 -10
  30. package/dist/runtime/server/api/eponyme/[name].get.d.ts +5 -1
  31. package/dist/runtime/server/api/eponyme/[name].get.js +3 -1
  32. package/dist/runtime/server/api/eponyme/[name].patch.js +17 -1
  33. package/dist/runtime/server/api/eponyme-collections/[path].delete.js +7 -2
  34. package/dist/runtime/server/api/eponyme-collections/[path].get.d.ts +1 -3
  35. package/dist/runtime/server/api/eponyme-collections/[path].get.js +31 -4
  36. package/dist/runtime/server/api/eponyme-export.get.d.ts +2 -0
  37. package/dist/runtime/server/api/eponyme-export.get.js +11 -0
  38. package/dist/runtime/server/api/eponyme-forms/[path].post.js +14 -1
  39. package/dist/runtime/server/api/eponyme-history/[path].patch.d.ts +1 -1
  40. package/dist/runtime/server/api/eponyme-history/[path].patch.js +14 -2
  41. package/dist/runtime/server/api/eponyme-import.post.d.ts +2 -0
  42. package/dist/runtime/server/api/eponyme-import.post.js +31 -0
  43. package/dist/runtime/server/api/eponyme-trash/[path].delete.d.ts +4 -0
  44. package/dist/runtime/server/api/eponyme-trash/[path].delete.js +16 -0
  45. package/dist/runtime/server/api/eponyme-trash/[path].get.d.ts +2 -0
  46. package/dist/runtime/server/api/eponyme-trash/[path].get.js +11 -0
  47. package/dist/runtime/server/api/eponyme-trash/[path].patch.d.ts +4 -0
  48. package/dist/runtime/server/api/eponyme-trash/[path].patch.js +16 -0
  49. package/dist/runtime/server/services/eponyme-form-store.d.ts +1 -0
  50. package/dist/runtime/server/services/eponyme-store.d.ts +91 -3
  51. package/dist/runtime/server/services/eponyme-store.js +272 -9
  52. package/dist/runtime/server/utils/eponyme-entry.d.ts +9 -0
  53. package/dist/runtime/server/utils/eponyme-entry.js +7 -0
  54. package/dist/runtime/server/utils/eponyme-hooks.d.ts +16 -0
  55. package/dist/runtime/server/utils/eponyme-hooks.js +24 -0
  56. package/dist/runtime/server/utils/eponyme-variables.d.ts +5 -0
  57. package/dist/runtime/server/utils/eponyme-variables.js +5 -0
  58. package/dist/runtime/types/eponyme-config.d.ts +7 -0
  59. package/dist/runtime/types/field.d.ts +1 -0
  60. package/dist/runtime/types/hooks.d.ts +67 -0
  61. package/dist/runtime/types/hooks.js +0 -0
  62. package/dist/runtime/types/index.d.ts +1 -0
  63. package/dist/runtime/types/index.js +1 -0
  64. package/dist/runtime/types/nuxt.d.ts +10 -0
  65. package/dist/runtime/types/variables.d.ts +17 -0
  66. package/dist/runtime/types/variables.js +0 -0
  67. package/dist/runtime/utils/build-navigation-tree.d.ts +22 -0
  68. package/dist/runtime/utils/build-navigation-tree.js +30 -0
  69. package/dist/runtime/utils/date-locale.d.ts +10 -0
  70. package/dist/runtime/utils/date-locale.js +1 -0
  71. package/dist/runtime/utils/downloadable-link.d.ts +2 -0
  72. package/dist/runtime/utils/downloadable-link.js +35 -0
  73. package/dist/runtime/utils/empty-variables.d.ts +2 -0
  74. package/dist/runtime/utils/empty-variables.js +1 -0
  75. package/dist/runtime/utils/filter-navigation-tree.d.ts +7 -0
  76. package/dist/runtime/utils/filter-navigation-tree.js +39 -0
  77. package/dist/runtime/utils/get-field-default-value.js +1 -1
  78. package/dist/runtime/utils/hydration-cache.d.ts +11 -0
  79. package/dist/runtime/utils/hydration-cache.js +3 -0
  80. package/dist/runtime/utils/validate-eponyme-data.js +1 -1
  81. package/dist/runtime/utils/variables.d.ts +18 -0
  82. package/dist/runtime/utils/variables.js +52 -0
  83. package/dist/types.d.mts +2 -2
  84. package/package.json +3 -1
@@ -0,0 +1,15 @@
1
+ import type { UrlValue } from '../../types/index.js';
2
+ type __VLS_Props = {
3
+ open: boolean;
4
+ modelValue?: unknown;
5
+ placeholder?: string;
6
+ };
7
+ declare const __VLS_export: import("vue").DefineComponent<__VLS_Props, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {
8
+ "update:modelValue": (value: UrlValue) => any;
9
+ "update:open": (value: boolean) => any;
10
+ }, string, import("vue").PublicProps, Readonly<__VLS_Props> & Readonly<{
11
+ "onUpdate:modelValue"?: ((value: UrlValue) => any) | undefined;
12
+ "onUpdate:open"?: ((value: boolean) => any) | undefined;
13
+ }>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
14
+ declare const _default: typeof __VLS_export;
15
+ export default _default;
@@ -1,10 +1,7 @@
1
1
  <script setup>
2
2
  import { computed, ref } from "vue";
3
3
  import EPButton from "./EPButton.vue";
4
- import EPDialog from "./EPDialog.vue";
5
- import EPInputText from "./EPInputText.vue";
6
- import EPRadioButton from "./EPRadioButton.vue";
7
- import EPSwitch from "./EPSwitch.vue";
4
+ import EPLinkDialog from "./EPLinkDialog.vue";
8
5
  import EPTooltip from "./EPTooltip.vue";
9
6
  const props = defineProps({
10
7
  id: { type: String, required: true },
@@ -16,47 +13,24 @@ const props = defineProps({
16
13
  });
17
14
  const emit = defineEmits(["update:modelValue"]);
18
15
  const dialogOpen = ref(false);
19
- const draftHref = ref("");
20
- const draftType = ref("external");
21
- const draftOpenInNewTab = ref(false);
22
16
  const value = computed(() => {
23
17
  if (props.modelValue && typeof props.modelValue === "object" && !Array.isArray(props.modelValue)) {
24
18
  const link = props.modelValue;
25
19
  return {
26
20
  href: typeof link.href === "string" ? link.href : "",
27
21
  type: link.type === "internal" ? "internal" : "external",
28
- openInNewTab: link.openInNewTab === true
22
+ openInNewTab: link.openInNewTab === true,
23
+ download: link.download === true
29
24
  };
30
25
  }
31
- return { href: "", type: "external", openInNewTab: false };
26
+ return { href: "", type: "external", openInNewTab: false, download: false };
32
27
  });
33
- const typeOptions = [
34
- { label: "Internal link", value: "internal" },
35
- { label: "External link", value: "external" }
36
- ];
37
28
  function openDialog() {
38
- draftHref.value = value.value.href;
39
- draftType.value = value.value.type;
40
- draftOpenInNewTab.value = value.value.openInNewTab;
41
29
  dialogOpen.value = true;
42
30
  }
43
31
  function updateDialogOpen(open) {
44
32
  dialogOpen.value = open;
45
33
  }
46
- function updateType(type) {
47
- draftType.value = type === "internal" ? "internal" : "external";
48
- }
49
- function updateHref(href) {
50
- draftHref.value = String(href);
51
- }
52
- function save() {
53
- emit("update:modelValue", {
54
- href: draftHref.value.trim(),
55
- type: draftType.value,
56
- openInNewTab: draftOpenInNewTab.value
57
- });
58
- dialogOpen.value = false;
59
- }
60
34
  function openLink() {
61
35
  if (!value.value.href || typeof window === "undefined") return;
62
36
  const href = value.value.type === "internal" ? new URL(value.value.href, window.location.origin).href : value.value.href;
@@ -80,7 +54,7 @@ function openLink() {
80
54
  {{ value.href || "No link configured" }}
81
55
  </p>
82
56
  <p class="ep:mt-0.5 ep:mb-0 ep:text-[11px] ep:text-muted-ep">
83
- {{ value.type === "internal" ? "Internal" : "External" }} · {{ value.openInNewTab ? "New tab" : "Same tab" }}
57
+ {{ value.type === "internal" ? "Internal" : "External" }} · {{ value.download ? "Download" : value.openInNewTab ? "New tab" : "Same tab" }}
84
58
  </p>
85
59
  </div>
86
60
  <EPTooltip content="Open link">
@@ -104,66 +78,11 @@ function openLink() {
104
78
  </EPTooltip>
105
79
  </div>
106
80
 
107
- <EPDialog
81
+ <EPLinkDialog
108
82
  :open="dialogOpen"
109
- title="Configure link"
110
- description="Choose where the link points and how it should open."
83
+ :model-value="value"
84
+ :placeholder="placeholder"
111
85
  @update:open="updateDialogOpen"
112
- >
113
- <div class="ep:grid ep:gap-5">
114
- <div>
115
- <p class="ep:mt-0 ep:mb-2 ep:text-sm ep:font-medium ep:text-white">
116
- Link type
117
- </p>
118
- <EPRadioButton
119
- :model-value="draftType"
120
- :options="typeOptions"
121
- @update:model-value="updateType"
122
- />
123
- </div>
124
-
125
- <label class="ep:block">
126
- <span class="ep:mb-2 ep:block ep:text-sm ep:font-medium ep:text-white">Destination</span>
127
- <EPInputText
128
- :model-value="draftHref"
129
- :type="draftType === 'external' ? 'url' : 'text'"
130
- :placeholder="draftType === 'internal' ? '/contact' : placeholder || 'https://example.com'"
131
- @update:model-value="updateHref"
132
- />
133
- <span class="ep:mt-1.5 ep:block ep:text-xs ep:text-muted-ep">
134
- {{ draftType === "internal" ? "Use a path beginning with / or an anchor beginning with #." : "Use a complete HTTP(S) address." }}
135
- </span>
136
- </label>
137
-
138
- <div class="ep:flex ep:items-center ep:justify-between ep:gap-4 ep:rounded-xl ep:bg-selected-ep/40 ep:px-4 ep:py-3">
139
- <div>
140
- <p class="ep:m-0 ep:text-sm ep:font-medium ep:text-white">
141
- Open in new tab
142
- </p>
143
- <p class="ep:mt-1 ep:mb-0 ep:text-xs ep:text-muted-ep">
144
- Keep the current page open when visitors follow this link.
145
- </p>
146
- </div>
147
- <EPSwitch
148
- :model-value="draftOpenInNewTab"
149
- @update:model-value="draftOpenInNewTab = $event"
150
- />
151
- </div>
152
-
153
- <div class="ep:flex ep:justify-end ep:gap-2">
154
- <EPButton
155
- variant="ghost"
156
- @click="dialogOpen = false"
157
- >
158
- Cancel
159
- </EPButton>
160
- <EPButton
161
- variant="primary"
162
- @click="save"
163
- >
164
- Apply link
165
- </EPButton>
166
- </div>
167
- </div>
168
- </EPDialog>
86
+ @update:model-value="emit('update:modelValue', $event)"
87
+ />
169
88
  </template>
@@ -6,6 +6,8 @@ import type { ValidationErrors } from '../utils/validate-eponyme-data.js';
6
6
  type ConfigEponymeName = EponymeName<typeof eponymeConfig>;
7
7
  export interface UseEponymeOptions {
8
8
  version?: EponymeVersionSelector;
9
+ /** Keep `{{ variables }}` unresolved. The dashboard editor needs the source text. */
10
+ raw?: boolean;
9
11
  }
10
12
  /** Read and save a configured eponyme through the public or draft API. */
11
13
  export declare function useEponyme<const Name extends ConfigEponymeName>(name: Name, options?: UseEponymeOptions): {
@@ -1,6 +1,7 @@
1
1
  import { clearNuxtData, useAsyncData, useRequestFetch, useRoute } from "#app";
2
2
  import { computed, ref } from "vue";
3
3
  import { readPreviewQuery, readPreviewVersion } from "../utils/preview.js";
4
+ import { cacheDuringHydrationOnly } from "../utils/hydration-cache.js";
4
5
  export function useEponyme(name, options = {}) {
5
6
  const route = useRoute();
6
7
  const preview = readPreviewQuery(route.query);
@@ -8,10 +9,11 @@ export function useEponyme(name, options = {}) {
8
9
  const errors = ref({});
9
10
  const saving = ref(false);
10
11
  const requestFetch = useRequestFetch();
12
+ const cacheKey = `eponyme:${name}:${version}${options.raw ? ":raw" : ""}`;
11
13
  const { data: response, pending: loading, error, refresh: load } = useAsyncData(
12
- `eponyme:${name}:${version}`,
13
- () => requestFetch(`/api/eponyme/${name}`, { query: { version }, cache: "no-store" }),
14
- { getCachedData: () => void 0 }
14
+ cacheKey,
15
+ () => requestFetch(`/api/eponyme/${name}`, { query: { version, raw: options.raw ? 1 : void 0 }, cache: "no-store" }),
16
+ { getCachedData: cacheDuringHydrationOnly }
15
17
  );
16
18
  const data = computed(() => response.value?.data);
17
19
  const status = computed(() => response.value?.status ?? "published");
@@ -31,12 +33,12 @@ export function useEponyme(name, options = {}) {
31
33
  });
32
34
  response.value = next;
33
35
  if (action === "publish") {
34
- clearNuxtData((key) => key.startsWith(`eponyme:${name}:`) && key !== `eponyme:${name}:${version}`);
36
+ clearNuxtData((key) => key.startsWith(`eponyme:${name}:`) && key !== cacheKey);
35
37
  const parts = String(name).split("/");
36
38
  const slug = parts.pop();
37
39
  const collectionName = parts.join("/");
38
40
  if (collectionName && slug) {
39
- clearNuxtData(`eponyme:collection:public:${collectionName}`);
41
+ clearNuxtData((key) => key.startsWith(`eponyme:collection:public:${collectionName}`));
40
42
  clearNuxtData((key) => key.startsWith(`eponyme:collection-entry:${collectionName}:${slug}:`));
41
43
  }
42
44
  }
@@ -1,10 +1,20 @@
1
1
  import type { ComputedRef, Ref } from 'vue';
2
2
  import type eponymeConfig from '#eponyme/config';
3
3
  import type { EponymeCollectionDataByName, EponymeCollectionName } from '../types/index.js';
4
- import type { EponymeCollectionEntry, EponymeStatus, EponymeVersionSelector } from '../server/services/eponyme-store.js';
4
+ import type { EponymeCollectionEntry, EponymeSortDirection, EponymeStatus, EponymeVersionSelector } from '../server/services/eponyme-store.js';
5
5
  type ConfigCollectionName = EponymeCollectionName<typeof eponymeConfig>;
6
+ /** Sortable keys: the entry metadata plus every field of the collection. */
7
+ export type EponymeCollectionSortKey<Name extends ConfigCollectionName> = 'updatedAt' | 'publishedAt' | 'title' | 'slug' | (keyof EponymeCollectionDataByName<typeof eponymeConfig, Name> & string);
8
+ export interface UseEponymeCollectionOptions<Name extends ConfigCollectionName> {
9
+ take?: number;
10
+ skip?: number;
11
+ orderBy?: EponymeCollectionSortKey<Name>;
12
+ order?: EponymeSortDirection;
13
+ }
6
14
  export interface UseEponymeCollectionResult<Data extends Record<string, unknown>> {
7
15
  entries: ComputedRef<EponymeCollectionEntry<Data>[]>;
16
+ /** Matching entries before `take` and `skip`, for building a pager. */
17
+ total: ComputedRef<number>;
8
18
  pending: Ref<boolean>;
9
19
  error: Ref<Error | null | undefined>;
10
20
  refresh: () => Promise<void>;
@@ -22,6 +32,6 @@ export interface UseEponymeCollectionEntryResult<Data extends Record<string, unk
22
32
  error: Ref<Error | null | undefined>;
23
33
  refresh: () => Promise<void>;
24
34
  }
25
- export declare function useEponymeCollection<const Name extends ConfigCollectionName>(name: Name): UseEponymeCollectionResult<EponymeCollectionDataByName<typeof eponymeConfig, Name>>;
35
+ export declare function useEponymeCollection<const Name extends ConfigCollectionName>(name: Name, options?: UseEponymeCollectionOptions<Name>): UseEponymeCollectionResult<EponymeCollectionDataByName<typeof eponymeConfig, Name>>;
26
36
  export declare function useEponymeCollectionEntry<const Name extends ConfigCollectionName>(name: Name, slug: string, options?: UseEponymeCollectionEntryOptions): UseEponymeCollectionEntryResult<EponymeCollectionDataByName<typeof eponymeConfig, Name>>;
27
37
  export {};
@@ -2,17 +2,27 @@ import { useAsyncData, useRequestFetch, useRoute } from "#app";
2
2
  import { useEventListener } from "@vueuse/core";
3
3
  import { computed } from "vue";
4
4
  import { readPreviewQuery, readPreviewVersion } from "../utils/preview.js";
5
- export function useEponymeCollection(name) {
5
+ import { cacheDuringHydrationOnly } from "../utils/hydration-cache.js";
6
+ export function useEponymeCollection(name, options = {}) {
6
7
  const requestFetch = useRequestFetch();
8
+ const query = {
9
+ take: options.take,
10
+ skip: options.skip,
11
+ orderBy: options.orderBy,
12
+ order: options.order
13
+ };
7
14
  const result = useAsyncData(
8
- `eponyme:collection:public:${name}`,
9
- () => requestFetch(`/api/eponyme-collections/${name}`, { cache: "no-store" }),
10
- { getCachedData: () => void 0 }
15
+ // The options belong in the key, otherwise two differently sorted calls to the
16
+ // same collection would share one cache entry.
17
+ `eponyme:collection:public:${name}:${options.take ?? ""}:${options.skip ?? ""}:${options.orderBy ?? ""}:${options.order ?? ""}`,
18
+ () => requestFetch(`/api/eponyme-collections/${name}`, { query, cache: "no-store" }),
19
+ { getCachedData: cacheDuringHydrationOnly }
11
20
  );
12
21
  const entries = computed(() => result.data.value?.entries ?? []);
13
22
  if (import.meta.client) useEventListener(window, "focus", () => void result.refresh());
14
23
  return {
15
24
  entries,
25
+ total: computed(() => result.data.value?.total ?? 0),
16
26
  pending: result.pending,
17
27
  error: result.error,
18
28
  refresh: async () => {
@@ -28,7 +38,7 @@ export function useEponymeCollectionEntry(name, slug, options = {}) {
28
38
  const result = useAsyncData(
29
39
  `eponyme:collection-entry:${name}:${slug}:${version}`,
30
40
  () => requestFetch(`/api/eponyme/${name}/${encodeURIComponent(slug)}`, { query: { version }, cache: "no-store" }),
31
- { getCachedData: () => void 0 }
41
+ { getCachedData: cacheDuringHydrationOnly }
32
42
  );
33
43
  if (import.meta.client) useEventListener(window, "focus", () => void result.refresh());
34
44
  return {
@@ -47,7 +47,10 @@ export function useEponymeForm(name, options = {}) {
47
47
  } else {
48
48
  if (options.onSubmit && import.meta.dev)
49
49
  console.warn(`[Eponyme] Form "${name}" is managed, so its onSubmit handler is ignored.`);
50
- await requestFetch(`/api/eponyme-forms/${name}`, { method: "POST", body: values.value });
50
+ await requestFetch(`/api/eponyme-forms/${name}`, {
51
+ method: "POST",
52
+ body: values.value
53
+ });
51
54
  }
52
55
  submitted.value = true;
53
56
  return true;
@@ -2,7 +2,7 @@ import type { EponymeHistoryEntry } from '../server/services/eponyme-store.js';
2
2
  export declare function useEponymeHistory(name: () => string): {
3
3
  history: import("vue").Ref<{
4
4
  id: number;
5
- action: import("../server/services/eponyme-store.js").EponymeAction | "restore";
5
+ action: import("../server/services/eponyme-store.js").EponymeAction | "restore" | "import";
6
6
  status: import("../server/services/eponyme-store.js").EponymeStatus;
7
7
  createdAt: string;
8
8
  user: {
@@ -11,7 +11,7 @@ export declare function useEponymeHistory(name: () => string): {
11
11
  } | null;
12
12
  }[], EponymeHistoryEntry[] | {
13
13
  id: number;
14
- action: import("../server/services/eponyme-store.js").EponymeAction | "restore";
14
+ action: import("../server/services/eponyme-store.js").EponymeAction | "restore" | "import";
15
15
  status: import("../server/services/eponyme-store.js").EponymeStatus;
16
16
  createdAt: string;
17
17
  user: {
@@ -1,5 +1,6 @@
1
1
  import { useRequestFetch } from "#app";
2
2
  import { ref } from "vue";
3
+ import { EPONYME_DATE_LOCALE } from "../utils/date-locale.js";
3
4
  export function useEponymeHistory(name) {
4
5
  const requestFetch = useRequestFetch();
5
6
  const history = ref([]);
@@ -20,5 +21,5 @@ export function useEponymeHistory(name) {
20
21
  return { history, pending, error, load };
21
22
  }
22
23
  export function formatVersionDate(value) {
23
- return new Intl.DateTimeFormat(void 0, { dateStyle: "medium", timeStyle: "short" }).format(new Date(value));
24
+ return new Intl.DateTimeFormat(EPONYME_DATE_LOCALE, { dateStyle: "medium", timeStyle: "short" }).format(new Date(value));
24
25
  }
@@ -0,0 +1,3 @@
1
+ import type { EponymeVariableSummary } from '../types/variables.js';
2
+ /** Variables an editor can insert with `{{ name }}`, for listing them in the editor. */
3
+ export declare function useEponymeVariables(): EponymeVariableSummary[];
@@ -0,0 +1,5 @@
1
+ import eponymeVariables from "#eponyme/variables";
2
+ import { summariseEponymeVariables } from "../utils/variables.js";
3
+ export function useEponymeVariables() {
4
+ return summariseEponymeVariables(eponymeVariables);
5
+ }
@@ -38,7 +38,7 @@ if (!schemas[eponymeName.value] && !collection.value && !form.value && !collecti
38
38
  <template>
39
39
  <main class="eponyme-root ep:flex ep:min-h-screen ep:flex-col ep:bg-theme-ep ep:font-sans ep:text-text-ep ep:md:flex-row">
40
40
  <EponymeSidebar :base-path="indexPath" />
41
- <div class="ep:w-full">
41
+ <div class="ep:w-full ep:min-w-0 ep:md:flex-1">
42
42
  <EponymeFolderPage
43
43
  v-if="isFolder"
44
44
  :base-path="indexPath"
@@ -1,34 +1,149 @@
1
1
  <script setup>
2
- import { useRoute, useSeoMeta } from "#app";
2
+ import { nextTick, ref } from "vue";
3
+ import { refreshNuxtData, useRequestFetch, useRoute, useSeoMeta } from "#app";
3
4
  import EponymeNavigationLink from "../components/editor/EponymeNavigationLink.vue";
4
5
  import EponymeSidebar from "../components/editor/EponymeSidebar.vue";
6
+ import EPButton from "../components/ui/EPButton.vue";
7
+ import EPDialog from "../components/ui/EPDialog.vue";
8
+ import EPToast from "../components/ui/EPToast.vue";
9
+ import { useEponymeAuth } from "../composables/useEponymeAuth";
5
10
  import { useEponymeConfig } from "../composables/useEponymeConfig";
6
11
  import { getEponymeCollections, getEponymeForms, getEponymeSchemas } from "../utils/get-eponyme-schemas";
7
12
  const config = useEponymeConfig();
8
13
  const route = useRoute();
14
+ const auth = useEponymeAuth();
15
+ const request = useRequestFetch();
9
16
  const eponymes = getEponymeSchemas(config);
10
17
  const collections = getEponymeCollections(config);
11
18
  const forms = getEponymeForms(config);
12
19
  const entries = { ...eponymes, ...collections, ...forms };
13
20
  useSeoMeta({ title: "Eponyme" });
21
+ const fileInput = ref();
22
+ const exporting = ref(false);
23
+ const importing = ref(false);
24
+ const preview = ref();
25
+ const pendingFile = ref();
26
+ const toast = ref({ open: false, title: "", description: "", variant: "success" });
14
27
  function label(name) {
15
28
  return name.replace(/[-_]/g, " ").replace(/\b\w/g, (char) => char.toUpperCase());
16
29
  }
30
+ async function showToast(variant, title, description) {
31
+ toast.value.open = false;
32
+ await nextTick();
33
+ toast.value = { open: true, variant, title, description };
34
+ }
35
+ function describeError(caught, fallback) {
36
+ const error = caught;
37
+ const mismatch = error?.data?.data?.schemaMismatch;
38
+ const message = error?.statusMessage || error?.data?.statusMessage || fallback;
39
+ return mismatch?.length ? `${message} (${mismatch.join(", ")})` : message;
40
+ }
41
+ async function exportContent() {
42
+ exporting.value = true;
43
+ try {
44
+ const file = await request("/api/eponyme-export", { cache: "no-store" });
45
+ const url = URL.createObjectURL(new Blob([JSON.stringify(file, null, 2)], { type: "application/json" }));
46
+ const link = document.createElement("a");
47
+ link.href = url;
48
+ link.download = `eponyme-export-${file.eponyme.exportedAt.slice(0, 10)}.json`;
49
+ link.click();
50
+ URL.revokeObjectURL(url);
51
+ } catch (caught) {
52
+ await showToast("error", "Export failed", describeError(caught, "Unable to export the content."));
53
+ } finally {
54
+ exporting.value = false;
55
+ }
56
+ }
57
+ async function reviewFile(event) {
58
+ const input = event.target;
59
+ const file = input.files?.[0];
60
+ input.value = "";
61
+ if (!file) return;
62
+ importing.value = true;
63
+ try {
64
+ const parsed = JSON.parse(await file.text());
65
+ preview.value = await request("/api/eponyme-import?dryRun=1", { method: "POST", body: parsed });
66
+ pendingFile.value = parsed;
67
+ } catch (caught) {
68
+ pendingFile.value = void 0;
69
+ preview.value = void 0;
70
+ await showToast("error", "Import refused", caught instanceof SyntaxError ? "This file is not valid JSON." : describeError(caught, "Unable to read this export file."));
71
+ } finally {
72
+ importing.value = false;
73
+ }
74
+ }
75
+ async function confirmImport() {
76
+ if (!pendingFile.value) return;
77
+ importing.value = true;
78
+ try {
79
+ const result = await request("/api/eponyme-import", { method: "POST", body: pendingFile.value });
80
+ closePreview();
81
+ await refreshNuxtData();
82
+ await showToast(
83
+ "success",
84
+ "Content imported",
85
+ `${result.created} created, ${result.updated} updated${result.skipped.length ? `, ${result.skipped.length} skipped` : ""}.`
86
+ );
87
+ } catch (caught) {
88
+ await showToast("error", "Import failed", describeError(caught, "Unable to import this file."));
89
+ } finally {
90
+ importing.value = false;
91
+ }
92
+ }
93
+ function closePreview() {
94
+ preview.value = void 0;
95
+ pendingFile.value = void 0;
96
+ }
17
97
  </script>
18
98
 
19
99
  <template>
20
100
  <main class="eponyme-root ep:flex ep:min-h-screen ep:flex-col ep:bg-theme-ep ep:font-sans ep:text-text-ep ep:md:flex-row">
21
101
  <EponymeSidebar :base-path="route.path" />
22
102
  <section class="ep:mx-auto ep:w-full ep:max-w-3xl ep:px-6 ep:py-8 ep:md:px-10 ep:md:py-12">
23
- <p class="ep:m-0 ep:text-[11px] ep:font-semibold ep:tracking-widest ep:text-muted-ep ep:uppercase">
24
- Eponyme
25
- </p>
26
- <h1 class="ep:mt-2 ep:text-3xl ep:font-semibold ep:tracking-tight ep:text-white">
27
- Content entries
28
- </h1>
29
- <p class="ep:mt-2 ep:text-sm ep:text-muted-ep">
30
- Choose the content area you want to update.
31
- </p>
103
+ <div class="ep:flex ep:flex-wrap ep:items-start ep:justify-between ep:gap-4">
104
+ <div>
105
+ <p class="ep:m-0 ep:text-[11px] ep:font-semibold ep:tracking-widest ep:text-muted-ep ep:uppercase">
106
+ Eponyme
107
+ </p>
108
+ <h1 class="ep:mt-2 ep:text-3xl ep:font-semibold ep:tracking-tight ep:text-white">
109
+ Content entries
110
+ </h1>
111
+ <p class="ep:mt-2 ep:text-sm ep:text-muted-ep">
112
+ Choose the content area you want to update.
113
+ </p>
114
+ </div>
115
+ <div
116
+ v-if="auth.canEdit.value"
117
+ class="ep:flex ep:shrink-0 ep:items-center ep:gap-2"
118
+ >
119
+ <EPButton
120
+ label="Export"
121
+ icon="mingcute:download-2-line"
122
+ size="sm"
123
+ :loading="exporting"
124
+ :disabled="exporting"
125
+ title="Download every entry as a JSON file"
126
+ @click="exportContent"
127
+ />
128
+ <EPButton
129
+ v-if="auth.isOwner.value"
130
+ label="Import"
131
+ icon="mingcute:upload-2-line"
132
+ size="sm"
133
+ :loading="importing"
134
+ :disabled="importing"
135
+ title="Apply an export file from another environment"
136
+ @click="fileInput?.click()"
137
+ />
138
+ <input
139
+ ref="fileInput"
140
+ type="file"
141
+ accept="application/json,.json"
142
+ class="ep:hidden"
143
+ @change="reviewFile"
144
+ >
145
+ </div>
146
+ </div>
32
147
  <nav
33
148
  class="ep:mt-8 ep:grid ep:gap-3 ep:sm:grid-cols-2"
34
149
  aria-label="Content entries"
@@ -43,6 +158,54 @@ function label(name) {
43
158
  />
44
159
  </nav>
45
160
  </section>
161
+ <ClientOnly>
162
+ <EPDialog
163
+ :open="Boolean(preview)"
164
+ title="Import content"
165
+ description="Entries in the file overwrite their counterpart. Nothing is ever deleted."
166
+ @update:open="!$event && closePreview()"
167
+ >
168
+ <div v-if="preview">
169
+ <ul class="ep:m-0 ep:list-none ep:space-y-1 ep:p-0 ep:text-sm ep:text-muted-ep">
170
+ <li><span class="ep:font-semibold ep:text-white">{{ preview.created }}</span> to create</li>
171
+ <li><span class="ep:font-semibold ep:text-white">{{ preview.updated }}</span> to overwrite</li>
172
+ <li><span class="ep:font-semibold ep:text-white">{{ preview.skipped.length }}</span> skipped</li>
173
+ </ul>
174
+ <ul
175
+ v-if="preview.skipped.length"
176
+ class="ep:mt-4 ep:max-h-40 ep:list-none ep:space-y-2 ep:overflow-y-auto ep:p-0 ep:text-xs ep:text-muted-ep"
177
+ >
178
+ <li
179
+ v-for="skipped in preview.skipped"
180
+ :key="skipped.name"
181
+ >
182
+ <span class="ep:font-medium ep:text-white">{{ skipped.name }}</span> — {{ skipped.reason }}
183
+ </li>
184
+ </ul>
185
+ <div class="ep:mt-6 ep:flex ep:justify-end ep:gap-2">
186
+ <EPButton
187
+ label="Cancel"
188
+ variant="ghost"
189
+ @click="closePreview"
190
+ />
191
+ <EPButton
192
+ label="Import"
193
+ variant="primary"
194
+ :loading="importing"
195
+ :disabled="importing || !preview.created && !preview.updated"
196
+ @click="confirmImport"
197
+ />
198
+ </div>
199
+ </div>
200
+ </EPDialog>
201
+ <EPToast
202
+ :open="toast.open"
203
+ :title="toast.title"
204
+ :description="toast.description"
205
+ :variant="toast.variant"
206
+ @update:open="toast.open = $event"
207
+ />
208
+ </ClientOnly>
46
209
  </main>
47
210
  </template>
48
211
 
@@ -1,4 +1,8 @@
1
- declare const _default: import("h3").EventHandler<import("h3").EventHandlerRequest, Promise<import("../../services/eponyme-store.js").EponymeResult | {
1
+ declare const _default: import("h3").EventHandler<import("h3").EventHandlerRequest, Promise<{
2
+ data: Record<string, unknown>;
3
+ status: import("../../services/eponyme-store.js").EponymeStatus;
4
+ publishedAt: string | null;
5
+ } | {
2
6
  data: Record<string, unknown>;
3
7
  }>>;
4
8
  export default _default;
@@ -1,6 +1,7 @@
1
1
  import { createError, defineEventHandler, getQuery, getRequestURL, setResponseHeader } from "h3";
2
2
  import { useEponymeService } from "../../services/eponyme-service.js";
3
3
  import { requireEponymeUser } from "../../utils/auth.js";
4
+ import { interpolateEponymeContent } from "../../utils/eponyme-variables.js";
4
5
  function readVersion(raw) {
5
6
  if (raw === "draft") return "draft";
6
7
  const id = Number(raw);
@@ -16,5 +17,6 @@ export default defineEventHandler(async (event) => {
16
17
  }
17
18
  const result = name ? await useEponymeService().getResult(name, version) : void 0;
18
19
  if (!result) throw createError({ statusCode: 404, statusMessage: "Eponyme entry not found." });
19
- return requestedVersion ? result : { data: result.data };
20
+ const data = getQuery(event).raw ? result.data : interpolateEponymeContent(result.data);
21
+ return requestedVersion ? { ...result, data } : { data };
20
22
  });
@@ -1,6 +1,8 @@
1
1
  import { createError, defineEventHandler, getQuery, getRequestURL, readBody, setResponseStatus } from "h3";
2
2
  import { useEponymeService } from "../../services/eponyme-service.js";
3
3
  import { assertEponymeMutationOrigin, requireEponymeUser } from "../../utils/auth.js";
4
+ import { callEponymeBlockingHook, callEponymeHook } from "../../utils/eponyme-hooks.js";
5
+ import { splitEponymeCollectionEntry } from "../../utils/eponyme-entry.js";
4
6
  export default defineEventHandler(async (event) => {
5
7
  assertEponymeMutationOrigin(event);
6
8
  const user = await requireEponymeUser(event, { roles: ["editor", "owner"] });
@@ -8,7 +10,12 @@ export default defineEventHandler(async (event) => {
8
10
  if (!name) throw createError({ statusCode: 404, statusMessage: "Eponyme entry not found." });
9
11
  const requestedAction = getQuery(event).action;
10
12
  const action = requestedAction === "draft" ? "draft" : "publish";
11
- const result = await useEponymeService().patch(name, await readBody(event), action, user.id);
13
+ const service = useEponymeService();
14
+ const collection = splitEponymeCollectionEntry(service, name);
15
+ const body = await readBody(event);
16
+ const beforeSave = { name, collection, action, data: body, userId: user.id };
17
+ await callEponymeBlockingHook("eponyme:entry:beforeSave", beforeSave);
18
+ const result = await service.patch(name, beforeSave.data, action, user.id);
12
19
  if (!result) throw createError({ statusCode: 404, statusMessage: "Eponyme entry not found." });
13
20
  if ("conflict" in result && result.conflict)
14
21
  throw createError({ statusCode: 409, message: "This entry was changed by someone else. Reload the page to get the latest version." });
@@ -16,5 +23,14 @@ export default defineEventHandler(async (event) => {
16
23
  setResponseStatus(event, 422);
17
24
  return { errors: result.errors };
18
25
  }
26
+ await callEponymeHook(action === "publish" ? "eponyme:entry:published" : "eponyme:entry:saved", {
27
+ name,
28
+ collection,
29
+ action,
30
+ status: result.status,
31
+ publishedAt: result.publishedAt,
32
+ data: result.data,
33
+ userId: user.id
34
+ });
19
35
  return requestedAction ? result : { data: result.data };
20
36
  });
@@ -1,11 +1,16 @@
1
1
  import { createError, defineEventHandler, getRequestURL } from "h3";
2
2
  import { useEponymeService } from "../../services/eponyme-service.js";
3
3
  import { assertEponymeMutationOrigin, requireEponymeUser } from "../../utils/auth.js";
4
+ import { callEponymeHook } from "../../utils/eponyme-hooks.js";
5
+ import { splitEponymeCollectionEntry } from "../../utils/eponyme-entry.js";
4
6
  export default defineEventHandler(async (event) => {
5
7
  assertEponymeMutationOrigin(event);
6
- await requireEponymeUser(event, { roles: ["editor", "owner"] });
8
+ const user = await requireEponymeUser(event, { roles: ["editor", "owner"] });
7
9
  const name = decodeURIComponent(getRequestURL(event).pathname.replace(/^\/api\/eponyme-collections\//, ""));
8
- if (!name || !await useEponymeService().deleteCollectionEntry(name))
10
+ const service = useEponymeService();
11
+ const collection = name ? splitEponymeCollectionEntry(service, name) : void 0;
12
+ if (!collection || !await service.deleteCollectionEntry(name))
9
13
  throw createError({ statusCode: 404, statusMessage: "Eponyme collection entry not found." });
14
+ await callEponymeHook("eponyme:entry:trashed", { name, collection, userId: user.id });
10
15
  return { deleted: true };
11
16
  });