lightnet 3.4.5 → 3.5.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.
- package/CHANGELOG.md +48 -10
- package/__e2e__/fixtures/basics/node_modules/.bin/astro +2 -2
- package/__e2e__/fixtures/basics/package.json +2 -2
- package/__tests__/utils/markdown.spec.ts +21 -0
- package/exports/components.ts +1 -0
- package/package.json +8 -7
- package/src/astro-integration/config.ts +6 -3
- package/src/components/HeroSection.astro +35 -7
- package/src/components/SearchSection.astro +16 -0
- package/src/components/Section.astro +20 -4
- package/src/i18n/translations/TRANSLATION-STATUS.md +1 -1
- package/src/i18n/translations/de.yml +1 -2
- package/src/i18n/translations/en.yml +3 -8
- package/src/i18n/translations/ru.yml +1 -2
- package/src/i18n/translations/uk.yml +1 -2
- package/src/i18n/translations.ts +0 -1
- package/src/layouts/Page.astro +1 -0
- package/src/layouts/components/PageNavigation.astro +13 -7
- package/src/pages/details-page/components/main-details/AudioPlayer.astro +1 -1
- package/src/pages/details-page/components/main-details/Cover.astro +1 -0
- package/src/pages/search-page/SearchPageRoute.astro +10 -40
- package/src/pages/search-page/components/LoadingSkeleton.tsx +19 -0
- package/src/pages/search-page/components/SearchFilter.astro +65 -0
- package/src/pages/search-page/components/SearchFilter.tsx +33 -84
- package/src/pages/search-page/components/SearchList.astro +50 -0
- package/src/pages/search-page/components/SearchList.tsx +117 -0
- package/src/pages/search-page/components/SearchListItem.tsx +105 -0
- package/src/pages/search-page/components/Select.tsx +5 -5
- package/src/pages/search-page/hooks/use-search-query-param.ts +31 -0
- package/src/pages/search-page/hooks/use-search.ts +103 -49
- package/src/pages/search-page/utils/search-filter-translations.ts +20 -0
- package/src/pages/search-page/utils/search-query.ts +76 -0
- package/src/pages/search-page/utils/search-translations.ts +1 -12
- package/src/utils/markdown.ts +8 -1
- package/src/pages/search-page/Search.tsx +0 -71
- package/src/pages/search-page/components/ResultList.tsx +0 -135
- package/src/pages/search-page/types.ts +0 -11
- package/src/pages/search-page/utils/use-provided-translations.ts +0 -5
|
@@ -1,21 +1,47 @@
|
|
|
1
1
|
import Fuse from "fuse.js"
|
|
2
|
-
import { useEffect, useRef, useState } from "react"
|
|
2
|
+
import { useEffect, useMemo, useRef, useState } from "react"
|
|
3
3
|
|
|
4
4
|
import type { SearchItem, SearchResponse } from "../../api/search-response"
|
|
5
|
+
import { observeSearchQuery, type SearchQuery } from "../utils/search-query"
|
|
5
6
|
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
7
|
+
declare global {
|
|
8
|
+
interface Window {
|
|
9
|
+
lnSearchState?: {
|
|
10
|
+
fuse: Fuse<SearchItem>
|
|
11
|
+
items: SearchItem[]
|
|
12
|
+
locale?: string
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
interface Context {
|
|
18
|
+
categories: Record<string, string>
|
|
19
|
+
mediaTypes: Record<string, { name: string }>
|
|
20
|
+
languages: Record<string, { name: string }>
|
|
21
|
+
currentLocale?: string
|
|
11
22
|
}
|
|
12
23
|
|
|
13
|
-
export function useSearch(
|
|
24
|
+
export function useSearch({
|
|
25
|
+
currentLocale,
|
|
26
|
+
categories,
|
|
27
|
+
mediaTypes,
|
|
28
|
+
languages,
|
|
29
|
+
}: Context) {
|
|
14
30
|
const fuse = useRef<Fuse<SearchItem>>(undefined)
|
|
15
31
|
const [allItems, setAllItems] = useState<SearchItem[]>([])
|
|
16
32
|
const [isLoading, setIsLoading] = useState(true)
|
|
17
|
-
const [query, setQuery] = useState<SearchQuery
|
|
33
|
+
const [query, setQuery] = useState<Partial<SearchQuery>>({})
|
|
18
34
|
useEffect(() => {
|
|
35
|
+
const removeSearchQueryObserver = observeSearchQuery((newQuery) => {
|
|
36
|
+
const queryIsUpdated = (
|
|
37
|
+
["search", "category", "language", "type"] as const
|
|
38
|
+
).find((key) => newQuery[key] !== query[key])
|
|
39
|
+
|
|
40
|
+
if (!queryIsUpdated) {
|
|
41
|
+
return
|
|
42
|
+
}
|
|
43
|
+
setQuery(newQuery)
|
|
44
|
+
})
|
|
19
45
|
const fetchData = async () => {
|
|
20
46
|
try {
|
|
21
47
|
const response = await fetch("/api/search.json")
|
|
@@ -25,11 +51,28 @@ export function useSearch() {
|
|
|
25
51
|
)
|
|
26
52
|
}
|
|
27
53
|
const { items }: SearchResponse = await response.json()
|
|
28
|
-
|
|
54
|
+
const enrichedItems = items.map((item) => {
|
|
55
|
+
const translatedCategories =
|
|
56
|
+
item.categories &&
|
|
57
|
+
item.categories.map((categoryId) => categories[categoryId])
|
|
58
|
+
const translatedType = mediaTypes[item.type].name
|
|
59
|
+
const translatedLanguage = languages[item.language].name
|
|
60
|
+
|
|
61
|
+
return {
|
|
62
|
+
...item,
|
|
63
|
+
translatedCategories,
|
|
64
|
+
translatedType,
|
|
65
|
+
translatedLanguage,
|
|
66
|
+
}
|
|
67
|
+
})
|
|
68
|
+
fuse.current = new Fuse(enrichedItems, {
|
|
29
69
|
keys: [
|
|
30
70
|
{ name: "title", weight: 3 },
|
|
31
71
|
"language",
|
|
32
72
|
{ name: "authors", weight: 2 },
|
|
73
|
+
{ name: "translatedCategories", weight: 2 },
|
|
74
|
+
{ name: "translatedType", weight: 2 },
|
|
75
|
+
{ name: "translatedLanguage", weight: 2 },
|
|
33
76
|
"description",
|
|
34
77
|
"type",
|
|
35
78
|
"categories",
|
|
@@ -40,58 +83,69 @@ export function useSearch() {
|
|
|
40
83
|
ignoreLocation: true,
|
|
41
84
|
})
|
|
42
85
|
setAllItems(items)
|
|
86
|
+
window.lnSearchState = {
|
|
87
|
+
locale: currentLocale,
|
|
88
|
+
items,
|
|
89
|
+
fuse: fuse.current,
|
|
90
|
+
}
|
|
43
91
|
} catch (error) {
|
|
44
92
|
console.error(error)
|
|
45
93
|
}
|
|
46
94
|
setIsLoading(false)
|
|
47
95
|
}
|
|
48
|
-
|
|
96
|
+
// try restore old search index only if
|
|
97
|
+
// locale is still the same because we add translated values to the
|
|
98
|
+
// search index
|
|
99
|
+
const { lnSearchState } = window
|
|
100
|
+
if (lnSearchState && lnSearchState.locale === currentLocale) {
|
|
101
|
+
fuse.current = lnSearchState.fuse
|
|
102
|
+
setAllItems(lnSearchState.items)
|
|
103
|
+
setIsLoading(false)
|
|
104
|
+
} else {
|
|
105
|
+
fetchData()
|
|
106
|
+
}
|
|
107
|
+
return removeSearchQueryObserver
|
|
49
108
|
}, [])
|
|
50
109
|
|
|
51
|
-
const
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
).find((key) => (newQuery[key] ? newQuery[key] !== query[key] : query[key]))
|
|
110
|
+
const results = useMemo(() => {
|
|
111
|
+
const { language, type, category, search } = query
|
|
112
|
+
const fuseQuery = []
|
|
113
|
+
// order is relevant! query will stop evaluation
|
|
114
|
+
// when condition is not met.
|
|
115
|
+
if (language) {
|
|
116
|
+
fuseQuery.push({ language: `=${language}` })
|
|
117
|
+
}
|
|
118
|
+
if (type) {
|
|
119
|
+
fuseQuery.push({ type: `=${type}` })
|
|
120
|
+
}
|
|
121
|
+
if (category) {
|
|
122
|
+
fuseQuery.push({ categories: `=${category}` })
|
|
123
|
+
}
|
|
124
|
+
if (search) {
|
|
125
|
+
fuseQuery.push({
|
|
126
|
+
$or: [
|
|
127
|
+
{ title: search },
|
|
128
|
+
{ translatedCategories: search },
|
|
129
|
+
{ translatedType: search },
|
|
130
|
+
{ translatedLanguage: search },
|
|
131
|
+
{ description: search },
|
|
132
|
+
{ authors: search },
|
|
133
|
+
{ id: search },
|
|
134
|
+
],
|
|
135
|
+
})
|
|
136
|
+
}
|
|
79
137
|
|
|
80
|
-
if (!
|
|
81
|
-
return
|
|
138
|
+
if (!fuse.current || !fuseQuery.length) {
|
|
139
|
+
return allItems
|
|
82
140
|
}
|
|
83
|
-
setQuery(newQuery)
|
|
84
|
-
}
|
|
85
141
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
142
|
+
return fuse.current
|
|
143
|
+
.search({ $and: fuseQuery })
|
|
144
|
+
.map((fuseItem) => fuseItem.item)
|
|
145
|
+
}, [query, allItems])
|
|
89
146
|
|
|
90
147
|
return {
|
|
91
|
-
results
|
|
92
|
-
.search({ $and: fuseQuery })
|
|
93
|
-
.map((fuseItem) => fuseItem.item),
|
|
94
|
-
updateQuery,
|
|
148
|
+
results,
|
|
95
149
|
isLoading,
|
|
96
150
|
}
|
|
97
151
|
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
const translationKeys = [
|
|
2
|
+
"ln.search.title",
|
|
3
|
+
"ln.language",
|
|
4
|
+
"ln.search.placeholder",
|
|
5
|
+
"ln.search.all-languages",
|
|
6
|
+
"ln.type",
|
|
7
|
+
"ln.search.all-types",
|
|
8
|
+
"ln.category",
|
|
9
|
+
"ln.search.all-categories",
|
|
10
|
+
] as const
|
|
11
|
+
|
|
12
|
+
export type TranslationKey = (typeof translationKeys)[number]
|
|
13
|
+
|
|
14
|
+
export type Translations = Record<TranslationKey, string>
|
|
15
|
+
|
|
16
|
+
export const provideTranslations = (translate: (key: string) => string) => {
|
|
17
|
+
return Object.fromEntries(
|
|
18
|
+
translationKeys.map((key) => [key, translate(key)]),
|
|
19
|
+
) as Translations
|
|
20
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
export type SearchQuery = {
|
|
2
|
+
search?: string
|
|
3
|
+
category?: string
|
|
4
|
+
language?: string
|
|
5
|
+
type?: string
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
// URL search parameter names
|
|
9
|
+
export const SEARCH = "search"
|
|
10
|
+
export const LANGUAGE = "language"
|
|
11
|
+
export const TYPE = "type"
|
|
12
|
+
export const CATEGORY = "category"
|
|
13
|
+
|
|
14
|
+
export const UPDATE_QUERY_EVENT = "ln:update-search-query"
|
|
15
|
+
|
|
16
|
+
export type SearchQueryParam =
|
|
17
|
+
| typeof SEARCH
|
|
18
|
+
| typeof LANGUAGE
|
|
19
|
+
| typeof TYPE
|
|
20
|
+
| typeof CATEGORY
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Read the current value of the given search query parameter
|
|
24
|
+
* from the url.
|
|
25
|
+
* @param paramName query parameter name
|
|
26
|
+
* @returns query parameter or undefined if it is not set
|
|
27
|
+
*/
|
|
28
|
+
export function getSearchQueryParam(paramName: SearchQueryParam) {
|
|
29
|
+
if (!window) {
|
|
30
|
+
return undefined
|
|
31
|
+
}
|
|
32
|
+
const searchParams = new URLSearchParams(window.location.search)
|
|
33
|
+
return searchParams.get(paramName) ?? undefined
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Set the new value of the search query parameter. This will
|
|
38
|
+
* also change the search results.
|
|
39
|
+
*
|
|
40
|
+
* We need to know if a value has been set by the user. Empty string
|
|
41
|
+
* means user action has removed a query parameter.
|
|
42
|
+
* E.g. when there is a default language filter this must not override
|
|
43
|
+
* the users choice to remove the language filter.
|
|
44
|
+
*
|
|
45
|
+
* @param paramName parameter name to set
|
|
46
|
+
* @param value new value, use empty string if you want to remove the filter.
|
|
47
|
+
*/
|
|
48
|
+
export function setSearchQueryParam(
|
|
49
|
+
paramName: SearchQueryParam,
|
|
50
|
+
value: string,
|
|
51
|
+
) {
|
|
52
|
+
const url = new URL(window.location.href)
|
|
53
|
+
// Only update when value before and after are different and both are non empty.
|
|
54
|
+
if (value === (url.searchParams.get(paramName) ?? "")) {
|
|
55
|
+
return
|
|
56
|
+
}
|
|
57
|
+
url.searchParams.set(paramName, value)
|
|
58
|
+
history.replaceState({ ...history.state }, "", url.toString())
|
|
59
|
+
document.dispatchEvent(new Event(UPDATE_QUERY_EVENT))
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function observeSearchQuery(callback: (query: SearchQuery) => void) {
|
|
63
|
+
const onUpdateQuery = () => {
|
|
64
|
+
const searchParams = new URL(window.location.href).searchParams
|
|
65
|
+
const query = {
|
|
66
|
+
search: searchParams.get(SEARCH) ?? undefined,
|
|
67
|
+
category: searchParams.get(CATEGORY) ?? undefined,
|
|
68
|
+
language: searchParams.get(LANGUAGE) ?? undefined,
|
|
69
|
+
type: searchParams.get(TYPE) ?? undefined,
|
|
70
|
+
}
|
|
71
|
+
callback(query)
|
|
72
|
+
}
|
|
73
|
+
document.addEventListener(UPDATE_QUERY_EVENT, onUpdateQuery)
|
|
74
|
+
onUpdateQuery()
|
|
75
|
+
return () => document.removeEventListener(UPDATE_QUERY_EVENT, onUpdateQuery)
|
|
76
|
+
}
|
|
@@ -1,15 +1,4 @@
|
|
|
1
|
-
const translationKeys = [
|
|
2
|
-
"ln.search.no-results",
|
|
3
|
-
"ln.search.title",
|
|
4
|
-
"ln.language",
|
|
5
|
-
"ln.search.placeholder",
|
|
6
|
-
"ln.search.all-languages",
|
|
7
|
-
"ln.type",
|
|
8
|
-
"ln.search.all-types",
|
|
9
|
-
"ln.category",
|
|
10
|
-
"ln.search.all-categories",
|
|
11
|
-
"ln.search.more-results",
|
|
12
|
-
] as const
|
|
1
|
+
const translationKeys = ["ln.search.no-results"] as const
|
|
13
2
|
|
|
14
3
|
export type TranslationKey = (typeof translationKeys)[number]
|
|
15
4
|
|
package/src/utils/markdown.ts
CHANGED
|
@@ -6,12 +6,15 @@ import { marked } from "marked"
|
|
|
6
6
|
* @param markdown string
|
|
7
7
|
* @returns plain text
|
|
8
8
|
*/
|
|
9
|
+
|
|
9
10
|
export function markdownToText(markdown?: string) {
|
|
10
11
|
if (!markdown) {
|
|
11
12
|
return markdown
|
|
12
13
|
}
|
|
13
14
|
return (
|
|
14
15
|
markdown
|
|
16
|
+
// line breaks
|
|
17
|
+
.replaceAll(/<br>/g, " ")
|
|
15
18
|
//headers
|
|
16
19
|
.replaceAll(/^#+ ?/gm, "")
|
|
17
20
|
// lists
|
|
@@ -19,11 +22,15 @@ export function markdownToText(markdown?: string) {
|
|
|
19
22
|
// block quotes
|
|
20
23
|
.replaceAll(/^>+ ?/gm, "")
|
|
21
24
|
// bold and italics
|
|
22
|
-
.replaceAll(/[*_]/g, "")
|
|
25
|
+
.replaceAll(/(?<!\\)[*_]/g, "")
|
|
23
26
|
// images
|
|
24
27
|
.replaceAll(/!\[(.*?)\]\(.*?\)/g, (_, imgAlt) => imgAlt)
|
|
25
28
|
// links
|
|
26
29
|
.replaceAll(/\[(.*?)\]\(.*?\)/g, (_, linkLabel) => linkLabel)
|
|
30
|
+
// escape character '\'
|
|
31
|
+
.replaceAll(/\\/g, "")
|
|
32
|
+
// multi white spaces
|
|
33
|
+
.replaceAll(/ +/g, " ")
|
|
27
34
|
)
|
|
28
35
|
}
|
|
29
36
|
|
|
@@ -1,71 +0,0 @@
|
|
|
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
|
-
}
|
|
@@ -1,135 +0,0 @@
|
|
|
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"
|
|
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 text-gray-700"
|
|
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
|
-
}
|