lightnet 3.8.1 → 3.9.1

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 CHANGED
@@ -1,5 +1,52 @@
1
1
  # lightnet
2
2
 
3
+ ## 3.9.1
4
+
5
+ ### Patch Changes
6
+
7
+ - [#312](https://github.com/LightNetDev/LightNet/pull/312) [`cda4af5`](https://github.com/LightNetDev/LightNet/commit/cda4af5e48d365ba7704c562874ba9fe7423ec79) Thanks [@smn-cds](https://github.com/smn-cds)! - Fix SearchSection padding
8
+
9
+ ## 3.9.0
10
+
11
+ ### Minor Changes
12
+
13
+ - [#309](https://github.com/LightNetDev/LightNet/pull/309) [`5ea2c13`](https://github.com/LightNetDev/LightNet/commit/5ea2c139e52a20b951643530dd0a57e50ed04ad1) Thanks [@smn-cds](https://github.com/smn-cds)! - Media type config: Added `coverImageStyle`
14
+ - Introduced a new `coverImageStyle` option for media type configuration.
15
+ - Controls how cover images are rendered for media items.
16
+
17
+ Supported values:
18
+ - `"default"` — unmodified media item image (default)
19
+ - `"book"` — styled as a book cover (book fold, sharper edges)
20
+ - `"video"` — forced 16:9 aspect ratio, ⚠️ removed filling up with a black background but scale the image to cover the whole cover area.
21
+
22
+ #### Deprecation Notice
23
+
24
+ The existing `detailsPage.coverStyle` option is now deprecated and will be removed in a future major release. Use `coverImageStyle` instead.
25
+
26
+ - [#309](https://github.com/LightNetDev/LightNet/pull/309) [`5ea2c13`](https://github.com/LightNetDev/LightNet/commit/5ea2c139e52a20b951643530dd0a57e50ed04ad1) Thanks [@smn-cds](https://github.com/smn-cds)! - `MediaGallerySection`: Added `carousel` layout option
27
+ - Introduced a new `viewLayout` prop for `MediaGallerySection`.
28
+ - Supported values:
29
+ - `"grid"` (default; existing behavior)
30
+ - `"carousel"` (new; renders items in a horizontal carousel)
31
+ - This change is **backwards-compatible**. Existing usages without `viewLayout` will continue to render as a grid.
32
+
33
+ ### Example
34
+
35
+ ```astro
36
+ <MediaGallerySection
37
+ title={t("x.home.our-latest-books")}
38
+ items={latestBooks}
39
+ layout="book"
40
+ viewLayout="carousel"
41
+ />
42
+ ```
43
+
44
+ - [#309](https://github.com/LightNetDev/LightNet/pull/309) [`5ea2c13`](https://github.com/LightNetDev/LightNet/commit/5ea2c139e52a20b951643530dd0a57e50ed04ad1) Thanks [@smn-cds](https://github.com/smn-cds)! - Added Kazakh language.
45
+
46
+ ### Patch Changes
47
+
48
+ - [#309](https://github.com/LightNetDev/LightNet/pull/309) [`5ea2c13`](https://github.com/LightNetDev/LightNet/commit/5ea2c139e52a20b951643530dd0a57e50ed04ad1) Thanks [@smn-cds](https://github.com/smn-cds)! - Improved page styling: shadows, spacing
49
+
3
50
  ## 3.8.1
4
51
 
5
52
  ### Patch Changes
package/README.md CHANGED
@@ -1,37 +1,29 @@
1
1
  ![LightNet](https://github.com/LightNetDev/lightnet/blob/main/lightnet-banner.webp)
2
2
 
3
- Share the gospel and strengthen believers in your community.
3
+ Many ministries struggle to share media content effectively. LightNet makes it easy to **run your own digital media library**, so more people can find what they need and grow in faith.
4
4
 
5
- LightNet makes it easy to **run your own digital media library**, so more people can find what they need and grow in faith.
6
-
7
- Built as an integration for the [Astro framework](https://astro.build), LightNet enables the creation of fast, statically generated websites that can be easily hosted on any file server.
5
+ Built as an integration for the [Astro framework](https://astro.build), LightNet creates fast, static sites that run anywhere.
8
6
 
9
7
  Learn more on the [LightNet homepage](https://lightnet.community).
10
8
 
11
- ## Start Your Own Library
12
-
13
- Get up and running quickly with LightNet by exploring its features and best practices. You can build your own media library by starting with the [LightNet example template](https://github.com/LightNetDev/example-template), which creates a local copy of a demo site for a fictional skateboard ministry. This beginner-friendly template serves as a great starting point for developers.
14
-
15
- To get started, simply run the following command in your terminal:
16
-
17
- ```bash
18
- npm create astro@latest -- --template LightNetDev/example-template
19
- ```
9
+ ## 🚀 Launch your own library
20
10
 
21
- This will set up the demo site that you can customize and expand to meet your community’s needs.
11
+ Quickly launch your own media library with LightNet. [Follow the getting started guide](https://docs.lightnet.community/start-here/getting-started/) to spin up a template, then customize and expand it to fit your community’s needs.
22
12
 
23
- ## Documentation
13
+ ## 🌍 Real-world example
24
14
 
25
- Need help? [Explore the LightNet developer docs](https://docs.lightnet.community) for everything you need to get started and make the most of LightNet.
15
+ Curious what LightNet looks like in action? [Check out the MediaWorks digital library](https://library.mediaworks.global)
16
+ to see how it powers a live site.
26
17
 
27
- ## Showcase
18
+ ## 👀 Want to learn more?
28
19
 
29
- Want to see LightNet in action? [Visit the MediaWorks digital library](https://library.mediaworks.global) and see how it powers a real-world digital library.
20
+ Take a look at the [LightNet developer docs](https://docs.lightnet.community).
30
21
 
31
- ## Contributing
22
+ ## 🤝 Contributing
32
23
 
33
- Want to help improve LightNet? [Check out the contribution guide](https://github.com/LightNetDev/lightnet/blob/main/CONTRIBUTING.md) to learn how you can get involved and make a difference!
24
+ Got ideas or improvements? We’d love your help! [See the contribution guide](https://github.com/LightNetDev/lightnet/blob/main/CONTRIBUTING.md) to get started.
34
25
 
35
- ## License
26
+ ## 📄 License
36
27
 
37
- LightNet is licensed under the MIT License. See the full details in the [LICENSE](https://github.com/LightNetDev/lightnet/blob/main/LICENSE) file.
28
+ LightNet uses the MIT License. You can read the full details in the [LICENSE](https://github.com/LightNetDev/lightnet/blob/main/LICENSE)
29
+ file.
@@ -10,9 +10,9 @@ case `uname` in
10
10
  esac
11
11
 
12
12
  if [ -z "$NODE_PATH" ]; then
13
- export NODE_PATH="/home/runner/work/LightNet/LightNet/node_modules/.pnpm/astro@5.12.8_@types+node@24.2.0_jiti@2.4.2_lightningcss@1.29.1_rollup@4.46.2_terser@5.39.0_typescript@5.9.2_yaml@2.8.1/node_modules/astro/node_modules:/home/runner/work/LightNet/LightNet/node_modules/.pnpm/astro@5.12.8_@types+node@24.2.0_jiti@2.4.2_lightningcss@1.29.1_rollup@4.46.2_terser@5.39.0_typescript@5.9.2_yaml@2.8.1/node_modules:/home/runner/work/LightNet/LightNet/node_modules/.pnpm/node_modules"
13
+ export NODE_PATH="/home/runner/work/LightNet/LightNet/node_modules/.pnpm/astro@5.13.3_@types+node@24.3.0_jiti@2.4.2_lightningcss@1.29.1_rollup@4.48.1_terser@5.39.0_typescript@5.9.2_yaml@2.8.1/node_modules/astro/node_modules:/home/runner/work/LightNet/LightNet/node_modules/.pnpm/astro@5.13.3_@types+node@24.3.0_jiti@2.4.2_lightningcss@1.29.1_rollup@4.48.1_terser@5.39.0_typescript@5.9.2_yaml@2.8.1/node_modules:/home/runner/work/LightNet/LightNet/node_modules/.pnpm/node_modules"
14
14
  else
15
- export NODE_PATH="/home/runner/work/LightNet/LightNet/node_modules/.pnpm/astro@5.12.8_@types+node@24.2.0_jiti@2.4.2_lightningcss@1.29.1_rollup@4.46.2_terser@5.39.0_typescript@5.9.2_yaml@2.8.1/node_modules/astro/node_modules:/home/runner/work/LightNet/LightNet/node_modules/.pnpm/astro@5.12.8_@types+node@24.2.0_jiti@2.4.2_lightningcss@1.29.1_rollup@4.46.2_terser@5.39.0_typescript@5.9.2_yaml@2.8.1/node_modules:/home/runner/work/LightNet/LightNet/node_modules/.pnpm/node_modules:$NODE_PATH"
15
+ export NODE_PATH="/home/runner/work/LightNet/LightNet/node_modules/.pnpm/astro@5.13.3_@types+node@24.3.0_jiti@2.4.2_lightningcss@1.29.1_rollup@4.48.1_terser@5.39.0_typescript@5.9.2_yaml@2.8.1/node_modules/astro/node_modules:/home/runner/work/LightNet/LightNet/node_modules/.pnpm/astro@5.13.3_@types+node@24.3.0_jiti@2.4.2_lightningcss@1.29.1_rollup@4.48.1_terser@5.39.0_typescript@5.9.2_yaml@2.8.1/node_modules:/home/runner/work/LightNet/LightNet/node_modules/.pnpm/node_modules:$NODE_PATH"
16
16
  fi
17
17
  if [ -x "$basedir/node" ]; then
18
18
  exec "$basedir/node" "$basedir/../astro/astro.js" "$@"
@@ -7,8 +7,8 @@
7
7
  "@astrojs/react": "^4.3.0",
8
8
  "@astrojs/tailwind": "^6.0.2",
9
9
  "@lightnet/decap-admin": "^3.1.2",
10
- "astro": "^5.12.8",
11
- "lightnet": "^3.8.0",
10
+ "astro": "^5.13.3",
11
+ "lightnet": "^3.9.0",
12
12
  "react": "^19.1.1",
13
13
  "react-dom": "^19.1.1",
14
14
  "sharp": "^0.34.3",
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "lightnet",
3
3
  "type": "module",
4
4
  "license": "MIT",
5
- "version": "3.8.1",
5
+ "version": "3.9.1",
6
6
  "repository": {
7
7
  "type": "git",
8
8
  "url": "https://github.com/LightNetDev/lightnet",
@@ -48,17 +48,16 @@
48
48
  "@tanstack/react-virtual": "^3.13.12",
49
49
  "daisyui": "^4.12.24",
50
50
  "embla-carousel": "^8.6.0",
51
- "embla-carousel-auto-height": "^8.6.0",
52
- "embla-carousel-wheel-gestures": "^8.0.2",
51
+ "embla-carousel-wheel-gestures": "^8.1.0",
53
52
  "fuse.js": "^7.1.0",
54
- "i18next": "^25.3.2",
55
- "marked": "^16.1.2",
53
+ "i18next": "^25.4.2",
54
+ "marked": "^16.2.0",
56
55
  "yaml": "^2.8.1"
57
56
  },
58
57
  "devDependencies": {
59
- "@playwright/test": "^1.54.2",
60
- "@types/node": "^22.17.0",
61
- "@types/react": "^19.1.9",
58
+ "@playwright/test": "^1.55.0",
59
+ "@types/node": "^22.18.0",
60
+ "@types/react": "^19.1.11",
62
61
  "typescript": "^5.9.2",
63
62
  "vitest": "^3.2.4"
64
63
  },
@@ -47,7 +47,6 @@ const { t, direction } = Astro.locals.i18n
47
47
 
48
48
  <script>
49
49
  import EmblaCarousel from "embla-carousel"
50
- import AutoHeightPlugin from "embla-carousel-auto-height"
51
50
  import { WheelGesturesPlugin } from "embla-carousel-wheel-gestures"
52
51
 
53
52
  class Carousel extends HTMLElement {
@@ -65,7 +64,7 @@ const { t, direction } = Astro.locals.i18n
65
64
  skipSnaps: true,
66
65
  direction,
67
66
  },
68
- [AutoHeightPlugin(), WheelGesturesPlugin({ forceWheelAxis: "x" })],
67
+ [WheelGesturesPlugin({ forceWheelAxis: "x" })],
69
68
  )
70
69
  const prevBtn = this.querySelector(
71
70
  "[data-button-prev]",
@@ -78,6 +77,14 @@ const { t, direction } = Astro.locals.i18n
78
77
  nextBtn.addEventListener("click", () => carousel.scrollNext())
79
78
 
80
79
  const updateArrowButtons = () => {
80
+ if (!carousel.canScrollPrev() && !carousel.canScrollNext()) {
81
+ prevBtn.style.visibility = "hidden"
82
+ nextBtn.style.visibility = "hidden"
83
+ } else {
84
+ prevBtn.style.visibility = "visible"
85
+ nextBtn.style.visibility = "visible"
86
+ }
87
+
81
88
  if (carousel.canScrollPrev()) {
82
89
  prevBtn.removeAttribute("disabled")
83
90
  } else {
@@ -40,7 +40,7 @@ function getImage({ image, id }: Category) {
40
40
  class="group flex h-full flex-col gap-3"
41
41
  >
42
42
  <div
43
- class="relative overflow-hidden rounded-2xl shadow-md outline-2 outline-gray-400 transition-colors duration-75 ease-in-out sm:group-hover:outline"
43
+ class="relative overflow-hidden rounded-2xl shadow-sm outline-2 outline-gray-400 transition-colors duration-75 ease-in-out sm:group-hover:outline"
44
44
  class:list={[
45
45
  !category.image && "h-full min-h-28 w-full bg-gray-300",
46
46
  ]}
@@ -52,8 +52,8 @@ function getImage({ image, id }: Category) {
52
52
  alt=""
53
53
  widths={[128, 256, 388, 512, 768]}
54
54
  sizes={
55
- "(max-width: 640px) calc(calc(100vw - 3.5rem ) / 2), " +
56
- "(max-width: 768px) calc(calc(100vw - 5rem ) / 3), " +
55
+ "(max-width: 640px) calc(calc(100vw - 3rem ) / 2), " +
56
+ "(max-width: 768px) calc(calc(100vw - 4rem ) / 3), " +
57
57
  "(max-width: 1024px) calc(calc(100vw - 10rem ) / 4), " +
58
58
  "(max-width: 1280px) calc(calc(100vw - 12rem ) / 5), " +
59
59
  "217px"
@@ -86,18 +86,22 @@ function getImage({ image, id }: Category) {
86
86
  <CarouselSection {...props} title={resolvedTitle}>
87
87
  {categories.map((category) => (
88
88
  <li
89
- class="carousel-item--narrow h-full"
89
+ class="carousel-item--narrow"
90
90
  role="group"
91
91
  aria-roledescription="slide"
92
+ class:list={[
93
+ /** Category cards without images should take the full height of other cards */
94
+ category.image ? "h-full" : "self-stretch",
95
+ ]}
92
96
  >
93
97
  <a
94
98
  href={searchPagePath(currentLocale, { category: category.id })}
95
99
  class="group flex h-full flex-col gap-3"
96
100
  >
97
101
  <div
98
- class="relative overflow-hidden rounded-2xl shadow-md outline-2 outline-gray-400 transition-all duration-75 ease-in-out sm:group-hover:outline"
102
+ class="relative overflow-hidden rounded-2xl shadow-sm outline-2 outline-gray-400 transition-all duration-75 ease-in-out sm:group-hover:outline"
99
103
  class:list={[
100
- !category.image && "h-full min-h-28 w-full bg-gray-300",
104
+ !category.image && "min-h-28 w-full flex-1 bg-gray-300",
101
105
  ]}
102
106
  >
103
107
  {category.image && (
@@ -0,0 +1,29 @@
1
+ import type { ReactNode } from "react"
2
+
3
+ type ImageStyle = "default" | "book" | "video"
4
+
5
+ type Props = {
6
+ children: ReactNode
7
+ style?: ImageStyle
8
+ className?: string
9
+ }
10
+
11
+ const containerClass: { [key in ImageStyle]: string } = {
12
+ default: "rounded-md",
13
+ book: "rounded-sm",
14
+ video: "rounded-md aspect-video",
15
+ }
16
+
17
+ export default function CoverImageDecorator(props: Props) {
18
+ const { children, style = "default", className = "" } = props
19
+ return (
20
+ <div
21
+ className={`relative overflow-hidden ${containerClass[style]} shadow-sm ${className}`}
22
+ >
23
+ {children}
24
+ {style === "book" && (
25
+ <span className="absolute start-[1%] top-0 h-full w-[2%] bg-gradient-to-r from-gray-500/20 to-transparent" />
26
+ )}
27
+ </div>
28
+ )
29
+ }
@@ -1,9 +1,16 @@
1
1
  ---
2
2
  import type { ImageMetadata } from "astro"
3
+ import { Image } from "astro:assets"
3
4
 
4
- import MediaGallery from "./MediaGallery.astro"
5
+ import { getMediaTypes } from "../content/get-media-types"
6
+ import { detailsPagePath } from "../utils/paths"
7
+ import CarouselSection from "./CarouselSection.astro"
8
+ import CoverImageDecorator from "./CoverImageDecorator"
9
+ import Icon from "./Icon"
5
10
  import Section, { type Props as SectionProps } from "./Section.astro"
6
11
 
12
+ type ItemStyle = "book" | "video" | "portrait" | "landscape"
13
+
7
14
  type MediaItem = {
8
15
  id: string
9
16
  data: {
@@ -15,12 +22,150 @@ type MediaItem = {
15
22
 
16
23
  type Props = SectionProps & {
17
24
  items: (MediaItem | undefined)[]
18
- layout: "book" | "video" | "portrait" | "landscape"
25
+ layout: ItemStyle
26
+ viewLayout?: "grid" | "carousel"
19
27
  }
20
28
 
21
- const { items, layout, ...sectionProps } = Astro.props
29
+ const {
30
+ items: itemsInput,
31
+ layout: itemStyle,
32
+ viewLayout = "grid",
33
+ ...sectionProps
34
+ } = Astro.props
35
+
36
+ const t = Astro.locals.i18n.t
37
+
38
+ const types = Object.fromEntries(
39
+ (await getMediaTypes()).map((type) => [
40
+ type.id,
41
+ { ...type.data, name: t(type.data.label) },
42
+ ]),
43
+ )
44
+
45
+ // We allow for undefined values because
46
+ // this is in the return type of getEntry
47
+ const items = itemsInput.filter((item) => !!item)
48
+
49
+ const itemWidth = (
50
+ {
51
+ book: "narrow",
52
+ portrait: "narrow",
53
+ video: "wide",
54
+ landscape: "wide",
55
+ } as const
56
+ )[itemStyle]
57
+
58
+ const imageSizes = {
59
+ narrow:
60
+ // 2 columns
61
+ "(max-width: 640px) calc(calc(100vw - 3rem ) / 2), " +
62
+ // 3 columns
63
+ "(max-width: 768px) calc(calc(100vw - 4rem ) / 3), " +
64
+ // 4 columns
65
+ "(max-width: 1024px) calc(calc(100vw - 10rem ) / 4), " +
66
+ // 5 columns
67
+ "(max-width: 1280px) calc(calc(100vw - 12rem ) / 5), " +
68
+ // 5 columns - full size
69
+ "217px",
70
+ wide:
71
+ // 1 column
72
+ "(max-width: 640px) calc(calc(100vw - 2rem ) / 1), " +
73
+ // 2 columns
74
+ "(max-width: 768px) calc(calc(100vw - 3rem ) / 2), " +
75
+ // 3 columns
76
+ "(max-width: 1024px) calc(calc(100vw - 8rem ) / 3), " +
77
+ // 4 columns
78
+ "(max-width: 1280px) calc(calc(100vw - 10rem ) / 4), " +
79
+ // 5 columns - full size
80
+ "280px",
81
+ } as const
82
+
83
+ const gridLayouts = {
84
+ narrow: "grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5",
85
+ wide: "grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4",
86
+ } as const
87
+
88
+ const coverImageStyle =
89
+ (["book", "video"] as const).find((style) => itemStyle === style) ?? "default"
22
90
  ---
23
91
 
24
- <Section {...sectionProps}>
25
- <MediaGallery items={items} layout={layout} />
26
- </Section>
92
+ {
93
+ viewLayout === "grid" && (
94
+ <Section {...sectionProps}>
95
+ <ol
96
+ class="grid items-end justify-between gap-4 md:gap-8"
97
+ class:list={gridLayouts[itemWidth]}
98
+ >
99
+ {items.map((item) => (
100
+ <li>
101
+ <a
102
+ href={detailsPagePath(Astro.currentLocale, item)}
103
+ class="group flex flex-col gap-3"
104
+ >
105
+ <CoverImageDecorator
106
+ style={coverImageStyle}
107
+ className="outline-2 outline-gray-400 transition-all duration-75 ease-in-out sm:group-hover:outline"
108
+ >
109
+ <Image
110
+ class="h-full w-full object-cover"
111
+ class:list={[itemStyle === "video" && "absolute top-0"]}
112
+ src={item.data.image}
113
+ alt=""
114
+ widths={[128, 256, 512, 768]}
115
+ sizes={imageSizes[itemWidth]}
116
+ />
117
+ </CoverImageDecorator>
118
+
119
+ <span class="line-clamp-2 h-12 text-balance text-sm font-bold text-gray-700">
120
+ <Icon
121
+ className={`${types[item.data.type.id].icon} me-2 align-bottom`}
122
+ ariaLabel={types[item.data.type.id].name}
123
+ />
124
+ {item.data.title}
125
+ </span>
126
+ </a>
127
+ </li>
128
+ ))}
129
+ </ol>
130
+ </Section>
131
+ )
132
+ }
133
+ {
134
+ viewLayout === "carousel" && (
135
+ <CarouselSection {...sectionProps}>
136
+ {items.map((item) => (
137
+ <li
138
+ class={`carousel-item--${itemWidth} h-full`}
139
+ role="group"
140
+ aria-roledescription="slide"
141
+ >
142
+ <a
143
+ href={detailsPagePath(Astro.currentLocale, item)}
144
+ class="group flex h-full flex-col justify-end gap-3"
145
+ >
146
+ <CoverImageDecorator
147
+ style={coverImageStyle}
148
+ className="outline-2 outline-gray-400 transition-all duration-75 ease-in-out sm:group-hover:outline"
149
+ >
150
+ <Image
151
+ class="h-full w-full object-cover"
152
+ src={item.data.image}
153
+ alt=""
154
+ widths={[128, 256, 512, 768]}
155
+ sizes={imageSizes[itemWidth]}
156
+ />
157
+ </CoverImageDecorator>
158
+
159
+ <span class="line-clamp-2 h-12 text-balance text-sm font-bold text-gray-700">
160
+ <Icon
161
+ className={`${types[item.data.type.id].icon} me-2 align-bottom`}
162
+ ariaLabel={types[item.data.type.id].name}
163
+ />
164
+ {item.data.title}
165
+ </span>
166
+ </a>
167
+ </li>
168
+ ))}
169
+ </CarouselSection>
170
+ )
171
+ }
@@ -4,6 +4,7 @@ import { Image } from "astro:assets"
4
4
 
5
5
  import { getMediaTypes } from "../content/get-media-types"
6
6
  import { detailsPagePath } from "../utils/paths"
7
+ import CoverImageDecorator from "./CoverImageDecorator"
7
8
  import Icon from "./Icon"
8
9
 
9
10
  type MediaItem = {
@@ -35,6 +36,7 @@ const mediaTypes = Object.fromEntries(
35
36
  id: type.id,
36
37
  name: t(type.data.label),
37
38
  icon: type.data.icon,
39
+ coverImageStyle: type.data.coverImageStyle,
38
40
  },
39
41
  ]),
40
42
  )
@@ -59,16 +61,24 @@ const mediaTypes = Object.fromEntries(
59
61
  tabindex={item.disabled ? -1 : 0}
60
62
  >
61
63
  <div class="flex h-32 w-32 shrink-0 flex-col items-start justify-center">
62
- <Image
63
- class="max-h-32 w-auto max-w-32 rounded-sm object-contain shadow-md"
64
- src={item.data.image}
65
- width={256}
66
- alt=""
67
- />
64
+ <CoverImageDecorator
65
+ style={mediaTypes[item.data.type.id].coverImageStyle}
66
+ >
67
+ <Image
68
+ class="max-h-32 w-auto max-w-32 object-cover"
69
+ class:list={[
70
+ mediaTypes[item.data.type.id].coverImageStyle === "video" &&
71
+ "aspect-video",
72
+ ]}
73
+ src={item.data.image}
74
+ width={256}
75
+ alt=""
76
+ />
77
+ </CoverImageDecorator>
68
78
  </div>
69
79
 
70
80
  <div
71
- class="ms-5 flex grow flex-col justify-center sm:ms-8"
81
+ class="ms-4 flex grow flex-col justify-center sm:ms-6"
72
82
  lang={item.data.language}
73
83
  >
74
84
  <p class="mb-1 line-clamp-3 text-balance font-bold text-gray-700 md:mb-3">
@@ -12,7 +12,7 @@ const { t } = Astro.locals.i18n
12
12
  action={`/${Astro.currentLocale}/media`}
13
13
  method="get"
14
14
  role="search"
15
- class="dy-join group w-full rounded-2xl shadow-md outline-2 outline-offset-2 outline-gray-400 group-focus-within:outline"
15
+ class="dy-join group w-full rounded-2xl shadow-sm outline-2 outline-offset-2 outline-gray-400 group-focus-within:outline"
16
16
  class:list={[Astro.props.className]}
17
17
  >
18
18
  <input
@@ -10,8 +10,8 @@ const { className = "", titleClass = "", ...props } = Astro.props
10
10
 
11
11
  <Section
12
12
  {...props}
13
- className={"px-0 md:px-0 " + className}
14
- titleClass={"px-4 md:px-8 " + titleClass}
13
+ className={"!px-0 md:!px-0 " + className}
14
+ titleClass={"!px-4 md:!px-8 " + titleClass}
15
15
  maxWidth="narrow"
16
16
  >
17
17
  <div class="px-4 md:px-8">
@@ -183,80 +183,111 @@ export const createCategorySchema = ({ image }: SchemaContext) =>
183
183
  /**
184
184
  * Media Type Schema
185
185
  */
186
- export const mediaTypeSchema = z.object({
187
- /**
188
- * Name of this media type that will be shown on the pages.
189
- *
190
- * This can either be a fixed string or a translation key.
191
- *
192
- * @example "media-type.book"
193
- */
194
- label: z.string(),
195
- /**
196
- * What media item details page to use for media items with this type.
197
- *
198
- */
199
- detailsPage: z
200
- .discriminatedUnion("layout", [
201
- z.object({
202
- /**
203
- * Details page for all media types.
204
- */
205
- layout: z.literal("default"),
206
- /**
207
- * Label for the open action button. Use this if you want to change the text
208
- * of the "Open" button to be more matching to your media item.
209
- * For example you could change the text to be "Read" for a book media type.
210
- *
211
- * The label is a translation key.
212
- *
213
- * @example "ln.details.open"
214
- */
215
- openActionLabel: z.string().optional(),
216
- /**
217
- * What style to use for the cover image.
218
- *
219
- * @example "book"
220
- */
221
- coverStyle: z.enum(["default", "book"]).default("default"),
222
- }),
223
- z.object({
224
- /**
225
- * Custom details page.
226
- */
227
- layout: z.literal("custom"),
228
- /**
229
- * This references a custom component name to be used for the
230
- * details page. The custom component has be located at src/details-pages/
231
- *
232
- * @example "MyArticleDetails.astro"
233
- */
234
- customComponent: z.string(),
235
- }),
236
- z.object({
237
- /**
238
- * Detail page for videos.
239
- */
240
- layout: z.literal("video"),
241
- }),
242
- z.object({
243
- /**
244
- * Detail page for audio files.
245
- *
246
- * This only supports mp3 files.
247
- */
248
- layout: z.literal("audio"),
249
- }),
250
- ])
251
- .optional(),
252
- /**
253
- * Pick the media type's icon from https://pictogrammers.com/library/mdi/
254
- * Prefix it's name with "mdi--"
255
- *
256
- * @example "mdi--ab-testing"
257
- */
258
- icon: z.string(),
259
- })
186
+ export const mediaTypeSchema = z
187
+ .object({
188
+ /**
189
+ * Name of this media type that will be shown on the pages.
190
+ *
191
+ * This can either be a fixed string or a translation key.
192
+ *
193
+ * @example "media-type.book"
194
+ */
195
+ label: z.string(),
196
+ /**
197
+ * Defines how the cover image for a media item of this type is rendered.
198
+ *
199
+ * Options:
200
+ * - `"default"` — Renders the media item image with no modifications.
201
+ * - `"book"` — Adds a book fold effect and sharper edges, styled like a book cover.
202
+ * - `"video"` — Constrains the image to a 16:9 aspect ratio with a black background.
203
+ *
204
+ * @default "default"
205
+ */
206
+ coverImageStyle: z.enum(["default", "book", "video"]).default("default"),
207
+ /**
208
+ * What media item details page to use for media items with this type.
209
+ *
210
+ */
211
+ detailsPage: z
212
+ .discriminatedUnion("layout", [
213
+ z.object({
214
+ /**
215
+ * Details page for all media types.
216
+ */
217
+ layout: z.literal("default"),
218
+ /**
219
+ * Label for the open action button. Use this if you want to change the text
220
+ * of the "Open" button to be more matching to your media item.
221
+ * For example you could change the text to be "Read" for a book media type.
222
+ *
223
+ * The label is a translation key.
224
+ *
225
+ * @example "ln.details.open"
226
+ */
227
+ openActionLabel: z.string().optional(),
228
+ /**
229
+ * (Deprecated) Specifies the style of the cover image.
230
+ *
231
+ * Use `coverImageStyle` instead. This option will be removed in a future major release.
232
+ *
233
+ * Supported values:
234
+ * - `"default"` — unmodified media item image
235
+ * - `"book"` — styled as a book cover (book fold, sharper edges)
236
+ *
237
+ * @example "book"
238
+ * @deprecated Use `coverImageStyle` instead
239
+ */
240
+ coverStyle: z.enum(["default", "book"]).default("default"),
241
+ }),
242
+ z.object({
243
+ /**
244
+ * Custom details page.
245
+ */
246
+ layout: z.literal("custom"),
247
+ /**
248
+ * This references a custom component name to be used for the
249
+ * details page. The custom component has be located at src/details-pages/
250
+ *
251
+ * @example "MyArticleDetails.astro"
252
+ */
253
+ customComponent: z.string(),
254
+ }),
255
+ z.object({
256
+ /**
257
+ * Detail page for videos.
258
+ */
259
+ layout: z.literal("video"),
260
+ }),
261
+ z.object({
262
+ /**
263
+ * Detail page for audio files.
264
+ *
265
+ * This only supports mp3 files.
266
+ */
267
+ layout: z.literal("audio"),
268
+ }),
269
+ ])
270
+ .optional(),
271
+ /**
272
+ * Pick the media type's icon from https://pictogrammers.com/library/mdi/
273
+ * Prefix it's name with "mdi--"
274
+ *
275
+ * @example "mdi--ab-testing"
276
+ */
277
+ icon: z.string(),
278
+ })
279
+ .transform((mediaType) => {
280
+ // migrate old cover images style to new property
281
+ const hasDeprecatedBookCover =
282
+ mediaType.detailsPage?.layout === "default" &&
283
+ mediaType.detailsPage.coverStyle === "book"
284
+ return {
285
+ ...mediaType,
286
+ coverImageStyle: hasDeprecatedBookCover
287
+ ? "book"
288
+ : mediaType.coverImageStyle,
289
+ }
290
+ })
260
291
 
261
292
  export const LIGHTNET_COLLECTIONS = {
262
293
  categories: defineCollection({
@@ -34,6 +34,13 @@ All keys have been translated. ✅
34
34
 
35
35
  All keys have been translated. ✅
36
36
 
37
+ ## **KK** ([kk.yml](./kk.yml))
38
+
39
+ Missing keys:
40
+
41
+ - ln.previous
42
+ - ln.next
43
+
37
44
  ## **PT** ([pt.yml](./pt.yml))
38
45
 
39
46
  All keys have been translated. ✅
@@ -0,0 +1,22 @@
1
+ ln.header.open-main-menu: Бастапқы мәзірді ашу
2
+ ln.header.select-language: Тіл таңдау
3
+ ln.home.title: Басты бет
4
+ ln.category: Санат
5
+ ln.categories: Барлық санаттар
6
+ ln.language: Тіл
7
+ ln.languages: Тілдер
8
+ ln.type: Түрі
9
+ ln.external-link: Сыртқы сілтеме
10
+ ln.search.title: Іздеу
11
+ ln.search.placeholder: Медиа іздеу
12
+ ln.search.all-languages: Барлық тілдер
13
+ ln.search.all-types: Барлық түрлер
14
+ ln.search.all-categories: Барлық санаттар
15
+ ln.search.no-results: Нәтижие жоқ
16
+ ln.details.open: Ашу
17
+ ln.details.share: Бөлісу
18
+ ln.details.part-of-collection: Жинақ бөлімі
19
+ ln.details.download: Жүктеу
20
+ ln.share.url-copied-to-clipboard: Жүктеме көшірілді
21
+ ln.404.page-not-found: Ештеңе табылмады
22
+ ln.404.go-to-the-home-page: Бастапқы бетке өту
@@ -9,6 +9,7 @@ const builtInTranslations = {
9
9
  fi: () => import("./translations/fi.yml?raw"),
10
10
  fr: () => import("./translations/fr.yml?raw"),
11
11
  hi: () => import("./translations/hi.yml?raw"),
12
+ kk: () => import("./translations/kk.yml?raw"),
12
13
  pt: () => import("./translations/pt.yml?raw"),
13
14
  ru: () => import("./translations/ru.yml?raw"),
14
15
  uk: () => import("./translations/uk.yml?raw"),
@@ -3,7 +3,7 @@ import PageNavigation from "./PageNavigation.astro"
3
3
  import PageTitle from "./PageTitle.astro"
4
4
  ---
5
5
 
6
- <header class="fixed top-0 z-50 h-14 w-full bg-white shadow-lg sm:h-20">
6
+ <header class="fixed top-0 z-50 h-14 w-full bg-white shadow-md sm:h-20">
7
7
  <div class="mx-auto flex h-full max-w-screen-xl justify-between px-4 md:px-8">
8
8
  <PageTitle />
9
9
  <PageNavigation />
@@ -15,7 +15,7 @@ const { mediaId } = Astro.props
15
15
  ---
16
16
 
17
17
  <DetailsPage mediaId={mediaId}>
18
- <MainDetailsSection thumbnailSize="md" mediaId={mediaId} />
18
+ <MainDetailsSection imageSize="md" mediaId={mediaId} />
19
19
  <AudioPanel mediaId={mediaId} className="mt-10" />
20
20
  <DescriptionSection mediaId={mediaId} />
21
21
  <ContentSection mediaId={mediaId} minimumItems={1} />
@@ -9,15 +9,14 @@ import MoreDetailsSection from "./components/MoreDetailsSection.astro"
9
9
 
10
10
  export type Props = {
11
11
  mediaId: string
12
- coverStyle?: "default" | "book"
13
12
  openActionLabel?: string
14
13
  }
15
14
 
16
- const { mediaId, coverStyle, openActionLabel = "ln.details.open" } = Astro.props
15
+ const { mediaId, openActionLabel = "ln.details.open" } = Astro.props
17
16
  ---
18
17
 
19
18
  <DetailsPage mediaId={mediaId}>
20
- <MainDetailsSection mediaId={mediaId} thumbnailStyle={coverStyle}>
19
+ <MainDetailsSection mediaId={mediaId}>
21
20
  <OpenButton
22
21
  className="mt-8"
23
22
  mediaId={mediaId}
@@ -1,22 +1,21 @@
1
1
  ---
2
2
  import Authors from "./main-details/Authors.astro"
3
+ import CoverImage from "./main-details/CoverImage.astro"
3
4
  import ShareButton from "./main-details/ShareButton.astro"
4
- import Thumbnail from "./main-details/Thumbnail.astro"
5
5
  import Title from "./main-details/Title.astro"
6
6
 
7
7
  export type Props = {
8
8
  mediaId: string
9
- thumbnailStyle?: "default" | "book"
10
- thumbnailSize?: "md" | "lg"
9
+ imageSize?: "md" | "lg"
11
10
  }
12
11
 
13
- const { mediaId, thumbnailStyle, thumbnailSize } = Astro.props
12
+ const { mediaId, imageSize } = Astro.props
14
13
  ---
15
14
 
16
15
  <div
17
16
  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"
18
17
  >
19
- <Thumbnail size={thumbnailSize} mediaId={mediaId} style={thumbnailStyle} />
18
+ <CoverImage size={imageSize} mediaId={mediaId} />
20
19
  <div class="flex w-full grow flex-col items-center sm:items-start">
21
20
  <Title className="text-center sm:text-start" mediaId={mediaId} />
22
21
  <Authors className="mt-2" mediaId={mediaId} />
@@ -33,7 +33,7 @@ const t = Astro.locals.i18n.t
33
33
  <h2 class="mb-1 text-xs font-bold uppercase text-gray-600 md:mb-2">
34
34
  {t("ln.details.part-of-collection")}
35
35
  </h2>
36
- <h3 class="text-lg font-bold text-gray-700">
36
+ <h3 class="mb-6 text-lg font-bold text-gray-700 sm:mb-8">
37
37
  {t(collection.data.label)}
38
38
  </h3>
39
39
  <MediaList items={items} />
@@ -1,16 +1,18 @@
1
1
  ---
2
2
  import { Image } from "astro:assets"
3
3
 
4
+ import CoverImageDecorator from "../../../../components/CoverImageDecorator"
4
5
  import { getMediaItem } from "../../../../content/get-media-items"
6
+ import { getMediaType } from "../../../../content/get-media-types"
5
7
 
6
8
  interface Props {
7
9
  mediaId: string
8
- style?: "default" | "book"
9
10
  size?: "md" | "lg"
10
11
  }
11
- const { mediaId, style = "default", size = "lg" } = Astro.props
12
+ const { mediaId, size = "lg" } = Astro.props
12
13
 
13
14
  const item = await getMediaItem(mediaId)
15
+ const type = await getMediaType(item.data.type.id)
14
16
  const image = item.data.image
15
17
  const isPortraitImage = image.height > image.width
16
18
  const imageSizes = {
@@ -25,10 +27,7 @@ const imageSizes = {
25
27
  }
26
28
  ---
27
29
 
28
- <div
29
- class="relative shrink-0 overflow-hidden shadow-sm"
30
- class:list={style === "book" ? "rounded-sm" : "rounded-md"}
31
- >
30
+ <CoverImageDecorator className="shrink-0" style={type.data.coverImageStyle}>
32
31
  <Image
33
32
  class:list={imageSizes[size].css}
34
33
  alt=""
@@ -39,9 +38,4 @@ const imageSizes = {
39
38
  loading="eager"
40
39
  fetchpriority="high"
41
40
  />
42
- {
43
- style === "book" && (
44
- <span class="absolute start-[3px] top-0 h-full w-[4px] bg-gradient-to-r from-gray-500/20 to-transparent" />
45
- )
46
- }
47
- </div>
41
+ </CoverImageDecorator>
@@ -20,6 +20,7 @@ const mediaTypes = Object.fromEntries(
20
20
  {
21
21
  name: t(type.data.label),
22
22
  icon: type.data.icon,
23
+ coverImageStyle: type.data.coverImageStyle,
23
24
  },
24
25
  ]),
25
26
  )
@@ -1,3 +1,4 @@
1
+ import CoverImageDecorator from "../../../components/CoverImageDecorator"
1
2
  import Icon from "../../../components/Icon"
2
3
  import { detailsPagePath } from "../../../utils/paths"
3
4
  import type { SearchItem } from "../../api/search-response"
@@ -5,6 +6,7 @@ import type { SearchItem } from "../../api/search-response"
5
6
  export type MediaType = {
6
7
  name: string
7
8
  icon: string
9
+ coverImageStyle: "default" | "book" | "video"
8
10
  }
9
11
 
10
12
  export type TranslatedLanguage = {
@@ -31,6 +33,7 @@ export default function SearchListItem({
31
33
  showLanguage,
32
34
  mediaTypes,
33
35
  }: Props) {
36
+ const coverImageStyle = mediaTypes[item.type].coverImageStyle
34
37
  return (
35
38
  <a
36
39
  href={detailsPagePath(currentLocale, {
@@ -40,15 +43,17 @@ export default function SearchListItem({
40
43
  className="group flex h-52 overflow-hidden py-2 transition-colors ease-in-out sm:h-64 md:rounded-sm md:hover:bg-gray-100"
41
44
  >
42
45
  <div className="flex h-full w-36 shrink-0 flex-col items-start justify-center">
43
- <img
44
- className="max-h-36 w-auto max-w-36 rounded-sm object-contain shadow-md"
45
- src={item.image.src}
46
- width={item.image.width}
47
- height={item.image.height}
48
- alt=""
49
- decoding="async"
50
- loading="eager"
51
- />
46
+ <CoverImageDecorator style={coverImageStyle}>
47
+ <img
48
+ className={`max-h-40 w-auto max-w-36 object-cover ${coverImageStyle === "video" ? "aspect-video" : ""}`}
49
+ src={item.image.src}
50
+ width={item.image.width}
51
+ height={item.image.height}
52
+ alt=""
53
+ decoding="async"
54
+ loading="eager"
55
+ />
56
+ </CoverImageDecorator>
52
57
  </div>
53
58
 
54
59
  <div className="ms-5 flex grow flex-col justify-center text-xs sm:ms-8">
@@ -1,124 +0,0 @@
1
- ---
2
- import type { ImageMetadata } from "astro"
3
- import { Image } from "astro:assets"
4
-
5
- import { getMediaTypes } from "../content/get-media-types"
6
- import { detailsPagePath } from "../utils/paths"
7
- import Icon from "./Icon"
8
-
9
- type MediaItem = {
10
- id: string
11
- data: {
12
- title: string
13
- type: { id: string }
14
- image: ImageMetadata
15
- }
16
- }
17
-
18
- const t = Astro.locals.i18n.t
19
-
20
- const types = Object.fromEntries(
21
- (await getMediaTypes()).map((type) => [
22
- type.id,
23
- { ...type.data, name: t(type.data.label) },
24
- ]),
25
- )
26
-
27
- interface Props {
28
- items: (MediaItem | undefined)[]
29
- layout: "book" | "video" | "portrait" | "landscape"
30
- }
31
-
32
- const { items: itemsInput, layout } = Astro.props
33
- // We allow users to pass undefined values because
34
- // this is in the return type of getEntry
35
- const items = itemsInput.filter((item) => !!item)
36
- ---
37
-
38
- {
39
- (layout === "book" || layout === "portrait") && (
40
- <ol class="grid grid-cols-2 items-end justify-between gap-x-7 gap-y-4 sm:grid-cols-3 md:grid-cols-4 md:gap-8 lg:grid-cols-5">
41
- {items.map((item) => (
42
- <li>
43
- <a
44
- href={detailsPagePath(Astro.currentLocale, item)}
45
- class="group flex flex-col gap-3"
46
- >
47
- <div
48
- class="relative overflow-hidden shadow-md outline-2 outline-gray-400 transition-all duration-75 ease-in-out sm:group-hover:outline"
49
- class:list={layout === "book" ? "rounded-sm" : "rounded-md"}
50
- >
51
- <Image
52
- class="h-full w-full object-contain"
53
- src={item.data.image}
54
- alt=""
55
- widths={[256, 512, 768, 1024]}
56
- sizes={
57
- "(max-width: 640px) calc(calc(100vw - 3.5rem ) / 2), " +
58
- "(max-width: 768px) calc(calc(100vw - 5rem ) / 3), " +
59
- "(max-width: 1024px) calc(calc(100vw - 10rem ) / 4), " +
60
- "(max-width: 1280px) calc(calc(100vw - 12rem ) / 5), " +
61
- "217px"
62
- }
63
- />
64
- {layout === "book" && (
65
- <span class="absolute start-[3px] top-0 h-full w-[4px] bg-gradient-to-r from-gray-500/20 to-transparent" />
66
- )}
67
- </div>
68
- <span class="line-clamp-2 h-12 text-balance text-sm font-bold text-gray-700">
69
- <Icon
70
- className={`${types[item.data.type.id].icon} me-2 align-bottom`}
71
- ariaLabel={types[item.data.type.id].name}
72
- />
73
- {item.data.title}
74
- </span>
75
- </a>
76
- </li>
77
- ))}
78
- </ol>
79
- )
80
- }
81
- {
82
- (layout === "video" || layout === "landscape") && (
83
- <ol
84
- class="grid grid-cols-1 justify-between gap-x-7 gap-y-4 sm:grid-cols-2 md:grid-cols-3 md:gap-8 lg:grid-cols-4"
85
- class:list={[layout === "landscape" && "items-end"]}
86
- >
87
- {items.map((item) => (
88
- <li>
89
- <a
90
- href={detailsPagePath(Astro.currentLocale, item)}
91
- class="group flex flex-col gap-3"
92
- >
93
- <div
94
- class="relative overflow-hidden rounded-md shadow-md outline-2 outline-gray-400 transition-all duration-75 ease-in-out sm:group-hover:outline"
95
- class:list={[layout === "video" && "aspect-video bg-gray-950"]}
96
- >
97
- <Image
98
- class="h-full w-full object-contain"
99
- class:list={[layout === "video" && "absolute top-0"]}
100
- src={item.data.image}
101
- alt=""
102
- widths={[120, 160, 240, 320, 640]}
103
- sizes={
104
- "(max-width: 640px) calc(calc(100vw - 2rem ) / 1), " +
105
- "(max-width: 768px) calc(calc(100vw - 3.5rem ) / 2), " +
106
- "(max-width: 1024px) calc(calc(100vw - 8.5rem ) / 3), " +
107
- "(max-width: 1280px) calc(calc(100vw - 10.5rem ) / 4), " +
108
- "280px"
109
- }
110
- />
111
- </div>
112
- <span class="line-clamp-2 h-12 text-balance text-sm font-bold text-gray-700">
113
- <Icon
114
- className={`${types[item.data.type.id].icon} me-2 align-bottom`}
115
- ariaLabel={types[item.data.type.id].name}
116
- />
117
- {item.data.title}
118
- </span>
119
- </a>
120
- </li>
121
- ))}
122
- </ol>
123
- )
124
- }