@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.
- package/README.md +198 -16
- package/dist/eponyme.d.mts +22 -2
- package/dist/eponyme.mjs +5 -1
- package/dist/module.d.mts +3 -2
- package/dist/module.json +1 -1
- package/dist/module.mjs +73 -43
- package/dist/runtime/components/editor/EponymeCollectionPage.vue +81 -19
- package/dist/runtime/components/editor/EponymeForm.vue +3 -3
- package/dist/runtime/components/editor/EponymeFormPage.vue +7 -3
- package/dist/runtime/components/editor/EponymeSidebar.vue +25 -33
- 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 +80 -8
- 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.d.ts +2 -0
- package/dist/runtime/composables/useEponyme.js +7 -5
- package/dist/runtime/composables/useEponymeCollection.d.ts +12 -2
- package/dist/runtime/composables/useEponymeCollection.js +15 -5
- package/dist/runtime/composables/useEponymeForm.js +4 -1
- package/dist/runtime/composables/useEponymeHistory.d.ts +2 -2
- package/dist/runtime/composables/useEponymeHistory.js +2 -1
- package/dist/runtime/composables/useEponymeVariables.d.ts +3 -0
- package/dist/runtime/composables/useEponymeVariables.js +5 -0
- package/dist/runtime/pages/EponymeDetailPage.vue +1 -1
- package/dist/runtime/pages/EponymeIndexPage.vue +173 -10
- package/dist/runtime/server/api/eponyme/[name].get.d.ts +5 -1
- package/dist/runtime/server/api/eponyme/[name].get.js +3 -1
- 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-collections/[path].get.d.ts +1 -3
- package/dist/runtime/server/api/eponyme-collections/[path].get.js +31 -4
- 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 +14 -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.d.ts +1 -0
- package/dist/runtime/server/services/eponyme-store.d.ts +91 -3
- package/dist/runtime/server/services/eponyme-store.js +272 -9
- 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/server/utils/eponyme-variables.d.ts +5 -0
- package/dist/runtime/server/utils/eponyme-variables.js +5 -0
- package/dist/runtime/types/eponyme-config.d.ts +7 -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/types/variables.d.ts +17 -0
- package/dist/runtime/types/variables.js +0 -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/empty-variables.d.ts +2 -0
- package/dist/runtime/utils/empty-variables.js +1 -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/validate-eponyme-data.js +1 -1
- package/dist/runtime/utils/variables.d.ts +18 -0
- package/dist/runtime/utils/variables.js +52 -0
- package/dist/types.d.mts +2 -2
- 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
|
|
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>
|
|
@@ -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
|
-
|
|
13
|
-
() => requestFetch(`/api/eponyme/${name}`, { query: { version }, cache: "no-store" }),
|
|
14
|
-
{ getCachedData:
|
|
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 !==
|
|
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
|
-
|
|
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
|
-
|
|
9
|
-
|
|
10
|
-
{
|
|
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:
|
|
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}`, {
|
|
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(
|
|
24
|
+
return new Intl.DateTimeFormat(EPONYME_DATE_LOCALE, { dateStyle: "medium", timeStyle: "short" }).format(new Date(value));
|
|
24
25
|
}
|
|
@@ -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 {
|
|
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
|
-
<
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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<
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
});
|