lightnet 3.10.1 → 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.
Files changed (32) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/package.json +2 -2
  3. package/src/admin/api/fs/{writeText.ts → write-file.ts} +9 -6
  4. package/src/admin/components/form/Select.tsx +39 -0
  5. package/src/admin/components/form/SubmitButton.tsx +6 -4
  6. package/src/admin/components/form/TextInput.tsx +34 -0
  7. package/src/admin/components/form/{FieldErrors.tsx → atoms/FieldErrors.tsx} +4 -1
  8. package/src/admin/components/form/atoms/Hint.tsx +10 -0
  9. package/src/admin/components/form/atoms/Label.tsx +12 -0
  10. package/src/admin/components/form/index.ts +4 -2
  11. package/src/admin/i18n/translations/en.yml +15 -0
  12. package/src/admin/pages/media/EditForm.tsx +71 -24
  13. package/src/admin/pages/media/EditRoute.astro +31 -4
  14. package/src/admin/pages/media/file-system.ts +3 -3
  15. package/src/admin/types/media-item.ts +9 -2
  16. package/src/astro-integration/integration.ts +2 -2
  17. package/src/components/Toast.tsx +1 -1
  18. package/src/i18n/locals.d.ts +35 -28
  19. package/src/i18n/locals.ts +2 -1
  20. package/src/i18n/react/i18n-context.ts +36 -0
  21. package/src/i18n/react/prepare-i18n-config.ts +31 -0
  22. package/src/i18n/react/useI18n.ts +15 -0
  23. package/src/i18n/translate.ts +15 -3
  24. package/src/pages/search-page/components/LoadingSkeleton.tsx +3 -1
  25. package/src/pages/search-page/components/SearchFilter.astro +12 -3
  26. package/src/pages/search-page/components/SearchFilter.tsx +4 -7
  27. package/src/pages/search-page/components/SearchList.astro +7 -6
  28. package/src/pages/search-page/components/SearchList.tsx +12 -15
  29. package/src/pages/search-page/components/SearchListItem.tsx +2 -4
  30. package/src/admin/components/form/TextField.tsx +0 -24
  31. package/src/pages/search-page/utils/search-filter-translations.ts +0 -20
  32. package/src/pages/search-page/utils/search-translations.ts +0 -11
package/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
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
+
9
+ ## 3.10.2
10
+
11
+ ### Patch Changes
12
+
13
+ - [#325](https://github.com/LightNetDev/LightNet/pull/325) [`07b71f4`](https://github.com/LightNetDev/LightNet/commit/07b71f4d44f8e005aafb48bedc347f14c71e182a) Thanks [@smn-cds](https://github.com/smn-cds)! - Refactor internal react i18n logic.
14
+
3
15
  ## 3.10.1
4
16
 
5
17
  ### 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.1",
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
+ }
@@ -2,6 +2,7 @@ import { useStore } from "@tanstack/react-form"
2
2
  import { useEffect, useRef, useState } from "react"
3
3
 
4
4
  import Icon from "../../../components/Icon"
5
+ import { useI18n } from "../../../i18n/react/useI18n"
5
6
  import { useFormContext } from "./form-context"
6
7
 
7
8
  const SUCCESS_DURATION_MS = 2000
@@ -18,9 +19,9 @@ const buttonStateClasses = {
18
19
  } as const
19
20
 
20
21
  const buttonLabels = {
21
- idle: "Save",
22
- success: "Saved",
23
- error: "Failed",
22
+ idle: "ln.admin.save",
23
+ success: "ln.admin.saved",
24
+ error: "ln.admin.failed",
24
25
  } as const
25
26
 
26
27
  const icons = {
@@ -31,6 +32,7 @@ const icons = {
31
32
 
32
33
  export default function SubmitButton() {
33
34
  const form = useFormContext()
35
+ const { t } = useI18n()
34
36
  const { submissionAttempts, isSubmitting, isSubmitSuccessful } = useStore(
35
37
  form.store,
36
38
  (state) => ({
@@ -48,7 +50,7 @@ export default function SubmitButton() {
48
50
  return (
49
51
  <button className={buttonClass} type="submit" disabled={isSubmitting}>
50
52
  {icon && <Icon className={icon} ariaLabel="" />}
51
- {label}
53
+ {t(label)}
52
54
  </button>
53
55
  )
54
56
  }
@@ -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,17 +1,20 @@
1
1
  import type { AnyFieldMeta } from "@tanstack/react-form"
2
2
 
3
+ import { useI18n } from "../../../../i18n/react/useI18n"
4
+
3
5
  type FieldErrorsProps = {
4
6
  meta: AnyFieldMeta
5
7
  }
6
8
 
7
9
  export const FieldErrors = ({ meta }: FieldErrorsProps) => {
10
+ const { t } = useI18n()
8
11
  if (!meta.isTouched || meta.isValid) return null
9
12
 
10
13
  return (
11
14
  <ul className="my-2 flex flex-col gap-1" role="alert">
12
15
  {meta.errors.map((error) => (
13
16
  <li className="text-sm text-rose-800" key={error.code}>
14
- {error.message}
17
+ {t(error.message)}
15
18
  </li>
16
19
  ))}
17
20
  </ul>
@@ -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,
@@ -1 +1,16 @@
1
1
  ln.admin.edit: Edit
2
+ ln.admin.save: Save
3
+ ln.admin.saved: Saved
4
+ ln.admin.failed: Failed
5
+ ln.admin.edit-media-item: Edit media item
6
+ ln.admin.back-to-details-page: Back to details page
7
+ ln.admin.title: Title
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.
12
+ ln.admin.toast.invalid-data.title: Invalid form data
13
+ ln.admin.toast.invalid-data.hint: Check the fields and try again.
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
@@ -2,6 +2,11 @@ import { revalidateLogic } from "@tanstack/react-form"
2
2
 
3
3
  import { showToastById } from "../../../components/showToast"
4
4
  import Toast from "../../../components/Toast"
5
+ import {
6
+ createI18n,
7
+ type I18nConfig,
8
+ I18nContext,
9
+ } from "../../../i18n/react/i18n-context"
5
10
  import { useAppForm } from "../../components/form"
6
11
  import { type MediaItem, mediaItemSchema } from "../../types/media-item"
7
12
  import { updateMediaItem } from "./media-item-store"
@@ -9,12 +14,18 @@ import { updateMediaItem } from "./media-item-store"
9
14
  export default function EditForm({
10
15
  mediaId,
11
16
  mediaItem,
17
+ i18nConfig,
18
+ mediaTypes,
19
+ languages,
12
20
  }: {
13
21
  mediaId: string
14
22
  mediaItem: MediaItem
23
+ i18nConfig: I18nConfig
24
+ mediaTypes: { id: string; label: string }[]
25
+ languages: { id: string; label: string }[]
15
26
  }) {
16
27
  const form = useAppForm({
17
- defaultValues: mediaItem,
28
+ defaultValues: { ...mediaItem },
18
29
  validators: {
19
30
  onDynamic: mediaItemSchema,
20
31
  },
@@ -29,30 +40,66 @@ export default function EditForm({
29
40
  showToastById("invalid-form-data-toast")
30
41
  },
31
42
  })
43
+ const i18n = createI18n(i18nConfig)
44
+ const { t } = i18n
32
45
 
33
46
  return (
34
- <form
35
- onSubmit={(e) => {
36
- e.preventDefault()
37
- form.handleSubmit()
38
- }}
39
- className="flex flex-col items-start gap-4"
40
- >
41
- <form.AppField
42
- name="commonId"
43
- children={(field) => <field.TextField label="Common ID" />}
44
- />
45
- <form.AppField
46
- name="title"
47
- children={(field) => <field.TextField label="Title" />}
48
- />
49
- <form.AppForm>
50
- <form.SubmitButton />
51
- <Toast id="invalid-form-data-toast" variant="error">
52
- <div className="font-bold text-gray-700">Invalid form data</div>
53
- Check the fields and try again.
54
- </Toast>
55
- </form.AppForm>
56
- </form>
47
+ <I18nContext.Provider value={i18n}>
48
+ <form
49
+ onSubmit={(e) => {
50
+ e.preventDefault()
51
+ form.handleSubmit()
52
+ }}
53
+ className="flex flex-col items-start"
54
+ >
55
+ <form.AppField
56
+ name="title"
57
+ children={(field) => <field.TextInput label="ln.admin.title" />}
58
+ />
59
+ <form.AppField
60
+ name="commonId"
61
+ children={(field) => (
62
+ <field.TextInput
63
+ label="ln.admin.common-id"
64
+ hint="ln.admin.common-id-hint"
65
+ />
66
+ )}
67
+ />
68
+ <form.AppField
69
+ name="type"
70
+ children={(field) => (
71
+ <field.Select label="ln.type" options={mediaTypes} />
72
+ )}
73
+ />
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>
102
+ </form>
103
+ </I18nContext.Provider>
57
104
  )
58
105
  }
@@ -4,6 +4,8 @@ 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"
8
+ import { prepareI18nConfig } from "../../../i18n/react/prepare-i18n-config"
7
9
  import { resolveLocales } from "../../../i18n/resolve-locales"
8
10
  import Page from "../../../layouts/Page.astro"
9
11
  import EditForm from "./EditForm"
@@ -17,17 +19,42 @@ export const getStaticPaths = (async () => {
17
19
 
18
20
  const { mediaId } = Astro.params
19
21
  const mediaItemEntry = await getRawMediaItem(mediaId)
22
+
23
+ const i18nConfig = prepareI18nConfig(Astro.locals.i18n, [
24
+ "ln.admin.*",
25
+ "ln.type",
26
+ "ln.language",
27
+ ])
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
+ }))
20
38
  ---
21
39
 
22
40
  <Page>
23
- <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">
24
42
  <a
25
43
  class="underline"
26
44
  href=`/${Astro.currentLocale}/media/faithful-freestyle--en`
27
- >Back to details page</a
45
+ >{t("ln.admin.back-to-details-page")}</a
28
46
  >
29
- <h1 class="mb-4 mt-8 text-lg">Edit media item</h1>
47
+ <h1 class="mb-10 mt-10 text-2xl">
48
+ {t("ln.admin.edit-media-item")}
49
+ </h1>
30
50
 
31
- <EditForm mediaId={mediaId} mediaItem={mediaItemEntry.data} client:load />
51
+ <EditForm
52
+ mediaId={mediaId}
53
+ mediaItem={mediaItemEntry.data}
54
+ i18nConfig={i18nConfig}
55
+ mediaTypes={mediaTypes}
56
+ languages={languages}
57
+ client:load
58
+ />
32
59
  </div>
33
60
  </Page>
@@ -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,8 +1,15 @@
1
1
  import { z } from "astro/zod"
2
2
 
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"
6
+
3
7
  export const mediaItemSchema = z.object({
4
- commonId: z.string().nonempty(),
5
- title: z.string().nonempty(),
8
+ commonId: z.string().nonempty(NON_EMPTY_STRING),
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),
6
13
  })
7
14
 
8
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>
@@ -3,36 +3,43 @@ declare namespace App {
3
3
  /**
4
4
  * Provides internationalization helpers.
5
5
  */
6
- i18n: {
7
- /**
8
- * Translate a key to the language of the current locale.
9
- *
10
- * @param TranslationKey to be translated.
11
- */
12
- t: import("./translate").TranslateFn
6
+ i18n: I18n
7
+ }
8
+ }
13
9
 
14
- /**
15
- * The current locale or the default locale if the current locale is not available.
16
- *
17
- * In comparison to Astro.currentLocale this will always return a locale.
18
- * Use Astro.currentLocale if you want to know the locale that is included in the current path.
19
- */
20
- currentLocale: string
10
+ type I18n = {
11
+ /**
12
+ * Translate a key to the language of the current locale.
13
+ *
14
+ * @param TranslationKey to be translated.
15
+ */
16
+ t: import("./translate").TranslateFn
21
17
 
22
- /**
23
- * The current text direction. Left-to-right or right-to-left.
24
- */
25
- direction: "ltr" | "rtl"
18
+ /**
19
+ * The current locale or the default locale if the current locale is not available.
20
+ *
21
+ * In comparison to Astro.currentLocale this will always return a locale.
22
+ * Use Astro.currentLocale if you want to know the locale that is included in the current path.
23
+ */
24
+ currentLocale: string
26
25
 
27
- /**
28
- * The default locale as defined in the project configuration.
29
- */
30
- defaultLocale: string
26
+ /**
27
+ * The current text direction. Left-to-right or right-to-left.
28
+ */
29
+ direction: "ltr" | "rtl"
31
30
 
32
- /**
33
- * The available locales as defined in the project configuration.
34
- */
35
- locales: string[]
36
- }
37
- }
31
+ /**
32
+ * The default locale as defined in the project configuration.
33
+ */
34
+ defaultLocale: string
35
+
36
+ /**
37
+ * The available locales as defined in the project configuration.
38
+ */
39
+ locales: string[]
40
+
41
+ /**
42
+ * All available translation keys.
43
+ */
44
+ translationKeys: string[]
38
45
  }
@@ -4,7 +4,7 @@ import config from "virtual:lightnet/config"
4
4
  import { resolveDefaultLocale } from "./resolve-default-locale"
5
5
  import { resolveLanguage } from "./resolve-language"
6
6
  import { resolveLocales } from "./resolve-locales"
7
- import { useTranslate } from "./translate"
7
+ import { translationKeys, useTranslate } from "./translate"
8
8
 
9
9
  export const onRequest: MiddlewareHandler = (
10
10
  { locals, currentLocale: astroCurrentLocale },
@@ -22,6 +22,7 @@ export const onRequest: MiddlewareHandler = (
22
22
  defaultLocale,
23
23
  direction,
24
24
  locales,
25
+ translationKeys,
25
26
  }
26
27
  }
27
28
  return next()
@@ -0,0 +1,36 @@
1
+ import { createContext } from "react"
2
+
3
+ export type I18n = {
4
+ t: (key: string) => string
5
+ currentLocale: string
6
+ direction: "rtl" | "ltr"
7
+ }
8
+
9
+ export type I18nConfig = Omit<I18n, "t"> & {
10
+ translations: Record<string, string>
11
+ }
12
+
13
+ export const I18nContext = createContext<I18n | undefined>(undefined)
14
+
15
+ /**
16
+ * Creates the runtime i18n helpers given a prepared configuration.
17
+ * Wraps the raw translation dictionary with a lookup that throws on missing keys.
18
+ */
19
+ export const createI18n = ({
20
+ translations,
21
+ currentLocale,
22
+ direction,
23
+ }: I18nConfig) => {
24
+ const t = (key: string) => {
25
+ const value = translations[key]
26
+ if (value) {
27
+ return value
28
+ }
29
+ if (key.match(/^(?:ln|x)\../i)) {
30
+ console.error(`Missing translation for key ${key}`)
31
+ return ""
32
+ }
33
+ return key
34
+ }
35
+ return { t, currentLocale, direction }
36
+ }
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Prepares the configuration object passed from an Astro page to the React i18n context.
3
+ * Resolves every requested translation key (supporting wildcard suffixes like `ln.dashboard.*`)
4
+ * so the React island receives only the strings it needs.
5
+ *
6
+ * @param i18n i18n helpers sourced from `Astro.locals`.
7
+ * @param translationKeys Specific keys (or wildcard groups) required by the React component.
8
+ * @returns A configuration object containing the resolved config.
9
+ */
10
+ export const prepareI18nConfig = (
11
+ { t, translationKeys: allKeys, currentLocale, direction }: I18n,
12
+ translationKeys: string[],
13
+ ) => {
14
+ const resolveTranslations = (key: string) => {
15
+ if (key.endsWith("*")) {
16
+ const keyPrefix = key.slice(0, -1)
17
+ return allKeys
18
+ .filter((k) => k.startsWith(keyPrefix))
19
+ .map((k) => [k, t(k)])
20
+ }
21
+ return [[key, t(key)]]
22
+ }
23
+
24
+ return {
25
+ translations: Object.fromEntries(
26
+ translationKeys.flatMap(resolveTranslations),
27
+ ) as Record<string, string>,
28
+ currentLocale,
29
+ direction,
30
+ }
31
+ }
@@ -0,0 +1,15 @@
1
+ import { useContext } from "react"
2
+
3
+ import { I18nContext } from "./i18n-context"
4
+
5
+ /**
6
+ * Retrieves the current i18n helpers from context.
7
+ * Must be called inside a React tree wrapped with `I18nContext.Provider`, otherwise throws.
8
+ */
9
+ export const useI18n = () => {
10
+ const i18n = useContext(I18nContext)
11
+ if (!i18n) {
12
+ throw new Error("No i18n context has been provided")
13
+ }
14
+ return i18n
15
+ }
@@ -22,18 +22,30 @@ const languageCodes = [
22
22
  ]
23
23
  const defaultLocale = resolveDefaultLocale(config)
24
24
 
25
- await i18next.init({
25
+ const translations = await prepareI18nextTranslations()
26
+ export const translationKeys = [
27
+ ...new Set(
28
+ Object.values(translations)
29
+ .map(({ translation }) => translation)
30
+ .flatMap((oneLanguageTranslations) =>
31
+ Object.keys(oneLanguageTranslations),
32
+ ),
33
+ ),
34
+ ]
35
+
36
+ const i18n = i18next.createInstance()
37
+ await i18n.init({
26
38
  lng: defaultLocale,
27
39
  // don't use name spacing
28
40
  nsSeparator: false,
29
41
  // only use flat keys
30
42
  keySeparator: false,
31
- resources: await prepareI18nextTranslations(),
43
+ resources: translations,
32
44
  })
33
45
 
34
46
  export function useTranslate(bcp47: string | undefined): TranslateFn {
35
47
  const resolvedLocale = bcp47 ?? defaultLocale
36
- const t = i18next.getFixedT<TranslationKey>(resolvedLocale)
48
+ const t = i18n.getFixedT<TranslationKey>(resolvedLocale)
37
49
  const fallbackLng = [
38
50
  ...resolveLanguage(resolvedLocale).fallbackLanguages,
39
51
  defaultLocale,
@@ -1,6 +1,8 @@
1
1
  import Icon from "../../../components/Icon"
2
+ import { useI18n } from "../../../i18n/react/useI18n"
2
3
 
3
- export default function LoadingSkeleton({ direction }: { direction: string }) {
4
+ export default function LoadingSkeleton() {
5
+ const { direction } = useI18n()
4
6
  return (
5
7
  <div className="flex h-52 animate-pulse items-center overflow-hidden py-2 sm:h-64">
6
8
  <div className="h-36 w-36 shrink-0 rounded-md bg-gray-200"></div>
@@ -4,7 +4,7 @@ import config from "virtual:lightnet/config"
4
4
  import { getUsedCategories } from "../../../content/get-categories"
5
5
  import { contentLanguages } from "../../../content/get-languages"
6
6
  import { getMediaTypes } from "../../../content/get-media-types"
7
- import { provideTranslations } from "../utils/search-filter-translations"
7
+ import { prepareI18nConfig } from "../../../i18n/react/prepare-i18n-config"
8
8
  import SearchFilterReact from "./SearchFilter.tsx"
9
9
 
10
10
  const { t, currentLocale } = Astro.locals.i18n
@@ -49,7 +49,16 @@ if (
49
49
  ) {
50
50
  initialLanguageFilter = currentLocale
51
51
  }
52
- const translations = provideTranslations(t)
52
+ const i18nConfig = prepareI18nConfig(Astro.locals.i18n, [
53
+ "ln.search.title",
54
+ "ln.language",
55
+ "ln.search.placeholder",
56
+ "ln.search.all-languages",
57
+ "ln.type",
58
+ "ln.search.all-types",
59
+ "ln.category",
60
+ "ln.search.all-categories",
61
+ ])
53
62
  ---
54
63
 
55
64
  <SearchFilterReact
@@ -57,7 +66,7 @@ const translations = provideTranslations(t)
57
66
  languages={languages}
58
67
  mediaTypes={mediaTypes}
59
68
  categories={categories}
60
- translations={translations}
69
+ i18nConfig={i18nConfig}
61
70
  languageFilterEnabled={languageFilterEnabled}
62
71
  typesFilterEnabled={typesFilterEnabled}
63
72
  categoriesFilterEnabled={categoriesFilterEnabled}
@@ -1,12 +1,9 @@
1
1
  import { useRef } from "react"
2
2
 
3
3
  import Icon from "../../../components/Icon"
4
+ import { createI18n, type I18nConfig } from "../../../i18n/react/i18n-context"
4
5
  import { useDebounce } from "../hooks/use-debounce"
5
6
  import { useSearchQueryParam } from "../hooks/use-search-query-param"
6
- import type {
7
- TranslationKey,
8
- Translations,
9
- } from "../utils/search-filter-translations"
10
7
  import { CATEGORY, LANGUAGE, SEARCH, TYPE } from "../utils/search-query"
11
8
  import Select from "./Select"
12
9
 
@@ -16,7 +13,7 @@ interface Props {
16
13
  languages: FilterValue[]
17
14
  categories: FilterValue[]
18
15
  mediaTypes: FilterValue[]
19
- translations: Translations
16
+ i18nConfig: I18nConfig
20
17
  languageFilterEnabled: boolean
21
18
  typesFilterEnabled: boolean
22
19
  categoriesFilterEnabled: boolean
@@ -26,7 +23,7 @@ interface Props {
26
23
  export default function SearchFilter({
27
24
  categories,
28
25
  mediaTypes,
29
- translations,
26
+ i18nConfig,
30
27
  languages,
31
28
  languageFilterEnabled,
32
29
  typesFilterEnabled,
@@ -40,7 +37,7 @@ export default function SearchFilter({
40
37
 
41
38
  const searchInput = useRef<HTMLInputElement | null>(null)
42
39
 
43
- const t = (key: TranslationKey) => translations[key]
40
+ const { t } = createI18n(i18nConfig)
44
41
 
45
42
  const debouncedSetSearch = useDebounce((value: string) => {
46
43
  setSearch(value)
@@ -4,10 +4,10 @@ import { getCollection } from "astro:content"
4
4
  import { getUsedCategories } from "../../../content/get-categories"
5
5
  import { contentLanguages } from "../../../content/get-languages"
6
6
  import { getMediaTypes } from "../../../content/get-media-types"
7
- import { provideTranslations } from "../utils/search-translations"
7
+ import { prepareI18nConfig } from "../../../i18n/react/prepare-i18n-config"
8
8
  import SearchListReact from "./SearchList.tsx"
9
9
 
10
- const { t, currentLocale, direction } = Astro.locals.i18n
10
+ const { t, currentLocale } = Astro.locals.i18n
11
11
 
12
12
  const categories: Record<string, string> = {}
13
13
  for (const { id, name } of await getUsedCategories(currentLocale, t)) {
@@ -34,15 +34,16 @@ const languages = Object.fromEntries(
34
34
 
35
35
  const mediaItemsTotal = (await getCollection("media")).length
36
36
 
37
- const translations = provideTranslations(t)
38
37
  const showLanguage = contentLanguages.length > 1
38
+
39
+ const i18nConfig = prepareI18nConfig(Astro.locals.i18n, [
40
+ "ln.search.no-results",
41
+ ])
39
42
  ---
40
43
 
41
44
  <SearchListReact
42
45
  client:load
43
- currentLocale={currentLocale}
44
- direction={direction}
45
- translations={translations}
46
+ i18nConfig={i18nConfig}
46
47
  categories={categories}
47
48
  mediaTypes={mediaTypes}
48
49
  languages={languages}
@@ -1,8 +1,12 @@
1
1
  import { useWindowVirtualizer } from "@tanstack/react-virtual"
2
2
  import { useEffect, useRef, useState } from "react"
3
3
 
4
+ import {
5
+ createI18n,
6
+ type I18nConfig,
7
+ I18nContext,
8
+ } from "../../../i18n/react/i18n-context"
4
9
  import { useSearch } from "../hooks/use-search"
5
- import type { TranslationKey, Translations } from "../utils/search-translations"
6
10
  import LoadingSkeleton from "./LoadingSkeleton"
7
11
  import SearchListItem, {
8
12
  type MediaType,
@@ -10,9 +14,7 @@ import SearchListItem, {
10
14
  } from "./SearchListItem"
11
15
 
12
16
  interface Props {
13
- currentLocale: string | undefined
14
- translations: Translations
15
- direction: "rtl" | "ltr"
17
+ i18nConfig: I18nConfig
16
18
  categories: Record<string, string>
17
19
  languages: Record<string, TranslatedLanguage>
18
20
  showLanguage: boolean
@@ -21,11 +23,9 @@ interface Props {
21
23
  }
22
24
 
23
25
  export default function SearchList({
24
- currentLocale,
25
26
  categories,
26
- translations,
27
+ i18nConfig,
27
28
  languages,
28
- direction,
29
29
  showLanguage,
30
30
  mediaTypes,
31
31
  mediaItemsTotal,
@@ -63,11 +63,10 @@ export default function SearchList({
63
63
  observer.disconnect()
64
64
  }
65
65
  }, [])
66
-
67
- const t = (key: TranslationKey) => translations[key]
66
+ const i18n = createI18n(i18nConfig)
68
67
 
69
68
  return (
70
- <>
69
+ <I18nContext.Provider value={i18n}>
71
70
  <div ref={listRef} className="px-4 md:px-8">
72
71
  <ol
73
72
  className="relative w-full divide-y divide-gray-200"
@@ -89,13 +88,11 @@ export default function SearchList({
89
88
  }}
90
89
  >
91
90
  {isLoading ? (
92
- <LoadingSkeleton direction={direction} />
91
+ <LoadingSkeleton />
93
92
  ) : (
94
93
  <SearchListItem
95
94
  item={item}
96
- direction={direction}
97
95
  showLanguage={showLanguage}
98
- currentLocale={currentLocale}
99
96
  categories={categories}
100
97
  languages={languages}
101
98
  mediaTypes={mediaTypes}
@@ -108,9 +105,9 @@ export default function SearchList({
108
105
  </div>
109
106
  {!results.length && !isLoading && (
110
107
  <div className="mt-24 text-center font-bold text-gray-500">
111
- {t("ln.search.no-results")}
108
+ {i18n.t("ln.search.no-results")}
112
109
  </div>
113
110
  )}
114
- </>
111
+ </I18nContext.Provider>
115
112
  )
116
113
  }
@@ -1,5 +1,6 @@
1
1
  import CoverImageDecorator from "../../../components/CoverImageDecorator"
2
2
  import Icon from "../../../components/Icon"
3
+ import { useI18n } from "../../../i18n/react/useI18n"
3
4
  import { detailsPagePath } from "../../../utils/paths"
4
5
  import type { SearchItem } from "../api/search-response"
5
6
 
@@ -16,8 +17,6 @@ export type TranslatedLanguage = {
16
17
 
17
18
  interface Props {
18
19
  item: SearchItem
19
- currentLocale: string | undefined
20
- direction: "rtl" | "ltr"
21
20
  categories: Record<string, string>
22
21
  languages: Record<string, TranslatedLanguage>
23
22
  showLanguage: boolean
@@ -26,13 +25,12 @@ interface Props {
26
25
 
27
26
  export default function SearchListItem({
28
27
  item,
29
- currentLocale,
30
28
  categories,
31
29
  languages,
32
- direction,
33
30
  showLanguage,
34
31
  mediaTypes,
35
32
  }: Props) {
33
+ const { currentLocale, direction } = useI18n()
36
34
  const coverImageStyle = mediaTypes[item.type].coverImageStyle
37
35
  return (
38
36
  <a
@@ -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
- }
@@ -1,20 +0,0 @@
1
- const translationKeys = [
2
- "ln.search.title",
3
- "ln.language",
4
- "ln.search.placeholder",
5
- "ln.search.all-languages",
6
- "ln.type",
7
- "ln.search.all-types",
8
- "ln.category",
9
- "ln.search.all-categories",
10
- ] as const
11
-
12
- export type TranslationKey = (typeof translationKeys)[number]
13
-
14
- export type Translations = Record<TranslationKey, string>
15
-
16
- export const provideTranslations = (translate: (key: string) => string) => {
17
- return Object.fromEntries(
18
- translationKeys.map((key) => [key, translate(key)]),
19
- ) as Translations
20
- }
@@ -1,11 +0,0 @@
1
- const translationKeys = ["ln.search.no-results"] as const
2
-
3
- export type TranslationKey = (typeof translationKeys)[number]
4
-
5
- export type Translations = Record<TranslationKey, string>
6
-
7
- export const provideTranslations = (translate: (key: string) => string) => {
8
- return Object.fromEntries(
9
- translationKeys.map((key) => [key, translate(key)]),
10
- ) as Translations
11
- }