@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
@@ -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
  };
@@ -2,11 +2,27 @@
2
2
  import Image from "@tiptap/extension-image";
3
3
  import Placeholder from "@tiptap/extension-placeholder";
4
4
  import StarterKit from "@tiptap/starter-kit";
5
- import { EditorContent, useEditor } from "@tiptap/vue-3";
5
+ import { EditorContent, Extension, useEditor } from "@tiptap/vue-3";
6
6
  import { computed, ref, watch } from "vue";
7
- import EPFormField from "../ui/EPFormField.vue";
8
- import EPDropdownMenu from "../ui/EPDropdownMenu.vue";
9
7
  import { useEponymeVariables } from "../../composables/useEponymeVariables";
8
+ import EPDropdownMenu from "../ui/EPDropdownMenu.vue";
9
+ import EPFormField from "../ui/EPFormField.vue";
10
+ import EPLinkDialog from "../ui/EPLinkDialog.vue";
11
+ const DownloadableLinkAttribute = Extension.create({
12
+ name: "downloadableLinkAttribute",
13
+ addGlobalAttributes() {
14
+ return [{
15
+ types: ["link"],
16
+ attributes: {
17
+ download: {
18
+ default: null,
19
+ parseHTML: (element) => element.hasAttribute("download"),
20
+ renderHTML: (attributes) => attributes.download ? { download: "" } : {}
21
+ }
22
+ }
23
+ }];
24
+ }
25
+ });
10
26
  const props = defineProps({
11
27
  id: { type: String, required: true },
12
28
  modelValue: { type: null, required: false },
@@ -22,6 +38,8 @@ const props = defineProps({
22
38
  });
23
39
  const emit = defineEmits(["update:modelValue"]);
24
40
  const revision = ref(0);
41
+ const linkDialogOpen = ref(false);
42
+ const linkValue = ref({ href: "", type: "external", openInNewTab: false, download: false });
25
43
  const initialContent = typeof props.modelValue === "string" ? props.modelValue : "";
26
44
  const editor = useEditor({
27
45
  content: initialContent,
@@ -36,6 +54,7 @@ const editor = useEditor({
36
54
  HTMLAttributes: { rel: "noopener noreferrer" }
37
55
  }
38
56
  }),
57
+ DownloadableLinkAttribute,
39
58
  Placeholder.configure({ placeholder: props.placeholder }),
40
59
  Image.configure({ HTMLAttributes: { class: "eponyme-rich-text-image" } })
41
60
  ],
@@ -61,17 +80,33 @@ function isActive(name, attributes = {}) {
61
80
  trackEditorRevision();
62
81
  return editor.value?.isActive(name, attributes) ?? false;
63
82
  }
64
- function setLink() {
83
+ function openLinkDialog() {
84
+ const instance = editor.value;
85
+ if (!instance) return;
86
+ const attributes = instance.getAttributes("link");
87
+ const href = String(attributes.href ?? "");
88
+ linkValue.value = {
89
+ href,
90
+ type: href.startsWith("/") || href.startsWith("#") ? "internal" : "external",
91
+ openInNewTab: attributes.target === "_blank",
92
+ download: attributes.download === true
93
+ };
94
+ linkDialogOpen.value = true;
95
+ }
96
+ function applyLink(value) {
65
97
  const instance = editor.value;
66
98
  if (!instance) return;
67
- const current = String(instance.getAttributes("link").href ?? "");
68
- const href = window.prompt("Link URL", current || "https://");
69
- if (href === null) return;
70
- if (!href.trim()) {
99
+ if (!value.href) {
71
100
  instance.chain().focus().extendMarkRange("link").unsetLink().run();
72
101
  return;
73
102
  }
74
- instance.chain().focus().extendMarkRange("link").setLink({ href: href.trim() }).run();
103
+ const attributes = {
104
+ href: value.href,
105
+ target: value.openInNewTab ? "_blank" : null,
106
+ rel: "noopener noreferrer",
107
+ download: value.download ? true : null
108
+ };
109
+ instance.chain().focus().extendMarkRange("link").setLink(attributes).run();
75
110
  }
76
111
  function setImage() {
77
112
  const instance = editor.value;
@@ -104,7 +139,7 @@ const tools = computed(() => {
104
139
  { icon: "mingcute:bold-line", title: "Bold", active: isActive("bold"), separated: true, run: () => instance.chain().focus().toggleBold().run() },
105
140
  { icon: "mingcute:italic-line", title: "Italic", active: isActive("italic"), run: () => instance.chain().focus().toggleItalic().run() },
106
141
  { icon: "mingcute:strikethrough-line", title: "Strikethrough", active: isActive("strike"), run: () => instance.chain().focus().toggleStrike().run() },
107
- { icon: "mingcute:link-line", title: "Link", active: isActive("link"), run: setLink },
142
+ { icon: "mingcute:link-line", title: "Link", active: isActive("link"), run: openLinkDialog },
108
143
  { icon: "mingcute:pic-line", title: "Image", active: isActive("image"), run: setImage },
109
144
  { icon: "mingcute:list-check-line", title: "Bullet list", active: isActive("bulletList"), separated: true, run: () => instance.chain().focus().toggleBulletList().run() },
110
145
  { icon: "mingcute:list-ordered-line", title: "Numbered list", active: isActive("orderedList"), run: () => instance.chain().focus().toggleOrderedList().run() },
@@ -199,6 +234,12 @@ const tools = computed(() => {
199
234
  >
200
235
  {{ characterCount }} / {{ maxLength }}
201
236
  </p>
237
+ <EPLinkDialog
238
+ :open="linkDialogOpen"
239
+ :model-value="linkValue"
240
+ @update:open="linkDialogOpen = $event"
241
+ @update:model-value="applyLink"
242
+ />
202
243
  </EPFormField>
203
244
  </template>
204
245
 
@@ -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;
@@ -0,0 +1,142 @@
1
+ <script setup>
2
+ import { computed, ref, watch } from "vue";
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";
8
+ import { getDownloadableExtension } from "../../utils/downloadable-link";
9
+ const props = defineProps({
10
+ open: { type: Boolean, required: true },
11
+ modelValue: { type: null, required: false },
12
+ placeholder: { type: String, required: false }
13
+ });
14
+ const emit = defineEmits(["update:open", "update:modelValue"]);
15
+ const draftHref = ref("");
16
+ const draftType = ref("external");
17
+ const draftOpenInNewTab = ref(false);
18
+ const draftDownload = ref(false);
19
+ const value = computed(() => {
20
+ if (props.modelValue && typeof props.modelValue === "object" && !Array.isArray(props.modelValue)) {
21
+ const link = props.modelValue;
22
+ return {
23
+ href: typeof link.href === "string" ? link.href : "",
24
+ type: link.type === "internal" ? "internal" : "external",
25
+ openInNewTab: link.openInNewTab === true,
26
+ download: link.download === true
27
+ };
28
+ }
29
+ return { href: "", type: "external", openInNewTab: false, download: false };
30
+ });
31
+ const downloadableExtension = computed(() => getDownloadableExtension(draftHref.value));
32
+ const typeOptions = [
33
+ { label: "Internal link", value: "internal" },
34
+ { label: "External link", value: "external" }
35
+ ];
36
+ watch(() => props.open, (open) => {
37
+ if (!open) return;
38
+ draftHref.value = value.value.href;
39
+ draftType.value = value.value.type;
40
+ draftOpenInNewTab.value = value.value.openInNewTab;
41
+ draftDownload.value = value.value.download === true;
42
+ });
43
+ function updateType(type) {
44
+ draftType.value = type === "internal" ? "internal" : "external";
45
+ }
46
+ function updateHref(href) {
47
+ draftHref.value = String(href);
48
+ if (!getDownloadableExtension(draftHref.value)) draftDownload.value = false;
49
+ }
50
+ function apply() {
51
+ emit("update:modelValue", {
52
+ href: draftHref.value.trim(),
53
+ type: draftType.value,
54
+ openInNewTab: draftOpenInNewTab.value,
55
+ download: Boolean(downloadableExtension.value && draftDownload.value)
56
+ });
57
+ emit("update:open", false);
58
+ }
59
+ </script>
60
+
61
+ <template>
62
+ <EPDialog
63
+ :open="open"
64
+ title="Configure link"
65
+ description="Choose where the link points and how it should open."
66
+ @update:open="emit('update:open', $event)"
67
+ >
68
+ <div class="ep:grid ep:gap-5">
69
+ <div>
70
+ <p class="ep:mt-0 ep:mb-2 ep:text-sm ep:font-medium ep:text-white">
71
+ Link type
72
+ </p>
73
+ <EPRadioButton
74
+ :model-value="draftType"
75
+ :options="typeOptions"
76
+ @update:model-value="updateType"
77
+ />
78
+ </div>
79
+
80
+ <label class="ep:block">
81
+ <span class="ep:mb-2 ep:block ep:text-sm ep:font-medium ep:text-white">Destination</span>
82
+ <EPInputText
83
+ :model-value="draftHref"
84
+ :type="draftType === 'external' ? 'url' : 'text'"
85
+ :placeholder="draftType === 'internal' ? '/contact' : placeholder || 'https://example.com'"
86
+ @update:model-value="updateHref"
87
+ />
88
+ <span class="ep:mt-1.5 ep:block ep:text-xs ep:text-muted-ep">
89
+ {{ draftType === "internal" ? "Use a path beginning with / or an anchor beginning with #." : "Use a complete HTTP(S) address." }}
90
+ </span>
91
+ </label>
92
+
93
+ <div
94
+ v-if="downloadableExtension"
95
+ 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"
96
+ >
97
+ <div>
98
+ <p class="ep:m-0 ep:text-sm ep:font-medium ep:text-white">
99
+ Download file
100
+ </p>
101
+ <p class="ep:mt-1 ep:mb-0 ep:text-xs ep:text-muted-ep">
102
+ A .{{ downloadableExtension }} file was detected. Ask the browser to download it instead of opening it.
103
+ </p>
104
+ </div>
105
+ <EPSwitch
106
+ :model-value="draftDownload"
107
+ @update:model-value="draftDownload = $event"
108
+ />
109
+ </div>
110
+
111
+ <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">
112
+ <div>
113
+ <p class="ep:m-0 ep:text-sm ep:font-medium ep:text-white">
114
+ Open in new tab
115
+ </p>
116
+ <p class="ep:mt-1 ep:mb-0 ep:text-xs ep:text-muted-ep">
117
+ Keep the current page open when visitors follow this link.
118
+ </p>
119
+ </div>
120
+ <EPSwitch
121
+ :model-value="draftOpenInNewTab"
122
+ @update:model-value="draftOpenInNewTab = $event"
123
+ />
124
+ </div>
125
+
126
+ <div class="ep:flex ep:justify-end ep:gap-2">
127
+ <EPButton
128
+ variant="ghost"
129
+ @click="emit('update:open', false)"
130
+ >
131
+ Cancel
132
+ </EPButton>
133
+ <EPButton
134
+ variant="primary"
135
+ @click="apply"
136
+ >
137
+ Apply link
138
+ </EPButton>
139
+ </div>
140
+ </div>
141
+ </EPDialog>
142
+ </template>
@@ -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>
@@ -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}${options.raw ? ":raw" : ""}`,
14
+ cacheKey,
13
15
  () => requestFetch(`/api/eponyme/${name}`, { query: { version, raw: options.raw ? 1 : void 0 }, cache: "no-store" }),
14
- { getCachedData: () => void 0 }
16
+ { getCachedData: cacheDuringHydrationOnly }
15
17
  );
16
18
  const data = computed(() => response.value?.data);
17
19
  const status = computed(() => response.value?.status ?? "published");
@@ -31,7 +33,7 @@ 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("/");
@@ -2,6 +2,7 @@ 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
+ import { cacheDuringHydrationOnly } from "../utils/hydration-cache.js";
5
6
  export function useEponymeCollection(name, options = {}) {
6
7
  const requestFetch = useRequestFetch();
7
8
  const query = {
@@ -15,7 +16,7 @@ export function useEponymeCollection(name, options = {}) {
15
16
  // same collection would share one cache entry.
16
17
  `eponyme:collection:public:${name}:${options.take ?? ""}:${options.skip ?? ""}:${options.orderBy ?? ""}:${options.order ?? ""}`,
17
18
  () => requestFetch(`/api/eponyme-collections/${name}`, { query, cache: "no-store" }),
18
- { getCachedData: () => void 0 }
19
+ { getCachedData: cacheDuringHydrationOnly }
19
20
  );
20
21
  const entries = computed(() => result.data.value?.entries ?? []);
21
22
  if (import.meta.client) useEventListener(window, "focus", () => void result.refresh());
@@ -37,7 +38,7 @@ export function useEponymeCollectionEntry(name, slug, options = {}) {
37
38
  const result = useAsyncData(
38
39
  `eponyme:collection-entry:${name}:${slug}:${version}`,
39
40
  () => requestFetch(`/api/eponyme/${name}/${encodeURIComponent(slug)}`, { query: { version }, cache: "no-store" }),
40
- { getCachedData: () => void 0 }
41
+ { getCachedData: cacheDuringHydrationOnly }
41
42
  );
42
43
  if (import.meta.client) useEventListener(window, "focus", () => void result.refresh());
43
44
  return {
@@ -24,6 +24,10 @@ export interface UseEponymeFormResult<Data extends Record<string, unknown>> {
24
24
  submitted: Ref<boolean>;
25
25
  /** Name of the trap field to render hidden, or `false` when disabled. */
26
26
  honeypot: string | false;
27
+ /** True when the form declares `captcha: true`; render a widget and fill `captchaToken`. */
28
+ requiresCaptcha: boolean;
29
+ /** Bind this to the captcha widget, for example `<NuxtTurnstile v-model="captchaToken" />`. */
30
+ captchaToken: Ref<string>;
27
31
  submit: () => Promise<boolean>;
28
32
  reset: () => void;
29
33
  }