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.
Files changed (47) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/__e2e__/admin.spec.ts +1 -365
  3. package/__e2e__/fixtures/basics/node_modules/.bin/astro +2 -2
  4. package/__e2e__/fixtures/basics/package.json +3 -3
  5. package/package.json +8 -15
  6. package/src/astro-integration/config.ts +1 -22
  7. package/src/astro-integration/integration.ts +4 -39
  8. package/src/content/content-schema.ts +0 -7
  9. package/src/content/get-media-items.ts +1 -47
  10. package/src/i18n/translate.ts +1 -1
  11. package/src/i18n/translations/TRANSLATION-STATUS.md +37 -12
  12. package/src/i18n/translations/en.yml +5 -0
  13. package/src/i18n/translations.ts +0 -7
  14. package/src/layouts/Page.astro +2 -0
  15. package/src/layouts/global.css +3 -0
  16. package/src/pages/details-page/components/main-details/EditButton.astro +2 -4
  17. package/src/pages/search-page/components/SearchFilter.tsx +1 -1
  18. package/src/admin/api/fs/write-file.ts +0 -53
  19. package/src/admin/components/form/DynamicArray.tsx +0 -129
  20. package/src/admin/components/form/Input.tsx +0 -68
  21. package/src/admin/components/form/LazyLoadedMarkdownEditor.tsx +0 -109
  22. package/src/admin/components/form/MarkdownEditor.tsx +0 -62
  23. package/src/admin/components/form/Select.tsx +0 -71
  24. package/src/admin/components/form/SubmitButton.tsx +0 -86
  25. package/src/admin/components/form/atoms/Button.tsx +0 -27
  26. package/src/admin/components/form/atoms/ErrorMessage.tsx +0 -13
  27. package/src/admin/components/form/atoms/FileUpload.tsx +0 -190
  28. package/src/admin/components/form/atoms/Hint.tsx +0 -19
  29. package/src/admin/components/form/atoms/Label.tsx +0 -37
  30. package/src/admin/components/form/hooks/use-field-dirty.tsx +0 -12
  31. package/src/admin/components/form/hooks/use-field-error.tsx +0 -34
  32. package/src/admin/components/form/utils/get-border-class.ts +0 -22
  33. package/src/admin/i18n/admin-i18n.ts +0 -21
  34. package/src/admin/i18n/translations/en.yml +0 -50
  35. package/src/admin/i18n/translations.ts +0 -5
  36. package/src/admin/pages/AdminRoute.astro +0 -14
  37. package/src/admin/pages/media/EditForm.tsx +0 -136
  38. package/src/admin/pages/media/EditRoute.astro +0 -100
  39. package/src/admin/pages/media/fields/Authors.tsx +0 -44
  40. package/src/admin/pages/media/fields/Categories.tsx +0 -49
  41. package/src/admin/pages/media/fields/Collections.tsx +0 -68
  42. package/src/admin/pages/media/fields/Content.tsx +0 -150
  43. package/src/admin/pages/media/fields/Image.tsx +0 -119
  44. package/src/admin/pages/media/file-system.ts +0 -41
  45. package/src/admin/pages/media/media-item-store.ts +0 -61
  46. package/src/admin/types/media-item.ts +0 -81
  47. 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
- }