Skip to content

Commit f81620a

Browse files
committed
feat: modernize card view with 2025 design trends and smart metadata
- Refactor card layout with glassmorphism, improved shadows, and micro-interactions - Add structured content_meta support (content_type, difficulty, prerequisites, trending) - Implement trending overlay badge on image with i18n support - Add configurable metadata display (show_date, show_read_time, show_read_more) - Optimize for mobile with responsive metadata hiding and layout stacking - Enhance image handling with fill_image toggle and AVIF/WebP support - Unify article-grid and card views for consistency across theme - Add reading time calculation and improved accessibility (ARIA labels, focus states) - Support primary color category pills and author avatars with proper sizing
1 parent 1d2248b commit f81620a

File tree

9 files changed

+231
-194
lines changed

9 files changed

+231
-194
lines changed

modules/blox-tailwind/blox/collection/block.html

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,14 @@
103103

104104
<div class="flex flex-col items-center px-6">
105105

106-
{{ $config := dict "columns" ($block.design.columns | default 2) "len" (len $query) "fill_image" ($block.design.fill_image | default true) }}
106+
{{ $config := dict
107+
"columns" ($block.design.columns | default 2)
108+
"len" (len $query)
109+
"fill_image" ($block.design.fill_image | default true)
110+
"show_date" ($block.design.show_date | default true)
111+
"show_read_time" ($block.design.show_read_time | default true)
112+
"show_read_more" ($block.design.show_read_more | default true)
113+
}}
107114
{{ partial "functions/render_view" (dict "fragment" "start" "page" $block "item" . "view" $view "config" $config) }}
108115

109116
{{ range $index, $item := $query }}

modules/blox-tailwind/i18n/en.yaml

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -343,3 +343,20 @@
343343

344344
- id: feedback_widget_answer_negative
345345
translation: 😡 No
346+
347+
# AI Features
348+
349+
- id: ai_insight
350+
translation: AI Insight
351+
352+
# Content Metadata
353+
- id: content_type
354+
translation: Content Type
355+
- id: difficulty
356+
translation: Difficulty
357+
- id: prerequisites
358+
translation: Prerequisites
359+
360+
# Card metadata
361+
- id: trending
362+
translation: Trending
Lines changed: 1 addition & 133 deletions
Original file line numberDiff line numberDiff line change
@@ -1,133 +1 @@
1-
{{ $item := .item }}
2-
{{ $fill_image := .config.fill_image | default true }}
3-
4-
{{ $resource := partial "functions/get_featured_image.html" $item }}
5-
{{ $anchor := $item.Params.image.focal_point | default "Center" }}
6-
7-
{{ $link := $item.Params.external_link | default $item.RelPermalink }}
8-
{{ $target := "" }}
9-
{{ if $item.Params.external_link }}
10-
{{ $link = $item.Params.external_link }}
11-
{{ $target = "target=\"_blank\" rel=\"noopener\"" }}
12-
{{ end }}
13-
14-
<div class="group cursor-pointer">
15-
16-
{{ with $resource }}
17-
{{/* Skip GIF processing */}}
18-
{{ if ne .MediaType.SubType "gif" }}
19-
{{/* Process original image first, then create responsive variants */}}
20-
{{ $original_image := "" }}
21-
{{if $fill_image}}
22-
{{ $original_image = .Fill (printf "960x540 %s" $anchor) }}
23-
{{else}}
24-
{{ $original_image = .Fit (printf "960x540 %s" $anchor) }}
25-
{{end}}
26-
27-
{{ $responsive := partial "functions/process_responsive_image.html" (dict
28-
"image" $original_image
29-
"mode" "responsive"
30-
"sizes" (slice 480 768 960 1200 1920)
31-
) }}
32-
<div class="overflow-hidden rounded-md bg-gray-100 transition-all hover:scale-105 dark:bg-gray-800">
33-
<a
34-
class="relative block aspect-video"
35-
href="{{ $link }}" {{ $target | safeHTMLAttr }}>
36-
37-
<img alt="{{ $item.Title | plainify }}"
38-
class="{{if $fill_image}}object-fill{{else}}object-contain{{end}} transition-all"
39-
srcset="{{ $responsive.srcset }}"
40-
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
41-
src="{{ $responsive.fallback.RelPermalink }}"
42-
width="{{ $original_image.Width }}"
43-
height="{{ $original_image.Height }}"
44-
decoding="async"
45-
fetchpriority="high"
46-
loading="lazy"
47-
style="position: absolute; height: 100%; width: 100%; inset: 0px; color: transparent;" />
48-
</a>
49-
</div>
50-
{{ else }}
51-
{{/* Handle GIF without processing */}}
52-
<div class="overflow-hidden rounded-md bg-gray-100 transition-all hover:scale-105 dark:bg-gray-800">
53-
<a
54-
class="relative block aspect-video"
55-
href="{{ $link }}" {{ $target | safeHTMLAttr }}>
56-
57-
<img alt="{{ $item.Title | plainify }}"
58-
class="{{if $fill_image}}object-fill{{else}}object-contain{{end}} transition-all"
59-
src="{{ .RelPermalink }}"
60-
width="{{ .Width }}"
61-
height="{{ .Height }}"
62-
loading="lazy"
63-
style="position: absolute; height: 100%; width: 100%; inset: 0px; color: transparent;" />
64-
</a>
65-
</div>
66-
{{ end }}
67-
{{end}}
68-
<div class="">
69-
<div class="">
70-
<div class="flex gap-3">
71-
{{ if and $item.Params.tags (gt (len $item.Params.tags) 0) }}
72-
{{ range $index, $value := first 1 ($item.GetTerms "tags") }}
73-
<a href="{{.RelPermalink}}"><span
74-
class="inline-block text-xs font-medium tracking-wider uppercase mt-5 text-primary-700 dark:text-primary-300">{{ .Page.LinkTitle }}</span></a>
75-
{{end}}
76-
{{ end }}
77-
</div>
78-
<!-- <div class="relative line-clamp-2" style="display: block; height: 4em">-->
79-
<h2 class="text-lg font-semibold leading-snug tracking-tight mt-2 dark:text-white"><a
80-
href="{{ $link }}" {{ $target | safeHTMLAttr }}><span
81-
class="bg-gradient-to-r from-primary-200 to-primary-100 bg-[length:0px_10px] bg-left-bottom bg-no-repeat transition-[background-size] duration-500 hover:bg-[length:100%_3px] group-hover:bg-[length:100%_10px] dark:from-primary-800 dark:to-primary-900">
82-
{{- $item.Title -}}
83-
{{if $target}}{{ partial "functions/get_icon" (dict "name" "arrow-top-right-on-square" "attributes" "style=\"height: 1em;\" class=\"inline-flex h-6 w-6 pl-2\"") }}{{end}}
84-
</span></a>
85-
</h2>
86-
<!-- </div>-->
87-
<div class="grow"><p class="mt-2 line-clamp-3 text-sm text-gray-500 dark:text-gray-400"><a
88-
href="{{ $link }}" {{ $target | safeHTMLAttr }}>
89-
{{ ($item.Params.summary | default $item.Summary) | plainify | htmlUnescape | chomp -}}
90-
</a></p>
91-
</div>
92-
<div class="flex-none">
93-
<div class="mt-3 flex items-center space-x-3 text-gray-500 dark:text-gray-400 cursor-default">
94-
<!-- <a href="">-->
95-
{{ if $item.Params.authors }}
96-
<div class="flex items-center gap-3">
97-
{{ if and $item.Params.authors (gt (len $item.Params.authors) 0) }}
98-
{{ range $index, $value := first 1 ($item.GetTerms "authors") }}
99-
<div class="relative h-5 w-5 flex-shrink-0">
100-
{{ $avatar := (.Resources.ByType "image").GetMatch "*avatar*" }}
101-
{{ if $avatar }}
102-
{{/* Use a single 2x retina source for a 20px display */}}
103-
{{ $avatar_40 := $avatar.Process "Fill 40x40 Center webp" }}
104-
<img alt="avatar"
105-
class="rounded-full object-cover"
106-
src="{{$avatar_40.RelPermalink}}"
107-
width="20"
108-
height="20"
109-
loading="lazy"
110-
decoding="async"
111-
style="position: absolute; height: 100%; width: 100%; inset: 0px; color: transparent;" />
112-
{{ else }}
113-
{{ warnf "An image named `avatar` was not found in the `%s` folder" $value.Path }}
114-
{{ end }}
115-
</div>
116-
<span class="truncate text-sm">
117-
{{- .Page.LinkTitle -}}
118-
</span>
119-
</div>
120-
{{end}}
121-
{{ end }}
122-
<!-- </a>-->
123-
<span class="text-xs text-gray-300 dark:text-gray-600"></span>
124-
{{end}}
125-
<time class="truncate text-sm" datetime="{{ time.Format "2006-01-02" $item.Date }}">
126-
{{- $item.Date | time.Format (site.Params.locale.date_format | default ":date_long") -}}
127-
</time>
128-
</div>
129-
</div>
130-
131-
</div>
132-
</div>
133-
</div>
1+
{{- partial "views/card.html" . -}}
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
<div class="container max-w-[65ch] mx-auto bg-white dark:bg-zinc-900 rounded-xl border-gray-100 dark:border-gray-700 border shadow-md overflow-hidden my-5"><!--max-w-md md:max-w-2xl-->
1+
<div class="container max-w-[65ch] mx-auto grid grid-cols-1 gap-6 my-5">

modules/blox-tailwind/layouts/_partials/views/card.html

Lines changed: 180 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -9,36 +9,189 @@
99

1010
{{ $resource := partial "functions/get_featured_image.html" $item }}
1111
{{ $anchor := $item.Params.image.focal_point | default "Center" }}
12+
{{ $fill_image := .config.fill_image | default true }}
13+
{{ $showDate := .config.show_date | default true }}
14+
{{ $showReadTime := .config.show_read_time | default true }}
15+
{{ $showReadMore := .config.show_read_more | default true }}
16+
{{ $hasMeta := or $showDate $showReadTime $showReadMore }}
17+
{{ $index := .index }}
1218

13-
<a href="{{ $link }}" {{ $target | safeHTMLAttr }} class="mb-5">
14-
<div class="md:flex">
15-
<div class="md:flex-shrink-0 overflow-hidden">
19+
<div class="group bg-white/90 dark:bg-zinc-900/90 backdrop-blur-sm rounded-2xl ring-1 ring-zinc-900/5 dark:ring-white/10 shadow-lg overflow-hidden transition-all duration-300 ease-out hover:shadow-xl hover:shadow-blue-500/10 hover:-translate-y-2 focus-within:ring-2 focus-within:ring-blue-500/50" role="article" aria-labelledby="card-title-{{ $item.File.UniqueID }}">
20+
<!-- Image Section -->
21+
<div class="relative overflow-hidden aspect-[16/9] bg-gradient-to-br from-zinc-100 to-zinc-200 dark:from-zinc-800 dark:to-zinc-900">
22+
{{ with $item.Params.content_meta }}
23+
{{ if .trending }}
24+
<div class="absolute top-3 right-3 z-10 inline-flex items-center gap-1 px-2.5 py-1 rounded-full text-[11px] font-semibold bg-black/55 backdrop-blur-sm text-white shadow-sm">
25+
<span>{{ i18n "trending" | default "Trending" }}</span>
26+
<span aria-hidden="true">🔥</span>
27+
</div>
28+
{{ end }}
29+
{{ end }}
1630
{{ with $resource }}
17-
{{ $original_image := .Fill (printf "655x655 %s" $anchor) }}
18-
{{ $responsive := partial "functions/process_responsive_image.html" (dict
19-
"image" $original_image
20-
"mode" "responsive"
21-
"sizes" (slice 192 384 480 655)
22-
) }}
23-
24-
<img class="h-48 w-full object-cover md:w-48 hover:scale-125 transition duration-500 cursor-pointer object-cover"
25-
srcset="{{ $responsive.srcset }}"
26-
sizes="(max-width: 768px) 100vw, 192px"
27-
src="{{ $responsive.fallback.RelPermalink }}"
28-
width="{{ $original_image.Width }}"
29-
height="{{ $original_image.Height }}"
30-
loading="lazy"
31-
alt="{{ $item.Title | plainify }}">
31+
{{ if ne .MediaType.SubType "gif" }}
32+
{{ $original_image := "" }}
33+
{{ if $fill_image }}
34+
{{ $original_image = .Fill (printf "800x450 %s" $anchor) }}
35+
{{ else }}
36+
{{ $original_image = .Fit (printf "800x450 %s" $anchor) }}
37+
{{ end }}
38+
{{ $responsive := partial "functions/process_responsive_image.html" (dict
39+
"image" $original_image
40+
"mode" "responsive"
41+
"sizes" (slice 400 600 800)
42+
"formats" (slice "avif" "webp" "jpg")
43+
) }}
44+
<a href="{{ $link }}" {{ $target | safeHTMLAttr }} class="block">
45+
<img class="w-full h-full transition-transform duration-500 ease-out group-hover:scale-105 {{ if $fill_image }}object-fill{{ else }}object-contain{{ end }}"
46+
srcset="{{ $responsive.srcset }}"
47+
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
48+
src="{{ $responsive.fallback.RelPermalink }}"
49+
width="{{ $original_image.Width }}"
50+
height="{{ $original_image.Height }}"
51+
loading="lazy"
52+
decoding="async"
53+
{{ if eq $index 0 }}fetchpriority="high"{{ end }}
54+
style="position: absolute; height: 100%; width: 100%; inset: 0px; color: transparent;"
55+
alt="{{ $item.Title | plainify }} featured image">
56+
</a>
57+
{{ else }}
58+
<a href="{{ $link }}" {{ $target | safeHTMLAttr }} class="block">
59+
<img class="w-full h-full transition-transform duration-500 ease-out group-hover:scale-105 {{ if $fill_image }}object-fill{{ else }}object-contain{{ end }}"
60+
src="{{ .RelPermalink }}"
61+
width="{{ .Width }}"
62+
height="{{ .Height }}"
63+
loading="lazy"
64+
decoding="async"
65+
style="position: absolute; height: 100%; width: 100%; inset: 0px; color: transparent;"
66+
alt="{{ $item.Title | plainify }} featured image">
67+
</a>
68+
{{ end }}
69+
{{ else }}
70+
<!-- Fallback gradient with subtle pattern -->
71+
<div class="w-full h-full bg-gradient-to-br from-blue-500/20 to-purple-500/20 flex items-center justify-center">
72+
<div class="w-16 h-16 rounded-full bg-white/20 backdrop-blur-sm flex items-center justify-center">
73+
<svg class="w-8 h-8 text-white/60" fill="currentColor" viewBox="0 0 20 20">
74+
<path fill-rule="evenodd" d="M4 3a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V5a2 2 0 00-2-2H4zm12 12H4l4-8 3 6 2-4 3 6z" clip-rule="evenodd"/>
75+
</svg>
76+
</div>
77+
</div>
3278
{{end}}
79+
80+
<!-- Subtle overlay for better text contrast (allow clicks to pass through) -->
81+
<div class="absolute inset-0 pointer-events-none bg-gradient-to-t from-black/10 via-transparent to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300"></div>
3382
</div>
34-
<div class="p-8">
35-
<div class="uppercase tracking-wide text-md text-primary-700 dark:text-primary-200 font-semibold">{{ $item.Title }}</div>
36-
<p class="block mt-1 text-sm leading-tight font-medium text-black dark:text-white">
37-
{{ ($item.Params.summary | default $item.Summary) | plainify | htmlUnescape | chomp -}}
38-
</p>
39-
<p class="mt-2 text-gray-500 dark:text-gray-400 text-sm">
40-
{{- $item.Date | time.Format (site.Params.locale.date_format | default ":date_long") -}}
83+
84+
<!-- Content Section -->
85+
<div class="p-8 space-y-4">
86+
{{ with $item.Params.content_meta }}
87+
{{ if or .content_type .difficulty }}
88+
<div class="flex items-center gap-x-4">
89+
{{ with .content_type }}
90+
<span class="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-300">
91+
{{ . }}
92+
</span>
93+
{{ end }}
94+
{{ with .difficulty }}
95+
<span class="text-sm font-medium text-zinc-600 dark:text-zinc-400">{{ i18n "difficulty" }}: {{ . }}</span>
96+
{{ end }}
97+
</div>
98+
{{ end }}
99+
{{ end }}
100+
101+
{{ if and $item.Params.tags (gt (len $item.Params.tags) 0) }}
102+
<div class="flex items-center gap-2">
103+
{{ with index ($item.GetTerms "tags") 0 }}
104+
<a href="{{ .RelPermalink }}">
105+
<span class="inline-flex items-center px-3 py-1 rounded-full text-xs font-semibold bg-primary-100 text-primary-800 dark:bg-primary-900/40 dark:text-primary-300">
106+
{{ .Page.LinkTitle }}
107+
</span>
108+
</a>
109+
{{ end }}
110+
</div>
111+
{{ end }}
112+
113+
<h3 id="card-title-{{ $item.File.UniqueID }}" class="text-xl font-bold tracking-tight text-zinc-900 dark:text-zinc-100 group-hover:text-blue-600 dark:group-hover:text-blue-400 transition-colors duration-200 leading-tight">
114+
<a href="{{ $link }}" {{ $target | safeHTMLAttr }} class="hover:underline">
115+
{{ $item.Title }}
116+
{{ if $item.Params.external_link }}
117+
{{ partial "functions/get_icon" (dict "name" "arrow-top-right-on-square" "attributes" "style=\"height: 1em;\" class=\"inline-flex h-4 w-4 ml-1 align-text-top\"") }}
118+
{{ end }}
119+
</a>
120+
</h3>
121+
122+
<p class="text-zinc-600 dark:text-zinc-400 text-base leading-relaxed line-clamp-3">
123+
{{ ($item.Params.summary | default $item.Summary) | plainify | htmlUnescape | truncate 180 }}
41124
</p>
125+
126+
{{ with $item.Params.content_meta.prerequisites }}
127+
{{ $max := 2 }}
128+
{{ $count := len . }}
129+
<div class="flex items-center gap-2 pt-1 text-sm text-zinc-600 dark:text-zinc-400">
130+
<span class="opacity-80">{{ i18n "prerequisites" }}:</span>
131+
{{ range (first $max .) }}
132+
<span class="inline-flex items-center px-2 py-0.5 rounded-md text-[11px] font-medium bg-zinc-100 text-zinc-800 dark:bg-zinc-800 dark:text-zinc-300">{{ . }}</span>
133+
{{ end }}
134+
{{ if gt $count $max }}
135+
<span class="text-[11px] opacity-70">+{{ sub $count $max }}</span>
136+
{{ end }}
137+
</div>
138+
{{ end }}
139+
140+
<!-- Metadata section -->
141+
<div class="flex flex-col gap-2 sm:flex-row sm:items-center justify-between pt-3 {{ if $hasMeta }}border-t border-zinc-100 dark:border-zinc-800{{ end }}">
142+
<div class="flex items-center gap-3 text-xs text-zinc-500 dark:text-zinc-500 flex-wrap">
143+
{{ if $item.Params.authors }}
144+
<div class="flex items-center gap-2 min-w-0">
145+
{{ with index ($item.GetTerms "authors") 0 }}
146+
<div class="relative h-6 w-6 flex-shrink-0">
147+
{{ $avatar := (.Resources.ByType "image").GetMatch "*avatar*" }}
148+
{{ if $avatar }}
149+
{{ $avatar_48 := $avatar.Process "Fill 48x48 Center webp" }}
150+
<img alt="avatar" class="rounded-full object-cover" src="{{$avatar_48.RelPermalink}}" width="24" height="24" loading="lazy" decoding="async" />
151+
{{ end }}
152+
</div>
153+
<span class="truncate max-w-[9rem] text-sm">{{ .Page.LinkTitle }}</span>
154+
{{ end }}
155+
</div>
156+
<span class="opacity-40"></span>
157+
{{ end }}
158+
{{ if $showDate }}
159+
<time class="hidden sm:inline whitespace-nowrap" datetime="{{ $item.Date.Format "2006-01-02" }}">
160+
{{ $item.Date | time.Format (site.Params.locale.date_format | default "Jan 2, 2006") }}
161+
</time>
162+
{{ end }}
163+
{{ if and $showDate $showReadTime }}
164+
<span class="hidden sm:inline opacity-40"></span>
165+
{{ end }}
166+
{{ if $showReadTime }}
167+
{{ $content := $item.Content | plainify }}
168+
{{ $words := len (split $content " ") }}
169+
{{ $readTime := div $words 200 }}
170+
{{ if lt $readTime 1 }}{{ $readTime = 1 }}{{ end }}
171+
<span class="hidden sm:inline whitespace-nowrap">{{ $readTime }} {{ i18n "minute_read" }}</span>
172+
{{ end }}
173+
</div>
174+
175+
<!-- Read More with arrow - always visible for accessibility -->
176+
{{ if $showReadMore }}
177+
<a href="{{ $link }}" {{ $target | safeHTMLAttr }} class="flex items-center gap-2 text-blue-600 dark:text-blue-400 font-medium text-sm opacity-70 group-hover:opacity-100 transform group-hover:translate-x-1 transition-all duration-300 self-start sm:self-auto sm:ml-0">
178+
<span>{{ i18n "read_more" }}</span>
179+
<svg class="w-4 h-4 transition-transform group-hover:translate-x-1" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
180+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 8l4 4m0 0l-4 4m4-4H3"/>
181+
</svg>
182+
</a>
183+
{{ end }}
184+
</div>
185+
186+
<!-- Optional AI metadata -->
187+
{{ if $item.Params.ai_insights }}
188+
<div class="flex items-center gap-2 pt-2 text-xs text-blue-500 bg-blue-50 dark:bg-blue-950/30 rounded-lg px-3 py-2">
189+
<svg class="w-4 h-4 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
190+
<path d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>
191+
</svg>
192+
<span class="font-medium">{{ i18n "ai_insight" }}:</span>
193+
<span class="text-zinc-600 dark:text-zinc-400">{{ $item.Params.ai_insights }}</span>
194+
</div>
195+
{{ end }}
42196
</div>
43-
</div>
44-
</a>
197+
</div>

0 commit comments

Comments
 (0)