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 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.2",
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/writeText.ts": "./src/admin/api/fs/writeText.ts",
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 { mkdir, rename, rm, writeFile } from "node:fs/promises"
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 body = await request.text()
34
- const timestamp = Date.now()
35
- const tmpPath = `${targetPath}.tmp-${timestamp}`
39
+ const tmpPath = `${targetPath}.tmp-${Date.now()}`
36
40
  try {
37
- const tmpPath = `${targetPath}.tmp-${Date.now()}`
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
+ }
@@ -1,6 +1,6 @@
1
1
  import type { AnyFieldMeta } from "@tanstack/react-form"
2
2
 
3
- import { useI18n } from "../../../i18n/react/useI18n"
3
+ import { useI18n } from "../../../../i18n/react/useI18n"
4
4
 
5
5
  type FieldErrorsProps = {
6
6
  meta: AnyFieldMeta
@@ -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 TextField from "./TextField"
6
+ import TextInput from "./TextInput"
6
7
 
7
8
  export const { useAppForm, withForm } = createFormHook({
8
9
  fieldComponents: {
9
- TextField,
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 gap-4"
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.TextField label={t("ln.admin.common-id")} />
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="title"
59
- children={(field) => <field.TextField label={t("ln.admin.title")} />}
69
+ name="type"
70
+ children={(field) => (
71
+ <field.Select label="ln.type" options={mediaTypes} />
72
+ )}
60
73
  />
61
- <form.AppForm>
62
- <form.SubmitButton />
63
- <Toast id="invalid-form-data-toast" variant="error">
64
- <div className="font-bold text-gray-700">
65
- {t("ln.admin.toast.invalid-data.title")}
66
- </div>
67
- {t("ln.admin.toast.invalid-data.hint")}
68
- </Toast>
69
- </form.AppForm>
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, ["ln.admin.*"])
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-lg px-4 pt-12 md:px-8">
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-4 mt-8 text-lg">{t("ln.admin.edit-media-item")}</h1>
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 writeText = (path: string, body: string) => {
1
+ export const writeFile = (path: string, body: string) => {
2
2
  return fetch(
3
- `/api/internal/fs/writeText?path=${encodeURIComponent(path.replace(/^\//, ""))}`,
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 writeText(path, JSON.stringify(sortObject(object), null, 2))
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/writeText",
91
- entrypoint: "lightnet/api/internal/fs/writeText.ts",
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
@@ -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-2xl border p-4 text-base shadow-md backdrop-blur-sm ${alertClasses}`}
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 translated = translations[key]
26
- if (!translated) {
27
- throw new Error(`Missing translation for key ${key}`)
25
+ const value = translations[key]
26
+ if (value) {
27
+ return value
28
28
  }
29
- return translated
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
- }