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.
Files changed (56) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/__e2e__/admin.spec.ts +54 -20
  3. package/__e2e__/fixtures/basics/node_modules/.bin/astro +2 -2
  4. package/__e2e__/fixtures/basics/node_modules/.bin/tailwind +2 -2
  5. package/__e2e__/fixtures/basics/node_modules/.bin/tailwindcss +2 -2
  6. package/__e2e__/fixtures/basics/package.json +5 -5
  7. package/__tests__/pages/details-page/create-content-metadata.spec.ts +23 -3
  8. package/package.json +10 -10
  9. package/src/admin/components/form/DynamicArray.tsx +82 -30
  10. package/src/admin/components/form/Input.tsx +17 -3
  11. package/src/admin/components/form/LazyLoadedMarkdownEditor.tsx +2 -2
  12. package/src/admin/components/form/MarkdownEditor.tsx +12 -4
  13. package/src/admin/components/form/Select.tsx +25 -13
  14. package/src/admin/components/form/SubmitButton.tsx +3 -3
  15. package/src/admin/components/form/atoms/Button.tsx +27 -0
  16. package/src/admin/components/form/atoms/ErrorMessage.tsx +1 -1
  17. package/src/admin/components/form/atoms/FileUpload.tsx +190 -0
  18. package/src/admin/components/form/atoms/Hint.tsx +3 -3
  19. package/src/admin/components/form/atoms/Label.tsx +18 -7
  20. package/src/admin/components/form/hooks/use-field-error.tsx +23 -2
  21. package/src/admin/components/form/utils/get-border-class.ts +22 -0
  22. package/src/admin/i18n/admin-i18n.ts +21 -0
  23. package/src/admin/i18n/translations/en.yml +24 -2
  24. package/src/admin/pages/AdminRoute.astro +1 -3
  25. package/src/admin/pages/media/EditForm.tsx +35 -11
  26. package/src/admin/pages/media/EditRoute.astro +33 -17
  27. package/src/admin/pages/media/fields/Authors.tsx +15 -5
  28. package/src/admin/pages/media/fields/Categories.tsx +17 -5
  29. package/src/admin/pages/media/fields/Collections.tsx +21 -11
  30. package/src/admin/pages/media/fields/Content.tsx +150 -0
  31. package/src/admin/pages/media/fields/Image.tsx +119 -0
  32. package/src/admin/pages/media/file-system.ts +6 -2
  33. package/src/admin/pages/media/media-item-store.ts +46 -3
  34. package/src/admin/types/media-item.ts +29 -0
  35. package/src/astro-integration/config.ts +10 -0
  36. package/src/astro-integration/integration.ts +7 -3
  37. package/src/components/SearchInput.astro +3 -3
  38. package/src/content/get-media-items.ts +2 -1
  39. package/src/i18n/react/i18n-context.ts +16 -5
  40. package/src/i18n/react/prepare-i18n-config.ts +1 -1
  41. package/src/i18n/react/{useI18n.ts → use-i18n.ts} +1 -1
  42. package/src/i18n/translations/TRANSLATION-STATUS.md +4 -0
  43. package/src/i18n/translations/ur.yml +25 -0
  44. package/src/i18n/translations.ts +1 -0
  45. package/src/layouts/Page.astro +5 -6
  46. package/src/layouts/components/LanguagePicker.astro +11 -5
  47. package/src/layouts/components/Menu.astro +76 -10
  48. package/src/pages/404Route.astro +3 -1
  49. package/src/pages/details-page/components/main-details/EditButton.astro +1 -1
  50. package/src/pages/details-page/utils/create-content-metadata.ts +2 -1
  51. package/src/pages/search-page/components/LoadingSkeleton.tsx +21 -14
  52. package/src/pages/search-page/components/SearchFilter.tsx +2 -2
  53. package/src/pages/search-page/components/SearchList.tsx +33 -29
  54. package/src/pages/search-page/components/SearchListItem.tsx +1 -1
  55. package/src/pages/search-page/components/Select.tsx +15 -13
  56. 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 resolveLocales(config).flatMap((locale) =>
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(Astro.locals.i18n, [
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 } = Astro.locals.i18n
52
+ const { t, currentLocale } = adminI18n
41
53
 
42
- const mediaTypes = (await getMediaTypes()).map(({ id, data: { label } }) => ({
54
+ const mediaTypes = (await getMediaTypes()).map(({ id }) => ({
43
55
  id,
44
- labelText: t(label),
56
+ labelText: id,
45
57
  }))
46
58
 
47
- const categories = (await getCategories(currentLocale, t)).map(
48
- ({ id, labelText }) => ({ id, labelText }),
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, data: { label } }) => ({ id, labelText: t(label) }),
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 mainClass="bg-gray-700">
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-gray-200 underline"
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-2xl bg-gray-200 px-4 py-8 shadow-md md:px-8"
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
- addButton={{
28
- label: "ln.admin.add-author",
29
- onClick: (append, index) =>
30
- append({ value: "" }, { focusName: `authors.${index}.value` }),
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
- addButton={{
31
- label: "ln.admin.add-category",
32
- onClick: (append, index) =>
33
- append({ value: "" }, { focusName: `categories.${index}.value` }),
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-4 py-2">
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
- {...control.register(`collections.${index}.index`, {
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
- addButton={{
49
- label: "ln.admin.add-collection",
50
- onClick: (append, index) =>
51
- append(
52
- { collection: "" },
53
- { focusName: `collections.${index}.collection` },
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 = (path: string, body: string) => {
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
- return writeJson(`/src/content/media/${id}.json`, mapToContentSchema(item))
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 mapToContentSchema = (item: MediaItem) => {
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
  })