lightnet 3.12.0 → 3.12.2
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/CHANGELOG.md +22 -0
- package/__e2e__/admin.spec.ts +1 -365
- package/__e2e__/fixtures/basics/node_modules/.bin/astro +2 -2
- package/__e2e__/fixtures/basics/package.json +3 -3
- package/package.json +8 -15
- package/src/astro-integration/config.ts +1 -22
- package/src/astro-integration/integration.ts +4 -39
- package/src/content/content-schema.ts +0 -7
- package/src/content/get-media-items.ts +1 -47
- package/src/i18n/translate.ts +1 -1
- package/src/i18n/translations/TRANSLATION-STATUS.md +37 -12
- package/src/i18n/translations/en.yml +5 -0
- package/src/i18n/translations.ts +0 -7
- package/src/layouts/Page.astro +2 -0
- package/src/layouts/global.css +3 -0
- package/src/pages/details-page/components/main-details/EditButton.astro +2 -4
- package/src/pages/search-page/components/SearchFilter.tsx +1 -1
- package/src/admin/api/fs/write-file.ts +0 -53
- package/src/admin/components/form/DynamicArray.tsx +0 -129
- package/src/admin/components/form/Input.tsx +0 -68
- package/src/admin/components/form/LazyLoadedMarkdownEditor.tsx +0 -109
- package/src/admin/components/form/MarkdownEditor.tsx +0 -62
- package/src/admin/components/form/Select.tsx +0 -71
- package/src/admin/components/form/SubmitButton.tsx +0 -86
- package/src/admin/components/form/atoms/Button.tsx +0 -27
- package/src/admin/components/form/atoms/ErrorMessage.tsx +0 -13
- package/src/admin/components/form/atoms/FileUpload.tsx +0 -190
- package/src/admin/components/form/atoms/Hint.tsx +0 -19
- package/src/admin/components/form/atoms/Label.tsx +0 -37
- package/src/admin/components/form/hooks/use-field-dirty.tsx +0 -12
- package/src/admin/components/form/hooks/use-field-error.tsx +0 -34
- package/src/admin/components/form/utils/get-border-class.ts +0 -22
- package/src/admin/i18n/admin-i18n.ts +0 -21
- package/src/admin/i18n/translations/en.yml +0 -50
- package/src/admin/i18n/translations.ts +0 -5
- package/src/admin/pages/AdminRoute.astro +0 -14
- package/src/admin/pages/media/EditForm.tsx +0 -136
- package/src/admin/pages/media/EditRoute.astro +0 -100
- package/src/admin/pages/media/fields/Authors.tsx +0 -44
- package/src/admin/pages/media/fields/Categories.tsx +0 -49
- package/src/admin/pages/media/fields/Collections.tsx +0 -68
- package/src/admin/pages/media/fields/Content.tsx +0 -150
- package/src/admin/pages/media/fields/Image.tsx +0 -119
- package/src/admin/pages/media/file-system.ts +0 -41
- package/src/admin/pages/media/media-item-store.ts +0 -61
- package/src/admin/types/media-item.ts +0 -81
- package/src/api/media/[mediaId].ts +0 -16
|
@@ -1,100 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
import type { GetStaticPaths } from "astro"
|
|
3
|
-
import { getImage } from "astro:assets"
|
|
4
|
-
import { getCollection } from "astro:content"
|
|
5
|
-
import config from "virtual:lightnet/config"
|
|
6
|
-
|
|
7
|
-
import { getCategories } from "../../../content/get-categories"
|
|
8
|
-
import { getMediaItem, getRawMediaItem } from "../../../content/get-media-items"
|
|
9
|
-
import { getMediaTypes } from "../../../content/get-media-types"
|
|
10
|
-
import { prepareI18nConfig } from "../../../i18n/react/prepare-i18n-config"
|
|
11
|
-
import Page from "../../../layouts/Page.astro"
|
|
12
|
-
import { adminI18n } from "../../i18n/admin-i18n"
|
|
13
|
-
import EditForm from "./EditForm"
|
|
14
|
-
|
|
15
|
-
export const getStaticPaths = (async () => {
|
|
16
|
-
const mediaItems = await getCollection("media")
|
|
17
|
-
return mediaItems.map(({ id: mediaId }) => ({ params: { mediaId } }))
|
|
18
|
-
}) satisfies GetStaticPaths
|
|
19
|
-
|
|
20
|
-
const { mediaId } = Astro.params
|
|
21
|
-
const { data: mediaItem } = await getRawMediaItem(mediaId)
|
|
22
|
-
const {
|
|
23
|
-
data: { image },
|
|
24
|
-
} = await getMediaItem(mediaId)
|
|
25
|
-
|
|
26
|
-
const previewImage = await getImage({
|
|
27
|
-
src: image,
|
|
28
|
-
width: 256,
|
|
29
|
-
format: "webp",
|
|
30
|
-
})
|
|
31
|
-
|
|
32
|
-
const formData = {
|
|
33
|
-
...mediaItem,
|
|
34
|
-
image: {
|
|
35
|
-
path: mediaItem.image,
|
|
36
|
-
previewSrc: previewImage.src,
|
|
37
|
-
},
|
|
38
|
-
authors: mediaItem.authors?.map((value) => ({ value })) ?? [],
|
|
39
|
-
categories:
|
|
40
|
-
mediaItem.categories?.map((value) => ({
|
|
41
|
-
value,
|
|
42
|
-
})) ?? [],
|
|
43
|
-
collections: mediaItem.collections ?? [],
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
const i18nConfig = prepareI18nConfig(adminI18n, [
|
|
47
|
-
"ln.admin.*",
|
|
48
|
-
"ln.type",
|
|
49
|
-
"ln.language",
|
|
50
|
-
"ln.categories",
|
|
51
|
-
])
|
|
52
|
-
const { t, currentLocale } = adminI18n
|
|
53
|
-
|
|
54
|
-
const mediaTypes = (await getMediaTypes()).map(({ id }) => ({
|
|
55
|
-
id,
|
|
56
|
-
labelText: id,
|
|
57
|
-
}))
|
|
58
|
-
|
|
59
|
-
const categories = (await getCategories(currentLocale, t)).map(({ id }) => ({
|
|
60
|
-
id,
|
|
61
|
-
labelText: id,
|
|
62
|
-
}))
|
|
63
|
-
|
|
64
|
-
const collections = (await getCollection("media-collections")).map(
|
|
65
|
-
({ id }) => ({ id, labelText: id }),
|
|
66
|
-
)
|
|
67
|
-
|
|
68
|
-
const languages = config.languages.map(({ code, label }) => ({
|
|
69
|
-
id: code,
|
|
70
|
-
labelText: `${code} - ${t(label)}`,
|
|
71
|
-
}))
|
|
72
|
-
---
|
|
73
|
-
|
|
74
|
-
<Page
|
|
75
|
-
mainClass="bg-gradient-to-t from-sky-950 to-slate-800"
|
|
76
|
-
locale={currentLocale}
|
|
77
|
-
>
|
|
78
|
-
<div class="mx-auto block max-w-screen-md px-4 md:px-8">
|
|
79
|
-
<a
|
|
80
|
-
class="block pb-4 pt-8 text-slate-200 underline"
|
|
81
|
-
href=`/${Astro.currentLocale}/media/${mediaId}`
|
|
82
|
-
>{t("ln.admin.back-to-details-page")}</a
|
|
83
|
-
>
|
|
84
|
-
</div>
|
|
85
|
-
|
|
86
|
-
<div
|
|
87
|
-
class="mx-auto min-h-screen max-w-screen-md rounded-xl bg-slate-100 px-4 py-8 shadow-md md:px-8"
|
|
88
|
-
>
|
|
89
|
-
<EditForm
|
|
90
|
-
mediaId={mediaId}
|
|
91
|
-
mediaItem={formData}
|
|
92
|
-
i18nConfig={i18nConfig}
|
|
93
|
-
mediaTypes={mediaTypes}
|
|
94
|
-
languages={languages}
|
|
95
|
-
categories={categories}
|
|
96
|
-
collections={collections}
|
|
97
|
-
client:load
|
|
98
|
-
/>
|
|
99
|
-
</div>
|
|
100
|
-
</Page>
|
|
@@ -1,44 +0,0 @@
|
|
|
1
|
-
import { type Control } from "react-hook-form"
|
|
2
|
-
|
|
3
|
-
import { useI18n } from "../../../../i18n/react/use-i18n"
|
|
4
|
-
import Button from "../../../components/form/atoms/Button"
|
|
5
|
-
import DynamicArray from "../../../components/form/DynamicArray"
|
|
6
|
-
import Input from "../../../components/form/Input"
|
|
7
|
-
import type { MediaItem } from "../../../types/media-item"
|
|
8
|
-
|
|
9
|
-
export default function Authors({
|
|
10
|
-
control,
|
|
11
|
-
defaultValue,
|
|
12
|
-
}: {
|
|
13
|
-
control: Control<MediaItem>
|
|
14
|
-
defaultValue: MediaItem["authors"]
|
|
15
|
-
}) {
|
|
16
|
-
const { t } = useI18n()
|
|
17
|
-
return (
|
|
18
|
-
<DynamicArray
|
|
19
|
-
control={control}
|
|
20
|
-
name="authors"
|
|
21
|
-
label="ln.admin.authors"
|
|
22
|
-
renderElement={(index) => (
|
|
23
|
-
<Input
|
|
24
|
-
name={`authors.${index}.value`}
|
|
25
|
-
preserveHintSpace={false}
|
|
26
|
-
placeholder={t("ln.admin.author-name")}
|
|
27
|
-
required
|
|
28
|
-
control={control}
|
|
29
|
-
defaultValue={defaultValue[index]?.value}
|
|
30
|
-
/>
|
|
31
|
-
)}
|
|
32
|
-
renderAddButton={({ addElement, index }) => (
|
|
33
|
-
<Button
|
|
34
|
-
variant="secondary"
|
|
35
|
-
onClick={() =>
|
|
36
|
-
addElement({ value: "" }, { focusName: `authors.${index}.value` })
|
|
37
|
-
}
|
|
38
|
-
>
|
|
39
|
-
{t("ln.admin.add-author")}
|
|
40
|
-
</Button>
|
|
41
|
-
)}
|
|
42
|
-
/>
|
|
43
|
-
)
|
|
44
|
-
}
|
|
@@ -1,49 +0,0 @@
|
|
|
1
|
-
import { type Control } from "react-hook-form"
|
|
2
|
-
|
|
3
|
-
import { useI18n } from "../../../../i18n/react/use-i18n"
|
|
4
|
-
import Button from "../../../components/form/atoms/Button"
|
|
5
|
-
import DynamicArray from "../../../components/form/DynamicArray"
|
|
6
|
-
import Select from "../../../components/form/Select"
|
|
7
|
-
import type { MediaItem } from "../../../types/media-item"
|
|
8
|
-
|
|
9
|
-
export default function Categories({
|
|
10
|
-
control,
|
|
11
|
-
categories,
|
|
12
|
-
defaultValue,
|
|
13
|
-
}: {
|
|
14
|
-
control: Control<MediaItem>
|
|
15
|
-
defaultValue: MediaItem["categories"]
|
|
16
|
-
categories: { id: string; labelText: string }[]
|
|
17
|
-
}) {
|
|
18
|
-
const { t } = useI18n()
|
|
19
|
-
return (
|
|
20
|
-
<DynamicArray
|
|
21
|
-
control={control}
|
|
22
|
-
name="categories"
|
|
23
|
-
label="ln.categories"
|
|
24
|
-
renderElement={(index) => (
|
|
25
|
-
<Select
|
|
26
|
-
options={categories}
|
|
27
|
-
control={control}
|
|
28
|
-
required
|
|
29
|
-
name={`categories.${index}.value`}
|
|
30
|
-
defaultValue={defaultValue[index]?.value}
|
|
31
|
-
preserveHintSpace={false}
|
|
32
|
-
/>
|
|
33
|
-
)}
|
|
34
|
-
renderAddButton={({ addElement, index }) => (
|
|
35
|
-
<Button
|
|
36
|
-
variant="secondary"
|
|
37
|
-
onClick={() =>
|
|
38
|
-
addElement(
|
|
39
|
-
{ value: "" },
|
|
40
|
-
{ focusName: `categories.${index}.value` },
|
|
41
|
-
)
|
|
42
|
-
}
|
|
43
|
-
>
|
|
44
|
-
{t("ln.admin.add-category")}
|
|
45
|
-
</Button>
|
|
46
|
-
)}
|
|
47
|
-
/>
|
|
48
|
-
)
|
|
49
|
-
}
|
|
@@ -1,68 +0,0 @@
|
|
|
1
|
-
import { type Control } from "react-hook-form"
|
|
2
|
-
|
|
3
|
-
import { useI18n } from "../../../../i18n/react/use-i18n"
|
|
4
|
-
import Button from "../../../components/form/atoms/Button"
|
|
5
|
-
import DynamicArray from "../../../components/form/DynamicArray"
|
|
6
|
-
import Input from "../../../components/form/Input"
|
|
7
|
-
import Select from "../../../components/form/Select"
|
|
8
|
-
import type { MediaItem } from "../../../types/media-item"
|
|
9
|
-
|
|
10
|
-
export default function Collections({
|
|
11
|
-
control,
|
|
12
|
-
collections,
|
|
13
|
-
defaultValue,
|
|
14
|
-
}: {
|
|
15
|
-
control: Control<MediaItem>
|
|
16
|
-
collections: { id: string; labelText: string }[]
|
|
17
|
-
defaultValue: MediaItem["collections"]
|
|
18
|
-
}) {
|
|
19
|
-
const { t } = useI18n()
|
|
20
|
-
return (
|
|
21
|
-
<DynamicArray
|
|
22
|
-
control={control}
|
|
23
|
-
name="collections"
|
|
24
|
-
label="ln.admin.collections"
|
|
25
|
-
renderElement={(index) => (
|
|
26
|
-
<div className="flex w-full flex-col gap-6">
|
|
27
|
-
<Select
|
|
28
|
-
options={collections}
|
|
29
|
-
label="ln.admin.name"
|
|
30
|
-
labelSize="small"
|
|
31
|
-
required
|
|
32
|
-
preserveHintSpace={false}
|
|
33
|
-
name={`collections.${index}.collection`}
|
|
34
|
-
control={control}
|
|
35
|
-
defaultValue={defaultValue[index]?.collection}
|
|
36
|
-
/>
|
|
37
|
-
<Input
|
|
38
|
-
type="number"
|
|
39
|
-
control={control}
|
|
40
|
-
label="ln.admin.position-in-collection"
|
|
41
|
-
labelSize="small"
|
|
42
|
-
step={1}
|
|
43
|
-
min={0}
|
|
44
|
-
preserveHintSpace={false}
|
|
45
|
-
defaultValue={defaultValue[index]?.index}
|
|
46
|
-
name={`collections.${index}.index`}
|
|
47
|
-
registerOptions={{
|
|
48
|
-
setValueAs: (value) => (value === "" ? undefined : Number(value)),
|
|
49
|
-
}}
|
|
50
|
-
/>
|
|
51
|
-
</div>
|
|
52
|
-
)}
|
|
53
|
-
renderAddButton={({ addElement, index }) => (
|
|
54
|
-
<Button
|
|
55
|
-
variant="secondary"
|
|
56
|
-
onClick={() =>
|
|
57
|
-
addElement(
|
|
58
|
-
{ collection: "" },
|
|
59
|
-
{ focusName: `collections.${index}.collection` },
|
|
60
|
-
)
|
|
61
|
-
}
|
|
62
|
-
>
|
|
63
|
-
{t("ln.admin.add-collection")}
|
|
64
|
-
</Button>
|
|
65
|
-
)}
|
|
66
|
-
/>
|
|
67
|
-
)
|
|
68
|
-
}
|
|
@@ -1,150 +0,0 @@
|
|
|
1
|
-
import { type Control, useWatch } from "react-hook-form"
|
|
2
|
-
|
|
3
|
-
import Icon from "../../../../components/Icon"
|
|
4
|
-
import { useI18n } from "../../../../i18n/react/use-i18n"
|
|
5
|
-
import { createContentMetadata } from "../../../../pages/details-page/utils/create-content-metadata"
|
|
6
|
-
import Button from "../../../components/form/atoms/Button"
|
|
7
|
-
import FileUpload from "../../../components/form/atoms/FileUpload"
|
|
8
|
-
import DynamicArray from "../../../components/form/DynamicArray"
|
|
9
|
-
import Input from "../../../components/form/Input"
|
|
10
|
-
import type { MediaItem } from "../../../types/media-item"
|
|
11
|
-
|
|
12
|
-
export default function Content({
|
|
13
|
-
control,
|
|
14
|
-
defaultValue,
|
|
15
|
-
}: {
|
|
16
|
-
control: Control<MediaItem>
|
|
17
|
-
defaultValue: MediaItem["content"]
|
|
18
|
-
}) {
|
|
19
|
-
const { t } = useI18n()
|
|
20
|
-
return (
|
|
21
|
-
<DynamicArray
|
|
22
|
-
control={control}
|
|
23
|
-
name="content"
|
|
24
|
-
required
|
|
25
|
-
label="ln.admin.content"
|
|
26
|
-
renderElement={(index) => (
|
|
27
|
-
<div className="flex w-full flex-col gap-6">
|
|
28
|
-
<URLInput
|
|
29
|
-
control={control}
|
|
30
|
-
defaultValue={defaultValue[index]?.url}
|
|
31
|
-
index={index}
|
|
32
|
-
/>
|
|
33
|
-
<LabelInput
|
|
34
|
-
control={control}
|
|
35
|
-
defaultValue={defaultValue[index]?.label}
|
|
36
|
-
index={index}
|
|
37
|
-
/>
|
|
38
|
-
</div>
|
|
39
|
-
)}
|
|
40
|
-
renderElementMeta={(index) => {
|
|
41
|
-
if (index !== 0) {
|
|
42
|
-
return <span></span>
|
|
43
|
-
}
|
|
44
|
-
return (
|
|
45
|
-
<span className="ms-1 flex items-center gap-1 rounded-lg bg-slate-200 px-2 py-1 text-xs font-bold uppercase text-slate-600">
|
|
46
|
-
<Icon className="text-sm text-sky-700 mdi--star" ariaLabel="" />
|
|
47
|
-
{t("ln.admin.primary-content")}
|
|
48
|
-
</span>
|
|
49
|
-
)
|
|
50
|
-
}}
|
|
51
|
-
renderAddButton={({ addElement, index }) => (
|
|
52
|
-
<div className="flex w-2/3 flex-col items-center gap-2 pb-2 pt-4">
|
|
53
|
-
<Button
|
|
54
|
-
variant="secondary"
|
|
55
|
-
onClick={() =>
|
|
56
|
-
addElement({ url: "" }, { focusName: `content.${index}.url` })
|
|
57
|
-
}
|
|
58
|
-
>
|
|
59
|
-
<Icon className="mdi--link-variant" ariaLabel="" />
|
|
60
|
-
{t("ln.admin.add-link")}
|
|
61
|
-
</Button>
|
|
62
|
-
<span className="text-sm font-bold uppercase text-slate-500">
|
|
63
|
-
{t("ln.admin.or")}
|
|
64
|
-
</span>
|
|
65
|
-
<FileUpload
|
|
66
|
-
title="ln.admin.files-upload-title"
|
|
67
|
-
description="ln.admin.files-upload-description"
|
|
68
|
-
icon="mdi--file-upload-outline"
|
|
69
|
-
multiple
|
|
70
|
-
onUpload={(...files: File[]) =>
|
|
71
|
-
files.forEach((file, i) => {
|
|
72
|
-
addElement(
|
|
73
|
-
{ url: `/files/${file.name}`, file },
|
|
74
|
-
{ focusName: `content.${index + i}.url` },
|
|
75
|
-
)
|
|
76
|
-
})
|
|
77
|
-
}
|
|
78
|
-
/>
|
|
79
|
-
</div>
|
|
80
|
-
)}
|
|
81
|
-
/>
|
|
82
|
-
)
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
function URLInput({
|
|
86
|
-
control,
|
|
87
|
-
defaultValue,
|
|
88
|
-
index,
|
|
89
|
-
}: {
|
|
90
|
-
defaultValue?: string
|
|
91
|
-
index: number
|
|
92
|
-
control: Control<MediaItem>
|
|
93
|
-
}) {
|
|
94
|
-
const { url, file } = useWatch({ control, name: `content.${index}` })
|
|
95
|
-
const { t } = useI18n()
|
|
96
|
-
const isFile = url.startsWith("/files/")
|
|
97
|
-
|
|
98
|
-
const label = () => {
|
|
99
|
-
if (!isFile) {
|
|
100
|
-
return "ln.admin.link"
|
|
101
|
-
}
|
|
102
|
-
if (!file) {
|
|
103
|
-
return "ln.admin.file"
|
|
104
|
-
}
|
|
105
|
-
return t("ln.admin.file-upload", {
|
|
106
|
-
fileSize: (file.size / 1024 / 1024).toFixed(1),
|
|
107
|
-
})
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
return (
|
|
111
|
-
<Input
|
|
112
|
-
control={control}
|
|
113
|
-
label={label()}
|
|
114
|
-
disabled={isFile}
|
|
115
|
-
labelSize="small"
|
|
116
|
-
required
|
|
117
|
-
preserveHintSpace={false}
|
|
118
|
-
defaultValue={defaultValue}
|
|
119
|
-
name={`content.${index}.url`}
|
|
120
|
-
/>
|
|
121
|
-
)
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
function LabelInput({
|
|
125
|
-
control,
|
|
126
|
-
defaultValue,
|
|
127
|
-
index,
|
|
128
|
-
}: {
|
|
129
|
-
control: Control<MediaItem>
|
|
130
|
-
defaultValue?: string
|
|
131
|
-
index: number
|
|
132
|
-
}) {
|
|
133
|
-
const url = useWatch({ control, name: `content.${index}.url` })
|
|
134
|
-
const { label: defaultLabel } = createContentMetadata({ url })
|
|
135
|
-
|
|
136
|
-
return (
|
|
137
|
-
<Input
|
|
138
|
-
name={`content.${index}.label`}
|
|
139
|
-
control={control}
|
|
140
|
-
label="ln.admin.label"
|
|
141
|
-
labelSize="small"
|
|
142
|
-
placeholder={defaultLabel}
|
|
143
|
-
preserveHintSpace={false}
|
|
144
|
-
defaultValue={defaultValue}
|
|
145
|
-
registerOptions={{
|
|
146
|
-
setValueAs: (value) => value?.trim() || undefined,
|
|
147
|
-
}}
|
|
148
|
-
/>
|
|
149
|
-
)
|
|
150
|
-
}
|
|
@@ -1,119 +0,0 @@
|
|
|
1
|
-
import { useEffect, useRef, useState } from "react"
|
|
2
|
-
import { type Control, useController } from "react-hook-form"
|
|
3
|
-
|
|
4
|
-
import ErrorMessage from "../../../components/form/atoms/ErrorMessage"
|
|
5
|
-
import FileUpload from "../../../components/form/atoms/FileUpload"
|
|
6
|
-
import Hint from "../../../components/form/atoms/Hint"
|
|
7
|
-
import Label from "../../../components/form/atoms/Label"
|
|
8
|
-
import { useFieldDirty } from "../../../components/form/hooks/use-field-dirty"
|
|
9
|
-
import { useFieldError } from "../../../components/form/hooks/use-field-error"
|
|
10
|
-
import type { MediaItem } from "../../../types/media-item"
|
|
11
|
-
|
|
12
|
-
const acceptedFileTypes = ["image/jpeg", "image/png", "image/webp"] as const
|
|
13
|
-
|
|
14
|
-
const sanitizeImageUrl = (url?: string) => {
|
|
15
|
-
if (!url) {
|
|
16
|
-
return undefined
|
|
17
|
-
}
|
|
18
|
-
const trimmed = url.trim()
|
|
19
|
-
if (!trimmed) {
|
|
20
|
-
return undefined
|
|
21
|
-
}
|
|
22
|
-
if (
|
|
23
|
-
trimmed.startsWith("/") ||
|
|
24
|
-
trimmed.startsWith("./") ||
|
|
25
|
-
trimmed.startsWith("blob:")
|
|
26
|
-
) {
|
|
27
|
-
return trimmed
|
|
28
|
-
}
|
|
29
|
-
if (/^https?:\/\//i.test(trimmed)) {
|
|
30
|
-
return trimmed
|
|
31
|
-
}
|
|
32
|
-
if (/^data:image\/(png|jpe?g|webp);/i.test(trimmed)) {
|
|
33
|
-
return trimmed
|
|
34
|
-
}
|
|
35
|
-
return undefined
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
export default function Image({
|
|
39
|
-
control,
|
|
40
|
-
defaultValue,
|
|
41
|
-
mediaId,
|
|
42
|
-
}: {
|
|
43
|
-
control: Control<MediaItem>
|
|
44
|
-
defaultValue: MediaItem["image"]
|
|
45
|
-
mediaId: string
|
|
46
|
-
}) {
|
|
47
|
-
const { field } = useController({ control, name: "image" })
|
|
48
|
-
const objectUrlRef = useRef<string | null>(null)
|
|
49
|
-
const [previewSrc, setPreviewSrc] = useState<string | undefined>(
|
|
50
|
-
defaultValue.previewSrc,
|
|
51
|
-
)
|
|
52
|
-
const isDirty = useFieldDirty({ control, name: "image" })
|
|
53
|
-
const errorMessage = useFieldError({ control, name: "image", exact: false })
|
|
54
|
-
|
|
55
|
-
useEffect(() => {
|
|
56
|
-
// cleanup on component unmount
|
|
57
|
-
return () => {
|
|
58
|
-
if (objectUrlRef.current) {
|
|
59
|
-
URL.revokeObjectURL(objectUrlRef.current)
|
|
60
|
-
}
|
|
61
|
-
}
|
|
62
|
-
}, [])
|
|
63
|
-
|
|
64
|
-
const updateImage = (file?: File) => {
|
|
65
|
-
if (!file) {
|
|
66
|
-
return
|
|
67
|
-
}
|
|
68
|
-
if (!acceptedFileTypes.includes(file.type as any)) {
|
|
69
|
-
return
|
|
70
|
-
}
|
|
71
|
-
if (objectUrlRef.current) {
|
|
72
|
-
URL.revokeObjectURL(objectUrlRef.current)
|
|
73
|
-
}
|
|
74
|
-
const objectUrl = URL.createObjectURL(file)
|
|
75
|
-
objectUrlRef.current = objectUrl
|
|
76
|
-
|
|
77
|
-
const nameParts = file.name.split(".")
|
|
78
|
-
const extension = nameParts.pop()
|
|
79
|
-
setPreviewSrc(objectUrl)
|
|
80
|
-
field.onChange({
|
|
81
|
-
...field.value,
|
|
82
|
-
path: `./images/${mediaId}.${extension}`,
|
|
83
|
-
file,
|
|
84
|
-
})
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
return (
|
|
88
|
-
<div className="group flex w-full flex-col">
|
|
89
|
-
<label htmlFor="image">
|
|
90
|
-
<Label
|
|
91
|
-
label="ln.admin.image"
|
|
92
|
-
isDirty={isDirty}
|
|
93
|
-
required
|
|
94
|
-
isInvalid={!!errorMessage}
|
|
95
|
-
/>
|
|
96
|
-
</label>
|
|
97
|
-
<div
|
|
98
|
-
className={`flex w-full items-stretch gap-4 rounded-xl rounded-ss-none border bg-slate-50 px-4 py-3 shadow-inner outline-none transition-colors focus-within:border-sky-700 focus-within:ring-1 focus-within:ring-sky-700 ${isDirty && !errorMessage ? "border-slate-700" : "border-slate-300"} ${errorMessage ? "border-rose-800" : ""} `}
|
|
99
|
-
>
|
|
100
|
-
<div className="flex h-32 w-32 shrink-0 items-center justify-center overflow-hidden rounded-xl bg-slate-200 p-1">
|
|
101
|
-
<img
|
|
102
|
-
src={sanitizeImageUrl(previewSrc)}
|
|
103
|
-
alt=""
|
|
104
|
-
className="h-full w-full object-contain"
|
|
105
|
-
/>
|
|
106
|
-
</div>
|
|
107
|
-
<FileUpload
|
|
108
|
-
title="ln.admin.image-upload-title"
|
|
109
|
-
description="ln.admin.image-upload-description"
|
|
110
|
-
onUpload={updateImage}
|
|
111
|
-
onBlur={field.onBlur}
|
|
112
|
-
acceptedFileTypes={acceptedFileTypes}
|
|
113
|
-
/>
|
|
114
|
-
</div>
|
|
115
|
-
<ErrorMessage message={errorMessage} />
|
|
116
|
-
<Hint preserveSpace={true} />
|
|
117
|
-
</div>
|
|
118
|
-
)
|
|
119
|
-
}
|
|
@@ -1,41 +0,0 @@
|
|
|
1
|
-
export const writeFile = (
|
|
2
|
-
path: string,
|
|
3
|
-
body: BodyInit,
|
|
4
|
-
contentType?: string,
|
|
5
|
-
) => {
|
|
6
|
-
return fetch(
|
|
7
|
-
`/api/internal/fs/write-file?path=${encodeURIComponent(path.replace(/^\//, ""))}`,
|
|
8
|
-
{
|
|
9
|
-
method: "POST",
|
|
10
|
-
headers: { "Content-Type": contentType ?? resolveContentType(path) },
|
|
11
|
-
body,
|
|
12
|
-
},
|
|
13
|
-
)
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
export const writeJson = async (path: string, object: unknown) => {
|
|
17
|
-
return writeFile(path, JSON.stringify(sortObject(object), null, 2))
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
const resolveContentType = (path: string) => {
|
|
21
|
-
const normalizedPath = path.trim().toLowerCase()
|
|
22
|
-
return normalizedPath.endsWith(".json")
|
|
23
|
-
? "application/json"
|
|
24
|
-
: "text/plain; charset=utf-8"
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
const sortObject = (value: unknown): unknown => {
|
|
28
|
-
if (Array.isArray(value)) {
|
|
29
|
-
return value.map(sortObject)
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
if (value && typeof value === "object") {
|
|
33
|
-
const entries = Object.entries(value as Record<string, unknown>)
|
|
34
|
-
.sort(([a], [b]) => (a > b ? 1 : a < b ? -1 : 0))
|
|
35
|
-
.map(([key, nestedValue]) => [key, sortObject(nestedValue)])
|
|
36
|
-
|
|
37
|
-
return Object.fromEntries(entries)
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
return value
|
|
41
|
-
}
|
|
@@ -1,61 +0,0 @@
|
|
|
1
|
-
import { type MediaItem } from "../../types/media-item"
|
|
2
|
-
import { writeFile, writeJson } from "./file-system"
|
|
3
|
-
|
|
4
|
-
export const updateMediaItem = async (id: string, item: MediaItem) => {
|
|
5
|
-
const imagePath = await saveImage(item.image)
|
|
6
|
-
await Promise.all(item.content.map(saveContentFile))
|
|
7
|
-
return writeJson(
|
|
8
|
-
`/src/content/media/${id}.json`,
|
|
9
|
-
mapToContentSchema(item, imagePath),
|
|
10
|
-
)
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
const ensureRelativePath = (path: string) => {
|
|
14
|
-
const trimmed = path.trim()
|
|
15
|
-
if (!trimmed) {
|
|
16
|
-
return ""
|
|
17
|
-
}
|
|
18
|
-
if (trimmed.startsWith("./")) {
|
|
19
|
-
return trimmed
|
|
20
|
-
}
|
|
21
|
-
return `./${trimmed}`
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
const saveImage = async (image: MediaItem["image"]) => {
|
|
25
|
-
const relativePath = ensureRelativePath(image?.path ?? "")
|
|
26
|
-
if (!relativePath || !image?.file) {
|
|
27
|
-
return relativePath
|
|
28
|
-
}
|
|
29
|
-
await writeFile(
|
|
30
|
-
`/src/content/media/${relativePath.replace(/^\.\//, "")}`,
|
|
31
|
-
await image.file.arrayBuffer(),
|
|
32
|
-
image.file.type || "application/octet-stream",
|
|
33
|
-
)
|
|
34
|
-
return relativePath
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
const saveContentFile = async ({ url, file }: MediaItem["content"][number]) => {
|
|
38
|
-
if (!file) {
|
|
39
|
-
return
|
|
40
|
-
}
|
|
41
|
-
const path = `/public/${url.replace(/^\//, "")}`
|
|
42
|
-
return writeFile(
|
|
43
|
-
path,
|
|
44
|
-
await file.arrayBuffer(),
|
|
45
|
-
file.type || "application/octet-stream",
|
|
46
|
-
)
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
const mapToContentSchema = (item: MediaItem, imagePath: string) => {
|
|
50
|
-
return {
|
|
51
|
-
...item,
|
|
52
|
-
image: imagePath,
|
|
53
|
-
authors: flatten(item.authors),
|
|
54
|
-
content: item.content.map(({ file: _, ...content }) => content),
|
|
55
|
-
categories: flatten(item.categories),
|
|
56
|
-
}
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
const flatten = <TValue>(valueArray: { value: TValue }[]) => {
|
|
60
|
-
return valueArray.map(({ value }) => value)
|
|
61
|
-
}
|