lightnet 3.10.2 → 3.10.4

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 (39) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/__e2e__/admin.spec.ts +297 -100
  3. package/__e2e__/fixtures/basics/node_modules/.bin/astro +2 -2
  4. package/__e2e__/fixtures/basics/package.json +5 -5
  5. package/package.json +10 -9
  6. package/src/admin/api/fs/{writeText.ts → write-file.ts} +9 -6
  7. package/src/admin/components/form/Input.tsx +36 -0
  8. package/src/admin/components/form/Select.tsx +43 -0
  9. package/src/admin/components/form/SubmitButton.tsx +21 -16
  10. package/src/admin/components/form/atoms/ErrorMessage.tsx +34 -0
  11. package/src/admin/components/form/atoms/Hint.tsx +10 -0
  12. package/src/admin/components/form/atoms/Label.tsx +19 -0
  13. package/src/admin/components/form/atoms/Legend.tsx +10 -0
  14. package/src/admin/components/form/hooks/use-field-error.tsx +21 -0
  15. package/src/admin/i18n/translations/en.yml +8 -2
  16. package/src/admin/pages/media/EditForm.tsx +43 -43
  17. package/src/admin/pages/media/EditRoute.astro +28 -5
  18. package/src/admin/pages/media/fields/Authors.tsx +58 -0
  19. package/src/admin/pages/media/file-system.ts +3 -3
  20. package/src/admin/pages/media/media-item-store.ts +9 -7
  21. package/src/admin/types/media-item.ts +10 -1
  22. package/src/astro-integration/integration.ts +2 -2
  23. package/src/components/HeroSection.astro +1 -1
  24. package/src/components/HighlightSection.astro +1 -1
  25. package/src/components/SearchInput.astro +1 -1
  26. package/src/components/Toast.tsx +1 -1
  27. package/src/i18n/react/i18n-context.ts +14 -8
  28. package/src/layouts/MarkdownPage.astro +1 -1
  29. package/src/layouts/components/Menu.astro +1 -1
  30. package/src/layouts/components/PageNavigation.astro +1 -1
  31. package/src/pages/details-page/components/main-details/OpenButton.astro +1 -1
  32. package/src/pages/search-page/components/LoadingSkeleton.tsx +1 -1
  33. package/src/pages/search-page/components/SearchFilter.tsx +1 -1
  34. package/src/pages/search-page/components/SearchListItem.tsx +1 -1
  35. package/src/pages/search-page/components/Select.tsx +1 -1
  36. package/src/admin/components/form/FieldErrors.tsx +0 -22
  37. package/src/admin/components/form/TextField.tsx +0 -24
  38. package/src/admin/components/form/form-context.ts +0 -4
  39. package/src/admin/components/form/index.ts +0 -16
package/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # lightnet
2
2
 
3
+ ## 3.10.4
4
+
5
+ ### Patch Changes
6
+
7
+ - [#329](https://github.com/LightNetDev/LightNet/pull/329) [`1730ef6`](https://github.com/LightNetDev/LightNet/commit/1730ef6f769d4c0d8fb22acdb86eaf19c3a32b2c) Thanks [@smn-cds](https://github.com/smn-cds)! - Improve performance of react i18n context.
8
+
9
+ ## 3.10.3
10
+
11
+ ### Patch Changes
12
+
13
+ - [#327](https://github.com/LightNetDev/LightNet/pull/327) [`9f1e468`](https://github.com/LightNetDev/LightNet/commit/9f1e468cc296e62788586a3f3c6f245c8bc0cec9) Thanks [@smn-cds](https://github.com/smn-cds)! - Gracefully handle missing translations in React components to avoid runtime crashes.
14
+
3
15
  ## 3.10.2
4
16
 
5
17
  ### Patch Changes
@@ -1,113 +1,310 @@
1
- import { expect } from "@playwright/test"
1
+ import { readFile } from "node:fs/promises"
2
+
3
+ import { expect, type Page } from "@playwright/test"
2
4
 
3
5
  import { lightnetTest } from "./test-utils"
4
6
 
5
7
  const test = lightnetTest("./fixtures/basics/")
8
+ const faithfulFreestyleMediaUrl = new URL(
9
+ "./fixtures/basics/src/content/media/faithful-freestyle--en.json",
10
+ import.meta.url,
11
+ )
6
12
 
7
- test("Should not show `Edit` button on details page by default.", async ({
8
- page,
9
- startLightnet,
10
- }) => {
11
- await startLightnet()
13
+ test.describe("Edit button on details page", () => {
14
+ test("Should not show `Edit` button on details page by default.", async ({
15
+ page,
16
+ startLightnet,
17
+ }) => {
18
+ await startLightnet()
12
19
 
13
- await page.getByRole("link", { name: "Faithful Freestyle" }).click()
14
- await expect(
15
- page.getByRole("heading", { name: "Faithful Freestyle" }),
16
- ).toBeVisible()
20
+ await page.getByRole("link", { name: "Faithful Freestyle" }).click()
21
+ await expect(
22
+ page.getByRole("heading", { name: "Faithful Freestyle" }),
23
+ ).toBeVisible()
17
24
 
18
- const editButton = page.locator("#edit-btn")
19
- await expect(editButton).toBeHidden()
20
- })
25
+ const editButton = page.locator("#edit-btn")
26
+ await expect(editButton).toBeHidden()
27
+ })
21
28
 
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
- })
29
+ test("Should show `Edit` button on book details page after visiting `/en/admin/` path.", async ({
30
+ page,
31
+ startLightnet,
32
+ }) => {
33
+ const ln = await startLightnet()
45
34
 
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
- })
35
+ await page.goto(ln.resolveURL("/en/admin/"))
36
+ await expect(
37
+ page.getByText("Admin features are enabled now.", { exact: true }),
38
+ ).toBeVisible()
39
+
40
+ await page.goto(ln.resolveURL("/en/media/faithful-freestyle--en"))
41
+ await expect(
42
+ page.getByRole("heading", { name: "Faithful Freestyle" }),
43
+ ).toBeVisible()
44
+
45
+ const editButton = page.locator("#edit-btn")
46
+ await expect(editButton).toBeVisible()
47
+ await expect(editButton).toHaveAttribute(
48
+ "href",
49
+ "/en/admin/media/faithful-freestyle--en",
50
+ )
51
+ })
52
+
53
+ test("Should show `Edit` button on video details page after visiting `/en/admin/` path.", async ({
54
+ page,
55
+ startLightnet,
56
+ }) => {
57
+ const ln = await startLightnet()
58
+
59
+ await page.goto(ln.resolveURL("/en/admin/"))
60
+ await expect(
61
+ page.getByText("Admin features are enabled now.", { exact: true }),
62
+ ).toBeVisible()
63
+
64
+ await page.goto(ln.resolveURL("/en/media/how-to-kickflip--de"))
65
+ await expect(
66
+ page.getByRole("heading", { name: "Kickflip Anleitung" }),
67
+ ).toBeVisible()
68
+
69
+ const editButton = page.locator("#edit-btn")
70
+ await expect(editButton).toBeVisible()
71
+ await expect(editButton).toHaveAttribute(
72
+ "href",
73
+ "/en/admin/media/how-to-kickflip--de",
74
+ )
75
+ })
76
+
77
+ test("Should show `Edit` button on audio details page after visiting `/en/admin/` path.", async ({
78
+ page,
79
+ startLightnet,
80
+ }) => {
81
+ const ln = await startLightnet()
82
+
83
+ await page.goto(ln.resolveURL("/en/admin/"))
84
+ await expect(
85
+ page.getByText("Admin features are enabled now.", { exact: true }),
86
+ ).toBeVisible()
87
+
88
+ await page.goto(ln.resolveURL("/en/media/skate-sounds--en"))
89
+ await expect(
90
+ page.getByRole("heading", { name: "Skate Sounds" }),
91
+ ).toBeVisible()
69
92
 
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
- )
93
+ const editButton = page.locator("#edit-btn")
94
+ await expect(editButton).toBeVisible()
95
+ await expect(editButton).toHaveAttribute(
96
+ "href",
97
+ "/en/admin/media/skate-sounds--en",
98
+ )
99
+ })
100
+
101
+ test("Edit button on details page should navigate to media item edit page", async ({
102
+ page,
103
+ startLightnet,
104
+ }) => {
105
+ const ln = await startLightnet()
106
+
107
+ await page.goto(ln.resolveURL("/en/admin/"))
108
+ await page.goto(ln.resolveURL("/en/media/faithful-freestyle--en"))
109
+
110
+ const editButton = page.locator("#edit-btn")
111
+ await expect(editButton).toBeVisible()
112
+
113
+ await editButton.click()
114
+ await expect(page).toHaveURL(
115
+ ln.resolveURL("/en/admin/media/faithful-freestyle--en"),
116
+ )
117
+ await expect(
118
+ page.getByText("Edit media item", { exact: false }),
119
+ ).toBeVisible()
120
+ })
92
121
  })
93
122
 
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()
123
+ test.describe("Media item edit page", () => {
124
+ const recordWriteFile = async (page: Page) => {
125
+ type WriteFileRequest = { url: string; body: unknown }
126
+ let resolveWriteFileRequest: ((value: WriteFileRequest) => void) | null =
127
+ null
128
+ const writeFileRequestPromise = new Promise<WriteFileRequest>((resolve) => {
129
+ resolveWriteFileRequest = resolve
130
+ })
131
+ await page.route("**/api/internal/fs/write-file?*", async (route) => {
132
+ const request = route.request()
133
+ resolveWriteFileRequest?.({
134
+ url: request.url(),
135
+ body: JSON.parse(request.postData() ?? ""),
136
+ })
137
+ await route.fulfill({
138
+ status: 200,
139
+ contentType: "application/json",
140
+ body: JSON.stringify({ status: "ok" }),
141
+ })
142
+ })
143
+ return () => writeFileRequestPromise
144
+ }
145
+
146
+ test("should edit title", async ({ page, startLightnet }) => {
147
+ const ln = await startLightnet()
148
+
149
+ const writeFileRequest = await recordWriteFile(page)
150
+
151
+ await page.goto(ln.resolveURL("/en/admin/media/faithful-freestyle--en"))
152
+
153
+ const updatedTitle = "Faithful Freestyle (Edited)"
154
+ const titleInput = page.getByLabel("Title")
155
+ await expect(titleInput).toHaveValue("Faithful Freestyle")
156
+ await titleInput.fill(updatedTitle)
157
+
158
+ const saveButton = page.getByRole("button", { name: "Save" })
159
+ await expect(saveButton).toBeEnabled()
160
+ await saveButton.click()
161
+
162
+ const { url, body } = await writeFileRequest()
163
+ expect(url).toContain(
164
+ "/api/internal/fs/write-file?path=src%2Fcontent%2Fmedia%2Ffaithful-freestyle--en.json",
165
+ )
166
+
167
+ const expectedMediaItem = JSON.parse(
168
+ await readFile(faithfulFreestyleMediaUrl, "utf-8"),
169
+ )
170
+ expect(body).toEqual({
171
+ ...expectedMediaItem,
172
+ title: updatedTitle,
173
+ })
174
+ await expect(page.getByRole("button", { name: "Saved" })).toBeVisible()
175
+ })
176
+
177
+ test("Should update media type", async ({ page, startLightnet }) => {
178
+ const ln = await startLightnet()
179
+ const writeFileRequest = await recordWriteFile(page)
180
+
181
+ await page.goto(ln.resolveURL("/en/admin/media/faithful-freestyle--en"))
182
+
183
+ const typeSelect = page.getByLabel("Type")
184
+ await expect(typeSelect).toHaveValue("book")
185
+ await typeSelect.selectOption("video")
186
+
187
+ const saveButton = page.getByRole("button", { name: "Save" })
188
+ await expect(saveButton).toBeEnabled()
189
+ await saveButton.click()
190
+
191
+ const { body } = await writeFileRequest()
192
+ const expectedMediaItem = JSON.parse(
193
+ await readFile(faithfulFreestyleMediaUrl, "utf-8"),
194
+ )
195
+ expect(body).toEqual({
196
+ ...expectedMediaItem,
197
+ type: "video",
198
+ })
199
+ await expect(page.getByRole("button", { name: "Saved" })).toBeVisible()
200
+ })
201
+
202
+ test("Should update author name", async ({ page, startLightnet }) => {
203
+ const ln = await startLightnet()
204
+ const writeFileRequest = await recordWriteFile(page)
205
+
206
+ await page.goto(ln.resolveURL("/en/admin/media/faithful-freestyle--en"))
207
+
208
+ const authorsFieldset = page.getByRole("group", { name: "Authors" })
209
+ const firstAuthorInput = authorsFieldset.getByRole("textbox").first()
210
+ const updatedAuthor = "Sk8 Ministries International"
211
+ await expect(firstAuthorInput).toHaveValue("Sk8 Ministries")
212
+ await firstAuthorInput.fill(updatedAuthor)
213
+
214
+ const saveButton = page.getByRole("button", { name: "Save" })
215
+ await expect(saveButton).toBeEnabled()
216
+ await saveButton.click()
217
+
218
+ const { body } = await writeFileRequest()
219
+ const expectedMediaItem = JSON.parse(
220
+ await readFile(faithfulFreestyleMediaUrl, "utf-8"),
221
+ )
222
+ expect(body).toEqual({
223
+ ...expectedMediaItem,
224
+ authors: [updatedAuthor],
225
+ })
226
+ await expect(page.getByRole("button", { name: "Saved" })).toBeVisible()
227
+ })
228
+
229
+ test("Should add author", async ({ page, startLightnet }) => {
230
+ const ln = await startLightnet()
231
+ const writeFileRequest = await recordWriteFile(page)
232
+
233
+ await page.goto(ln.resolveURL("/en/admin/media/faithful-freestyle--en"))
234
+
235
+ const authorsFieldset = page.getByRole("group", { name: "Authors" })
236
+ const addAuthorButton = page.getByRole("button", { name: "Add Author" })
237
+ await addAuthorButton.click()
238
+ const newAuthorInput = authorsFieldset.getByRole("textbox").last()
239
+ const additionalAuthor = "Tony Hawk"
240
+ await newAuthorInput.fill(additionalAuthor)
241
+
242
+ const saveButton = page.getByRole("button", { name: "Save" })
243
+ await expect(saveButton).toBeEnabled()
244
+ await saveButton.click()
245
+
246
+ const { body } = await writeFileRequest()
247
+ const expectedMediaItem = JSON.parse(
248
+ await readFile(faithfulFreestyleMediaUrl, "utf-8"),
249
+ )
250
+ expect(body).toEqual({
251
+ ...expectedMediaItem,
252
+ authors: ["Sk8 Ministries", additionalAuthor],
253
+ })
254
+ await expect(page.getByRole("button", { name: "Saved" })).toBeVisible()
255
+ })
256
+
257
+ test("Should remove author", async ({ page, startLightnet }) => {
258
+ const ln = await startLightnet()
259
+ const writeFileRequest = await recordWriteFile(page)
260
+
261
+ await page.goto(ln.resolveURL("/en/admin/media/faithful-freestyle--en"))
262
+
263
+ const authorsFieldset = page.getByRole("group", { name: "Authors" })
264
+ const addAuthorButton = page.getByRole("button", { name: "Add Author" })
265
+ const replacementAuthor = "Skate Evangelists"
266
+ await addAuthorButton.click()
267
+ const addedAuthorInput = authorsFieldset.getByRole("textbox").last()
268
+ await addedAuthorInput.fill(replacementAuthor)
269
+
270
+ const removeButtons = authorsFieldset.getByRole("button", {
271
+ name: "Remove",
272
+ })
273
+ await removeButtons.first().click()
274
+
275
+ const saveButton = page.getByRole("button", { name: "Save" })
276
+ await expect(saveButton).toBeEnabled()
277
+ await saveButton.click()
278
+
279
+ const { body } = await writeFileRequest()
280
+ const expectedMediaItem = JSON.parse(
281
+ await readFile(faithfulFreestyleMediaUrl, "utf-8"),
282
+ )
283
+ expect(body).toEqual({
284
+ ...expectedMediaItem,
285
+ authors: [replacementAuthor],
286
+ })
287
+ await expect(page.getByRole("button", { name: "Saved" })).toBeVisible()
288
+ })
289
+
290
+ test("should show error message if common id is set empty", async ({
291
+ page,
292
+ startLightnet,
293
+ }) => {
294
+ const ln = await startLightnet()
295
+ await page.goto(ln.resolveURL("/en/admin/media/faithful-freestyle--en"))
296
+
297
+ const commonIdInput = page.getByLabel("Common ID")
298
+ await expect(commonIdInput).toHaveValue("faithful-freestyle")
299
+
300
+ await commonIdInput.fill("")
301
+ await commonIdInput.blur()
302
+
303
+ await expect(
304
+ page
305
+ .getByRole("alert")
306
+ .filter({ hasText: "String must contain at least 1 character(s)" }),
307
+ ).toBeVisible()
308
+ await expect(page.getByRole("button", { name: "Save" })).toBeDisabled()
309
+ })
113
310
  })
@@ -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.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"
13
+ export NODE_PATH="/home/runner/work/LightNet/LightNet/node_modules/.pnpm/astro@5.15.4_@types+node@24.10.0_jiti@2.4.2_lightningcss@1.29.1_rollup@4.53.2_terser@5.39.0_typescript@5.9.3_yaml@2.8.1/node_modules/astro/node_modules:/home/runner/work/LightNet/LightNet/node_modules/.pnpm/astro@5.15.4_@types+node@24.10.0_jiti@2.4.2_lightningcss@1.29.1_rollup@4.53.2_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.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"
15
+ export NODE_PATH="/home/runner/work/LightNet/LightNet/node_modules/.pnpm/astro@5.15.4_@types+node@24.10.0_jiti@2.4.2_lightningcss@1.29.1_rollup@4.53.2_terser@5.39.0_typescript@5.9.3_yaml@2.8.1/node_modules/astro/node_modules:/home/runner/work/LightNet/LightNet/node_modules/.pnpm/astro@5.15.4_@types+node@24.10.0_jiti@2.4.2_lightningcss@1.29.1_rollup@4.53.2_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" "$@"
@@ -4,14 +4,14 @@
4
4
  "version": "0.0.1",
5
5
  "private": "true",
6
6
  "dependencies": {
7
- "@astrojs/react": "^4.4.0",
7
+ "@astrojs/react": "^4.4.2",
8
8
  "@astrojs/tailwind": "^6.0.2",
9
- "@lightnet/decap-admin": "^3.1.3",
10
- "astro": "^5.14.8",
11
- "lightnet": "^3.10.0",
9
+ "@lightnet/decap-admin": "^3.1.4",
10
+ "astro": "^5.15.4",
11
+ "lightnet": "^3.10.3",
12
12
  "react": "^19.2.0",
13
13
  "react-dom": "^19.2.0",
14
- "sharp": "^0.34.4",
14
+ "sharp": "^0.34.5",
15
15
  "tailwindcss": "^3.4.18",
16
16
  "typescript": "^5.9.3"
17
17
  },
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.2",
6
+ "version": "3.10.4",
7
7
  "repository": {
8
8
  "type": "git",
9
9
  "url": "https://github.com/LightNetDev/lightnet",
@@ -34,7 +34,7 @@
34
34
  "./api/internal/search.ts": "./src/pages/search-page/api/search.ts",
35
35
  "./api/versions.ts": "./src/api/versions.ts",
36
36
  "./api/media/[mediaId].ts": "./src/api/media/[mediaId].ts",
37
- "./api/internal/fs/writeText.ts": "./src/admin/api/fs/writeText.ts",
37
+ "./api/internal/fs/write-file.ts": "./src/admin/api/fs/write-file.ts",
38
38
  "./admin/pages/AdminRoute.astro": "./src/admin/pages/AdminRoute.astro",
39
39
  "./admin/pages/media/EditRoute.astro": "./src/admin/pages/media/EditRoute.astro"
40
40
  },
@@ -45,28 +45,29 @@
45
45
  "tailwindcss": ">=3.4.0 <4.0.0"
46
46
  },
47
47
  "dependencies": {
48
- "@astrojs/react": "^4.4.0",
48
+ "@astrojs/react": "^4.4.2",
49
49
  "@astrojs/tailwind": "^6.0.2",
50
+ "@hookform/error-message": "^2.0.1",
51
+ "@hookform/resolvers": "^5.2.2",
50
52
  "@iconify-json/mdi": "^1.2.3",
51
53
  "@iconify/tailwind": "^1.2.0",
52
54
  "@tailwindcss/typography": "^0.5.19",
53
- "@tanstack/react-form": "^1.23.7",
54
- "@tanstack/react-query": "^5.90.5",
55
55
  "@tanstack/react-virtual": "^3.13.12",
56
56
  "daisyui": "^4.12.24",
57
57
  "embla-carousel": "^8.6.0",
58
58
  "embla-carousel-wheel-gestures": "^8.1.0",
59
59
  "fuse.js": "^7.1.0",
60
- "i18next": "^25.6.0",
61
- "marked": "^16.4.1",
60
+ "i18next": "^25.6.1",
61
+ "marked": "^16.4.2",
62
+ "react-hook-form": "^7.66.0",
62
63
  "yaml": "^2.8.1"
63
64
  },
64
65
  "devDependencies": {
65
66
  "@playwright/test": "^1.56.1",
66
- "@types/node": "^22.18.12",
67
+ "@types/node": "^22.19.0",
67
68
  "@types/react": "^19.2.2",
68
69
  "typescript": "^5.9.3",
69
- "vitest": "^3.2.4"
70
+ "vitest": "^4.0.8"
70
71
  },
71
72
  "engines": {
72
73
  "node": ">=22"
@@ -1,5 +1,7 @@
1
- import { mkdir, rename, rm, writeFile } from "node:fs/promises"
1
+ import { createWriteStream } from "node:fs"
2
+ import { mkdir, rename, rm } from "node:fs/promises"
2
3
  import { dirname, isAbsolute, relative, resolve } from "node:path"
4
+ import { Writable } from "node:stream"
3
5
  import { fileURLToPath } from "node:url"
4
6
 
5
7
  import type { APIRoute } from "astro"
@@ -26,16 +28,17 @@ export const POST: APIRoute = async ({ request }) => {
26
28
  ) {
27
29
  throw new Error("Path escapes project root.")
28
30
  }
31
+ const { body } = request
32
+ if (!body) {
33
+ throw new Error("Request body missing.")
34
+ }
29
35
 
30
36
  const targetDir = dirname(targetPath)
31
37
  await mkdir(targetDir, { recursive: true })
32
38
 
33
- const body = await request.text()
34
- const timestamp = Date.now()
35
- const tmpPath = `${targetPath}.tmp-${timestamp}`
39
+ const tmpPath = `${targetPath}.tmp-${Date.now()}`
36
40
  try {
37
- const tmpPath = `${targetPath}.tmp-${Date.now()}`
38
- await writeFile(tmpPath, body, "utf-8")
41
+ await body.pipeTo(Writable.toWeb(createWriteStream(tmpPath)))
39
42
  await rename(tmpPath, targetPath)
40
43
  } finally {
41
44
  await rm(tmpPath, { force: true }).catch(() => {})
@@ -0,0 +1,36 @@
1
+ import { type Control, type FieldValues, type Path } from "react-hook-form"
2
+
3
+ import ErrorMessage from "./atoms/ErrorMessage"
4
+ import Hint from "./atoms/Hint"
5
+ import Label from "./atoms/Label"
6
+ import { useFieldError } from "./hooks/use-field-error"
7
+
8
+ export default function Input<TFieldValues extends FieldValues>({
9
+ name,
10
+ label,
11
+ hint,
12
+ control,
13
+ type = "text",
14
+ }: {
15
+ name: Path<TFieldValues>
16
+ label: string
17
+ hint?: string
18
+ control: Control<TFieldValues>
19
+ type?: "text" | "date"
20
+ }) {
21
+ const hasError = !!useFieldError({ control, name })
22
+ return (
23
+ <div key={name} className="flex w-full flex-col">
24
+ <Label for={name} label={label} />
25
+ <input
26
+ className={`dy-input dy-input-bordered ${hasError ? "dy-input-error" : ""}`}
27
+ type={type}
28
+ id={name}
29
+ aria-invalid={hasError}
30
+ {...control.register(name)}
31
+ />
32
+ <ErrorMessage name={name} control={control} />
33
+ <Hint hint={hint} />
34
+ </div>
35
+ )
36
+ }
@@ -0,0 +1,43 @@
1
+ import type { Control, FieldValues, Path } from "react-hook-form"
2
+
3
+ import { useI18n } from "../../../i18n/react/useI18n"
4
+ import ErrorMessage from "./atoms/ErrorMessage"
5
+ import Hint from "./atoms/Hint"
6
+ import Label from "./atoms/Label"
7
+ import { useFieldError } from "./hooks/use-field-error"
8
+
9
+ export default function Select<TFieldValues extends FieldValues>({
10
+ name,
11
+ label,
12
+ control,
13
+ hint,
14
+ options,
15
+ }: {
16
+ name: Path<TFieldValues>
17
+ label: string
18
+ hint?: string
19
+ control: Control<TFieldValues>
20
+ options: { id: string; label?: string }[]
21
+ }) {
22
+ const { t } = useI18n()
23
+ const hasError = !!useFieldError({ control, name })
24
+ return (
25
+ <div key={name} className="flex w-full flex-col">
26
+ <Label for={name} label={label} />
27
+ <select
28
+ {...control.register(name)}
29
+ id={name}
30
+ aria-invalid={hasError}
31
+ className={`dy-select dy-select-bordered ${hasError ? "dy-select-error" : ""}"`}
32
+ >
33
+ {options.map(({ id, label }) => (
34
+ <option key={id} value={id}>
35
+ {label ? t(label) : id}
36
+ </option>
37
+ ))}
38
+ </select>
39
+ <ErrorMessage name={name} control={control} />
40
+ <Hint hint={hint} />
41
+ </div>
42
+ )
43
+ }