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.
- package/README.md +17 -3
- package/dist/module.json +1 -1
- package/dist/module.mjs +8 -5
- package/dist/runtime/app/components/content/editor/csv.vue +39 -4
- package/dist/runtime/app/components/content/editor/frontmatter/modal.vue +2 -2
- package/dist/runtime/app/components/content/editor/markdown.vue +54 -5
- package/dist/runtime/app/components/content/editor/txt.vue +39 -4
- package/dist/runtime/app/components/content/fileButtons.vue +32 -0
- package/dist/runtime/app/components/content/index.vue +5 -17
- package/dist/runtime/app/components/content/upload.vue +34 -7
- package/dist/runtime/app/composables/useCopyMode.d.ts +9 -0
- package/dist/runtime/app/composables/useCopyMode.js +77 -0
- package/dist/runtime/app/composables/useFileType.js +6 -6
- package/dist/runtime/app/pages/admin/login.vue +34 -7
- package/dist/runtime/app/styles/admin.min.css +1 -1
- package/dist/runtime/server/api/admin/blob.js +4 -3
- package/dist/runtime/server/api/admin/csv.js +3 -2
- package/dist/runtime/server/api/admin/csv.post.js +6 -4
- package/dist/runtime/server/api/admin/delete.js +6 -4
- package/dist/runtime/server/api/admin/download.js +11 -8
- package/dist/runtime/server/api/admin/git-branch.js +3 -2
- package/dist/runtime/server/api/admin/git-history.js +3 -2
- package/dist/runtime/server/api/admin/git-update-status.js +3 -2
- package/dist/runtime/server/api/admin/git-update.post.js +2 -2
- package/dist/runtime/server/api/admin/image.post.js +14 -10
- package/dist/runtime/server/api/admin/list.js +11 -6
- package/dist/runtime/server/api/admin/login.js +6 -4
- package/dist/runtime/server/api/admin/logout.js +2 -1
- package/dist/runtime/server/api/admin/md.js +3 -2
- package/dist/runtime/server/api/admin/md.post.js +6 -4
- package/dist/runtime/server/api/admin/pdf.post.js +13 -9
- package/dist/runtime/server/api/admin/txt.js +3 -2
- package/dist/runtime/server/api/admin/txt.post.js +6 -4
- package/dist/runtime/server/api/admin/upload.js +10 -6
- package/dist/runtime/server/api/content/[path].js +14 -12
- package/dist/runtime/server/api/content/list.js +9 -7
- package/dist/runtime/server/middleware/auth.js +2 -1
- package/dist/runtime/server/utils/authCookie.d.ts +9 -0
- package/dist/runtime/server/utils/authCookie.js +12 -0
- package/dist/runtime/server/utils/contentKey.d.ts +2 -0
- package/dist/runtime/server/utils/contentKey.js +67 -0
- package/dist/runtime/server/utils/gitErrorSanitization.d.ts +1 -0
- package/dist/runtime/server/utils/gitErrorSanitization.js +17 -0
- package/dist/runtime/server/utils/gitVersioning.d.ts +1 -2
- package/dist/runtime/server/utils/gitVersioning.js +2 -13
- package/dist/runtime/server/utils/loginRateLimit.d.ts +4 -0
- package/dist/runtime/server/utils/loginRateLimit.js +72 -0
- package/dist/runtime/server/utils/uploadGuard.d.ts +2 -0
- package/dist/runtime/server/utils/uploadGuard.js +20 -0
- package/dist/runtime/shared/contentFiles.d.ts +15 -0
- package/dist/runtime/shared/contentFiles.js +38 -0
- package/package.json +1 -1
- package/dist/runtime/app/composables/useImport.d.ts +0 -6
- package/dist/runtime/app/composables/useImport.js +0 -37
- package/dist/runtime/server/api/admin/import.d.ts +0 -5
- package/dist/runtime/server/api/admin/import.js +0 -86
package/README.md
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
|
-
#
|
|
1
|
+
# MktCMS
|
|
2
|
+
|
|
3
|
+
A file based CMS for self-hosted Nuxt apps.
|
|
2
4
|
|
|
3
5
|

|
|
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
|
|
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
|
|
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
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
|
|
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="
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
<
|
|
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,
|
|
2
|
+
import { onMounted, ref, useRoute } from "#imports";
|
|
3
3
|
import useAdminUpload from "../../composables/useAdminUpload";
|
|
4
|
-
|
|
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
|
|
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="
|
|
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="
|
|
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="
|
|
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
|
|
3
|
-
const
|
|
4
|
-
const
|
|
5
|
-
const
|
|
6
|
-
const
|
|
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,
|