mktcms 0.2.13 → 0.2.15

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 (56) hide show
  1. package/README.md +17 -3
  2. package/dist/module.json +1 -1
  3. package/dist/module.mjs +8 -5
  4. package/dist/runtime/app/components/content/editor/csv.vue +39 -4
  5. package/dist/runtime/app/components/content/editor/frontmatter/modal.vue +2 -2
  6. package/dist/runtime/app/components/content/editor/markdown.vue +54 -5
  7. package/dist/runtime/app/components/content/editor/txt.vue +39 -4
  8. package/dist/runtime/app/components/content/fileButtons.vue +32 -0
  9. package/dist/runtime/app/components/content/index.vue +5 -17
  10. package/dist/runtime/app/components/content/upload.vue +34 -7
  11. package/dist/runtime/app/composables/useCopyMode.d.ts +9 -0
  12. package/dist/runtime/app/composables/useCopyMode.js +77 -0
  13. package/dist/runtime/app/composables/useFileType.js +6 -6
  14. package/dist/runtime/app/pages/admin/login.vue +34 -7
  15. package/dist/runtime/app/styles/admin.min.css +1 -1
  16. package/dist/runtime/server/api/admin/blob.js +4 -3
  17. package/dist/runtime/server/api/admin/csv.js +3 -2
  18. package/dist/runtime/server/api/admin/csv.post.js +6 -4
  19. package/dist/runtime/server/api/admin/delete.js +6 -4
  20. package/dist/runtime/server/api/admin/download.js +11 -8
  21. package/dist/runtime/server/api/admin/git-branch.js +3 -2
  22. package/dist/runtime/server/api/admin/git-history.js +3 -2
  23. package/dist/runtime/server/api/admin/git-update-status.js +3 -2
  24. package/dist/runtime/server/api/admin/git-update.post.js +2 -2
  25. package/dist/runtime/server/api/admin/image.post.js +14 -10
  26. package/dist/runtime/server/api/admin/list.js +11 -6
  27. package/dist/runtime/server/api/admin/login.js +6 -4
  28. package/dist/runtime/server/api/admin/logout.js +2 -1
  29. package/dist/runtime/server/api/admin/md.js +3 -2
  30. package/dist/runtime/server/api/admin/md.post.js +6 -4
  31. package/dist/runtime/server/api/admin/pdf.post.js +13 -9
  32. package/dist/runtime/server/api/admin/txt.js +3 -2
  33. package/dist/runtime/server/api/admin/txt.post.js +6 -4
  34. package/dist/runtime/server/api/admin/upload.js +10 -6
  35. package/dist/runtime/server/api/content/[path].js +14 -12
  36. package/dist/runtime/server/api/content/list.js +9 -7
  37. package/dist/runtime/server/middleware/auth.js +2 -1
  38. package/dist/runtime/server/utils/authCookie.d.ts +9 -0
  39. package/dist/runtime/server/utils/authCookie.js +12 -0
  40. package/dist/runtime/server/utils/contentKey.d.ts +2 -0
  41. package/dist/runtime/server/utils/contentKey.js +67 -0
  42. package/dist/runtime/server/utils/gitErrorSanitization.d.ts +1 -0
  43. package/dist/runtime/server/utils/gitErrorSanitization.js +17 -0
  44. package/dist/runtime/server/utils/gitVersioning.d.ts +1 -2
  45. package/dist/runtime/server/utils/gitVersioning.js +2 -13
  46. package/dist/runtime/server/utils/loginRateLimit.d.ts +4 -0
  47. package/dist/runtime/server/utils/loginRateLimit.js +72 -0
  48. package/dist/runtime/server/utils/uploadGuard.d.ts +2 -0
  49. package/dist/runtime/server/utils/uploadGuard.js +20 -0
  50. package/dist/runtime/shared/contentFiles.d.ts +15 -0
  51. package/dist/runtime/shared/contentFiles.js +38 -0
  52. package/package.json +1 -1
  53. package/dist/runtime/app/composables/useImport.d.ts +0 -6
  54. package/dist/runtime/app/composables/useImport.js +0 -37
  55. package/dist/runtime/server/api/admin/import.d.ts +0 -5
  56. package/dist/runtime/server/api/admin/import.js +0 -86
package/README.md CHANGED
@@ -1,8 +1,10 @@
1
- # Simple CMS module for Nuxt
1
+ # MktCMS
2
+
3
+ A file based CMS for self-hosted Nuxt apps.
2
4
 
3
5
  ![Screenshot of Admin UI](https://raw.githubusercontent.com/mktcode/mktcms/refs/heads/main/docs/screenshot.png)
4
6
 
5
- This is my personal, minimalist alternative to @nuxt/content and Studio, which are fantastic projects, but sometimes lacking the kind of complete documentation I’m looking for. Over time I will probably migrate more and and more (MDC already supported), and keep this repo as a custom nuxt template for my projects. If you find it useful, feel free to use it as well and contribute to it.
7
+ This is my personal, minimalist alternative to @nuxt/content and Studio, which are fantastic projects, but sometimes lacking the kind of complete documentation I’m looking for. Over time I will maybe migrate more and more (MDC already supported), and just keep [the website template](https://github.com/mktcode/mktcms-website-template) and the [server utility](https://github.com/mktcode/mktcms-server). If you find this useful, feel free to use it and contribute to it.
6
8
 
7
9
  [![npm version][npm-version-src]][npm-version-href]
8
10
  [![npm downloads][npm-downloads-src]][npm-downloads-href]
@@ -15,7 +17,7 @@ This is my personal, minimalist alternative to @nuxt/content and Studio, which a
15
17
 
16
18
  ## Features
17
19
 
18
- - Simple Admin UI to manage content files
20
+ - Simple Admin UI with Git integration to manage content
19
21
  - Composables to use the content in your Nuxt app
20
22
  - `sendMail` utility to send emails via SMTP
21
23
  - [MDC](https://github.com/nuxt-content/mdc) support
@@ -28,7 +30,18 @@ npx nuxi module add mktcms
28
30
 
29
31
  ```bash
30
32
  NUXT_PUBLIC_MKTCMS_SITE_URL="http://localhost:3000"
33
+ NUXT_PUBLIC_MKTCMS_SHOW_VERSIONING=false
34
+
31
35
  NUXT_MKTCMS_ADMIN_AUTH_KEY="your-admin-auth-key"
36
+ NUXT_MKTCMS_AUTH_COOKIE_MAX_AGE_SECONDS=604800
37
+ NUXT_MKTCMS_AUTH_COOKIE_PATH="/"
38
+ NUXT_MKTCMS_AUTH_COOKIE_SAME_SITE="lax"
39
+ NUXT_MKTCMS_AUTH_COOKIE_SECURE=true
40
+ NUXT_MKTCMS_LOGIN_RATE_LIMIT_MAX_ATTEMPTS=5
41
+ NUXT_MKTCMS_LOGIN_RATE_LIMIT_WINDOW_SECONDS=300
42
+ NUXT_MKTCMS_LOGIN_RATE_LIMIT_BLOCK_SECONDS=600
43
+ NUXT_MKTCMS_UPLOAD_MAX_BYTES=52428800
44
+
32
45
  NUXT_MKTCMS_SMTP_HOST="your-smtp-host"
33
46
  NUXT_MKTCMS_SMTP_PORT=465
34
47
  NUXT_MKTCMS_SMTP_SECURE=true
@@ -36,6 +49,7 @@ NUXT_MKTCMS_SMTP_USER="your-smtp-user"
36
49
  NUXT_MKTCMS_SMTP_PASS="your-smtp-pass"
37
50
  NUXT_MKTCMS_MAILER_FROM="your-mailer-from-address"
38
51
  NUXT_MKTCMS_MAILER_TO="your-mailer-to-address"
52
+
39
53
  NUXT_MKTCMS_GIT_USER="your-github-username"
40
54
  NUXT_MKTCMS_GIT_REPO="owner/repository.git"
41
55
  NUXT_MKTCMS_GIT_TOKEN="your-github-personal-access-token"
package/dist/module.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "mktcms",
3
3
  "configKey": "mktcms",
4
- "version": "0.2.13",
4
+ "version": "0.2.15",
5
5
  "builder": {
6
6
  "@nuxt/module-builder": "1.0.2",
7
7
  "unbuild": "3.6.1"
package/dist/module.mjs CHANGED
@@ -15,6 +15,14 @@ const module$1 = defineNuxtModule({
15
15
  const resolver = createResolver(import.meta.url);
16
16
  _nuxt.options.runtimeConfig.mktcms = defu((_nuxt.options.runtimeConfig.mktcms, {
17
17
  adminAuthKey: "",
18
+ authCookieMaxAgeSeconds: 7 * 24 * 60 * 60,
19
+ authCookiePath: "/",
20
+ authCookieSameSite: "lax",
21
+ authCookieSecure: process.env.NODE_ENV === "production",
22
+ loginRateLimitMaxAttempts: 5,
23
+ loginRateLimitWindowSeconds: 300,
24
+ loginRateLimitBlockSeconds: 600,
25
+ uploadMaxBytes: 50 * 1024 * 1024,
18
26
  smtpHost: "",
19
27
  smtpPort: 465,
20
28
  smtpSecure: true,
@@ -143,11 +151,6 @@ const module$1 = defineNuxtModule({
143
151
  route: "/api/admin/blob",
144
152
  handler: resolver.resolve("./runtime/server/api/admin/blob")
145
153
  });
146
- addServerHandler({
147
- route: "/api/admin/import",
148
- method: "post",
149
- handler: resolver.resolve("./runtime/server/api/admin/import")
150
- });
151
154
  addServerHandler({
152
155
  route: "/api/admin/upload",
153
156
  handler: resolver.resolve("./runtime/server/api/admin/upload")
@@ -2,24 +2,31 @@
2
2
  import { ref } from "vue";
3
3
  import Saved from "../saved.vue";
4
4
  import usePathParam from "../../../composables/usePathParam";
5
- import { useFetch } from "#imports";
5
+ import useCopyMode from "../../../composables/useCopyMode";
6
+ import { navigateTo, useFetch } from "#imports";
6
7
  const { path } = usePathParam();
8
+ const { isCopyMode, newFilename, sourceExtension, targetPath, targetEditPath, filenameError, confirmOverwriteIfNeeded } = useCopyMode(path);
7
9
  const { data: table } = await useFetch(`/api/admin/csv?path=${path}`);
8
10
  const isSaving = ref(false);
9
11
  const savingSuccessful = ref(false);
10
12
  const commitMessage = ref("Inhaltliche \xC4nderungen");
11
13
  async function saveCsv() {
12
14
  if (!table.value) return;
15
+ if (isCopyMode.value && !await confirmOverwriteIfNeeded()) return;
13
16
  isSaving.value = true;
14
17
  savingSuccessful.value = false;
15
18
  try {
16
- await useFetch(`/api/admin/csv?path=${path}`, {
19
+ await useFetch(`/api/admin/csv?path=${isCopyMode.value ? targetPath.value : path}`, {
17
20
  method: "POST",
18
21
  body: {
19
22
  table: table.value,
20
23
  commitMessage: commitMessage.value
21
24
  }
22
25
  });
26
+ if (isCopyMode.value) {
27
+ await navigateTo(targetEditPath.value, { replace: true });
28
+ return;
29
+ }
23
30
  savingSuccessful.value = true;
24
31
  } catch (e) {
25
32
  console.error("Fehler beim Speichern der CSV-Datei:", e);
@@ -98,6 +105,34 @@ function cancelEdit() {
98
105
 
99
106
  <template>
100
107
  <div class="w-full">
108
+ <div
109
+ v-if="isCopyMode"
110
+ class="mb-3"
111
+ >
112
+ <label
113
+ for="copy-filename"
114
+ class="block mb-1"
115
+ >
116
+ Neuer Dateiname
117
+ </label>
118
+ <div class="flex items-center gap-2">
119
+ <input
120
+ id="copy-filename"
121
+ v-model="newFilename"
122
+ type="text"
123
+ required
124
+ class="w-full border border-gray-200 rounded-sm px-3 py-2"
125
+ >
126
+ <span class="text-sm text-gray-400">{{ sourceExtension }}</span>
127
+ </div>
128
+ <p
129
+ v-if="filenameError"
130
+ class="text-sm mt-1"
131
+ >
132
+ {{ filenameError }}
133
+ </p>
134
+ </div>
135
+
101
136
  <div
102
137
  v-if="table"
103
138
  class="bg-white"
@@ -274,11 +309,11 @@ function cancelEdit() {
274
309
  <button
275
310
  type="button"
276
311
  class="button w-full mt-3 justify-center"
277
- :disabled="!commitMessage.trim() || isSaving"
312
+ :disabled="!commitMessage.trim() || isSaving || !!filenameError"
278
313
  @click="saveCsv"
279
314
  >
280
315
  <span v-if="isSaving">Speichern...</span>
281
- <span v-else>Speichern</span>
316
+ <span v-else>{{ isCopyMode ? "Neue Datei ver\xF6ffentlichen" : "Speichern" }}</span>
282
317
  </button>
283
318
  <Saved v-if="savingSuccessful" />
284
319
 
@@ -57,11 +57,11 @@ watch(yamlContent, (value) => {
57
57
  class="w-full max-w-220 bg-white rounded-[10px] border border-black/10 shadow-[0_10px_40px_rgba(0,0,0,0.28)] p-6 flex flex-col gap-3"
58
58
  role="dialog"
59
59
  aria-modal="true"
60
- aria-label="Metadaten"
60
+ aria-label="Einstellungen"
61
61
  >
62
62
  <div class="flex items-center justify-between gap-2">
63
63
  <h2 class="font-bold text-2xl">
64
- Metadaten
64
+ Einstellungen
65
65
  </h2>
66
66
  <div class="flex items-center gap-2">
67
67
  <button
@@ -3,10 +3,12 @@ import { ref } from "vue";
3
3
  import { refDebounced } from "@vueuse/core";
4
4
  import Saved from "../saved.vue";
5
5
  import usePathParam from "../../../composables/usePathParam";
6
- import { useFetch } from "#imports";
6
+ import useCopyMode from "../../../composables/useCopyMode";
7
+ import { navigateTo, useFetch } from "#imports";
7
8
  import FrontmatterModal from "./frontmatter/modal.vue";
8
9
  import MonacoEditor from "./monacoEditor.vue";
9
10
  const { path } = usePathParam();
11
+ const { isCopyMode, newFilename, sourceExtension, targetPath, targetEditPath, filenameError, confirmOverwriteIfNeeded } = useCopyMode(path);
10
12
  const { data: content } = await useFetch(`/api/admin/md?path=${path}`);
11
13
  const frontmatter = ref(content.value?.frontmatter ?? {});
12
14
  const markdown = ref(content.value?.markdown ?? "");
@@ -17,9 +19,10 @@ const savingSuccessful = ref(false);
17
19
  const showFrontmatterModal = ref(false);
18
20
  async function saveMarkdown() {
19
21
  if (!content.value) return;
22
+ if (isCopyMode.value && !await confirmOverwriteIfNeeded()) return;
20
23
  isSaving.value = true;
21
24
  savingSuccessful.value = false;
22
- await useFetch(`/api/admin/md?path=${path}`, {
25
+ await useFetch(`/api/admin/md?path=${isCopyMode.value ? targetPath.value : path}`, {
23
26
  method: "POST",
24
27
  body: {
25
28
  frontmatter: frontmatter.value,
@@ -27,6 +30,10 @@ async function saveMarkdown() {
27
30
  commitMessage: commitMessage.value
28
31
  }
29
32
  });
33
+ if (isCopyMode.value) {
34
+ await navigateTo(targetEditPath.value, { replace: true });
35
+ return;
36
+ }
30
37
  isSaving.value = false;
31
38
  savingSuccessful.value = true;
32
39
  }
@@ -43,7 +50,21 @@ const mode = ref("preview");
43
50
  class="button secondary small mb-3 self-start"
44
51
  @click="showFrontmatterModal = true"
45
52
  >
46
- Metadaten bearbeiten
53
+ <svg
54
+ xmlns="http://www.w3.org/2000/svg"
55
+ fill="none"
56
+ viewBox="0 0 24 24"
57
+ stroke-width="1.5"
58
+ stroke="currentColor"
59
+ class="size-5 opacity-50"
60
+ >
61
+ <path
62
+ stroke-linecap="round"
63
+ stroke-linejoin="round"
64
+ d="M11.42 15.17 17.25 21A2.652 2.652 0 0 0 21 17.25l-5.877-5.877M11.42 15.17l2.496-3.03c.317-.384.74-.626 1.208-.766M11.42 15.17l-4.655 5.653a2.548 2.548 0 1 1-3.586-3.586l6.837-5.63m5.108-.233c.55-.164 1.163-.188 1.743-.14a4.5 4.5 0 0 0 4.486-6.336l-3.276 3.277a3.004 3.004 0 0 1-2.25-2.25l3.276-3.276a4.5 4.5 0 0 0-6.336 4.486c.091 1.076-.071 2.264-.904 2.95l-.102.085m-1.745 1.437L5.909 7.5H4.5L2.25 3.75l1.5-1.5L7.5 4.5v1.409l4.26 4.26m-1.745 1.437 1.745-1.437m6.615 8.206L15.75 15.75M4.867 19.125h.008v.008h-.008v-.008Z"
65
+ />
66
+ </svg>
67
+ Einstellungen
47
68
  </button>
48
69
 
49
70
  <FrontmatterModal
@@ -99,6 +120,34 @@ const mode = ref("preview");
99
120
  </div>
100
121
 
101
122
  <div class="flex-none">
123
+ <div
124
+ v-if="isCopyMode"
125
+ class="mt-3"
126
+ >
127
+ <label
128
+ for="copy-filename"
129
+ class="block mb-1"
130
+ >
131
+ Neuer Dateiname
132
+ </label>
133
+ <div class="flex items-center gap-2">
134
+ <input
135
+ id="copy-filename"
136
+ v-model="newFilename"
137
+ type="text"
138
+ required
139
+ class="w-full border border-gray-200 rounded-sm px-3 py-2"
140
+ >
141
+ <span class="text-sm text-gray-400">{{ sourceExtension }}</span>
142
+ </div>
143
+ <p
144
+ v-if="filenameError"
145
+ class="text-sm mt-1"
146
+ >
147
+ {{ filenameError }}
148
+ </p>
149
+ </div>
150
+
102
151
  <div class="mt-3">
103
152
  <label
104
153
  for="commit-message"
@@ -118,11 +167,11 @@ const mode = ref("preview");
118
167
  <button
119
168
  type="button"
120
169
  class="button w-full justify-center mt-3"
121
- :disabled="!commitMessage.trim() || isSaving"
170
+ :disabled="!commitMessage.trim() || isSaving || !!filenameError"
122
171
  @click="saveMarkdown"
123
172
  >
124
173
  <span v-if="isSaving">Speichern...</span>
125
- <span v-else>Speichern</span>
174
+ <span v-else>{{ isCopyMode ? "Neue Datei ver\xF6ffentlichen" : "Speichern" }}</span>
126
175
  </button>
127
176
  <Saved v-if="savingSuccessful" />
128
177
  </div>
@@ -1,23 +1,30 @@
1
1
  <script setup>
2
2
  import Saved from "../saved.vue";
3
3
  import usePathParam from "../../../composables/usePathParam";
4
- import { ref, useFetch } from "#imports";
4
+ import useCopyMode from "../../../composables/useCopyMode";
5
+ import { navigateTo, ref, useFetch } from "#imports";
5
6
  const { path } = usePathParam();
7
+ const { isCopyMode, newFilename, sourceExtension, targetPath, targetEditPath, filenameError, confirmOverwriteIfNeeded } = useCopyMode(path);
6
8
  const { data: content } = await useFetch(`/api/admin/txt?path=${path}`);
7
9
  const isSaving = ref(false);
8
10
  const savingSuccessful = ref(false);
9
11
  const commitMessage = ref("Inhaltliche \xC4nderungen");
10
12
  async function saveContent() {
11
13
  if (content.value === void 0) return;
14
+ if (isCopyMode.value && !await confirmOverwriteIfNeeded()) return;
12
15
  isSaving.value = true;
13
16
  savingSuccessful.value = false;
14
- await useFetch(`/api/admin/txt?path=${path}`, {
17
+ await useFetch(`/api/admin/txt?path=${isCopyMode.value ? targetPath.value : path}`, {
15
18
  method: "POST",
16
19
  body: {
17
20
  text: content.value,
18
21
  commitMessage: commitMessage.value
19
22
  }
20
23
  });
24
+ if (isCopyMode.value) {
25
+ await navigateTo(targetEditPath.value, { replace: true });
26
+ return;
27
+ }
21
28
  isSaving.value = false;
22
29
  savingSuccessful.value = true;
23
30
  }
@@ -25,6 +32,34 @@ async function saveContent() {
25
32
 
26
33
  <template>
27
34
  <div>
35
+ <div
36
+ v-if="isCopyMode"
37
+ class="mb-3"
38
+ >
39
+ <label
40
+ for="copy-filename"
41
+ class="block mb-1"
42
+ >
43
+ Neuer Dateiname
44
+ </label>
45
+ <div class="flex items-center gap-2">
46
+ <input
47
+ id="copy-filename"
48
+ v-model="newFilename"
49
+ type="text"
50
+ required
51
+ class="w-full border border-gray-200 rounded-sm px-3 py-2"
52
+ >
53
+ <span class="text-sm text-gray-400">{{ sourceExtension }}</span>
54
+ </div>
55
+ <p
56
+ v-if="filenameError"
57
+ class="text-sm mt-1"
58
+ >
59
+ {{ filenameError }}
60
+ </p>
61
+ </div>
62
+
28
63
  <textarea
29
64
  v-model="content"
30
65
  class="w-full h-24 resize-y border border-gray-300 p-2 box-border font-mono"
@@ -50,11 +85,11 @@ async function saveContent() {
50
85
  <button
51
86
  type="button"
52
87
  class="button w-full justify-center mt-3"
53
- :disabled="!commitMessage.trim() || isSaving"
88
+ :disabled="!commitMessage.trim() || isSaving || !!filenameError"
54
89
  @click="saveContent"
55
90
  >
56
91
  <span v-if="isSaving">Speichern...</span>
57
- <span v-else>Speichern</span>
92
+ <span v-else>{{ isCopyMode ? "Neue Datei ver\xF6ffentlichen" : "Speichern" }}</span>
58
93
  </button>
59
94
  <Saved v-if="savingSuccessful" />
60
95
  </div>
@@ -1,15 +1,47 @@
1
1
  <script setup>
2
2
  import { useRuntimeConfig } from "#app";
3
3
  import { useClipboard } from "@vueuse/core";
4
+ import { EDITABLE_EXTENSIONS, isMarkdownPath, toFileExtension } from "../../../shared/contentFiles";
4
5
  const { filePath } = defineProps({
5
6
  filePath: { type: String, required: true }
6
7
  });
7
8
  const { public: { mktcms: { siteUrl } } } = useRuntimeConfig();
8
9
  const { copy, copied } = useClipboard();
10
+ function isCopyableTextFile(path) {
11
+ return EDITABLE_EXTENSIONS.includes(toFileExtension(path));
12
+ }
13
+ function copyEditRoute(path) {
14
+ const editorType = isMarkdownPath(path) ? "markdown" : "file";
15
+ return {
16
+ path: `/admin/edit/${editorType}/${path}`,
17
+ query: { copy: "1" }
18
+ };
19
+ }
9
20
  </script>
10
21
 
11
22
  <template>
12
23
  <div class="flex gap-2">
24
+ <NuxtLink
25
+ v-if="isCopyableTextFile(filePath)"
26
+ :to="copyEditRoute(filePath)"
27
+ class="button secondary"
28
+ title="Neue Datei aus Vorlage"
29
+ >
30
+ <svg
31
+ xmlns="http://www.w3.org/2000/svg"
32
+ fill="none"
33
+ viewBox="0 0 24 24"
34
+ stroke-width="1.5"
35
+ stroke="currentColor"
36
+ class="size-4"
37
+ >
38
+ <path
39
+ stroke-linecap="round"
40
+ stroke-linejoin="round"
41
+ d="M15.75 17.25v3.375c0 .621-.504 1.125-1.125 1.125h-9.75a1.125 1.125 0 0 1-1.125-1.125V7.875c0-.621.504-1.125 1.125-1.125H6.75a9.06 9.06 0 0 1 1.5.124m7.5 10.376h3.375c.621 0 1.125-.504 1.125-1.125V11.25c0-4.46-3.243-8.161-7.5-8.876a9.06 9.06 0 0 0-1.5-.124H9.375c-.621 0-1.125.504-1.125 1.125v3.5m7.5 10.375H9.375a1.125 1.125 0 0 1-1.125-1.125v-9.25m12 6.625v-1.875a3.375 3.375 0 0 0-3.375-3.375h-1.5a1.125 1.125 0 0 1-1.125-1.125v-1.5a3.375 3.375 0 0 0-3.375-3.375H9.75"
42
+ />
43
+ </svg>
44
+ </NuxtLink>
13
45
  <NuxtLink
14
46
  class="button secondary"
15
47
  title="Link kopieren"
@@ -3,11 +3,9 @@ import { useFetch } from "#app";
3
3
  import TreeNode from "./treeNode.vue";
4
4
  import FileIcon from "./fileIcon.vue";
5
5
  import FileButtons from "./fileButtons.vue";
6
- import useImport from "../../composables/useImport";
7
- const { data: list, refresh } = await useFetch("/api/admin/list", {
6
+ const { data: list } = await useFetch("/api/admin/list", {
8
7
  query: { path: "" }
9
8
  });
10
- const { fileInput, uploadFile } = useImport();
11
9
  function filenameWithoutExtension(filename) {
12
10
  return filename.replace(/\.[^/.]+$/, "");
13
11
  }
@@ -86,22 +84,12 @@ function fileExtension(filename) {
86
84
  class="flex flex-col gap-4"
87
85
  >
88
86
  <p>Keine Dateien oder Verzeichnisse gefunden.</p>
89
- <button
87
+ <NuxtLink
88
+ to="/admin/new"
90
89
  class="button"
91
- @click="fileInput?.click()"
92
- >
93
- Dateien importieren
94
- </button>
95
- <input
96
- ref="fileInput"
97
- type="file"
98
- class="hidden"
99
- accept=".zip"
100
- @change="async (e) => {
101
- await uploadFile(e);
102
- await refresh();
103
- }"
104
90
  >
91
+ Datei hochladen
92
+ </NuxtLink>
105
93
  </div>
106
94
  </div>
107
95
  </template>
@@ -1,11 +1,38 @@
1
1
  <script setup>
2
- import { onMounted, ref, useFetch, useRoute } from "#imports";
2
+ import { onMounted, ref, useRoute } from "#imports";
3
3
  import useAdminUpload from "../../composables/useAdminUpload";
4
- const { data: list } = await useFetch("/api/admin/list");
4
+ import { CONTENT_UPLOAD_EXTENSIONS, IMAGE_EXTENSIONS, PDF_EXTENSIONS, toAcceptAttribute } from "../../../shared/contentFiles";
5
5
  const route = useRoute();
6
6
  const dir = ref(route.query.dir || "");
7
+ const dirs = ref([]);
7
8
  const newSubdir = ref("");
9
+ const uploadAccept = toAcceptAttribute(CONTENT_UPLOAD_EXTENSIONS);
10
+ const imageAccept = toAcceptAttribute(IMAGE_EXTENSIONS);
11
+ const pdfAccept = toAcceptAttribute(PDF_EXTENSIONS);
8
12
  const { isUploading, fileInput, fileInputImg, fileInputPdf, path, uploadFiles } = useAdminUpload();
13
+ const loadAllDirs = async () => {
14
+ const found = /* @__PURE__ */ new Set();
15
+ const queue = [""];
16
+ while (queue.length > 0) {
17
+ const currentPath = queue.shift();
18
+ if (currentPath === void 0) {
19
+ continue;
20
+ }
21
+ const list = await $fetch("/api/admin/list", {
22
+ query: currentPath ? { path: currentPath } : {}
23
+ });
24
+ for (const childDir of list.dirs) {
25
+ const fullPath = currentPath ? `${currentPath}:${childDir}` : childDir;
26
+ if (found.has(fullPath)) {
27
+ continue;
28
+ }
29
+ found.add(fullPath);
30
+ queue.push(fullPath);
31
+ }
32
+ }
33
+ dirs.value = [...found].sort((a, b) => a.localeCompare(b, "de"));
34
+ };
35
+ await loadAllDirs();
9
36
  onMounted(() => {
10
37
  path.value = dir.value;
11
38
  });
@@ -49,11 +76,11 @@ onMounted(() => {
49
76
  Hauptordner
50
77
  </option>
51
78
  <option
52
- v-for="d in list?.dirs"
79
+ v-for="d in dirs"
53
80
  :key="d"
54
81
  :value="d"
55
82
  >
56
- {{ d }}
83
+ {{ d.replace(/:/g, " / ") }}
57
84
  </option>
58
85
  </select>
59
86
  </div>
@@ -98,7 +125,7 @@ onMounted(() => {
98
125
  ref="fileInput"
99
126
  class="hidden"
100
127
  type="file"
101
- accept=".pdf,.jpg,.jpeg,.png,.gif,.webp,.md,.docx,.txt,.csv,.json"
128
+ :accept="uploadAccept"
102
129
  @change="async (e) => {
103
130
  await uploadFiles(e);
104
131
  }"
@@ -107,7 +134,7 @@ onMounted(() => {
107
134
  ref="fileInputImg"
108
135
  class="hidden"
109
136
  type="file"
110
- accept=".jpg,.jpeg,.png,.gif,.webp"
137
+ :accept="imageAccept"
111
138
  @change="async (e) => {
112
139
  await uploadFiles(e);
113
140
  }"
@@ -116,7 +143,7 @@ onMounted(() => {
116
143
  ref="fileInputPdf"
117
144
  class="hidden"
118
145
  type="file"
119
- accept=".pdf"
146
+ :accept="pdfAccept"
120
147
  @change="async (e) => {
121
148
  await uploadFiles(e);
122
149
  }"
@@ -0,0 +1,9 @@
1
+ export default function useCopyMode(sourcePath: string): {
2
+ isCopyMode: import("vue").ComputedRef<boolean>;
3
+ newFilename: import("vue").Ref<string, string>;
4
+ sourceExtension: string;
5
+ targetPath: import("vue").ComputedRef<string>;
6
+ targetEditPath: import("vue").ComputedRef<string>;
7
+ filenameError: import("vue").ComputedRef<"" | "Bitte einen Dateinamen eingeben." | "Der Dateiname enthält ungültige Zeichen." | "Bitte einen neuen Dateinamen eingeben.">;
8
+ confirmOverwriteIfNeeded: () => Promise<boolean>;
9
+ };
@@ -0,0 +1,77 @@
1
+ import { computed, ref } from "vue";
2
+ import { useRoute } from "#app";
3
+ import { isMarkdownPath, toFileExtension } from "../../shared/contentFiles.js";
4
+ function hasInvalidFilenameCharacters(filename) {
5
+ return /[:\\/]/.test(filename) || hasControlCharacters(filename);
6
+ }
7
+ function hasControlCharacters(value) {
8
+ for (const char of value) {
9
+ const code = char.charCodeAt(0);
10
+ if (code >= 0 && code <= 31 || code === 127) {
11
+ return true;
12
+ }
13
+ }
14
+ return false;
15
+ }
16
+ export default function useCopyMode(sourcePath) {
17
+ const route = useRoute();
18
+ const isCopyMode = computed(() => {
19
+ const value = route.query.copy;
20
+ if (Array.isArray(value)) {
21
+ return value.includes("1") || value.includes("true");
22
+ }
23
+ return value === "1" || value === "true";
24
+ });
25
+ const sourceParts = sourcePath.split(":").filter((part) => part.trim() !== "");
26
+ const sourceFilename = sourceParts.at(-1) || "";
27
+ const sourceBaseName = sourceFilename.replace(/\.[^/.]+$/, "");
28
+ const sourceExtension = toFileExtension(sourceFilename);
29
+ const targetDirectory = sourceParts.slice(0, -1).join(":");
30
+ const newFilename = ref(sourceBaseName);
31
+ const trimmedFilename = computed(() => newFilename.value.trim());
32
+ const targetFilename = computed(() => `${trimmedFilename.value}${sourceExtension}`);
33
+ const targetPath = computed(() => targetDirectory ? `${targetDirectory}:${targetFilename.value}` : targetFilename.value);
34
+ const targetEditPath = computed(() => {
35
+ const editorType = isMarkdownPath(targetPath.value) ? "markdown" : "file";
36
+ return `/admin/edit/${editorType}/${targetPath.value}`;
37
+ });
38
+ const filenameError = computed(() => {
39
+ if (!isCopyMode.value) return "";
40
+ if (!trimmedFilename.value) {
41
+ return "Bitte einen Dateinamen eingeben.";
42
+ }
43
+ if (hasInvalidFilenameCharacters(trimmedFilename.value)) {
44
+ return "Der Dateiname enth\xE4lt ung\xFCltige Zeichen.";
45
+ }
46
+ if (trimmedFilename.value === sourceBaseName) {
47
+ return "Bitte einen neuen Dateinamen eingeben.";
48
+ }
49
+ return "";
50
+ });
51
+ async function confirmOverwriteIfNeeded() {
52
+ if (!isCopyMode.value || filenameError.value) {
53
+ return false;
54
+ }
55
+ const response = await $fetch("/api/admin/list", {
56
+ query: {
57
+ path: targetDirectory
58
+ }
59
+ });
60
+ if (!response.files.includes(targetFilename.value)) {
61
+ return true;
62
+ }
63
+ if (import.meta.server) {
64
+ return false;
65
+ }
66
+ return window.confirm(`Die Datei "${targetFilename.value}" existiert bereits. M\xF6chtest du sie \xFCberschreiben?`);
67
+ }
68
+ return {
69
+ isCopyMode,
70
+ newFilename,
71
+ sourceExtension,
72
+ targetPath,
73
+ targetEditPath,
74
+ filenameError,
75
+ confirmOverwriteIfNeeded
76
+ };
77
+ }
@@ -1,10 +1,10 @@
1
+ import { isCsvPath, isImagePath, isMarkdownPath, isPdfPath, isTextPath } from "../../shared/contentFiles.js";
1
2
  export default function useFileType(path) {
2
- const normalizedPath = path.toLowerCase();
3
- const isImage = normalizedPath.match(/\.(png|jpg|jpeg|gif|webp)$/) !== null;
4
- const isPdf = normalizedPath.endsWith(".pdf");
5
- const isMarkdown = normalizedPath.endsWith(".md");
6
- const isCsv = normalizedPath.endsWith(".csv");
7
- const isText = normalizedPath.match(/\.(txt|json)$/) !== null;
3
+ const isImage = isImagePath(path);
4
+ const isPdf = isPdfPath(path);
5
+ const isMarkdown = isMarkdownPath(path);
6
+ const isCsv = isCsvPath(path);
7
+ const isText = isTextPath(path);
8
8
  return {
9
9
  isImage,
10
10
  isPdf,