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.
- package/CHANGELOG.md +6 -0
- package/__e2e__/admin.spec.ts +297 -100
- package/__e2e__/fixtures/basics/node_modules/.bin/astro +2 -2
- package/__e2e__/fixtures/basics/package.json +5 -5
- package/package.json +9 -8
- package/src/admin/components/form/Input.tsx +36 -0
- package/src/admin/components/form/Select.tsx +18 -14
- package/src/admin/components/form/SubmitButton.tsx +21 -16
- package/src/admin/components/form/atoms/ErrorMessage.tsx +34 -0
- package/src/admin/components/form/atoms/Hint.tsx +1 -1
- package/src/admin/components/form/atoms/Label.tsx +13 -6
- package/src/admin/components/form/atoms/Legend.tsx +10 -0
- package/src/admin/components/form/hooks/use-field-error.tsx +21 -0
- package/src/admin/i18n/translations/en.yml +3 -2
- package/src/admin/pages/media/EditForm.tsx +34 -66
- package/src/admin/pages/media/EditRoute.astro +7 -2
- package/src/admin/pages/media/fields/Authors.tsx +58 -0
- package/src/admin/pages/media/media-item-store.ts +9 -7
- package/src/admin/types/media-item.ts +5 -1
- package/src/components/HeroSection.astro +1 -1
- package/src/components/HighlightSection.astro +1 -1
- package/src/components/SearchInput.astro +1 -1
- package/src/i18n/react/i18n-context.ts +14 -12
- package/src/layouts/MarkdownPage.astro +1 -1
- package/src/layouts/components/Menu.astro +1 -1
- package/src/layouts/components/PageNavigation.astro +1 -1
- package/src/pages/details-page/components/main-details/OpenButton.astro +1 -1
- package/src/pages/search-page/components/LoadingSkeleton.tsx +1 -1
- package/src/pages/search-page/components/SearchFilter.tsx +1 -1
- package/src/pages/search-page/components/SearchListItem.tsx +1 -1
- package/src/pages/search-page/components/Select.tsx +1 -1
- package/src/admin/components/form/TextInput.tsx +0 -34
- package/src/admin/components/form/atoms/FieldErrors.tsx +0 -22
- package/src/admin/components/form/form-context.ts +0 -4
- 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
|
package/__e2e__/admin.spec.ts
CHANGED
|
@@ -1,113 +1,310 @@
|
|
|
1
|
-
import {
|
|
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("
|
|
8
|
-
page,
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
20
|
+
await page.getByRole("link", { name: "Faithful Freestyle" }).click()
|
|
21
|
+
await expect(
|
|
22
|
+
page.getByRole("heading", { name: "Faithful Freestyle" }),
|
|
23
|
+
).toBeVisible()
|
|
17
24
|
|
|
18
|
-
|
|
19
|
-
|
|
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
|
-
|
|
24
|
-
|
|
25
|
-
}) => {
|
|
26
|
-
|
|
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
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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("
|
|
95
|
-
page
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
7
|
+
"@astrojs/react": "^4.4.2",
|
|
8
8
|
"@astrojs/tailwind": "^6.0.2",
|
|
9
|
-
"@lightnet/decap-admin": "^3.1.
|
|
10
|
-
"astro": "^5.
|
|
11
|
-
"lightnet": "^3.10.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
61
|
-
"marked": "^16.4.
|
|
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.
|
|
67
|
+
"@types/node": "^22.19.0",
|
|
67
68
|
"@types/react": "^19.2.2",
|
|
68
69
|
"typescript": "^5.9.3",
|
|
69
|
-
"vitest": "^
|
|
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
|
|
4
|
+
import ErrorMessage from "./atoms/ErrorMessage"
|
|
3
5
|
import Hint from "./atoms/Hint"
|
|
4
6
|
import Label from "./atoms/Label"
|
|
5
|
-
import {
|
|
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
|
-
<
|
|
20
|
-
<Label label={label} />
|
|
25
|
+
<div key={name} className="flex w-full flex-col">
|
|
26
|
+
<Label for={name} label={label} />
|
|
21
27
|
<select
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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
|
-
<
|
|
39
|
+
<ErrorMessage name={name} control={control} />
|
|
36
40
|
<Hint hint={hint} />
|
|
37
|
-
</
|
|
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 {
|
|
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
|
-
|
|
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 {
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
|
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-
|
|
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({
|
|
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
|
-
<
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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 {
|
|
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
|
|
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
|
|
28
|
-
defaultValues:
|
|
29
|
-
|
|
30
|
-
|
|
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
|
-
|
|
50
|
-
|
|
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
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
-
<
|
|
48
|
+
<Select
|
|
69
49
|
name="type"
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
50
|
+
label="ln.type"
|
|
51
|
+
options={mediaTypes}
|
|
52
|
+
control={control}
|
|
73
53
|
/>
|
|
74
|
-
<
|
|
54
|
+
<Select
|
|
75
55
|
name="language"
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
56
|
+
label="ln.language"
|
|
57
|
+
options={languages}
|
|
58
|
+
control={control}
|
|
79
59
|
/>
|
|
80
|
-
<
|
|
60
|
+
<Authors control={control} />
|
|
61
|
+
<Input
|
|
81
62
|
name="dateCreated"
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
-
<
|
|
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
|
|
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={
|
|
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
|
|
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.
|
|
19
|
+
export type MediaItem = z.input<typeof mediaItemSchema>
|
|
@@ -52,7 +52,7 @@ const subtitleSizes = {
|
|
|
52
52
|
alt=""
|
|
53
53
|
/>
|
|
54
54
|
<div
|
|
55
|
-
class="
|
|
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="
|
|
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
|
|
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
|
-
|
|
25
|
-
const
|
|
26
|
-
|
|
27
|
-
|
|
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
|
-
|
|
30
|
-
|
|
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
|
|
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="
|
|
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="
|
|
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)}
|
|
@@ -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="
|
|
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
|
|
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="
|
|
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
|
|
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,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
|
-
})
|