@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.
- package/README.md +159 -12
- package/dist/eponyme.mjs +2 -1
- package/dist/module.d.mts +6 -0
- package/dist/module.json +1 -1
- package/dist/module.mjs +55 -36
- package/dist/runtime/components/editor/EponymeCollectionPage.vue +80 -18
- package/dist/runtime/components/editor/EponymeForm.vue +2 -2
- package/dist/runtime/components/editor/EponymeFormPage.vue +7 -3
- package/dist/runtime/components/editor/EponymeSidebar.vue +24 -32
- package/dist/runtime/components/editor/EponymeSidebarTree.d.vue.ts +2 -0
- package/dist/runtime/components/editor/EponymeSidebarTree.vue +3 -1
- package/dist/runtime/components/editor/EponymeSidebarTree.vue.d.ts +2 -0
- package/dist/runtime/components/fields/RichTextField.vue +51 -10
- package/dist/runtime/components/ui/EPLinkDialog.d.vue.ts +15 -0
- package/dist/runtime/components/ui/EPLinkDialog.vue +142 -0
- package/dist/runtime/components/ui/EPLinkDialog.vue.d.ts +15 -0
- package/dist/runtime/components/ui/EPLinkEditor.vue +10 -91
- package/dist/runtime/composables/useEponyme.js +5 -3
- package/dist/runtime/composables/useEponymeCollection.js +3 -2
- package/dist/runtime/composables/useEponymeForm.d.ts +4 -0
- package/dist/runtime/composables/useEponymeForm.js +11 -1
- package/dist/runtime/composables/useEponymeHistory.d.ts +2 -2
- package/dist/runtime/composables/useEponymeHistory.js +2 -1
- package/dist/runtime/pages/EponymeDetailPage.vue +1 -1
- package/dist/runtime/pages/EponymeIndexPage.vue +173 -10
- package/dist/runtime/server/api/eponyme/[name].patch.js +17 -1
- package/dist/runtime/server/api/eponyme-collections/[path].delete.js +7 -2
- package/dist/runtime/server/api/eponyme-export.get.d.ts +2 -0
- package/dist/runtime/server/api/eponyme-export.get.js +11 -0
- package/dist/runtime/server/api/eponyme-forms/[path].post.js +29 -1
- package/dist/runtime/server/api/eponyme-history/[path].patch.d.ts +1 -1
- package/dist/runtime/server/api/eponyme-history/[path].patch.js +14 -2
- package/dist/runtime/server/api/eponyme-import.post.d.ts +2 -0
- package/dist/runtime/server/api/eponyme-import.post.js +31 -0
- package/dist/runtime/server/api/eponyme-trash/[path].delete.d.ts +4 -0
- package/dist/runtime/server/api/eponyme-trash/[path].delete.js +16 -0
- package/dist/runtime/server/api/eponyme-trash/[path].get.d.ts +2 -0
- package/dist/runtime/server/api/eponyme-trash/[path].get.js +11 -0
- package/dist/runtime/server/api/eponyme-trash/[path].patch.d.ts +4 -0
- package/dist/runtime/server/api/eponyme-trash/[path].patch.js +16 -0
- package/dist/runtime/server/services/eponyme-form-store.js +2 -1
- package/dist/runtime/server/services/eponyme-service.js +8 -1
- package/dist/runtime/server/services/eponyme-store.d.ts +118 -5
- package/dist/runtime/server/services/eponyme-store.js +344 -45
- package/dist/runtime/server/utils/eponyme-captcha.d.ts +6 -0
- package/dist/runtime/server/utils/eponyme-captcha.js +14 -0
- package/dist/runtime/server/utils/eponyme-entry.d.ts +9 -0
- package/dist/runtime/server/utils/eponyme-entry.js +7 -0
- package/dist/runtime/server/utils/eponyme-hooks.d.ts +16 -0
- package/dist/runtime/server/utils/eponyme-hooks.js +24 -0
- package/dist/runtime/types/captcha.d.ts +16 -0
- package/dist/runtime/types/captcha.js +0 -0
- package/dist/runtime/types/eponyme-config.d.ts +7 -0
- package/dist/runtime/types/eponyme.d.ts +3 -0
- package/dist/runtime/types/field.d.ts +1 -0
- package/dist/runtime/types/hooks.d.ts +67 -0
- package/dist/runtime/types/hooks.js +0 -0
- package/dist/runtime/types/index.d.ts +1 -0
- package/dist/runtime/types/index.js +1 -0
- package/dist/runtime/types/nuxt.d.ts +10 -0
- package/dist/runtime/utils/build-navigation-tree.d.ts +22 -0
- package/dist/runtime/utils/build-navigation-tree.js +30 -0
- package/dist/runtime/utils/date-locale.d.ts +10 -0
- package/dist/runtime/utils/date-locale.js +1 -0
- package/dist/runtime/utils/downloadable-link.d.ts +2 -0
- package/dist/runtime/utils/downloadable-link.js +35 -0
- package/dist/runtime/utils/filter-navigation-tree.d.ts +7 -0
- package/dist/runtime/utils/filter-navigation-tree.js +39 -0
- package/dist/runtime/utils/get-field-default-value.js +1 -1
- package/dist/runtime/utils/hydration-cache.d.ts +11 -0
- package/dist/runtime/utils/hydration-cache.js +3 -0
- package/dist/runtime/utils/no-captcha.d.ts +7 -0
- package/dist/runtime/utils/no-captcha.js +5 -0
- package/dist/runtime/utils/validate-eponyme-data.js +1 -1
- package/dist/runtime/utils/variables.d.ts +8 -1
- package/dist/runtime/utils/variables.js +17 -4
- 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
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
const kind = index === parts.length - 1 ? finalKind : "folder";
|
|
98
|
-
let node = children.find((item) => item.path === path);
|
|
99
|
-
if (!node) {
|
|
100
|
-
node = { kind, path, label: index === parts.length - 1 && configuredLabel ? configuredLabel : label(part), children: [] };
|
|
101
|
-
children.push(node);
|
|
102
|
-
} else if (index === parts.length - 1) {
|
|
103
|
-
node.kind = finalKind;
|
|
104
|
-
if (configuredLabel) node.label = configuredLabel;
|
|
105
|
-
}
|
|
106
|
-
children = node.children;
|
|
107
|
-
}
|
|
108
|
-
}
|
|
109
|
-
for (const entry of Object.keys(getEponymeSchemas(config))) addPath(entry, "entry");
|
|
110
|
-
for (const [name, definition] of Object.entries(collections)) {
|
|
111
|
-
addPath(name, "collection", definition.label);
|
|
112
|
-
for (const entry of collectionEntries.value[name] ?? []) addPath(`${name}/${entry.slug}`, "entry", entry.title);
|
|
113
|
-
}
|
|
114
|
-
for (const [name, definition] of Object.entries(forms)) addPath(name, "form", definition.label);
|
|
115
|
-
return root;
|
|
116
|
-
});
|
|
97
|
+
const navigationTree = computed(() => buildEponymeNavigationTree({
|
|
98
|
+
schemas: getEponymeSchemas(config),
|
|
99
|
+
collections,
|
|
100
|
+
forms,
|
|
101
|
+
collectionEntries: collectionEntries.value
|
|
102
|
+
}));
|
|
103
|
+
const isFiltering = computed(() => searchQuery.value.trim().length > 0);
|
|
104
|
+
const filteredTree = computed(() => filterEponymeNavigationTree(navigationTree.value, searchQuery.value));
|
|
117
105
|
const openFolders = useState(`eponyme:open-folders:${normalizedBasePath.value}`, () => navigation.value.filter((item) => item.kind === "folder").map((item) => item.path));
|
|
118
|
-
function label(name) {
|
|
119
|
-
return name.replace(/[-_]/g, " ").replace(/\b\w/g, (char) => char.toUpperCase());
|
|
120
|
-
}
|
|
121
106
|
function entryPath(name) {
|
|
122
107
|
return `${normalizedBasePath.value}/${name}`;
|
|
123
108
|
}
|
|
@@ -204,14 +189,21 @@ watch(() => route.path, () => {
|
|
|
204
189
|
Editables
|
|
205
190
|
</div>
|
|
206
191
|
<EponymeSidebarTree
|
|
207
|
-
:nodes="
|
|
192
|
+
:nodes="filteredTree"
|
|
208
193
|
:base-path="normalizedBasePath"
|
|
209
194
|
:current-path="route.path"
|
|
210
195
|
:statuses="statuses"
|
|
211
196
|
:open-folders="openFolders"
|
|
197
|
+
:force-open="isFiltering"
|
|
212
198
|
:can-edit="auth.canEdit.value"
|
|
213
199
|
@update:open-folders="openFolders = $event"
|
|
214
200
|
/>
|
|
201
|
+
<p
|
|
202
|
+
v-if="isFiltering && !filteredTree.length"
|
|
203
|
+
class="ep:m-0 ep:px-3 ep:py-2 ep:text-xs ep:text-muted-ep"
|
|
204
|
+
>
|
|
205
|
+
No match for “{{ searchQuery }}”.
|
|
206
|
+
</p>
|
|
215
207
|
</nav>
|
|
216
208
|
<div
|
|
217
209
|
v-if="!collapsed && auth.user.value"
|
|
@@ -6,6 +6,8 @@ type __VLS_Props = {
|
|
|
6
6
|
currentPath: string;
|
|
7
7
|
statuses: Record<string, EponymeStatus>;
|
|
8
8
|
openFolders: string[];
|
|
9
|
+
/** Reveals every folder while a search is active, without touching `openFolders`. */
|
|
10
|
+
forceOpen?: boolean;
|
|
9
11
|
canEdit?: boolean;
|
|
10
12
|
depth?: number;
|
|
11
13
|
};
|
|
@@ -8,6 +8,7 @@ const props = defineProps({
|
|
|
8
8
|
currentPath: { type: String, required: true },
|
|
9
9
|
statuses: { type: Object, required: true },
|
|
10
10
|
openFolders: { type: Array, required: true },
|
|
11
|
+
forceOpen: { type: Boolean, required: false, default: false },
|
|
11
12
|
canEdit: { type: Boolean, required: false, default: false },
|
|
12
13
|
depth: { type: Number, required: false, default: 0 }
|
|
13
14
|
});
|
|
@@ -16,7 +17,7 @@ function entryPath(path) {
|
|
|
16
17
|
return `${props.basePath}/${path}`;
|
|
17
18
|
}
|
|
18
19
|
function isOpen(path) {
|
|
19
|
-
return props.openFolders.includes(path);
|
|
20
|
+
return props.forceOpen || props.openFolders.includes(path);
|
|
20
21
|
}
|
|
21
22
|
function setOpen(path, open) {
|
|
22
23
|
const next = new Set(props.openFolders);
|
|
@@ -66,6 +67,7 @@ function setOpen(path, open) {
|
|
|
66
67
|
:current-path="currentPath"
|
|
67
68
|
:statuses="statuses"
|
|
68
69
|
:open-folders="openFolders"
|
|
70
|
+
:force-open="forceOpen"
|
|
69
71
|
:can-edit="canEdit"
|
|
70
72
|
:depth="depth + 1"
|
|
71
73
|
@update:open-folders="emit('update:openFolders', $event)"
|
|
@@ -6,6 +6,8 @@ type __VLS_Props = {
|
|
|
6
6
|
currentPath: string;
|
|
7
7
|
statuses: Record<string, EponymeStatus>;
|
|
8
8
|
openFolders: string[];
|
|
9
|
+
/** Reveals every folder while a search is active, without touching `openFolders`. */
|
|
10
|
+
forceOpen?: boolean;
|
|
9
11
|
canEdit?: boolean;
|
|
10
12
|
depth?: number;
|
|
11
13
|
};
|
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
|
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
|
-
<
|
|
81
|
+
<EPLinkDialog
|
|
108
82
|
:open="dialogOpen"
|
|
109
|
-
|
|
110
|
-
|
|
83
|
+
:model-value="value"
|
|
84
|
+
:placeholder="placeholder"
|
|
111
85
|
@update:open="updateDialogOpen"
|
|
112
|
-
|
|
113
|
-
|
|
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
|
-
|
|
14
|
+
cacheKey,
|
|
13
15
|
() => requestFetch(`/api/eponyme/${name}`, { query: { version, raw: options.raw ? 1 : void 0 }, cache: "no-store" }),
|
|
14
|
-
{ getCachedData:
|
|
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 !==
|
|
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:
|
|
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:
|
|
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
|
}
|