lightnet 3.10.4 → 3.10.5

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 (36) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/__e2e__/admin.spec.ts +71 -12
  3. package/__e2e__/fixtures/basics/node_modules/.bin/astro +2 -2
  4. package/__e2e__/fixtures/basics/package.json +2 -2
  5. package/package.json +4 -5
  6. package/src/admin/components/form/DynamicArray.tsx +74 -0
  7. package/src/admin/components/form/Input.tsx +5 -5
  8. package/src/admin/components/form/Select.tsx +8 -10
  9. package/src/admin/components/form/SubmitButton.tsx +9 -12
  10. package/src/admin/components/form/atoms/ErrorMessage.tsx +7 -28
  11. package/src/admin/components/form/atoms/Hint.tsx +2 -2
  12. package/src/admin/components/form/atoms/Label.tsx +5 -1
  13. package/src/admin/components/form/atoms/Legend.tsx +12 -2
  14. package/src/admin/components/form/hooks/use-field-error.tsx +3 -11
  15. package/src/admin/i18n/translations/en.yml +15 -5
  16. package/src/admin/pages/media/EditForm.tsx +20 -4
  17. package/src/admin/pages/media/EditRoute.astro +28 -9
  18. package/src/admin/pages/media/fields/Authors.tsx +35 -50
  19. package/src/admin/pages/media/fields/Categories.tsx +64 -0
  20. package/src/admin/pages/media/fields/Collections.tsx +103 -0
  21. package/src/admin/pages/media/media-item-store.ts +6 -1
  22. package/src/admin/types/media-item.ts +34 -2
  23. package/src/components/CategoriesSection.astro +2 -2
  24. package/src/components/MediaGallerySection.astro +3 -3
  25. package/src/components/MediaList.astro +2 -2
  26. package/src/content/get-categories.ts +18 -3
  27. package/src/i18n/resolve-language.ts +1 -1
  28. package/src/layouts/Page.astro +3 -2
  29. package/src/layouts/components/LanguagePicker.astro +1 -1
  30. package/src/pages/details-page/components/more-details/Languages.astro +2 -2
  31. package/src/pages/search-page/components/SearchFilter.astro +7 -7
  32. package/src/pages/search-page/components/SearchFilter.tsx +4 -4
  33. package/src/pages/search-page/components/SearchList.astro +4 -4
  34. package/src/pages/search-page/components/SearchListItem.tsx +4 -4
  35. package/src/pages/search-page/components/Select.tsx +3 -3
  36. package/src/pages/search-page/hooks/use-search.ts +4 -4
package/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # lightnet
2
2
 
3
+ ## 3.10.5
4
+
5
+ ### Patch Changes
6
+
7
+ - [#331](https://github.com/LightNetDev/LightNet/pull/331) [`cdacfdf`](https://github.com/LightNetDev/LightNet/commit/cdacfdfca311469248cb7d1ac0b46f92d9fd5521) Thanks [@smn-cds](https://github.com/smn-cds)! - Experimenatl Admin UI: support editing categories and collections
8
+
3
9
  ## 3.10.4
4
10
 
5
11
  ### Patch Changes
@@ -143,6 +143,14 @@ test.describe("Media item edit page", () => {
143
143
  return () => writeFileRequestPromise
144
144
  }
145
145
 
146
+ const getPublishButton = (page: Page) =>
147
+ page.getByRole("button", { name: "Publish Changes" }).first()
148
+
149
+ const expectPublishedMessage = (page: Page) =>
150
+ expect(
151
+ page.getByRole("button", { name: "Published" }).first(),
152
+ ).toBeVisible()
153
+
146
154
  test("should edit title", async ({ page, startLightnet }) => {
147
155
  const ln = await startLightnet()
148
156
 
@@ -155,7 +163,7 @@ test.describe("Media item edit page", () => {
155
163
  await expect(titleInput).toHaveValue("Faithful Freestyle")
156
164
  await titleInput.fill(updatedTitle)
157
165
 
158
- const saveButton = page.getByRole("button", { name: "Save" })
166
+ const saveButton = getPublishButton(page)
159
167
  await expect(saveButton).toBeEnabled()
160
168
  await saveButton.click()
161
169
 
@@ -171,7 +179,7 @@ test.describe("Media item edit page", () => {
171
179
  ...expectedMediaItem,
172
180
  title: updatedTitle,
173
181
  })
174
- await expect(page.getByRole("button", { name: "Saved" })).toBeVisible()
182
+ await expectPublishedMessage(page)
175
183
  })
176
184
 
177
185
  test("Should update media type", async ({ page, startLightnet }) => {
@@ -184,7 +192,7 @@ test.describe("Media item edit page", () => {
184
192
  await expect(typeSelect).toHaveValue("book")
185
193
  await typeSelect.selectOption("video")
186
194
 
187
- const saveButton = page.getByRole("button", { name: "Save" })
195
+ const saveButton = getPublishButton(page)
188
196
  await expect(saveButton).toBeEnabled()
189
197
  await saveButton.click()
190
198
 
@@ -196,7 +204,7 @@ test.describe("Media item edit page", () => {
196
204
  ...expectedMediaItem,
197
205
  type: "video",
198
206
  })
199
- await expect(page.getByRole("button", { name: "Saved" })).toBeVisible()
207
+ await expectPublishedMessage(page)
200
208
  })
201
209
 
202
210
  test("Should update author name", async ({ page, startLightnet }) => {
@@ -211,7 +219,7 @@ test.describe("Media item edit page", () => {
211
219
  await expect(firstAuthorInput).toHaveValue("Sk8 Ministries")
212
220
  await firstAuthorInput.fill(updatedAuthor)
213
221
 
214
- const saveButton = page.getByRole("button", { name: "Save" })
222
+ const saveButton = getPublishButton(page)
215
223
  await expect(saveButton).toBeEnabled()
216
224
  await saveButton.click()
217
225
 
@@ -223,7 +231,7 @@ test.describe("Media item edit page", () => {
223
231
  ...expectedMediaItem,
224
232
  authors: [updatedAuthor],
225
233
  })
226
- await expect(page.getByRole("button", { name: "Saved" })).toBeVisible()
234
+ await expectPublishedMessage(page)
227
235
  })
228
236
 
229
237
  test("Should add author", async ({ page, startLightnet }) => {
@@ -239,7 +247,7 @@ test.describe("Media item edit page", () => {
239
247
  const additionalAuthor = "Tony Hawk"
240
248
  await newAuthorInput.fill(additionalAuthor)
241
249
 
242
- const saveButton = page.getByRole("button", { name: "Save" })
250
+ const saveButton = getPublishButton(page)
243
251
  await expect(saveButton).toBeEnabled()
244
252
  await saveButton.click()
245
253
 
@@ -251,7 +259,7 @@ test.describe("Media item edit page", () => {
251
259
  ...expectedMediaItem,
252
260
  authors: ["Sk8 Ministries", additionalAuthor],
253
261
  })
254
- await expect(page.getByRole("button", { name: "Saved" })).toBeVisible()
262
+ await expectPublishedMessage(page)
255
263
  })
256
264
 
257
265
  test("Should remove author", async ({ page, startLightnet }) => {
@@ -272,7 +280,7 @@ test.describe("Media item edit page", () => {
272
280
  })
273
281
  await removeButtons.first().click()
274
282
 
275
- const saveButton = page.getByRole("button", { name: "Save" })
283
+ const saveButton = getPublishButton(page)
276
284
  await expect(saveButton).toBeEnabled()
277
285
  await saveButton.click()
278
286
 
@@ -284,7 +292,7 @@ test.describe("Media item edit page", () => {
284
292
  ...expectedMediaItem,
285
293
  authors: [replacementAuthor],
286
294
  })
287
- await expect(page.getByRole("button", { name: "Saved" })).toBeVisible()
295
+ await expectPublishedMessage(page)
288
296
  })
289
297
 
290
298
  test("should show error message if common id is set empty", async ({
@@ -303,8 +311,59 @@ test.describe("Media item edit page", () => {
303
311
  await expect(
304
312
  page
305
313
  .getByRole("alert")
306
- .filter({ hasText: "String must contain at least 1 character(s)" }),
314
+ .filter({ hasText: "Please enter at least one character." }),
307
315
  ).toBeVisible()
308
- await expect(page.getByRole("button", { name: "Save" })).toBeDisabled()
316
+ })
317
+
318
+ test("should focus invalid field when submitting invalid form data", async ({
319
+ page,
320
+ startLightnet,
321
+ }) => {
322
+ const ln = await startLightnet()
323
+ await page.goto(ln.resolveURL("/en/admin/media/faithful-freestyle--en"))
324
+
325
+ const categoriesFieldset = page.getByRole("group", { name: "Categories" })
326
+ await page.getByRole("button", { name: "Add Category" }).click()
327
+ const newCategorySelect = categoriesFieldset.getByRole("combobox").last()
328
+ await expect(newCategorySelect).toHaveValue("")
329
+
330
+ // move focus away so the submission handler needs to return focus
331
+ await page.getByLabel("Title").click()
332
+
333
+ const saveButton = getPublishButton(page)
334
+ await saveButton.click()
335
+
336
+ await expect(
337
+ page.getByRole("alert").filter({ hasText: "This field is required." }),
338
+ ).toBeVisible()
339
+ await expect(newCategorySelect).toBeFocused()
340
+ })
341
+
342
+ test("should not allow assigning duplicate categories", async ({
343
+ page,
344
+ startLightnet,
345
+ }) => {
346
+ const ln = await startLightnet()
347
+ await page.goto(ln.resolveURL("/en/admin/media/faithful-freestyle--en"))
348
+
349
+ const categoriesFieldset = page.getByRole("group", { name: "Categories" })
350
+ await page.getByRole("button", { name: "Add Category" }).click()
351
+
352
+ const categorySelects = categoriesFieldset.getByRole("combobox")
353
+ const firstCategoryValue = await categorySelects.first().inputValue()
354
+ const duplicateCategorySelect = categorySelects.last()
355
+ await duplicateCategorySelect.selectOption(firstCategoryValue)
356
+
357
+ const publishButton = getPublishButton(page)
358
+ await publishButton.click()
359
+
360
+ const duplicateCategoryError = categoriesFieldset
361
+ .getByRole("alert")
362
+ .filter({ hasText: "Please choose a different value for each entry." })
363
+ await expect(duplicateCategoryError).toBeVisible()
364
+ await expect(duplicateCategorySelect).toHaveAttribute(
365
+ "aria-invalid",
366
+ "true",
367
+ )
309
368
  })
310
369
  })
@@ -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.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"
13
+ export NODE_PATH="/home/runner/work/LightNet/LightNet/node_modules/.pnpm/astro@5.15.5_@types+node@24.10.1_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.5_@types+node@24.10.1_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.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"
15
+ export NODE_PATH="/home/runner/work/LightNet/LightNet/node_modules/.pnpm/astro@5.15.5_@types+node@24.10.1_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.5_@types+node@24.10.1_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" "$@"
@@ -7,8 +7,8 @@
7
7
  "@astrojs/react": "^4.4.2",
8
8
  "@astrojs/tailwind": "^6.0.2",
9
9
  "@lightnet/decap-admin": "^3.1.4",
10
- "astro": "^5.15.4",
11
- "lightnet": "^3.10.3",
10
+ "astro": "^5.15.5",
11
+ "lightnet": "^3.10.4",
12
12
  "react": "^19.2.0",
13
13
  "react-dom": "^19.2.0",
14
14
  "sharp": "^0.34.5",
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.4",
6
+ "version": "3.10.5",
7
7
  "repository": {
8
8
  "type": "git",
9
9
  "url": "https://github.com/LightNetDev/lightnet",
@@ -47,7 +47,6 @@
47
47
  "dependencies": {
48
48
  "@astrojs/react": "^4.4.2",
49
49
  "@astrojs/tailwind": "^6.0.2",
50
- "@hookform/error-message": "^2.0.1",
51
50
  "@hookform/resolvers": "^5.2.2",
52
51
  "@iconify-json/mdi": "^1.2.3",
53
52
  "@iconify/tailwind": "^1.2.0",
@@ -57,15 +56,15 @@
57
56
  "embla-carousel": "^8.6.0",
58
57
  "embla-carousel-wheel-gestures": "^8.1.0",
59
58
  "fuse.js": "^7.1.0",
60
- "i18next": "^25.6.1",
59
+ "i18next": "^25.6.2",
61
60
  "marked": "^16.4.2",
62
61
  "react-hook-form": "^7.66.0",
63
62
  "yaml": "^2.8.1"
64
63
  },
65
64
  "devDependencies": {
66
65
  "@playwright/test": "^1.56.1",
67
- "@types/node": "^22.19.0",
68
- "@types/react": "^19.2.2",
66
+ "@types/node": "^22.19.1",
67
+ "@types/react": "^19.2.4",
69
68
  "typescript": "^5.9.3",
70
69
  "vitest": "^4.0.8"
71
70
  },
@@ -0,0 +1,74 @@
1
+ import type { ReactNode } from "react"
2
+ import {
3
+ type ArrayPath,
4
+ type Control,
5
+ type FieldValues,
6
+ useFieldArray,
7
+ type UseFieldArrayAppend,
8
+ } from "react-hook-form"
9
+
10
+ import Icon from "../../../components/Icon"
11
+ import { useI18n } from "../../../i18n/react/useI18n"
12
+ import ErrorMessage from "./atoms/ErrorMessage"
13
+ import Hint from "./atoms/Hint"
14
+ import Legend from "./atoms/Legend"
15
+ import { useFieldError } from "./hooks/use-field-error"
16
+
17
+ export default function DynamicArray<TFieldValues extends FieldValues>({
18
+ control,
19
+ name,
20
+ label,
21
+ hint,
22
+ renderElement,
23
+ addButton,
24
+ }: {
25
+ name: ArrayPath<TFieldValues>
26
+ label: string
27
+ hint?: string
28
+ control: Control<TFieldValues>
29
+ renderElement: (index: number) => ReactNode
30
+ addButton: {
31
+ label: string
32
+ onClick: (
33
+ append: UseFieldArrayAppend<TFieldValues, ArrayPath<TFieldValues>>,
34
+ elementIndex: number,
35
+ ) => void
36
+ }
37
+ }) {
38
+ const { fields, append, remove } = useFieldArray({
39
+ name,
40
+ control,
41
+ })
42
+ const { t } = useI18n()
43
+ const errorMessage = useFieldError({ control, name })
44
+ return (
45
+ <fieldset key={name}>
46
+ <Legend label={label} />
47
+ <div className="flex w-full flex-col divide-y divide-gray-300 overflow-hidden rounded-lg border border-gray-300 bg-gray-100 shadow-sm">
48
+ {fields.map((field, index) => (
49
+ <div className="flex w-full items-center gap-2 p-2" key={field.id}>
50
+ <div className="flex grow flex-col">{renderElement(index)}</div>
51
+ <button
52
+ className="flex items-center p-2 text-gray-600 transition-colors ease-in-out hover:text-rose-800"
53
+ type="button"
54
+ onClick={() => remove(index)}
55
+ >
56
+ <Icon className="mdi--remove" ariaLabel={t("ln.admin.remove")} />
57
+ </button>
58
+ </div>
59
+ ))}
60
+ <button
61
+ type="button"
62
+ className="p-4 text-sm font-bold text-gray-500 transition-colors ease-in-out hover:bg-gray-200"
63
+ onClick={() => {
64
+ addButton.onClick(append, fields.length)
65
+ }}
66
+ >
67
+ {t(addButton.label)}
68
+ </button>
69
+ </div>
70
+ <ErrorMessage message={errorMessage} />
71
+ <Hint label={hint} />
72
+ </fieldset>
73
+ )
74
+ }
@@ -18,19 +18,19 @@ export default function Input<TFieldValues extends FieldValues>({
18
18
  control: Control<TFieldValues>
19
19
  type?: "text" | "date"
20
20
  }) {
21
- const hasError = !!useFieldError({ control, name })
21
+ const errorMessage = useFieldError({ control, name })
22
22
  return (
23
23
  <div key={name} className="flex w-full flex-col">
24
24
  <Label for={name} label={label} />
25
25
  <input
26
- className={`dy-input dy-input-bordered ${hasError ? "dy-input-error" : ""}`}
26
+ className={`dy-input dy-input-bordered shadow-inner ${errorMessage ? "dy-input-error" : ""}`}
27
27
  type={type}
28
28
  id={name}
29
- aria-invalid={hasError}
29
+ aria-invalid={!!errorMessage}
30
30
  {...control.register(name)}
31
31
  />
32
- <ErrorMessage name={name} control={control} />
33
- <Hint hint={hint} />
32
+ <ErrorMessage message={errorMessage} />
33
+ <Hint label={hint} />
34
34
  </div>
35
35
  )
36
36
  }
@@ -1,6 +1,5 @@
1
1
  import type { Control, FieldValues, Path } from "react-hook-form"
2
2
 
3
- import { useI18n } from "../../../i18n/react/useI18n"
4
3
  import ErrorMessage from "./atoms/ErrorMessage"
5
4
  import Hint from "./atoms/Hint"
6
5
  import Label from "./atoms/Label"
@@ -17,27 +16,26 @@ export default function Select<TFieldValues extends FieldValues>({
17
16
  label: string
18
17
  hint?: string
19
18
  control: Control<TFieldValues>
20
- options: { id: string; label?: string }[]
19
+ options: { id: string; labelText?: string }[]
21
20
  }) {
22
- const { t } = useI18n()
23
- const hasError = !!useFieldError({ control, name })
21
+ const errorMessage = useFieldError({ control, name })
24
22
  return (
25
23
  <div key={name} className="flex w-full flex-col">
26
24
  <Label for={name} label={label} />
27
25
  <select
28
26
  {...control.register(name)}
29
27
  id={name}
30
- aria-invalid={hasError}
31
- className={`dy-select dy-select-bordered ${hasError ? "dy-select-error" : ""}"`}
28
+ aria-invalid={!!errorMessage}
29
+ className={`dy-select dy-select-bordered text-base shadow-sm ${errorMessage ? "dy-select-error" : ""}`}
32
30
  >
33
- {options.map(({ id, label }) => (
31
+ {options.map(({ id, labelText }) => (
34
32
  <option key={id} value={id}>
35
- {label ? t(label) : id}
33
+ {labelText ?? id}
36
34
  </option>
37
35
  ))}
38
36
  </select>
39
- <ErrorMessage name={name} control={control} />
40
- <Hint hint={hint} />
37
+ <ErrorMessage message={errorMessage} />
38
+ <Hint label={hint} />
41
39
  </div>
42
40
  )
43
41
  }
@@ -8,7 +8,7 @@ import type { MediaItem } from "../../types/media-item"
8
8
  const SUCCESS_DURATION_MS = 2000
9
9
 
10
10
  const baseButtonClass =
11
- "flex min-w-52 items-center justify-center gap-2 rounded-2xl px-6 py-3 font-bold uppercase shadow-sm transition-colors duration-200 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-gray-900 disabled:cursor-not-allowed"
11
+ "flex min-w-52 items-center justify-center gap-2 rounded-2xl px-4 py-3 font-bold uppercase shadow-sm transition-colors easy-in-out focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-gray-900 disabled:cursor-not-allowed"
12
12
 
13
13
  const buttonStateClasses = {
14
14
  idle: "bg-gray-800 text-gray-100 hover:bg-gray-950 hover:text-gray-300 disabled:bg-gray-600 disabled:text-gray-200",
@@ -19,8 +19,10 @@ const buttonStateClasses = {
19
19
  } as const
20
20
 
21
21
  const buttonLabels = {
22
- idle: "ln.admin.save",
23
- success: "ln.admin.saved",
22
+ idle: import.meta.env.DEV
23
+ ? "ln.admin.save-changes"
24
+ : "ln.admin.publish-changes",
25
+ success: import.meta.env.DEV ? "ln.admin.saved" : "ln.admin.published",
24
26
  error: "ln.admin.failed",
25
27
  } as const
26
28
 
@@ -38,10 +40,9 @@ export default function SubmitButton({
38
40
  className?: string
39
41
  }) {
40
42
  const { t } = useI18n()
41
- const { isValid, isSubmitting, isSubmitSuccessful, submitCount } =
42
- useFormState({
43
- control,
44
- })
43
+ const { isSubmitting, isSubmitSuccessful, submitCount } = useFormState({
44
+ control,
45
+ })
45
46
 
46
47
  const buttonState = useButtonState(isSubmitSuccessful, submitCount)
47
48
  const buttonClass = `${baseButtonClass} ${buttonStateClasses[buttonState]} ${className}`
@@ -49,11 +50,7 @@ export default function SubmitButton({
49
50
  const icon = icons[buttonState]
50
51
 
51
52
  return (
52
- <button
53
- className={buttonClass}
54
- type="submit"
55
- disabled={!isValid || isSubmitting}
56
- >
53
+ <button className={buttonClass} type="submit" disabled={isSubmitting}>
57
54
  {icon && <Icon className={icon} ariaLabel="" />}
58
55
  {t(label)}
59
56
  </button>
@@ -1,34 +1,13 @@
1
- import { ErrorMessage as RhfErrorMessage } from "@hookform/error-message"
2
- import { type Control, useFormState } from "react-hook-form"
3
-
4
1
  import { useI18n } from "../../../../i18n/react/useI18n"
5
2
 
6
- export default function ErrorMessage({
7
- name,
8
- control,
9
- }: {
10
- name: string
11
- control: Control<any>
12
- }) {
3
+ export default function ErrorMessage({ message }: { message?: string }) {
13
4
  const { t } = useI18n()
14
- const { errors } = useFormState({ control, name, exact: true })
5
+ if (!message) {
6
+ return null
7
+ }
15
8
  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
- />
9
+ <p className="my-2 flex flex-col gap-1 text-sm text-rose-800" role="alert">
10
+ {t(message)}
11
+ </p>
33
12
  )
34
13
  }
@@ -1,10 +1,10 @@
1
1
  import { useI18n } from "../../../../i18n/react/useI18n"
2
2
 
3
- export default function Hint({ hint }: { hint?: string }) {
3
+ export default function Hint({ label }: { label?: string }) {
4
4
  const { t } = useI18n()
5
5
  return (
6
6
  <div className="flex h-12 w-full items-start justify-end p-2">
7
- {hint && <span className="dy-label-text-alt">{t(hint)}</span>}
7
+ {label && <span className="dy-label-text-alt">{t(label)}</span>}
8
8
  </div>
9
9
  )
10
10
  }
@@ -3,15 +3,19 @@ import { useI18n } from "../../../../i18n/react/useI18n"
3
3
  export default function Label({
4
4
  label,
5
5
  for: htmlFor,
6
+ size = "sm",
7
+ className,
6
8
  }: {
7
9
  label: string
8
10
  for: string
11
+ className?: string
12
+ size?: "sm" | "xs"
9
13
  }) {
10
14
  const { t } = useI18n()
11
15
  return (
12
16
  <label
13
17
  htmlFor={htmlFor}
14
- className="pb-2 text-sm font-bold uppercase text-gray-600"
18
+ className={`font-bold uppercase text-gray-500 ${size === "sm" ? "pb-2 text-sm" : "pb-1 text-xs"} ${className}`}
15
19
  >
16
20
  {t(label)}
17
21
  </label>
@@ -1,9 +1,19 @@
1
1
  import { useI18n } from "../../../../i18n/react/useI18n"
2
2
 
3
- export default function Label({ label }: { label: string }) {
3
+ export default function Legend({
4
+ label,
5
+ size = "sm",
6
+ className,
7
+ }: {
8
+ label: string
9
+ className?: string
10
+ size?: "sm" | "xs"
11
+ }) {
4
12
  const { t } = useI18n()
5
13
  return (
6
- <legend className="pb-2 text-sm font-bold uppercase text-gray-600">
14
+ <legend
15
+ className={`pb-2 font-bold uppercase text-gray-500 ${size === "sm" ? "text-sm" : "text-xs"} ${className}`}
16
+ >
7
17
  {t(label)}
8
18
  </legend>
9
19
  )
@@ -1,21 +1,13 @@
1
- import { type Control, type FieldError, useFormState } from "react-hook-form"
1
+ import { type Control, get, useFormState } from "react-hook-form"
2
2
 
3
3
  export function useFieldError({
4
4
  control,
5
5
  name,
6
- index,
7
6
  }: {
8
7
  control: Control<any>
9
8
  name: string
10
- index?: number
11
9
  }) {
12
10
  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
11
+ const error = get(errors, name) as { message: string } | undefined
12
+ return error?.message
21
13
  }
@@ -1,17 +1,27 @@
1
1
  ln.admin.edit: Edit
2
- ln.admin.save: Save
2
+ ln.admin.publish-changes: Publish Changes
3
+ ln.admin.published: Published
4
+ ln.admin.save-changes: Save Changes
3
5
  ln.admin.saved: Saved
4
6
  ln.admin.failed: Failed
5
7
  ln.admin.remove: Remove
8
+ ln.admin.name: Name
6
9
  ln.admin.add-author: Add Author
10
+ ln.admin.add-category: Add Category
11
+ ln.admin.collections: Collections
12
+ ln.admin.add-collection: Add Collection
7
13
  ln.admin.edit-media-item: Edit media item
14
+ ln.admin.position-in-collection: Position in Collection
8
15
  ln.admin.back-to-details-page: Back to details page
9
16
  ln.admin.title: Title
10
17
  ln.admin.common-id: Common ID
11
18
  ln.admin.authors: Authors
12
19
  ln.admin.created-on: Created on
13
20
  ln.admin.created-on-hint: When has this item been created on this library?
14
- ln.admin.common-id-hint: The english title, all lowercase, words separated with hyphens.
15
- ln.admin.errors.non-empty-string: String must contain at least 1 character(s)
16
- ln.admin.errors.invalid-date: Invalid date
17
- ln.admin.errors.required: Required field
21
+ ln.admin.common-id-hint: The English title, all lowercase, words separated with hyphens.
22
+ ln.admin.errors.non-empty-string: Please enter at least one character.
23
+ ln.admin.errors.invalid-date: That date doesn't look right.
24
+ ln.admin.errors.required: This field is required.
25
+ ln.admin.errors.gte-0: Use a number zero or greater.
26
+ ln.admin.errors.unique-elements: Please choose a different value for each entry.
27
+ ln.admin.errors.integer: Please enter a whole number.
@@ -11,24 +11,33 @@ import Select from "../../components/form/Select"
11
11
  import SubmitButton from "../../components/form/SubmitButton"
12
12
  import { type MediaItem, mediaItemSchema } from "../../types/media-item"
13
13
  import Authors from "./fields/Authors"
14
+ import Categories from "./fields/Categories"
15
+ import Collections from "./fields/Collections"
14
16
  import { updateMediaItem } from "./media-item-store"
15
17
 
18
+ type SelectOption = { id: string; labelText: string }
19
+
16
20
  export default function EditForm({
17
21
  mediaId,
18
22
  mediaItem,
19
23
  i18nConfig,
20
24
  mediaTypes,
21
25
  languages,
26
+ categories,
27
+ collections,
22
28
  }: {
23
29
  mediaId: string
24
30
  mediaItem: MediaItem
25
31
  i18nConfig: I18nConfig
26
- mediaTypes: { id: string; label: string }[]
27
- languages: { id: string; label: string }[]
32
+ mediaTypes: SelectOption[]
33
+ languages: SelectOption[]
34
+ categories: SelectOption[]
35
+ collections: SelectOption[]
28
36
  }) {
29
37
  const { handleSubmit, control } = useForm({
30
38
  defaultValues: mediaItem,
31
39
  mode: "onTouched",
40
+ shouldFocusError: true,
32
41
  resolver: zodResolver(mediaItemSchema),
33
42
  })
34
43
  const onSubmit = handleSubmit(
@@ -37,7 +46,12 @@ export default function EditForm({
37
46
  const i18n = createI18n(i18nConfig)
38
47
  return (
39
48
  <I18nContext.Provider value={i18n}>
40
- <form onSubmit={onSubmit}>
49
+ <form className="flex flex-col" onSubmit={onSubmit}>
50
+ <div className="mb-8 flex items-end justify-between">
51
+ <h1 className="text-3xl">{i18n.t("ln.admin.edit-media-item")}</h1>
52
+ <SubmitButton control={control} />
53
+ </div>
54
+
41
55
  <Input name="title" label="ln.admin.title" control={control} />
42
56
  <Input
43
57
  name="commonId"
@@ -65,8 +79,10 @@ export default function EditForm({
65
79
  type="date"
66
80
  control={control}
67
81
  />
82
+ <Categories categories={categories} control={control} />
83
+ <Collections collections={collections} control={control} />
68
84
 
69
- <SubmitButton className="mt-8" control={control} />
85
+ <SubmitButton className="self-end" control={control} />
70
86
  </form>
71
87
  </I18nContext.Provider>
72
88
  )