lightnet 2.15.2

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 (122) hide show
  1. package/CHANGELOG.md +428 -0
  2. package/LICENSE +21 -0
  3. package/README.md +1 -0
  4. package/__e2e__/detailPage.spec.ts +0 -0
  5. package/__e2e__/fixtures/basics/astro.config.mjs +38 -0
  6. package/__e2e__/fixtures/basics/node_modules/.bin/astro +17 -0
  7. package/__e2e__/fixtures/basics/node_modules/.bin/astro-check +17 -0
  8. package/__e2e__/fixtures/basics/node_modules/.bin/tailwind +17 -0
  9. package/__e2e__/fixtures/basics/node_modules/.bin/tailwindcss +17 -0
  10. package/__e2e__/fixtures/basics/node_modules/.bin/tsc +17 -0
  11. package/__e2e__/fixtures/basics/node_modules/.bin/tsserver +17 -0
  12. package/__e2e__/fixtures/basics/package.json +19 -0
  13. package/__e2e__/fixtures/basics/public/favicon.svg +1 -0
  14. package/__e2e__/fixtures/basics/public/files/example.pdf +0 -0
  15. package/__e2e__/fixtures/basics/src/assets/logo.png +0 -0
  16. package/__e2e__/fixtures/basics/src/content/categories/christian-living.json +3 -0
  17. package/__e2e__/fixtures/basics/src/content/categories/teens.json +3 -0
  18. package/__e2e__/fixtures/basics/src/content/categories/theology.json +3 -0
  19. package/__e2e__/fixtures/basics/src/content/media/faithful-freestyle--en.json +13 -0
  20. package/__e2e__/fixtures/basics/src/content/media/how-to-kickflip--de.json +12 -0
  21. package/__e2e__/fixtures/basics/src/content/media/images/cover.jpg +0 -0
  22. package/__e2e__/fixtures/basics/src/content/media/images/how-to-kickflip--en.webp +0 -0
  23. package/__e2e__/fixtures/basics/src/content/media-collections/how-to-articles.json +3 -0
  24. package/__e2e__/fixtures/basics/src/content/media-types/book.json +9 -0
  25. package/__e2e__/fixtures/basics/src/content/media-types/video.json +7 -0
  26. package/__e2e__/fixtures/basics/src/content.config.ts +3 -0
  27. package/__e2e__/fixtures/basics/src/pages/[locale]/index.astro +14 -0
  28. package/__e2e__/fixtures/basics/src/translations/de.json +10 -0
  29. package/__e2e__/fixtures/basics/src/translations/en.json +10 -0
  30. package/__e2e__/fixtures/basics/tailwind.config.mjs +8 -0
  31. package/__e2e__/homepage.spec.ts +108 -0
  32. package/__e2e__/search.spec.ts +16 -0
  33. package/__e2e__/test-utils.ts +80 -0
  34. package/__tests__/pages/details-page/create-content-metadata.spec.ts +104 -0
  35. package/__tests__/utils/markdown.spec.ts +33 -0
  36. package/exports/components.ts +9 -0
  37. package/exports/content.ts +8 -0
  38. package/exports/details-page.ts +1 -0
  39. package/exports/i18n.ts +2 -0
  40. package/exports/index.ts +6 -0
  41. package/exports/utils.ts +2 -0
  42. package/package.json +54 -0
  43. package/playwright.config.ts +30 -0
  44. package/src/astro-integration/config.ts +185 -0
  45. package/src/astro-integration/integration.ts +74 -0
  46. package/src/astro-integration/project-context.ts +5 -0
  47. package/src/astro-integration/virtual.d.ts +14 -0
  48. package/src/astro-integration/vite-plugin-lightnet-config.ts +55 -0
  49. package/src/components/CategoriesOverview.astro +37 -0
  50. package/src/components/Gallery.astro +121 -0
  51. package/src/components/Hero.astro +82 -0
  52. package/src/components/HighlightSection.astro +71 -0
  53. package/src/components/Icon.tsx +27 -0
  54. package/src/components/MediaItemList.astro +84 -0
  55. package/src/components/Section.astro +49 -0
  56. package/src/content/astro-image.ts +14 -0
  57. package/src/content/content-schema-internal.ts +52 -0
  58. package/src/content/content-schema.ts +263 -0
  59. package/src/content/external-api.ts +7 -0
  60. package/src/content/get-categories.ts +15 -0
  61. package/src/content/get-languages.ts +14 -0
  62. package/src/content/get-media-items.ts +27 -0
  63. package/src/content/get-media-types.ts +23 -0
  64. package/src/content/query-media-items.ts +89 -0
  65. package/src/content/resolve-category-label.ts +20 -0
  66. package/src/i18n/get-locale-paths.ts +8 -0
  67. package/src/i18n/languages.ts +10 -0
  68. package/src/i18n/locals.d.ts +38 -0
  69. package/src/i18n/locals.ts +28 -0
  70. package/src/i18n/resolve-default-locale.ts +30 -0
  71. package/src/i18n/resolve-language.ts +25 -0
  72. package/src/i18n/resolve-locales.ts +5 -0
  73. package/src/i18n/translate.ts +64 -0
  74. package/src/i18n/translations/de.json +25 -0
  75. package/src/i18n/translations/en.json +25 -0
  76. package/src/layouts/MarkdownPage.astro +11 -0
  77. package/src/layouts/Page.astro +54 -0
  78. package/src/layouts/components/Favicon.astro +32 -0
  79. package/src/layouts/components/LanguagePicker.astro +38 -0
  80. package/src/layouts/components/Menu.astro +28 -0
  81. package/src/layouts/components/MenuItem.astro +21 -0
  82. package/src/layouts/components/PageNavigation.astro +65 -0
  83. package/src/layouts/components/PageTitle.astro +44 -0
  84. package/src/layouts/components/PreloadReact.tsx +3 -0
  85. package/src/pages/404.astro +14 -0
  86. package/src/pages/RedirectToDefaultLocale.astro +3 -0
  87. package/src/pages/api/search-response.ts +14 -0
  88. package/src/pages/api/search.ts +47 -0
  89. package/src/pages/details-page/DefaultDetails.astro +44 -0
  90. package/src/pages/details-page/DetailsPage.astro +53 -0
  91. package/src/pages/details-page/VideoDetails.astro +43 -0
  92. package/src/pages/details-page/components/Authors.astro +19 -0
  93. package/src/pages/details-page/components/Content.astro +73 -0
  94. package/src/pages/details-page/components/Cover.astro +35 -0
  95. package/src/pages/details-page/components/Description.astro +26 -0
  96. package/src/pages/details-page/components/MediaCollection.astro +39 -0
  97. package/src/pages/details-page/components/MediaCollections.astro +21 -0
  98. package/src/pages/details-page/components/OpenButton.astro +34 -0
  99. package/src/pages/details-page/components/SectionTitle.astro +8 -0
  100. package/src/pages/details-page/components/ShareButton.astro +58 -0
  101. package/src/pages/details-page/components/Title.astro +18 -0
  102. package/src/pages/details-page/components/VideoPlayer.astro +78 -0
  103. package/src/pages/details-page/components/details/Categories.astro +31 -0
  104. package/src/pages/details-page/components/details/Details.astro +17 -0
  105. package/src/pages/details-page/components/details/Label.astro +3 -0
  106. package/src/pages/details-page/components/details/Languages.astro +46 -0
  107. package/src/pages/details-page/utils/create-content-metadata.ts +78 -0
  108. package/src/pages/search-page/Search.tsx +71 -0
  109. package/src/pages/search-page/SearchPage.astro +51 -0
  110. package/src/pages/search-page/components/ResultList.tsx +135 -0
  111. package/src/pages/search-page/components/SearchFilter.tsx +189 -0
  112. package/src/pages/search-page/hooks/use-debounce.ts +17 -0
  113. package/src/pages/search-page/hooks/use-search.ts +95 -0
  114. package/src/pages/search-page/types.ts +9 -0
  115. package/src/pages/search-page/utils/search-translations.ts +22 -0
  116. package/src/pages/search-page/utils/use-provided-translations.ts +5 -0
  117. package/src/utils/markdown.ts +41 -0
  118. package/src/utils/paths.ts +45 -0
  119. package/src/utils/urls.ts +29 -0
  120. package/src/utils/verify-schema.ts +38 -0
  121. package/tailwind.config.ts +56 -0
  122. package/vitest.config.js +19 -0
@@ -0,0 +1,78 @@
1
+ import { isExternalUrl } from "../../../utils/urls"
2
+
3
+ export type UrlType =
4
+ | "link"
5
+ | "source"
6
+ | "image"
7
+ | "audio"
8
+ | "video"
9
+ | "text"
10
+ | "package"
11
+
12
+ const KNOWN_EXTENSIONS: Record<
13
+ string,
14
+ { type: UrlType; canBeOpened?: boolean } | undefined
15
+ > = {
16
+ htm: { type: "link", canBeOpened: true },
17
+ html: { type: "link", canBeOpened: true },
18
+ php: { type: "link", canBeOpened: true },
19
+ json: { type: "source", canBeOpened: true },
20
+ xml: { type: "source", canBeOpened: true },
21
+ svg: { type: "image", canBeOpened: true },
22
+ jpg: { type: "image", canBeOpened: true },
23
+ jpeg: { type: "image", canBeOpened: true },
24
+ png: { type: "image", canBeOpened: true },
25
+ gif: { type: "image", canBeOpened: true },
26
+ ico: { type: "image", canBeOpened: true },
27
+ webp: { type: "image", canBeOpened: true },
28
+ mp3: { type: "audio", canBeOpened: true },
29
+ wav: { type: "audio", canBeOpened: true },
30
+ aac: { type: "audio", canBeOpened: true },
31
+ ogg: { type: "audio", canBeOpened: true },
32
+ mp4: { type: "video", canBeOpened: true },
33
+ webm: { type: "video", canBeOpened: true },
34
+ ogv: { type: "video", canBeOpened: true },
35
+ pdf: { type: "text", canBeOpened: true },
36
+ txt: { type: "text", canBeOpened: true },
37
+ epub: { type: "text" },
38
+ zip: { type: "package" },
39
+ ppt: { type: "text" },
40
+ pptx: { type: "text" },
41
+ doc: { type: "text" },
42
+ docx: { type: "text" },
43
+ } as const
44
+
45
+ export function createContentMetadata({
46
+ url,
47
+ name: customName,
48
+ }: {
49
+ url: string
50
+ name?: string
51
+ }) {
52
+ const lastUrlSegment = url.split("/").slice(-1)[0]
53
+ const hasExtension = lastUrlSegment.includes(".")
54
+ const extension = hasExtension
55
+ ? lastUrlSegment.split(".").slice(-1)[0].toLowerCase()
56
+ : ""
57
+
58
+ const isExternal = isExternalUrl(url)
59
+
60
+ const linkName = isExternal ? new URL(url).hostname : lastUrlSegment
61
+ const fileName = hasExtension
62
+ ? lastUrlSegment.slice(0, -(extension.length + 1))
63
+ : undefined
64
+ const name = customName ?? fileName ?? linkName
65
+ const type = KNOWN_EXTENSIONS[extension]?.type ?? "link"
66
+ const canBeOpened =
67
+ !hasExtension || !!KNOWN_EXTENSIONS[extension]?.canBeOpened
68
+
69
+ return {
70
+ url,
71
+ extension,
72
+ isExternal,
73
+ name,
74
+ canBeOpened,
75
+ type,
76
+ target: isExternal ? "_blank" : "_self",
77
+ } as const
78
+ }
@@ -0,0 +1,71 @@
1
+ import { useEffect } from "react"
2
+
3
+ import ResultList from "./components/ResultList"
4
+ import SearchFilter from "./components/SearchFilter"
5
+ import { useSearch } 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"
9
+
10
+ export default function Search({
11
+ locale,
12
+ contentLanguages,
13
+ categories,
14
+ mediaTypes,
15
+ direction,
16
+ translations,
17
+ filterByLocale,
18
+ }: {
19
+ locale?: string
20
+ contentLanguages: TranslatedLanguage[]
21
+ categories: Record<string, string>
22
+ translations: Translations
23
+ mediaTypes: MediaType[]
24
+ direction: "rtl" | "ltr"
25
+ filterByLocale: boolean
26
+ }) {
27
+ const { results, updateQuery, isLoading } = useSearch()
28
+ const t = useProvidedTranslations(translations)
29
+
30
+ // restore scroll position after back navigation
31
+ useEffect(() => {
32
+ const { state } = history
33
+ if (!isLoading && state?.scrollY) {
34
+ window.scrollTo(0, state.scrollY)
35
+ }
36
+ }, [isLoading])
37
+
38
+ return (
39
+ <>
40
+ <div className="px-4 md:px-8">
41
+ <h1 className="mb-4 mt-8 text-4xl md:mb-8 md:mt-12 md:text-5xl">
42
+ {t("ln.search.title")}
43
+ </h1>
44
+ <SearchFilter
45
+ contentLanguages={contentLanguages}
46
+ mediaTypes={mediaTypes}
47
+ translations={translations}
48
+ categories={categories}
49
+ locale={locale}
50
+ filterByLocale={filterByLocale}
51
+ updateQuery={updateQuery}
52
+ />
53
+ </div>
54
+
55
+ <ResultList
56
+ items={results}
57
+ locale={locale}
58
+ direction={direction}
59
+ translations={translations}
60
+ categories={categories}
61
+ mediaTypes={mediaTypes}
62
+ contentLanguages={contentLanguages}
63
+ />
64
+ {!results.length && !isLoading && (
65
+ <div className="mt-24 text-center font-bold text-gray-500">
66
+ {t("ln.search.no-results")}
67
+ </div>
68
+ )}
69
+ </>
70
+ )
71
+ }
@@ -0,0 +1,51 @@
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
+ import Page from "../../layouts/Page.astro"
8
+ import Search from "./Search"
9
+ import { provideTranslations } from "./utils/search-translations"
10
+
11
+ export { getLocalePaths as getStaticPaths } from "../../i18n/get-locale-paths"
12
+
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, { allowFixedStrings: true }),
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, { allowFixedStrings: true }),
32
+ }))
33
+ .sort((a, b) => a.name.localeCompare(b.name, currentLocale))
34
+
35
+ const filterByLocale = !!config.searchPage?.filterByLocale
36
+ ---
37
+
38
+ <Page>
39
+ <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
+ />
50
+ </div>
51
+ </Page>
@@ -0,0 +1,135 @@
1
+ import { useMemo, useState } from "react"
2
+
3
+ import Icon from "../../../components/Icon"
4
+ import { detailsPagePath } from "../../../utils/paths"
5
+ import type { SearchItem } from "../../api/search-response"
6
+ import type { MediaType, TranslatedLanguage } from "../types"
7
+ import type { Translations } from "../utils/search-translations"
8
+ import { useProvidedTranslations } from "../utils/use-provided-translations"
9
+
10
+ const PAGE_SIZE = 30
11
+
12
+ interface Props {
13
+ items: SearchItem[]
14
+ locale: string | undefined
15
+ translations: Translations
16
+ categories: Record<string, string>
17
+ contentLanguages: TranslatedLanguage[]
18
+ direction: "rtl" | "ltr"
19
+ mediaTypes: MediaType[]
20
+ }
21
+
22
+ export default function ResultList({
23
+ items,
24
+ locale,
25
+ categories,
26
+ translations,
27
+ contentLanguages,
28
+ direction,
29
+ mediaTypes,
30
+ }: Props) {
31
+ const [maxItems, setMaxItems] = useState(15)
32
+ const t = useProvidedTranslations(translations)
33
+
34
+ const types = useMemo(
35
+ () =>
36
+ Object.fromEntries(
37
+ mediaTypes.map(({ id, icon, label }) => [id, { icon, label }]),
38
+ ),
39
+ [mediaTypes],
40
+ )
41
+ const languages = useMemo(
42
+ () => Object.fromEntries(contentLanguages.map((lang) => [lang.code, lang])),
43
+ [contentLanguages],
44
+ )
45
+
46
+ return (
47
+ <>
48
+ <ol className={`divide-y divide-gray-200 px-4 md:px-8`}>
49
+ {items.slice(0, maxItems).map((item) => (
50
+ <li key={item.id} lang={item.language}>
51
+ <a
52
+ href={detailsPagePath(locale, {
53
+ id: item.id,
54
+ })}
55
+ className="group flex overflow-hidden py-6 transition-colors ease-in-out md:rounded-sm md:py-10 md:hover:bg-gray-100"
56
+ >
57
+ <div className="flex h-36 w-36 shrink-0 flex-col items-start justify-center">
58
+ <img
59
+ className="max-h-36 w-auto max-w-36 rounded-sm object-contain shadow-md"
60
+ src={item.image.src}
61
+ width={item.image.width}
62
+ height={item.image.height}
63
+ alt=""
64
+ decoding="async"
65
+ loading="lazy"
66
+ />
67
+ </div>
68
+
69
+ <div className="ms-5 flex grow flex-col justify-center text-xs sm:ms-8">
70
+ <h2 className="mb-1 line-clamp-3 text-sm font-bold text-gray-700 md:mb-3 md:text-base">
71
+ <Icon
72
+ className={`${types[item.type].icon} me-2 align-bottom text-2xl text-gray-700`}
73
+ ariaLabel={types[item.type].label}
74
+ />
75
+ <span>{item.title}</span>
76
+ </h2>
77
+ <div className="mb-3 flex flex-col flex-wrap items-start gap-2 md:flex-row md:items-center md:gap-3">
78
+ {!!item.authors?.length && (
79
+ <p className="mb-1 md:mb-0 md:text-base">
80
+ {item.authors.join(", ")}
81
+ </p>
82
+ )}
83
+ {contentLanguages.length > 1 && (
84
+ <span className="rounded-lg border border-gray-300 px-2 py-1 text-gray-500">
85
+ {languages[item.language].name}
86
+ </span>
87
+ )}
88
+ <ul lang={locale} className="flex flex-wrap gap-1">
89
+ {item.categories?.map((category) => (
90
+ <li
91
+ key={category}
92
+ className="rounded-lg bg-gray-200 px-2 py-1 text-gray-600"
93
+ >
94
+ {categories[category]}
95
+ </li>
96
+ ))}
97
+ </ul>
98
+ </div>
99
+
100
+ <div className="hidden sm:block">
101
+ <p
102
+ className="line-clamp-3 max-w-screen-sm text-xs text-gray-500"
103
+ lang={item.language}
104
+ dir={languages[item.language].direction}
105
+ >
106
+ {item.description}
107
+ </p>
108
+ </div>
109
+ </div>
110
+ <Icon
111
+ 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"
112
+ flipIcon={direction === "rtl"}
113
+ ariaLabel=""
114
+ />
115
+ </a>
116
+ </li>
117
+ ))}
118
+ </ol>
119
+ {items.length > maxItems && (
120
+ <div className="mt-8 flex flex-col px-4 md:px-8">
121
+ <div className="dy-divider">
122
+ <button
123
+ type="button"
124
+ className="dy-btn"
125
+ onClick={() => setMaxItems(maxItems + PAGE_SIZE)}
126
+ >
127
+ {t("ln.search.more-results")}
128
+ <Icon className="mdi--chevron-down" ariaLabel="" />
129
+ </button>
130
+ </div>
131
+ </div>
132
+ )}
133
+ </>
134
+ )
135
+ }
@@ -0,0 +1,189 @@
1
+ import { useEffect, useRef, useState } from "react"
2
+
3
+ import Icon from "../../../components/Icon"
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"
9
+
10
+ // URL search params
11
+ const SEARCH = "search"
12
+ const LANGUAGE = "language"
13
+ const TYPE = "type"
14
+ const CATEGORY = "category"
15
+
16
+ interface Props {
17
+ contentLanguages: TranslatedLanguage[]
18
+ categories: Record<string, string>
19
+ mediaTypes: MediaType[]
20
+ locale?: string
21
+ translations: Translations
22
+ filterByLocale: boolean
23
+ updateQuery: (query: SearchQuery) => void
24
+ }
25
+
26
+ export default function SearchFilter({
27
+ categories,
28
+ mediaTypes,
29
+ updateQuery,
30
+ translations,
31
+ filterByLocale,
32
+ locale,
33
+ contentLanguages,
34
+ }: Props) {
35
+ const languageFilterEnabled = contentLanguages.length > 1
36
+ const typesFilterEnabled = mediaTypes.length > 1
37
+ // Not every media item has a category. So it makes
38
+ // sense to have the filter when there is only one category.
39
+ const categoriesFilterEnabled = Object.keys(categories).length > 0
40
+
41
+ const getSearchParam = (name: string, defaultValue = "") => {
42
+ // be lazy to avoid parsing search params all the time
43
+ return () => {
44
+ const searchParams = new URLSearchParams(window.location.search)
45
+ return searchParams.get(name) ?? defaultValue
46
+ }
47
+ }
48
+
49
+ const [search, setSearch] = useState(getSearchParam(SEARCH))
50
+ const [language, setLanguage] = useState(() => {
51
+ let initialLanguageFilter = ""
52
+ const hasContentLanguage = contentLanguages.find(
53
+ ({ code }) => code === locale,
54
+ )
55
+ if (
56
+ filterByLocale &&
57
+ locale &&
58
+ hasContentLanguage &&
59
+ languageFilterEnabled
60
+ ) {
61
+ initialLanguageFilter = locale
62
+ }
63
+ return getSearchParam(LANGUAGE, initialLanguageFilter)()
64
+ })
65
+ const [type, setType] = useState(getSearchParam(TYPE))
66
+ const [category, setCategory] = useState(getSearchParam(CATEGORY))
67
+
68
+ const searchInput = useRef<HTMLInputElement | null>(null)
69
+
70
+ const t = useProvidedTranslations(translations)
71
+
72
+ const debouncedSetSearch = useDebounce((value: string) => {
73
+ setSearch(value)
74
+ }, 300)
75
+
76
+ useEffect(() => {
77
+ const url = new URL(window.location.href)
78
+ const updateSearchParam = (name: string, value: string) => {
79
+ // Only update when value before and after are different and both are non empty.
80
+ if (value === (url.searchParams.get(name) ?? "")) {
81
+ return
82
+ }
83
+ url.searchParams.set(name, value)
84
+ }
85
+
86
+ updateSearchParam(SEARCH, search)
87
+ updateSearchParam(LANGUAGE, language)
88
+ updateSearchParam(TYPE, type)
89
+ updateSearchParam(CATEGORY, category)
90
+ history.replaceState({ ...history.state }, "", url.toString())
91
+
92
+ updateQuery({ search, language, type, category })
93
+ }, [search, language, type, category])
94
+
95
+ return (
96
+ <>
97
+ <label className="dy-input dy-input-bordered mb-2 flex items-center gap-2">
98
+ <input
99
+ type="search"
100
+ className="grow"
101
+ id="search-input"
102
+ ref={searchInput}
103
+ placeholder={t("ln.search.placeholder")}
104
+ enterKeyHint="search"
105
+ defaultValue={search}
106
+ onInput={(e) => debouncedSetSearch(e.currentTarget.value)}
107
+ onKeyDown={(e) => e.key === "Enter" && searchInput.current?.blur()}
108
+ />
109
+ <Icon className="mdi--magnify text-xl" ariaLabel="" />
110
+ </label>
111
+ <div className="mb-8 grid grid-cols-1 gap-2 sm:grid-cols-3 sm:gap-6 md:mb-10">
112
+ {languageFilterEnabled && (
113
+ <label className="dy-form-control">
114
+ <div className="dy-label">
115
+ <span className="text-xs font-bold uppercase text-gray-500">
116
+ {t("ln.common.language")}
117
+ </span>
118
+ </div>
119
+ <select
120
+ className="dy-select dy-select-bordered sm:dy-select-sm"
121
+ value={language}
122
+ id="language-select"
123
+ onChange={(e) => setLanguage(e.currentTarget.value)}
124
+ >
125
+ <option value="">{t("ln.search.all-languages")}</option>
126
+ {contentLanguages.map(({ code, name }) => (
127
+ <option key={code} value={code} lang={code}>
128
+ {name}
129
+ </option>
130
+ ))}
131
+ </select>
132
+ </label>
133
+ )}
134
+
135
+ {typesFilterEnabled && (
136
+ <label className="dy-form-control">
137
+ <div className="dy-label">
138
+ <span className="text-xs font-bold uppercase text-gray-500">
139
+ {t("ln.common.type")}
140
+ </span>
141
+ </div>
142
+ <select
143
+ className="dy-select dy-select-bordered sm:dy-select-sm"
144
+ value={type}
145
+ id="type-select"
146
+ onChange={(e) => setType(e.currentTarget.value)}
147
+ >
148
+ <option key="" value="">
149
+ {t("ln.search.all-types")}
150
+ </option>
151
+ {mediaTypes.map(({ id, label }) => (
152
+ <option key={id} value={id}>
153
+ {label}
154
+ </option>
155
+ ))}
156
+ </select>
157
+ </label>
158
+ )}
159
+
160
+ {categoriesFilterEnabled && (
161
+ <label className="dy-form-control">
162
+ <div className="dy-label">
163
+ <span className="text-xs font-bold uppercase text-gray-500">
164
+ {t("ln.common.category")}
165
+ </span>
166
+ </div>
167
+ <select
168
+ className="dy-select dy-select-bordered sm:dy-select-sm"
169
+ value={category}
170
+ id="category-select"
171
+ onChange={(e) => setCategory(e.currentTarget.value)}
172
+ >
173
+ <option key="" value="">
174
+ {t("ln.search.all-categories")}
175
+ </option>
176
+ {Object.entries(categories)
177
+ .sort((a, b) => a[1].localeCompare(b[1], locale))
178
+ .map(([id, label]) => (
179
+ <option key={id} value={id}>
180
+ {label}
181
+ </option>
182
+ ))}
183
+ </select>
184
+ </label>
185
+ )}
186
+ </div>
187
+ </>
188
+ )
189
+ }
@@ -0,0 +1,17 @@
1
+ import { useRef } from "react"
2
+
3
+ export function useDebounce<T>(
4
+ callback: (...args: T[]) => unknown,
5
+ time: number,
6
+ ) {
7
+ const timeout = useRef<number>()
8
+ return (...args: T[]) => {
9
+ if (timeout.current) {
10
+ window.clearTimeout(timeout.current)
11
+ }
12
+ timeout.current = window.setTimeout(() => {
13
+ callback(...args)
14
+ timeout.current = undefined
15
+ }, time)
16
+ }
17
+ }
@@ -0,0 +1,95 @@
1
+ import Fuse from "fuse.js"
2
+ import { useEffect, useRef, useState } from "react"
3
+
4
+ import type { SearchItem, SearchResponse } from "../../api/search-response"
5
+
6
+ export type SearchQuery = {
7
+ search?: string
8
+ language?: string
9
+ type?: string
10
+ category?: string
11
+ }
12
+
13
+ export function useSearch() {
14
+ const fuse = useRef<Fuse<SearchItem>>()
15
+ const [allItems, setAllItems] = useState<SearchItem[]>([])
16
+ const [isLoading, setIsLoading] = useState(true)
17
+ const [query, setQuery] = useState<SearchQuery>({})
18
+ useEffect(() => {
19
+ const fetchData = async () => {
20
+ try {
21
+ const response = await fetch("/api/search.json")
22
+ if (!response.ok) {
23
+ throw new Error("Network response was not ok")
24
+ }
25
+ const { items }: SearchResponse = await response.json()
26
+ fuse.current = new Fuse(items, {
27
+ keys: [
28
+ { name: "title", weight: 3 },
29
+ "language",
30
+ { name: "authors", weight: 2 },
31
+ "description",
32
+ "type",
33
+ "categories",
34
+ "id",
35
+ ],
36
+ useExtendedSearch: true,
37
+ threshold: 0.3,
38
+ ignoreLocation: true,
39
+ })
40
+ setAllItems(items)
41
+ } catch (error) {
42
+ console.error(error)
43
+ }
44
+ setIsLoading(false)
45
+ }
46
+ fetchData()
47
+ }, [])
48
+
49
+ const { language, type, category, search } = query
50
+ const fuseQuery = []
51
+ // order is relevant! query will stop evaluation
52
+ // when condition is not met.
53
+ if (language) {
54
+ fuseQuery.push({ language: `=${language}` })
55
+ }
56
+ if (type) {
57
+ fuseQuery.push({ type: `=${type}` })
58
+ }
59
+ if (category) {
60
+ fuseQuery.push({ categories: `=${category}` })
61
+ }
62
+ if (search) {
63
+ fuseQuery.push({
64
+ $or: [
65
+ { title: search },
66
+ { description: search },
67
+ { authors: search },
68
+ { id: search },
69
+ ],
70
+ })
71
+ }
72
+
73
+ const updateQuery = (newQuery: SearchQuery) => {
74
+ const queryIsUpdated = (
75
+ ["search", "category", "language", "type"] as const
76
+ ).find((key) => (newQuery[key] ? newQuery[key] !== query[key] : query[key]))
77
+
78
+ if (!queryIsUpdated) {
79
+ return
80
+ }
81
+ setQuery(newQuery)
82
+ }
83
+
84
+ if (!fuse.current || !fuseQuery.length) {
85
+ return { results: allItems, updateQuery, isLoading }
86
+ }
87
+
88
+ return {
89
+ results: fuse.current
90
+ .search({ $and: fuseQuery })
91
+ .map((fuseItem) => fuseItem.item),
92
+ updateQuery,
93
+ isLoading,
94
+ }
95
+ }
@@ -0,0 +1,9 @@
1
+ import type { Language } from "../../i18n/languages"
2
+
3
+ export type MediaType = {
4
+ id: string
5
+ label: string
6
+ icon: string
7
+ }
8
+
9
+ export type TranslatedLanguage = Language & { name: string }
@@ -0,0 +1,22 @@
1
+ const translationKeys = [
2
+ "ln.search.no-results",
3
+ "ln.search.title",
4
+ "ln.common.language",
5
+ "ln.search.placeholder",
6
+ "ln.search.all-languages",
7
+ "ln.common.type",
8
+ "ln.search.all-types",
9
+ "ln.common.category",
10
+ "ln.search.all-categories",
11
+ "ln.search.more-results",
12
+ ] as const
13
+
14
+ export type TranslationKey = (typeof translationKeys)[number]
15
+
16
+ export type Translations = Record<TranslationKey, string>
17
+
18
+ export const provideTranslations = (translate: (key: string) => string) => {
19
+ return Object.fromEntries(
20
+ translationKeys.map((key) => [key, translate(key)]),
21
+ ) as Translations
22
+ }
@@ -0,0 +1,5 @@
1
+ import type { TranslationKey, Translations } from "./search-translations"
2
+
3
+ export const useProvidedTranslations = (translations: Translations) => {
4
+ return (key: TranslationKey) => translations[key]
5
+ }