lightnet 3.10.3 → 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 (49) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/__e2e__/admin.spec.ts +356 -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 -9
  6. package/src/admin/components/form/DynamicArray.tsx +74 -0
  7. package/src/admin/components/form/Input.tsx +36 -0
  8. package/src/admin/components/form/Select.tsx +22 -20
  9. package/src/admin/components/form/SubmitButton.tsx +20 -18
  10. package/src/admin/components/form/atoms/ErrorMessage.tsx +13 -0
  11. package/src/admin/components/form/atoms/Hint.tsx +3 -3
  12. package/src/admin/components/form/atoms/Label.tsx +17 -6
  13. package/src/admin/components/form/atoms/Legend.tsx +20 -0
  14. package/src/admin/components/form/hooks/use-field-error.tsx +13 -0
  15. package/src/admin/i18n/translations/en.yml +18 -7
  16. package/src/admin/pages/media/EditForm.tsx +52 -68
  17. package/src/admin/pages/media/EditRoute.astro +35 -11
  18. package/src/admin/pages/media/fields/Authors.tsx +43 -0
  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 +14 -7
  22. package/src/admin/types/media-item.ts +38 -2
  23. package/src/components/CategoriesSection.astro +2 -2
  24. package/src/components/HeroSection.astro +1 -1
  25. package/src/components/HighlightSection.astro +1 -1
  26. package/src/components/MediaGallerySection.astro +3 -3
  27. package/src/components/MediaList.astro +2 -2
  28. package/src/components/SearchInput.astro +1 -1
  29. package/src/content/get-categories.ts +18 -3
  30. package/src/i18n/react/i18n-context.ts +14 -12
  31. package/src/i18n/resolve-language.ts +1 -1
  32. package/src/layouts/MarkdownPage.astro +1 -1
  33. package/src/layouts/Page.astro +3 -2
  34. package/src/layouts/components/LanguagePicker.astro +1 -1
  35. package/src/layouts/components/Menu.astro +1 -1
  36. package/src/layouts/components/PageNavigation.astro +1 -1
  37. package/src/pages/details-page/components/main-details/OpenButton.astro +1 -1
  38. package/src/pages/details-page/components/more-details/Languages.astro +2 -2
  39. package/src/pages/search-page/components/LoadingSkeleton.tsx +1 -1
  40. package/src/pages/search-page/components/SearchFilter.astro +7 -7
  41. package/src/pages/search-page/components/SearchFilter.tsx +5 -5
  42. package/src/pages/search-page/components/SearchList.astro +4 -4
  43. package/src/pages/search-page/components/SearchListItem.tsx +5 -5
  44. package/src/pages/search-page/components/Select.tsx +4 -4
  45. package/src/pages/search-page/hooks/use-search.ts +4 -4
  46. package/src/admin/components/form/TextInput.tsx +0 -34
  47. package/src/admin/components/form/atoms/FieldErrors.tsx +0 -22
  48. package/src/admin/components/form/form-context.ts +0 -4
  49. package/src/admin/components/form/index.ts +0 -18
@@ -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 errorMessage = 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 shadow-inner ${errorMessage ? "dy-input-error" : ""}`}
27
+ type={type}
28
+ id={name}
29
+ aria-invalid={!!errorMessage}
30
+ {...control.register(name)}
31
+ />
32
+ <ErrorMessage message={errorMessage} />
33
+ <Hint label={hint} />
34
+ </div>
35
+ )
36
+ }
@@ -1,39 +1,41 @@
1
- import { useI18n } from "../../../i18n/react/useI18n"
2
- import { FieldErrors } from "./atoms/FieldErrors"
1
+ import type { Control, FieldValues, Path } from "react-hook-form"
2
+
3
+ import ErrorMessage from "./atoms/ErrorMessage"
3
4
  import Hint from "./atoms/Hint"
4
5
  import Label from "./atoms/Label"
5
- import { useFieldContext } from "./form-context"
6
+ import { useFieldError } from "./hooks/use-field-error"
6
7
 
7
- export default function Select({
8
+ export default function Select<TFieldValues extends FieldValues>({
9
+ name,
8
10
  label,
11
+ control,
9
12
  hint,
10
13
  options,
11
14
  }: {
15
+ name: Path<TFieldValues>
12
16
  label: string
13
17
  hint?: string
14
- options: { id: string; label?: string }[]
18
+ control: Control<TFieldValues>
19
+ options: { id: string; labelText?: string }[]
15
20
  }) {
16
- const field = useFieldContext<string>()
17
- const { t } = useI18n()
21
+ const errorMessage = useFieldError({ control, name })
18
22
  return (
19
- <label className="dy-form-control w-full">
20
- <Label label={label} />
23
+ <div key={name} className="flex w-full flex-col">
24
+ <Label for={name} label={label} />
21
25
  <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" : ""}"`}
26
+ {...control.register(name)}
27
+ id={name}
28
+ aria-invalid={!!errorMessage}
29
+ className={`dy-select dy-select-bordered text-base shadow-sm ${errorMessage ? "dy-select-error" : ""}`}
28
30
  >
29
- {options.map(({ id, label }) => (
31
+ {options.map(({ id, labelText }) => (
30
32
  <option key={id} value={id}>
31
- {label ? t(label) : id}
33
+ {labelText ?? id}
32
34
  </option>
33
35
  ))}
34
36
  </select>
35
- <FieldErrors meta={field.state.meta} />
36
- <Hint hint={hint} />
37
- </label>
37
+ <ErrorMessage message={errorMessage} />
38
+ <Hint label={hint} />
39
+ </div>
38
40
  )
39
41
  }
@@ -1,14 +1,14 @@
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
 
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
 
@@ -30,20 +32,20 @@ const icons = {
30
32
  error: "mdi--error-outline",
31
33
  } as const
32
34
 
33
- export default function SubmitButton() {
34
- const form = useFormContext()
35
+ export default function SubmitButton({
36
+ control,
37
+ className,
38
+ }: {
39
+ control: Control<MediaItem>
40
+ className?: string
41
+ }) {
35
42
  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]}`
43
+ const { isSubmitting, isSubmitSuccessful, submitCount } = useFormState({
44
+ control,
45
+ })
46
+
47
+ const buttonState = useButtonState(isSubmitSuccessful, submitCount)
48
+ const buttonClass = `${baseButtonClass} ${buttonStateClasses[buttonState]} ${className}`
47
49
  const label = buttonLabels[buttonState]
48
50
  const icon = icons[buttonState]
49
51
 
@@ -0,0 +1,13 @@
1
+ import { useI18n } from "../../../../i18n/react/useI18n"
2
+
3
+ export default function ErrorMessage({ message }: { message?: string }) {
4
+ const { t } = useI18n()
5
+ if (!message) {
6
+ return null
7
+ }
8
+ return (
9
+ <p className="my-2 flex flex-col gap-1 text-sm text-rose-800" role="alert">
10
+ {t(message)}
11
+ </p>
12
+ )
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
- <div className="flex h-8 w-full items-center justify-end">
7
- {hint && <span className="dy-label-text-alt">{t(hint)}</span>}
6
+ <div className="flex h-12 w-full items-start justify-end p-2">
7
+ {label && <span className="dy-label-text-alt">{t(label)}</span>}
8
8
  </div>
9
9
  )
10
10
  }
@@ -1,12 +1,23 @@
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
+ size = "sm",
7
+ className,
8
+ }: {
9
+ label: string
10
+ for: string
11
+ className?: string
12
+ size?: "sm" | "xs"
13
+ }) {
4
14
  const { t } = useI18n()
5
15
  return (
6
- <div className="dy-label">
7
- <span className="text-sm font-bold uppercase text-gray-600">
8
- {t(label)}
9
- </span>
10
- </div>
16
+ <label
17
+ htmlFor={htmlFor}
18
+ className={`font-bold uppercase text-gray-500 ${size === "sm" ? "pb-2 text-sm" : "pb-1 text-xs"} ${className}`}
19
+ >
20
+ {t(label)}
21
+ </label>
11
22
  )
12
23
  }
@@ -0,0 +1,20 @@
1
+ import { useI18n } from "../../../../i18n/react/useI18n"
2
+
3
+ export default function Legend({
4
+ label,
5
+ size = "sm",
6
+ className,
7
+ }: {
8
+ label: string
9
+ className?: string
10
+ size?: "sm" | "xs"
11
+ }) {
12
+ const { t } = useI18n()
13
+ return (
14
+ <legend
15
+ className={`pb-2 font-bold uppercase text-gray-500 ${size === "sm" ? "text-sm" : "text-xs"} ${className}`}
16
+ >
17
+ {t(label)}
18
+ </legend>
19
+ )
20
+ }
@@ -0,0 +1,13 @@
1
+ import { type Control, get, useFormState } from "react-hook-form"
2
+
3
+ export function useFieldError({
4
+ control,
5
+ name,
6
+ }: {
7
+ control: Control<any>
8
+ name: string
9
+ }) {
10
+ const { errors } = useFormState({ control, name, exact: true })
11
+ const error = get(errors, name) as { message: string } | undefined
12
+ return error?.message
13
+ }
@@ -1,16 +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
7
+ ln.admin.remove: Remove
8
+ ln.admin.name: Name
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
5
13
  ln.admin.edit-media-item: Edit media item
14
+ ln.admin.position-in-collection: Position in Collection
6
15
  ln.admin.back-to-details-page: Back to details page
7
16
  ln.admin.title: Title
8
17
  ln.admin.common-id: Common ID
18
+ ln.admin.authors: Authors
9
19
  ln.admin.created-on: Created on
10
20
  ln.admin.created-on-hint: When has this item been created on this library?
11
- 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
- ln.admin.errors.non-empty-string: String must contain at least 1 character(s)
15
- ln.admin.errors.invalid-date: Invalid date
16
- 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.
@@ -1,104 +1,88 @@
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"
14
+ import Categories from "./fields/Categories"
15
+ import Collections from "./fields/Collections"
12
16
  import { updateMediaItem } from "./media-item-store"
13
17
 
18
+ type SelectOption = { id: string; labelText: string }
19
+
14
20
  export default function EditForm({
15
21
  mediaId,
16
22
  mediaItem,
17
23
  i18nConfig,
18
24
  mediaTypes,
19
25
  languages,
26
+ categories,
27
+ collections,
20
28
  }: {
21
29
  mediaId: string
22
30
  mediaItem: MediaItem
23
31
  i18nConfig: I18nConfig
24
- mediaTypes: { id: string; label: string }[]
25
- languages: { id: string; label: string }[]
32
+ mediaTypes: SelectOption[]
33
+ languages: SelectOption[]
34
+ categories: SelectOption[]
35
+ collections: SelectOption[]
26
36
  }) {
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
- },
37
+ const { handleSubmit, control } = useForm({
38
+ defaultValues: mediaItem,
39
+ mode: "onTouched",
40
+ shouldFocusError: true,
41
+ resolver: zodResolver(mediaItemSchema),
42
42
  })
43
+ const onSubmit = handleSubmit(
44
+ async (data) => await updateMediaItem(mediaId, { ...mediaItem, ...data }),
45
+ )
43
46
  const i18n = createI18n(i18nConfig)
44
- const { t } = i18n
45
-
46
47
  return (
47
48
  <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
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
+
55
+ <Input name="title" label="ln.admin.title" control={control} />
56
+ <Input
60
57
  name="commonId"
61
- children={(field) => (
62
- <field.TextInput
63
- label="ln.admin.common-id"
64
- hint="ln.admin.common-id-hint"
65
- />
66
- )}
58
+ label="ln.admin.common-id"
59
+ hint="ln.admin.common-id-hint"
60
+ control={control}
67
61
  />
68
- <form.AppField
62
+ <Select
69
63
  name="type"
70
- children={(field) => (
71
- <field.Select label="ln.type" options={mediaTypes} />
72
- )}
64
+ label="ln.type"
65
+ options={mediaTypes}
66
+ control={control}
73
67
  />
74
- <form.AppField
68
+ <Select
75
69
  name="language"
76
- children={(field) => (
77
- <field.Select label="ln.language" options={languages} />
78
- )}
70
+ label="ln.language"
71
+ options={languages}
72
+ control={control}
79
73
  />
80
- <form.AppField
74
+ <Authors control={control} />
75
+ <Input
81
76
  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
- )}
77
+ label="ln.admin.created-on"
78
+ hint="ln.admin.created-on-hint"
79
+ type="date"
80
+ control={control}
89
81
  />
82
+ <Categories categories={categories} control={control} />
83
+ <Collections collections={collections} control={control} />
90
84
 
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>
85
+ <SubmitButton className="self-end" control={control} />
102
86
  </form>
103
87
  </I18nContext.Provider>
104
88
  )
@@ -3,6 +3,7 @@ import type { GetStaticPaths } from "astro"
3
3
  import { getCollection } from "astro:content"
4
4
  import config from "virtual:lightnet/config"
5
5
 
6
+ import { getCategories } from "../../../content/get-categories"
6
7
  import { getRawMediaItem } from "../../../content/get-media-items"
7
8
  import { getMediaTypes } from "../../../content/get-media-types"
8
9
  import { prepareI18nConfig } from "../../../i18n/react/prepare-i18n-config"
@@ -18,42 +19,65 @@ export const getStaticPaths = (async () => {
18
19
  }) satisfies GetStaticPaths
19
20
 
20
21
  const { mediaId } = Astro.params
21
- const mediaItemEntry = await getRawMediaItem(mediaId)
22
+ const { data: mediaItem } = await getRawMediaItem(mediaId)
23
+
24
+ const formData = {
25
+ ...mediaItem,
26
+ authors: mediaItem.authors?.map((value) => ({ value })) ?? [],
27
+ categories:
28
+ mediaItem.categories?.map((value) => ({
29
+ value,
30
+ })) ?? [],
31
+ collections: mediaItem.collections ?? [],
32
+ }
22
33
 
23
34
  const i18nConfig = prepareI18nConfig(Astro.locals.i18n, [
24
35
  "ln.admin.*",
25
36
  "ln.type",
26
37
  "ln.language",
38
+ "ln.categories",
27
39
  ])
28
- const { t } = Astro.locals.i18n
40
+ const { t, currentLocale } = Astro.locals.i18n
29
41
 
30
42
  const mediaTypes = (await getMediaTypes()).map(({ id, data: { label } }) => ({
31
43
  id,
32
- label: t(label),
44
+ labelText: t(label),
33
45
  }))
46
+
47
+ const categories = (await getCategories(currentLocale, t)).map(
48
+ ({ id, labelText }) => ({ id, labelText }),
49
+ )
50
+
51
+ const collections = (await getCollection("media-collections")).map(
52
+ ({ id, data: { label } }) => ({ id, labelText: t(label) }),
53
+ )
54
+
34
55
  const languages = config.languages.map(({ code, label }) => ({
35
56
  id: code,
36
- label: t(label),
57
+ labelText: t(label),
37
58
  }))
38
59
  ---
39
60
 
40
- <Page>
41
- <div class="mx-auto max-w-screen-md px-4 pt-12 md:px-8">
61
+ <Page mainClass="bg-slate-500">
62
+ <div class="mx-auto block max-w-screen-md px-4 md:px-8">
42
63
  <a
43
- class="underline"
64
+ class="block py-4 text-gray-200 underline"
44
65
  href=`/${Astro.currentLocale}/media/faithful-freestyle--en`
45
66
  >{t("ln.admin.back-to-details-page")}</a
46
67
  >
47
- <h1 class="mb-10 mt-10 text-2xl">
48
- {t("ln.admin.edit-media-item")}
49
- </h1>
68
+ </div>
50
69
 
70
+ <div
71
+ class="mx-auto max-w-screen-md rounded-2xl bg-gray-200 px-4 py-8 shadow-md md:px-8"
72
+ >
51
73
  <EditForm
52
74
  mediaId={mediaId}
53
- mediaItem={mediaItemEntry.data}
75
+ mediaItem={formData}
54
76
  i18nConfig={i18nConfig}
55
77
  mediaTypes={mediaTypes}
56
78
  languages={languages}
79
+ categories={categories}
80
+ collections={collections}
57
81
  client:load
58
82
  />
59
83
  </div>
@@ -0,0 +1,43 @@
1
+ import { type Control } from "react-hook-form"
2
+
3
+ import ErrorMessage from "../../../components/form/atoms/ErrorMessage"
4
+ import DynamicArray from "../../../components/form/DynamicArray"
5
+ import { useFieldError } from "../../../components/form/hooks/use-field-error"
6
+ import type { MediaItem } from "../../../types/media-item"
7
+
8
+ export default function Authors({ control }: { control: Control<MediaItem> }) {
9
+ return (
10
+ <DynamicArray
11
+ control={control}
12
+ name="authors"
13
+ label="ln.admin.authors"
14
+ renderElement={(index) => <AuthorInput index={index} control={control} />}
15
+ addButton={{
16
+ label: "ln.admin.add-author",
17
+ onClick: (append, index) =>
18
+ append({ value: "" }, { focusName: `authors.${index}.value` }),
19
+ }}
20
+ />
21
+ )
22
+ }
23
+
24
+ function AuthorInput({
25
+ index,
26
+ control,
27
+ }: {
28
+ index: number
29
+ control: Control<MediaItem>
30
+ }) {
31
+ const name = `authors.${index}.value` as const
32
+ const errorMessage = useFieldError({ name, control })
33
+ return (
34
+ <>
35
+ <input
36
+ className={`dy-input dy-input-bordered shadow-inner ${errorMessage ? "dy-input-error" : ""}`}
37
+ aria-invalid={!!errorMessage}
38
+ {...control.register(name)}
39
+ />
40
+ <ErrorMessage message={errorMessage} />
41
+ </>
42
+ )
43
+ }
@@ -0,0 +1,64 @@
1
+ import { type Control } from "react-hook-form"
2
+
3
+ import ErrorMessage from "../../../components/form/atoms/ErrorMessage"
4
+ import DynamicArray from "../../../components/form/DynamicArray"
5
+ import { useFieldError } from "../../../components/form/hooks/use-field-error"
6
+ import type { MediaItem } from "../../../types/media-item"
7
+
8
+ export default function Categories({
9
+ control,
10
+ categories,
11
+ }: {
12
+ control: Control<MediaItem>
13
+ categories: { id: string; labelText: string }[]
14
+ }) {
15
+ return (
16
+ <DynamicArray
17
+ control={control}
18
+ name="categories"
19
+ label="ln.categories"
20
+ renderElement={(index) => (
21
+ <CategorySelect
22
+ categories={categories}
23
+ control={control}
24
+ index={index}
25
+ />
26
+ )}
27
+ addButton={{
28
+ label: "ln.admin.add-category",
29
+ onClick: (append, index) =>
30
+ append({ value: "" }, { focusName: `categories.${index}.value` }),
31
+ }}
32
+ />
33
+ )
34
+ }
35
+
36
+ function CategorySelect({
37
+ control,
38
+ categories,
39
+ index,
40
+ }: {
41
+ control: Control<MediaItem>
42
+ categories: { id: string; labelText: string }[]
43
+ index: number
44
+ }) {
45
+ const name = `categories.${index}.value` as const
46
+ const errorMessage = useFieldError({ name, control })
47
+ return (
48
+ <>
49
+ <select
50
+ {...control.register(name)}
51
+ id={name}
52
+ aria-invalid={!!errorMessage}
53
+ className={`dy-select dy-select-bordered text-base shadow-sm ${errorMessage ? "dy-select-error" : ""}`}
54
+ >
55
+ {categories.map(({ id, labelText }) => (
56
+ <option key={id} value={id}>
57
+ {labelText}
58
+ </option>
59
+ ))}
60
+ </select>
61
+ <ErrorMessage message={errorMessage} />
62
+ </>
63
+ )
64
+ }