lightnet 3.4.6 → 3.6.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 (65) hide show
  1. package/CHANGELOG.md +67 -10
  2. package/__e2e__/fixtures/basics/node_modules/.bin/astro +2 -2
  3. package/__e2e__/fixtures/basics/package.json +2 -2
  4. package/exports/components.ts +2 -0
  5. package/exports/content.ts +13 -3
  6. package/package.json +8 -7
  7. package/src/astro-integration/config.ts +6 -3
  8. package/src/components/CategoriesSection.astro +69 -19
  9. package/src/components/HeroSection.astro +15 -7
  10. package/src/components/HighlightSection.astro +1 -1
  11. package/src/components/MediaGallery.astro +2 -2
  12. package/src/components/MediaList.astro +2 -2
  13. package/src/components/SearchInput.astro +30 -0
  14. package/src/components/SearchSection.astro +16 -0
  15. package/src/components/Section.astro +20 -4
  16. package/src/content/astro-image.ts +10 -0
  17. package/src/content/compare-media-collection-items.ts +1 -1
  18. package/src/content/content-schema.ts +38 -6
  19. package/src/content/get-categories.ts +64 -6
  20. package/src/content/get-media-items.ts +1 -1
  21. package/src/content/get-media-types.ts +1 -1
  22. package/src/content/query-media-items.ts +1 -1
  23. package/src/i18n/translations/TRANSLATION-STATUS.md +33 -1
  24. package/src/i18n/translations/ar.yml +22 -0
  25. package/src/i18n/translations/bn.yml +22 -0
  26. package/src/i18n/translations/de.yml +1 -2
  27. package/src/i18n/translations/en.yml +3 -8
  28. package/src/i18n/translations/es.yml +22 -0
  29. package/src/i18n/translations/fi.yml +22 -0
  30. package/src/i18n/translations/fr.yml +22 -0
  31. package/src/i18n/translations/hi.yml +22 -0
  32. package/src/i18n/translations/pt.yml +22 -0
  33. package/src/i18n/translations/ru.yml +1 -2
  34. package/src/i18n/translations/uk.yml +1 -2
  35. package/src/i18n/translations/zh.yml +22 -0
  36. package/src/i18n/translations.ts +10 -3
  37. package/src/layouts/MarkdownPage.astro +7 -1
  38. package/src/layouts/Page.astro +1 -0
  39. package/src/layouts/components/PageNavigation.astro +13 -7
  40. package/src/pages/api/search.ts +1 -1
  41. package/src/pages/details-page/components/main-details/AudioPlayer.astro +1 -1
  42. package/src/pages/details-page/components/main-details/Cover.astro +1 -0
  43. package/src/pages/details-page/components/main-details/Title.astro +1 -1
  44. package/src/pages/details-page/components/more-details/Categories.astro +11 -4
  45. package/src/pages/details-page/utils/get-collection-items.ts +1 -1
  46. package/src/pages/search-page/SearchPageRoute.astro +10 -40
  47. package/src/pages/search-page/components/LoadingSkeleton.tsx +19 -0
  48. package/src/pages/search-page/components/SearchFilter.astro +65 -0
  49. package/src/pages/search-page/components/SearchFilter.tsx +33 -84
  50. package/src/pages/search-page/components/SearchList.astro +50 -0
  51. package/src/pages/search-page/components/SearchList.tsx +117 -0
  52. package/src/pages/search-page/components/SearchListItem.tsx +105 -0
  53. package/src/pages/search-page/components/Select.tsx +5 -5
  54. package/src/pages/search-page/hooks/use-search-query-param.ts +31 -0
  55. package/src/pages/search-page/hooks/use-search.ts +103 -49
  56. package/src/pages/search-page/utils/search-filter-translations.ts +20 -0
  57. package/src/pages/search-page/utils/search-query.ts +76 -0
  58. package/src/pages/search-page/utils/search-translations.ts +1 -12
  59. package/src/content/content-schema-internal.ts +0 -52
  60. package/src/content/external-api.ts +0 -7
  61. package/src/content/resolve-category-label.ts +0 -20
  62. package/src/pages/search-page/Search.tsx +0 -71
  63. package/src/pages/search-page/components/ResultList.tsx +0 -135
  64. package/src/pages/search-page/types.ts +0 -11
  65. package/src/pages/search-page/utils/use-provided-translations.ts +0 -5
@@ -1,5 +1,5 @@
1
1
  import { compareMediaCollectionItems } from "../../../content/compare-media-collection-items"
2
- import type { MediaItemEntry } from "../../../content/content-schema-internal"
2
+ import type { MediaItemEntry } from "../../../content/content-schema"
3
3
  import { getMediaItems } from "../../../content/get-media-items"
4
4
 
5
5
  const groupItemsByCollections = async () => {
@@ -1,51 +1,21 @@
1
1
  ---
2
- import config from "virtual:lightnet/config"
3
-
4
- import { getCategories } from "../../content/get-categories"
5
- import { contentLanguages } from "../../content/get-languages"
6
- import { getMediaTypes } from "../../content/get-media-types"
7
2
  import Page from "../../layouts/Page.astro"
8
- import Search from "./Search"
9
- import { provideTranslations } from "./utils/search-translations"
3
+ import SearchFilter from "./components/SearchFilter.astro"
4
+ import SearchList from "./components/SearchList.astro"
10
5
 
11
6
  export { getLocalePaths as getStaticPaths } from "../../i18n/get-locale-paths"
12
7
 
13
- const { t, currentLocale } = Astro.locals.i18n
14
-
15
- const categories: Record<string, string> = {}
16
- for (const { id, name } of await getCategories(currentLocale, t)) {
17
- categories[id] = name
18
- }
19
-
20
- const mediaTypes = (await getMediaTypes())
21
- .map((type) => ({
22
- id: type.id,
23
- label: t(type.data.label),
24
- icon: type.data.icon,
25
- }))
26
- .sort((a, b) => a.label.localeCompare(b.label, currentLocale))
27
-
28
- const translatedContentLanguages = contentLanguages
29
- .map((language) => ({
30
- ...language,
31
- name: t(language.label),
32
- }))
33
- .sort((a, b) => a.name.localeCompare(b.name, currentLocale))
34
-
35
- const filterByLocale = !!config.searchPage?.filterByLocale
8
+ const { t } = Astro.locals.i18n
36
9
  ---
37
10
 
38
11
  <Page>
39
12
  <div class="mx-auto max-w-screen-md">
40
- <Search
41
- client:only="react"
42
- contentLanguages={translatedContentLanguages}
43
- mediaTypes={mediaTypes}
44
- translations={provideTranslations(t)}
45
- locale={currentLocale}
46
- direction={Astro.locals.i18n.direction}
47
- categories={categories}
48
- filterByLocale={filterByLocale}
49
- />
13
+ <div class="px-4 md:px-8">
14
+ <h1 class="mb-4 mt-8 text-balance text-4xl md:mb-8 md:mt-12 md:text-5xl">
15
+ {t("ln.search.title")}
16
+ </h1>
17
+ <SearchFilter />
18
+ </div>
19
+ <SearchList />
50
20
  </div>
51
21
  </Page>
@@ -0,0 +1,19 @@
1
+ import Icon from "../../../components/Icon"
2
+
3
+ export default function LoadingSkeleton({ direction }: { direction: string }) {
4
+ return (
5
+ <div className="flex h-52 animate-pulse items-center overflow-hidden py-2 sm:h-64">
6
+ <div className="h-36 w-36 shrink-0 rounded-md bg-gray-200"></div>
7
+ <div className="ms-5 flex grow flex-col gap-3">
8
+ <div className="h-4 w-1/2 rounded-md bg-gray-200 md:h-6"></div>
9
+ <div className="h-4 w-3/4 rounded-md bg-gray-200 md:h-6"></div>
10
+ <div className="h-4 w-5/6 rounded-md bg-gray-200 md:h-6"></div>
11
+ </div>
12
+ <Icon
13
+ className="mdi--chevron-right my-auto me-4 ms-2 hidden shrink-0 text-2xl text-gray-300 sm:block"
14
+ flipIcon={direction === "rtl"}
15
+ ariaLabel=""
16
+ />
17
+ </div>
18
+ )
19
+ }
@@ -0,0 +1,65 @@
1
+ ---
2
+ import config from "virtual:lightnet/config"
3
+
4
+ import { getUsedCategories } from "../../../content/get-categories"
5
+ import { contentLanguages } from "../../../content/get-languages"
6
+ import { getMediaTypes } from "../../../content/get-media-types"
7
+ import { provideTranslations } from "../utils/search-filter-translations"
8
+ import SearchFilterReact from "./SearchFilter.tsx"
9
+
10
+ const { t, currentLocale } = Astro.locals.i18n
11
+
12
+ const sortByName = (array: { id: string; name: string }[]) =>
13
+ array.sort((a, b) => a.name.localeCompare(b.name, currentLocale))
14
+
15
+ const categories = (await getUsedCategories(currentLocale, t)).map(
16
+ ({ id, name }) => ({ id, name }),
17
+ )
18
+
19
+ const mediaTypes = sortByName(
20
+ (await getMediaTypes()).map((type) => ({
21
+ id: type.id,
22
+ name: t(type.data.label),
23
+ })),
24
+ )
25
+
26
+ const languages = sortByName(
27
+ contentLanguages.map((language) => ({
28
+ id: language.code,
29
+ name: t(language.label),
30
+ })),
31
+ )
32
+
33
+ const languageFilterEnabled = contentLanguages.length > 1
34
+ const typesFilterEnabled = mediaTypes.length > 1
35
+ // Not every media item has a category. So it makes
36
+ // sense to have the filter when there is only one category.
37
+ const categoriesFilterEnabled = categories.length > 0
38
+
39
+ const filterByLocale = !!config.searchPage?.filterByLocale
40
+ let initialLanguageFilter: string | undefined = undefined
41
+ const hasContentLanguage = contentLanguages.find(
42
+ ({ code }) => code === currentLocale,
43
+ )
44
+ if (
45
+ filterByLocale &&
46
+ currentLocale &&
47
+ hasContentLanguage &&
48
+ languageFilterEnabled
49
+ ) {
50
+ initialLanguageFilter = currentLocale
51
+ }
52
+ const translations = provideTranslations(t)
53
+ ---
54
+
55
+ <SearchFilterReact
56
+ client:load
57
+ languages={languages}
58
+ mediaTypes={mediaTypes}
59
+ categories={categories}
60
+ translations={translations}
61
+ languageFilterEnabled={languageFilterEnabled}
62
+ typesFilterEnabled={typesFilterEnabled}
63
+ categoriesFilterEnabled={categoriesFilterEnabled}
64
+ initialLanguage={initialLanguageFilter}
65
+ />
@@ -1,104 +1,58 @@
1
- import { useEffect, useRef, useState } from "react"
1
+ import { useRef } from "react"
2
2
 
3
3
  import Icon from "../../../components/Icon"
4
4
  import { useDebounce } from "../hooks/use-debounce"
5
- import type { SearchQuery } from "../hooks/use-search"
6
- import type { MediaType, TranslatedLanguage } from "../types"
7
- import type { Translations } from "../utils/search-translations"
8
- import { useProvidedTranslations } from "../utils/use-provided-translations"
5
+ import { useSearchQueryParam } from "../hooks/use-search-query-param"
6
+ import type {
7
+ TranslationKey,
8
+ Translations,
9
+ } from "../utils/search-filter-translations"
10
+ import { CATEGORY, LANGUAGE, SEARCH, TYPE } from "../utils/search-query"
9
11
  import Select from "./Select"
10
12
 
11
- // URL search params
12
- const SEARCH = "search"
13
- const LANGUAGE = "language"
14
- const TYPE = "type"
15
- const CATEGORY = "category"
13
+ type FilterValue = { id: string; name: string }
16
14
 
17
15
  interface Props {
18
- contentLanguages: TranslatedLanguage[]
19
- categories: Record<string, string>
20
- mediaTypes: MediaType[]
21
- locale?: string
16
+ languages: FilterValue[]
17
+ categories: FilterValue[]
18
+ mediaTypes: FilterValue[]
22
19
  translations: Translations
23
- filterByLocale: boolean
24
- updateQuery: (query: SearchQuery) => void
20
+ languageFilterEnabled: boolean
21
+ typesFilterEnabled: boolean
22
+ categoriesFilterEnabled: boolean
23
+ initialLanguage?: string
25
24
  }
26
25
 
27
26
  export default function SearchFilter({
28
27
  categories,
29
28
  mediaTypes,
30
- updateQuery,
31
29
  translations,
32
- filterByLocale,
33
- locale,
34
- contentLanguages,
30
+ languages,
31
+ languageFilterEnabled,
32
+ typesFilterEnabled,
33
+ categoriesFilterEnabled,
34
+ initialLanguage,
35
35
  }: Props) {
36
- const languageFilterEnabled = contentLanguages.length > 1
37
- const typesFilterEnabled = mediaTypes.length > 1
38
- // Not every media item has a category. So it makes
39
- // sense to have the filter when there is only one category.
40
- const categoriesFilterEnabled = Object.keys(categories).length > 0
41
-
42
- const getSearchParam = (name: string, defaultValue = "") => {
43
- // be lazy to avoid parsing search params all the time
44
- return () => {
45
- const searchParams = new URLSearchParams(window.location.search)
46
- return searchParams.get(name) ?? defaultValue
47
- }
48
- }
49
-
50
- const [search, setSearch] = useState(getSearchParam(SEARCH))
51
- const [language, setLanguage] = useState(() => {
52
- let initialLanguageFilter = ""
53
- const hasContentLanguage = contentLanguages.find(
54
- ({ code }) => code === locale,
55
- )
56
- if (
57
- filterByLocale &&
58
- locale &&
59
- hasContentLanguage &&
60
- languageFilterEnabled
61
- ) {
62
- initialLanguageFilter = locale
63
- }
64
- return getSearchParam(LANGUAGE, initialLanguageFilter)()
65
- })
66
- const [type, setType] = useState(getSearchParam(TYPE))
67
- const [category, setCategory] = useState(getSearchParam(CATEGORY))
36
+ const [search, setSearch] = useSearchQueryParam(SEARCH)
37
+ const [language, setLanguage] = useSearchQueryParam(LANGUAGE, initialLanguage)
38
+ const [type, setType] = useSearchQueryParam(TYPE)
39
+ const [category, setCategory] = useSearchQueryParam(CATEGORY)
68
40
 
69
41
  const searchInput = useRef<HTMLInputElement | null>(null)
70
42
 
71
- const t = useProvidedTranslations(translations)
43
+ const t = (key: TranslationKey) => translations[key]
72
44
 
73
45
  const debouncedSetSearch = useDebounce((value: string) => {
74
46
  setSearch(value)
75
47
  }, 300)
76
48
 
77
- useEffect(() => {
78
- const url = new URL(window.location.href)
79
- const updateSearchParam = (name: string, value: string) => {
80
- // Only update when value before and after are different and both are non empty.
81
- if (value === (url.searchParams.get(name) ?? "")) {
82
- return
83
- }
84
- url.searchParams.set(name, value)
85
- }
86
-
87
- updateSearchParam(SEARCH, search)
88
- updateSearchParam(LANGUAGE, language)
89
- updateSearchParam(TYPE, type)
90
- updateSearchParam(CATEGORY, category)
91
- history.replaceState({ ...history.state }, "", url.toString())
92
-
93
- updateQuery({ search, language, type, category })
94
- }, [search, language, type, category])
95
-
96
49
  return (
97
50
  <>
98
- <label className="dy-input dy-input-bordered mb-2 flex items-center gap-2">
51
+ <label className="dy-input dy-input-bordered mb-2 flex items-center gap-2 rounded-2xl">
99
52
  <input
100
53
  type="search"
101
- className="grow"
54
+ className="grow placeholder-gray-500"
55
+ name="search"
102
56
  ref={searchInput}
103
57
  placeholder={t("ln.search.placeholder")}
104
58
  enterKeyHint="search"
@@ -115,11 +69,8 @@ export default function SearchFilter({
115
69
  initialValue={language}
116
70
  valueChange={(val) => setLanguage(val)}
117
71
  options={[
118
- { id: "", label: t("ln.search.all-languages") },
119
- ...contentLanguages.map(({ code: id, name: label }) => ({
120
- id,
121
- label,
122
- })),
72
+ { id: "", name: t("ln.search.all-languages") },
73
+ ...languages,
123
74
  ]}
124
75
  />
125
76
  )}
@@ -130,7 +81,7 @@ export default function SearchFilter({
130
81
  initialValue={type}
131
82
  valueChange={(val) => setType(val)}
132
83
  options={[
133
- { id: "", label: t("ln.search.all-types") },
84
+ { id: "", name: t("ln.search.all-types") },
134
85
  ...mediaTypes,
135
86
  ]}
136
87
  />
@@ -142,10 +93,8 @@ export default function SearchFilter({
142
93
  initialValue={category}
143
94
  valueChange={(val) => setCategory(val)}
144
95
  options={[
145
- { id: "", label: t("ln.search.all-categories") },
146
- ...Object.entries(categories)
147
- .sort((a, b) => a[1].localeCompare(b[1], locale))
148
- .map(([id, label]) => ({ id, label })),
96
+ { id: "", name: t("ln.search.all-categories") },
97
+ ...categories,
149
98
  ]}
150
99
  />
151
100
  )}
@@ -0,0 +1,50 @@
1
+ ---
2
+ import { getCollection } from "astro:content"
3
+
4
+ import { getUsedCategories } from "../../../content/get-categories"
5
+ import { contentLanguages } from "../../../content/get-languages"
6
+ import { getMediaTypes } from "../../../content/get-media-types"
7
+ import { provideTranslations } from "../utils/search-translations"
8
+ import SearchListReact from "./SearchList.tsx"
9
+
10
+ const { t, currentLocale, direction } = Astro.locals.i18n
11
+
12
+ const categories: Record<string, string> = {}
13
+ for (const { id, name } of await getUsedCategories(currentLocale, t)) {
14
+ categories[id] = name
15
+ }
16
+
17
+ const mediaTypes = Object.fromEntries(
18
+ (await getMediaTypes()).map((type) => [
19
+ type.id,
20
+ {
21
+ name: t(type.data.label),
22
+ icon: type.data.icon,
23
+ },
24
+ ]),
25
+ )
26
+
27
+ const languages = Object.fromEntries(
28
+ contentLanguages.map((language) => [
29
+ language.code,
30
+ { direction: language.direction, name: t(language.label) },
31
+ ]),
32
+ )
33
+
34
+ const mediaItemsTotal = (await getCollection("media")).length
35
+
36
+ const translations = provideTranslations(t)
37
+ const showLanguage = contentLanguages.length > 1
38
+ ---
39
+
40
+ <SearchListReact
41
+ client:load
42
+ currentLocale={currentLocale}
43
+ direction={direction}
44
+ translations={translations}
45
+ categories={categories}
46
+ mediaTypes={mediaTypes}
47
+ languages={languages}
48
+ showLanguage={showLanguage}
49
+ mediaItemsTotal={mediaItemsTotal}
50
+ />
@@ -0,0 +1,117 @@
1
+ import { useWindowVirtualizer } from "@tanstack/react-virtual"
2
+ import { useEffect, useRef, useState } from "react"
3
+
4
+ import { useSearch } from "../hooks/use-search"
5
+ import type { TranslationKey, Translations } from "../utils/search-translations"
6
+ import LoadingSkeleton from "./LoadingSkeleton"
7
+ import SearchListItem, {
8
+ type MediaType,
9
+ type TranslatedLanguage,
10
+ } from "./SearchListItem"
11
+
12
+ interface Props {
13
+ currentLocale: string | undefined
14
+ translations: Translations
15
+ direction: "rtl" | "ltr"
16
+ categories: Record<string, string>
17
+ languages: Record<string, TranslatedLanguage>
18
+ showLanguage: boolean
19
+ mediaTypes: Record<string, MediaType>
20
+ mediaItemsTotal: number
21
+ }
22
+
23
+ export default function SearchList({
24
+ currentLocale,
25
+ categories,
26
+ translations,
27
+ languages,
28
+ direction,
29
+ showLanguage,
30
+ mediaTypes,
31
+ mediaItemsTotal,
32
+ }: Props) {
33
+ const listRef = useRef<HTMLDivElement | null>(null)
34
+ const [rowHeight, setRowHeight] = useState(256)
35
+ const { results, isLoading } = useSearch({
36
+ currentLocale,
37
+ categories,
38
+ languages,
39
+ mediaTypes,
40
+ })
41
+ const count = isLoading ? mediaItemsTotal : results.length
42
+
43
+ const virtualizer = useWindowVirtualizer({
44
+ count,
45
+ estimateSize: () => rowHeight,
46
+ getItemKey: (index) => (isLoading ? index : results[index].id),
47
+ overscan: 2,
48
+ scrollMargin: listRef.current?.offsetTop ?? 0,
49
+ })
50
+
51
+ useEffect(() => {
52
+ const updateRowHeight = () => {
53
+ // This is the fixed row heights that have a responsive breakpoint
54
+ // If you update here, also update the initial height
55
+ const newRowHeight = window.matchMedia("(min-width: 640px)").matches
56
+ ? 256
57
+ : 208
58
+ setRowHeight(newRowHeight)
59
+ }
60
+ const observer = new ResizeObserver(() => updateRowHeight())
61
+ observer.observe(document.body)
62
+ updateRowHeight()
63
+ return () => {
64
+ observer.disconnect()
65
+ }
66
+ }, [])
67
+
68
+ const t = (key: TranslationKey) => translations[key]
69
+
70
+ return (
71
+ <>
72
+ <div ref={listRef} className="px-4 md:px-8">
73
+ <ol
74
+ className="relative w-full divide-y divide-gray-200"
75
+ style={{
76
+ height: `${virtualizer.getTotalSize()}px`,
77
+ }}
78
+ >
79
+ {virtualizer.getVirtualItems().map((virtualRow) => {
80
+ const item = results[virtualRow.index]
81
+ return (
82
+ <li
83
+ key={virtualRow.key}
84
+ className="absolute left-0 top-0 block w-full"
85
+ style={{
86
+ height: `${virtualRow.size}px`,
87
+ transform: `translateY(${
88
+ virtualRow.start - virtualizer.options.scrollMargin
89
+ }px)`,
90
+ }}
91
+ >
92
+ {isLoading ? (
93
+ <LoadingSkeleton direction={direction} />
94
+ ) : (
95
+ <SearchListItem
96
+ item={item}
97
+ direction={direction}
98
+ showLanguage={showLanguage}
99
+ currentLocale={currentLocale}
100
+ categories={categories}
101
+ languages={languages}
102
+ mediaTypes={mediaTypes}
103
+ />
104
+ )}
105
+ </li>
106
+ )
107
+ })}
108
+ </ol>
109
+ </div>
110
+ {!results.length && !isLoading && (
111
+ <div className="mt-24 text-center font-bold text-gray-500">
112
+ {t("ln.search.no-results")}
113
+ </div>
114
+ )}
115
+ </>
116
+ )
117
+ }
@@ -0,0 +1,105 @@
1
+ import Icon from "../../../components/Icon"
2
+ import { detailsPagePath } from "../../../utils/paths"
3
+ import type { SearchItem } from "../../api/search-response"
4
+
5
+ export type MediaType = {
6
+ name: string
7
+ icon: string
8
+ }
9
+
10
+ export type TranslatedLanguage = {
11
+ name: string
12
+ direction: "rtl" | "ltr"
13
+ }
14
+
15
+ interface Props {
16
+ item: SearchItem
17
+ currentLocale: string | undefined
18
+ direction: "rtl" | "ltr"
19
+ categories: Record<string, string>
20
+ languages: Record<string, TranslatedLanguage>
21
+ showLanguage: boolean
22
+ mediaTypes: Record<string, MediaType>
23
+ }
24
+
25
+ export default function SearchListItem({
26
+ item,
27
+ currentLocale,
28
+ categories,
29
+ languages,
30
+ direction,
31
+ showLanguage,
32
+ mediaTypes,
33
+ }: Props) {
34
+ return (
35
+ <a
36
+ href={detailsPagePath(currentLocale, {
37
+ id: item.id,
38
+ })}
39
+ lang={item.language}
40
+ className="group flex h-52 overflow-hidden py-2 transition-colors ease-in-out sm:h-64 md:rounded-sm md:hover:bg-gray-100"
41
+ >
42
+ <div className="flex h-full w-36 shrink-0 flex-col items-start justify-center">
43
+ <img
44
+ className="max-h-36 w-auto max-w-36 rounded-sm object-contain shadow-md"
45
+ src={item.image.src}
46
+ width={item.image.width}
47
+ height={item.image.height}
48
+ alt=""
49
+ decoding="async"
50
+ loading="eager"
51
+ />
52
+ </div>
53
+
54
+ <div className="ms-5 flex grow flex-col justify-center text-xs sm:ms-8">
55
+ <h2 className="mb-1 line-clamp-2 text-balance text-sm font-bold text-gray-700 md:mb-3 md:text-base">
56
+ <Icon
57
+ className={`${mediaTypes[item.type].icon} me-2 align-bottom text-2xl text-gray-700`}
58
+ ariaLabel={mediaTypes[item.type].name}
59
+ />
60
+ <span>{item.title}</span>
61
+ </h2>
62
+ <div className="flex flex-col flex-wrap items-start gap-2 sm:mb-3 md:flex-row md:items-center md:gap-3">
63
+ {!!item.authors?.length && (
64
+ <p className="mb-1 line-clamp-2 sm:line-clamp-1 md:mb-0 md:text-base">
65
+ {item.authors.join(", ")}
66
+ </p>
67
+ )}
68
+ {showLanguage && (
69
+ <span className="rounded-lg border border-gray-300 px-2 py-1 text-gray-500">
70
+ {languages[item.language].name}
71
+ </span>
72
+ )}
73
+ <ul
74
+ lang={currentLocale}
75
+ className="flex max-h-14 flex-wrap gap-1 overflow-hidden sm:max-h-6"
76
+ >
77
+ {item.categories?.map((category) => (
78
+ <li
79
+ key={category}
80
+ className="rounded-lg bg-gray-200 px-2 py-1 text-gray-600"
81
+ >
82
+ {categories[category]}
83
+ </li>
84
+ ))}
85
+ </ul>
86
+ </div>
87
+
88
+ <div className="hidden sm:block">
89
+ <p
90
+ className="line-clamp-3 max-w-screen-sm text-xs"
91
+ lang={item.language}
92
+ dir={languages[item.language].direction}
93
+ >
94
+ {item.description}
95
+ </p>
96
+ </div>
97
+ </div>
98
+ <Icon
99
+ className="mdi--chevron-right md:group-hover:text-primary my-auto me-4 ms-2 hidden shrink-0 text-2xl text-gray-300 sm:block"
100
+ flipIcon={direction === "rtl"}
101
+ ariaLabel=""
102
+ />
103
+ </a>
104
+ )
105
+ }
@@ -1,8 +1,8 @@
1
1
  type Props = {
2
2
  label: string
3
- initialValue: string
3
+ initialValue: string | undefined
4
4
  valueChange: (value: string) => void
5
- options: { id: string; label: string }[]
5
+ options: { id: string; name: string }[]
6
6
  }
7
7
 
8
8
  export default function Select({
@@ -17,13 +17,13 @@ export default function Select({
17
17
  {label}
18
18
  </span>
19
19
  <select
20
- className="dy-select dy-select-bordered sm:dy-select-sm w-full"
20
+ className="dy-select dy-select-bordered sm:dy-select-sm w-full rounded-xl"
21
21
  value={initialValue}
22
22
  onChange={(e) => valueChange(e.currentTarget.value)}
23
23
  >
24
- {options.map(({ id, label }) => (
24
+ {options.map(({ id, name }) => (
25
25
  <option key={id} value={id}>
26
- {label}
26
+ {name}
27
27
  </option>
28
28
  ))}
29
29
  </select>
@@ -0,0 +1,31 @@
1
+ import { useEffect, useState } from "react"
2
+
3
+ import {
4
+ getSearchQueryParam,
5
+ type SearchQueryParam,
6
+ setSearchQueryParam,
7
+ } from "../utils/search-query"
8
+
9
+ export function useSearchQueryParam(
10
+ paramName: SearchQueryParam,
11
+ initialValue?: string,
12
+ ) {
13
+ const [state, updateState] = useState(initialValue)
14
+ const updateParam = (value: string) => {
15
+ setSearchQueryParam(paramName, value)
16
+ updateState(value)
17
+ }
18
+ // sync with url query param
19
+ useEffect(() => {
20
+ // if there is already a parameter set on the url
21
+ // override the initial state
22
+ if (getSearchQueryParam(paramName) !== undefined) {
23
+ updateState(getSearchQueryParam(paramName))
24
+ return
25
+ }
26
+ if (initialValue !== undefined) {
27
+ setSearchQueryParam(paramName, initialValue)
28
+ }
29
+ }, [])
30
+ return [state, updateParam] as const
31
+ }