lightnet 3.10.7 → 3.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +25 -0
- package/__e2e__/admin.spec.ts +54 -20
- package/__e2e__/fixtures/basics/node_modules/.bin/astro +2 -2
- package/__e2e__/fixtures/basics/node_modules/.bin/tailwind +2 -2
- package/__e2e__/fixtures/basics/node_modules/.bin/tailwindcss +2 -2
- package/__e2e__/fixtures/basics/package.json +5 -5
- package/__tests__/pages/details-page/create-content-metadata.spec.ts +23 -3
- package/package.json +10 -10
- package/src/admin/components/form/DynamicArray.tsx +82 -30
- package/src/admin/components/form/Input.tsx +17 -3
- package/src/admin/components/form/LazyLoadedMarkdownEditor.tsx +2 -2
- package/src/admin/components/form/MarkdownEditor.tsx +12 -4
- package/src/admin/components/form/Select.tsx +25 -13
- package/src/admin/components/form/SubmitButton.tsx +3 -3
- package/src/admin/components/form/atoms/Button.tsx +27 -0
- package/src/admin/components/form/atoms/ErrorMessage.tsx +1 -1
- package/src/admin/components/form/atoms/FileUpload.tsx +190 -0
- package/src/admin/components/form/atoms/Hint.tsx +3 -3
- package/src/admin/components/form/atoms/Label.tsx +18 -7
- package/src/admin/components/form/hooks/use-field-error.tsx +23 -2
- package/src/admin/components/form/utils/get-border-class.ts +22 -0
- package/src/admin/i18n/admin-i18n.ts +21 -0
- package/src/admin/i18n/translations/en.yml +24 -2
- package/src/admin/pages/AdminRoute.astro +1 -3
- package/src/admin/pages/media/EditForm.tsx +35 -11
- package/src/admin/pages/media/EditRoute.astro +33 -17
- package/src/admin/pages/media/fields/Authors.tsx +15 -5
- package/src/admin/pages/media/fields/Categories.tsx +17 -5
- package/src/admin/pages/media/fields/Collections.tsx +21 -11
- package/src/admin/pages/media/fields/Content.tsx +150 -0
- package/src/admin/pages/media/fields/Image.tsx +119 -0
- package/src/admin/pages/media/file-system.ts +6 -2
- package/src/admin/pages/media/media-item-store.ts +46 -3
- package/src/admin/types/media-item.ts +29 -0
- package/src/astro-integration/config.ts +10 -0
- package/src/astro-integration/integration.ts +7 -3
- package/src/components/SearchInput.astro +3 -3
- package/src/content/get-media-items.ts +2 -1
- package/src/i18n/react/i18n-context.ts +16 -5
- package/src/i18n/react/prepare-i18n-config.ts +1 -1
- package/src/i18n/react/{useI18n.ts → use-i18n.ts} +1 -1
- package/src/i18n/translations/TRANSLATION-STATUS.md +4 -0
- package/src/i18n/translations/ur.yml +25 -0
- package/src/i18n/translations.ts +1 -0
- package/src/layouts/Page.astro +5 -6
- package/src/layouts/components/LanguagePicker.astro +11 -5
- package/src/layouts/components/Menu.astro +76 -10
- package/src/pages/404Route.astro +3 -1
- package/src/pages/details-page/components/main-details/EditButton.astro +1 -1
- package/src/pages/details-page/utils/create-content-metadata.ts +2 -1
- package/src/pages/search-page/components/LoadingSkeleton.tsx +21 -14
- package/src/pages/search-page/components/SearchFilter.tsx +2 -2
- package/src/pages/search-page/components/SearchList.tsx +33 -29
- package/src/pages/search-page/components/SearchListItem.tsx +1 -1
- package/src/pages/search-page/components/Select.tsx +15 -13
- package/src/layouts/components/PreloadReact.tsx +0 -3
|
@@ -1,28 +1,40 @@
|
|
|
1
1
|
---
|
|
2
2
|
import type { GetStaticPaths } from "astro"
|
|
3
|
+
import { getImage } from "astro:assets"
|
|
3
4
|
import { getCollection } from "astro:content"
|
|
4
5
|
import config from "virtual:lightnet/config"
|
|
5
6
|
|
|
6
7
|
import { getCategories } from "../../../content/get-categories"
|
|
7
|
-
import { getRawMediaItem } from "../../../content/get-media-items"
|
|
8
|
+
import { getMediaItem, getRawMediaItem } from "../../../content/get-media-items"
|
|
8
9
|
import { getMediaTypes } from "../../../content/get-media-types"
|
|
9
10
|
import { prepareI18nConfig } from "../../../i18n/react/prepare-i18n-config"
|
|
10
|
-
import { resolveLocales } from "../../../i18n/resolve-locales"
|
|
11
11
|
import Page from "../../../layouts/Page.astro"
|
|
12
|
+
import { adminI18n } from "../../i18n/admin-i18n"
|
|
12
13
|
import EditForm from "./EditForm"
|
|
13
14
|
|
|
14
15
|
export const getStaticPaths = (async () => {
|
|
15
16
|
const mediaItems = await getCollection("media")
|
|
16
|
-
return
|
|
17
|
-
mediaItems.map(({ id: mediaId }) => ({ params: { mediaId, locale } })),
|
|
18
|
-
)
|
|
17
|
+
return mediaItems.map(({ id: mediaId }) => ({ params: { mediaId } }))
|
|
19
18
|
}) satisfies GetStaticPaths
|
|
20
19
|
|
|
21
20
|
const { mediaId } = Astro.params
|
|
22
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
|
+
})
|
|
23
31
|
|
|
24
32
|
const formData = {
|
|
25
33
|
...mediaItem,
|
|
34
|
+
image: {
|
|
35
|
+
path: mediaItem.image,
|
|
36
|
+
previewSrc: previewImage.src,
|
|
37
|
+
},
|
|
26
38
|
authors: mediaItem.authors?.map((value) => ({ value })) ?? [],
|
|
27
39
|
categories:
|
|
28
40
|
mediaItem.categories?.map((value) => ({
|
|
@@ -31,44 +43,48 @@ const formData = {
|
|
|
31
43
|
collections: mediaItem.collections ?? [],
|
|
32
44
|
}
|
|
33
45
|
|
|
34
|
-
const i18nConfig = prepareI18nConfig(
|
|
46
|
+
const i18nConfig = prepareI18nConfig(adminI18n, [
|
|
35
47
|
"ln.admin.*",
|
|
36
48
|
"ln.type",
|
|
37
49
|
"ln.language",
|
|
38
50
|
"ln.categories",
|
|
39
51
|
])
|
|
40
|
-
const { t, currentLocale } =
|
|
52
|
+
const { t, currentLocale } = adminI18n
|
|
41
53
|
|
|
42
|
-
const mediaTypes = (await getMediaTypes()).map(({ id
|
|
54
|
+
const mediaTypes = (await getMediaTypes()).map(({ id }) => ({
|
|
43
55
|
id,
|
|
44
|
-
labelText:
|
|
56
|
+
labelText: id,
|
|
45
57
|
}))
|
|
46
58
|
|
|
47
|
-
const categories = (await getCategories(currentLocale, t)).map(
|
|
48
|
-
|
|
49
|
-
|
|
59
|
+
const categories = (await getCategories(currentLocale, t)).map(({ id }) => ({
|
|
60
|
+
id,
|
|
61
|
+
labelText: id,
|
|
62
|
+
}))
|
|
50
63
|
|
|
51
64
|
const collections = (await getCollection("media-collections")).map(
|
|
52
|
-
({ id
|
|
65
|
+
({ id }) => ({ id, labelText: id }),
|
|
53
66
|
)
|
|
54
67
|
|
|
55
68
|
const languages = config.languages.map(({ code, label }) => ({
|
|
56
69
|
id: code,
|
|
57
|
-
labelText: t(label)
|
|
70
|
+
labelText: `${code} - ${t(label)}`,
|
|
58
71
|
}))
|
|
59
72
|
---
|
|
60
73
|
|
|
61
|
-
<Page
|
|
74
|
+
<Page
|
|
75
|
+
mainClass="bg-gradient-to-t from-sky-950 to-slate-800"
|
|
76
|
+
locale={currentLocale}
|
|
77
|
+
>
|
|
62
78
|
<div class="mx-auto block max-w-screen-md px-4 md:px-8">
|
|
63
79
|
<a
|
|
64
|
-
class="block pb-4 pt-8 text-
|
|
80
|
+
class="block pb-4 pt-8 text-slate-200 underline"
|
|
65
81
|
href=`/${Astro.currentLocale}/media/${mediaId}`
|
|
66
82
|
>{t("ln.admin.back-to-details-page")}</a
|
|
67
83
|
>
|
|
68
84
|
</div>
|
|
69
85
|
|
|
70
86
|
<div
|
|
71
|
-
class="mx-auto max-w-screen-md rounded-
|
|
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"
|
|
72
88
|
>
|
|
73
89
|
<EditForm
|
|
74
90
|
mediaId={mediaId}
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { type Control } from "react-hook-form"
|
|
2
2
|
|
|
3
|
+
import { useI18n } from "../../../../i18n/react/use-i18n"
|
|
4
|
+
import Button from "../../../components/form/atoms/Button"
|
|
3
5
|
import DynamicArray from "../../../components/form/DynamicArray"
|
|
4
6
|
import Input from "../../../components/form/Input"
|
|
5
7
|
import type { MediaItem } from "../../../types/media-item"
|
|
@@ -11,6 +13,7 @@ export default function Authors({
|
|
|
11
13
|
control: Control<MediaItem>
|
|
12
14
|
defaultValue: MediaItem["authors"]
|
|
13
15
|
}) {
|
|
16
|
+
const { t } = useI18n()
|
|
14
17
|
return (
|
|
15
18
|
<DynamicArray
|
|
16
19
|
control={control}
|
|
@@ -20,15 +23,22 @@ export default function Authors({
|
|
|
20
23
|
<Input
|
|
21
24
|
name={`authors.${index}.value`}
|
|
22
25
|
preserveHintSpace={false}
|
|
26
|
+
placeholder={t("ln.admin.author-name")}
|
|
27
|
+
required
|
|
23
28
|
control={control}
|
|
24
29
|
defaultValue={defaultValue[index]?.value}
|
|
25
30
|
/>
|
|
26
31
|
)}
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
+
)}
|
|
32
42
|
/>
|
|
33
43
|
)
|
|
34
44
|
}
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { type Control } from "react-hook-form"
|
|
2
2
|
|
|
3
|
+
import { useI18n } from "../../../../i18n/react/use-i18n"
|
|
4
|
+
import Button from "../../../components/form/atoms/Button"
|
|
3
5
|
import DynamicArray from "../../../components/form/DynamicArray"
|
|
4
6
|
import Select from "../../../components/form/Select"
|
|
5
7
|
import type { MediaItem } from "../../../types/media-item"
|
|
@@ -13,6 +15,7 @@ export default function Categories({
|
|
|
13
15
|
defaultValue: MediaItem["categories"]
|
|
14
16
|
categories: { id: string; labelText: string }[]
|
|
15
17
|
}) {
|
|
18
|
+
const { t } = useI18n()
|
|
16
19
|
return (
|
|
17
20
|
<DynamicArray
|
|
18
21
|
control={control}
|
|
@@ -22,16 +25,25 @@ export default function Categories({
|
|
|
22
25
|
<Select
|
|
23
26
|
options={categories}
|
|
24
27
|
control={control}
|
|
28
|
+
required
|
|
25
29
|
name={`categories.${index}.value`}
|
|
26
30
|
defaultValue={defaultValue[index]?.value}
|
|
27
31
|
preserveHintSpace={false}
|
|
28
32
|
/>
|
|
29
33
|
)}
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
+
)}
|
|
35
47
|
/>
|
|
36
48
|
)
|
|
37
49
|
}
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { type Control } from "react-hook-form"
|
|
2
2
|
|
|
3
|
+
import { useI18n } from "../../../../i18n/react/use-i18n"
|
|
4
|
+
import Button from "../../../components/form/atoms/Button"
|
|
3
5
|
import DynamicArray from "../../../components/form/DynamicArray"
|
|
4
6
|
import Input from "../../../components/form/Input"
|
|
5
7
|
import Select from "../../../components/form/Select"
|
|
@@ -14,17 +16,19 @@ export default function Collections({
|
|
|
14
16
|
collections: { id: string; labelText: string }[]
|
|
15
17
|
defaultValue: MediaItem["collections"]
|
|
16
18
|
}) {
|
|
19
|
+
const { t } = useI18n()
|
|
17
20
|
return (
|
|
18
21
|
<DynamicArray
|
|
19
22
|
control={control}
|
|
20
23
|
name="collections"
|
|
21
24
|
label="ln.admin.collections"
|
|
22
25
|
renderElement={(index) => (
|
|
23
|
-
<div className="flex w-full flex-col gap-
|
|
26
|
+
<div className="flex w-full flex-col gap-6">
|
|
24
27
|
<Select
|
|
25
28
|
options={collections}
|
|
26
29
|
label="ln.admin.name"
|
|
27
30
|
labelSize="small"
|
|
31
|
+
required
|
|
28
32
|
preserveHintSpace={false}
|
|
29
33
|
name={`collections.${index}.collection`}
|
|
30
34
|
control={control}
|
|
@@ -39,20 +43,26 @@ export default function Collections({
|
|
|
39
43
|
min={0}
|
|
40
44
|
preserveHintSpace={false}
|
|
41
45
|
defaultValue={defaultValue[index]?.index}
|
|
42
|
-
{
|
|
46
|
+
name={`collections.${index}.index`}
|
|
47
|
+
registerOptions={{
|
|
43
48
|
setValueAs: (value) => (value === "" ? undefined : Number(value)),
|
|
44
|
-
}
|
|
49
|
+
}}
|
|
45
50
|
/>
|
|
46
51
|
</div>
|
|
47
52
|
)}
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
+
)}
|
|
56
66
|
/>
|
|
57
67
|
)
|
|
58
68
|
}
|
|
@@ -0,0 +1,150 @@
|
|
|
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-sky-700 px-2 py-1 text-xs uppercase text-slate-100">
|
|
46
|
+
<Icon className="text-sm 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
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
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,9 +1,13 @@
|
|
|
1
|
-
export const writeFile = (
|
|
1
|
+
export const writeFile = (
|
|
2
|
+
path: string,
|
|
3
|
+
body: BodyInit,
|
|
4
|
+
contentType?: string,
|
|
5
|
+
) => {
|
|
2
6
|
return fetch(
|
|
3
7
|
`/api/internal/fs/write-file?path=${encodeURIComponent(path.replace(/^\//, ""))}`,
|
|
4
8
|
{
|
|
5
9
|
method: "POST",
|
|
6
|
-
headers: { "Content-Type": resolveContentType(path) },
|
|
10
|
+
headers: { "Content-Type": contentType ?? resolveContentType(path) },
|
|
7
11
|
body,
|
|
8
12
|
},
|
|
9
13
|
)
|
|
@@ -1,14 +1,57 @@
|
|
|
1
1
|
import { type MediaItem } from "../../types/media-item"
|
|
2
|
-
import { writeJson } from "./file-system"
|
|
2
|
+
import { writeFile, writeJson } from "./file-system"
|
|
3
3
|
|
|
4
4
|
export const updateMediaItem = async (id: string, item: MediaItem) => {
|
|
5
|
-
|
|
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
|
+
)
|
|
6
11
|
}
|
|
7
12
|
|
|
8
|
-
const
|
|
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) => {
|
|
9
50
|
return {
|
|
10
51
|
...item,
|
|
52
|
+
image: imagePath,
|
|
11
53
|
authors: flatten(item.authors),
|
|
54
|
+
content: item.content.map(({ file: _, ...content }) => content),
|
|
12
55
|
categories: flatten(item.categories),
|
|
13
56
|
}
|
|
14
57
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { type RefinementCtx, z } from "astro/zod"
|
|
2
|
+
import config from "virtual:lightnet/config"
|
|
2
3
|
|
|
3
4
|
const NON_EMPTY_STRING = "ln.admin.errors.non-empty-string"
|
|
4
5
|
const INVALID_DATE = "ln.admin.errors.invalid-date"
|
|
@@ -6,6 +7,8 @@ const REQUIRED = "ln.admin.errors.required"
|
|
|
6
7
|
const GTE_0 = "ln.admin.errors.gte-0"
|
|
7
8
|
const INTEGER = "ln.admin.errors.integer"
|
|
8
9
|
const UNIQUE_ELEMENTS = "ln.admin.errors.unique-elements"
|
|
10
|
+
const FILE_SIZE_EXCEEDED = "ln.admin.error.file-size-exceeded"
|
|
11
|
+
const NON_EMPTY_LIST = "ln.admin.errors.non-empty-list"
|
|
9
12
|
|
|
10
13
|
const unique = <TArrayItem>(path: Extract<keyof TArrayItem, string>) => {
|
|
11
14
|
return (values: TArrayItem[], ctx: RefinementCtx) => {
|
|
@@ -23,6 +26,19 @@ const unique = <TArrayItem>(path: Extract<keyof TArrayItem, string>) => {
|
|
|
23
26
|
}
|
|
24
27
|
}
|
|
25
28
|
|
|
29
|
+
const fileShape = z
|
|
30
|
+
.instanceof(File)
|
|
31
|
+
.optional()
|
|
32
|
+
.refine(
|
|
33
|
+
(file) =>
|
|
34
|
+
!file ||
|
|
35
|
+
!!(
|
|
36
|
+
file.size <
|
|
37
|
+
(config.experimental?.admin?.maxFileSize ?? 0) * 1024 * 1024
|
|
38
|
+
),
|
|
39
|
+
{ message: FILE_SIZE_EXCEEDED },
|
|
40
|
+
)
|
|
41
|
+
|
|
26
42
|
export const mediaItemSchema = z.object({
|
|
27
43
|
commonId: z.string().nonempty(NON_EMPTY_STRING),
|
|
28
44
|
title: z.string().nonempty(NON_EMPTY_STRING),
|
|
@@ -47,6 +63,19 @@ export const mediaItemSchema = z.object({
|
|
|
47
63
|
.superRefine(unique("collection")),
|
|
48
64
|
dateCreated: z.string().date(INVALID_DATE),
|
|
49
65
|
description: z.string().optional(),
|
|
66
|
+
image: z.object({
|
|
67
|
+
path: z.string().nonempty(NON_EMPTY_STRING),
|
|
68
|
+
previewSrc: z.string(),
|
|
69
|
+
file: fileShape,
|
|
70
|
+
}),
|
|
71
|
+
content: z
|
|
72
|
+
.object({
|
|
73
|
+
url: z.string().nonempty(NON_EMPTY_STRING),
|
|
74
|
+
file: fileShape,
|
|
75
|
+
label: z.string().optional(),
|
|
76
|
+
})
|
|
77
|
+
.array()
|
|
78
|
+
.min(1, NON_EMPTY_LIST),
|
|
50
79
|
})
|
|
51
80
|
|
|
52
81
|
export type MediaItem = z.input<typeof mediaItemSchema>
|
|
@@ -228,6 +228,16 @@ export const configSchema = z.object({
|
|
|
228
228
|
admin: z
|
|
229
229
|
.object({
|
|
230
230
|
enabled: z.boolean().default(false),
|
|
231
|
+
/**
|
|
232
|
+
* Currently we only support english as Admin UI language.
|
|
233
|
+
*/
|
|
234
|
+
languageCode: z.literal("en").default("en"),
|
|
235
|
+
/**
|
|
236
|
+
* Max file size to upload in mega bytes.
|
|
237
|
+
*
|
|
238
|
+
* Default is 25 (this aligns with Cloudflare's max file size).
|
|
239
|
+
*/
|
|
240
|
+
maxFileSize: z.number().default(25),
|
|
231
241
|
})
|
|
232
242
|
.optional(),
|
|
233
243
|
})
|