lightnet 3.10.0 → 3.10.2
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 +20 -0
- package/__e2e__/admin.spec.ts +113 -0
- package/__e2e__/fixtures/basics/astro.config.mjs +6 -0
- package/__e2e__/fixtures/basics/node_modules/.bin/astro +2 -2
- package/__e2e__/fixtures/basics/package.json +2 -2
- package/__e2e__/fixtures/basics/src/content/media/skate-sounds--en.json +15 -0
- package/__e2e__/fixtures/basics/src/content/media-types/audio.json +7 -0
- package/__e2e__/fixtures/basics/src/translations/de.yml +1 -0
- package/__e2e__/fixtures/basics/src/translations/en.yml +1 -0
- package/__e2e__/homepage.spec.ts +21 -0
- package/package.json +13 -6
- package/src/admin/api/fs/writeText.ts +50 -0
- package/src/admin/components/form/FieldErrors.tsx +22 -0
- package/src/admin/components/form/SubmitButton.tsx +79 -0
- package/src/admin/components/form/TextField.tsx +24 -0
- package/src/admin/components/form/form-context.ts +4 -0
- package/src/admin/components/form/index.ts +16 -0
- package/src/admin/i18n/translations/en.yml +11 -0
- package/src/admin/i18n/translations.ts +5 -0
- package/src/admin/pages/AdminRoute.astro +16 -0
- package/src/admin/pages/media/EditForm.tsx +73 -0
- package/src/admin/pages/media/EditRoute.astro +42 -0
- package/src/admin/pages/media/file-system.ts +37 -0
- package/src/admin/pages/media/media-item-store.ts +11 -0
- package/src/admin/types/media-item.ts +10 -0
- package/src/api/media/[mediaId].ts +16 -0
- package/src/{pages/api → api}/versions.ts +1 -1
- package/src/astro-integration/config.ts +15 -0
- package/src/astro-integration/integration.ts +44 -6
- package/src/components/CategoriesSection.astro +1 -1
- package/src/components/MediaGallerySection.astro +1 -1
- package/src/components/Toast.tsx +55 -0
- package/src/components/showToast.ts +61 -0
- package/src/content/astro-image.ts +1 -14
- package/src/content/content-schema.ts +10 -3
- package/src/content/get-media-items.ts +46 -1
- package/src/i18n/locals.d.ts +35 -28
- package/src/i18n/locals.ts +2 -1
- package/src/i18n/react/i18n-context.ts +32 -0
- package/src/i18n/react/prepare-i18n-config.ts +31 -0
- package/src/i18n/react/useI18n.ts +15 -0
- package/src/i18n/translate.ts +15 -3
- package/src/i18n/translations.ts +20 -7
- package/src/layouts/Page.astro +1 -1
- package/src/pages/details-page/components/MainDetailsSection.astro +5 -1
- package/src/pages/details-page/components/VideoDetailsSection.astro +5 -1
- package/src/pages/details-page/components/main-details/EditButton.astro +30 -0
- package/src/pages/details-page/components/main-details/ShareButton.astro +9 -13
- package/src/pages/{api → search-page/api}/search.ts +3 -3
- package/src/pages/search-page/components/LoadingSkeleton.tsx +3 -1
- package/src/pages/search-page/components/SearchFilter.astro +12 -3
- package/src/pages/search-page/components/SearchFilter.tsx +4 -7
- package/src/pages/search-page/components/SearchList.astro +7 -6
- package/src/pages/search-page/components/SearchList.tsx +12 -15
- package/src/pages/search-page/components/SearchListItem.tsx +3 -5
- package/src/pages/search-page/hooks/use-search.ts +3 -3
- package/tailwind.config.ts +1 -0
- package/src/pages/search-page/utils/search-filter-translations.ts +0 -20
- package/src/pages/search-page/utils/search-translations.ts +0 -11
- /package/src/pages/{api → search-page/api}/search-response.ts +0 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,25 @@
|
|
|
1
1
|
# lightnet
|
|
2
2
|
|
|
3
|
+
## 3.10.2
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- [#325](https://github.com/LightNetDev/LightNet/pull/325) [`07b71f4`](https://github.com/LightNetDev/LightNet/commit/07b71f4d44f8e005aafb48bedc347f14c71e182a) Thanks [@smn-cds](https://github.com/smn-cds)! - Refactor internal react i18n logic.
|
|
8
|
+
|
|
9
|
+
## 3.10.1
|
|
10
|
+
|
|
11
|
+
### Patch Changes
|
|
12
|
+
|
|
13
|
+
- [#321](https://github.com/LightNetDev/LightNet/pull/321) [`87e0faf`](https://github.com/LightNetDev/LightNet/commit/87e0faf4ba7a6615d60e755385479092c5d2aa84) Thanks [@smn-cds](https://github.com/smn-cds)! - Update dependencies
|
|
14
|
+
|
|
15
|
+
- [#321](https://github.com/LightNetDev/LightNet/pull/321) [`87e0faf`](https://github.com/LightNetDev/LightNet/commit/87e0faf4ba7a6615d60e755385479092c5d2aa84) Thanks [@smn-cds](https://github.com/smn-cds)! - Start implementing integrated experimental Admin UI.
|
|
16
|
+
|
|
17
|
+
- [#321](https://github.com/LightNetDev/LightNet/pull/321) [`87e0faf`](https://github.com/LightNetDev/LightNet/commit/87e0faf4ba7a6615d60e755385479092c5d2aa84) Thanks [@smn-cds](https://github.com/smn-cds)! - Astro 5.14.8 resolves relative image paths like `image/my-image.jpg` without a `./` prefix, so remove the manual prefixing in `packages/lightnet/src/content/astro-image.ts`. See [Astro's release notes for version 5.14.8](https://github.com/withastro/astro/releases/tag/astro%405.14.8).
|
|
18
|
+
|
|
19
|
+
- [#323](https://github.com/LightNetDev/LightNet/pull/323) [`a2e6980`](https://github.com/LightNetDev/LightNet/commit/a2e6980b2866795b1517dc7a916698f5d92231b1) Thanks [@smn-cds](https://github.com/smn-cds)! - Remove the unsupported `maxWidth` option from `MediaGallerySection` and `CategoriesSection`.
|
|
20
|
+
|
|
21
|
+
The option never worked correctly, but our docs previously only mentioned that in a note. Tighten the typings and runtime guardrails so that consumers see explicit feedback instead of misconfiguring the component.
|
|
22
|
+
|
|
3
23
|
## 3.10.0
|
|
4
24
|
|
|
5
25
|
### Minor Changes
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { expect } from "@playwright/test"
|
|
2
|
+
|
|
3
|
+
import { lightnetTest } from "./test-utils"
|
|
4
|
+
|
|
5
|
+
const test = lightnetTest("./fixtures/basics/")
|
|
6
|
+
|
|
7
|
+
test("Should not show `Edit` button on details page by default.", async ({
|
|
8
|
+
page,
|
|
9
|
+
startLightnet,
|
|
10
|
+
}) => {
|
|
11
|
+
await startLightnet()
|
|
12
|
+
|
|
13
|
+
await page.getByRole("link", { name: "Faithful Freestyle" }).click()
|
|
14
|
+
await expect(
|
|
15
|
+
page.getByRole("heading", { name: "Faithful Freestyle" }),
|
|
16
|
+
).toBeVisible()
|
|
17
|
+
|
|
18
|
+
const editButton = page.locator("#edit-btn")
|
|
19
|
+
await expect(editButton).toBeHidden()
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
test("Should show `Edit` button on book details page after visiting `/en/admin/` path.", async ({
|
|
23
|
+
page,
|
|
24
|
+
startLightnet,
|
|
25
|
+
}) => {
|
|
26
|
+
const ln = await startLightnet()
|
|
27
|
+
|
|
28
|
+
await page.goto(ln.resolveURL("/en/admin/"))
|
|
29
|
+
await expect(
|
|
30
|
+
page.getByText("Admin features are enabled now.", { exact: true }),
|
|
31
|
+
).toBeVisible()
|
|
32
|
+
|
|
33
|
+
await page.goto(ln.resolveURL("/en/media/faithful-freestyle--en"))
|
|
34
|
+
await expect(
|
|
35
|
+
page.getByRole("heading", { name: "Faithful Freestyle" }),
|
|
36
|
+
).toBeVisible()
|
|
37
|
+
|
|
38
|
+
const editButton = page.locator("#edit-btn")
|
|
39
|
+
await expect(editButton).toBeVisible()
|
|
40
|
+
await expect(editButton).toHaveAttribute(
|
|
41
|
+
"href",
|
|
42
|
+
"/en/admin/media/faithful-freestyle--en",
|
|
43
|
+
)
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
test("Should show `Edit` button on video details page after visiting `/en/admin/` path.", async ({
|
|
47
|
+
page,
|
|
48
|
+
startLightnet,
|
|
49
|
+
}) => {
|
|
50
|
+
const ln = await startLightnet()
|
|
51
|
+
|
|
52
|
+
await page.goto(ln.resolveURL("/en/admin/"))
|
|
53
|
+
await expect(
|
|
54
|
+
page.getByText("Admin features are enabled now.", { exact: true }),
|
|
55
|
+
).toBeVisible()
|
|
56
|
+
|
|
57
|
+
await page.goto(ln.resolveURL("/en/media/how-to-kickflip--de"))
|
|
58
|
+
await expect(
|
|
59
|
+
page.getByRole("heading", { name: "Kickflip Anleitung" }),
|
|
60
|
+
).toBeVisible()
|
|
61
|
+
|
|
62
|
+
const editButton = page.locator("#edit-btn")
|
|
63
|
+
await expect(editButton).toBeVisible()
|
|
64
|
+
await expect(editButton).toHaveAttribute(
|
|
65
|
+
"href",
|
|
66
|
+
"/en/admin/media/how-to-kickflip--de",
|
|
67
|
+
)
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
test("Should show `Edit` button on audio details page after visiting `/en/admin/` path.", async ({
|
|
71
|
+
page,
|
|
72
|
+
startLightnet,
|
|
73
|
+
}) => {
|
|
74
|
+
const ln = await startLightnet()
|
|
75
|
+
|
|
76
|
+
await page.goto(ln.resolveURL("/en/admin/"))
|
|
77
|
+
await expect(
|
|
78
|
+
page.getByText("Admin features are enabled now.", { exact: true }),
|
|
79
|
+
).toBeVisible()
|
|
80
|
+
|
|
81
|
+
await page.goto(ln.resolveURL("/en/media/skate-sounds--en"))
|
|
82
|
+
await expect(
|
|
83
|
+
page.getByRole("heading", { name: "Skate Sounds" }),
|
|
84
|
+
).toBeVisible()
|
|
85
|
+
|
|
86
|
+
const editButton = page.locator("#edit-btn")
|
|
87
|
+
await expect(editButton).toBeVisible()
|
|
88
|
+
await expect(editButton).toHaveAttribute(
|
|
89
|
+
"href",
|
|
90
|
+
"/en/admin/media/skate-sounds--en",
|
|
91
|
+
)
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
test("Edit button on details page should navigate to media item edit page", async ({
|
|
95
|
+
page,
|
|
96
|
+
startLightnet,
|
|
97
|
+
}) => {
|
|
98
|
+
const ln = await startLightnet()
|
|
99
|
+
|
|
100
|
+
await page.goto(ln.resolveURL("/en/admin/"))
|
|
101
|
+
await page.goto(ln.resolveURL("/en/media/faithful-freestyle--en"))
|
|
102
|
+
|
|
103
|
+
const editButton = page.locator("#edit-btn")
|
|
104
|
+
await expect(editButton).toBeVisible()
|
|
105
|
+
|
|
106
|
+
await editButton.click()
|
|
107
|
+
await expect(page).toHaveURL(
|
|
108
|
+
ln.resolveURL("/en/admin/media/faithful-freestyle--en"),
|
|
109
|
+
)
|
|
110
|
+
await expect(
|
|
111
|
+
page.getByText("Edit media item", { exact: false }),
|
|
112
|
+
).toBeVisible()
|
|
113
|
+
})
|
|
@@ -9,6 +9,7 @@ export default defineConfig({
|
|
|
9
9
|
lightnet({
|
|
10
10
|
title: "Basic Test",
|
|
11
11
|
logo: { src: "./src/assets/logo.png" },
|
|
12
|
+
credits: true,
|
|
12
13
|
languages: [
|
|
13
14
|
{
|
|
14
15
|
code: "en",
|
|
@@ -29,6 +30,11 @@ export default defineConfig({
|
|
|
29
30
|
},
|
|
30
31
|
{ href: "/media", label: "ln.search.title" },
|
|
31
32
|
],
|
|
33
|
+
experimental: {
|
|
34
|
+
admin: {
|
|
35
|
+
enabled: true,
|
|
36
|
+
},
|
|
37
|
+
},
|
|
32
38
|
}),
|
|
33
39
|
],
|
|
34
40
|
})
|
|
@@ -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.14.
|
|
13
|
+
export NODE_PATH="/home/runner/work/LightNet/LightNet/node_modules/.pnpm/astro@5.14.8_@types+node@24.3.0_jiti@2.4.2_lightningcss@1.29.1_rollup@4.52.5_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.14.8_@types+node@24.3.0_jiti@2.4.2_lightningcss@1.29.1_rollup@4.52.5_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.14.
|
|
15
|
+
export NODE_PATH="/home/runner/work/LightNet/LightNet/node_modules/.pnpm/astro@5.14.8_@types+node@24.3.0_jiti@2.4.2_lightningcss@1.29.1_rollup@4.52.5_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.14.8_@types+node@24.3.0_jiti@2.4.2_lightningcss@1.29.1_rollup@4.52.5_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" "$@"
|
|
@@ -6,8 +6,8 @@
|
|
|
6
6
|
"dependencies": {
|
|
7
7
|
"@astrojs/react": "^4.4.0",
|
|
8
8
|
"@astrojs/tailwind": "^6.0.2",
|
|
9
|
-
"@lightnet/decap-admin": "^3.1.
|
|
10
|
-
"astro": "^5.14.
|
|
9
|
+
"@lightnet/decap-admin": "^3.1.3",
|
|
10
|
+
"astro": "^5.14.8",
|
|
11
11
|
"lightnet": "^3.10.0",
|
|
12
12
|
"react": "^19.2.0",
|
|
13
13
|
"react-dom": "^19.2.0",
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
{
|
|
2
|
+
"commonId": "skate-sounds",
|
|
3
|
+
"title": "Skate Sounds",
|
|
4
|
+
"type": "audio",
|
|
5
|
+
"dateCreated": "2024-04-21",
|
|
6
|
+
"authors": ["Sk8 Ministries"],
|
|
7
|
+
"content": [
|
|
8
|
+
{ "url": "/files/example-audio.mp3" },
|
|
9
|
+
{ "url": "https://www.soundhelix.com/examples/mp3/SoundHelix-Song-2.mp3" }
|
|
10
|
+
],
|
|
11
|
+
"image": "./images/cover.jpg",
|
|
12
|
+
"language": "en",
|
|
13
|
+
"categories": ["christian-living"],
|
|
14
|
+
"description": "A vibrant collection of authentic skate park sounds to energize your projects and playlists.\n\n**Highlights:**\n- Real-world skating ambience\n- Perfect for creative mixes\n- Inspiring background audio for worship gatherings"
|
|
15
|
+
}
|
package/__e2e__/homepage.spec.ts
CHANGED
|
@@ -102,3 +102,24 @@ test("Should verify DE Detail media page url and title", async ({
|
|
|
102
102
|
await expect(page.getByText("Sprache")).toBeVisible()
|
|
103
103
|
await expect(page.getByText("Kategorie")).toBeVisible()
|
|
104
104
|
})
|
|
105
|
+
|
|
106
|
+
test("Should show `Powered by LightNet` in footer", async ({
|
|
107
|
+
page,
|
|
108
|
+
startLightnet,
|
|
109
|
+
}) => {
|
|
110
|
+
await startLightnet()
|
|
111
|
+
|
|
112
|
+
const footerLink = page
|
|
113
|
+
.getByRole("contentinfo")
|
|
114
|
+
.getByRole("link", { name: /LightNet/ })
|
|
115
|
+
|
|
116
|
+
await expect(footerLink).toHaveText("Powered by LightNet")
|
|
117
|
+
await expect(footerLink).toHaveAttribute("href", "https://lightnet.community")
|
|
118
|
+
|
|
119
|
+
await page.getByLabel("Select language").click()
|
|
120
|
+
await page.getByRole("link", { name: "Deutsch" }).click()
|
|
121
|
+
|
|
122
|
+
await expect(
|
|
123
|
+
page.getByRole("contentinfo").getByRole("link", { name: /LightNet/ }),
|
|
124
|
+
).toHaveText("Ermöglicht durch LightNet")
|
|
125
|
+
})
|
package/package.json
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "lightnet",
|
|
3
|
+
"description": "LightNet makes it easy to run your own digital media library.",
|
|
3
4
|
"type": "module",
|
|
4
5
|
"license": "MIT",
|
|
5
|
-
"version": "3.10.
|
|
6
|
+
"version": "3.10.2",
|
|
6
7
|
"repository": {
|
|
7
8
|
"type": "git",
|
|
8
9
|
"url": "https://github.com/LightNetDev/lightnet",
|
|
@@ -30,8 +31,12 @@
|
|
|
30
31
|
"./pages/RootRoute.astro": "./src/pages/RootRoute.astro",
|
|
31
32
|
"./pages/SearchPageRoute.astro": "./src/pages/search-page/SearchPageRoute.astro",
|
|
32
33
|
"./pages/DetailsPageRoute.astro": "./src/pages/details-page/DetailsPageRoute.astro",
|
|
33
|
-
"./
|
|
34
|
-
"./
|
|
34
|
+
"./api/internal/search.ts": "./src/pages/search-page/api/search.ts",
|
|
35
|
+
"./api/versions.ts": "./src/api/versions.ts",
|
|
36
|
+
"./api/media/[mediaId].ts": "./src/api/media/[mediaId].ts",
|
|
37
|
+
"./api/internal/fs/writeText.ts": "./src/admin/api/fs/writeText.ts",
|
|
38
|
+
"./admin/pages/AdminRoute.astro": "./src/admin/pages/AdminRoute.astro",
|
|
39
|
+
"./admin/pages/media/EditRoute.astro": "./src/admin/pages/media/EditRoute.astro"
|
|
35
40
|
},
|
|
36
41
|
"peerDependencies": {
|
|
37
42
|
"astro": "^5.1.0",
|
|
@@ -45,18 +50,20 @@
|
|
|
45
50
|
"@iconify-json/mdi": "^1.2.3",
|
|
46
51
|
"@iconify/tailwind": "^1.2.0",
|
|
47
52
|
"@tailwindcss/typography": "^0.5.19",
|
|
53
|
+
"@tanstack/react-form": "^1.23.7",
|
|
54
|
+
"@tanstack/react-query": "^5.90.5",
|
|
48
55
|
"@tanstack/react-virtual": "^3.13.12",
|
|
49
56
|
"daisyui": "^4.12.24",
|
|
50
57
|
"embla-carousel": "^8.6.0",
|
|
51
58
|
"embla-carousel-wheel-gestures": "^8.1.0",
|
|
52
59
|
"fuse.js": "^7.1.0",
|
|
53
60
|
"i18next": "^25.6.0",
|
|
54
|
-
"marked": "^16.4.
|
|
61
|
+
"marked": "^16.4.1",
|
|
55
62
|
"yaml": "^2.8.1"
|
|
56
63
|
},
|
|
57
64
|
"devDependencies": {
|
|
58
|
-
"@playwright/test": "^1.56.
|
|
59
|
-
"@types/node": "^22.18.
|
|
65
|
+
"@playwright/test": "^1.56.1",
|
|
66
|
+
"@types/node": "^22.18.12",
|
|
60
67
|
"@types/react": "^19.2.2",
|
|
61
68
|
"typescript": "^5.9.3",
|
|
62
69
|
"vitest": "^3.2.4"
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { mkdir, rename, rm, writeFile } from "node:fs/promises"
|
|
2
|
+
import { dirname, isAbsolute, relative, resolve } from "node:path"
|
|
3
|
+
import { fileURLToPath } from "node:url"
|
|
4
|
+
|
|
5
|
+
import type { APIRoute } from "astro"
|
|
6
|
+
import { root } from "astro:config/server"
|
|
7
|
+
|
|
8
|
+
export const prerender = false
|
|
9
|
+
|
|
10
|
+
export const POST: APIRoute = async ({ request }) => {
|
|
11
|
+
const rootDirPath = fileURLToPath(root)
|
|
12
|
+
const requestedPath = new URL(request.url).searchParams.get("path")
|
|
13
|
+
if (!requestedPath) {
|
|
14
|
+
throw new Error("'path' search param is undefined.")
|
|
15
|
+
}
|
|
16
|
+
if (isAbsolute(requestedPath)) {
|
|
17
|
+
throw new Error("Absolute paths are not allowed.")
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const targetPath = resolve(rootDirPath, requestedPath)
|
|
21
|
+
const relativeToRoot = relative(rootDirPath, targetPath)
|
|
22
|
+
if (
|
|
23
|
+
relativeToRoot.startsWith("..") ||
|
|
24
|
+
relativeToRoot === "" ||
|
|
25
|
+
isAbsolute(relativeToRoot)
|
|
26
|
+
) {
|
|
27
|
+
throw new Error("Path escapes project root.")
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const targetDir = dirname(targetPath)
|
|
31
|
+
await mkdir(targetDir, { recursive: true })
|
|
32
|
+
|
|
33
|
+
const body = await request.text()
|
|
34
|
+
const timestamp = Date.now()
|
|
35
|
+
const tmpPath = `${targetPath}.tmp-${timestamp}`
|
|
36
|
+
try {
|
|
37
|
+
const tmpPath = `${targetPath}.tmp-${Date.now()}`
|
|
38
|
+
await writeFile(tmpPath, body, "utf-8")
|
|
39
|
+
await rename(tmpPath, targetPath)
|
|
40
|
+
} finally {
|
|
41
|
+
await rm(tmpPath, { force: true }).catch(() => {})
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return new Response(JSON.stringify({ status: "ok" }), {
|
|
45
|
+
status: 200,
|
|
46
|
+
headers: {
|
|
47
|
+
"Content-Type": "application/json",
|
|
48
|
+
},
|
|
49
|
+
})
|
|
50
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { AnyFieldMeta } from "@tanstack/react-form"
|
|
2
|
+
|
|
3
|
+
import { useI18n } from "../../../i18n/react/useI18n"
|
|
4
|
+
|
|
5
|
+
type FieldErrorsProps = {
|
|
6
|
+
meta: AnyFieldMeta
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export const FieldErrors = ({ meta }: FieldErrorsProps) => {
|
|
10
|
+
const { t } = useI18n()
|
|
11
|
+
if (!meta.isTouched || meta.isValid) return null
|
|
12
|
+
|
|
13
|
+
return (
|
|
14
|
+
<ul className="my-2 flex flex-col gap-1" role="alert">
|
|
15
|
+
{meta.errors.map((error) => (
|
|
16
|
+
<li className="text-sm text-rose-800" key={error.code}>
|
|
17
|
+
{t(error.message)}
|
|
18
|
+
</li>
|
|
19
|
+
))}
|
|
20
|
+
</ul>
|
|
21
|
+
)
|
|
22
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { useStore } from "@tanstack/react-form"
|
|
2
|
+
import { useEffect, useRef, useState } from "react"
|
|
3
|
+
|
|
4
|
+
import Icon from "../../../components/Icon"
|
|
5
|
+
import { useI18n } from "../../../i18n/react/useI18n"
|
|
6
|
+
import { useFormContext } from "./form-context"
|
|
7
|
+
|
|
8
|
+
const SUCCESS_DURATION_MS = 2000
|
|
9
|
+
|
|
10
|
+
const baseButtonClass =
|
|
11
|
+
"flex min-w-52 items-center justify-center gap-2 rounded-2xl px-6 py-3 font-bold uppercase shadow-sm transition-colors duration-200 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-gray-900 disabled:cursor-not-allowed"
|
|
12
|
+
|
|
13
|
+
const buttonStateClasses = {
|
|
14
|
+
idle: "bg-gray-800 text-gray-100 hover:bg-gray-950 hover:text-gray-300 disabled:bg-gray-600 disabled:text-gray-200",
|
|
15
|
+
error:
|
|
16
|
+
"bg-rose-700 text-white hover:bg-rose-800 hover:text-white disabled:bg-rose-600",
|
|
17
|
+
success:
|
|
18
|
+
"bg-emerald-600 text-white hover:bg-emerald-700 hover:text-white disabled:bg-emerald-500",
|
|
19
|
+
} as const
|
|
20
|
+
|
|
21
|
+
const buttonLabels = {
|
|
22
|
+
idle: "ln.admin.save",
|
|
23
|
+
success: "ln.admin.saved",
|
|
24
|
+
error: "ln.admin.failed",
|
|
25
|
+
} as const
|
|
26
|
+
|
|
27
|
+
const icons = {
|
|
28
|
+
idle: undefined,
|
|
29
|
+
success: "mdi--check",
|
|
30
|
+
error: "mdi--error-outline",
|
|
31
|
+
} as const
|
|
32
|
+
|
|
33
|
+
export default function SubmitButton() {
|
|
34
|
+
const form = useFormContext()
|
|
35
|
+
const { t } = useI18n()
|
|
36
|
+
const { submissionAttempts, isSubmitting, isSubmitSuccessful } = useStore(
|
|
37
|
+
form.store,
|
|
38
|
+
(state) => ({
|
|
39
|
+
canSubmit: state.canSubmit,
|
|
40
|
+
isSubmitting: state.isSubmitting,
|
|
41
|
+
isSubmitSuccessful: state.isSubmitSuccessful,
|
|
42
|
+
submissionAttempts: state.submissionAttempts,
|
|
43
|
+
}),
|
|
44
|
+
)
|
|
45
|
+
const buttonState = useButtonState(isSubmitSuccessful, submissionAttempts)
|
|
46
|
+
const buttonClass = `${baseButtonClass} ${buttonStateClasses[buttonState]}`
|
|
47
|
+
const label = buttonLabels[buttonState]
|
|
48
|
+
const icon = icons[buttonState]
|
|
49
|
+
|
|
50
|
+
return (
|
|
51
|
+
<button className={buttonClass} type="submit" disabled={isSubmitting}>
|
|
52
|
+
{icon && <Icon className={icon} ariaLabel="" />}
|
|
53
|
+
{t(label)}
|
|
54
|
+
</button>
|
|
55
|
+
)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function useButtonState(
|
|
59
|
+
isSubmitSuccessful: boolean,
|
|
60
|
+
submissionAttempts: number,
|
|
61
|
+
) {
|
|
62
|
+
const [state, setState] = useState<"success" | "error" | "idle">("idle")
|
|
63
|
+
const timeoutRef = useRef<number | undefined>(undefined)
|
|
64
|
+
useEffect(() => {
|
|
65
|
+
if (submissionAttempts === 0) {
|
|
66
|
+
return
|
|
67
|
+
}
|
|
68
|
+
setState(isSubmitSuccessful ? "success" : "error")
|
|
69
|
+
if (timeoutRef.current !== undefined) {
|
|
70
|
+
window.clearTimeout(timeoutRef.current)
|
|
71
|
+
}
|
|
72
|
+
timeoutRef.current = window.setTimeout(() => {
|
|
73
|
+
setState("idle")
|
|
74
|
+
timeoutRef.current = undefined
|
|
75
|
+
}, SUCCESS_DURATION_MS)
|
|
76
|
+
}, [submissionAttempts, isSubmitSuccessful])
|
|
77
|
+
|
|
78
|
+
return state
|
|
79
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { FieldErrors } from "./FieldErrors"
|
|
2
|
+
import { useFieldContext } from "./form-context"
|
|
3
|
+
|
|
4
|
+
export default function TextField({ label }: { label: string }) {
|
|
5
|
+
const field = useFieldContext<string>()
|
|
6
|
+
return (
|
|
7
|
+
<>
|
|
8
|
+
<label className="dy-form-control w-full max-w-sm">
|
|
9
|
+
<div className="dy-label">
|
|
10
|
+
<span className="dy-label-text">{label}</span>
|
|
11
|
+
</div>
|
|
12
|
+
<input
|
|
13
|
+
id={field.name}
|
|
14
|
+
name={field.name}
|
|
15
|
+
value={field.state.value}
|
|
16
|
+
onChange={(e) => field.handleChange(e.target.value)}
|
|
17
|
+
onBlur={field.handleBlur}
|
|
18
|
+
className={`dy-input dy-input-bordered dy-input-sm w-full max-w-sm ${field.state.meta.errors.length ? "dy-input-error" : ""}`}
|
|
19
|
+
/>
|
|
20
|
+
<FieldErrors meta={field.state.meta} />
|
|
21
|
+
</label>
|
|
22
|
+
</>
|
|
23
|
+
)
|
|
24
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { createFormHook } from "@tanstack/react-form"
|
|
2
|
+
|
|
3
|
+
import { fieldContext, formContext } from "./form-context"
|
|
4
|
+
import SubmitButton from "./SubmitButton"
|
|
5
|
+
import TextField from "./TextField"
|
|
6
|
+
|
|
7
|
+
export const { useAppForm, withForm } = createFormHook({
|
|
8
|
+
fieldComponents: {
|
|
9
|
+
TextField,
|
|
10
|
+
},
|
|
11
|
+
formComponents: {
|
|
12
|
+
SubmitButton,
|
|
13
|
+
},
|
|
14
|
+
fieldContext,
|
|
15
|
+
formContext,
|
|
16
|
+
})
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
ln.admin.edit: Edit
|
|
2
|
+
ln.admin.save: Save
|
|
3
|
+
ln.admin.saved: Saved
|
|
4
|
+
ln.admin.failed: Failed
|
|
5
|
+
ln.admin.edit-media-item: Edit media item
|
|
6
|
+
ln.admin.back-to-details-page: Back to details page
|
|
7
|
+
ln.admin.title: Title
|
|
8
|
+
ln.admin.common-id: Common ID
|
|
9
|
+
ln.admin.toast.invalid-data.title: Invalid form data
|
|
10
|
+
ln.admin.toast.invalid-data.hint: Check the fields and try again.
|
|
11
|
+
ln.admin.errors.non-empty-string: String must contain at least 1 character(s)
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
---
|
|
2
|
+
import Page from "../../layouts/Page.astro"
|
|
3
|
+
|
|
4
|
+
export { getLocalePaths as getStaticPaths } from "../../i18n/get-locale-paths"
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
<Page>
|
|
8
|
+
<div
|
|
9
|
+
class="flex h-96 w-full items-center justify-center text-lg font-bold text-gray-500"
|
|
10
|
+
>
|
|
11
|
+
Admin features are enabled now.
|
|
12
|
+
</div>
|
|
13
|
+
</Page>
|
|
14
|
+
<script>
|
|
15
|
+
localStorage.setItem("ln-admin-enabled", "true")
|
|
16
|
+
</script>
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { revalidateLogic } from "@tanstack/react-form"
|
|
2
|
+
|
|
3
|
+
import { showToastById } from "../../../components/showToast"
|
|
4
|
+
import Toast from "../../../components/Toast"
|
|
5
|
+
import {
|
|
6
|
+
createI18n,
|
|
7
|
+
type I18nConfig,
|
|
8
|
+
I18nContext,
|
|
9
|
+
} from "../../../i18n/react/i18n-context"
|
|
10
|
+
import { useAppForm } from "../../components/form"
|
|
11
|
+
import { type MediaItem, mediaItemSchema } from "../../types/media-item"
|
|
12
|
+
import { updateMediaItem } from "./media-item-store"
|
|
13
|
+
|
|
14
|
+
export default function EditForm({
|
|
15
|
+
mediaId,
|
|
16
|
+
mediaItem,
|
|
17
|
+
i18nConfig,
|
|
18
|
+
}: {
|
|
19
|
+
mediaId: string
|
|
20
|
+
mediaItem: MediaItem
|
|
21
|
+
i18nConfig: I18nConfig
|
|
22
|
+
}) {
|
|
23
|
+
const form = useAppForm({
|
|
24
|
+
defaultValues: mediaItem,
|
|
25
|
+
validators: {
|
|
26
|
+
onDynamic: mediaItemSchema,
|
|
27
|
+
},
|
|
28
|
+
validationLogic: revalidateLogic({
|
|
29
|
+
mode: "blur",
|
|
30
|
+
modeAfterSubmission: "change",
|
|
31
|
+
}),
|
|
32
|
+
onSubmit: async ({ value }) => {
|
|
33
|
+
await updateMediaItem(mediaId, { ...mediaItem, ...value })
|
|
34
|
+
},
|
|
35
|
+
onSubmitInvalid: () => {
|
|
36
|
+
showToastById("invalid-form-data-toast")
|
|
37
|
+
},
|
|
38
|
+
})
|
|
39
|
+
const i18n = createI18n(i18nConfig)
|
|
40
|
+
const { t } = i18n
|
|
41
|
+
|
|
42
|
+
return (
|
|
43
|
+
<I18nContext.Provider value={i18n}>
|
|
44
|
+
<form
|
|
45
|
+
onSubmit={(e) => {
|
|
46
|
+
e.preventDefault()
|
|
47
|
+
form.handleSubmit()
|
|
48
|
+
}}
|
|
49
|
+
className="flex flex-col items-start gap-4"
|
|
50
|
+
>
|
|
51
|
+
<form.AppField
|
|
52
|
+
name="commonId"
|
|
53
|
+
children={(field) => (
|
|
54
|
+
<field.TextField label={t("ln.admin.common-id")} />
|
|
55
|
+
)}
|
|
56
|
+
/>
|
|
57
|
+
<form.AppField
|
|
58
|
+
name="title"
|
|
59
|
+
children={(field) => <field.TextField label={t("ln.admin.title")} />}
|
|
60
|
+
/>
|
|
61
|
+
<form.AppForm>
|
|
62
|
+
<form.SubmitButton />
|
|
63
|
+
<Toast id="invalid-form-data-toast" variant="error">
|
|
64
|
+
<div className="font-bold text-gray-700">
|
|
65
|
+
{t("ln.admin.toast.invalid-data.title")}
|
|
66
|
+
</div>
|
|
67
|
+
{t("ln.admin.toast.invalid-data.hint")}
|
|
68
|
+
</Toast>
|
|
69
|
+
</form.AppForm>
|
|
70
|
+
</form>
|
|
71
|
+
</I18nContext.Provider>
|
|
72
|
+
)
|
|
73
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
---
|
|
2
|
+
import type { GetStaticPaths } from "astro"
|
|
3
|
+
import { getCollection } from "astro:content"
|
|
4
|
+
import config from "virtual:lightnet/config"
|
|
5
|
+
|
|
6
|
+
import { getRawMediaItem } from "../../../content/get-media-items"
|
|
7
|
+
import { prepareI18nConfig } from "../../../i18n/react/prepare-i18n-config"
|
|
8
|
+
import { resolveLocales } from "../../../i18n/resolve-locales"
|
|
9
|
+
import Page from "../../../layouts/Page.astro"
|
|
10
|
+
import EditForm from "./EditForm"
|
|
11
|
+
|
|
12
|
+
export const getStaticPaths = (async () => {
|
|
13
|
+
const mediaItems = await getCollection("media")
|
|
14
|
+
return resolveLocales(config).flatMap((locale) =>
|
|
15
|
+
mediaItems.map(({ id: mediaId }) => ({ params: { mediaId, locale } })),
|
|
16
|
+
)
|
|
17
|
+
}) satisfies GetStaticPaths
|
|
18
|
+
|
|
19
|
+
const { mediaId } = Astro.params
|
|
20
|
+
const mediaItemEntry = await getRawMediaItem(mediaId)
|
|
21
|
+
|
|
22
|
+
const i18nConfig = prepareI18nConfig(Astro.locals.i18n, ["ln.admin.*"])
|
|
23
|
+
const { t } = Astro.locals.i18n
|
|
24
|
+
---
|
|
25
|
+
|
|
26
|
+
<Page>
|
|
27
|
+
<div class="mx-auto max-w-screen-lg px-4 pt-12 md:px-8">
|
|
28
|
+
<a
|
|
29
|
+
class="underline"
|
|
30
|
+
href=`/${Astro.currentLocale}/media/faithful-freestyle--en`
|
|
31
|
+
>{t("ln.admin.back-to-details-page")}</a
|
|
32
|
+
>
|
|
33
|
+
<h1 class="mb-4 mt-8 text-lg">{t("ln.admin.edit-media-item")}</h1>
|
|
34
|
+
|
|
35
|
+
<EditForm
|
|
36
|
+
mediaId={mediaId}
|
|
37
|
+
mediaItem={mediaItemEntry.data}
|
|
38
|
+
i18nConfig={i18nConfig}
|
|
39
|
+
client:load
|
|
40
|
+
/>
|
|
41
|
+
</div>
|
|
42
|
+
</Page>
|