@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
|
@@ -3,11 +3,11 @@ import { useAsyncData, useRequestFetch, useRoute, useRouter, useState } from "#a
|
|
|
3
3
|
import { computed, ref, watch } from "vue";
|
|
4
4
|
import { normalizeSlug } from "../../utils/normalize-slug";
|
|
5
5
|
import { useEponymeAuth } from "../../composables/useEponymeAuth";
|
|
6
|
-
import EPBadge from "../ui/EPBadge.vue";
|
|
7
6
|
import EPButton from "../ui/EPButton.vue";
|
|
8
7
|
import EPDialog from "../ui/EPDialog.vue";
|
|
9
8
|
import EPFormField from "../ui/EPFormField.vue";
|
|
10
9
|
import EPInputText from "../ui/EPInputText.vue";
|
|
10
|
+
import { EPONYME_DATE_LOCALE } from "../../utils/date-locale";
|
|
11
11
|
const props = defineProps({
|
|
12
12
|
basePath: { type: String, required: true },
|
|
13
13
|
name: { type: String, required: true },
|
|
@@ -24,12 +24,19 @@ const slugTouched = ref(false);
|
|
|
24
24
|
const errors = ref({});
|
|
25
25
|
const collectionEntries = useState("eponyme:collection-entries", () => ({}));
|
|
26
26
|
const endpoint = computed(() => `/api/eponyme-collections/${props.name}`);
|
|
27
|
+
const trashEndpoint = computed(() => `/api/eponyme-trash/${props.name}`);
|
|
27
28
|
const auth = useEponymeAuth();
|
|
29
|
+
const view = ref("entries");
|
|
28
30
|
const { data: response, pending, refresh } = useAsyncData(
|
|
29
31
|
`eponyme:collection:${props.name}`,
|
|
30
|
-
() => requestFetch(endpoint.value, { query: { version: "draft" } })
|
|
32
|
+
() => requestFetch(endpoint.value, { query: { version: "draft", raw: 1 } })
|
|
33
|
+
);
|
|
34
|
+
const { data: trashResponse, refresh: refreshTrash } = useAsyncData(
|
|
35
|
+
`eponyme:collection-trash:${props.name}`,
|
|
36
|
+
() => auth.canEdit.value ? requestFetch(trashEndpoint.value) : Promise.resolve({ entries: [] })
|
|
31
37
|
);
|
|
32
38
|
const entries = computed(() => response.value?.entries ?? []);
|
|
39
|
+
const trashEntries = computed(() => trashResponse.value?.entries ?? []);
|
|
33
40
|
const label = computed(() => props.definition.label || humanize(props.name.split("/").at(-1) || props.name));
|
|
34
41
|
watch(title, (value) => {
|
|
35
42
|
if (!slugTouched.value) slug.value = normalizeSlug(value);
|
|
@@ -87,12 +94,23 @@ async function createEntry() {
|
|
|
87
94
|
}
|
|
88
95
|
}
|
|
89
96
|
async function deleteEntry(entry) {
|
|
90
|
-
if (!confirm(`
|
|
97
|
+
if (!confirm(`Move \u201C${entry.title}\u201D to the trash? Its slug stays reserved until you restore it or delete it for good.`)) return;
|
|
91
98
|
await requestFetch(`${endpoint.value}/${entry.slug}`, { method: "DELETE" });
|
|
92
|
-
await refresh();
|
|
99
|
+
await Promise.all([refresh(), refreshTrash()]);
|
|
100
|
+
}
|
|
101
|
+
async function restoreEntry(entry) {
|
|
102
|
+
await requestFetch(`${trashEndpoint.value}/${entry.slug}`, { method: "PATCH" });
|
|
103
|
+
await Promise.all([refresh(), refreshTrash()]);
|
|
104
|
+
if (!trashEntries.value.length) view.value = "entries";
|
|
105
|
+
}
|
|
106
|
+
async function purgeEntry(entry) {
|
|
107
|
+
if (!confirm(`Permanently delete \u201C${entry.title}\u201D? Its content and its whole history are lost. This cannot be undone.`)) return;
|
|
108
|
+
await requestFetch(`${trashEndpoint.value}/${entry.slug}`, { method: "DELETE" });
|
|
109
|
+
await refreshTrash();
|
|
110
|
+
if (!trashEntries.value.length) view.value = "entries";
|
|
93
111
|
}
|
|
94
112
|
function formatDate(value) {
|
|
95
|
-
return value ? new Intl.DateTimeFormat(
|
|
113
|
+
return value ? new Intl.DateTimeFormat(EPONYME_DATE_LOCALE, { dateStyle: "medium", timeStyle: "short" }).format(new Date(value)) : "";
|
|
96
114
|
}
|
|
97
115
|
</script>
|
|
98
116
|
|
|
@@ -113,17 +131,65 @@ function formatDate(value) {
|
|
|
113
131
|
{{ definition.description }}
|
|
114
132
|
</p>
|
|
115
133
|
</div>
|
|
116
|
-
<
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
134
|
+
<div class="ep:flex ep:flex-wrap ep:items-center ep:gap-2">
|
|
135
|
+
<EPButton
|
|
136
|
+
v-if="auth.canEdit.value && trashEntries.length"
|
|
137
|
+
size="md"
|
|
138
|
+
:variant="view === 'trash' ? 'primary' : 'secondary'"
|
|
139
|
+
@click="view = view === 'trash' ? 'entries' : 'trash'"
|
|
140
|
+
>
|
|
141
|
+
Trash ({{ trashEntries.length }})
|
|
142
|
+
</EPButton>
|
|
143
|
+
<EPButton
|
|
144
|
+
v-if="auth.canEdit.value"
|
|
145
|
+
variant="primary"
|
|
146
|
+
@click="createOpen = true"
|
|
147
|
+
>
|
|
148
|
+
+ New {{ humanize(name.split("/").at(-1) || "entry").replace(/s$/, "") }}
|
|
149
|
+
</EPButton>
|
|
150
|
+
</div>
|
|
123
151
|
</header>
|
|
124
152
|
|
|
153
|
+
<div
|
|
154
|
+
v-if="view === 'trash'"
|
|
155
|
+
class="ep:mt-8 ep:grid ep:gap-3"
|
|
156
|
+
>
|
|
157
|
+
<p class="ep:m-0 ep:text-sm ep:text-muted-ep">
|
|
158
|
+
Trashed entries keep their history and their slug. Restore one to bring it back.
|
|
159
|
+
</p>
|
|
160
|
+
<article
|
|
161
|
+
v-for="entry in trashEntries"
|
|
162
|
+
:key="entry.slug"
|
|
163
|
+
class="ep:flex ep:flex-wrap ep:items-center ep:justify-between ep:gap-3 ep:rounded-xl ep:bg-selected-ep/50 ep:p-4"
|
|
164
|
+
>
|
|
165
|
+
<div
|
|
166
|
+
class="ep:min-w-0 ep:flex-1"
|
|
167
|
+
:title="entry.title"
|
|
168
|
+
>
|
|
169
|
+
<span class="ep:block ep:min-w-0 ep:overflow-hidden ep:text-ellipsis ep:whitespace-nowrap ep:text-sm ep:font-semibold ep:text-white">{{ truncateTitle(entry.title) }}</span>
|
|
170
|
+
<span class="ep:mt-1 ep:block ep:text-xs ep:text-muted-ep">{{ entry.deletedAt ? `Deleted ${formatDate(entry.deletedAt)}` : "Deleted" }}</span>
|
|
171
|
+
</div>
|
|
172
|
+
<div class="ep:flex ep:shrink-0 ep:gap-2">
|
|
173
|
+
<EPButton
|
|
174
|
+
size="sm"
|
|
175
|
+
@click="restoreEntry(entry)"
|
|
176
|
+
>
|
|
177
|
+
Restore
|
|
178
|
+
</EPButton>
|
|
179
|
+
<EPButton
|
|
180
|
+
v-if="auth.isOwner.value"
|
|
181
|
+
size="sm"
|
|
182
|
+
variant="danger"
|
|
183
|
+
@click="purgeEntry(entry)"
|
|
184
|
+
>
|
|
185
|
+
Delete for good
|
|
186
|
+
</EPButton>
|
|
187
|
+
</div>
|
|
188
|
+
</article>
|
|
189
|
+
</div>
|
|
190
|
+
|
|
125
191
|
<p
|
|
126
|
-
v-if="pending"
|
|
192
|
+
v-else-if="pending"
|
|
127
193
|
class="ep:mt-8 ep:text-sm ep:text-muted-ep"
|
|
128
194
|
>
|
|
129
195
|
Loading…
|
|
@@ -144,18 +210,14 @@ function formatDate(value) {
|
|
|
144
210
|
>
|
|
145
211
|
<span class="ep:flex ep:min-w-0 ep:items-center ep:gap-2">
|
|
146
212
|
<span class="ep:min-w-0 ep:flex-1 ep:overflow-hidden ep:text-ellipsis ep:whitespace-nowrap ep:text-sm ep:font-semibold ep:text-white">{{ truncateTitle(entry.title) }}</span>
|
|
147
|
-
<EPBadge
|
|
148
|
-
class="ep:shrink-0"
|
|
149
|
-
:variant="entry.status === 'draft' ? 'warning' : 'success'"
|
|
150
|
-
>{{ entry.status }}</EPBadge>
|
|
151
213
|
</span>
|
|
152
|
-
<span class="ep:mt-1 ep:block ep:text-xs ep:text-muted-ep"
|
|
214
|
+
<span class="ep:mt-1 ep:block ep:text-xs ep:text-muted-ep ep:capitalize">{{ entry.status }}<template v-if="entry.updatedAt"> · {{ formatDate(entry.updatedAt) }}</template></span>
|
|
153
215
|
</NuxtLink>
|
|
154
216
|
<EPButton
|
|
155
217
|
v-if="auth.canEdit.value"
|
|
156
218
|
size="sm"
|
|
157
219
|
variant="danger"
|
|
158
|
-
class="ep:opacity-0 ep:group-hover:opacity-100 ep:focus-visible:opacity-100"
|
|
220
|
+
class="ep:shrink-0 ep:md:opacity-0 ep:md:group-hover:opacity-100 ep:md:focus-visible:opacity-100"
|
|
159
221
|
@click="deleteEntry(entry)"
|
|
160
222
|
>
|
|
161
223
|
Delete
|
|
@@ -30,7 +30,7 @@ const previewPaths = runtimeConfig.public.eponyme?.previewPaths ?? {};
|
|
|
30
30
|
const collectionNames = Object.keys(getEponymeCollections(useEponymeConfig()));
|
|
31
31
|
const previewPath = computed(() => resolvePreviewPath(previewPaths, collectionNames, props.name));
|
|
32
32
|
const auth = useEponymeAuth();
|
|
33
|
-
const { data: eponymeData, errors: serverErrors, pending, status, publishedAt, refresh, save: persist } = useEponyme(props.name, { version: "draft" });
|
|
33
|
+
const { data: eponymeData, errors: serverErrors, pending, status, publishedAt, refresh, save: persist } = useEponyme(props.name, { version: "draft", raw: true });
|
|
34
34
|
const data = ref({});
|
|
35
35
|
const savedData = ref({});
|
|
36
36
|
const validation = useEponymeValidation(props.schema, data, savedData, { serverErrors });
|
|
@@ -64,10 +64,10 @@ watch(eponymeData, (value) => {
|
|
|
64
64
|
data.value = next;
|
|
65
65
|
savedData.value = cloneData(next);
|
|
66
66
|
}
|
|
67
|
-
}, { immediate: true });
|
|
67
|
+
}, { immediate: true, flush: "sync" });
|
|
68
68
|
watch(status, (value) => {
|
|
69
69
|
entryStatuses.value = { ...entryStatuses.value, [props.name]: value };
|
|
70
|
-
}, { immediate: true });
|
|
70
|
+
}, { immediate: true, flush: "sync" });
|
|
71
71
|
function cloneData(value) {
|
|
72
72
|
return JSON.parse(JSON.stringify(value));
|
|
73
73
|
}
|
|
@@ -7,6 +7,7 @@ import { useEponymeAuth } from "../../composables/useEponymeAuth";
|
|
|
7
7
|
import EPBadge from "../ui/EPBadge.vue";
|
|
8
8
|
import EPButton from "../ui/EPButton.vue";
|
|
9
9
|
import EPDialog from "../ui/EPDialog.vue";
|
|
10
|
+
import { EPONYME_DATE_LOCALE } from "../../utils/date-locale";
|
|
10
11
|
const props = defineProps({
|
|
11
12
|
name: { type: String, required: true },
|
|
12
13
|
definition: { type: Object, required: true }
|
|
@@ -35,7 +36,10 @@ const columns = computed(() => [
|
|
|
35
36
|
{
|
|
36
37
|
id: fieldName,
|
|
37
38
|
header: humanizeLabel(fieldName, field.options.label),
|
|
38
|
-
|
|
39
|
+
// Never empty: SSR emits nothing for an empty string while the client still
|
|
40
|
+
// builds a text node, which hydrates as a missing child. The dash also makes
|
|
41
|
+
// an unanswered optional field readable instead of looking like a broken cell.
|
|
42
|
+
cell: (info) => truncate(info.getValue()) || "\u2014"
|
|
39
43
|
}
|
|
40
44
|
)),
|
|
41
45
|
columnHelper.accessor((row) => row.createdAt, {
|
|
@@ -79,7 +83,7 @@ function truncate(value, maxLength = 60) {
|
|
|
79
83
|
return characters.length <= maxLength ? value : `${characters.slice(0, maxLength - 1).join("")}\u2026`;
|
|
80
84
|
}
|
|
81
85
|
function formatDate(value) {
|
|
82
|
-
return new Intl.DateTimeFormat(
|
|
86
|
+
return new Intl.DateTimeFormat(EPONYME_DATE_LOCALE, { dateStyle: "medium", timeStyle: "short" }).format(new Date(value));
|
|
83
87
|
}
|
|
84
88
|
async function clearAll() {
|
|
85
89
|
if (!confirm(`Delete all ${total.value} submissions of \u201C${label.value}\u201D? This cannot be undone.`)) return;
|
|
@@ -184,7 +188,7 @@ async function deleteSubmission(submission) {
|
|
|
184
188
|
</th>
|
|
185
189
|
<th
|
|
186
190
|
scope="col"
|
|
187
|
-
class="ep:border-b ep:border-border-ep ep:p-3"
|
|
191
|
+
class="ep:relative ep:border-b ep:border-border-ep ep:p-3"
|
|
188
192
|
>
|
|
189
193
|
<span class="ep:sr-only">Actions</span>
|
|
190
194
|
</th>
|
|
@@ -6,6 +6,8 @@ import EponymeNavigationLink from "./EponymeNavigationLink.vue";
|
|
|
6
6
|
import EponymeSidebarTree from "./EponymeSidebarTree.vue";
|
|
7
7
|
import { useEponymeConfig } from "../../composables/useEponymeConfig";
|
|
8
8
|
import { getEponymeCollections, getEponymeForms, getEponymeSchemas } from "../../utils/get-eponyme-schemas";
|
|
9
|
+
import { buildEponymeNavigationTree } from "../../utils/build-navigation-tree";
|
|
10
|
+
import { filterEponymeNavigationTree } from "../../utils/filter-navigation-tree";
|
|
9
11
|
import { useEponymeAuth } from "../../composables/useEponymeAuth";
|
|
10
12
|
import EPAvatar from "../ui/EPAvatar.vue";
|
|
11
13
|
import EPButton from "../ui/EPButton.vue";
|
|
@@ -39,7 +41,7 @@ onMounted(async () => {
|
|
|
39
41
|
await restoreNavigationScroll();
|
|
40
42
|
const [statusResponse, ...collectionResponses] = await Promise.all([
|
|
41
43
|
requestFetch("/api/eponyme-statuses"),
|
|
42
|
-
...Object.keys(collections).map((name) => requestFetch(`/api/eponyme-collections/${name}`, { query: { version: "draft" } }))
|
|
44
|
+
...Object.keys(collections).map((name) => requestFetch(`/api/eponyme-collections/${name}`, { query: { version: "draft", raw: 1 } }))
|
|
43
45
|
]);
|
|
44
46
|
statuses.value = { ...statusResponse.statuses };
|
|
45
47
|
collectionEntries.value = Object.fromEntries(Object.keys(collections).map((name, index) => [name, collectionResponses[index]?.entries ?? []]));
|
|
@@ -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,9 +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 { useEponymeVariables } from "../../composables/useEponymeVariables";
|
|
8
|
+
import EPDropdownMenu from "../ui/EPDropdownMenu.vue";
|
|
7
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
|
+
});
|
|
8
26
|
const props = defineProps({
|
|
9
27
|
id: { type: String, required: true },
|
|
10
28
|
modelValue: { type: null, required: false },
|
|
@@ -20,6 +38,8 @@ const props = defineProps({
|
|
|
20
38
|
});
|
|
21
39
|
const emit = defineEmits(["update:modelValue"]);
|
|
22
40
|
const revision = ref(0);
|
|
41
|
+
const linkDialogOpen = ref(false);
|
|
42
|
+
const linkValue = ref({ href: "", type: "external", openInNewTab: false, download: false });
|
|
23
43
|
const initialContent = typeof props.modelValue === "string" ? props.modelValue : "";
|
|
24
44
|
const editor = useEditor({
|
|
25
45
|
content: initialContent,
|
|
@@ -34,6 +54,7 @@ const editor = useEditor({
|
|
|
34
54
|
HTMLAttributes: { rel: "noopener noreferrer" }
|
|
35
55
|
}
|
|
36
56
|
}),
|
|
57
|
+
DownloadableLinkAttribute,
|
|
37
58
|
Placeholder.configure({ placeholder: props.placeholder }),
|
|
38
59
|
Image.configure({ HTMLAttributes: { class: "eponyme-rich-text-image" } })
|
|
39
60
|
],
|
|
@@ -59,17 +80,33 @@ function isActive(name, attributes = {}) {
|
|
|
59
80
|
trackEditorRevision();
|
|
60
81
|
return editor.value?.isActive(name, attributes) ?? false;
|
|
61
82
|
}
|
|
62
|
-
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) {
|
|
63
97
|
const instance = editor.value;
|
|
64
98
|
if (!instance) return;
|
|
65
|
-
|
|
66
|
-
const href = window.prompt("Link URL", current || "https://");
|
|
67
|
-
if (href === null) return;
|
|
68
|
-
if (!href.trim()) {
|
|
99
|
+
if (!value.href) {
|
|
69
100
|
instance.chain().focus().extendMarkRange("link").unsetLink().run();
|
|
70
101
|
return;
|
|
71
102
|
}
|
|
72
|
-
|
|
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();
|
|
73
110
|
}
|
|
74
111
|
function setImage() {
|
|
75
112
|
const instance = editor.value;
|
|
@@ -83,6 +120,14 @@ function setImage() {
|
|
|
83
120
|
const alt = window.prompt("Alt text (describe the image)", "") ?? "";
|
|
84
121
|
instance.chain().focus().setImage({ src: src.trim(), alt: alt.trim() }).run();
|
|
85
122
|
}
|
|
123
|
+
const variables = useEponymeVariables();
|
|
124
|
+
const variableItems = computed(() => variables.map((variable) => ({
|
|
125
|
+
label: variable.preview ? `${variable.label} \u2014 ${variable.preview}` : variable.label,
|
|
126
|
+
value: variable.name
|
|
127
|
+
})));
|
|
128
|
+
function insertVariable(name) {
|
|
129
|
+
editor.value?.chain().focus().insertContent(`{{ ${name} }}`).run();
|
|
130
|
+
}
|
|
86
131
|
const tools = computed(() => {
|
|
87
132
|
const instance = editor.value;
|
|
88
133
|
if (!instance) return [];
|
|
@@ -94,7 +139,7 @@ const tools = computed(() => {
|
|
|
94
139
|
{ icon: "mingcute:bold-line", title: "Bold", active: isActive("bold"), separated: true, run: () => instance.chain().focus().toggleBold().run() },
|
|
95
140
|
{ icon: "mingcute:italic-line", title: "Italic", active: isActive("italic"), run: () => instance.chain().focus().toggleItalic().run() },
|
|
96
141
|
{ icon: "mingcute:strikethrough-line", title: "Strikethrough", active: isActive("strike"), run: () => instance.chain().focus().toggleStrike().run() },
|
|
97
|
-
{ icon: "mingcute:link-line", title: "Link", active: isActive("link"), run:
|
|
142
|
+
{ icon: "mingcute:link-line", title: "Link", active: isActive("link"), run: openLinkDialog },
|
|
98
143
|
{ icon: "mingcute:pic-line", title: "Image", active: isActive("image"), run: setImage },
|
|
99
144
|
{ icon: "mingcute:list-check-line", title: "Bullet list", active: isActive("bulletList"), separated: true, run: () => instance.chain().focus().toggleBulletList().run() },
|
|
100
145
|
{ icon: "mingcute:list-ordered-line", title: "Numbered list", active: isActive("orderedList"), run: () => instance.chain().focus().toggleOrderedList().run() },
|
|
@@ -148,6 +193,27 @@ const tools = computed(() => {
|
|
|
148
193
|
/>
|
|
149
194
|
</button>
|
|
150
195
|
</template>
|
|
196
|
+
<EPDropdownMenu
|
|
197
|
+
v-if="variables.length"
|
|
198
|
+
:items="variableItems"
|
|
199
|
+
@select="insertVariable"
|
|
200
|
+
>
|
|
201
|
+
<template #trigger>
|
|
202
|
+
<button
|
|
203
|
+
type="button"
|
|
204
|
+
class="rich-text-tool"
|
|
205
|
+
:disabled="disabled"
|
|
206
|
+
title="Insert a variable"
|
|
207
|
+
aria-label="Insert a variable"
|
|
208
|
+
>
|
|
209
|
+
<Icon
|
|
210
|
+
name="mingcute:code-line"
|
|
211
|
+
size="18"
|
|
212
|
+
aria-hidden="true"
|
|
213
|
+
/>
|
|
214
|
+
</button>
|
|
215
|
+
</template>
|
|
216
|
+
</EPDropdownMenu>
|
|
151
217
|
</div>
|
|
152
218
|
<EditorContent
|
|
153
219
|
v-if="editor"
|
|
@@ -168,6 +234,12 @@ const tools = computed(() => {
|
|
|
168
234
|
>
|
|
169
235
|
{{ characterCount }} / {{ maxLength }}
|
|
170
236
|
</p>
|
|
237
|
+
<EPLinkDialog
|
|
238
|
+
:open="linkDialogOpen"
|
|
239
|
+
:model-value="linkValue"
|
|
240
|
+
@update:open="linkDialogOpen = $event"
|
|
241
|
+
@update:model-value="applyLink"
|
|
242
|
+
/>
|
|
171
243
|
</EPFormField>
|
|
172
244
|
</template>
|
|
173
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>
|