lightnet 3.10.3 → 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 (35) hide show
  1. package/CHANGELOG.md +6 -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 +9 -8
  6. package/src/admin/components/form/Input.tsx +36 -0
  7. package/src/admin/components/form/Select.tsx +18 -14
  8. package/src/admin/components/form/SubmitButton.tsx +21 -16
  9. package/src/admin/components/form/atoms/ErrorMessage.tsx +34 -0
  10. package/src/admin/components/form/atoms/Hint.tsx +1 -1
  11. package/src/admin/components/form/atoms/Label.tsx +13 -6
  12. package/src/admin/components/form/atoms/Legend.tsx +10 -0
  13. package/src/admin/components/form/hooks/use-field-error.tsx +21 -0
  14. package/src/admin/i18n/translations/en.yml +3 -2
  15. package/src/admin/pages/media/EditForm.tsx +34 -66
  16. package/src/admin/pages/media/EditRoute.astro +7 -2
  17. package/src/admin/pages/media/fields/Authors.tsx +58 -0
  18. package/src/admin/pages/media/media-item-store.ts +9 -7
  19. package/src/admin/types/media-item.ts +5 -1
  20. package/src/components/HeroSection.astro +1 -1
  21. package/src/components/HighlightSection.astro +1 -1
  22. package/src/components/SearchInput.astro +1 -1
  23. package/src/i18n/react/i18n-context.ts +14 -12
  24. package/src/layouts/MarkdownPage.astro +1 -1
  25. package/src/layouts/components/Menu.astro +1 -1
  26. package/src/layouts/components/PageNavigation.astro +1 -1
  27. package/src/pages/details-page/components/main-details/OpenButton.astro +1 -1
  28. package/src/pages/search-page/components/LoadingSkeleton.tsx +1 -1
  29. package/src/pages/search-page/components/SearchFilter.tsx +1 -1
  30. package/src/pages/search-page/components/SearchListItem.tsx +1 -1
  31. package/src/pages/search-page/components/Select.tsx +1 -1
  32. package/src/admin/components/form/TextInput.tsx +0 -34
  33. package/src/admin/components/form/atoms/FieldErrors.tsx +0 -22
  34. package/src/admin/components/form/form-context.ts +0 -4
  35. package/src/admin/components/form/index.ts +0 -18
package/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
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
+
3
9
  ## 3.10.3
4
10
 
5
11
  ### 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.3",
6
+ "version": "3.10.4",
7
7
  "repository": {
8
8
  "type": "git",
9
9
  "url": "https://github.com/LightNetDev/lightnet",
@@ -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"
@@ -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
+ }
@@ -1,30 +1,34 @@
1
+ import type { Control, FieldValues, Path } from "react-hook-form"
2
+
1
3
  import { useI18n } from "../../../i18n/react/useI18n"
2
- import { FieldErrors } from "./atoms/FieldErrors"
4
+ import ErrorMessage from "./atoms/ErrorMessage"
3
5
  import Hint from "./atoms/Hint"
4
6
  import Label from "./atoms/Label"
5
- import { useFieldContext } from "./form-context"
7
+ import { useFieldError } from "./hooks/use-field-error"
6
8
 
7
- export default function Select({
9
+ export default function Select<TFieldValues extends FieldValues>({
10
+ name,
8
11
  label,
12
+ control,
9
13
  hint,
10
14
  options,
11
15
  }: {
16
+ name: Path<TFieldValues>
12
17
  label: string
13
18
  hint?: string
19
+ control: Control<TFieldValues>
14
20
  options: { id: string; label?: string }[]
15
21
  }) {
16
- const field = useFieldContext<string>()
17
22
  const { t } = useI18n()
23
+ const hasError = !!useFieldError({ control, name })
18
24
  return (
19
- <label className="dy-form-control w-full">
20
- <Label label={label} />
25
+ <div key={name} className="flex w-full flex-col">
26
+ <Label for={name} label={label} />
21
27
  <select
22
- id={field.name}
23
- name={field.name}
24
- value={field.state.value}
25
- onChange={(e) => field.handleChange(e.target.value)}
26
- onBlur={field.handleBlur}
27
- className={`dy-select dy-select-bordered ${field.state.meta.errors.length ? "dy-select-error" : ""}"`}
28
+ {...control.register(name)}
29
+ id={name}
30
+ aria-invalid={hasError}
31
+ className={`dy-select dy-select-bordered ${hasError ? "dy-select-error" : ""}"`}
28
32
  >
29
33
  {options.map(({ id, label }) => (
30
34
  <option key={id} value={id}>
@@ -32,8 +36,8 @@ export default function Select({
32
36
  </option>
33
37
  ))}
34
38
  </select>
35
- <FieldErrors meta={field.state.meta} />
39
+ <ErrorMessage name={name} control={control} />
36
40
  <Hint hint={hint} />
37
- </label>
41
+ </div>
38
42
  )
39
43
  }
@@ -1,9 +1,9 @@
1
- import { useStore } from "@tanstack/react-form"
2
1
  import { useEffect, useRef, useState } from "react"
2
+ import { type Control, useFormState } from "react-hook-form"
3
3
 
4
4
  import Icon from "../../../components/Icon"
5
5
  import { useI18n } from "../../../i18n/react/useI18n"
6
- import { useFormContext } from "./form-context"
6
+ import type { MediaItem } from "../../types/media-item"
7
7
 
8
8
  const SUCCESS_DURATION_MS = 2000
9
9
 
@@ -30,25 +30,30 @@ const icons = {
30
30
  error: "mdi--error-outline",
31
31
  } as const
32
32
 
33
- export default function SubmitButton() {
34
- const form = useFormContext()
33
+ export default function SubmitButton({
34
+ control,
35
+ className,
36
+ }: {
37
+ control: Control<MediaItem>
38
+ className?: string
39
+ }) {
35
40
  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]}`
41
+ const { isValid, isSubmitting, isSubmitSuccessful, submitCount } =
42
+ useFormState({
43
+ control,
44
+ })
45
+
46
+ const buttonState = useButtonState(isSubmitSuccessful, submitCount)
47
+ const buttonClass = `${baseButtonClass} ${buttonStateClasses[buttonState]} ${className}`
47
48
  const label = buttonLabels[buttonState]
48
49
  const icon = icons[buttonState]
49
50
 
50
51
  return (
51
- <button className={buttonClass} type="submit" disabled={isSubmitting}>
52
+ <button
53
+ className={buttonClass}
54
+ type="submit"
55
+ disabled={!isValid || isSubmitting}
56
+ >
52
57
  {icon && <Icon className={icon} ariaLabel="" />}
53
58
  {t(label)}
54
59
  </button>
@@ -0,0 +1,34 @@
1
+ import { ErrorMessage as RhfErrorMessage } from "@hookform/error-message"
2
+ import { type Control, useFormState } from "react-hook-form"
3
+
4
+ import { useI18n } from "../../../../i18n/react/useI18n"
5
+
6
+ export default function ErrorMessage({
7
+ name,
8
+ control,
9
+ }: {
10
+ name: string
11
+ control: Control<any>
12
+ }) {
13
+ const { t } = useI18n()
14
+ const { errors } = useFormState({ control, name, exact: true })
15
+ return (
16
+ <RhfErrorMessage
17
+ errors={errors}
18
+ name={name}
19
+ render={({ message }) => {
20
+ if (!message) {
21
+ return null
22
+ }
23
+ return (
24
+ <p
25
+ className="my-2 flex flex-col gap-1 text-sm text-rose-800"
26
+ role="alert"
27
+ >
28
+ {t(message)}
29
+ </p>
30
+ )
31
+ }}
32
+ />
33
+ )
34
+ }
@@ -3,7 +3,7 @@ import { useI18n } from "../../../../i18n/react/useI18n"
3
3
  export default function Hint({ hint }: { hint?: string }) {
4
4
  const { t } = useI18n()
5
5
  return (
6
- <div className="flex h-8 w-full items-center justify-end">
6
+ <div className="flex h-12 w-full items-start justify-end p-2">
7
7
  {hint && <span className="dy-label-text-alt">{t(hint)}</span>}
8
8
  </div>
9
9
  )
@@ -1,12 +1,19 @@
1
1
  import { useI18n } from "../../../../i18n/react/useI18n"
2
2
 
3
- export default function Label({ label }: { label: string }) {
3
+ export default function Label({
4
+ label,
5
+ for: htmlFor,
6
+ }: {
7
+ label: string
8
+ for: string
9
+ }) {
4
10
  const { t } = useI18n()
5
11
  return (
6
- <div className="dy-label">
7
- <span className="text-sm font-bold uppercase text-gray-600">
8
- {t(label)}
9
- </span>
10
- </div>
12
+ <label
13
+ htmlFor={htmlFor}
14
+ className="pb-2 text-sm font-bold uppercase text-gray-600"
15
+ >
16
+ {t(label)}
17
+ </label>
11
18
  )
12
19
  }
@@ -0,0 +1,10 @@
1
+ import { useI18n } from "../../../../i18n/react/useI18n"
2
+
3
+ export default function Label({ label }: { label: string }) {
4
+ const { t } = useI18n()
5
+ return (
6
+ <legend className="pb-2 text-sm font-bold uppercase text-gray-600">
7
+ {t(label)}
8
+ </legend>
9
+ )
10
+ }
@@ -0,0 +1,21 @@
1
+ import { type Control, type FieldError, useFormState } from "react-hook-form"
2
+
3
+ export function useFieldError({
4
+ control,
5
+ name,
6
+ index,
7
+ }: {
8
+ control: Control<any>
9
+ name: string
10
+ index?: number
11
+ }) {
12
+ const { errors } = useFormState({ control, name, exact: true })
13
+ const error = errors[name]
14
+ if (!error) {
15
+ return undefined
16
+ }
17
+ if (Array.isArray(error)) {
18
+ return index !== undefined ? (error[index] as FieldError) : undefined
19
+ }
20
+ return error as FieldError
21
+ }
@@ -2,15 +2,16 @@ ln.admin.edit: Edit
2
2
  ln.admin.save: Save
3
3
  ln.admin.saved: Saved
4
4
  ln.admin.failed: Failed
5
+ ln.admin.remove: Remove
6
+ ln.admin.add-author: Add Author
5
7
  ln.admin.edit-media-item: Edit media item
6
8
  ln.admin.back-to-details-page: Back to details page
7
9
  ln.admin.title: Title
8
10
  ln.admin.common-id: Common ID
11
+ ln.admin.authors: Authors
9
12
  ln.admin.created-on: Created on
10
13
  ln.admin.created-on-hint: When has this item been created on this library?
11
14
  ln.admin.common-id-hint: The english title, all lowercase, words separated with hyphens.
12
- ln.admin.toast.invalid-data.title: Invalid form data
13
- ln.admin.toast.invalid-data.hint: Check the fields and try again.
14
15
  ln.admin.errors.non-empty-string: String must contain at least 1 character(s)
15
16
  ln.admin.errors.invalid-date: Invalid date
16
17
  ln.admin.errors.required: Required field
@@ -1,14 +1,16 @@
1
- import { revalidateLogic } from "@tanstack/react-form"
1
+ import { zodResolver } from "@hookform/resolvers/zod"
2
+ import { useForm } from "react-hook-form"
2
3
 
3
- import { showToastById } from "../../../components/showToast"
4
- import Toast from "../../../components/Toast"
5
4
  import {
6
5
  createI18n,
7
6
  type I18nConfig,
8
7
  I18nContext,
9
8
  } from "../../../i18n/react/i18n-context"
10
- import { useAppForm } from "../../components/form"
9
+ import Input from "../../components/form/Input"
10
+ import Select from "../../components/form/Select"
11
+ import SubmitButton from "../../components/form/SubmitButton"
11
12
  import { type MediaItem, mediaItemSchema } from "../../types/media-item"
13
+ import Authors from "./fields/Authors"
12
14
  import { updateMediaItem } from "./media-item-store"
13
15
 
14
16
  export default function EditForm({
@@ -24,81 +26,47 @@ export default function EditForm({
24
26
  mediaTypes: { id: string; label: string }[]
25
27
  languages: { id: string; label: string }[]
26
28
  }) {
27
- const form = useAppForm({
28
- defaultValues: { ...mediaItem },
29
- validators: {
30
- onDynamic: mediaItemSchema,
31
- },
32
- validationLogic: revalidateLogic({
33
- mode: "blur",
34
- modeAfterSubmission: "change",
35
- }),
36
- onSubmit: async ({ value }) => {
37
- await updateMediaItem(mediaId, { ...mediaItem, ...value })
38
- },
39
- onSubmitInvalid: () => {
40
- showToastById("invalid-form-data-toast")
41
- },
29
+ const { handleSubmit, control } = useForm({
30
+ defaultValues: mediaItem,
31
+ mode: "onTouched",
32
+ resolver: zodResolver(mediaItemSchema),
42
33
  })
34
+ const onSubmit = handleSubmit(
35
+ async (data) => await updateMediaItem(mediaId, { ...mediaItem, ...data }),
36
+ )
43
37
  const i18n = createI18n(i18nConfig)
44
- const { t } = i18n
45
-
46
38
  return (
47
39
  <I18nContext.Provider value={i18n}>
48
- <form
49
- onSubmit={(e) => {
50
- e.preventDefault()
51
- form.handleSubmit()
52
- }}
53
- className="flex flex-col items-start"
54
- >
55
- <form.AppField
56
- name="title"
57
- children={(field) => <field.TextInput label="ln.admin.title" />}
58
- />
59
- <form.AppField
40
+ <form onSubmit={onSubmit}>
41
+ <Input name="title" label="ln.admin.title" control={control} />
42
+ <Input
60
43
  name="commonId"
61
- children={(field) => (
62
- <field.TextInput
63
- label="ln.admin.common-id"
64
- hint="ln.admin.common-id-hint"
65
- />
66
- )}
44
+ label="ln.admin.common-id"
45
+ hint="ln.admin.common-id-hint"
46
+ control={control}
67
47
  />
68
- <form.AppField
48
+ <Select
69
49
  name="type"
70
- children={(field) => (
71
- <field.Select label="ln.type" options={mediaTypes} />
72
- )}
50
+ label="ln.type"
51
+ options={mediaTypes}
52
+ control={control}
73
53
  />
74
- <form.AppField
54
+ <Select
75
55
  name="language"
76
- children={(field) => (
77
- <field.Select label="ln.language" options={languages} />
78
- )}
56
+ label="ln.language"
57
+ options={languages}
58
+ control={control}
79
59
  />
80
- <form.AppField
60
+ <Authors control={control} />
61
+ <Input
81
62
  name="dateCreated"
82
- children={(field) => (
83
- <field.TextInput
84
- type="date"
85
- label="ln.admin.created-on"
86
- hint="ln.admin.created-on-hint"
87
- />
88
- )}
63
+ label="ln.admin.created-on"
64
+ hint="ln.admin.created-on-hint"
65
+ type="date"
66
+ control={control}
89
67
  />
90
68
 
91
- <div className="mt-8">
92
- <form.AppForm>
93
- <form.SubmitButton />
94
- <Toast id="invalid-form-data-toast" variant="error">
95
- <div className="font-bold text-gray-700">
96
- {t("ln.admin.toast.invalid-data.title")}
97
- </div>
98
- {t("ln.admin.toast.invalid-data.hint")}
99
- </Toast>
100
- </form.AppForm>
101
- </div>
69
+ <SubmitButton className="mt-8" control={control} />
102
70
  </form>
103
71
  </I18nContext.Provider>
104
72
  )
@@ -18,7 +18,12 @@ export const getStaticPaths = (async () => {
18
18
  }) satisfies GetStaticPaths
19
19
 
20
20
  const { mediaId } = Astro.params
21
- const mediaItemEntry = await getRawMediaItem(mediaId)
21
+ const { data: mediaItem } = await getRawMediaItem(mediaId)
22
+
23
+ const formData = {
24
+ ...mediaItem,
25
+ authors: mediaItem.authors?.map((value) => ({ value })) ?? [],
26
+ }
22
27
 
23
28
  const i18nConfig = prepareI18nConfig(Astro.locals.i18n, [
24
29
  "ln.admin.*",
@@ -50,7 +55,7 @@ const languages = config.languages.map(({ code, label }) => ({
50
55
 
51
56
  <EditForm
52
57
  mediaId={mediaId}
53
- mediaItem={mediaItemEntry.data}
58
+ mediaItem={formData}
54
59
  i18nConfig={i18nConfig}
55
60
  mediaTypes={mediaTypes}
56
61
  languages={languages}
@@ -0,0 +1,58 @@
1
+ import { type Control, useFieldArray } from "react-hook-form"
2
+
3
+ import Icon from "../../../../components/Icon"
4
+ import { useI18n } from "../../../../i18n/react/useI18n"
5
+ import ErrorMessage from "../../../components/form/atoms/ErrorMessage"
6
+ import Hint from "../../../components/form/atoms/Hint"
7
+ import Legend from "../../../components/form/atoms/Legend"
8
+ import type { MediaItem } from "../../../types/media-item"
9
+
10
+ export default function Authors({ control }: { control: Control<MediaItem> }) {
11
+ const { fields, append, remove } = useFieldArray({
12
+ name: "authors",
13
+ control,
14
+ })
15
+ const { t } = useI18n()
16
+ return (
17
+ <fieldset key="authors">
18
+ <Legend label="ln.admin.authors" />
19
+ <div className="flex w-full flex-col divide-y divide-gray-300 rounded-lg border border-gray-300">
20
+ {fields.map((author, index) => (
21
+ <div className="p-2" key={author.id}>
22
+ <div className="flex w-full items-center gap-2">
23
+ <input
24
+ className="dy-input dy-input-sm grow"
25
+ {...control.register(`authors.${index}.value`)}
26
+ />
27
+ <button
28
+ className="flex items-center p-2 text-gray-600 hover:text-gray-900"
29
+ type="button"
30
+ onClick={() => remove(index)}
31
+ >
32
+ <Icon
33
+ className="mdi--remove"
34
+ ariaLabel={t("ln.admin.remove")}
35
+ />
36
+ </button>
37
+ </div>
38
+ <ErrorMessage name={`authors.${index}.value`} control={control} />
39
+ </div>
40
+ ))}
41
+ <button
42
+ type="button"
43
+ className="p-4 text-sm font-bold text-gray-600 hover:bg-gray-200"
44
+ onClick={() => {
45
+ append(
46
+ { value: "" },
47
+ { focusName: `authors.${fields.length}.value` },
48
+ )
49
+ }}
50
+ >
51
+ {t("ln.admin.add-author")}
52
+ </button>
53
+ </div>
54
+ <ErrorMessage name="authors" control={control} />
55
+ <Hint />
56
+ </fieldset>
57
+ )
58
+ }
@@ -1,11 +1,13 @@
1
- import { type MediaItem, mediaItemSchema } from "../../types/media-item"
1
+ import { type MediaItem } from "../../types/media-item"
2
2
  import { writeJson } from "./file-system"
3
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
4
  export const updateMediaItem = async (id: string, item: MediaItem) => {
10
- return writeJson(`/src/content/media/${id}.json`, item)
5
+ return writeJson(`/src/content/media/${id}.json`, mapToContentSchema(item))
6
+ }
7
+
8
+ const mapToContentSchema = (item: MediaItem) => {
9
+ return {
10
+ ...item,
11
+ authors: item.authors.map(({ value }) => value),
12
+ }
11
13
  }
@@ -9,7 +9,11 @@ export const mediaItemSchema = z.object({
9
9
  title: z.string().nonempty(NON_EMPTY_STRING),
10
10
  type: z.string().nonempty(REQUIRED),
11
11
  language: z.string().nonempty(REQUIRED),
12
+ authors: z
13
+ .object({ value: z.string().nonempty(NON_EMPTY_STRING) })
14
+ .array()
15
+ .min(1),
12
16
  dateCreated: z.string().date(INVALID_DATE),
13
17
  })
14
18
 
15
- export type MediaItem = z.infer<typeof mediaItemSchema>
19
+ export type MediaItem = z.input<typeof mediaItemSchema>
@@ -52,7 +52,7 @@ const subtitleSizes = {
52
52
  alt=""
53
53
  />
54
54
  <div
55
- class="bg-gradient-radial absolute top-0 flex h-full w-full flex-col items-center justify-center from-black/30 to-black/40 p-4 text-center text-gray-50"
55
+ class="absolute top-0 flex h-full w-full flex-col items-center justify-center bg-gradient-radial from-black/30 to-black/40 p-4 text-center text-gray-50"
56
56
  class:list={[className]}
57
57
  >
58
58
  {
@@ -53,7 +53,7 @@ const { image, id, title, text, link, className, titleClass, textClass } =
53
53
  {
54
54
  link && (
55
55
  <a
56
- class="bg-primary hover:bg-primary/85 inline-flex items-center justify-center gap-2 rounded-2xl px-6 py-3 text-sm font-bold uppercase text-gray-50 shadow-sm hover:text-gray-100"
56
+ class="inline-flex items-center justify-center gap-2 rounded-2xl bg-primary px-6 py-3 text-sm font-bold uppercase text-gray-50 shadow-sm hover:bg-primary/85 hover:text-gray-100"
57
57
  href={link.href}
58
58
  >
59
59
  {link.text}
@@ -12,7 +12,7 @@ const { t } = Astro.locals.i18n
12
12
  action={`/${Astro.currentLocale}/media`}
13
13
  method="get"
14
14
  role="search"
15
- class="dy-join group w-full rounded-2xl shadow-sm outline-2 outline-offset-2 outline-gray-400 group-focus-within:outline"
15
+ class="group dy-join w-full rounded-2xl shadow-sm outline-2 outline-offset-2 outline-gray-400 group-focus-within:outline"
16
16
  class:list={[Astro.props.className]}
17
17
  >
18
18
  <input
@@ -1,4 +1,4 @@
1
- import { createContext } from "react"
1
+ import { createContext, useMemo } from "react"
2
2
 
3
3
  export type I18n = {
4
4
  t: (key: string) => string
@@ -21,16 +21,18 @@ export const createI18n = ({
21
21
  currentLocale,
22
22
  direction,
23
23
  }: I18nConfig) => {
24
- const t = (key: string) => {
25
- const value = translations[key]
26
- if (value) {
27
- return value
24
+ return useMemo(() => {
25
+ const t = (key: string) => {
26
+ const value = translations[key]
27
+ if (value) {
28
+ return value
29
+ }
30
+ if (key.match(/^(?:ln|x)\../i)) {
31
+ console.error(`Missing translation for key ${key}`)
32
+ return ""
33
+ }
34
+ return key
28
35
  }
29
- if (key.match(/^(?:ln|x)\../i)) {
30
- console.error(`Missing translation for key ${key}`)
31
- return ""
32
- }
33
- return key
34
- }
35
- return { t, currentLocale, direction }
36
+ return { t, currentLocale, direction }
37
+ }, [])
36
38
  }
@@ -9,7 +9,7 @@ type Props = {
9
9
 
10
10
  <Page>
11
11
  <article
12
- class="prose prose-img:rounded-md mx-auto mt-8 max-w-screen-md px-4 sm:mt-16 md:px-8"
12
+ class="prose mx-auto mt-8 max-w-screen-md px-4 prose-img:rounded-md sm:mt-16 md:px-8"
13
13
  class:list={[Astro.props.className]}
14
14
  >
15
15
  <slot />
@@ -14,7 +14,7 @@ const { icon, label } = Astro.props
14
14
  role="button"
15
15
  tabindex="0"
16
16
  aria-label={Astro.locals.i18n.t(label)}
17
- class="hover:text-primary flex cursor-pointer rounded-md p-3 text-gray-600"
17
+ class="flex cursor-pointer rounded-md p-3 text-gray-600 hover:text-primary"
18
18
  >
19
19
  <Icon className={icon} ariaLabel="" />
20
20
  </div>
@@ -35,7 +35,7 @@ const t = Astro.locals.i18n.t
35
35
  {
36
36
  !config.searchPage?.hideHeaderSearchIcon && (
37
37
  <a
38
- class="hover:text-primary flex p-3 text-gray-600"
38
+ class="flex p-3 text-gray-600 hover:text-primary"
39
39
  aria-label={t("ln.search.title")}
40
40
  data-astro-prefetch="viewport"
41
41
  href={searchPagePath(Astro.currentLocale)}
@@ -24,7 +24,7 @@ const content = createContentMetadata(item.data.content[0])
24
24
  content.isExternal && (
25
25
  <Icon
26
26
  ariaLabel={Astro.locals.i18n.t("ln.external-link")}
27
- className={`mdi--external-link shrink-0`}
27
+ className={`shrink-0 mdi--external-link`}
28
28
  />
29
29
  )
30
30
  }
@@ -12,7 +12,7 @@ export default function LoadingSkeleton() {
12
12
  <div className="h-4 w-5/6 rounded-md bg-gray-200 md:h-6"></div>
13
13
  </div>
14
14
  <Icon
15
- className="mdi--chevron-right my-auto me-4 ms-2 hidden shrink-0 text-2xl text-gray-300 sm:block"
15
+ className="my-auto me-4 ms-2 hidden shrink-0 text-2xl text-gray-300 mdi--chevron-right sm:block"
16
16
  flipIcon={direction === "rtl"}
17
17
  ariaLabel=""
18
18
  />
@@ -57,7 +57,7 @@ export default function SearchFilter({
57
57
  onInput={(e) => debouncedSetSearch(e.currentTarget.value)}
58
58
  onKeyDown={(e) => e.key === "Enter" && searchInput.current?.blur()}
59
59
  />
60
- <Icon className="mdi--magnify text-xl" ariaLabel="" />
60
+ <Icon className="text-xl mdi--magnify" ariaLabel="" />
61
61
  </label>
62
62
  <div className="mb-8 grid grid-cols-1 gap-2 sm:grid-cols-3 sm:gap-6 md:mb-10">
63
63
  {languageFilterEnabled && (
@@ -99,7 +99,7 @@ export default function SearchListItem({
99
99
  </div>
100
100
  </div>
101
101
  <Icon
102
- className="mdi--chevron-right md:group-hover:text-primary my-auto me-4 ms-2 hidden shrink-0 text-2xl text-gray-300 sm:block"
102
+ className="my-auto me-4 ms-2 hidden shrink-0 text-2xl text-gray-300 mdi--chevron-right sm:block md:group-hover:text-primary"
103
103
  flipIcon={direction === "rtl"}
104
104
  ariaLabel=""
105
105
  />
@@ -17,7 +17,7 @@ export default function Select({
17
17
  {label}
18
18
  </span>
19
19
  <select
20
- className="dy-select dy-select-bordered sm:dy-select-sm w-full rounded-xl"
20
+ className="dy-select dy-select-bordered w-full rounded-xl sm:dy-select-sm"
21
21
  value={initialValue}
22
22
  onChange={(e) => valueChange(e.currentTarget.value)}
23
23
  >
@@ -1,34 +0,0 @@
1
- import { FieldErrors } from "./atoms/FieldErrors"
2
- import Hint from "./atoms/Hint"
3
- import Label from "./atoms/Label"
4
- import { useFieldContext } from "./form-context"
5
-
6
- export default function TextInput({
7
- label,
8
- hint,
9
- type = "text",
10
- }: {
11
- label: string
12
- hint?: string
13
- type?: "text" | "date"
14
- }) {
15
- const field = useFieldContext<string>()
16
- return (
17
- <>
18
- <label className="dy-form-control w-full">
19
- <Label label={label} />
20
- <input
21
- id={field.name}
22
- name={field.name}
23
- type={type}
24
- value={field.state.value}
25
- onChange={(e) => field.handleChange(e.target.value)}
26
- onBlur={field.handleBlur}
27
- className={`dy-input dy-input-bordered ${field.state.meta.errors.length ? "dy-input-error" : ""}`}
28
- />
29
- <FieldErrors meta={field.state.meta} />
30
- <Hint hint={hint} />
31
- </label>
32
- </>
33
- )
34
- }
@@ -1,22 +0,0 @@
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
- }
@@ -1,4 +0,0 @@
1
- import { createFormHookContexts } from "@tanstack/react-form"
2
-
3
- export const { fieldContext, useFieldContext, formContext, useFormContext } =
4
- createFormHookContexts()
@@ -1,18 +0,0 @@
1
- import { createFormHook } from "@tanstack/react-form"
2
-
3
- import { fieldContext, formContext } from "./form-context"
4
- import Select from "./Select"
5
- import SubmitButton from "./SubmitButton"
6
- import TextInput from "./TextInput"
7
-
8
- export const { useAppForm, withForm } = createFormHook({
9
- fieldComponents: {
10
- TextInput,
11
- Select,
12
- },
13
- formComponents: {
14
- SubmitButton,
15
- },
16
- fieldContext,
17
- formContext,
18
- })