lightnet 3.11.0 → 3.12.1

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 (49) hide show
  1. package/CHANGELOG.md +24 -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 +4 -4
  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/components/VideoPlayer.astro +74 -10
  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 +8 -4
  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/AudioPanel.astro +1 -12
  17. package/src/pages/details-page/components/AudioPlayer.astro +40 -8
  18. package/src/pages/details-page/components/main-details/EditButton.astro +2 -4
  19. package/src/pages/search-page/components/SearchFilter.tsx +1 -1
  20. package/src/admin/api/fs/write-file.ts +0 -53
  21. package/src/admin/components/form/DynamicArray.tsx +0 -129
  22. package/src/admin/components/form/Input.tsx +0 -68
  23. package/src/admin/components/form/LazyLoadedMarkdownEditor.tsx +0 -102
  24. package/src/admin/components/form/MarkdownEditor.tsx +0 -62
  25. package/src/admin/components/form/Select.tsx +0 -71
  26. package/src/admin/components/form/SubmitButton.tsx +0 -86
  27. package/src/admin/components/form/atoms/Button.tsx +0 -27
  28. package/src/admin/components/form/atoms/ErrorMessage.tsx +0 -13
  29. package/src/admin/components/form/atoms/FileUpload.tsx +0 -190
  30. package/src/admin/components/form/atoms/Hint.tsx +0 -19
  31. package/src/admin/components/form/atoms/Label.tsx +0 -37
  32. package/src/admin/components/form/hooks/use-field-dirty.tsx +0 -12
  33. package/src/admin/components/form/hooks/use-field-error.tsx +0 -34
  34. package/src/admin/components/form/utils/get-border-class.ts +0 -22
  35. package/src/admin/i18n/admin-i18n.ts +0 -21
  36. package/src/admin/i18n/translations/en.yml +0 -50
  37. package/src/admin/i18n/translations.ts +0 -5
  38. package/src/admin/pages/AdminRoute.astro +0 -14
  39. package/src/admin/pages/media/EditForm.tsx +0 -136
  40. package/src/admin/pages/media/EditRoute.astro +0 -100
  41. package/src/admin/pages/media/fields/Authors.tsx +0 -44
  42. package/src/admin/pages/media/fields/Categories.tsx +0 -49
  43. package/src/admin/pages/media/fields/Collections.tsx +0 -68
  44. package/src/admin/pages/media/fields/Content.tsx +0 -150
  45. package/src/admin/pages/media/fields/Image.tsx +0 -119
  46. package/src/admin/pages/media/file-system.ts +0 -41
  47. package/src/admin/pages/media/media-item-store.ts +0 -61
  48. package/src/admin/types/media-item.ts +0 -81
  49. package/src/api/media/[mediaId].ts +0 -16
@@ -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-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
- }
@@ -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
- }
@@ -1,81 +0,0 @@
1
- import { type RefinementCtx, z } from "astro/zod"
2
- import config from "virtual:lightnet/config"
3
-
4
- const NON_EMPTY_STRING = "ln.admin.errors.non-empty-string"
5
- const INVALID_DATE = "ln.admin.errors.invalid-date"
6
- const REQUIRED = "ln.admin.errors.required"
7
- const GTE_0 = "ln.admin.errors.gte-0"
8
- const INTEGER = "ln.admin.errors.integer"
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"
12
-
13
- const unique = <TArrayItem>(path: Extract<keyof TArrayItem, string>) => {
14
- return (values: TArrayItem[], ctx: RefinementCtx) => {
15
- const seenValues = new Set<unknown>()
16
- values.forEach((value, index) => {
17
- if (seenValues.has(value[path])) {
18
- ctx.addIssue({
19
- path: [index, path],
20
- message: UNIQUE_ELEMENTS,
21
- code: "custom",
22
- })
23
- }
24
- seenValues.add(value[path])
25
- })
26
- }
27
- }
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
-
42
- export const mediaItemSchema = z.object({
43
- commonId: z.string().nonempty(NON_EMPTY_STRING),
44
- title: z.string().nonempty(NON_EMPTY_STRING),
45
- type: z.string().nonempty(REQUIRED),
46
- language: z.string().nonempty(REQUIRED),
47
- authors: z
48
- .object({ value: z.string().nonempty(NON_EMPTY_STRING) })
49
- .array()
50
- .superRefine(unique("value")),
51
- categories: z
52
- .object({
53
- value: z.string().nonempty(REQUIRED),
54
- })
55
- .array()
56
- .superRefine(unique("value")),
57
- collections: z
58
- .object({
59
- collection: z.string().nonempty(REQUIRED),
60
- index: z.number().int(INTEGER).gte(0, GTE_0).optional(),
61
- })
62
- .array()
63
- .superRefine(unique("collection")),
64
- dateCreated: z.string().date(INVALID_DATE),
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),
79
- })
80
-
81
- export type MediaItem = z.input<typeof mediaItemSchema>
@@ -1,16 +0,0 @@
1
- import type { APIRoute, GetStaticPaths } from "astro"
2
- import { getCollection } from "astro:content"
3
-
4
- import { getRawMediaItem } from "../../content/get-media-items"
5
-
6
- export const getStaticPaths = (async () => {
7
- const mediaItems = await getCollection("media")
8
- return mediaItems.map(({ id: mediaId }) => ({ params: { mediaId } }))
9
- }) satisfies GetStaticPaths
10
-
11
- export const GET: APIRoute = async ({ params: { mediaId } }) => {
12
- const entry = await getRawMediaItem(mediaId!)
13
- return new Response(
14
- JSON.stringify({ id: entry.id, content: entry.data }, null, 2),
15
- )
16
- }