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.
Files changed (38) hide show
  1. package/CHANGELOG.md +48 -10
  2. package/__e2e__/fixtures/basics/node_modules/.bin/astro +2 -2
  3. package/__e2e__/fixtures/basics/package.json +2 -2
  4. package/__tests__/utils/markdown.spec.ts +21 -0
  5. package/exports/components.ts +1 -0
  6. package/package.json +8 -7
  7. package/src/astro-integration/config.ts +6 -3
  8. package/src/components/HeroSection.astro +35 -7
  9. package/src/components/SearchSection.astro +16 -0
  10. package/src/components/Section.astro +20 -4
  11. package/src/i18n/translations/TRANSLATION-STATUS.md +1 -1
  12. package/src/i18n/translations/de.yml +1 -2
  13. package/src/i18n/translations/en.yml +3 -8
  14. package/src/i18n/translations/ru.yml +1 -2
  15. package/src/i18n/translations/uk.yml +1 -2
  16. package/src/i18n/translations.ts +0 -1
  17. package/src/layouts/Page.astro +1 -0
  18. package/src/layouts/components/PageNavigation.astro +13 -7
  19. package/src/pages/details-page/components/main-details/AudioPlayer.astro +1 -1
  20. package/src/pages/details-page/components/main-details/Cover.astro +1 -0
  21. package/src/pages/search-page/SearchPageRoute.astro +10 -40
  22. package/src/pages/search-page/components/LoadingSkeleton.tsx +19 -0
  23. package/src/pages/search-page/components/SearchFilter.astro +65 -0
  24. package/src/pages/search-page/components/SearchFilter.tsx +33 -84
  25. package/src/pages/search-page/components/SearchList.astro +50 -0
  26. package/src/pages/search-page/components/SearchList.tsx +117 -0
  27. package/src/pages/search-page/components/SearchListItem.tsx +105 -0
  28. package/src/pages/search-page/components/Select.tsx +5 -5
  29. package/src/pages/search-page/hooks/use-search-query-param.ts +31 -0
  30. package/src/pages/search-page/hooks/use-search.ts +103 -49
  31. package/src/pages/search-page/utils/search-filter-translations.ts +20 -0
  32. package/src/pages/search-page/utils/search-query.ts +76 -0
  33. package/src/pages/search-page/utils/search-translations.ts +1 -12
  34. package/src/utils/markdown.ts +8 -1
  35. package/src/pages/search-page/Search.tsx +0 -71
  36. package/src/pages/search-page/components/ResultList.tsx +0 -135
  37. package/src/pages/search-page/types.ts +0 -11
  38. 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
- export type SearchQuery = {
7
- search?: string
8
- language?: string
9
- type?: string
10
- category?: string
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
- fuse.current = new Fuse(items, {
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
- fetchData()
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 { language, type, category, search } = query
52
- const fuseQuery = []
53
- // order is relevant! query will stop evaluation
54
- // when condition is not met.
55
- if (language) {
56
- fuseQuery.push({ language: `=${language}` })
57
- }
58
- if (type) {
59
- fuseQuery.push({ type: `=${type}` })
60
- }
61
- if (category) {
62
- fuseQuery.push({ categories: `=${category}` })
63
- }
64
- if (search) {
65
- fuseQuery.push({
66
- $or: [
67
- { title: search },
68
- { description: search },
69
- { authors: search },
70
- { id: search },
71
- ],
72
- })
73
- }
74
-
75
- const updateQuery = (newQuery: SearchQuery) => {
76
- const queryIsUpdated = (
77
- ["search", "category", "language", "type"] as const
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 (!queryIsUpdated) {
81
- return
138
+ if (!fuse.current || !fuseQuery.length) {
139
+ return allItems
82
140
  }
83
- setQuery(newQuery)
84
- }
85
141
 
86
- if (!fuse.current || !fuseQuery.length) {
87
- return { results: allItems, updateQuery, isLoading }
88
- }
142
+ return fuse.current
143
+ .search({ $and: fuseQuery })
144
+ .map((fuseItem) => fuseItem.item)
145
+ }, [query, allItems])
89
146
 
90
147
  return {
91
- results: fuse.current
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
 
@@ -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
- }
@@ -1,11 +0,0 @@
1
- export type MediaType = {
2
- id: string
3
- label: string
4
- icon: string
5
- }
6
-
7
- export type TranslatedLanguage = {
8
- code: string
9
- name: string
10
- direction: "rtl" | "ltr"
11
- }
@@ -1,5 +0,0 @@
1
- import type { TranslationKey, Translations } from "./search-translations"
2
-
3
- export const useProvidedTranslations = (translations: Translations) => {
4
- return (key: TranslationKey) => translations[key]
5
- }