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.
Files changed (60) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/__e2e__/admin.spec.ts +113 -0
  3. package/__e2e__/fixtures/basics/astro.config.mjs +6 -0
  4. package/__e2e__/fixtures/basics/node_modules/.bin/astro +2 -2
  5. package/__e2e__/fixtures/basics/package.json +2 -2
  6. package/__e2e__/fixtures/basics/src/content/media/skate-sounds--en.json +15 -0
  7. package/__e2e__/fixtures/basics/src/content/media-types/audio.json +7 -0
  8. package/__e2e__/fixtures/basics/src/translations/de.yml +1 -0
  9. package/__e2e__/fixtures/basics/src/translations/en.yml +1 -0
  10. package/__e2e__/homepage.spec.ts +21 -0
  11. package/package.json +13 -6
  12. package/src/admin/api/fs/writeText.ts +50 -0
  13. package/src/admin/components/form/FieldErrors.tsx +22 -0
  14. package/src/admin/components/form/SubmitButton.tsx +79 -0
  15. package/src/admin/components/form/TextField.tsx +24 -0
  16. package/src/admin/components/form/form-context.ts +4 -0
  17. package/src/admin/components/form/index.ts +16 -0
  18. package/src/admin/i18n/translations/en.yml +11 -0
  19. package/src/admin/i18n/translations.ts +5 -0
  20. package/src/admin/pages/AdminRoute.astro +16 -0
  21. package/src/admin/pages/media/EditForm.tsx +73 -0
  22. package/src/admin/pages/media/EditRoute.astro +42 -0
  23. package/src/admin/pages/media/file-system.ts +37 -0
  24. package/src/admin/pages/media/media-item-store.ts +11 -0
  25. package/src/admin/types/media-item.ts +10 -0
  26. package/src/api/media/[mediaId].ts +16 -0
  27. package/src/{pages/api → api}/versions.ts +1 -1
  28. package/src/astro-integration/config.ts +15 -0
  29. package/src/astro-integration/integration.ts +44 -6
  30. package/src/components/CategoriesSection.astro +1 -1
  31. package/src/components/MediaGallerySection.astro +1 -1
  32. package/src/components/Toast.tsx +55 -0
  33. package/src/components/showToast.ts +61 -0
  34. package/src/content/astro-image.ts +1 -14
  35. package/src/content/content-schema.ts +10 -3
  36. package/src/content/get-media-items.ts +46 -1
  37. package/src/i18n/locals.d.ts +35 -28
  38. package/src/i18n/locals.ts +2 -1
  39. package/src/i18n/react/i18n-context.ts +32 -0
  40. package/src/i18n/react/prepare-i18n-config.ts +31 -0
  41. package/src/i18n/react/useI18n.ts +15 -0
  42. package/src/i18n/translate.ts +15 -3
  43. package/src/i18n/translations.ts +20 -7
  44. package/src/layouts/Page.astro +1 -1
  45. package/src/pages/details-page/components/MainDetailsSection.astro +5 -1
  46. package/src/pages/details-page/components/VideoDetailsSection.astro +5 -1
  47. package/src/pages/details-page/components/main-details/EditButton.astro +30 -0
  48. package/src/pages/details-page/components/main-details/ShareButton.astro +9 -13
  49. package/src/pages/{api → search-page/api}/search.ts +3 -3
  50. package/src/pages/search-page/components/LoadingSkeleton.tsx +3 -1
  51. package/src/pages/search-page/components/SearchFilter.astro +12 -3
  52. package/src/pages/search-page/components/SearchFilter.tsx +4 -7
  53. package/src/pages/search-page/components/SearchList.astro +7 -6
  54. package/src/pages/search-page/components/SearchList.tsx +12 -15
  55. package/src/pages/search-page/components/SearchListItem.tsx +3 -5
  56. package/src/pages/search-page/hooks/use-search.ts +3 -3
  57. package/tailwind.config.ts +1 -0
  58. package/src/pages/search-page/utils/search-filter-translations.ts +0 -20
  59. package/src/pages/search-page/utils/search-translations.ts +0 -11
  60. /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.4_@types+node@24.3.0_jiti@2.4.2_lightningcss@1.29.1_rollup@4.52.4_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.4_@types+node@24.3.0_jiti@2.4.2_lightningcss@1.29.1_rollup@4.52.4_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.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.4_@types+node@24.3.0_jiti@2.4.2_lightningcss@1.29.1_rollup@4.52.4_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.4_@types+node@24.3.0_jiti@2.4.2_lightningcss@1.29.1_rollup@4.52.4_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.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.2",
10
- "astro": "^5.14.4",
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
+ }
@@ -0,0 +1,7 @@
1
+ {
2
+ "label": "type.audio",
3
+ "detailsPage": {
4
+ "layout": "audio"
5
+ },
6
+ "icon": "mdi--music-box-outline"
7
+ }
@@ -4,5 +4,6 @@ category.teens: Teens
4
4
  category.theology: Theologie
5
5
  type.book: Buch
6
6
  type.video: Video
7
+ type.audio: Audio
7
8
  details.action.read: Lesen
8
9
  details.action.watch: Anschauen
@@ -4,5 +4,6 @@ category.teens: Teens
4
4
  category.theology: Theology
5
5
  type.book: Book
6
6
  type.video: Video
7
+ type.audio: Audio
7
8
  details.action.read: Read
8
9
  details.action.watch: Watch
@@ -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.0",
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
- "./pages/api/search.ts": "./src/pages/api/search.ts",
34
- "./pages/api/versions.ts": "./src/pages/api/versions.ts"
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.0",
61
+ "marked": "^16.4.1",
55
62
  "yaml": "^2.8.1"
56
63
  },
57
64
  "devDependencies": {
58
- "@playwright/test": "^1.56.0",
59
- "@types/node": "^22.18.10",
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,4 @@
1
+ import { createFormHookContexts } from "@tanstack/react-form"
2
+
3
+ export const { fieldContext, useFieldContext, formContext, useFormContext } =
4
+ createFormHookContexts()
@@ -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,5 @@
1
+ export const builtInAdminTranslations = {
2
+ en: () => import("./translations/en.yml?raw"),
3
+ } as const
4
+
5
+ export type AdminTranslationKey = "ln.admin.edit"
@@ -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>