lightnet 3.10.8 → 3.11.0

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 (45) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/__e2e__/admin.spec.ts +35 -1
  3. package/__e2e__/fixtures/basics/node_modules/.bin/astro +2 -2
  4. package/__e2e__/fixtures/basics/node_modules/.bin/tailwind +2 -2
  5. package/__e2e__/fixtures/basics/node_modules/.bin/tailwindcss +2 -2
  6. package/__e2e__/fixtures/basics/package.json +5 -5
  7. package/__tests__/pages/details-page/create-content-metadata.spec.ts +23 -3
  8. package/package.json +8 -8
  9. package/src/admin/components/form/DynamicArray.tsx +81 -29
  10. package/src/admin/components/form/Input.tsx +17 -3
  11. package/src/admin/components/form/LazyLoadedMarkdownEditor.tsx +2 -2
  12. package/src/admin/components/form/MarkdownEditor.tsx +12 -4
  13. package/src/admin/components/form/Select.tsx +25 -13
  14. package/src/admin/components/form/SubmitButton.tsx +2 -2
  15. package/src/admin/components/form/atoms/Button.tsx +27 -0
  16. package/src/admin/components/form/atoms/FileUpload.tsx +124 -49
  17. package/src/admin/components/form/atoms/Hint.tsx +2 -2
  18. package/src/admin/components/form/atoms/Label.tsx +17 -6
  19. package/src/admin/components/form/utils/get-border-class.ts +22 -0
  20. package/src/admin/i18n/translations/en.yml +19 -3
  21. package/src/admin/pages/AdminRoute.astro +1 -1
  22. package/src/admin/pages/media/EditForm.tsx +33 -15
  23. package/src/admin/pages/media/EditRoute.astro +2 -2
  24. package/src/admin/pages/media/fields/Authors.tsx +15 -5
  25. package/src/admin/pages/media/fields/Categories.tsx +17 -5
  26. package/src/admin/pages/media/fields/Collections.tsx +21 -11
  27. package/src/admin/pages/media/fields/Content.tsx +150 -0
  28. package/src/admin/pages/media/fields/Image.tsx +43 -10
  29. package/src/admin/pages/media/media-item-store.ts +16 -2
  30. package/src/admin/types/media-item.ts +9 -0
  31. package/src/components/SearchInput.astro +3 -3
  32. package/src/i18n/react/prepare-i18n-config.ts +1 -1
  33. package/src/i18n/react/use-i18n.ts +1 -1
  34. package/src/i18n/translations/TRANSLATION-STATUS.md +4 -0
  35. package/src/i18n/translations/ur.yml +25 -0
  36. package/src/i18n/translations.ts +1 -0
  37. package/src/layouts/Page.astro +0 -2
  38. package/src/layouts/components/Menu.astro +1 -1
  39. package/src/pages/404Route.astro +3 -1
  40. package/src/pages/details-page/utils/create-content-metadata.ts +2 -1
  41. package/src/pages/search-page/components/LoadingSkeleton.tsx +20 -13
  42. package/src/pages/search-page/components/SearchFilter.tsx +2 -2
  43. package/src/pages/search-page/components/SearchList.tsx +33 -29
  44. package/src/pages/search-page/components/Select.tsx +15 -13
  45. package/src/layouts/components/PreloadReact.tsx +0 -3
@@ -0,0 +1,150 @@
1
+ import { type Control, useWatch } from "react-hook-form"
2
+
3
+ import Icon from "../../../../components/Icon"
4
+ import { useI18n } from "../../../../i18n/react/use-i18n"
5
+ import { createContentMetadata } from "../../../../pages/details-page/utils/create-content-metadata"
6
+ import Button from "../../../components/form/atoms/Button"
7
+ import FileUpload from "../../../components/form/atoms/FileUpload"
8
+ import DynamicArray from "../../../components/form/DynamicArray"
9
+ import Input from "../../../components/form/Input"
10
+ import type { MediaItem } from "../../../types/media-item"
11
+
12
+ export default function Content({
13
+ control,
14
+ defaultValue,
15
+ }: {
16
+ control: Control<MediaItem>
17
+ defaultValue: MediaItem["content"]
18
+ }) {
19
+ const { t } = useI18n()
20
+ return (
21
+ <DynamicArray
22
+ control={control}
23
+ name="content"
24
+ required
25
+ label="ln.admin.content"
26
+ renderElement={(index) => (
27
+ <div className="flex w-full flex-col gap-6">
28
+ <URLInput
29
+ control={control}
30
+ defaultValue={defaultValue[index]?.url}
31
+ index={index}
32
+ />
33
+ <LabelInput
34
+ control={control}
35
+ defaultValue={defaultValue[index]?.label}
36
+ index={index}
37
+ />
38
+ </div>
39
+ )}
40
+ renderElementMeta={(index) => {
41
+ if (index !== 0) {
42
+ return <span></span>
43
+ }
44
+ return (
45
+ <span className="ms-1 flex items-center gap-1 rounded-lg bg-sky-700 px-2 py-1 text-xs uppercase text-slate-100">
46
+ <Icon className="text-sm mdi--star" ariaLabel="" />
47
+ {t("ln.admin.primary-content")}
48
+ </span>
49
+ )
50
+ }}
51
+ renderAddButton={({ addElement, index }) => (
52
+ <div className="flex w-2/3 flex-col items-center gap-2 pb-2 pt-4">
53
+ <Button
54
+ variant="secondary"
55
+ onClick={() =>
56
+ addElement({ url: "" }, { focusName: `content.${index}.url` })
57
+ }
58
+ >
59
+ <Icon className="mdi--link-variant" ariaLabel="" />
60
+ {t("ln.admin.add-link")}
61
+ </Button>
62
+ <span className="text-sm font-bold uppercase text-slate-500">
63
+ {t("ln.admin.or")}
64
+ </span>
65
+ <FileUpload
66
+ title="ln.admin.files-upload-title"
67
+ description="ln.admin.files-upload-description"
68
+ icon="mdi--file-upload-outline"
69
+ multiple
70
+ onUpload={(...files: File[]) =>
71
+ files.forEach((file, i) => {
72
+ addElement(
73
+ { url: `/files/${file.name}`, file },
74
+ { focusName: `content.${index + i}.url` },
75
+ )
76
+ })
77
+ }
78
+ />
79
+ </div>
80
+ )}
81
+ />
82
+ )
83
+ }
84
+
85
+ function URLInput({
86
+ control,
87
+ defaultValue,
88
+ index,
89
+ }: {
90
+ defaultValue?: string
91
+ index: number
92
+ control: Control<MediaItem>
93
+ }) {
94
+ const { url, file } = useWatch({ control, name: `content.${index}` })
95
+ const { t } = useI18n()
96
+ const isFile = url.startsWith("/files/")
97
+
98
+ const label = () => {
99
+ if (!isFile) {
100
+ return "ln.admin.link"
101
+ }
102
+ if (!file) {
103
+ return "ln.admin.file"
104
+ }
105
+ return t("ln.admin.file-upload", {
106
+ fileSize: (file.size / 1024 / 1024).toFixed(1),
107
+ })
108
+ }
109
+
110
+ return (
111
+ <Input
112
+ control={control}
113
+ label={label()}
114
+ disabled={isFile}
115
+ labelSize="small"
116
+ required
117
+ preserveHintSpace={false}
118
+ defaultValue={defaultValue}
119
+ name={`content.${index}.url`}
120
+ />
121
+ )
122
+ }
123
+
124
+ function LabelInput({
125
+ control,
126
+ defaultValue,
127
+ index,
128
+ }: {
129
+ control: Control<MediaItem>
130
+ defaultValue?: string
131
+ index: number
132
+ }) {
133
+ const url = useWatch({ control, name: `content.${index}.url` })
134
+ const { label: defaultLabel } = createContentMetadata({ url })
135
+
136
+ return (
137
+ <Input
138
+ name={`content.${index}.label`}
139
+ control={control}
140
+ label="ln.admin.label"
141
+ labelSize="small"
142
+ placeholder={defaultLabel}
143
+ preserveHintSpace={false}
144
+ defaultValue={defaultValue}
145
+ registerOptions={{
146
+ setValueAs: (value) => value?.trim() || undefined,
147
+ }}
148
+ />
149
+ )
150
+ }
@@ -1,5 +1,5 @@
1
1
  import { useEffect, useRef, useState } from "react"
2
- import { type Control } from "react-hook-form"
2
+ import { type Control, useController } from "react-hook-form"
3
3
 
4
4
  import ErrorMessage from "../../../components/form/atoms/ErrorMessage"
5
5
  import FileUpload from "../../../components/form/atoms/FileUpload"
@@ -11,6 +11,30 @@ import type { MediaItem } from "../../../types/media-item"
11
11
 
12
12
  const acceptedFileTypes = ["image/jpeg", "image/png", "image/webp"] as const
13
13
 
14
+ const sanitizeImageUrl = (url?: string) => {
15
+ if (!url) {
16
+ return undefined
17
+ }
18
+ const trimmed = url.trim()
19
+ if (!trimmed) {
20
+ return undefined
21
+ }
22
+ if (
23
+ trimmed.startsWith("/") ||
24
+ trimmed.startsWith("./") ||
25
+ trimmed.startsWith("blob:")
26
+ ) {
27
+ return trimmed
28
+ }
29
+ if (/^https?:\/\//i.test(trimmed)) {
30
+ return trimmed
31
+ }
32
+ if (/^data:image\/(png|jpe?g|webp);/i.test(trimmed)) {
33
+ return trimmed
34
+ }
35
+ return undefined
36
+ }
37
+
14
38
  export default function Image({
15
39
  control,
16
40
  defaultValue,
@@ -20,6 +44,7 @@ export default function Image({
20
44
  defaultValue: MediaItem["image"]
21
45
  mediaId: string
22
46
  }) {
47
+ const { field } = useController({ control, name: "image" })
23
48
  const objectUrlRef = useRef<string | null>(null)
24
49
  const [previewSrc, setPreviewSrc] = useState<string | undefined>(
25
50
  defaultValue.previewSrc,
@@ -48,7 +73,15 @@ export default function Image({
48
73
  }
49
74
  const objectUrl = URL.createObjectURL(file)
50
75
  objectUrlRef.current = objectUrl
76
+
77
+ const nameParts = file.name.split(".")
78
+ const extension = nameParts.pop()
51
79
  setPreviewSrc(objectUrl)
80
+ field.onChange({
81
+ ...field.value,
82
+ path: `./images/${mediaId}.${extension}`,
83
+ file,
84
+ })
52
85
  }
53
86
 
54
87
  return (
@@ -57,30 +90,30 @@ export default function Image({
57
90
  <Label
58
91
  label="ln.admin.image"
59
92
  isDirty={isDirty}
93
+ required
60
94
  isInvalid={!!errorMessage}
61
95
  />
62
96
  </label>
63
97
  <div
64
- className={`flex w-full items-stretch gap-4 rounded-lg rounded-ss-none border bg-gray-50 px-4 py-3 shadow-inner outline-none transition-colors ${isDirty && !errorMessage ? "border-gray-700" : "border-gray-300"} ${errorMessage ? "border-rose-800" : ""} `}
98
+ className={`flex w-full items-stretch gap-4 rounded-xl rounded-ss-none border bg-slate-50 px-4 py-3 shadow-inner outline-none transition-colors focus-within:border-sky-700 focus-within:ring-1 focus-within:ring-sky-700 ${isDirty && !errorMessage ? "border-slate-700" : "border-slate-300"} ${errorMessage ? "border-rose-800" : ""} `}
65
99
  >
66
- <div className="flex h-32 w-32 shrink-0 items-center justify-center overflow-hidden rounded-lg bg-gray-200 p-1">
100
+ <div className="flex h-32 w-32 shrink-0 items-center justify-center overflow-hidden rounded-xl bg-slate-200 p-1">
67
101
  <img
68
- src={previewSrc}
102
+ src={sanitizeImageUrl(previewSrc)}
69
103
  alt=""
70
104
  className="h-full w-full object-contain"
71
105
  />
72
106
  </div>
73
107
  <FileUpload
74
- name="image"
75
- control={control}
76
- onFileChange={updateImage}
77
- destinationPath="./images"
108
+ title="ln.admin.image-upload-title"
109
+ description="ln.admin.image-upload-description"
110
+ onUpload={updateImage}
111
+ onBlur={field.onBlur}
78
112
  acceptedFileTypes={acceptedFileTypes}
79
- fileName={mediaId}
80
113
  />
81
114
  </div>
82
115
  <ErrorMessage message={errorMessage} />
83
- <Hint preserveSpace={true} label="ln.admin.image-hint" />
116
+ <Hint preserveSpace={true} />
84
117
  </div>
85
118
  )
86
119
  }
@@ -3,13 +3,14 @@ import { writeFile, writeJson } from "./file-system"
3
3
 
4
4
  export const updateMediaItem = async (id: string, item: MediaItem) => {
5
5
  const imagePath = await saveImage(item.image)
6
+ await Promise.all(item.content.map(saveContentFile))
6
7
  return writeJson(
7
8
  `/src/content/media/${id}.json`,
8
9
  mapToContentSchema(item, imagePath),
9
10
  )
10
11
  }
11
12
 
12
- const ensureRelativeImagePath = (path: string) => {
13
+ const ensureRelativePath = (path: string) => {
13
14
  const trimmed = path.trim()
14
15
  if (!trimmed) {
15
16
  return ""
@@ -21,7 +22,7 @@ const ensureRelativeImagePath = (path: string) => {
21
22
  }
22
23
 
23
24
  const saveImage = async (image: MediaItem["image"]) => {
24
- const relativePath = ensureRelativeImagePath(image?.path ?? "")
25
+ const relativePath = ensureRelativePath(image?.path ?? "")
25
26
  if (!relativePath || !image?.file) {
26
27
  return relativePath
27
28
  }
@@ -33,11 +34,24 @@ const saveImage = async (image: MediaItem["image"]) => {
33
34
  return relativePath
34
35
  }
35
36
 
37
+ const saveContentFile = async ({ url, file }: MediaItem["content"][number]) => {
38
+ if (!file) {
39
+ return
40
+ }
41
+ const path = `/public/${url.replace(/^\//, "")}`
42
+ return writeFile(
43
+ path,
44
+ await file.arrayBuffer(),
45
+ file.type || "application/octet-stream",
46
+ )
47
+ }
48
+
36
49
  const mapToContentSchema = (item: MediaItem, imagePath: string) => {
37
50
  return {
38
51
  ...item,
39
52
  image: imagePath,
40
53
  authors: flatten(item.authors),
54
+ content: item.content.map(({ file: _, ...content }) => content),
41
55
  categories: flatten(item.categories),
42
56
  }
43
57
  }
@@ -8,6 +8,7 @@ const GTE_0 = "ln.admin.errors.gte-0"
8
8
  const INTEGER = "ln.admin.errors.integer"
9
9
  const UNIQUE_ELEMENTS = "ln.admin.errors.unique-elements"
10
10
  const FILE_SIZE_EXCEEDED = "ln.admin.error.file-size-exceeded"
11
+ const NON_EMPTY_LIST = "ln.admin.errors.non-empty-list"
11
12
 
12
13
  const unique = <TArrayItem>(path: Extract<keyof TArrayItem, string>) => {
13
14
  return (values: TArrayItem[], ctx: RefinementCtx) => {
@@ -67,6 +68,14 @@ export const mediaItemSchema = z.object({
67
68
  previewSrc: z.string(),
68
69
  file: fileShape,
69
70
  }),
71
+ content: z
72
+ .object({
73
+ url: z.string().nonempty(NON_EMPTY_STRING),
74
+ file: fileShape,
75
+ label: z.string().optional(),
76
+ })
77
+ .array()
78
+ .min(1, NON_EMPTY_LIST),
70
79
  })
71
80
 
72
81
  export type MediaItem = z.input<typeof mediaItemSchema>
@@ -12,11 +12,11 @@ const { t } = Astro.locals.i18n
12
12
  action={`/${Astro.currentLocale}/media`}
13
13
  method="get"
14
14
  role="search"
15
- class="group dy-join w-full rounded-2xl shadow-sm outline-2 outline-offset-2 outline-gray-400 group-focus-within:outline"
15
+ class="group flex w-full rounded-2xl shadow-sm outline outline-2 outline-offset-2 outline-transparent transition-all ease-in-out group-focus-within:outline-gray-400"
16
16
  class:list={[Astro.props.className]}
17
17
  >
18
18
  <input
19
- class="dy-input dy-join-item grow rounded-2xl bg-gray-100/95 text-gray-900 placeholder-gray-500 shadow-inner focus:outline-none"
19
+ class="grow rounded-s-2xl bg-gray-100/95 px-4 py-3 text-gray-900 placeholder-gray-500 shadow-inner focus:outline-none"
20
20
  enterkeyhint="search"
21
21
  type="search"
22
22
  name="search"
@@ -24,7 +24,7 @@ const { t } = Astro.locals.i18n
24
24
  />
25
25
  <button
26
26
  type="submit"
27
- class="dy-btn dy-join-item rounded-2xl border-gray-100/95 bg-gray-800 text-gray-50 hover:bg-gray-950 hover:text-gray-300"
27
+ class="flex items-center rounded-e-2xl border border-gray-100/95 bg-gray-800 px-4 py-3 text-gray-50 hover:bg-gray-950 hover:text-gray-300"
28
28
  >
29
29
  <Icon className="mdi--magnify" ariaLabel={t("ln.search.title")} />
30
30
  </button>
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Prepares the configuration object passed from an Astro page to the React i18n context.
2
+ * Prepares the configuration object passed from an Astro page to the react i18n context.
3
3
  * Resolves every requested translation key (supporting wildcard suffixes like `ln.dashboard.*`)
4
4
  * so the React island receives only the strings it needs.
5
5
  *
@@ -4,7 +4,7 @@ import { I18nContext } from "./i18n-context"
4
4
 
5
5
  /**
6
6
  * Retrieves the current i18n helpers from context.
7
- * Must be called inside a React tree wrapped with `I18nContext.Provider`, otherwise throws.
7
+ * Must be called inside a react tree wrapped with `I18nContext.Provider`, otherwise throws.
8
8
  */
9
9
  export const useI18n = () => {
10
10
  const i18n = useContext(I18nContext)
@@ -53,6 +53,10 @@ All keys have been translated. ✅
53
53
 
54
54
  All keys have been translated. ✅
55
55
 
56
+ ## **UR** ([ur.yml](./ur.yml))
57
+
58
+ All keys have been translated. ✅
59
+
56
60
  ## **ZH** ([zh.yml](./zh.yml))
57
61
 
58
62
  All keys have been translated. ✅
@@ -0,0 +1,25 @@
1
+ ln.header.open-main-menu: مرکزی مینو کھولیں
2
+ ln.header.select-language: زبان منتخب کریں
3
+ ln.home.title: ہوم
4
+ ln.category: زمرہ
5
+ ln.categories: زمروں
6
+ ln.language: زبان
7
+ ln.languages: زبانیں
8
+ ln.type: قسم
9
+ ln.previous: پچھلا
10
+ ln.next: اگلا
11
+ ln.external-link: بیرونی لنک
12
+ ln.search.title: تلاش
13
+ ln.search.placeholder: میڈیا تلاش کریں
14
+ ln.search.all-languages: تمام زبانیں
15
+ ln.search.all-types: تمام اقسام
16
+ ln.search.all-categories: تمام زمرے
17
+ ln.search.no-results: کوئی نتیجہ نہیں ملا
18
+ ln.details.open: کھولیں
19
+ ln.details.share: شیئر کریں
20
+ ln.details.part-of-collection: مجموعے کا حصہ
21
+ ln.details.download: ڈاؤن لوڈ کریں
22
+ ln.share.url-copied-to-clipboard: لنک کاپی ہو گیا
23
+ ln.404.page-not-found: صفحہ نہیں ملا
24
+ ln.404.go-to-the-home-page: ہوم پیج پر جائیں
25
+ ln.footer.powered-by-lightnet: لائٹ نیٹ کے ذریعے چلایا گیا
@@ -18,6 +18,7 @@ const builtInTranslations = {
18
18
  pt: () => import("./translations/pt.yml?raw"),
19
19
  ru: () => import("./translations/ru.yml?raw"),
20
20
  uk: () => import("./translations/uk.yml?raw"),
21
+ ur: () => import("./translations/ur.yml?raw"),
21
22
  zh: () => import("./translations/zh.yml?raw"),
22
23
  } as const
23
24
 
@@ -7,7 +7,6 @@ import { resolveLanguage } from "../i18n/resolve-language"
7
7
  import Favicon from "./components/Favicon.astro"
8
8
  import Footer from "./components/Footer.astro"
9
9
  import Header from "./components/Header.astro"
10
- import PreloadReact from "./components/PreloadReact"
11
10
  import ViewTransition from "./components/ViewTransition.astro"
12
11
 
13
12
  interface Props {
@@ -45,6 +44,5 @@ const { direction } = resolveLanguage(currentLocale)
45
44
  <slot />
46
45
  </main>
47
46
  {CustomFooter ? <CustomFooter /> : <Footer />}
48
- <PreloadReact client:idle />
49
47
  </body>
50
48
  </html>
@@ -24,7 +24,7 @@ const { icon, label, disabled } = Astro.props
24
24
  data-menu-panel
25
25
  aria-hidden="true"
26
26
  inert
27
- class="pointer-events-none absolute right-3 top-full mt-px flex w-48 origin-top scale-y-90 flex-col overflow-hidden rounded-b-md bg-white py-3 opacity-0 shadow-lg transition-all duration-100 ease-out"
27
+ class="pointer-events-none absolute end-3 top-full mt-px flex w-48 origin-top scale-y-90 flex-col overflow-hidden rounded-b-md bg-white py-3 opacity-0 shadow-lg transition-all duration-100 ease-out"
28
28
  >
29
29
  <slot />
30
30
  </ul>
@@ -7,7 +7,9 @@ import Page from "../layouts/Page.astro"
7
7
  <p class="pb-8 pt-20 text-center text-3xl opacity-80">
8
8
  {Astro.locals.i18n.t("ln.404.page-not-found")}
9
9
  </p>
10
- <a href={`/${Astro.locals.i18n.defaultLocale}`} class="dy-btn"
10
+ <a
11
+ href={`/${Astro.locals.i18n.defaultLocale}`}
12
+ class="rounded-2xl bg-gray-200 px-8 py-4 text-sm font-bold transition-colors ease-in-out hover:bg-gray-400"
11
13
  >{Astro.locals.i18n.t("ln.404.go-to-the-home-page")}</a
12
14
  >
13
15
  </div>
@@ -18,6 +18,7 @@ const KNOWN_EXTENSIONS: Record<
18
18
  php: { type: "link", canBeOpened: true },
19
19
  json: { type: "source", canBeOpened: true },
20
20
  xml: { type: "source", canBeOpened: true },
21
+ md: { type: "source", canBeOpened: true },
21
22
  svg: { type: "image", canBeOpened: true },
22
23
  jpg: { type: "image", canBeOpened: true },
23
24
  jpeg: { type: "image", canBeOpened: true },
@@ -62,7 +63,7 @@ export function createContentMetadata({
62
63
  const fileName = hasExtension
63
64
  ? lastPathSegment.slice(0, -(extension.length + 1))
64
65
  : undefined
65
- const label = customLabel ?? fileName ?? linkName
66
+ const label = customLabel || fileName || linkName
66
67
  const type = KNOWN_EXTENSIONS[extension]?.type ?? "link"
67
68
  const canBeOpened =
68
69
  !hasExtension || !!KNOWN_EXTENSIONS[extension]?.canBeOpened
@@ -4,18 +4,25 @@ import { useI18n } from "../../../i18n/react/use-i18n"
4
4
  export default function LoadingSkeleton() {
5
5
  const { direction } = useI18n()
6
6
  return (
7
- <div className="flex h-52 animate-pulse items-center overflow-hidden py-2 sm:h-64">
8
- <div className="h-36 w-36 shrink-0 rounded-md bg-gray-200"></div>
9
- <div className="ms-5 flex grow flex-col gap-3">
10
- <div className="h-4 w-1/2 rounded-md bg-gray-200 md:h-6"></div>
11
- <div className="h-4 w-3/4 rounded-md bg-gray-200 md:h-6"></div>
12
- <div className="h-4 w-5/6 rounded-md bg-gray-200 md:h-6"></div>
13
- </div>
14
- <Icon
15
- className="my-auto me-4 ms-2 hidden shrink-0 text-2xl text-gray-300 mdi--chevron-right sm:block"
16
- flipIcon={direction === "rtl"}
17
- ariaLabel=""
18
- />
19
- </div>
7
+ <ul>
8
+ {Array.from({ length: 8 }, (_, index) => (
9
+ <li
10
+ key={index}
11
+ className="flex h-52 animate-pulse items-center overflow-hidden py-2 sm:h-64"
12
+ >
13
+ <div className="h-36 w-36 shrink-0 rounded-md bg-gray-200"></div>
14
+ <div className="ms-5 flex grow flex-col gap-3">
15
+ <div className="h-4 w-1/2 rounded-md bg-gray-200 md:h-6"></div>
16
+ <div className="h-4 w-3/4 rounded-md bg-gray-200 md:h-6"></div>
17
+ <div className="h-4 w-5/6 rounded-md bg-gray-200 md:h-6"></div>
18
+ </div>
19
+ <Icon
20
+ className="my-auto me-4 ms-2 hidden shrink-0 text-2xl text-gray-300 mdi--chevron-right sm:block"
21
+ flipIcon={direction === "rtl"}
22
+ ariaLabel=""
23
+ />
24
+ </li>
25
+ ))}
26
+ </ul>
20
27
  )
21
28
  }
@@ -45,10 +45,10 @@ export default function SearchFilter({
45
45
 
46
46
  return (
47
47
  <>
48
- <label className="dy-input dy-input-bordered mb-2 flex items-center gap-2 rounded-2xl">
48
+ <label className="mb-2 flex items-center gap-2 rounded-2xl border border-gray-300 bg-white px-4 py-3 shadow-inner outline outline-2 outline-offset-2 outline-transparent transition-all ease-in-out focus-within:outline-gray-300">
49
49
  <input
50
50
  type="search"
51
- className="grow placeholder-gray-500"
51
+ className="grow placeholder-gray-500 focus:outline-none"
52
52
  name="search"
53
53
  ref={searchInput}
54
54
  placeholder={t("ln.search.placeholder")}
@@ -1,5 +1,5 @@
1
1
  import { useWindowVirtualizer } from "@tanstack/react-virtual"
2
- import { useEffect, useRef, useState } from "react"
2
+ import { useCallback, useEffect, useRef, useState } from "react"
3
3
 
4
4
  import {
5
5
  createI18n,
@@ -38,11 +38,15 @@ export default function SearchList({
38
38
  mediaTypes,
39
39
  })
40
40
  const count = isLoading ? mediaItemsTotal : results.length
41
+ const getItemKey = useCallback(
42
+ (index: number) => (isLoading ? index : results[index].id),
43
+ [isLoading, results],
44
+ )
41
45
 
42
46
  const virtualizer = useWindowVirtualizer({
43
47
  count,
44
48
  estimateSize: () => rowHeight,
45
- getItemKey: (index) => (isLoading ? index : results[index].id),
49
+ getItemKey,
46
50
  overscan: 2,
47
51
  scrollMargin: listRef.current?.offsetTop ?? 0,
48
52
  })
@@ -68,28 +72,28 @@ export default function SearchList({
68
72
  return (
69
73
  <I18nContext.Provider value={i18n}>
70
74
  <div ref={listRef} className="px-4 md:px-8">
71
- <ol
72
- className="relative w-full divide-y divide-gray-200"
73
- style={{
74
- height: `${virtualizer.getTotalSize()}px`,
75
- }}
76
- >
77
- {virtualizer.getVirtualItems().map((virtualRow) => {
78
- const item = results[virtualRow.index]
79
- return (
80
- <li
81
- key={virtualRow.key}
82
- className="absolute left-0 top-0 block w-full"
83
- style={{
84
- height: `${virtualRow.size}px`,
85
- transform: `translateY(${
86
- virtualRow.start - virtualizer.options.scrollMargin
87
- }px)`,
88
- }}
89
- >
90
- {isLoading ? (
91
- <LoadingSkeleton />
92
- ) : (
75
+ {isLoading ? (
76
+ <LoadingSkeleton />
77
+ ) : (
78
+ <ol
79
+ className="relative w-full divide-y divide-gray-200"
80
+ style={{
81
+ height: `${virtualizer.getTotalSize()}px`,
82
+ }}
83
+ >
84
+ {virtualizer.getVirtualItems().map((virtualRow) => {
85
+ const item = results[virtualRow.index]
86
+ return (
87
+ <li
88
+ key={virtualRow.key}
89
+ className="absolute left-0 top-0 block w-full"
90
+ style={{
91
+ height: `${virtualRow.size}px`,
92
+ transform: `translateY(${
93
+ virtualRow.start - virtualizer.options.scrollMargin
94
+ }px)`,
95
+ }}
96
+ >
93
97
  <SearchListItem
94
98
  item={item}
95
99
  showLanguage={showLanguage}
@@ -97,11 +101,11 @@ export default function SearchList({
97
101
  languages={languages}
98
102
  mediaTypes={mediaTypes}
99
103
  />
100
- )}
101
- </li>
102
- )
103
- })}
104
- </ol>
104
+ </li>
105
+ )
106
+ })}
107
+ </ol>
108
+ )}
105
109
  </div>
106
110
  {!results.length && !isLoading && (
107
111
  <div className="mt-24 text-center font-bold text-gray-500">
@@ -12,21 +12,23 @@ export default function Select({
12
12
  options,
13
13
  }: Props) {
14
14
  return (
15
- <label className="dy-form-control">
16
- <span className="my-2 block text-xs font-bold uppercase text-gray-500">
15
+ <label>
16
+ <span className="mb-1 mt-2 block text-xs font-bold uppercase text-gray-600">
17
17
  {label}
18
18
  </span>
19
- <select
20
- className="dy-select dy-select-bordered w-full rounded-xl sm:dy-select-sm"
21
- value={initialValue}
22
- onChange={(e) => valueChange(e.currentTarget.value)}
23
- >
24
- {options.map(({ id, labelText }) => (
25
- <option key={id} value={id}>
26
- {labelText}
27
- </option>
28
- ))}
29
- </select>
19
+ <div className="rounded-2xl border border-gray-300 bg-white px-4 py-3 shadow-sm outline outline-2 outline-offset-2 outline-transparent transition-all ease-in-out focus-within:outline-gray-300 sm:p-2">
20
+ <select
21
+ className="w-full bg-white focus:outline-none sm:text-sm"
22
+ value={initialValue}
23
+ onChange={(e) => valueChange(e.currentTarget.value)}
24
+ >
25
+ {options.map(({ id, labelText }) => (
26
+ <option key={id} value={id}>
27
+ {labelText}
28
+ </option>
29
+ ))}
30
+ </select>
31
+ </div>
30
32
  </label>
31
33
  )
32
34
  }
@@ -1,3 +0,0 @@
1
- export default function PreloadReact() {
2
- return <></>
3
- }