lightnet 3.10.0 → 3.10.1

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 (47) hide show
  1. package/CHANGELOG.md +14 -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 +19 -0
  14. package/src/admin/components/form/SubmitButton.tsx +77 -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 +1 -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 +58 -0
  22. package/src/admin/pages/media/EditRoute.astro +33 -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 +8 -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/translations.ts +20 -7
  38. package/src/layouts/Page.astro +1 -1
  39. package/src/pages/details-page/components/MainDetailsSection.astro +5 -1
  40. package/src/pages/details-page/components/VideoDetailsSection.astro +5 -1
  41. package/src/pages/details-page/components/main-details/EditButton.astro +30 -0
  42. package/src/pages/details-page/components/main-details/ShareButton.astro +9 -13
  43. package/src/pages/{api → search-page/api}/search.ts +3 -3
  44. package/src/pages/search-page/components/SearchListItem.tsx +1 -1
  45. package/src/pages/search-page/hooks/use-search.ts +3 -3
  46. package/tailwind.config.ts +1 -0
  47. /package/src/pages/{api → search-page/api}/search-response.ts +0 -0
package/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  # lightnet
2
2
 
3
+ ## 3.10.1
4
+
5
+ ### Patch Changes
6
+
7
+ - [#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
8
+
9
+ - [#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.
10
+
11
+ - [#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).
12
+
13
+ - [#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`.
14
+
15
+ 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.
16
+
3
17
  ## 3.10.0
4
18
 
5
19
  ### 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.1",
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,19 @@
1
+ import type { AnyFieldMeta } from "@tanstack/react-form"
2
+
3
+ type FieldErrorsProps = {
4
+ meta: AnyFieldMeta
5
+ }
6
+
7
+ export const FieldErrors = ({ meta }: FieldErrorsProps) => {
8
+ if (!meta.isTouched || meta.isValid) return null
9
+
10
+ return (
11
+ <ul className="my-2 flex flex-col gap-1" role="alert">
12
+ {meta.errors.map((error) => (
13
+ <li className="text-sm text-rose-800" key={error.code}>
14
+ {error.message}
15
+ </li>
16
+ ))}
17
+ </ul>
18
+ )
19
+ }
@@ -0,0 +1,77 @@
1
+ import { useStore } from "@tanstack/react-form"
2
+ import { useEffect, useRef, useState } from "react"
3
+
4
+ import Icon from "../../../components/Icon"
5
+ import { useFormContext } from "./form-context"
6
+
7
+ const SUCCESS_DURATION_MS = 2000
8
+
9
+ const baseButtonClass =
10
+ "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"
11
+
12
+ const buttonStateClasses = {
13
+ idle: "bg-gray-800 text-gray-100 hover:bg-gray-950 hover:text-gray-300 disabled:bg-gray-600 disabled:text-gray-200",
14
+ error:
15
+ "bg-rose-700 text-white hover:bg-rose-800 hover:text-white disabled:bg-rose-600",
16
+ success:
17
+ "bg-emerald-600 text-white hover:bg-emerald-700 hover:text-white disabled:bg-emerald-500",
18
+ } as const
19
+
20
+ const buttonLabels = {
21
+ idle: "Save",
22
+ success: "Saved",
23
+ error: "Failed",
24
+ } as const
25
+
26
+ const icons = {
27
+ idle: undefined,
28
+ success: "mdi--check",
29
+ error: "mdi--error-outline",
30
+ } as const
31
+
32
+ export default function SubmitButton() {
33
+ const form = useFormContext()
34
+ const { submissionAttempts, isSubmitting, isSubmitSuccessful } = useStore(
35
+ form.store,
36
+ (state) => ({
37
+ canSubmit: state.canSubmit,
38
+ isSubmitting: state.isSubmitting,
39
+ isSubmitSuccessful: state.isSubmitSuccessful,
40
+ submissionAttempts: state.submissionAttempts,
41
+ }),
42
+ )
43
+ const buttonState = useButtonState(isSubmitSuccessful, submissionAttempts)
44
+ const buttonClass = `${baseButtonClass} ${buttonStateClasses[buttonState]}`
45
+ const label = buttonLabels[buttonState]
46
+ const icon = icons[buttonState]
47
+
48
+ return (
49
+ <button className={buttonClass} type="submit" disabled={isSubmitting}>
50
+ {icon && <Icon className={icon} ariaLabel="" />}
51
+ {label}
52
+ </button>
53
+ )
54
+ }
55
+
56
+ function useButtonState(
57
+ isSubmitSuccessful: boolean,
58
+ submissionAttempts: number,
59
+ ) {
60
+ const [state, setState] = useState<"success" | "error" | "idle">("idle")
61
+ const timeoutRef = useRef<number | undefined>(undefined)
62
+ useEffect(() => {
63
+ if (submissionAttempts === 0) {
64
+ return
65
+ }
66
+ setState(isSubmitSuccessful ? "success" : "error")
67
+ if (timeoutRef.current !== undefined) {
68
+ window.clearTimeout(timeoutRef.current)
69
+ }
70
+ timeoutRef.current = window.setTimeout(() => {
71
+ setState("idle")
72
+ timeoutRef.current = undefined
73
+ }, SUCCESS_DURATION_MS)
74
+ }, [submissionAttempts, isSubmitSuccessful])
75
+
76
+ return state
77
+ }
@@ -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 @@
1
+ ln.admin.edit: Edit
@@ -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,58 @@
1
+ import { revalidateLogic } from "@tanstack/react-form"
2
+
3
+ import { showToastById } from "../../../components/showToast"
4
+ import Toast from "../../../components/Toast"
5
+ import { useAppForm } from "../../components/form"
6
+ import { type MediaItem, mediaItemSchema } from "../../types/media-item"
7
+ import { updateMediaItem } from "./media-item-store"
8
+
9
+ export default function EditForm({
10
+ mediaId,
11
+ mediaItem,
12
+ }: {
13
+ mediaId: string
14
+ mediaItem: MediaItem
15
+ }) {
16
+ const form = useAppForm({
17
+ defaultValues: mediaItem,
18
+ validators: {
19
+ onDynamic: mediaItemSchema,
20
+ },
21
+ validationLogic: revalidateLogic({
22
+ mode: "blur",
23
+ modeAfterSubmission: "change",
24
+ }),
25
+ onSubmit: async ({ value }) => {
26
+ await updateMediaItem(mediaId, { ...mediaItem, ...value })
27
+ },
28
+ onSubmitInvalid: () => {
29
+ showToastById("invalid-form-data-toast")
30
+ },
31
+ })
32
+
33
+ return (
34
+ <form
35
+ onSubmit={(e) => {
36
+ e.preventDefault()
37
+ form.handleSubmit()
38
+ }}
39
+ className="flex flex-col items-start gap-4"
40
+ >
41
+ <form.AppField
42
+ name="commonId"
43
+ children={(field) => <field.TextField label="Common ID" />}
44
+ />
45
+ <form.AppField
46
+ name="title"
47
+ children={(field) => <field.TextField label="Title" />}
48
+ />
49
+ <form.AppForm>
50
+ <form.SubmitButton />
51
+ <Toast id="invalid-form-data-toast" variant="error">
52
+ <div className="font-bold text-gray-700">Invalid form data</div>
53
+ Check the fields and try again.
54
+ </Toast>
55
+ </form.AppForm>
56
+ </form>
57
+ )
58
+ }
@@ -0,0 +1,33 @@
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 { resolveLocales } from "../../../i18n/resolve-locales"
8
+ import Page from "../../../layouts/Page.astro"
9
+ import EditForm from "./EditForm"
10
+
11
+ export const getStaticPaths = (async () => {
12
+ const mediaItems = await getCollection("media")
13
+ return resolveLocales(config).flatMap((locale) =>
14
+ mediaItems.map(({ id: mediaId }) => ({ params: { mediaId, locale } })),
15
+ )
16
+ }) satisfies GetStaticPaths
17
+
18
+ const { mediaId } = Astro.params
19
+ const mediaItemEntry = await getRawMediaItem(mediaId)
20
+ ---
21
+
22
+ <Page>
23
+ <div class="mx-auto max-w-screen-lg px-4 pt-12 md:px-8">
24
+ <a
25
+ class="underline"
26
+ href=`/${Astro.currentLocale}/media/faithful-freestyle--en`
27
+ >Back to details page</a
28
+ >
29
+ <h1 class="mb-4 mt-8 text-lg">Edit media item</h1>
30
+
31
+ <EditForm mediaId={mediaId} mediaItem={mediaItemEntry.data} client:load />
32
+ </div>
33
+ </Page>
@@ -0,0 +1,37 @@
1
+ export const writeText = (path: string, body: string) => {
2
+ return fetch(
3
+ `/api/internal/fs/writeText?path=${encodeURIComponent(path.replace(/^\//, ""))}`,
4
+ {
5
+ method: "POST",
6
+ headers: { "Content-Type": resolveContentType(path) },
7
+ body,
8
+ },
9
+ )
10
+ }
11
+
12
+ export const writeJson = async (path: string, object: unknown) => {
13
+ return writeText(path, JSON.stringify(sortObject(object), null, 2))
14
+ }
15
+
16
+ const resolveContentType = (path: string) => {
17
+ const normalizedPath = path.trim().toLowerCase()
18
+ return normalizedPath.endsWith(".json")
19
+ ? "application/json"
20
+ : "text/plain; charset=utf-8"
21
+ }
22
+
23
+ const sortObject = (value: unknown): unknown => {
24
+ if (Array.isArray(value)) {
25
+ return value.map(sortObject)
26
+ }
27
+
28
+ if (value && typeof value === "object") {
29
+ const entries = Object.entries(value as Record<string, unknown>)
30
+ .sort(([a], [b]) => (a > b ? 1 : a < b ? -1 : 0))
31
+ .map(([key, nestedValue]) => [key, sortObject(nestedValue)])
32
+
33
+ return Object.fromEntries(entries)
34
+ }
35
+
36
+ return value
37
+ }
@@ -0,0 +1,11 @@
1
+ import { type MediaItem, mediaItemSchema } from "../../types/media-item"
2
+ import { writeJson } from "./file-system"
3
+
4
+ export const loadMediaItem = (id: string) =>
5
+ fetch(`/api/media/${id}.json`)
6
+ .then((response) => response.json())
7
+ .then((json) => mediaItemSchema.parse(json.content))
8
+
9
+ export const updateMediaItem = async (id: string, item: MediaItem) => {
10
+ return writeJson(`/src/content/media/${id}.json`, item)
11
+ }