lightnet 3.10.2 → 3.10.3
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 +6 -0
- package/package.json +2 -2
- package/src/admin/api/fs/{writeText.ts → write-file.ts} +9 -6
- package/src/admin/components/form/Select.tsx +39 -0
- package/src/admin/components/form/TextInput.tsx +34 -0
- package/src/admin/components/form/{FieldErrors.tsx → atoms/FieldErrors.tsx} +1 -1
- package/src/admin/components/form/atoms/Hint.tsx +10 -0
- package/src/admin/components/form/atoms/Label.tsx +12 -0
- package/src/admin/components/form/index.ts +4 -2
- package/src/admin/i18n/translations/en.yml +5 -0
- package/src/admin/pages/media/EditForm.tsx +46 -14
- package/src/admin/pages/media/EditRoute.astro +21 -3
- package/src/admin/pages/media/file-system.ts +3 -3
- package/src/admin/types/media-item.ts +5 -0
- package/src/astro-integration/integration.ts +2 -2
- package/src/components/Toast.tsx +1 -1
- package/src/i18n/react/i18n-context.ts +8 -4
- package/src/admin/components/form/TextField.tsx +0 -24
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
# lightnet
|
|
2
2
|
|
|
3
|
+
## 3.10.3
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- [#327](https://github.com/LightNetDev/LightNet/pull/327) [`9f1e468`](https://github.com/LightNetDev/LightNet/commit/9f1e468cc296e62788586a3f3c6f245c8bc0cec9) Thanks [@smn-cds](https://github.com/smn-cds)! - Gracefully handle missing translations in React components to avoid runtime crashes.
|
|
8
|
+
|
|
3
9
|
## 3.10.2
|
|
4
10
|
|
|
5
11
|
### Patch Changes
|
package/package.json
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
"description": "LightNet makes it easy to run your own digital media library.",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"license": "MIT",
|
|
6
|
-
"version": "3.10.
|
|
6
|
+
"version": "3.10.3",
|
|
7
7
|
"repository": {
|
|
8
8
|
"type": "git",
|
|
9
9
|
"url": "https://github.com/LightNetDev/lightnet",
|
|
@@ -34,7 +34,7 @@
|
|
|
34
34
|
"./api/internal/search.ts": "./src/pages/search-page/api/search.ts",
|
|
35
35
|
"./api/versions.ts": "./src/api/versions.ts",
|
|
36
36
|
"./api/media/[mediaId].ts": "./src/api/media/[mediaId].ts",
|
|
37
|
-
"./api/internal/fs/
|
|
37
|
+
"./api/internal/fs/write-file.ts": "./src/admin/api/fs/write-file.ts",
|
|
38
38
|
"./admin/pages/AdminRoute.astro": "./src/admin/pages/AdminRoute.astro",
|
|
39
39
|
"./admin/pages/media/EditRoute.astro": "./src/admin/pages/media/EditRoute.astro"
|
|
40
40
|
},
|
|
@@ -1,5 +1,7 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { createWriteStream } from "node:fs"
|
|
2
|
+
import { mkdir, rename, rm } from "node:fs/promises"
|
|
2
3
|
import { dirname, isAbsolute, relative, resolve } from "node:path"
|
|
4
|
+
import { Writable } from "node:stream"
|
|
3
5
|
import { fileURLToPath } from "node:url"
|
|
4
6
|
|
|
5
7
|
import type { APIRoute } from "astro"
|
|
@@ -26,16 +28,17 @@ export const POST: APIRoute = async ({ request }) => {
|
|
|
26
28
|
) {
|
|
27
29
|
throw new Error("Path escapes project root.")
|
|
28
30
|
}
|
|
31
|
+
const { body } = request
|
|
32
|
+
if (!body) {
|
|
33
|
+
throw new Error("Request body missing.")
|
|
34
|
+
}
|
|
29
35
|
|
|
30
36
|
const targetDir = dirname(targetPath)
|
|
31
37
|
await mkdir(targetDir, { recursive: true })
|
|
32
38
|
|
|
33
|
-
const
|
|
34
|
-
const timestamp = Date.now()
|
|
35
|
-
const tmpPath = `${targetPath}.tmp-${timestamp}`
|
|
39
|
+
const tmpPath = `${targetPath}.tmp-${Date.now()}`
|
|
36
40
|
try {
|
|
37
|
-
|
|
38
|
-
await writeFile(tmpPath, body, "utf-8")
|
|
41
|
+
await body.pipeTo(Writable.toWeb(createWriteStream(tmpPath)))
|
|
39
42
|
await rename(tmpPath, targetPath)
|
|
40
43
|
} finally {
|
|
41
44
|
await rm(tmpPath, { force: true }).catch(() => {})
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { useI18n } from "../../../i18n/react/useI18n"
|
|
2
|
+
import { FieldErrors } from "./atoms/FieldErrors"
|
|
3
|
+
import Hint from "./atoms/Hint"
|
|
4
|
+
import Label from "./atoms/Label"
|
|
5
|
+
import { useFieldContext } from "./form-context"
|
|
6
|
+
|
|
7
|
+
export default function Select({
|
|
8
|
+
label,
|
|
9
|
+
hint,
|
|
10
|
+
options,
|
|
11
|
+
}: {
|
|
12
|
+
label: string
|
|
13
|
+
hint?: string
|
|
14
|
+
options: { id: string; label?: string }[]
|
|
15
|
+
}) {
|
|
16
|
+
const field = useFieldContext<string>()
|
|
17
|
+
const { t } = useI18n()
|
|
18
|
+
return (
|
|
19
|
+
<label className="dy-form-control w-full">
|
|
20
|
+
<Label label={label} />
|
|
21
|
+
<select
|
|
22
|
+
id={field.name}
|
|
23
|
+
name={field.name}
|
|
24
|
+
value={field.state.value}
|
|
25
|
+
onChange={(e) => field.handleChange(e.target.value)}
|
|
26
|
+
onBlur={field.handleBlur}
|
|
27
|
+
className={`dy-select dy-select-bordered ${field.state.meta.errors.length ? "dy-select-error" : ""}"`}
|
|
28
|
+
>
|
|
29
|
+
{options.map(({ id, label }) => (
|
|
30
|
+
<option key={id} value={id}>
|
|
31
|
+
{label ? t(label) : id}
|
|
32
|
+
</option>
|
|
33
|
+
))}
|
|
34
|
+
</select>
|
|
35
|
+
<FieldErrors meta={field.state.meta} />
|
|
36
|
+
<Hint hint={hint} />
|
|
37
|
+
</label>
|
|
38
|
+
)
|
|
39
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { FieldErrors } from "./atoms/FieldErrors"
|
|
2
|
+
import Hint from "./atoms/Hint"
|
|
3
|
+
import Label from "./atoms/Label"
|
|
4
|
+
import { useFieldContext } from "./form-context"
|
|
5
|
+
|
|
6
|
+
export default function TextInput({
|
|
7
|
+
label,
|
|
8
|
+
hint,
|
|
9
|
+
type = "text",
|
|
10
|
+
}: {
|
|
11
|
+
label: string
|
|
12
|
+
hint?: string
|
|
13
|
+
type?: "text" | "date"
|
|
14
|
+
}) {
|
|
15
|
+
const field = useFieldContext<string>()
|
|
16
|
+
return (
|
|
17
|
+
<>
|
|
18
|
+
<label className="dy-form-control w-full">
|
|
19
|
+
<Label label={label} />
|
|
20
|
+
<input
|
|
21
|
+
id={field.name}
|
|
22
|
+
name={field.name}
|
|
23
|
+
type={type}
|
|
24
|
+
value={field.state.value}
|
|
25
|
+
onChange={(e) => field.handleChange(e.target.value)}
|
|
26
|
+
onBlur={field.handleBlur}
|
|
27
|
+
className={`dy-input dy-input-bordered ${field.state.meta.errors.length ? "dy-input-error" : ""}`}
|
|
28
|
+
/>
|
|
29
|
+
<FieldErrors meta={field.state.meta} />
|
|
30
|
+
<Hint hint={hint} />
|
|
31
|
+
</label>
|
|
32
|
+
</>
|
|
33
|
+
)
|
|
34
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { useI18n } from "../../../../i18n/react/useI18n"
|
|
2
|
+
|
|
3
|
+
export default function Hint({ hint }: { hint?: string }) {
|
|
4
|
+
const { t } = useI18n()
|
|
5
|
+
return (
|
|
6
|
+
<div className="flex h-8 w-full items-center justify-end">
|
|
7
|
+
{hint && <span className="dy-label-text-alt">{t(hint)}</span>}
|
|
8
|
+
</div>
|
|
9
|
+
)
|
|
10
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { useI18n } from "../../../../i18n/react/useI18n"
|
|
2
|
+
|
|
3
|
+
export default function Label({ label }: { label: string }) {
|
|
4
|
+
const { t } = useI18n()
|
|
5
|
+
return (
|
|
6
|
+
<div className="dy-label">
|
|
7
|
+
<span className="text-sm font-bold uppercase text-gray-600">
|
|
8
|
+
{t(label)}
|
|
9
|
+
</span>
|
|
10
|
+
</div>
|
|
11
|
+
)
|
|
12
|
+
}
|
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
import { createFormHook } from "@tanstack/react-form"
|
|
2
2
|
|
|
3
3
|
import { fieldContext, formContext } from "./form-context"
|
|
4
|
+
import Select from "./Select"
|
|
4
5
|
import SubmitButton from "./SubmitButton"
|
|
5
|
-
import
|
|
6
|
+
import TextInput from "./TextInput"
|
|
6
7
|
|
|
7
8
|
export const { useAppForm, withForm } = createFormHook({
|
|
8
9
|
fieldComponents: {
|
|
9
|
-
|
|
10
|
+
TextInput,
|
|
11
|
+
Select,
|
|
10
12
|
},
|
|
11
13
|
formComponents: {
|
|
12
14
|
SubmitButton,
|
|
@@ -6,6 +6,11 @@ ln.admin.edit-media-item: Edit media item
|
|
|
6
6
|
ln.admin.back-to-details-page: Back to details page
|
|
7
7
|
ln.admin.title: Title
|
|
8
8
|
ln.admin.common-id: Common ID
|
|
9
|
+
ln.admin.created-on: Created on
|
|
10
|
+
ln.admin.created-on-hint: When has this item been created on this library?
|
|
11
|
+
ln.admin.common-id-hint: The english title, all lowercase, words separated with hyphens.
|
|
9
12
|
ln.admin.toast.invalid-data.title: Invalid form data
|
|
10
13
|
ln.admin.toast.invalid-data.hint: Check the fields and try again.
|
|
11
14
|
ln.admin.errors.non-empty-string: String must contain at least 1 character(s)
|
|
15
|
+
ln.admin.errors.invalid-date: Invalid date
|
|
16
|
+
ln.admin.errors.required: Required field
|
|
@@ -15,13 +15,17 @@ export default function EditForm({
|
|
|
15
15
|
mediaId,
|
|
16
16
|
mediaItem,
|
|
17
17
|
i18nConfig,
|
|
18
|
+
mediaTypes,
|
|
19
|
+
languages,
|
|
18
20
|
}: {
|
|
19
21
|
mediaId: string
|
|
20
22
|
mediaItem: MediaItem
|
|
21
23
|
i18nConfig: I18nConfig
|
|
24
|
+
mediaTypes: { id: string; label: string }[]
|
|
25
|
+
languages: { id: string; label: string }[]
|
|
22
26
|
}) {
|
|
23
27
|
const form = useAppForm({
|
|
24
|
-
defaultValues: mediaItem,
|
|
28
|
+
defaultValues: { ...mediaItem },
|
|
25
29
|
validators: {
|
|
26
30
|
onDynamic: mediaItemSchema,
|
|
27
31
|
},
|
|
@@ -46,27 +50,55 @@ export default function EditForm({
|
|
|
46
50
|
e.preventDefault()
|
|
47
51
|
form.handleSubmit()
|
|
48
52
|
}}
|
|
49
|
-
className="flex flex-col items-start
|
|
53
|
+
className="flex flex-col items-start"
|
|
50
54
|
>
|
|
55
|
+
<form.AppField
|
|
56
|
+
name="title"
|
|
57
|
+
children={(field) => <field.TextInput label="ln.admin.title" />}
|
|
58
|
+
/>
|
|
51
59
|
<form.AppField
|
|
52
60
|
name="commonId"
|
|
53
61
|
children={(field) => (
|
|
54
|
-
<field.
|
|
62
|
+
<field.TextInput
|
|
63
|
+
label="ln.admin.common-id"
|
|
64
|
+
hint="ln.admin.common-id-hint"
|
|
65
|
+
/>
|
|
55
66
|
)}
|
|
56
67
|
/>
|
|
57
68
|
<form.AppField
|
|
58
|
-
name="
|
|
59
|
-
children={(field) =>
|
|
69
|
+
name="type"
|
|
70
|
+
children={(field) => (
|
|
71
|
+
<field.Select label="ln.type" options={mediaTypes} />
|
|
72
|
+
)}
|
|
60
73
|
/>
|
|
61
|
-
<form.
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
<
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
74
|
+
<form.AppField
|
|
75
|
+
name="language"
|
|
76
|
+
children={(field) => (
|
|
77
|
+
<field.Select label="ln.language" options={languages} />
|
|
78
|
+
)}
|
|
79
|
+
/>
|
|
80
|
+
<form.AppField
|
|
81
|
+
name="dateCreated"
|
|
82
|
+
children={(field) => (
|
|
83
|
+
<field.TextInput
|
|
84
|
+
type="date"
|
|
85
|
+
label="ln.admin.created-on"
|
|
86
|
+
hint="ln.admin.created-on-hint"
|
|
87
|
+
/>
|
|
88
|
+
)}
|
|
89
|
+
/>
|
|
90
|
+
|
|
91
|
+
<div className="mt-8">
|
|
92
|
+
<form.AppForm>
|
|
93
|
+
<form.SubmitButton />
|
|
94
|
+
<Toast id="invalid-form-data-toast" variant="error">
|
|
95
|
+
<div className="font-bold text-gray-700">
|
|
96
|
+
{t("ln.admin.toast.invalid-data.title")}
|
|
97
|
+
</div>
|
|
98
|
+
{t("ln.admin.toast.invalid-data.hint")}
|
|
99
|
+
</Toast>
|
|
100
|
+
</form.AppForm>
|
|
101
|
+
</div>
|
|
70
102
|
</form>
|
|
71
103
|
</I18nContext.Provider>
|
|
72
104
|
)
|
|
@@ -4,6 +4,7 @@ import { getCollection } from "astro:content"
|
|
|
4
4
|
import config from "virtual:lightnet/config"
|
|
5
5
|
|
|
6
6
|
import { getRawMediaItem } from "../../../content/get-media-items"
|
|
7
|
+
import { getMediaTypes } from "../../../content/get-media-types"
|
|
7
8
|
import { prepareI18nConfig } from "../../../i18n/react/prepare-i18n-config"
|
|
8
9
|
import { resolveLocales } from "../../../i18n/resolve-locales"
|
|
9
10
|
import Page from "../../../layouts/Page.astro"
|
|
@@ -19,23 +20,40 @@ export const getStaticPaths = (async () => {
|
|
|
19
20
|
const { mediaId } = Astro.params
|
|
20
21
|
const mediaItemEntry = await getRawMediaItem(mediaId)
|
|
21
22
|
|
|
22
|
-
const i18nConfig = prepareI18nConfig(Astro.locals.i18n, [
|
|
23
|
+
const i18nConfig = prepareI18nConfig(Astro.locals.i18n, [
|
|
24
|
+
"ln.admin.*",
|
|
25
|
+
"ln.type",
|
|
26
|
+
"ln.language",
|
|
27
|
+
])
|
|
23
28
|
const { t } = Astro.locals.i18n
|
|
29
|
+
|
|
30
|
+
const mediaTypes = (await getMediaTypes()).map(({ id, data: { label } }) => ({
|
|
31
|
+
id,
|
|
32
|
+
label: t(label),
|
|
33
|
+
}))
|
|
34
|
+
const languages = config.languages.map(({ code, label }) => ({
|
|
35
|
+
id: code,
|
|
36
|
+
label: t(label),
|
|
37
|
+
}))
|
|
24
38
|
---
|
|
25
39
|
|
|
26
40
|
<Page>
|
|
27
|
-
<div class="mx-auto max-w-screen-
|
|
41
|
+
<div class="mx-auto max-w-screen-md px-4 pt-12 md:px-8">
|
|
28
42
|
<a
|
|
29
43
|
class="underline"
|
|
30
44
|
href=`/${Astro.currentLocale}/media/faithful-freestyle--en`
|
|
31
45
|
>{t("ln.admin.back-to-details-page")}</a
|
|
32
46
|
>
|
|
33
|
-
<h1 class="mb-
|
|
47
|
+
<h1 class="mb-10 mt-10 text-2xl">
|
|
48
|
+
{t("ln.admin.edit-media-item")}
|
|
49
|
+
</h1>
|
|
34
50
|
|
|
35
51
|
<EditForm
|
|
36
52
|
mediaId={mediaId}
|
|
37
53
|
mediaItem={mediaItemEntry.data}
|
|
38
54
|
i18nConfig={i18nConfig}
|
|
55
|
+
mediaTypes={mediaTypes}
|
|
56
|
+
languages={languages}
|
|
39
57
|
client:load
|
|
40
58
|
/>
|
|
41
59
|
</div>
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
export const
|
|
1
|
+
export const writeFile = (path: string, body: string) => {
|
|
2
2
|
return fetch(
|
|
3
|
-
`/api/internal/fs/
|
|
3
|
+
`/api/internal/fs/write-file?path=${encodeURIComponent(path.replace(/^\//, ""))}`,
|
|
4
4
|
{
|
|
5
5
|
method: "POST",
|
|
6
6
|
headers: { "Content-Type": resolveContentType(path) },
|
|
@@ -10,7 +10,7 @@ export const writeText = (path: string, body: string) => {
|
|
|
10
10
|
}
|
|
11
11
|
|
|
12
12
|
export const writeJson = async (path: string, object: unknown) => {
|
|
13
|
-
return
|
|
13
|
+
return writeFile(path, JSON.stringify(sortObject(object), null, 2))
|
|
14
14
|
}
|
|
15
15
|
|
|
16
16
|
const resolveContentType = (path: string) => {
|
|
@@ -1,10 +1,15 @@
|
|
|
1
1
|
import { z } from "astro/zod"
|
|
2
2
|
|
|
3
3
|
const NON_EMPTY_STRING = "ln.admin.errors.non-empty-string"
|
|
4
|
+
const INVALID_DATE = "ln.admin.errors.invalid-date"
|
|
5
|
+
const REQUIRED = "ln.admin.errors.required"
|
|
4
6
|
|
|
5
7
|
export const mediaItemSchema = z.object({
|
|
6
8
|
commonId: z.string().nonempty(NON_EMPTY_STRING),
|
|
7
9
|
title: z.string().nonempty(NON_EMPTY_STRING),
|
|
10
|
+
type: z.string().nonempty(REQUIRED),
|
|
11
|
+
language: z.string().nonempty(REQUIRED),
|
|
12
|
+
dateCreated: z.string().date(INVALID_DATE),
|
|
8
13
|
})
|
|
9
14
|
|
|
10
15
|
export type MediaItem = z.infer<typeof mediaItemSchema>
|
|
@@ -87,8 +87,8 @@ export function lightnet(lightnetConfig: LightnetConfig): AstroIntegration {
|
|
|
87
87
|
// this endpoints to write files.
|
|
88
88
|
if (config.experimental?.admin?.enabled && command === "dev") {
|
|
89
89
|
injectRoute({
|
|
90
|
-
pattern: "/api/internal/fs/
|
|
91
|
-
entrypoint: "lightnet/api/internal/fs/
|
|
90
|
+
pattern: "/api/internal/fs/write-file",
|
|
91
|
+
entrypoint: "lightnet/api/internal/fs/write-file.ts",
|
|
92
92
|
prerender: false,
|
|
93
93
|
})
|
|
94
94
|
// Add empty adapter to avoid warning
|
package/src/components/Toast.tsx
CHANGED
|
@@ -46,7 +46,7 @@ export default function Toast({
|
|
|
46
46
|
}}
|
|
47
47
|
>
|
|
48
48
|
<div
|
|
49
|
-
className={`pointer-events-auto flex max-w-sm flex-col items-start gap-1 rounded-
|
|
49
|
+
className={`pointer-events-auto flex max-w-sm flex-col items-start gap-1 rounded-md border p-4 text-base shadow-md backdrop-blur-sm ${alertClasses}`}
|
|
50
50
|
>
|
|
51
51
|
{children}
|
|
52
52
|
</div>
|
|
@@ -22,11 +22,15 @@ export const createI18n = ({
|
|
|
22
22
|
direction,
|
|
23
23
|
}: I18nConfig) => {
|
|
24
24
|
const t = (key: string) => {
|
|
25
|
-
const
|
|
26
|
-
if (
|
|
27
|
-
|
|
25
|
+
const value = translations[key]
|
|
26
|
+
if (value) {
|
|
27
|
+
return value
|
|
28
28
|
}
|
|
29
|
-
|
|
29
|
+
if (key.match(/^(?:ln|x)\../i)) {
|
|
30
|
+
console.error(`Missing translation for key ${key}`)
|
|
31
|
+
return ""
|
|
32
|
+
}
|
|
33
|
+
return key
|
|
30
34
|
}
|
|
31
35
|
return { t, currentLocale, direction }
|
|
32
36
|
}
|
|
@@ -1,24 +0,0 @@
|
|
|
1
|
-
import { FieldErrors } from "./FieldErrors"
|
|
2
|
-
import { useFieldContext } from "./form-context"
|
|
3
|
-
|
|
4
|
-
export default function TextField({ label }: { label: string }) {
|
|
5
|
-
const field = useFieldContext<string>()
|
|
6
|
-
return (
|
|
7
|
-
<>
|
|
8
|
-
<label className="dy-form-control w-full max-w-sm">
|
|
9
|
-
<div className="dy-label">
|
|
10
|
-
<span className="dy-label-text">{label}</span>
|
|
11
|
-
</div>
|
|
12
|
-
<input
|
|
13
|
-
id={field.name}
|
|
14
|
-
name={field.name}
|
|
15
|
-
value={field.state.value}
|
|
16
|
-
onChange={(e) => field.handleChange(e.target.value)}
|
|
17
|
-
onBlur={field.handleBlur}
|
|
18
|
-
className={`dy-input dy-input-bordered dy-input-sm w-full max-w-sm ${field.state.meta.errors.length ? "dy-input-error" : ""}`}
|
|
19
|
-
/>
|
|
20
|
-
<FieldErrors meta={field.state.meta} />
|
|
21
|
-
</label>
|
|
22
|
-
</>
|
|
23
|
-
)
|
|
24
|
-
}
|