lightnet 2.15.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (122) hide show
  1. package/CHANGELOG.md +428 -0
  2. package/LICENSE +21 -0
  3. package/README.md +1 -0
  4. package/__e2e__/detailPage.spec.ts +0 -0
  5. package/__e2e__/fixtures/basics/astro.config.mjs +38 -0
  6. package/__e2e__/fixtures/basics/node_modules/.bin/astro +17 -0
  7. package/__e2e__/fixtures/basics/node_modules/.bin/astro-check +17 -0
  8. package/__e2e__/fixtures/basics/node_modules/.bin/tailwind +17 -0
  9. package/__e2e__/fixtures/basics/node_modules/.bin/tailwindcss +17 -0
  10. package/__e2e__/fixtures/basics/node_modules/.bin/tsc +17 -0
  11. package/__e2e__/fixtures/basics/node_modules/.bin/tsserver +17 -0
  12. package/__e2e__/fixtures/basics/package.json +19 -0
  13. package/__e2e__/fixtures/basics/public/favicon.svg +1 -0
  14. package/__e2e__/fixtures/basics/public/files/example.pdf +0 -0
  15. package/__e2e__/fixtures/basics/src/assets/logo.png +0 -0
  16. package/__e2e__/fixtures/basics/src/content/categories/christian-living.json +3 -0
  17. package/__e2e__/fixtures/basics/src/content/categories/teens.json +3 -0
  18. package/__e2e__/fixtures/basics/src/content/categories/theology.json +3 -0
  19. package/__e2e__/fixtures/basics/src/content/media/faithful-freestyle--en.json +13 -0
  20. package/__e2e__/fixtures/basics/src/content/media/how-to-kickflip--de.json +12 -0
  21. package/__e2e__/fixtures/basics/src/content/media/images/cover.jpg +0 -0
  22. package/__e2e__/fixtures/basics/src/content/media/images/how-to-kickflip--en.webp +0 -0
  23. package/__e2e__/fixtures/basics/src/content/media-collections/how-to-articles.json +3 -0
  24. package/__e2e__/fixtures/basics/src/content/media-types/book.json +9 -0
  25. package/__e2e__/fixtures/basics/src/content/media-types/video.json +7 -0
  26. package/__e2e__/fixtures/basics/src/content.config.ts +3 -0
  27. package/__e2e__/fixtures/basics/src/pages/[locale]/index.astro +14 -0
  28. package/__e2e__/fixtures/basics/src/translations/de.json +10 -0
  29. package/__e2e__/fixtures/basics/src/translations/en.json +10 -0
  30. package/__e2e__/fixtures/basics/tailwind.config.mjs +8 -0
  31. package/__e2e__/homepage.spec.ts +108 -0
  32. package/__e2e__/search.spec.ts +16 -0
  33. package/__e2e__/test-utils.ts +80 -0
  34. package/__tests__/pages/details-page/create-content-metadata.spec.ts +104 -0
  35. package/__tests__/utils/markdown.spec.ts +33 -0
  36. package/exports/components.ts +9 -0
  37. package/exports/content.ts +8 -0
  38. package/exports/details-page.ts +1 -0
  39. package/exports/i18n.ts +2 -0
  40. package/exports/index.ts +6 -0
  41. package/exports/utils.ts +2 -0
  42. package/package.json +54 -0
  43. package/playwright.config.ts +30 -0
  44. package/src/astro-integration/config.ts +185 -0
  45. package/src/astro-integration/integration.ts +74 -0
  46. package/src/astro-integration/project-context.ts +5 -0
  47. package/src/astro-integration/virtual.d.ts +14 -0
  48. package/src/astro-integration/vite-plugin-lightnet-config.ts +55 -0
  49. package/src/components/CategoriesOverview.astro +37 -0
  50. package/src/components/Gallery.astro +121 -0
  51. package/src/components/Hero.astro +82 -0
  52. package/src/components/HighlightSection.astro +71 -0
  53. package/src/components/Icon.tsx +27 -0
  54. package/src/components/MediaItemList.astro +84 -0
  55. package/src/components/Section.astro +49 -0
  56. package/src/content/astro-image.ts +14 -0
  57. package/src/content/content-schema-internal.ts +52 -0
  58. package/src/content/content-schema.ts +263 -0
  59. package/src/content/external-api.ts +7 -0
  60. package/src/content/get-categories.ts +15 -0
  61. package/src/content/get-languages.ts +14 -0
  62. package/src/content/get-media-items.ts +27 -0
  63. package/src/content/get-media-types.ts +23 -0
  64. package/src/content/query-media-items.ts +89 -0
  65. package/src/content/resolve-category-label.ts +20 -0
  66. package/src/i18n/get-locale-paths.ts +8 -0
  67. package/src/i18n/languages.ts +10 -0
  68. package/src/i18n/locals.d.ts +38 -0
  69. package/src/i18n/locals.ts +28 -0
  70. package/src/i18n/resolve-default-locale.ts +30 -0
  71. package/src/i18n/resolve-language.ts +25 -0
  72. package/src/i18n/resolve-locales.ts +5 -0
  73. package/src/i18n/translate.ts +64 -0
  74. package/src/i18n/translations/de.json +25 -0
  75. package/src/i18n/translations/en.json +25 -0
  76. package/src/layouts/MarkdownPage.astro +11 -0
  77. package/src/layouts/Page.astro +54 -0
  78. package/src/layouts/components/Favicon.astro +32 -0
  79. package/src/layouts/components/LanguagePicker.astro +38 -0
  80. package/src/layouts/components/Menu.astro +28 -0
  81. package/src/layouts/components/MenuItem.astro +21 -0
  82. package/src/layouts/components/PageNavigation.astro +65 -0
  83. package/src/layouts/components/PageTitle.astro +44 -0
  84. package/src/layouts/components/PreloadReact.tsx +3 -0
  85. package/src/pages/404.astro +14 -0
  86. package/src/pages/RedirectToDefaultLocale.astro +3 -0
  87. package/src/pages/api/search-response.ts +14 -0
  88. package/src/pages/api/search.ts +47 -0
  89. package/src/pages/details-page/DefaultDetails.astro +44 -0
  90. package/src/pages/details-page/DetailsPage.astro +53 -0
  91. package/src/pages/details-page/VideoDetails.astro +43 -0
  92. package/src/pages/details-page/components/Authors.astro +19 -0
  93. package/src/pages/details-page/components/Content.astro +73 -0
  94. package/src/pages/details-page/components/Cover.astro +35 -0
  95. package/src/pages/details-page/components/Description.astro +26 -0
  96. package/src/pages/details-page/components/MediaCollection.astro +39 -0
  97. package/src/pages/details-page/components/MediaCollections.astro +21 -0
  98. package/src/pages/details-page/components/OpenButton.astro +34 -0
  99. package/src/pages/details-page/components/SectionTitle.astro +8 -0
  100. package/src/pages/details-page/components/ShareButton.astro +58 -0
  101. package/src/pages/details-page/components/Title.astro +18 -0
  102. package/src/pages/details-page/components/VideoPlayer.astro +78 -0
  103. package/src/pages/details-page/components/details/Categories.astro +31 -0
  104. package/src/pages/details-page/components/details/Details.astro +17 -0
  105. package/src/pages/details-page/components/details/Label.astro +3 -0
  106. package/src/pages/details-page/components/details/Languages.astro +46 -0
  107. package/src/pages/details-page/utils/create-content-metadata.ts +78 -0
  108. package/src/pages/search-page/Search.tsx +71 -0
  109. package/src/pages/search-page/SearchPage.astro +51 -0
  110. package/src/pages/search-page/components/ResultList.tsx +135 -0
  111. package/src/pages/search-page/components/SearchFilter.tsx +189 -0
  112. package/src/pages/search-page/hooks/use-debounce.ts +17 -0
  113. package/src/pages/search-page/hooks/use-search.ts +95 -0
  114. package/src/pages/search-page/types.ts +9 -0
  115. package/src/pages/search-page/utils/search-translations.ts +22 -0
  116. package/src/pages/search-page/utils/use-provided-translations.ts +5 -0
  117. package/src/utils/markdown.ts +41 -0
  118. package/src/utils/paths.ts +45 -0
  119. package/src/utils/urls.ts +29 -0
  120. package/src/utils/verify-schema.ts +38 -0
  121. package/tailwind.config.ts +56 -0
  122. package/vitest.config.js +19 -0
@@ -0,0 +1,44 @@
1
+ ---
2
+ import Authors from "./components/Authors.astro"
3
+ import Content from "./components/Content.astro"
4
+ import Cover from "./components/Cover.astro"
5
+ import Description from "./components/Description.astro"
6
+ import Details from "./components/details/Details.astro"
7
+ import MediaCollections from "./components/MediaCollections.astro"
8
+ import OpenButton from "./components/OpenButton.astro"
9
+ import ShareButton from "./components/ShareButton.astro"
10
+ import Title from "./components/Title.astro"
11
+
12
+ export type Props = {
13
+ slug: string
14
+ coverStyle?: "default" | "book"
15
+ openActionLabel?: string
16
+ }
17
+
18
+ const {
19
+ slug,
20
+ coverStyle = "default",
21
+ openActionLabel = "ln.details.open",
22
+ } = Astro.props
23
+ ---
24
+
25
+ <div
26
+ class="mx-auto mt-10 flex max-w-screen-md flex-col items-center gap-8 px-4 sm:mt-20 sm:flex-row sm:items-start sm:gap-14 md:px-8"
27
+ >
28
+ <Cover slug={slug} style={coverStyle} />
29
+ <div class="flex flex-col items-center sm:items-start">
30
+ <Title className="text-center sm:text-start" slug={slug} />
31
+ <Authors className="text-center sm:text-start" slug={slug} />
32
+
33
+ <div
34
+ class="mt-8 flex flex-col justify-center gap-4 sm:justify-start md:mt-14"
35
+ >
36
+ <OpenButton slug={slug} openActionLabel={openActionLabel} />
37
+ <ShareButton />
38
+ </div>
39
+ </div>
40
+ </div>
41
+ <Description slug={slug} />
42
+ <Content slug={slug} />
43
+ <MediaCollections slug={slug} />
44
+ <Details slug={slug} />
@@ -0,0 +1,53 @@
1
+ ---
2
+ import type { GetStaticPaths } from "astro"
3
+ import { AstroError } from "astro/errors"
4
+ import config from "virtual:lightnet/config"
5
+
6
+ import { getMediaItem, getMediaItems } from "../../content/get-media-items"
7
+ import { getMediaType } from "../../content/get-media-types"
8
+ import { resolveLocales } from "../../i18n/resolve-locales"
9
+ import Page from "../../layouts/Page.astro"
10
+ import { markdownToText } from "../../utils/markdown"
11
+ import DefaultDetails from "./DefaultDetails.astro"
12
+ import VideoDetails from "./VideoDetails.astro"
13
+
14
+ export const getStaticPaths = (async () => {
15
+ const mediaItems = await getMediaItems()
16
+ return resolveLocales(config).flatMap((locale) =>
17
+ mediaItems.map(({ id: slug }) => ({ params: { slug, locale } })),
18
+ )
19
+ }) satisfies GetStaticPaths
20
+
21
+ const { slug } = Astro.params
22
+ const mediaItem = (await getMediaItem(Astro.params.slug)).data
23
+
24
+ const {
25
+ data: { detailsPage },
26
+ } = await getMediaType(mediaItem.type.id)
27
+
28
+ let CustomDetails
29
+ const layout = detailsPage?.layout ?? "default"
30
+ if (detailsPage?.layout === "custom") {
31
+ const d = import.meta.glob("/src/details-pages/*.astro")
32
+ const customDetailsImport = d[
33
+ `/src/details-pages/${detailsPage.customComponent}`
34
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
35
+ ] as () => Promise<any>
36
+ if (!customDetailsImport) {
37
+ throw new AstroError(
38
+ `Unknown details page ${detailsPage.customComponent}`,
39
+ "Make sure to add your details component within /src/details-pages/",
40
+ )
41
+ }
42
+ CustomDetails = (await customDetailsImport()).default
43
+ }
44
+ ---
45
+
46
+ <Page
47
+ title={mediaItem.title}
48
+ description={markdownToText(mediaItem.description)}
49
+ >
50
+ {layout === "default" && <DefaultDetails slug={slug} {...detailsPage} />}
51
+ {layout === "video" && <VideoDetails slug={slug} {...detailsPage} />}
52
+ {CustomDetails && <CustomDetails slug={slug} {...detailsPage} />}
53
+ </Page>
@@ -0,0 +1,43 @@
1
+ ---
2
+ import { getMediaItem } from "../../content/get-media-items"
3
+ import Authors from "./components/Authors.astro"
4
+ import Content from "./components/Content.astro"
5
+ import Description from "./components/Description.astro"
6
+ import Details from "./components/details/Details.astro"
7
+ import MediaCollections from "./components/MediaCollections.astro"
8
+ import ShareButton from "./components/ShareButton.astro"
9
+ import Title from "./components/Title.astro"
10
+ import VideoPlayer from "./components/VideoPlayer.astro"
11
+
12
+ export type Props = {
13
+ slug: string
14
+ }
15
+
16
+ const { slug } = Astro.props
17
+
18
+ const item = await getMediaItem(slug)
19
+ ---
20
+
21
+ <div class="mx-auto max-w-screen-md md:px-8">
22
+ <div
23
+ class="aspect-video w-full overflow-hidden bg-black md:mt-8 md:rounded-lg"
24
+ >
25
+ <VideoPlayer
26
+ url={item.data.content[0].url!}
27
+ title={item.data.title}
28
+ image={item.data.image}
29
+ />
30
+ </div>
31
+ </div>
32
+ <div
33
+ class="mx-auto mt-12 flex max-w-screen-md flex-col items-start px-4 md:mt-14 md:px-8"
34
+ >
35
+ <Title slug={slug} />
36
+ <Authors slug={slug} />
37
+ <ShareButton className="mt-8" />
38
+ </div>
39
+
40
+ <Description slug={slug} />
41
+ <Content slug={slug} />
42
+ <MediaCollections slug={slug} />
43
+ <Details slug={slug} />
@@ -0,0 +1,19 @@
1
+ ---
2
+ import { getMediaItem } from "../../../content/get-media-items"
3
+
4
+ interface Props {
5
+ slug: string
6
+ className?: string
7
+ }
8
+
9
+ const item = await getMediaItem(Astro.props.slug)
10
+ const authors = item.data.authors?.join(", ")
11
+ ---
12
+
13
+ {
14
+ authors && (
15
+ <p class="mt-2 text-xl sm:text-2xl" class:list={Astro.props.className}>
16
+ {authors}
17
+ </p>
18
+ )
19
+ }
@@ -0,0 +1,73 @@
1
+ ---
2
+ import Icon from "../../../components/Icon"
3
+ import { getMediaItem } from "../../../content/get-media-items"
4
+ import {
5
+ createContentMetadata,
6
+ type UrlType,
7
+ } from "../utils/create-content-metadata"
8
+
9
+ interface Props {
10
+ slug: string
11
+ }
12
+
13
+ const item = await getMediaItem(Astro.props.slug)
14
+
15
+ if (item.data.content.length < 2) {
16
+ return
17
+ }
18
+
19
+ const content = item.data.content.map((c) => createContentMetadata(c))
20
+ const typeIcons: { [k in UrlType]: string } = {
21
+ audio: "mdi--music",
22
+ text: "mdi--text",
23
+ source: "mdi--code-tags",
24
+ link: "mdi--link-variant",
25
+ image: "mdi--image-outline",
26
+ video: "mdi--video-outline",
27
+ package: "mdi--zip-box-outline",
28
+ } as const
29
+
30
+ const { t, direction } = Astro.locals.i18n
31
+ ---
32
+
33
+ <ol
34
+ class="mx-auto mt-20 max-w-screen-md overflow-hidden bg-gray-200 md:mt-24 md:rounded-xl"
35
+ >
36
+ {
37
+ content.map(
38
+ ({ extension, name, type, canBeOpened, url, target }, index) => (
39
+ <li class="group -mt-px px-4 transition-colors ease-in-out hover:bg-gray-300 md:px-8">
40
+ <a
41
+ href={url}
42
+ target={target}
43
+ class="flex items-center justify-between py-8"
44
+ >
45
+ <span class="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-gray-800">
46
+ <Icon
47
+ className={`${typeIcons[type]} text-gray-200`}
48
+ ariaLabel=""
49
+ />
50
+ </span>
51
+
52
+ <div class="ms-4 line-clamp-1 shrink grow overflow-hidden sm:ms-8">
53
+ {name}
54
+ </div>
55
+ <div class="me-4 ms-2 shrink-0 font-bold uppercase text-gray-600 sm:me-8">
56
+ {extension}
57
+ </div>
58
+ <Icon
59
+ className={`${canBeOpened ? "mdi--chevron-right" : "mdi--download"} shrink-0 bg-gray-600 group-hover:bg-gray-800`}
60
+ ariaLabel={
61
+ canBeOpened ? t("ln.details.open") : t("ln.details.download")
62
+ }
63
+ flipIcon={direction === "rtl"}
64
+ />
65
+ </a>
66
+ {index !== content.length - 1 && (
67
+ <div class="h-px w-full bg-gray-300" />
68
+ )}
69
+ </li>
70
+ ),
71
+ )
72
+ }
73
+ </ol>
@@ -0,0 +1,35 @@
1
+ ---
2
+ import { Image } from "astro:assets"
3
+
4
+ import { getMediaItem } from "../../../content/get-media-items"
5
+
6
+ interface Props {
7
+ slug: string
8
+ style?: "default" | "book"
9
+ }
10
+ const { slug, style = "default" } = Astro.props
11
+
12
+ const item = await getMediaItem(slug)
13
+ const image = item.data.image
14
+ const isPortraitImage = image.height > image.width
15
+ ---
16
+
17
+ <div
18
+ class="relative shrink-0 overflow-hidden shadow-sm"
19
+ class:list={style === "book" ? "rounded-sm" : "rounded-md"}
20
+ >
21
+ <Image
22
+ class:list={isPortraitImage ? "h-52 w-auto sm:h-72" : "h-auto w-52 sm:w-72"}
23
+ alt=""
24
+ widths={[208, 288, 576, 864]}
25
+ sizes="(max-width: 640px) 13rem, 18rem"
26
+ src={image}
27
+ quality="high"
28
+ loading="eager"
29
+ />
30
+ {
31
+ style === "book" && (
32
+ <span class="absolute start-[3px] top-0 h-full w-[4px] bg-gradient-to-r from-gray-500/20 to-transparent" />
33
+ )
34
+ }
35
+ </div>
@@ -0,0 +1,26 @@
1
+ ---
2
+ import { getMediaItem } from "../../../content/get-media-items"
3
+ import { resolveLanguage } from "../../../i18n/resolve-language"
4
+ import { markdownToHtml } from "../../../utils/markdown"
5
+
6
+ interface Props {
7
+ slug: string
8
+ }
9
+ const item = await getMediaItem(Astro.props.slug)
10
+
11
+ const { description, language } = item.data
12
+
13
+ const { direction, code: lang } = resolveLanguage(language)
14
+ ---
15
+
16
+ {
17
+ description && (
18
+ <div
19
+ dir={direction}
20
+ class="prose prose-gray mx-auto mt-10 max-w-screen-md px-4 sm:mt-14 md:px-8"
21
+ set:html={await markdownToHtml(description)}
22
+ lang={lang}
23
+ id="description"
24
+ />
25
+ )
26
+ }
@@ -0,0 +1,39 @@
1
+ ---
2
+ import { AstroError } from "astro/errors"
3
+ import { getEntry } from "astro:content"
4
+
5
+ import MediaItemList from "../../../components/MediaItemList.astro"
6
+ import { getMediaItems } from "../../../content/get-media-items"
7
+ import { queryMediaItems } from "../../../content/query-media-items"
8
+
9
+ interface Props {
10
+ collectionId: string
11
+ disableItem: string
12
+ }
13
+ const { collectionId, disableItem } = Astro.props
14
+
15
+ const collection = await getEntry("media-collections", collectionId)
16
+ if (!collection) {
17
+ throw new AstroError(`Unknown media collection id ${collection}.`)
18
+ }
19
+ const items = (
20
+ await queryMediaItems(getMediaItems(), {
21
+ where: { collection: collection.id },
22
+ })
23
+ ).map((item) => ({ ...item, disabled: item.id === disableItem }))
24
+
25
+ if (items.length < 2) {
26
+ return
27
+ }
28
+ const t = Astro.locals.i18n.t
29
+ ---
30
+
31
+ <section class="mx-auto mt-16 max-w-screen-md px-4 md:mt-20 md:px-8">
32
+ <h2 class="mb-3 text-xs font-bold uppercase text-gray-600">
33
+ {t("ln.details.part-of-collection")}
34
+ </h2>
35
+ <h3 class="mb-2 text-lg font-bold text-gray-700">
36
+ {t(collection.data.label, { allowFixedStrings: true })}
37
+ </h3>
38
+ <MediaItemList items={items} />
39
+ </section>
@@ -0,0 +1,21 @@
1
+ ---
2
+ import { getMediaItem } from "../../../content/get-media-items"
3
+ import MediaCollection from "./MediaCollection.astro"
4
+
5
+ interface Props {
6
+ slug: string
7
+ }
8
+
9
+ const { slug } = Astro.props
10
+ const item = await getMediaItem(slug)
11
+
12
+ const collections = item.data.collections?.map(
13
+ ({ collection }) => collection.id,
14
+ )
15
+ ---
16
+
17
+ {
18
+ collections?.map((id) => (
19
+ <MediaCollection collectionId={id} disableItem={slug} />
20
+ ))
21
+ }
@@ -0,0 +1,34 @@
1
+ ---
2
+ import Icon from "../../../components/Icon"
3
+ import { getMediaItem } from "../../../content/get-media-items"
4
+ import { createContentMetadata } from "../utils/create-content-metadata"
5
+
6
+ interface Props {
7
+ slug: string
8
+ openActionLabel: string
9
+ }
10
+ const { slug, openActionLabel } = Astro.props
11
+ const item = await getMediaItem(slug)
12
+ const content = createContentMetadata(item.data.content[0])
13
+ ---
14
+
15
+ <a
16
+ class="flex min-w-52 items-center justify-center gap-2 rounded-2xl bg-gray-800 px-6 py-3 font-bold uppercase text-gray-100 shadow-sm hover:bg-gray-950 hover:text-gray-300"
17
+ href={content.url}
18
+ target={content.target}
19
+ hreflang={item.data.language}
20
+ >
21
+ {
22
+ content.canBeOpened
23
+ ? Astro.locals.i18n.t(openActionLabel)
24
+ : Astro.locals.i18n.t("ln.details.download")
25
+ }
26
+ {
27
+ content.isExternal && (
28
+ <Icon
29
+ ariaLabel={Astro.locals.i18n.t("ln.common.a11y.external-link")}
30
+ className={`mdi--external-link shrink-0`}
31
+ />
32
+ )
33
+ }
34
+ </a>
@@ -0,0 +1,8 @@
1
+ ---
2
+ interface Props {
3
+ text: string
4
+ }
5
+ const { text } = Astro.props
6
+ ---
7
+
8
+ <h2 class="mb-2 text-xs font-bold uppercase text-gray-600">{text}</h2>
@@ -0,0 +1,58 @@
1
+ ---
2
+ import Icon from "../../../components/Icon"
3
+
4
+ interface Props {
5
+ className?: string
6
+ }
7
+ ---
8
+
9
+ <button
10
+ class="flex items-center justify-center gap-2 rounded-2xl bg-gray-200 px-4 py-2 text-xs font-bold uppercase text-gray-600 shadow-sm hover:bg-gray-300"
11
+ class:list={[Astro.props.className]}
12
+ id="share-btn"
13
+ ><Icon className="mdi--share-variant-outline" ariaLabel="" />
14
+ {Astro.locals.i18n.t("ln.details.share")}</button
15
+ >
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>
24
+ <script>
25
+ document.addEventListener("astro:after-swap", () => {
26
+ initShareButton()
27
+ })
28
+ initShareButton()
29
+
30
+ function initShareButton() {
31
+ const btn = document.querySelector("#share-btn")
32
+ if (!btn) {
33
+ return
34
+ }
35
+ btn?.addEventListener("click", () => {
36
+ if (navigator.share) {
37
+ navigator
38
+ .share({
39
+ url: window.location.href,
40
+ })
41
+ .catch((e) => console.debug("Could not share", e))
42
+ } else {
43
+ navigator.clipboard
44
+ .writeText(window.location.href)
45
+ .then(() => {
46
+ const toast = document.querySelector<HTMLElement>("#share-success")!
47
+ toast.style.opacity = "100%"
48
+ setTimeout(() => {
49
+ toast.style.opacity = "0%"
50
+ }, 3000)
51
+ })
52
+ .catch((error) =>
53
+ console.log("Error copying URL to clipboard:", error),
54
+ )
55
+ }
56
+ })
57
+ }
58
+ </script>
@@ -0,0 +1,18 @@
1
+ ---
2
+ import { getMediaItem } from "../../../content/get-media-items"
3
+
4
+ interface Props {
5
+ slug: string
6
+ className?: string
7
+ }
8
+
9
+ const item = await getMediaItem(Astro.props.slug)
10
+ ---
11
+
12
+ <h1
13
+ class="text-4xl font-bold sm:text-5xl"
14
+ lang={item.data.language}
15
+ class:list={Astro.props.className}
16
+ >
17
+ {item.data.title}
18
+ </h1>
@@ -0,0 +1,78 @@
1
+ ---
2
+ import type { ImageMetadata } from "astro"
3
+ import { getImage } from "astro:assets"
4
+
5
+ interface Props {
6
+ url: string
7
+ title: string
8
+ image: ImageMetadata
9
+ }
10
+
11
+ const { url, title, image: imageMetadata } = Astro.props
12
+
13
+ const { host, id, image } = await parseUrl(url)
14
+
15
+ async function parseUrl(urlToParse: string): Promise<{
16
+ host: "youtube" | "vimeo" | "mp4"
17
+ id: string | null
18
+ image?: string
19
+ }> {
20
+ const url = new URL(urlToParse)
21
+ // https://www.youtube.com/embed/ABC123abc
22
+ // https://www.youtube.com/watch?v=ABC123abc
23
+ if (url.hostname === "www.youtube.com") {
24
+ if (url.pathname.startsWith("/embed/")) {
25
+ return { host: "youtube", id: url.pathname.slice(7) }
26
+ }
27
+ return { host: "youtube", id: url.searchParams.get("v") }
28
+ }
29
+ // https://youtu.be/ABC123abc
30
+ if (url.hostname === "youtu.be") {
31
+ return { host: "youtube", id: url.pathname.slice(1) }
32
+ }
33
+ // https://www.youtube-nocookie.com/embed/ABC123abc
34
+ if (url.hostname === "www.youtube-nocookie.com") {
35
+ return { host: "youtube", id: url.pathname.slice(7) }
36
+ }
37
+ // https://vimeo.com/12345678
38
+ if (url.hostname === "vimeo.com") {
39
+ return { host: "vimeo", id: url.pathname.slice(1) }
40
+ }
41
+ // https://player.vimeo.com/video/12345678
42
+ if (url.hostname === "player.vimeo.com") {
43
+ return { host: "vimeo", id: url.pathname.slice(7) }
44
+ }
45
+
46
+ // https://domain.com/video.mp4
47
+ if (url.pathname.endsWith(".mp4")) {
48
+ const image = (await getImage({ src: imageMetadata, format: "webp" })).src
49
+ return { host: "mp4", id: url.toString(), image }
50
+ }
51
+ throw Error(`Unsupported video url: ${urlToParse}`)
52
+ }
53
+ ---
54
+
55
+ {
56
+ host === "youtube" ? (
57
+ <iframe
58
+ class="h-full w-full"
59
+ src={`https://www.youtube-nocookie.com/embed/${id}`}
60
+ title={title}
61
+ allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
62
+ referrerpolicy="strict-origin-when-cross-origin"
63
+ allowfullscreen
64
+ />
65
+ ) : host === "vimeo" ? (
66
+ <iframe
67
+ class="h-full w-full"
68
+ src={`https://player.vimeo.com/video/${id}`}
69
+ allow="autoplay; fullscreen; picture-in-picture"
70
+ allowfullscreen
71
+ />
72
+ ) : host === "mp4" ? (
73
+ <video class="h-full w-full" controls preload="auto" poster={image}>
74
+ <source src={id} type="video/mp4" />
75
+ Your browser does not support the video tag.
76
+ </video>
77
+ ) : null
78
+ }
@@ -0,0 +1,31 @@
1
+ ---
2
+ import { getMediaItem } from "../../../../content/get-media-items"
3
+ import { resolveCategoryLabel } from "../../../../content/resolve-category-label"
4
+ import { searchPagePath } from "../../../../utils/paths"
5
+ import Label from "./Label.astro"
6
+
7
+ interface Props {
8
+ slug: string
9
+ }
10
+
11
+ const item = await getMediaItem(Astro.props.slug)
12
+
13
+ const categories = item.data.categories?.map(({ id }) => id)
14
+ ---
15
+
16
+ {
17
+ !!categories?.length && (
18
+ <div>
19
+ <Label>{Astro.locals.i18n.t("ln.common.categories")}</Label>
20
+ <ul class="flex flex-wrap gap-2">
21
+ {categories.map(async (category) => (
22
+ <li class="flex rounded-lg bg-gray-200 px-4 py-1 text-gray-500 hover:bg-gray-300">
23
+ <a href={searchPagePath(Astro.currentLocale, { category })}>
24
+ {resolveCategoryLabel(Astro.locals.i18n.t, category)}
25
+ </a>
26
+ </li>
27
+ ))}
28
+ </ul>
29
+ </div>
30
+ )
31
+ }
@@ -0,0 +1,17 @@
1
+ ---
2
+ import Categories from "./Categories.astro"
3
+ import Languages from "./Languages.astro"
4
+
5
+ interface Props {
6
+ slug: string
7
+ }
8
+
9
+ const { slug } = Astro.props
10
+ ---
11
+
12
+ <div class="mx-auto mt-20 max-w-screen-md px-4 sm:mt-24 md:px-8" id="details">
13
+ <div class="flex flex-col gap-4">
14
+ <Languages slug={slug} />
15
+ <Categories slug={slug} />
16
+ </div>
17
+ </div>
@@ -0,0 +1,3 @@
1
+ <div class="mb-2 font-bold text-gray-700">
2
+ <slot />
3
+ </div>
@@ -0,0 +1,46 @@
1
+ ---
2
+ import {
3
+ getMediaItem,
4
+ getMediaItems,
5
+ } from "../../../../content/get-media-items"
6
+ import { resolveTranslatedLanguage } from "../../../../i18n/resolve-language"
7
+ import { detailsPagePath } from "../../../../utils/paths"
8
+ import Label from "./Label.astro"
9
+
10
+ interface Props {
11
+ slug: string
12
+ }
13
+
14
+ const item = await getMediaItem(Astro.props.slug)
15
+
16
+ const { slug } = Astro.props
17
+ const translations = (await getMediaItems())
18
+ .filter(
19
+ (entry) => entry.data.commonId === item.data.commonId && entry.id !== slug,
20
+ )
21
+ .sort((a, b) => a.data.language.localeCompare(b.data.language))
22
+
23
+ const label = translations.length ? "ln.common.languages" : "ln.common.language"
24
+ const { t } = Astro.locals.i18n
25
+ ---
26
+
27
+ <div>
28
+ <Label>{t(label)}</Label>
29
+ <ul class="flex flex-wrap gap-2">
30
+ <li class="py-1 pe-2 text-gray-800">
31
+ {resolveTranslatedLanguage(item.data.language, t).name}
32
+ </li>
33
+ {
34
+ translations.map((translation) => (
35
+ <li class="flex rounded-lg border border-gray-200 px-4 py-1 text-gray-600 hover:bg-gray-200">
36
+ <a
37
+ href={detailsPagePath(Astro.currentLocale, translation)}
38
+ hreflang={translation.data.language}
39
+ >
40
+ {resolveTranslatedLanguage(translation.data.language, t).name}
41
+ </a>
42
+ </li>
43
+ ))
44
+ }
45
+ </ul>
46
+ </div>