lightnet 3.10.0 → 3.10.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 (60) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/__e2e__/admin.spec.ts +113 -0
  3. package/__e2e__/fixtures/basics/astro.config.mjs +6 -0
  4. package/__e2e__/fixtures/basics/node_modules/.bin/astro +2 -2
  5. package/__e2e__/fixtures/basics/package.json +2 -2
  6. package/__e2e__/fixtures/basics/src/content/media/skate-sounds--en.json +15 -0
  7. package/__e2e__/fixtures/basics/src/content/media-types/audio.json +7 -0
  8. package/__e2e__/fixtures/basics/src/translations/de.yml +1 -0
  9. package/__e2e__/fixtures/basics/src/translations/en.yml +1 -0
  10. package/__e2e__/homepage.spec.ts +21 -0
  11. package/package.json +13 -6
  12. package/src/admin/api/fs/writeText.ts +50 -0
  13. package/src/admin/components/form/FieldErrors.tsx +22 -0
  14. package/src/admin/components/form/SubmitButton.tsx +79 -0
  15. package/src/admin/components/form/TextField.tsx +24 -0
  16. package/src/admin/components/form/form-context.ts +4 -0
  17. package/src/admin/components/form/index.ts +16 -0
  18. package/src/admin/i18n/translations/en.yml +11 -0
  19. package/src/admin/i18n/translations.ts +5 -0
  20. package/src/admin/pages/AdminRoute.astro +16 -0
  21. package/src/admin/pages/media/EditForm.tsx +73 -0
  22. package/src/admin/pages/media/EditRoute.astro +42 -0
  23. package/src/admin/pages/media/file-system.ts +37 -0
  24. package/src/admin/pages/media/media-item-store.ts +11 -0
  25. package/src/admin/types/media-item.ts +10 -0
  26. package/src/api/media/[mediaId].ts +16 -0
  27. package/src/{pages/api → api}/versions.ts +1 -1
  28. package/src/astro-integration/config.ts +15 -0
  29. package/src/astro-integration/integration.ts +44 -6
  30. package/src/components/CategoriesSection.astro +1 -1
  31. package/src/components/MediaGallerySection.astro +1 -1
  32. package/src/components/Toast.tsx +55 -0
  33. package/src/components/showToast.ts +61 -0
  34. package/src/content/astro-image.ts +1 -14
  35. package/src/content/content-schema.ts +10 -3
  36. package/src/content/get-media-items.ts +46 -1
  37. package/src/i18n/locals.d.ts +35 -28
  38. package/src/i18n/locals.ts +2 -1
  39. package/src/i18n/react/i18n-context.ts +32 -0
  40. package/src/i18n/react/prepare-i18n-config.ts +31 -0
  41. package/src/i18n/react/useI18n.ts +15 -0
  42. package/src/i18n/translate.ts +15 -3
  43. package/src/i18n/translations.ts +20 -7
  44. package/src/layouts/Page.astro +1 -1
  45. package/src/pages/details-page/components/MainDetailsSection.astro +5 -1
  46. package/src/pages/details-page/components/VideoDetailsSection.astro +5 -1
  47. package/src/pages/details-page/components/main-details/EditButton.astro +30 -0
  48. package/src/pages/details-page/components/main-details/ShareButton.astro +9 -13
  49. package/src/pages/{api → search-page/api}/search.ts +3 -3
  50. package/src/pages/search-page/components/LoadingSkeleton.tsx +3 -1
  51. package/src/pages/search-page/components/SearchFilter.astro +12 -3
  52. package/src/pages/search-page/components/SearchFilter.tsx +4 -7
  53. package/src/pages/search-page/components/SearchList.astro +7 -6
  54. package/src/pages/search-page/components/SearchList.tsx +12 -15
  55. package/src/pages/search-page/components/SearchListItem.tsx +3 -5
  56. package/src/pages/search-page/hooks/use-search.ts +3 -3
  57. package/tailwind.config.ts +1 -0
  58. package/src/pages/search-page/utils/search-filter-translations.ts +0 -20
  59. package/src/pages/search-page/utils/search-translations.ts +0 -11
  60. /package/src/pages/{api → search-page/api}/search-response.ts +0 -0
@@ -1,5 +1,10 @@
1
1
  import YAML from "yaml"
2
2
 
3
+ import {
4
+ type AdminTranslationKey,
5
+ builtInAdminTranslations,
6
+ } from "../admin/i18n/translations"
7
+
3
8
  const builtInTranslations = {
4
9
  ar: () => import("./translations/ar.yml?raw"),
5
10
  bn: () => import("./translations/bn.yml?raw"),
@@ -32,23 +37,30 @@ const userTranslations = Object.fromEntries(
32
37
  )
33
38
 
34
39
  export const loadTranslations = async (bcp47: string) => ({
35
- ...(await loadBuiltInTranslations(bcp47)),
40
+ ...(await loadBuiltInTranslations(builtInTranslations, bcp47)),
41
+ ...(await loadBuiltInTranslations(builtInAdminTranslations, bcp47)),
36
42
  ...(await loadUserTranslations(bcp47)),
37
43
  })
38
44
 
39
- function isBuiltInLanguage(bcp47: string): bcp47 is BuiltInLanguage {
40
- return Object.hasOwn(builtInTranslations, bcp47)
45
+ function hasTranslations(
46
+ translationMap: Record<string, unknown>,
47
+ bcp47: string,
48
+ ): bcp47 is BuiltInLanguage {
49
+ return Object.hasOwn(translationMap, bcp47)
41
50
  }
42
51
 
43
- export const loadBuiltInTranslations = async (bcp47: string) => {
44
- if (!isBuiltInLanguage(bcp47)) {
52
+ const loadBuiltInTranslations = async (
53
+ translationMap: Record<string, () => Promise<typeof import("*?raw")>>,
54
+ bcp47: string,
55
+ ) => {
56
+ if (!hasTranslations(translationMap, bcp47)) {
45
57
  return {}
46
58
  }
47
- const yml = (await builtInTranslations[bcp47]()).default
59
+ const yml = (await translationMap[bcp47]()).default
48
60
  return YAML.parse(yml)
49
61
  }
50
62
 
51
- export const loadUserTranslations = async (bcp47: string) => {
63
+ const loadUserTranslations = async (bcp47: string) => {
52
64
  if (!userTranslations[bcp47]) {
53
65
  return {}
54
66
  }
@@ -82,3 +94,4 @@ export type LightNetTranslationKey =
82
94
  | "ln.search.title"
83
95
  | "ln.share.url-copied-to-clipboard"
84
96
  | "ln.footer.powered-by-lightnet"
97
+ | AdminTranslationKey
@@ -31,7 +31,7 @@ const language = resolveLanguage(currentLocale)
31
31
  <title>{title ? `${title} | ${configTitle}` : configTitle}</title>
32
32
  {description && <meta name="description" content={description} />}
33
33
  {config.manifest && <link rel="manifest" href={config.manifest} />}
34
- <link rel="prefetch" href="/api/search.json" />
34
+ <link rel="prefetch" href="/api/internal/search.json" />
35
35
  <Favicon />
36
36
  <ViewTransition />
37
37
  </head>
@@ -1,6 +1,7 @@
1
1
  ---
2
2
  import Authors from "./main-details/Authors.astro"
3
3
  import CoverImage from "./main-details/CoverImage.astro"
4
+ import EditButton from "./main-details/EditButton.astro"
4
5
  import ShareButton from "./main-details/ShareButton.astro"
5
6
  import Title from "./main-details/Title.astro"
6
7
 
@@ -19,7 +20,10 @@ const { mediaId, imageSize } = Astro.props
19
20
  <div class="flex w-full grow flex-col items-center sm:items-start">
20
21
  <Title className="text-center sm:text-start" mediaId={mediaId} />
21
22
  <Authors className="mt-2" mediaId={mediaId} />
22
- <ShareButton className="mt-3" />
23
+ <div class="mt-4 flex gap-6">
24
+ <ShareButton />
25
+ <EditButton mediaId={mediaId} />
26
+ </div>
23
27
  <slot />
24
28
  </div>
25
29
  </div>
@@ -2,6 +2,7 @@
2
2
  import VideoPlayer from "../../../components/VideoPlayer.astro"
3
3
  import { getMediaItem } from "../../../content/get-media-items"
4
4
  import Authors from "./main-details/Authors.astro"
5
+ import EditButton from "./main-details/EditButton.astro"
5
6
  import ShareButton from "./main-details/ShareButton.astro"
6
7
  import Title from "./main-details/Title.astro"
7
8
 
@@ -26,5 +27,8 @@ const item = await getMediaItem(mediaId)
26
27
  >
27
28
  <Title mediaId={mediaId} />
28
29
  <Authors mediaId={mediaId} />
29
- <ShareButton className="mt-3" />
30
+ <div class="mt-4 flex gap-6">
31
+ <ShareButton />
32
+ <EditButton mediaId={mediaId} />
33
+ </div>
30
34
  </div>
@@ -0,0 +1,30 @@
1
+ ---
2
+ import config from "virtual:lightnet/config"
3
+
4
+ import Icon from "../../../../components/Icon"
5
+
6
+ interface Props {
7
+ mediaId: string
8
+ }
9
+
10
+ const { mediaId } = Astro.props
11
+ ---
12
+
13
+ <a
14
+ class="hidden cursor-pointer items-center gap-2 font-bold text-gray-700 underline"
15
+ id="edit-btn"
16
+ data-admin-enabled={config.experimental?.admin?.enabled}
17
+ href={`/${Astro.currentLocale}/admin/media/${mediaId}`}
18
+ ><Icon className="mdi--square-edit-outline" ariaLabel="" />
19
+ {Astro.locals.i18n.t("ln.admin.edit")}</a
20
+ >
21
+ <script>
22
+ const btn: HTMLAnchorElement | null = document.querySelector("#edit-btn")
23
+ const showEditButton =
24
+ btn?.dataset.adminEnabled === "true" &&
25
+ (import.meta.env.DEV || localStorage.getItem("ln-admin-enabled") === "true")
26
+ if (showEditButton) {
27
+ btn?.classList.remove("hidden")
28
+ btn?.classList.add("flex")
29
+ }
30
+ </script>
@@ -1,5 +1,6 @@
1
1
  ---
2
2
  import Icon from "../../../../components/Icon"
3
+ import Toast from "../../../../components/Toast"
3
4
 
4
5
  interface Props {
5
6
  className?: string
@@ -13,16 +14,15 @@ interface Props {
13
14
  ><Icon className="mdi--share" ariaLabel="" />
14
15
  {Astro.locals.i18n.t("ln.details.share")}</button
15
16
  >
16
- <div
17
- id="share-success"
18
- class="dy-toast pointer-events-none opacity-0 transition-opacity duration-300"
19
- >
20
- <div class="dy-alert dy-alert-success">
21
- <span>{Astro.locals.i18n.t("ln.share.url-copied-to-clipboard")}</span>
22
- </div>
23
- </div>
17
+ <Toast id="share-success" variant="success">
18
+ {Astro.locals.i18n.t("ln.share.url-copied-to-clipboard")}
19
+ </Toast>
24
20
  <script>
21
+ import { showToastById } from "../../../../components/showToast"
22
+
25
23
  const btn = document.querySelector("#share-btn")
24
+ const toastId = "share-success"
25
+
26
26
  btn?.addEventListener("click", () => {
27
27
  if (navigator.share) {
28
28
  navigator
@@ -34,11 +34,7 @@ interface Props {
34
34
  navigator.clipboard
35
35
  .writeText(window.location.href)
36
36
  .then(() => {
37
- const toast = document.querySelector<HTMLElement>("#share-success")!
38
- toast.style.opacity = "100%"
39
- setTimeout(() => {
40
- toast.style.opacity = "0%"
41
- }, 3000)
37
+ showToastById(toastId)
42
38
  })
43
39
  .catch((error) => console.log("Error copying URL to clipboard:", error))
44
40
  }
@@ -1,9 +1,9 @@
1
1
  import type { APIRoute } from "astro"
2
2
  import { getImage } from "astro:assets"
3
3
 
4
- import type { MediaItemEntry } from "../../content/content-schema"
5
- import { getMediaItems } from "../../content/get-media-items"
6
- import { markdownToText } from "../../utils/markdown"
4
+ import type { MediaItemEntry } from "../../../content/content-schema"
5
+ import { getMediaItems } from "../../../content/get-media-items"
6
+ import { markdownToText } from "../../../utils/markdown"
7
7
  import type { SearchItem } from "./search-response"
8
8
 
9
9
  export const GET: APIRoute = async () => {
@@ -1,6 +1,8 @@
1
1
  import Icon from "../../../components/Icon"
2
+ import { useI18n } from "../../../i18n/react/useI18n"
2
3
 
3
- export default function LoadingSkeleton({ direction }: { direction: string }) {
4
+ export default function LoadingSkeleton() {
5
+ const { direction } = useI18n()
4
6
  return (
5
7
  <div className="flex h-52 animate-pulse items-center overflow-hidden py-2 sm:h-64">
6
8
  <div className="h-36 w-36 shrink-0 rounded-md bg-gray-200"></div>
@@ -4,7 +4,7 @@ import config from "virtual:lightnet/config"
4
4
  import { getUsedCategories } from "../../../content/get-categories"
5
5
  import { contentLanguages } from "../../../content/get-languages"
6
6
  import { getMediaTypes } from "../../../content/get-media-types"
7
- import { provideTranslations } from "../utils/search-filter-translations"
7
+ import { prepareI18nConfig } from "../../../i18n/react/prepare-i18n-config"
8
8
  import SearchFilterReact from "./SearchFilter.tsx"
9
9
 
10
10
  const { t, currentLocale } = Astro.locals.i18n
@@ -49,7 +49,16 @@ if (
49
49
  ) {
50
50
  initialLanguageFilter = currentLocale
51
51
  }
52
- const translations = provideTranslations(t)
52
+ const i18nConfig = prepareI18nConfig(Astro.locals.i18n, [
53
+ "ln.search.title",
54
+ "ln.language",
55
+ "ln.search.placeholder",
56
+ "ln.search.all-languages",
57
+ "ln.type",
58
+ "ln.search.all-types",
59
+ "ln.category",
60
+ "ln.search.all-categories",
61
+ ])
53
62
  ---
54
63
 
55
64
  <SearchFilterReact
@@ -57,7 +66,7 @@ const translations = provideTranslations(t)
57
66
  languages={languages}
58
67
  mediaTypes={mediaTypes}
59
68
  categories={categories}
60
- translations={translations}
69
+ i18nConfig={i18nConfig}
61
70
  languageFilterEnabled={languageFilterEnabled}
62
71
  typesFilterEnabled={typesFilterEnabled}
63
72
  categoriesFilterEnabled={categoriesFilterEnabled}
@@ -1,12 +1,9 @@
1
1
  import { useRef } from "react"
2
2
 
3
3
  import Icon from "../../../components/Icon"
4
+ import { createI18n, type I18nConfig } from "../../../i18n/react/i18n-context"
4
5
  import { useDebounce } from "../hooks/use-debounce"
5
6
  import { useSearchQueryParam } from "../hooks/use-search-query-param"
6
- import type {
7
- TranslationKey,
8
- Translations,
9
- } from "../utils/search-filter-translations"
10
7
  import { CATEGORY, LANGUAGE, SEARCH, TYPE } from "../utils/search-query"
11
8
  import Select from "./Select"
12
9
 
@@ -16,7 +13,7 @@ interface Props {
16
13
  languages: FilterValue[]
17
14
  categories: FilterValue[]
18
15
  mediaTypes: FilterValue[]
19
- translations: Translations
16
+ i18nConfig: I18nConfig
20
17
  languageFilterEnabled: boolean
21
18
  typesFilterEnabled: boolean
22
19
  categoriesFilterEnabled: boolean
@@ -26,7 +23,7 @@ interface Props {
26
23
  export default function SearchFilter({
27
24
  categories,
28
25
  mediaTypes,
29
- translations,
26
+ i18nConfig,
30
27
  languages,
31
28
  languageFilterEnabled,
32
29
  typesFilterEnabled,
@@ -40,7 +37,7 @@ export default function SearchFilter({
40
37
 
41
38
  const searchInput = useRef<HTMLInputElement | null>(null)
42
39
 
43
- const t = (key: TranslationKey) => translations[key]
40
+ const { t } = createI18n(i18nConfig)
44
41
 
45
42
  const debouncedSetSearch = useDebounce((value: string) => {
46
43
  setSearch(value)
@@ -4,10 +4,10 @@ import { getCollection } from "astro:content"
4
4
  import { getUsedCategories } from "../../../content/get-categories"
5
5
  import { contentLanguages } from "../../../content/get-languages"
6
6
  import { getMediaTypes } from "../../../content/get-media-types"
7
- import { provideTranslations } from "../utils/search-translations"
7
+ import { prepareI18nConfig } from "../../../i18n/react/prepare-i18n-config"
8
8
  import SearchListReact from "./SearchList.tsx"
9
9
 
10
- const { t, currentLocale, direction } = Astro.locals.i18n
10
+ const { t, currentLocale } = Astro.locals.i18n
11
11
 
12
12
  const categories: Record<string, string> = {}
13
13
  for (const { id, name } of await getUsedCategories(currentLocale, t)) {
@@ -34,15 +34,16 @@ const languages = Object.fromEntries(
34
34
 
35
35
  const mediaItemsTotal = (await getCollection("media")).length
36
36
 
37
- const translations = provideTranslations(t)
38
37
  const showLanguage = contentLanguages.length > 1
38
+
39
+ const i18nConfig = prepareI18nConfig(Astro.locals.i18n, [
40
+ "ln.search.no-results",
41
+ ])
39
42
  ---
40
43
 
41
44
  <SearchListReact
42
45
  client:load
43
- currentLocale={currentLocale}
44
- direction={direction}
45
- translations={translations}
46
+ i18nConfig={i18nConfig}
46
47
  categories={categories}
47
48
  mediaTypes={mediaTypes}
48
49
  languages={languages}
@@ -1,8 +1,12 @@
1
1
  import { useWindowVirtualizer } from "@tanstack/react-virtual"
2
2
  import { useEffect, useRef, useState } from "react"
3
3
 
4
+ import {
5
+ createI18n,
6
+ type I18nConfig,
7
+ I18nContext,
8
+ } from "../../../i18n/react/i18n-context"
4
9
  import { useSearch } from "../hooks/use-search"
5
- import type { TranslationKey, Translations } from "../utils/search-translations"
6
10
  import LoadingSkeleton from "./LoadingSkeleton"
7
11
  import SearchListItem, {
8
12
  type MediaType,
@@ -10,9 +14,7 @@ import SearchListItem, {
10
14
  } from "./SearchListItem"
11
15
 
12
16
  interface Props {
13
- currentLocale: string | undefined
14
- translations: Translations
15
- direction: "rtl" | "ltr"
17
+ i18nConfig: I18nConfig
16
18
  categories: Record<string, string>
17
19
  languages: Record<string, TranslatedLanguage>
18
20
  showLanguage: boolean
@@ -21,11 +23,9 @@ interface Props {
21
23
  }
22
24
 
23
25
  export default function SearchList({
24
- currentLocale,
25
26
  categories,
26
- translations,
27
+ i18nConfig,
27
28
  languages,
28
- direction,
29
29
  showLanguage,
30
30
  mediaTypes,
31
31
  mediaItemsTotal,
@@ -63,11 +63,10 @@ export default function SearchList({
63
63
  observer.disconnect()
64
64
  }
65
65
  }, [])
66
-
67
- const t = (key: TranslationKey) => translations[key]
66
+ const i18n = createI18n(i18nConfig)
68
67
 
69
68
  return (
70
- <>
69
+ <I18nContext.Provider value={i18n}>
71
70
  <div ref={listRef} className="px-4 md:px-8">
72
71
  <ol
73
72
  className="relative w-full divide-y divide-gray-200"
@@ -89,13 +88,11 @@ export default function SearchList({
89
88
  }}
90
89
  >
91
90
  {isLoading ? (
92
- <LoadingSkeleton direction={direction} />
91
+ <LoadingSkeleton />
93
92
  ) : (
94
93
  <SearchListItem
95
94
  item={item}
96
- direction={direction}
97
95
  showLanguage={showLanguage}
98
- currentLocale={currentLocale}
99
96
  categories={categories}
100
97
  languages={languages}
101
98
  mediaTypes={mediaTypes}
@@ -108,9 +105,9 @@ export default function SearchList({
108
105
  </div>
109
106
  {!results.length && !isLoading && (
110
107
  <div className="mt-24 text-center font-bold text-gray-500">
111
- {t("ln.search.no-results")}
108
+ {i18n.t("ln.search.no-results")}
112
109
  </div>
113
110
  )}
114
- </>
111
+ </I18nContext.Provider>
115
112
  )
116
113
  }
@@ -1,7 +1,8 @@
1
1
  import CoverImageDecorator from "../../../components/CoverImageDecorator"
2
2
  import Icon from "../../../components/Icon"
3
+ import { useI18n } from "../../../i18n/react/useI18n"
3
4
  import { detailsPagePath } from "../../../utils/paths"
4
- import type { SearchItem } from "../../api/search-response"
5
+ import type { SearchItem } from "../api/search-response"
5
6
 
6
7
  export type MediaType = {
7
8
  name: string
@@ -16,8 +17,6 @@ export type TranslatedLanguage = {
16
17
 
17
18
  interface Props {
18
19
  item: SearchItem
19
- currentLocale: string | undefined
20
- direction: "rtl" | "ltr"
21
20
  categories: Record<string, string>
22
21
  languages: Record<string, TranslatedLanguage>
23
22
  showLanguage: boolean
@@ -26,13 +25,12 @@ interface Props {
26
25
 
27
26
  export default function SearchListItem({
28
27
  item,
29
- currentLocale,
30
28
  categories,
31
29
  languages,
32
- direction,
33
30
  showLanguage,
34
31
  mediaTypes,
35
32
  }: Props) {
33
+ const { currentLocale, direction } = useI18n()
36
34
  const coverImageStyle = mediaTypes[item.type].coverImageStyle
37
35
  return (
38
36
  <a
@@ -1,7 +1,7 @@
1
1
  import Fuse from "fuse.js"
2
2
  import { useEffect, useMemo, useRef, useState } from "react"
3
3
 
4
- import type { SearchItem, SearchResponse } from "../../api/search-response"
4
+ import type { SearchItem, SearchResponse } from "../api/search-response"
5
5
  import { observeSearchQuery, type SearchQuery } from "../utils/search-query"
6
6
 
7
7
  interface Context {
@@ -28,10 +28,10 @@ export function useSearch({ categories, mediaTypes, languages }: Context) {
28
28
  })
29
29
  const fetchData = async () => {
30
30
  try {
31
- const response = await fetch("/api/search.json")
31
+ const response = await fetch("/api/internal/search.json")
32
32
  if (!response.ok) {
33
33
  throw new Error(
34
- "Was not able to load search results from /api/search.json.",
34
+ "Was not able to load search results from /api/internal/search.json.",
35
35
  )
36
36
  }
37
37
  const { items }: SearchResponse = await response.json()
@@ -35,6 +35,7 @@ export function lightnetStyles({
35
35
  secondary: primary,
36
36
  accent: primary,
37
37
  neutral: "#030712",
38
+ error: "#9f1239",
38
39
  "base-100": "#f9fafb",
39
40
 
40
41
  "--rounded-box": "0.375rem", // border radius rounded-box utility class, used in card and other large boxes
@@ -1,20 +0,0 @@
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
- }
@@ -1,11 +0,0 @@
1
- const translationKeys = ["ln.search.no-results"] as const
2
-
3
- export type TranslationKey = (typeof translationKeys)[number]
4
-
5
- export type Translations = Record<TranslationKey, string>
6
-
7
- export const provideTranslations = (translate: (key: string) => string) => {
8
- return Object.fromEntries(
9
- translationKeys.map((key) => [key, translate(key)]),
10
- ) as Translations
11
- }