@karibsen/eponyme 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. package/README.md +198 -16
  2. package/dist/eponyme.d.mts +22 -2
  3. package/dist/eponyme.mjs +5 -1
  4. package/dist/module.d.mts +3 -2
  5. package/dist/module.json +1 -1
  6. package/dist/module.mjs +73 -43
  7. package/dist/runtime/components/editor/EponymeCollectionPage.vue +81 -19
  8. package/dist/runtime/components/editor/EponymeForm.vue +3 -3
  9. package/dist/runtime/components/editor/EponymeFormPage.vue +7 -3
  10. package/dist/runtime/components/editor/EponymeSidebar.vue +25 -33
  11. package/dist/runtime/components/editor/EponymeSidebarTree.d.vue.ts +2 -0
  12. package/dist/runtime/components/editor/EponymeSidebarTree.vue +3 -1
  13. package/dist/runtime/components/editor/EponymeSidebarTree.vue.d.ts +2 -0
  14. package/dist/runtime/components/fields/RichTextField.vue +80 -8
  15. package/dist/runtime/components/ui/EPLinkDialog.d.vue.ts +15 -0
  16. package/dist/runtime/components/ui/EPLinkDialog.vue +142 -0
  17. package/dist/runtime/components/ui/EPLinkDialog.vue.d.ts +15 -0
  18. package/dist/runtime/components/ui/EPLinkEditor.vue +10 -91
  19. package/dist/runtime/composables/useEponyme.d.ts +2 -0
  20. package/dist/runtime/composables/useEponyme.js +7 -5
  21. package/dist/runtime/composables/useEponymeCollection.d.ts +12 -2
  22. package/dist/runtime/composables/useEponymeCollection.js +15 -5
  23. package/dist/runtime/composables/useEponymeForm.js +4 -1
  24. package/dist/runtime/composables/useEponymeHistory.d.ts +2 -2
  25. package/dist/runtime/composables/useEponymeHistory.js +2 -1
  26. package/dist/runtime/composables/useEponymeVariables.d.ts +3 -0
  27. package/dist/runtime/composables/useEponymeVariables.js +5 -0
  28. package/dist/runtime/pages/EponymeDetailPage.vue +1 -1
  29. package/dist/runtime/pages/EponymeIndexPage.vue +173 -10
  30. package/dist/runtime/server/api/eponyme/[name].get.d.ts +5 -1
  31. package/dist/runtime/server/api/eponyme/[name].get.js +3 -1
  32. package/dist/runtime/server/api/eponyme/[name].patch.js +17 -1
  33. package/dist/runtime/server/api/eponyme-collections/[path].delete.js +7 -2
  34. package/dist/runtime/server/api/eponyme-collections/[path].get.d.ts +1 -3
  35. package/dist/runtime/server/api/eponyme-collections/[path].get.js +31 -4
  36. package/dist/runtime/server/api/eponyme-export.get.d.ts +2 -0
  37. package/dist/runtime/server/api/eponyme-export.get.js +11 -0
  38. package/dist/runtime/server/api/eponyme-forms/[path].post.js +14 -1
  39. package/dist/runtime/server/api/eponyme-history/[path].patch.d.ts +1 -1
  40. package/dist/runtime/server/api/eponyme-history/[path].patch.js +14 -2
  41. package/dist/runtime/server/api/eponyme-import.post.d.ts +2 -0
  42. package/dist/runtime/server/api/eponyme-import.post.js +31 -0
  43. package/dist/runtime/server/api/eponyme-trash/[path].delete.d.ts +4 -0
  44. package/dist/runtime/server/api/eponyme-trash/[path].delete.js +16 -0
  45. package/dist/runtime/server/api/eponyme-trash/[path].get.d.ts +2 -0
  46. package/dist/runtime/server/api/eponyme-trash/[path].get.js +11 -0
  47. package/dist/runtime/server/api/eponyme-trash/[path].patch.d.ts +4 -0
  48. package/dist/runtime/server/api/eponyme-trash/[path].patch.js +16 -0
  49. package/dist/runtime/server/services/eponyme-form-store.d.ts +1 -0
  50. package/dist/runtime/server/services/eponyme-store.d.ts +91 -3
  51. package/dist/runtime/server/services/eponyme-store.js +272 -9
  52. package/dist/runtime/server/utils/eponyme-entry.d.ts +9 -0
  53. package/dist/runtime/server/utils/eponyme-entry.js +7 -0
  54. package/dist/runtime/server/utils/eponyme-hooks.d.ts +16 -0
  55. package/dist/runtime/server/utils/eponyme-hooks.js +24 -0
  56. package/dist/runtime/server/utils/eponyme-variables.d.ts +5 -0
  57. package/dist/runtime/server/utils/eponyme-variables.js +5 -0
  58. package/dist/runtime/types/eponyme-config.d.ts +7 -0
  59. package/dist/runtime/types/field.d.ts +1 -0
  60. package/dist/runtime/types/hooks.d.ts +67 -0
  61. package/dist/runtime/types/hooks.js +0 -0
  62. package/dist/runtime/types/index.d.ts +1 -0
  63. package/dist/runtime/types/index.js +1 -0
  64. package/dist/runtime/types/nuxt.d.ts +10 -0
  65. package/dist/runtime/types/variables.d.ts +17 -0
  66. package/dist/runtime/types/variables.js +0 -0
  67. package/dist/runtime/utils/build-navigation-tree.d.ts +22 -0
  68. package/dist/runtime/utils/build-navigation-tree.js +30 -0
  69. package/dist/runtime/utils/date-locale.d.ts +10 -0
  70. package/dist/runtime/utils/date-locale.js +1 -0
  71. package/dist/runtime/utils/downloadable-link.d.ts +2 -0
  72. package/dist/runtime/utils/downloadable-link.js +35 -0
  73. package/dist/runtime/utils/empty-variables.d.ts +2 -0
  74. package/dist/runtime/utils/empty-variables.js +1 -0
  75. package/dist/runtime/utils/filter-navigation-tree.d.ts +7 -0
  76. package/dist/runtime/utils/filter-navigation-tree.js +39 -0
  77. package/dist/runtime/utils/get-field-default-value.js +1 -1
  78. package/dist/runtime/utils/hydration-cache.d.ts +11 -0
  79. package/dist/runtime/utils/hydration-cache.js +3 -0
  80. package/dist/runtime/utils/validate-eponyme-data.js +1 -1
  81. package/dist/runtime/utils/variables.d.ts +18 -0
  82. package/dist/runtime/utils/variables.js +52 -0
  83. package/dist/types.d.mts +2 -2
  84. package/package.json +3 -1
@@ -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(`Delete \u201C${entry.title}\u201D? This cannot be undone.`)) return;
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(void 0, { dateStyle: "medium", timeStyle: "short" }).format(new Date(value)) : "";
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
- <EPButton
117
- v-if="auth.canEdit.value"
118
- variant="primary"
119
- @click="createOpen = true"
120
- >
121
- + New {{ humanize(name.split("/").at(-1) || "entry").replace(/s$/, "") }}
122
- </EPButton>
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">/{{ entry.slug }}<template v-if="entry.updatedAt"> · {{ formatDate(entry.updatedAt) }}</template></span>
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
- cell: (info) => truncate(info.getValue())
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(void 0, { dateStyle: "medium", timeStyle: "short" }).format(new Date(value));
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
- const root = [];
91
- function addPath(pathValue, finalKind, configuredLabel) {
92
- const parts = pathValue.split("/");
93
- let children = root;
94
- for (let index = 0; index < parts.length; index++) {
95
- const part = parts[index];
96
- const path = parts.slice(0, index + 1).join("/");
97
- const kind = index === parts.length - 1 ? finalKind : "folder";
98
- let node = children.find((item) => item.path === path);
99
- if (!node) {
100
- node = { kind, path, label: index === parts.length - 1 && configuredLabel ? configuredLabel : label(part), children: [] };
101
- children.push(node);
102
- } else if (index === parts.length - 1) {
103
- node.kind = finalKind;
104
- if (configuredLabel) node.label = configuredLabel;
105
- }
106
- children = node.children;
107
- }
108
- }
109
- for (const entry of Object.keys(getEponymeSchemas(config))) addPath(entry, "entry");
110
- for (const [name, definition] of Object.entries(collections)) {
111
- addPath(name, "collection", definition.label);
112
- for (const entry of collectionEntries.value[name] ?? []) addPath(`${name}/${entry.slug}`, "entry", entry.title);
113
- }
114
- for (const [name, definition] of Object.entries(forms)) addPath(name, "form", definition.label);
115
- return root;
116
- });
97
+ const navigationTree = computed(() => buildEponymeNavigationTree({
98
+ schemas: getEponymeSchemas(config),
99
+ collections,
100
+ forms,
101
+ collectionEntries: collectionEntries.value
102
+ }));
103
+ const isFiltering = computed(() => searchQuery.value.trim().length > 0);
104
+ const filteredTree = computed(() => filterEponymeNavigationTree(navigationTree.value, searchQuery.value));
117
105
  const openFolders = useState(`eponyme:open-folders:${normalizedBasePath.value}`, () => navigation.value.filter((item) => item.kind === "folder").map((item) => item.path));
118
- function label(name) {
119
- return name.replace(/[-_]/g, " ").replace(/\b\w/g, (char) => char.toUpperCase());
120
- }
121
106
  function entryPath(name) {
122
107
  return `${normalizedBasePath.value}/${name}`;
123
108
  }
@@ -204,14 +189,21 @@ watch(() => route.path, () => {
204
189
  Editables
205
190
  </div>
206
191
  <EponymeSidebarTree
207
- :nodes="navigationTree"
192
+ :nodes="filteredTree"
208
193
  :base-path="normalizedBasePath"
209
194
  :current-path="route.path"
210
195
  :statuses="statuses"
211
196
  :open-folders="openFolders"
197
+ :force-open="isFiltering"
212
198
  :can-edit="auth.canEdit.value"
213
199
  @update:open-folders="openFolders = $event"
214
200
  />
201
+ <p
202
+ v-if="isFiltering && !filteredTree.length"
203
+ class="ep:m-0 ep:px-3 ep:py-2 ep:text-xs ep:text-muted-ep"
204
+ >
205
+ No match for “{{ searchQuery }}”.
206
+ </p>
215
207
  </nav>
216
208
  <div
217
209
  v-if="!collapsed && auth.user.value"
@@ -6,6 +6,8 @@ type __VLS_Props = {
6
6
  currentPath: string;
7
7
  statuses: Record<string, EponymeStatus>;
8
8
  openFolders: string[];
9
+ /** Reveals every folder while a search is active, without touching `openFolders`. */
10
+ forceOpen?: boolean;
9
11
  canEdit?: boolean;
10
12
  depth?: number;
11
13
  };
@@ -8,6 +8,7 @@ const props = defineProps({
8
8
  currentPath: { type: String, required: true },
9
9
  statuses: { type: Object, required: true },
10
10
  openFolders: { type: Array, required: true },
11
+ forceOpen: { type: Boolean, required: false, default: false },
11
12
  canEdit: { type: Boolean, required: false, default: false },
12
13
  depth: { type: Number, required: false, default: 0 }
13
14
  });
@@ -16,7 +17,7 @@ function entryPath(path) {
16
17
  return `${props.basePath}/${path}`;
17
18
  }
18
19
  function isOpen(path) {
19
- return props.openFolders.includes(path);
20
+ return props.forceOpen || props.openFolders.includes(path);
20
21
  }
21
22
  function setOpen(path, open) {
22
23
  const next = new Set(props.openFolders);
@@ -66,6 +67,7 @@ function setOpen(path, open) {
66
67
  :current-path="currentPath"
67
68
  :statuses="statuses"
68
69
  :open-folders="openFolders"
70
+ :force-open="forceOpen"
69
71
  :can-edit="canEdit"
70
72
  :depth="depth + 1"
71
73
  @update:open-folders="emit('update:openFolders', $event)"
@@ -6,6 +6,8 @@ type __VLS_Props = {
6
6
  currentPath: string;
7
7
  statuses: Record<string, EponymeStatus>;
8
8
  openFolders: string[];
9
+ /** Reveals every folder while a search is active, without touching `openFolders`. */
10
+ forceOpen?: boolean;
9
11
  canEdit?: boolean;
10
12
  depth?: number;
11
13
  };
@@ -2,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 setLink() {
83
+ function openLinkDialog() {
84
+ const instance = editor.value;
85
+ if (!instance) return;
86
+ const attributes = instance.getAttributes("link");
87
+ const href = String(attributes.href ?? "");
88
+ linkValue.value = {
89
+ href,
90
+ type: href.startsWith("/") || href.startsWith("#") ? "internal" : "external",
91
+ openInNewTab: attributes.target === "_blank",
92
+ download: attributes.download === true
93
+ };
94
+ linkDialogOpen.value = true;
95
+ }
96
+ function applyLink(value) {
63
97
  const instance = editor.value;
64
98
  if (!instance) return;
65
- const current = String(instance.getAttributes("link").href ?? "");
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
- instance.chain().focus().extendMarkRange("link").setLink({ href: href.trim() }).run();
103
+ const attributes = {
104
+ href: value.href,
105
+ target: value.openInNewTab ? "_blank" : null,
106
+ rel: "noopener noreferrer",
107
+ download: value.download ? true : null
108
+ };
109
+ instance.chain().focus().extendMarkRange("link").setLink(attributes).run();
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: setLink },
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>