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.
- package/CHANGELOG.md +15 -0
- package/__e2e__/admin.spec.ts +19 -19
- package/__e2e__/fixtures/basics/node_modules/.bin/astro +2 -2
- package/__e2e__/fixtures/basics/package.json +2 -2
- package/package.json +5 -5
- package/src/admin/components/form/DynamicArray.tsx +3 -3
- package/src/admin/components/form/Input.tsx +1 -1
- package/src/admin/components/form/MarkdownEditor.tsx +1 -1
- package/src/admin/components/form/Select.tsx +1 -1
- package/src/admin/components/form/SubmitButton.tsx +2 -2
- package/src/admin/components/form/atoms/ErrorMessage.tsx +1 -1
- package/src/admin/components/form/atoms/FileUpload.tsx +115 -0
- package/src/admin/components/form/atoms/Hint.tsx +1 -1
- package/src/admin/components/form/atoms/Label.tsx +2 -2
- package/src/admin/components/form/hooks/use-field-error.tsx +23 -2
- package/src/admin/i18n/admin-i18n.ts +21 -0
- package/src/admin/i18n/translations/en.yml +8 -2
- package/src/admin/pages/AdminRoute.astro +0 -2
- package/src/admin/pages/media/EditForm.tsx +14 -8
- package/src/admin/pages/media/EditRoute.astro +31 -15
- package/src/admin/pages/media/fields/Image.tsx +86 -0
- package/src/admin/pages/media/file-system.ts +6 -2
- package/src/admin/pages/media/media-item-store.ts +32 -3
- package/src/admin/types/media-item.ts +20 -0
- package/src/astro-integration/config.ts +10 -0
- package/src/astro-integration/integration.ts +7 -3
- package/src/content/get-media-items.ts +2 -1
- package/src/i18n/react/i18n-context.ts +16 -5
- package/src/layouts/Page.astro +5 -4
- package/src/layouts/components/LanguagePicker.astro +11 -5
- package/src/layouts/components/Menu.astro +76 -10
- package/src/pages/details-page/components/main-details/EditButton.astro +1 -1
- package/src/pages/search-page/components/LoadingSkeleton.tsx +1 -1
- package/src/pages/search-page/components/SearchListItem.tsx +1 -1
- /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
|
package/__e2e__/admin.spec.ts
CHANGED
|
@@ -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 `/
|
|
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("/
|
|
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
|
-
"/
|
|
48
|
+
"/admin/media/faithful-freestyle--en",
|
|
49
49
|
)
|
|
50
50
|
})
|
|
51
51
|
|
|
52
|
-
test("Should show `Edit` button on video details page after visiting `/
|
|
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("/
|
|
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
|
-
"/
|
|
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 `/
|
|
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("/
|
|
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
|
-
"/
|
|
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("/
|
|
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("/
|
|
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("/
|
|
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("/
|
|
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("/
|
|
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("/
|
|
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("/
|
|
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("/
|
|
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("/
|
|
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("/
|
|
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.
|
|
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
|
+
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.
|
|
11
|
-
"lightnet": "^3.10.
|
|
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.
|
|
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.
|
|
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.
|
|
66
|
+
"@playwright/test": "^1.57.0",
|
|
67
67
|
"@types/node": "^22.19.1",
|
|
68
|
-
"@types/react": "^19.2.
|
|
68
|
+
"@types/react": "^19.2.7",
|
|
69
69
|
"typescript": "^5.9.3",
|
|
70
|
-
"vitest": "^4.0.
|
|
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/
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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/
|
|
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-
|
|
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",
|
|
@@ -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/
|
|
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-
|
|
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
|
|
12
|
+
const { errors } = useFormState({ control, name, exact })
|
|
11
13
|
const error = get(errors, name) as { message: string } | undefined
|
|
12
|
-
|
|
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:
|
|
22
|
-
ln.admin.common-id-hint:
|
|
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.
|
|
@@ -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
|
|
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(
|
|
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 } =
|
|
52
|
+
const { t, currentLocale } = adminI18n
|
|
41
53
|
|
|
42
|
-
const mediaTypes = (await getMediaTypes()).map(({ id
|
|
54
|
+
const mediaTypes = (await getMediaTypes()).map(({ id }) => ({
|
|
43
55
|
id,
|
|
44
|
-
labelText:
|
|
56
|
+
labelText: id,
|
|
45
57
|
}))
|
|
46
58
|
|
|
47
|
-
const categories = (await getCategories(currentLocale, t)).map(
|
|
48
|
-
|
|
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
|
|
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
|
|
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 = (
|
|
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
|
-
|
|
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
|
|
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: "/
|
|
75
|
+
pattern: "/admin",
|
|
76
76
|
entrypoint: "lightnet/admin/pages/AdminRoute.astro",
|
|
77
77
|
prerender: true,
|
|
78
78
|
})
|
|
79
79
|
injectRoute({
|
|
80
|
-
pattern: "/
|
|
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
|
-
|
|
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 =
|
|
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
|
}, [])
|
package/src/layouts/Page.astro
CHANGED
|
@@ -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
|
|
23
|
-
const
|
|
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={
|
|
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
|
-
|
|
22
|
-
|
|
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
|
-
<
|
|
13
|
-
<
|
|
14
|
-
|
|
15
|
-
|
|
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
|
|
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
|
-
</
|
|
21
|
+
</button>
|
|
21
22
|
|
|
22
23
|
<ul
|
|
23
|
-
|
|
24
|
-
|
|
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
|
-
</
|
|
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={
|
|
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,6 +1,6 @@
|
|
|
1
1
|
import CoverImageDecorator from "../../../components/CoverImageDecorator"
|
|
2
2
|
import Icon from "../../../components/Icon"
|
|
3
|
-
import { useI18n } from "../../../i18n/react/
|
|
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
|