lightnet 3.10.7 → 3.10.8

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 (35) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/__e2e__/admin.spec.ts +19 -19
  3. package/__e2e__/fixtures/basics/node_modules/.bin/astro +2 -2
  4. package/__e2e__/fixtures/basics/package.json +2 -2
  5. package/package.json +5 -5
  6. package/src/admin/components/form/DynamicArray.tsx +3 -3
  7. package/src/admin/components/form/Input.tsx +1 -1
  8. package/src/admin/components/form/MarkdownEditor.tsx +1 -1
  9. package/src/admin/components/form/Select.tsx +1 -1
  10. package/src/admin/components/form/SubmitButton.tsx +2 -2
  11. package/src/admin/components/form/atoms/ErrorMessage.tsx +1 -1
  12. package/src/admin/components/form/atoms/FileUpload.tsx +115 -0
  13. package/src/admin/components/form/atoms/Hint.tsx +1 -1
  14. package/src/admin/components/form/atoms/Label.tsx +2 -2
  15. package/src/admin/components/form/hooks/use-field-error.tsx +23 -2
  16. package/src/admin/i18n/admin-i18n.ts +21 -0
  17. package/src/admin/i18n/translations/en.yml +8 -2
  18. package/src/admin/pages/AdminRoute.astro +0 -2
  19. package/src/admin/pages/media/EditForm.tsx +14 -8
  20. package/src/admin/pages/media/EditRoute.astro +31 -15
  21. package/src/admin/pages/media/fields/Image.tsx +86 -0
  22. package/src/admin/pages/media/file-system.ts +6 -2
  23. package/src/admin/pages/media/media-item-store.ts +32 -3
  24. package/src/admin/types/media-item.ts +20 -0
  25. package/src/astro-integration/config.ts +10 -0
  26. package/src/astro-integration/integration.ts +7 -3
  27. package/src/content/get-media-items.ts +2 -1
  28. package/src/i18n/react/i18n-context.ts +16 -5
  29. package/src/layouts/Page.astro +5 -4
  30. package/src/layouts/components/LanguagePicker.astro +11 -5
  31. package/src/layouts/components/Menu.astro +76 -10
  32. package/src/pages/details-page/components/main-details/EditButton.astro +1 -1
  33. package/src/pages/search-page/components/LoadingSkeleton.tsx +1 -1
  34. package/src/pages/search-page/components/SearchListItem.tsx +1 -1
  35. /package/src/i18n/react/{useI18n.ts → use-i18n.ts} +0 -0
package/CHANGELOG.md CHANGED
@@ -1,5 +1,20 @@
1
1
  # lightnet
2
2
 
3
+ ## 3.10.8
4
+
5
+ ### Patch Changes
6
+
7
+ - [#340](https://github.com/LightNetDev/LightNet/pull/340) [`6162179`](https://github.com/LightNetDev/LightNet/commit/6162179b90e987359934bd08ac9d4500eb560e25) Thanks [@smn-cds](https://github.com/smn-cds)! - Tighten locale detection to avoid partial matches
8
+
9
+ When the URL path starts with a locale-like prefix, only full segment matches should be treated as a locale. Previously `/enx` was parsed as locale `en`; now only `/en/...` (or just `/en`) will match.
10
+
11
+ - [#346](https://github.com/LightNetDev/LightNet/pull/346) [`2bc1560`](https://github.com/LightNetDev/LightNet/commit/2bc15600b0a9bcfc3410f155191b7ea1c9c5306a) Thanks [@smn-cds](https://github.com/smn-cds)! - Fixes an issue where top menus would not close when tapping outside the menu on iOS Safari.
12
+ This update also improves overall menu accessibility.
13
+
14
+ - [#345](https://github.com/LightNetDev/LightNet/pull/345) [`1e6cddb`](https://github.com/LightNetDev/LightNet/commit/1e6cddba0e783f9d59da7d36dd154dbcd784ed6b) Thanks [@smn-cds](https://github.com/smn-cds)! - Fix menu not working on ios safari
15
+
16
+ - [#340](https://github.com/LightNetDev/LightNet/pull/340) [`6162179`](https://github.com/LightNetDev/LightNet/commit/6162179b90e987359934bd08ac9d4500eb560e25) Thanks [@smn-cds](https://github.com/smn-cds)! - Update dependencies
17
+
3
18
  ## 3.10.7
4
19
 
5
20
  ### Patch Changes
@@ -25,13 +25,13 @@ test.describe("Edit button on details page", () => {
25
25
  await expect(editButton).toBeHidden()
26
26
  })
27
27
 
28
- test("Should show `Edit` button on book details page after visiting `/en/admin/` path.", async ({
28
+ test("Should show `Edit` button on book details page after visiting `/admin/` path.", async ({
29
29
  page,
30
30
  lightnet,
31
31
  }) => {
32
32
  const ln = await lightnet()
33
33
 
34
- await page.goto(ln.resolveURL("/en/admin/"))
34
+ await page.goto(ln.resolveURL("/admin/"))
35
35
  await expect(
36
36
  page.getByText("Admin features are enabled now.", { exact: true }),
37
37
  ).toBeVisible()
@@ -45,15 +45,15 @@ test.describe("Edit button on details page", () => {
45
45
  await expect(editButton).toBeVisible()
46
46
  await expect(editButton).toHaveAttribute(
47
47
  "href",
48
- "/en/admin/media/faithful-freestyle--en",
48
+ "/admin/media/faithful-freestyle--en",
49
49
  )
50
50
  })
51
51
 
52
- test("Should show `Edit` button on video details page after visiting `/en/admin/` path.", async ({
52
+ test("Should show `Edit` button on video details page after visiting `/admin/` path.", async ({
53
53
  page,
54
54
  lightnet,
55
55
  }) => {
56
- const ln = await lightnet("/en/admin/")
56
+ const ln = await lightnet("/admin/")
57
57
 
58
58
  await expect(
59
59
  page.getByText("Admin features are enabled now.", { exact: true }),
@@ -68,15 +68,15 @@ test.describe("Edit button on details page", () => {
68
68
  await expect(editButton).toBeVisible()
69
69
  await expect(editButton).toHaveAttribute(
70
70
  "href",
71
- "/en/admin/media/how-to-kickflip--de",
71
+ "/admin/media/how-to-kickflip--de",
72
72
  )
73
73
  })
74
74
 
75
- test("Should show `Edit` button on audio details page after visiting `/en/admin/` path.", async ({
75
+ test("Should show `Edit` button on audio details page after visiting `/admin/` path.", async ({
76
76
  page,
77
77
  lightnet,
78
78
  }) => {
79
- const ln = await lightnet("/en/admin/")
79
+ const ln = await lightnet("/admin/")
80
80
 
81
81
  await expect(
82
82
  page.getByText("Admin features are enabled now.", { exact: true }),
@@ -91,7 +91,7 @@ test.describe("Edit button on details page", () => {
91
91
  await expect(editButton).toBeVisible()
92
92
  await expect(editButton).toHaveAttribute(
93
93
  "href",
94
- "/en/admin/media/skate-sounds--en",
94
+ "/admin/media/skate-sounds--en",
95
95
  )
96
96
  })
97
97
 
@@ -99,7 +99,7 @@ test.describe("Edit button on details page", () => {
99
99
  page,
100
100
  lightnet,
101
101
  }) => {
102
- const ln = await lightnet("/en/admin/")
102
+ const ln = await lightnet("/admin/")
103
103
  await ln.goto("/en/media/faithful-freestyle--en")
104
104
 
105
105
  const editButton = page.locator("#edit-btn")
@@ -107,7 +107,7 @@ test.describe("Edit button on details page", () => {
107
107
 
108
108
  await editButton.click()
109
109
  await expect(page).toHaveURL(
110
- ln.resolveURL("/en/admin/media/faithful-freestyle--en"),
110
+ ln.resolveURL("/admin/media/faithful-freestyle--en"),
111
111
  )
112
112
  await expect(
113
113
  page.getByText("Edit media item", { exact: false }),
@@ -147,7 +147,7 @@ test.describe("Media item edit page", () => {
147
147
  ).toBeVisible()
148
148
 
149
149
  test("should edit title", async ({ page, lightnet }) => {
150
- await lightnet("/en/admin/media/faithful-freestyle--en")
150
+ await lightnet("/admin/media/faithful-freestyle--en")
151
151
  const writeFileRequest = await recordWriteFile(page)
152
152
 
153
153
  const updatedTitle = "Faithful Freestyle (Edited)"
@@ -175,7 +175,7 @@ test.describe("Media item edit page", () => {
175
175
  })
176
176
 
177
177
  test("Should update media type", async ({ page, lightnet }) => {
178
- await lightnet("/en/admin/media/faithful-freestyle--en")
178
+ await lightnet("/admin/media/faithful-freestyle--en")
179
179
  const writeFileRequest = await recordWriteFile(page)
180
180
 
181
181
  const typeSelect = page.getByLabel("Type").first()
@@ -198,7 +198,7 @@ test.describe("Media item edit page", () => {
198
198
  })
199
199
 
200
200
  test("Should update author name", async ({ page, lightnet }) => {
201
- await lightnet("/en/admin/media/faithful-freestyle--en")
201
+ await lightnet("/admin/media/faithful-freestyle--en")
202
202
  const writeFileRequest = await recordWriteFile(page)
203
203
 
204
204
  const authorsFieldset = page.getByRole("group", { name: "Authors" })
@@ -223,7 +223,7 @@ test.describe("Media item edit page", () => {
223
223
  })
224
224
 
225
225
  test("Should add author", async ({ page, lightnet }) => {
226
- await lightnet("/en/admin/media/faithful-freestyle--en")
226
+ await lightnet("/admin/media/faithful-freestyle--en")
227
227
  const writeFileRequest = await recordWriteFile(page)
228
228
 
229
229
  const authorsFieldset = page.getByRole("group", { name: "Authors" })
@@ -249,7 +249,7 @@ test.describe("Media item edit page", () => {
249
249
  })
250
250
 
251
251
  test("Should remove author", async ({ page, lightnet }) => {
252
- await lightnet("/en/admin/media/faithful-freestyle--en")
252
+ await lightnet("/admin/media/faithful-freestyle--en")
253
253
  const writeFileRequest = await recordWriteFile(page)
254
254
 
255
255
  const authorsFieldset = page.getByRole("group", { name: "Authors" })
@@ -283,7 +283,7 @@ test.describe("Media item edit page", () => {
283
283
  page,
284
284
  lightnet,
285
285
  }) => {
286
- await lightnet("/en/admin/media/faithful-freestyle--en")
286
+ await lightnet("/admin/media/faithful-freestyle--en")
287
287
 
288
288
  const commonIdInput = page.getByLabel("Common ID")
289
289
  await expect(commonIdInput).toHaveValue("faithful-freestyle")
@@ -302,7 +302,7 @@ test.describe("Media item edit page", () => {
302
302
  page,
303
303
  lightnet,
304
304
  }) => {
305
- await lightnet("/en/admin/media/faithful-freestyle--en")
305
+ await lightnet("/admin/media/faithful-freestyle--en")
306
306
 
307
307
  const categoriesFieldset = page.getByRole("group", { name: "Categories" })
308
308
  await page.getByRole("button", { name: "Add Category" }).click()
@@ -325,7 +325,7 @@ test.describe("Media item edit page", () => {
325
325
  page,
326
326
  lightnet,
327
327
  }) => {
328
- await lightnet("/en/admin/media/faithful-freestyle--en")
328
+ await lightnet("/admin/media/faithful-freestyle--en")
329
329
 
330
330
  const categoriesFieldset = page.getByRole("group", { name: "Categories" })
331
331
  await page.getByRole("button", { name: "Add Category" }).click()
@@ -10,9 +10,9 @@ case `uname` in
10
10
  esac
11
11
 
12
12
  if [ -z "$NODE_PATH" ]; then
13
- export NODE_PATH="/home/runner/work/LightNet/LightNet/node_modules/.pnpm/astro@5.15.9_@types+node@24.10.1_jiti@2.4.2_lightningcss@1.29.1_rollup@4.53.3_terser@5.39.0_typescript@5.9.3_yaml@2.8.1/node_modules/astro/node_modules:/home/runner/work/LightNet/LightNet/node_modules/.pnpm/astro@5.15.9_@types+node@24.10.1_jiti@2.4.2_lightningcss@1.29.1_rollup@4.53.3_terser@5.39.0_typescript@5.9.3_yaml@2.8.1/node_modules:/home/runner/work/LightNet/LightNet/node_modules/.pnpm/node_modules"
13
+ export NODE_PATH="/home/runner/work/LightNet/LightNet/node_modules/.pnpm/astro@5.16.1_@types+node@24.10.1_jiti@2.4.2_lightningcss@1.29.1_rollup@4.53.3_terser@5.39.0_typescript@5.9.3_yaml@2.8.1/node_modules/astro/node_modules:/home/runner/work/LightNet/LightNet/node_modules/.pnpm/astro@5.16.1_@types+node@24.10.1_jiti@2.4.2_lightningcss@1.29.1_rollup@4.53.3_terser@5.39.0_typescript@5.9.3_yaml@2.8.1/node_modules:/home/runner/work/LightNet/LightNet/node_modules/.pnpm/node_modules"
14
14
  else
15
- export NODE_PATH="/home/runner/work/LightNet/LightNet/node_modules/.pnpm/astro@5.15.9_@types+node@24.10.1_jiti@2.4.2_lightningcss@1.29.1_rollup@4.53.3_terser@5.39.0_typescript@5.9.3_yaml@2.8.1/node_modules/astro/node_modules:/home/runner/work/LightNet/LightNet/node_modules/.pnpm/astro@5.15.9_@types+node@24.10.1_jiti@2.4.2_lightningcss@1.29.1_rollup@4.53.3_terser@5.39.0_typescript@5.9.3_yaml@2.8.1/node_modules:/home/runner/work/LightNet/LightNet/node_modules/.pnpm/node_modules:$NODE_PATH"
15
+ export NODE_PATH="/home/runner/work/LightNet/LightNet/node_modules/.pnpm/astro@5.16.1_@types+node@24.10.1_jiti@2.4.2_lightningcss@1.29.1_rollup@4.53.3_terser@5.39.0_typescript@5.9.3_yaml@2.8.1/node_modules/astro/node_modules:/home/runner/work/LightNet/LightNet/node_modules/.pnpm/astro@5.16.1_@types+node@24.10.1_jiti@2.4.2_lightningcss@1.29.1_rollup@4.53.3_terser@5.39.0_typescript@5.9.3_yaml@2.8.1/node_modules:/home/runner/work/LightNet/LightNet/node_modules/.pnpm/node_modules:$NODE_PATH"
16
16
  fi
17
17
  if [ -x "$basedir/node" ]; then
18
18
  exec "$basedir/node" "$basedir/../astro/astro.js" "$@"
@@ -7,8 +7,8 @@
7
7
  "@astrojs/react": "^4.4.2",
8
8
  "@astrojs/tailwind": "^6.0.2",
9
9
  "@lightnet/decap-admin": "^3.1.4",
10
- "astro": "^5.15.9",
11
- "lightnet": "^3.10.6",
10
+ "astro": "^5.16.1",
11
+ "lightnet": "^3.10.7",
12
12
  "react": "^19.2.0",
13
13
  "react-dom": "^19.2.0",
14
14
  "sharp": "^0.34.5",
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.7",
6
+ "version": "3.10.8",
7
7
  "repository": {
8
8
  "type": "git",
9
9
  "url": "https://github.com/LightNetDev/lightnet",
@@ -50,7 +50,7 @@
50
50
  "@hookform/resolvers": "^5.2.2",
51
51
  "@iconify-json/mdi": "^1.2.3",
52
52
  "@iconify/tailwind": "^1.2.0",
53
- "@mdxeditor/editor": "^3.49.3",
53
+ "@mdxeditor/editor": "^3.50.0",
54
54
  "@tailwindcss/typography": "^0.5.19",
55
55
  "@tanstack/react-virtual": "^3.13.12",
56
56
  "daisyui": "^4.12.24",
@@ -63,11 +63,11 @@
63
63
  "yaml": "^2.8.1"
64
64
  },
65
65
  "devDependencies": {
66
- "@playwright/test": "^1.56.1",
66
+ "@playwright/test": "^1.57.0",
67
67
  "@types/node": "^22.19.1",
68
- "@types/react": "^19.2.6",
68
+ "@types/react": "^19.2.7",
69
69
  "typescript": "^5.9.3",
70
- "vitest": "^4.0.10"
70
+ "vitest": "^4.0.14"
71
71
  },
72
72
  "engines": {
73
73
  "node": ">=22"
@@ -8,7 +8,7 @@ import {
8
8
  } from "react-hook-form"
9
9
 
10
10
  import Icon from "../../../components/Icon"
11
- import { useI18n } from "../../../i18n/react/useI18n"
11
+ import { useI18n } from "../../../i18n/react/use-i18n"
12
12
  import ErrorMessage from "./atoms/ErrorMessage"
13
13
  import Hint from "./atoms/Hint"
14
14
  import Label from "./atoms/Label"
@@ -52,7 +52,7 @@ export default function DynamicArray<TFieldValues extends FieldValues>({
52
52
  <div className="flex w-full items-center gap-2 p-2" key={field.id}>
53
53
  <div className="flex grow flex-col">{renderElement(index)}</div>
54
54
  <button
55
- className="flex items-center rounded-md p-2 text-gray-600 transition-colors ease-in-out hover:text-rose-800 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-700"
55
+ className="flex items-center rounded-md p-2 text-gray-600 transition-colors ease-in-out hover:text-rose-800 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-700"
56
56
  type="button"
57
57
  onClick={() => remove(index)}
58
58
  >
@@ -62,7 +62,7 @@ export default function DynamicArray<TFieldValues extends FieldValues>({
62
62
  ))}
63
63
  <button
64
64
  type="button"
65
- className="rounded-b-lg p-4 text-sm font-bold text-gray-500 transition-colors ease-in-out hover:bg-gray-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-700"
65
+ className="rounded-b-lg p-4 text-sm font-bold text-gray-500 transition-colors ease-in-out hover:bg-sky-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-700"
66
66
  onClick={() => {
67
67
  addButton.onClick(append, fields.length)
68
68
  }}
@@ -41,7 +41,7 @@ export default function Input<TFieldValues extends FieldValues>({
41
41
  )}
42
42
 
43
43
  <input
44
- className={`dy-input dy-input-bordered border-gray-300 shadow-inner focus:border-blue-700 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-blue-700 ${isDirty && !errorMessage ? "border-gray-700" : ""} ${errorMessage ? "border-rose-800" : ""} ${label ? "rounded-ss-none" : ""}`}
44
+ className={`dy-input dy-input-bordered border-gray-300 shadow-inner focus:border-sky-700 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-sky-700 ${isDirty && !errorMessage ? "border-gray-700" : ""} ${errorMessage ? "border-rose-800" : ""} ${label ? "rounded-ss-none" : ""}`}
45
45
  id={name}
46
46
  aria-invalid={!!errorMessage}
47
47
  {...control.register(name)}
@@ -32,7 +32,7 @@ export default function MarkdownEditor<TFieldValues extends FieldValues>({
32
32
  </legend>
33
33
 
34
34
  <div
35
- className={`overflow-hidden rounded-lg rounded-ss-none border border-gray-300 shadow-sm group-focus-within:border-blue-700 group-focus-within:ring-1 group-focus-within:ring-blue-700 ${isDirty && !errorMessage ? "border-gray-700" : ""} ${errorMessage ? "border-rose-800" : ""}`}
35
+ className={`overflow-hidden rounded-lg rounded-ss-none border border-gray-300 shadow-sm group-focus-within:border-sky-700 group-focus-within:ring-1 group-focus-within:ring-sky-700 ${isDirty && !errorMessage ? "border-gray-700" : ""} ${errorMessage ? "border-rose-800" : ""}`}
36
36
  >
37
37
  <Suspense
38
38
  fallback={
@@ -44,7 +44,7 @@ export default function Select<TFieldValues extends FieldValues>({
44
44
  id={name}
45
45
  aria-invalid={!!errorMessage}
46
46
  defaultValue={defaultValue}
47
- className={`dy-select dy-select-bordered text-base shadow-sm focus:border-blue-700 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-blue-700 ${isDirty && !errorMessage ? "border-gray-700" : ""} ${errorMessage ? "border-rose-800" : ""} ${label ? "rounded-ss-none" : ""}`}
47
+ className={`dy-select dy-select-bordered text-base shadow-sm focus:border-sky-700 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-sky-700 ${isDirty && !errorMessage ? "border-gray-700" : ""} ${errorMessage ? "border-rose-800" : ""} ${label ? "rounded-ss-none" : ""}`}
48
48
  >
49
49
  {options.map(({ id, labelText }) => (
50
50
  <option key={id} value={id}>
@@ -2,13 +2,13 @@ import { useEffect, useRef, useState } from "react"
2
2
  import { type Control, useFormState } from "react-hook-form"
3
3
 
4
4
  import Icon from "../../../components/Icon"
5
- import { useI18n } from "../../../i18n/react/useI18n"
5
+ import { useI18n } from "../../../i18n/react/use-i18n"
6
6
  import type { MediaItem } from "../../types/media-item"
7
7
 
8
8
  const SUCCESS_DURATION_MS = 2000
9
9
 
10
10
  const baseButtonClass =
11
- "flex min-w-52 items-center justify-center gap-2 rounded-2xl px-4 py-3 font-bold shadow-sm transition-colors easy-in-out focus-visible:ring-2 focus-visible:outline-none focus-visible:ring-blue-700 focus-visible:ring-offset-1 disabled:cursor-not-allowed"
11
+ "flex min-w-52 items-center justify-center gap-2 rounded-2xl px-4 py-3 font-bold shadow-sm transition-colors easy-in-out focus-visible:ring-2 focus-visible:outline-none focus-visible:ring-sky-700 focus-visible:ring-offset-1 disabled:cursor-not-allowed"
12
12
 
13
13
  const buttonStateClasses = {
14
14
  idle: "bg-gray-800 text-gray-50 hover:bg-gray-950 hover:text-gray-300 disabled:bg-gray-500 disabled:text-gray-300",
@@ -1,4 +1,4 @@
1
- import { useI18n } from "../../../../i18n/react/useI18n"
1
+ import { useI18n } from "../../../../i18n/react/use-i18n"
2
2
 
3
3
  export default function ErrorMessage({ message }: { message?: string }) {
4
4
  const { t } = useI18n()
@@ -0,0 +1,115 @@
1
+ import { type ChangeEvent, type DragEvent, useRef, useState } from "react"
2
+ import {
3
+ type Control,
4
+ type FieldValues,
5
+ type Path,
6
+ useController,
7
+ } from "react-hook-form"
8
+ import config from "virtual:lightnet/config"
9
+
10
+ import { useI18n } from "../../../../i18n/react/use-i18n"
11
+
12
+ type FileType = "image/png" | "image/jpeg" | "image/webp"
13
+
14
+ export default function FileUpload<TFieldValues extends FieldValues>({
15
+ name,
16
+ control,
17
+ destinationPath,
18
+ onFileChange,
19
+ fileName,
20
+ acceptedFileTypes,
21
+ }: {
22
+ onFileChange: (file: File) => void
23
+ control: Control<TFieldValues>
24
+ name: Path<TFieldValues>
25
+ destinationPath: string
26
+ fileName?: string
27
+ acceptedFileTypes: Readonly<FileType[]>
28
+ }) {
29
+ const { field } = useController({
30
+ name,
31
+ control,
32
+ })
33
+ const fileInputRef = useRef<HTMLInputElement | null>(null)
34
+ const { t } = useI18n()
35
+
36
+ const [isDragging, setIsDragging] = useState(false)
37
+
38
+ const onFileSelected = (file?: File) => {
39
+ if (!file) {
40
+ return
41
+ }
42
+ if (!acceptedFileTypes.includes(file.type as any)) {
43
+ return
44
+ }
45
+ const nameParts = file.name.split(".")
46
+ const extension = nameParts.pop()
47
+ const name = nameParts.join(".")
48
+ field.onChange({
49
+ ...field.value,
50
+ path: `${destinationPath}/${fileName ?? name}.${extension}`,
51
+ file,
52
+ })
53
+ onFileChange(file)
54
+ field.onBlur()
55
+ }
56
+
57
+ const onDragEnter = (event: DragEvent<HTMLDivElement>) => {
58
+ event.preventDefault()
59
+ setIsDragging(true)
60
+ }
61
+
62
+ const onDrop = (event: DragEvent<HTMLDivElement>) => {
63
+ event.preventDefault()
64
+ setIsDragging(false)
65
+ onFileSelected(event.dataTransfer.files?.[0])
66
+ }
67
+
68
+ const onInputChange = (event: ChangeEvent<HTMLInputElement>) => {
69
+ onFileSelected(event.target.files?.[0])
70
+ // allow selecting the same file twice in a row
71
+ event.target.value = ""
72
+ }
73
+
74
+ return (
75
+ <>
76
+ <div
77
+ className={`flex w-full flex-col items-center justify-center gap-1 rounded-md border-2 border-dashed border-gray-300 bg-gray-200 p-4 transition-colors ease-in-out ${isDragging ? "border-sky-700 bg-sky-50" : ""} focus-within:border-sky-700 hover:bg-sky-50`}
78
+ role="button"
79
+ tabIndex={0}
80
+ onClick={() => fileInputRef.current?.click()}
81
+ onBlur={field.onBlur}
82
+ onKeyDown={(event) => {
83
+ if (event.key === "Enter" || event.key === " ") {
84
+ event.preventDefault()
85
+ fileInputRef.current?.click()
86
+ }
87
+ }}
88
+ onDragOver={onDragEnter}
89
+ onDragLeave={() => setIsDragging(false)}
90
+ onDrop={onDrop}
91
+ >
92
+ <span className="text-sm text-gray-800">
93
+ {t("ln.admin.file-upload-hint")}
94
+ </span>
95
+ <span className="text-xs text-gray-600">
96
+ {t("ln.admin.file-upload-size-limit", {
97
+ limit: config.experimental?.admin?.maxFileSize,
98
+ })}
99
+ </span>
100
+ </div>
101
+ <input
102
+ id={field.name}
103
+ name={field.name}
104
+ ref={(ref) => {
105
+ fileInputRef.current = ref
106
+ field.ref(ref)
107
+ }}
108
+ type="file"
109
+ accept={acceptedFileTypes.join(",")}
110
+ className="sr-only"
111
+ onChange={onInputChange}
112
+ />
113
+ </>
114
+ )
115
+ }
@@ -1,4 +1,4 @@
1
- import { useI18n } from "../../../../i18n/react/useI18n"
1
+ import { useI18n } from "../../../../i18n/react/use-i18n"
2
2
 
3
3
  export default function Hint({
4
4
  label,
@@ -1,4 +1,4 @@
1
- import { useI18n } from "../../../../i18n/react/useI18n"
1
+ import { useI18n } from "../../../../i18n/react/use-i18n"
2
2
 
3
3
  export default function Label({
4
4
  label,
@@ -17,7 +17,7 @@ export default function Label({
17
17
  return (
18
18
  <div className="flex">
19
19
  <span
20
- className={`rounded-t-md bg-gray-300 px-4 font-bold text-gray-700 shadow-sm transition-colors duration-150 group-focus-within:bg-blue-700 group-focus-within:text-gray-50 group-focus-within:ring-1 group-focus-within:ring-blue-700 ${size === "medium" ? "py-2 text-sm" : "py-1 text-xs"} ${isDirty ? "bg-gray-700 !text-white" : ""} ${isInvalid ? "bg-rose-800 !text-gray-50" : ""} ${className}`}
20
+ className={`rounded-t-md bg-gray-300 px-4 font-bold text-gray-700 shadow-sm transition-colors duration-150 group-focus-within:bg-sky-700 group-focus-within:text-gray-50 group-focus-within:ring-1 group-focus-within:ring-sky-700 ${size === "medium" ? "py-2 text-sm" : "py-1 text-xs"} ${isDirty ? "bg-gray-700 !text-white" : ""} ${isInvalid ? "bg-rose-800 !text-gray-50" : ""} ${className}`}
21
21
  >
22
22
  {t(label)}
23
23
  </span>
@@ -3,11 +3,32 @@ import { type Control, get, useFormState } from "react-hook-form"
3
3
  export function useFieldError({
4
4
  control,
5
5
  name,
6
+ exact = true,
6
7
  }: {
7
8
  control: Control<any>
8
9
  name: string
10
+ exact?: boolean
9
11
  }) {
10
- const { errors } = useFormState({ control, name, exact: true })
12
+ const { errors } = useFormState({ control, name, exact })
11
13
  const error = get(errors, name) as { message: string } | undefined
12
- return error?.message
14
+ if (exact) {
15
+ return error?.message
16
+ } else {
17
+ return findErrorMessage(get(errors, name))
18
+ }
19
+ }
20
+
21
+ function findErrorMessage(errors?: unknown) {
22
+ if (!errors) {
23
+ return undefined
24
+ }
25
+ for (const [key, value] of Object.entries(errors)) {
26
+ if (key === "message" && typeof value === "string") {
27
+ return value
28
+ }
29
+ if (value && typeof value === "object" && !Array.isArray(value)) {
30
+ return findErrorMessage(value)
31
+ }
32
+ }
33
+ return undefined
13
34
  }
@@ -0,0 +1,21 @@
1
+ import config from "virtual:lightnet/config"
2
+
3
+ import { resolveDefaultLocale } from "../../i18n/resolve-default-locale"
4
+ import { resolveLanguage } from "../../i18n/resolve-language"
5
+ import { resolveLocales } from "../../i18n/resolve-locales"
6
+ import { translationKeys, useTranslate } from "../../i18n/translate"
7
+
8
+ const currentLocale = config.experimental?.admin?.languageCode ?? "en"
9
+ const t = useTranslate(currentLocale)
10
+ const { direction } = resolveLanguage(currentLocale)
11
+ const defaultLocale = resolveDefaultLocale(config)
12
+ const locales = resolveLocales(config)
13
+
14
+ export const adminI18n = {
15
+ currentLocale,
16
+ t,
17
+ direction,
18
+ translationKeys,
19
+ defaultLocale,
20
+ locales,
21
+ }
@@ -15,13 +15,19 @@ ln.admin.position-in-collection: Position in Collection
15
15
  ln.admin.back-to-details-page: Back to details page
16
16
  ln.admin.title: Title
17
17
  ln.admin.common-id: Common ID
18
+ ln.admin.image: Image
19
+ ln.admin.file-upload-hint: Drag a file here or click to browse files.
20
+ ln.admin.file-upload-size-limit: "Files up to {{limit}} MB accepted."
21
+ ln.admin.image-hint: Cover image with format PNG, JPG or WebP.
22
+ ln.admin.select-file: Select file
18
23
  ln.admin.authors: Authors
19
24
  ln.admin.description: Description
20
25
  ln.admin.date-created: Date Created
21
- ln.admin.date-created-hint: When has this item been created on this library?
22
- ln.admin.common-id-hint: The English title, all lowercase, words separated with hyphens.
26
+ ln.admin.date-created-hint: The date this item was added to this media library.
27
+ ln.admin.common-id-hint: Use a shared Common ID to link translated versions of a media item.
23
28
  ln.admin.errors.non-empty-string: Please enter at least one character.
24
29
  ln.admin.errors.invalid-date: That date doesn't look right.
30
+ ln.admin.error.file-size-exceeded: This file is too big.
25
31
  ln.admin.errors.required: This field is required.
26
32
  ln.admin.errors.gte-0: Use a number zero or greater.
27
33
  ln.admin.errors.unique-elements: Please choose a different value for each entry.
@@ -1,7 +1,5 @@
1
1
  ---
2
2
  import Page from "../../layouts/Page.astro"
3
-
4
- export { getLocalePaths as getStaticPaths } from "../../i18n/get-locale-paths"
5
3
  ---
6
4
 
7
5
  <Page>
@@ -14,6 +14,7 @@ import { type MediaItem, mediaItemSchema } from "../../types/media-item"
14
14
  import Authors from "./fields/Authors"
15
15
  import Categories from "./fields/Categories"
16
16
  import Collections from "./fields/Collections"
17
+ import Image from "./fields/Image"
17
18
  import { updateMediaItem } from "./media-item-store"
18
19
 
19
20
  type SelectOption = { id: string; labelText: string }
@@ -59,13 +60,6 @@ export default function EditForm({
59
60
  control={control}
60
61
  defaultValue={mediaItem.title}
61
62
  />
62
- <Input
63
- name="commonId"
64
- label="ln.admin.common-id"
65
- hint="ln.admin.common-id-hint"
66
- control={control}
67
- defaultValue={mediaItem.commonId}
68
- />
69
63
  <Select
70
64
  name="type"
71
65
  label="ln.type"
@@ -73,6 +67,11 @@ export default function EditForm({
73
67
  control={control}
74
68
  defaultValue={mediaItem.type}
75
69
  />
70
+ <Image
71
+ control={control}
72
+ defaultValue={mediaItem.image}
73
+ mediaId={mediaId}
74
+ />
76
75
  <Select
77
76
  name="language"
78
77
  label="ln.language"
@@ -104,8 +103,15 @@ export default function EditForm({
104
103
  name="description"
105
104
  label="ln.admin.description"
106
105
  />
106
+ <Input
107
+ name="commonId"
108
+ label="ln.admin.common-id"
109
+ hint="ln.admin.common-id-hint"
110
+ control={control}
111
+ defaultValue={mediaItem.commonId}
112
+ />
107
113
 
108
- <SubmitButton className="self-end" control={control} />
114
+ <SubmitButton className="mt-8 self-end" control={control} />
109
115
  </form>
110
116
  </I18nContext.Provider>
111
117
  )
@@ -1,28 +1,40 @@
1
1
  ---
2
2
  import type { GetStaticPaths } from "astro"
3
+ import { getImage } from "astro:assets"
3
4
  import { getCollection } from "astro:content"
4
5
  import config from "virtual:lightnet/config"
5
6
 
6
7
  import { getCategories } from "../../../content/get-categories"
7
- import { getRawMediaItem } from "../../../content/get-media-items"
8
+ import { getMediaItem, getRawMediaItem } from "../../../content/get-media-items"
8
9
  import { getMediaTypes } from "../../../content/get-media-types"
9
10
  import { prepareI18nConfig } from "../../../i18n/react/prepare-i18n-config"
10
- import { resolveLocales } from "../../../i18n/resolve-locales"
11
11
  import Page from "../../../layouts/Page.astro"
12
+ import { adminI18n } from "../../i18n/admin-i18n"
12
13
  import EditForm from "./EditForm"
13
14
 
14
15
  export const getStaticPaths = (async () => {
15
16
  const mediaItems = await getCollection("media")
16
- return resolveLocales(config).flatMap((locale) =>
17
- mediaItems.map(({ id: mediaId }) => ({ params: { mediaId, locale } })),
18
- )
17
+ return mediaItems.map(({ id: mediaId }) => ({ params: { mediaId } }))
19
18
  }) satisfies GetStaticPaths
20
19
 
21
20
  const { mediaId } = Astro.params
22
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
+ })
23
31
 
24
32
  const formData = {
25
33
  ...mediaItem,
34
+ image: {
35
+ path: mediaItem.image,
36
+ previewSrc: previewImage.src,
37
+ },
26
38
  authors: mediaItem.authors?.map((value) => ({ value })) ?? [],
27
39
  categories:
28
40
  mediaItem.categories?.map((value) => ({
@@ -31,34 +43,38 @@ const formData = {
31
43
  collections: mediaItem.collections ?? [],
32
44
  }
33
45
 
34
- const i18nConfig = prepareI18nConfig(Astro.locals.i18n, [
46
+ const i18nConfig = prepareI18nConfig(adminI18n, [
35
47
  "ln.admin.*",
36
48
  "ln.type",
37
49
  "ln.language",
38
50
  "ln.categories",
39
51
  ])
40
- const { t, currentLocale } = Astro.locals.i18n
52
+ const { t, currentLocale } = adminI18n
41
53
 
42
- const mediaTypes = (await getMediaTypes()).map(({ id, data: { label } }) => ({
54
+ const mediaTypes = (await getMediaTypes()).map(({ id }) => ({
43
55
  id,
44
- labelText: t(label),
56
+ labelText: id,
45
57
  }))
46
58
 
47
- const categories = (await getCategories(currentLocale, t)).map(
48
- ({ id, labelText }) => ({ id, labelText }),
49
- )
59
+ const categories = (await getCategories(currentLocale, t)).map(({ id }) => ({
60
+ id,
61
+ labelText: id,
62
+ }))
50
63
 
51
64
  const collections = (await getCollection("media-collections")).map(
52
- ({ id, data: { label } }) => ({ id, labelText: t(label) }),
65
+ ({ id }) => ({ id, labelText: id }),
53
66
  )
54
67
 
55
68
  const languages = config.languages.map(({ code, label }) => ({
56
69
  id: code,
57
- labelText: t(label),
70
+ labelText: `${code} - ${t(label)}`,
58
71
  }))
59
72
  ---
60
73
 
61
- <Page mainClass="bg-gray-700">
74
+ <Page
75
+ mainClass="bg-gradient-to-t from-sky-950 to-slate-800"
76
+ locale={currentLocale}
77
+ >
62
78
  <div class="mx-auto block max-w-screen-md px-4 md:px-8">
63
79
  <a
64
80
  class="block pb-4 pt-8 text-gray-200 underline"
@@ -0,0 +1,86 @@
1
+ import { useEffect, useRef, useState } from "react"
2
+ import { type Control } 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
+ export default function Image({
15
+ control,
16
+ defaultValue,
17
+ mediaId,
18
+ }: {
19
+ control: Control<MediaItem>
20
+ defaultValue: MediaItem["image"]
21
+ mediaId: string
22
+ }) {
23
+ const objectUrlRef = useRef<string | null>(null)
24
+ const [previewSrc, setPreviewSrc] = useState<string | undefined>(
25
+ defaultValue.previewSrc,
26
+ )
27
+ const isDirty = useFieldDirty({ control, name: "image" })
28
+ const errorMessage = useFieldError({ control, name: "image", exact: false })
29
+
30
+ useEffect(() => {
31
+ // cleanup on component unmount
32
+ return () => {
33
+ if (objectUrlRef.current) {
34
+ URL.revokeObjectURL(objectUrlRef.current)
35
+ }
36
+ }
37
+ }, [])
38
+
39
+ const updateImage = (file?: File) => {
40
+ if (!file) {
41
+ return
42
+ }
43
+ if (!acceptedFileTypes.includes(file.type as any)) {
44
+ return
45
+ }
46
+ if (objectUrlRef.current) {
47
+ URL.revokeObjectURL(objectUrlRef.current)
48
+ }
49
+ const objectUrl = URL.createObjectURL(file)
50
+ objectUrlRef.current = objectUrl
51
+ setPreviewSrc(objectUrl)
52
+ }
53
+
54
+ return (
55
+ <div className="group flex w-full flex-col">
56
+ <label htmlFor="image">
57
+ <Label
58
+ label="ln.admin.image"
59
+ isDirty={isDirty}
60
+ isInvalid={!!errorMessage}
61
+ />
62
+ </label>
63
+ <div
64
+ className={`flex w-full items-stretch gap-4 rounded-lg rounded-ss-none border bg-gray-50 px-4 py-3 shadow-inner outline-none transition-colors ${isDirty && !errorMessage ? "border-gray-700" : "border-gray-300"} ${errorMessage ? "border-rose-800" : ""} `}
65
+ >
66
+ <div className="flex h-32 w-32 shrink-0 items-center justify-center overflow-hidden rounded-lg bg-gray-200 p-1">
67
+ <img
68
+ src={previewSrc}
69
+ alt=""
70
+ className="h-full w-full object-contain"
71
+ />
72
+ </div>
73
+ <FileUpload
74
+ name="image"
75
+ control={control}
76
+ onFileChange={updateImage}
77
+ destinationPath="./images"
78
+ acceptedFileTypes={acceptedFileTypes}
79
+ fileName={mediaId}
80
+ />
81
+ </div>
82
+ <ErrorMessage message={errorMessage} />
83
+ <Hint preserveSpace={true} label="ln.admin.image-hint" />
84
+ </div>
85
+ )
86
+ }
@@ -1,9 +1,13 @@
1
- export const writeFile = (path: string, body: string) => {
1
+ export const writeFile = (
2
+ path: string,
3
+ body: BodyInit,
4
+ contentType?: string,
5
+ ) => {
2
6
  return fetch(
3
7
  `/api/internal/fs/write-file?path=${encodeURIComponent(path.replace(/^\//, ""))}`,
4
8
  {
5
9
  method: "POST",
6
- headers: { "Content-Type": resolveContentType(path) },
10
+ headers: { "Content-Type": contentType ?? resolveContentType(path) },
7
11
  body,
8
12
  },
9
13
  )
@@ -1,13 +1,42 @@
1
1
  import { type MediaItem } from "../../types/media-item"
2
- import { writeJson } from "./file-system"
2
+ import { writeFile, writeJson } from "./file-system"
3
3
 
4
4
  export const updateMediaItem = async (id: string, item: MediaItem) => {
5
- return writeJson(`/src/content/media/${id}.json`, mapToContentSchema(item))
5
+ const imagePath = await saveImage(item.image)
6
+ return writeJson(
7
+ `/src/content/media/${id}.json`,
8
+ mapToContentSchema(item, imagePath),
9
+ )
6
10
  }
7
11
 
8
- const mapToContentSchema = (item: MediaItem) => {
12
+ const ensureRelativeImagePath = (path: string) => {
13
+ const trimmed = path.trim()
14
+ if (!trimmed) {
15
+ return ""
16
+ }
17
+ if (trimmed.startsWith("./")) {
18
+ return trimmed
19
+ }
20
+ return `./${trimmed}`
21
+ }
22
+
23
+ const saveImage = async (image: MediaItem["image"]) => {
24
+ const relativePath = ensureRelativeImagePath(image?.path ?? "")
25
+ if (!relativePath || !image?.file) {
26
+ return relativePath
27
+ }
28
+ await writeFile(
29
+ `/src/content/media/${relativePath.replace(/^\.\//, "")}`,
30
+ await image.file.arrayBuffer(),
31
+ image.file.type || "application/octet-stream",
32
+ )
33
+ return relativePath
34
+ }
35
+
36
+ const mapToContentSchema = (item: MediaItem, imagePath: string) => {
9
37
  return {
10
38
  ...item,
39
+ image: imagePath,
11
40
  authors: flatten(item.authors),
12
41
  categories: flatten(item.categories),
13
42
  }
@@ -1,4 +1,5 @@
1
1
  import { type RefinementCtx, z } from "astro/zod"
2
+ import config from "virtual:lightnet/config"
2
3
 
3
4
  const NON_EMPTY_STRING = "ln.admin.errors.non-empty-string"
4
5
  const INVALID_DATE = "ln.admin.errors.invalid-date"
@@ -6,6 +7,7 @@ const REQUIRED = "ln.admin.errors.required"
6
7
  const GTE_0 = "ln.admin.errors.gte-0"
7
8
  const INTEGER = "ln.admin.errors.integer"
8
9
  const UNIQUE_ELEMENTS = "ln.admin.errors.unique-elements"
10
+ const FILE_SIZE_EXCEEDED = "ln.admin.error.file-size-exceeded"
9
11
 
10
12
  const unique = <TArrayItem>(path: Extract<keyof TArrayItem, string>) => {
11
13
  return (values: TArrayItem[], ctx: RefinementCtx) => {
@@ -23,6 +25,19 @@ const unique = <TArrayItem>(path: Extract<keyof TArrayItem, string>) => {
23
25
  }
24
26
  }
25
27
 
28
+ const fileShape = z
29
+ .instanceof(File)
30
+ .optional()
31
+ .refine(
32
+ (file) =>
33
+ !file ||
34
+ !!(
35
+ file.size <
36
+ (config.experimental?.admin?.maxFileSize ?? 0) * 1024 * 1024
37
+ ),
38
+ { message: FILE_SIZE_EXCEEDED },
39
+ )
40
+
26
41
  export const mediaItemSchema = z.object({
27
42
  commonId: z.string().nonempty(NON_EMPTY_STRING),
28
43
  title: z.string().nonempty(NON_EMPTY_STRING),
@@ -47,6 +62,11 @@ export const mediaItemSchema = z.object({
47
62
  .superRefine(unique("collection")),
48
63
  dateCreated: z.string().date(INVALID_DATE),
49
64
  description: z.string().optional(),
65
+ image: z.object({
66
+ path: z.string().nonempty(NON_EMPTY_STRING),
67
+ previewSrc: z.string(),
68
+ file: fileShape,
69
+ }),
50
70
  })
51
71
 
52
72
  export type MediaItem = z.input<typeof mediaItemSchema>
@@ -228,6 +228,16 @@ export const configSchema = z.object({
228
228
  admin: z
229
229
  .object({
230
230
  enabled: z.boolean().default(false),
231
+ /**
232
+ * Currently we only support english as Admin UI language.
233
+ */
234
+ languageCode: z.literal("en").default("en"),
235
+ /**
236
+ * Max file size to upload in mega bytes.
237
+ *
238
+ * Default is 25 (this aligns with Cloudflare's max file size).
239
+ */
240
+ maxFileSize: z.number().default(25),
231
241
  })
232
242
  .optional(),
233
243
  })
@@ -72,12 +72,12 @@ export function lightnet(lightnetConfig: LightnetConfig): AstroIntegration {
72
72
  })
73
73
 
74
74
  injectRoute({
75
- pattern: "/[locale]/admin",
75
+ pattern: "/admin",
76
76
  entrypoint: "lightnet/admin/pages/AdminRoute.astro",
77
77
  prerender: true,
78
78
  })
79
79
  injectRoute({
80
- pattern: "/[locale]/admin/media/[mediaId]",
80
+ pattern: "/admin/media/[mediaId]",
81
81
  entrypoint: "lightnet/admin/pages/media/EditRoute.astro",
82
82
  prerender: true,
83
83
  })
@@ -114,7 +114,11 @@ export function lightnet(lightnetConfig: LightnetConfig): AstroIntegration {
114
114
  locales: resolveLocales(config),
115
115
  routing: {
116
116
  redirectToDefaultLocale: false,
117
- prefixDefaultLocale: true,
117
+ // We need to set this to false to allow for
118
+ // admin paths without locale. But actually
119
+ // the default locale will be prefixed for regular
120
+ // LightNet pages.
121
+ prefixDefaultLocale: false,
118
122
  fallbackType: "rewrite",
119
123
  },
120
124
  },
@@ -59,7 +59,8 @@ async function revertMediaItemEntry({ id, data: mediaItem }: MediaItemEntry) {
59
59
  ...collection,
60
60
  collection: collection.collection.id,
61
61
  }))
62
- const image = (await getEntry("internal-media-image-path", id))?.data.image
62
+ const image =
63
+ (await getEntry("internal-media-image-path", id))?.data.image ?? ""
63
64
  return {
64
65
  id,
65
66
  data: {
@@ -1,7 +1,7 @@
1
1
  import { createContext, useMemo } from "react"
2
2
 
3
3
  export type I18n = {
4
- t: (key: string) => string
4
+ t: (key: string, params?: Record<string, unknown>) => string
5
5
  currentLocale: string
6
6
  direction: "rtl" | "ltr"
7
7
  }
@@ -12,6 +12,17 @@ export type I18nConfig = Omit<I18n, "t"> & {
12
12
 
13
13
  export const I18nContext = createContext<I18n | undefined>(undefined)
14
14
 
15
+ const interpolate = (value: string, params?: Record<string, unknown>) => {
16
+ if (!params) {
17
+ return value
18
+ }
19
+ return Object.entries(params ?? {}).reduce(
20
+ (prev, [paramName, paramValue]) =>
21
+ prev.replaceAll(`{{${paramName}}}`, `${paramValue}`),
22
+ value,
23
+ )
24
+ }
25
+
15
26
  /**
16
27
  * Creates the runtime i18n helpers given a prepared configuration.
17
28
  * Wraps the raw translation dictionary with a lookup that throws on missing keys.
@@ -22,16 +33,16 @@ export const createI18n = ({
22
33
  direction,
23
34
  }: I18nConfig) => {
24
35
  return useMemo(() => {
25
- const t = (key: string) => {
36
+ const t = (key: string, params?: Record<string, unknown>) => {
26
37
  const value = translations[key]
27
38
  if (value) {
28
- return value
39
+ return interpolate(value, params)
29
40
  }
30
- if (key.match(/^(?:ln|x)\../i)) {
41
+ if (!key || key.match(/^(?:ln|x)\../i)) {
31
42
  console.error(`Missing translation for key ${key}`)
32
43
  return ""
33
44
  }
34
- return key
45
+ return interpolate(key, params)
35
46
  }
36
47
  return { t, currentLocale, direction }
37
48
  }, [])
@@ -14,17 +14,18 @@ interface Props {
14
14
  title?: string
15
15
  description?: string
16
16
  mainClass?: string
17
+ locale?: string
17
18
  }
18
19
 
19
- const { title, description, mainClass } = Astro.props
20
+ const { title, description, mainClass, locale } = Astro.props
20
21
  const configTitle = Astro.locals.i18n.t(config.title)
21
22
 
22
- const { currentLocale } = Astro.locals.i18n
23
- const language = resolveLanguage(currentLocale)
23
+ const currentLocale = locale ?? Astro.locals.i18n.currentLocale
24
+ const { direction } = resolveLanguage(currentLocale)
24
25
  ---
25
26
 
26
27
  <!doctype html>
27
- <html lang={currentLocale} dir={language.direction}>
28
+ <html lang={currentLocale} dir={direction}>
28
29
  <head>
29
30
  <meta charset="UTF-8" />
30
31
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
@@ -6,6 +6,11 @@ import MenuItem from "./MenuItem.astro"
6
6
 
7
7
  const { t, locales } = Astro.locals.i18n
8
8
 
9
+ const hasLocale =
10
+ Astro.currentLocale &&
11
+ (Astro.url.pathname.startsWith(`/${Astro.currentLocale}/`) ||
12
+ Astro.url.pathname === `/${Astro.currentLocale}`)
13
+
9
14
  const translations = locales
10
15
  .map((locale) => ({
11
16
  locale,
@@ -17,17 +22,18 @@ const translations = locales
17
22
 
18
23
  function currentPathWithLocale(locale: string) {
19
24
  const currentPath = Astro.url.pathname
20
- const currentPathWithoutLocale =
21
- Astro.currentLocale && currentPath.startsWith(`/${Astro.currentLocale}`)
22
- ? currentPath.slice(Astro.currentLocale.length + 1)
23
- : currentPath
25
+ const currentPathWithoutLocale = hasLocale
26
+ ? currentPath.slice(Astro.currentLocale.length + 1)
27
+ : currentPath
24
28
  return localizePath(locale, currentPathWithoutLocale)
25
29
  }
30
+
31
+ const disabled = !hasLocale && Astro.url.pathname !== "/"
26
32
  ---
27
33
 
28
34
  {
29
35
  translations.length > 1 && (
30
- <Menu icon="mdi--web" label="ln.header.select-language">
36
+ <Menu disabled={disabled} icon="mdi--web" label="ln.header.select-language">
31
37
  {translations.map(({ label, locale, active, href }) => (
32
38
  <MenuItem href={href} hreflang={locale} active={active}>
33
39
  {label}
@@ -4,25 +4,91 @@ import Icon from "../../components/Icon"
4
4
  interface Props {
5
5
  icon: string
6
6
  label: string
7
+ disabled?: boolean
7
8
  }
8
9
 
9
- const { icon, label } = Astro.props
10
+ const { icon, label, disabled } = Astro.props
10
11
  ---
11
12
 
12
- <div class="dy-dropdown dy-dropdown-end">
13
- <div
14
- role="button"
15
- tabindex="0"
13
+ <ln-menu class="relative flex h-full items-center">
14
+ <button
15
+ disabled={disabled}
16
+ aria-disabled={disabled}
16
17
  aria-label={Astro.locals.i18n.t(label)}
17
- class="flex cursor-pointer rounded-md p-3 text-gray-600 hover:text-primary"
18
+ class="flex rounded-md p-3 text-gray-600 hover:text-primary disabled:text-gray-300"
18
19
  >
19
20
  <Icon className={icon} ariaLabel="" />
20
- </div>
21
+ </button>
21
22
 
22
23
  <ul
23
- tabindex="0"
24
- class="dy-dropdown-content top-px me-3 mt-[3.25rem] w-48 overflow-hidden rounded-b-md bg-white py-3 shadow-lg sm:mt-16"
24
+ data-menu-panel
25
+ aria-hidden="true"
26
+ inert
27
+ class="pointer-events-none absolute right-3 top-full mt-px flex w-48 origin-top scale-y-90 flex-col overflow-hidden rounded-b-md bg-white py-3 opacity-0 shadow-lg transition-all duration-100 ease-out"
25
28
  >
26
29
  <slot />
27
30
  </ul>
28
- </div>
31
+ </ln-menu>
32
+ <script>
33
+ class Menu extends HTMLElement {
34
+ menuPanel = this.querySelector<HTMLElement>("[data-menu-panel]")!
35
+ isMenuOpened = false
36
+ handleOutsideClick = (event: MouseEvent) => {
37
+ if (!this.isMenuOpened) {
38
+ return
39
+ }
40
+ const target = event.target as Node | null
41
+ if (target && this.contains(target)) {
42
+ return
43
+ }
44
+ this.closeMenu()
45
+ }
46
+
47
+ toggleMenu() {
48
+ if (this.isMenuOpened) {
49
+ this.closeMenu()
50
+ } else {
51
+ this.openMenu()
52
+ }
53
+ }
54
+
55
+ openMenu() {
56
+ if (this.isMenuOpened) {
57
+ return
58
+ }
59
+ this.menuPanel.style.opacity = "1"
60
+ this.menuPanel.style.pointerEvents = "auto"
61
+ this.menuPanel.style.transform = "scaleY(1)"
62
+
63
+ this.menuPanel.setAttribute("aria-hidden", "false")
64
+ this.menuPanel.removeAttribute("inert")
65
+ document.addEventListener("click", this.handleOutsideClick)
66
+ this.isMenuOpened = true
67
+ }
68
+
69
+ closeMenu() {
70
+ if (!this.isMenuOpened) {
71
+ return
72
+ }
73
+ this.menuPanel.style.opacity = "0"
74
+ this.menuPanel.style.pointerEvents = "none"
75
+ this.menuPanel.style.transform = "scaleY(0.9)"
76
+
77
+ this.menuPanel.setAttribute("aria-hidden", "true")
78
+ this.menuPanel.setAttribute("inert", "")
79
+ document.removeEventListener("click", this.handleOutsideClick)
80
+ this.isMenuOpened = false
81
+ }
82
+
83
+ connectedCallback() {
84
+ this.querySelector("button")?.addEventListener("click", () =>
85
+ this.toggleMenu(),
86
+ )
87
+
88
+ window.addEventListener("beforeunload", () => {
89
+ this.closeMenu()
90
+ })
91
+ }
92
+ }
93
+ customElements.define("ln-menu", Menu)
94
+ </script>
@@ -14,7 +14,7 @@ const { mediaId } = Astro.props
14
14
  class="hidden cursor-pointer items-center gap-2 font-bold text-gray-700 underline"
15
15
  id="edit-btn"
16
16
  data-admin-enabled={config.experimental?.admin?.enabled}
17
- href={`/${Astro.currentLocale}/admin/media/${mediaId}`}
17
+ href={`/admin/media/${mediaId}`}
18
18
  ><Icon className="mdi--square-edit-outline" ariaLabel="" />
19
19
  {Astro.locals.i18n.t("ln.admin.edit")}</a
20
20
  >
@@ -1,5 +1,5 @@
1
1
  import Icon from "../../../components/Icon"
2
- import { useI18n } from "../../../i18n/react/useI18n"
2
+ import { useI18n } from "../../../i18n/react/use-i18n"
3
3
 
4
4
  export default function LoadingSkeleton() {
5
5
  const { direction } = useI18n()
@@ -1,6 +1,6 @@
1
1
  import CoverImageDecorator from "../../../components/CoverImageDecorator"
2
2
  import Icon from "../../../components/Icon"
3
- import { useI18n } from "../../../i18n/react/useI18n"
3
+ import { useI18n } from "../../../i18n/react/use-i18n"
4
4
  import { detailsPagePath } from "../../../utils/paths"
5
5
  import type { SearchItem } from "../api/search-response"
6
6
 
File without changes