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.
- package/CHANGELOG.md +428 -0
- package/LICENSE +21 -0
- package/README.md +1 -0
- package/__e2e__/detailPage.spec.ts +0 -0
- package/__e2e__/fixtures/basics/astro.config.mjs +38 -0
- package/__e2e__/fixtures/basics/node_modules/.bin/astro +17 -0
- package/__e2e__/fixtures/basics/node_modules/.bin/astro-check +17 -0
- package/__e2e__/fixtures/basics/node_modules/.bin/tailwind +17 -0
- package/__e2e__/fixtures/basics/node_modules/.bin/tailwindcss +17 -0
- package/__e2e__/fixtures/basics/node_modules/.bin/tsc +17 -0
- package/__e2e__/fixtures/basics/node_modules/.bin/tsserver +17 -0
- package/__e2e__/fixtures/basics/package.json +19 -0
- package/__e2e__/fixtures/basics/public/favicon.svg +1 -0
- package/__e2e__/fixtures/basics/public/files/example.pdf +0 -0
- package/__e2e__/fixtures/basics/src/assets/logo.png +0 -0
- package/__e2e__/fixtures/basics/src/content/categories/christian-living.json +3 -0
- package/__e2e__/fixtures/basics/src/content/categories/teens.json +3 -0
- package/__e2e__/fixtures/basics/src/content/categories/theology.json +3 -0
- package/__e2e__/fixtures/basics/src/content/media/faithful-freestyle--en.json +13 -0
- package/__e2e__/fixtures/basics/src/content/media/how-to-kickflip--de.json +12 -0
- package/__e2e__/fixtures/basics/src/content/media/images/cover.jpg +0 -0
- package/__e2e__/fixtures/basics/src/content/media/images/how-to-kickflip--en.webp +0 -0
- package/__e2e__/fixtures/basics/src/content/media-collections/how-to-articles.json +3 -0
- package/__e2e__/fixtures/basics/src/content/media-types/book.json +9 -0
- package/__e2e__/fixtures/basics/src/content/media-types/video.json +7 -0
- package/__e2e__/fixtures/basics/src/content.config.ts +3 -0
- package/__e2e__/fixtures/basics/src/pages/[locale]/index.astro +14 -0
- package/__e2e__/fixtures/basics/src/translations/de.json +10 -0
- package/__e2e__/fixtures/basics/src/translations/en.json +10 -0
- package/__e2e__/fixtures/basics/tailwind.config.mjs +8 -0
- package/__e2e__/homepage.spec.ts +108 -0
- package/__e2e__/search.spec.ts +16 -0
- package/__e2e__/test-utils.ts +80 -0
- package/__tests__/pages/details-page/create-content-metadata.spec.ts +104 -0
- package/__tests__/utils/markdown.spec.ts +33 -0
- package/exports/components.ts +9 -0
- package/exports/content.ts +8 -0
- package/exports/details-page.ts +1 -0
- package/exports/i18n.ts +2 -0
- package/exports/index.ts +6 -0
- package/exports/utils.ts +2 -0
- package/package.json +54 -0
- package/playwright.config.ts +30 -0
- package/src/astro-integration/config.ts +185 -0
- package/src/astro-integration/integration.ts +74 -0
- package/src/astro-integration/project-context.ts +5 -0
- package/src/astro-integration/virtual.d.ts +14 -0
- package/src/astro-integration/vite-plugin-lightnet-config.ts +55 -0
- package/src/components/CategoriesOverview.astro +37 -0
- package/src/components/Gallery.astro +121 -0
- package/src/components/Hero.astro +82 -0
- package/src/components/HighlightSection.astro +71 -0
- package/src/components/Icon.tsx +27 -0
- package/src/components/MediaItemList.astro +84 -0
- package/src/components/Section.astro +49 -0
- package/src/content/astro-image.ts +14 -0
- package/src/content/content-schema-internal.ts +52 -0
- package/src/content/content-schema.ts +263 -0
- package/src/content/external-api.ts +7 -0
- package/src/content/get-categories.ts +15 -0
- package/src/content/get-languages.ts +14 -0
- package/src/content/get-media-items.ts +27 -0
- package/src/content/get-media-types.ts +23 -0
- package/src/content/query-media-items.ts +89 -0
- package/src/content/resolve-category-label.ts +20 -0
- package/src/i18n/get-locale-paths.ts +8 -0
- package/src/i18n/languages.ts +10 -0
- package/src/i18n/locals.d.ts +38 -0
- package/src/i18n/locals.ts +28 -0
- package/src/i18n/resolve-default-locale.ts +30 -0
- package/src/i18n/resolve-language.ts +25 -0
- package/src/i18n/resolve-locales.ts +5 -0
- package/src/i18n/translate.ts +64 -0
- package/src/i18n/translations/de.json +25 -0
- package/src/i18n/translations/en.json +25 -0
- package/src/layouts/MarkdownPage.astro +11 -0
- package/src/layouts/Page.astro +54 -0
- package/src/layouts/components/Favicon.astro +32 -0
- package/src/layouts/components/LanguagePicker.astro +38 -0
- package/src/layouts/components/Menu.astro +28 -0
- package/src/layouts/components/MenuItem.astro +21 -0
- package/src/layouts/components/PageNavigation.astro +65 -0
- package/src/layouts/components/PageTitle.astro +44 -0
- package/src/layouts/components/PreloadReact.tsx +3 -0
- package/src/pages/404.astro +14 -0
- package/src/pages/RedirectToDefaultLocale.astro +3 -0
- package/src/pages/api/search-response.ts +14 -0
- package/src/pages/api/search.ts +47 -0
- package/src/pages/details-page/DefaultDetails.astro +44 -0
- package/src/pages/details-page/DetailsPage.astro +53 -0
- package/src/pages/details-page/VideoDetails.astro +43 -0
- package/src/pages/details-page/components/Authors.astro +19 -0
- package/src/pages/details-page/components/Content.astro +73 -0
- package/src/pages/details-page/components/Cover.astro +35 -0
- package/src/pages/details-page/components/Description.astro +26 -0
- package/src/pages/details-page/components/MediaCollection.astro +39 -0
- package/src/pages/details-page/components/MediaCollections.astro +21 -0
- package/src/pages/details-page/components/OpenButton.astro +34 -0
- package/src/pages/details-page/components/SectionTitle.astro +8 -0
- package/src/pages/details-page/components/ShareButton.astro +58 -0
- package/src/pages/details-page/components/Title.astro +18 -0
- package/src/pages/details-page/components/VideoPlayer.astro +78 -0
- package/src/pages/details-page/components/details/Categories.astro +31 -0
- package/src/pages/details-page/components/details/Details.astro +17 -0
- package/src/pages/details-page/components/details/Label.astro +3 -0
- package/src/pages/details-page/components/details/Languages.astro +46 -0
- package/src/pages/details-page/utils/create-content-metadata.ts +78 -0
- package/src/pages/search-page/Search.tsx +71 -0
- package/src/pages/search-page/SearchPage.astro +51 -0
- package/src/pages/search-page/components/ResultList.tsx +135 -0
- package/src/pages/search-page/components/SearchFilter.tsx +189 -0
- package/src/pages/search-page/hooks/use-debounce.ts +17 -0
- package/src/pages/search-page/hooks/use-search.ts +95 -0
- package/src/pages/search-page/types.ts +9 -0
- package/src/pages/search-page/utils/search-translations.ts +22 -0
- package/src/pages/search-page/utils/use-provided-translations.ts +5 -0
- package/src/utils/markdown.ts +41 -0
- package/src/utils/paths.ts +45 -0
- package/src/utils/urls.ts +29 -0
- package/src/utils/verify-schema.ts +38 -0
- package/tailwind.config.ts +56 -0
- 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,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
|
+
}
|