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
This commit is contained in:
George Cushen committed 2025-09-07 00:38:59 +01:00
1 parent 1d2248be50
commit f81620a20f
9 files changed
+231 -194

No files matched your search

@@ -103,7 +103,14 @@
<div class="flex flex-col items-center px-6">
{{ $config := dict "columns" ($block.design.columns | default 2) "len" (len $query) "fill_image" ($block.design.fill_image | default true) }}
{{ $config := dict
"columns" ($block.design.columns | default 2)
"len" (len $query)
"fill_image" ($block.design.fill_image | default true)
"show_date" ($block.design.show_date | default true)
"show_read_time" ($block.design.show_read_time | default true)
"show_read_more" ($block.design.show_read_more | default true)
}}
{{ partial "functions/render_view" (dict "fragment" "start" "page" $block "item" . "view" $view "config" $config) }}
{{ range $index, $item := $query }}
+17
View File
@@ -343,3 +343,20 @@
- id: feedback_widget_answer_negative
translation: 😡 No
# AI Features
- id: ai_insight
translation: AI Insight
# Content Metadata
- id: content_type
translation: Content Type
- id: difficulty
translation: Difficulty
- id: prerequisites
translation: Prerequisites
# Card metadata
- id: trending
translation: Trending
@@ -1,133 +1 @@
{{ $item := .item }}
{{ $fill_image := .config.fill_image | default true }}
{{ $resource := partial "functions/get_featured_image.html" $item }}
{{ $anchor := $item.Params.image.focal_point | default "Center" }}
{{ $link := $item.Params.external_link | default $item.RelPermalink }}
{{ $target := "" }}
{{ if $item.Params.external_link }}
{{ $link = $item.Params.external_link }}
{{ $target = "target=\"_blank\" rel=\"noopener\"" }}
{{ end }}
<div class="group cursor-pointer">
{{ with $resource }}
{{/* Skip GIF processing */}}
{{ if ne .MediaType.SubType "gif" }}
{{/* Process original image first, then create responsive variants */}}
{{ $original_image := "" }}
{{if $fill_image}}
{{ $original_image = .Fill (printf "960x540 %s" $anchor) }}
{{else}}
{{ $original_image = .Fit (printf "960x540 %s" $anchor) }}
{{end}}
{{ $responsive := partial "functions/process_responsive_image.html" (dict
"image" $original_image
"mode" "responsive"
"sizes" (slice 480 768 960 1200 1920)
) }}
<div class="overflow-hidden rounded-md bg-gray-100 transition-all hover:scale-105 dark:bg-gray-800">
<a
class="relative block aspect-video"
href="{{ $link }}" {{ $target | safeHTMLAttr }}>
<img alt="{{ $item.Title | plainify }}"
class="{{if $fill_image}}object-fill{{else}}object-contain{{end}} transition-all"
srcset="{{ $responsive.srcset }}"
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
src="{{ $responsive.fallback.RelPermalink }}"
width="{{ $original_image.Width }}"
height="{{ $original_image.Height }}"
decoding="async"
fetchpriority="high"
loading="lazy"
style="position: absolute; height: 100%; width: 100%; inset: 0px; color: transparent;" />
</a>
</div>
{{ else }}
{{/* Handle GIF without processing */}}
<div class="overflow-hidden rounded-md bg-gray-100 transition-all hover:scale-105 dark:bg-gray-800">
<a
class="relative block aspect-video"
href="{{ $link }}" {{ $target | safeHTMLAttr }}>
<img alt="{{ $item.Title | plainify }}"
class="{{if $fill_image}}object-fill{{else}}object-contain{{end}} transition-all"
src="{{ .RelPermalink }}"
width="{{ .Width }}"
height="{{ .Height }}"
loading="lazy"
style="position: absolute; height: 100%; width: 100%; inset: 0px; color: transparent;" />
</a>
</div>
{{ end }}
{{end}}
<div class="">
<div class="">
<div class="flex gap-3">
{{ if and $item.Params.tags (gt (len $item.Params.tags) 0) }}
{{ range $index, $value := first 1 ($item.GetTerms "tags") }}
<a href="{{.RelPermalink}}"><span
class="inline-block text-xs font-medium tracking-wider uppercase mt-5 text-primary-700 dark:text-primary-300">{{ .Page.LinkTitle }}</span></a>
{{end}}
{{ end }}
</div>
<!-- <div class="relative line-clamp-2" style="display: block; height: 4em">-->
<h2 class="text-lg font-semibold leading-snug tracking-tight mt-2 dark:text-white"><a
href="{{ $link }}" {{ $target | safeHTMLAttr }}><span
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">
{{- $item.Title -}}
{{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}}
</span></a>
</h2>
<!-- </div>-->
<div class="grow"><p class="mt-2 line-clamp-3 text-sm text-gray-500 dark:text-gray-400"><a
href="{{ $link }}" {{ $target | safeHTMLAttr }}>
{{ ($item.Params.summary | default $item.Summary) | plainify | htmlUnescape | chomp -}}
</a></p>
</div>
<div class="flex-none">
<div class="mt-3 flex items-center space-x-3 text-gray-500 dark:text-gray-400 cursor-default">
<!-- <a href="">-->
{{ if $item.Params.authors }}
<div class="flex items-center gap-3">
{{ if and $item.Params.authors (gt (len $item.Params.authors) 0) }}
{{ range $index, $value := first 1 ($item.GetTerms "authors") }}
<div class="relative h-5 w-5 flex-shrink-0">
{{ $avatar := (.Resources.ByType "image").GetMatch "*avatar*" }}
{{ if $avatar }}
{{/* Use a single 2x retina source for a 20px display */}}
{{ $avatar_40 := $avatar.Process "Fill 40x40 Center webp" }}
<img alt="avatar"
class="rounded-full object-cover"
src="{{$avatar_40.RelPermalink}}"
width="20"
height="20"
loading="lazy"
decoding="async"
style="position: absolute; height: 100%; width: 100%; inset: 0px; color: transparent;" />
{{ else }}
{{ warnf "An image named `avatar` was not found in the `%s` folder" $value.Path }}
{{ end }}
</div>
<span class="truncate text-sm">
{{- .Page.LinkTitle -}}
</span>
</div>
{{end}}
{{ end }}
<!-- </a>-->
<span class="text-xs text-gray-300 dark:text-gray-600">•</span>
{{end}}
<time class="truncate text-sm" datetime="{{ time.Format "2006-01-02" $item.Date }}">
{{- $item.Date | time.Format (site.Params.locale.date_format | default ":date_long") -}}
</time>
</div>
</div>
</div>
</div>
</div>
{{- partial "views/card.html" . -}}
@@ -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-->
<div class="container max-w-[65ch] mx-auto grid grid-cols-1 gap-6 my-5">
@@ -9,36 +9,189 @@
{{ $resource := partial "functions/get_featured_image.html" $item }}
{{ $anchor := $item.Params.image.focal_point | default "Center" }}
{{ $fill_image := .config.fill_image | default true }}
{{ $showDate := .config.show_date | default true }}
{{ $showReadTime := .config.show_read_time | default true }}
{{ $showReadMore := .config.show_read_more | default true }}
{{ $hasMeta := or $showDate $showReadTime $showReadMore }}
{{ $index := .index }}
<a href="{{ $link }}" {{ $target | safeHTMLAttr }} class="mb-5">
<div class="md:flex">
<div class="md:flex-shrink-0 overflow-hidden">
<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 }}">
<!-- Image Section -->
<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">
{{ with $item.Params.content_meta }}
{{ if .trending }}
<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">
<span>{{ i18n "trending" | default "Trending" }}</span>
<span aria-hidden="true">🔥</span>
</div>
{{ end }}
{{ end }}
{{ with $resource }}
{{ $original_image := .Fill (printf "655x655 %s" $anchor) }}
{{ $responsive := partial "functions/process_responsive_image.html" (dict
"image" $original_image
"mode" "responsive"
"sizes" (slice 192 384 480 655)
) }}
<img class="h-48 w-full object-cover md:w-48 hover:scale-125 transition duration-500 cursor-pointer object-cover"
srcset="{{ $responsive.srcset }}"
sizes="(max-width: 768px) 100vw, 192px"
src="{{ $responsive.fallback.RelPermalink }}"
width="{{ $original_image.Width }}"
height="{{ $original_image.Height }}"
loading="lazy"
alt="{{ $item.Title | plainify }}">
{{ if ne .MediaType.SubType "gif" }}
{{ $original_image := "" }}
{{ if $fill_image }}
{{ $original_image = .Fill (printf "800x450 %s" $anchor) }}
{{ else }}
{{ $original_image = .Fit (printf "800x450 %s" $anchor) }}
{{ end }}
{{ $responsive := partial "functions/process_responsive_image.html" (dict
"image" $original_image
"mode" "responsive"
"sizes" (slice 400 600 800)
"formats" (slice "avif" "webp" "jpg")
) }}
<a href="{{ $link }}" {{ $target | safeHTMLAttr }} class="block">
<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 }}"
srcset="{{ $responsive.srcset }}"
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
src="{{ $responsive.fallback.RelPermalink }}"
width="{{ $original_image.Width }}"
height="{{ $original_image.Height }}"
loading="lazy"
decoding="async"
{{ if eq $index 0 }}fetchpriority="high"{{ end }}
style="position: absolute; height: 100%; width: 100%; inset: 0px; color: transparent;"
alt="{{ $item.Title | plainify }} featured image">
</a>
{{ else }}
<a href="{{ $link }}" {{ $target | safeHTMLAttr }} class="block">
<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 }}"
src="{{ .RelPermalink }}"
width="{{ .Width }}"
height="{{ .Height }}"
loading="lazy"
decoding="async"
style="position: absolute; height: 100%; width: 100%; inset: 0px; color: transparent;"
alt="{{ $item.Title | plainify }} featured image">
</a>
{{ end }}
{{ else }}
<!-- Fallback gradient with subtle pattern -->
<div class="w-full h-full bg-gradient-to-br from-blue-500/20 to-purple-500/20 flex items-center justify-center">
<div class="w-16 h-16 rounded-full bg-white/20 backdrop-blur-sm flex items-center justify-center">
<svg class="w-8 h-8 text-white/60" fill="currentColor" viewBox="0 0 20 20">
<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"/>
</svg>
</div>
</div>
{{end}}
<!-- Subtle overlay for better text contrast (allow clicks to pass through) -->
<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>
</div>
<div class="p-8">
<div class="uppercase tracking-wide text-md text-primary-700 dark:text-primary-200 font-semibold">{{ $item.Title }}</div>
<p class="block mt-1 text-sm leading-tight font-medium text-black dark:text-white">
{{ ($item.Params.summary | default $item.Summary) | plainify | htmlUnescape | chomp -}}
</p>
<p class="mt-2 text-gray-500 dark:text-gray-400 text-sm">
{{- $item.Date | time.Format (site.Params.locale.date_format | default ":date_long") -}}
<!-- Content Section -->
<div class="p-8 space-y-4">
{{ with $item.Params.content_meta }}
{{ if or .content_type .difficulty }}
<div class="flex items-center gap-x-4">
{{ with .content_type }}
<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">
{{ . }}
</span>
{{ end }}
{{ with .difficulty }}
<span class="text-sm font-medium text-zinc-600 dark:text-zinc-400">{{ i18n "difficulty" }}: {{ . }}</span>
{{ end }}
</div>
{{ end }}
{{ end }}
{{ if and $item.Params.tags (gt (len $item.Params.tags) 0) }}
<div class="flex items-center gap-2">
{{ with index ($item.GetTerms "tags") 0 }}
<a href="{{ .RelPermalink }}">
<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">
{{ .Page.LinkTitle }}
</span>
</a>
{{ end }}
</div>
{{ end }}
<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">
<a href="{{ $link }}" {{ $target | safeHTMLAttr }} class="hover:underline">
{{ $item.Title }}
{{ if $item.Params.external_link }}
{{ 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\"") }}
{{ end }}
</a>
</h3>
<p class="text-zinc-600 dark:text-zinc-400 text-base leading-relaxed line-clamp-3">
{{ ($item.Params.summary | default $item.Summary) | plainify | htmlUnescape | truncate 180 }}
</p>
{{ with $item.Params.content_meta.prerequisites }}
{{ $max := 2 }}
{{ $count := len . }}
<div class="flex items-center gap-2 pt-1 text-sm text-zinc-600 dark:text-zinc-400">
<span class="opacity-80">{{ i18n "prerequisites" }}:</span>
{{ range (first $max .) }}
<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>
{{ end }}
{{ if gt $count $max }}
<span class="text-[11px] opacity-70">+{{ sub $count $max }}</span>
{{ end }}
</div>
{{ end }}
<!-- Metadata section -->
<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 }}">
<div class="flex items-center gap-3 text-xs text-zinc-500 dark:text-zinc-500 flex-wrap">
{{ if $item.Params.authors }}
<div class="flex items-center gap-2 min-w-0">
{{ with index ($item.GetTerms "authors") 0 }}
<div class="relative h-6 w-6 flex-shrink-0">
{{ $avatar := (.Resources.ByType "image").GetMatch "*avatar*" }}
{{ if $avatar }}
{{ $avatar_48 := $avatar.Process "Fill 48x48 Center webp" }}
<img alt="avatar" class="rounded-full object-cover" src="{{$avatar_48.RelPermalink}}" width="24" height="24" loading="lazy" decoding="async" />
{{ end }}
</div>
<span class="truncate max-w-[9rem] text-sm">{{ .Page.LinkTitle }}</span>
{{ end }}
</div>
<span class="opacity-40">•</span>
{{ end }}
{{ if $showDate }}
<time class="hidden sm:inline whitespace-nowrap" datetime="{{ $item.Date.Format "2006-01-02" }}">
{{ $item.Date | time.Format (site.Params.locale.date_format | default "Jan 2, 2006") }}
</time>
{{ end }}
{{ if and $showDate $showReadTime }}
<span class="hidden sm:inline opacity-40">•</span>
{{ end }}
{{ if $showReadTime }}
{{ $content := $item.Content | plainify }}
{{ $words := len (split $content " ") }}
{{ $readTime := div $words 200 }}
{{ if lt $readTime 1 }}{{ $readTime = 1 }}{{ end }}
<span class="hidden sm:inline whitespace-nowrap">{{ $readTime }} {{ i18n "minute_read" }}</span>
{{ end }}
</div>
<!-- Read More with arrow - always visible for accessibility -->
{{ if $showReadMore }}
<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">
<span>{{ i18n "read_more" }}</span>
<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">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 8l4 4m0 0l-4 4m4-4H3"/>
</svg>
</a>
{{ end }}
</div>
<!-- Optional AI metadata -->
{{ if $item.Params.ai_insights }}
<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">
<svg class="w-4 h-4 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
<path d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
<span class="font-medium">{{ i18n "ai_insight" }}:</span>
<span class="text-zinc-600 dark:text-zinc-400">{{ $item.Params.ai_insights }}</span>
</div>
{{ end }}
</div>
</div>
</a>
</div>
+12 -3
View File
@@ -10,6 +10,16 @@
{{ $view := .Params.view | default "card" }}
{{ $block := . }}
{{ $paginator := .Paginate .Pages.ByDate.Reverse }}
{{ $config := dict
"columns" (.Params.columns | default 2)
"len" (len $paginator.Pages)
"fill_image" (.Params.fill_image | default true)
"show_date" (.Params.show_date | default true)
"show_read_time" (.Params.show_read_time | default true)
"show_read_more" (.Params.show_read_more | default true)
}}
{{/* Check if any pages have author_notes to enable Alpine.js */}}
{{ range .Pages }}
{{ if isset .Params "author_notes" }}
@@ -21,11 +31,10 @@
<div class="flex flex-col items-center">
{{ partial "functions/render_view" (dict "fragment" "start" "page" $block "item" . "view" $view) }}
{{ partial "functions/render_view" (dict "fragment" "start" "page" $block "item" . "view" $view "config" $config) }}
{{ $paginator := .Paginate .Pages.ByDate.Reverse }}
{{ range $index, $item := $paginator.Pages }}
{{ partial "functions/render_view" (dict "page" . "item" . "view" $view "index" $index) }}
{{ partial "functions/render_view" (dict "page" . "item" . "view" $view "index" $index "config" $config) }}
{{end}}
{{ partial "functions/render_view" (dict "fragment" "end" "page" $block "item" . "view" $view) }}
+3 -29
View File
@@ -23,37 +23,12 @@ sections:
education: ''
interests: ''
design:
# Apply a gradient background
css_class: hbx-bg-gradient
# Avatar customization
avatar:
size: medium # Options: small (150px), medium (200px, default), large (320px), xl (400px), xxl (500px)
shape: circle # Options: circle (default), square, rounded
background:
#color: black
#image:
# RECOMMENDED: Modern gradient mesh backgrounds
# Option 1: Abstract neural network visualization (perfect for AI researchers)
# filename: https://images.unsplash.com/photo-1635070041078-e363dbe005cb
# Option 2: Dark gradient mesh (sophisticated and modern)
# filename: https://images.unsplash.com/photo-1557683316-973673baf926
# Option 3: Subtle tech pattern (professional)
# filename: https://images.unsplash.com/photo-1451187580459-43490279c0fa
# Current: Generic geometric pattern (consider upgrading)
#filename: stacked-peaks.svg
# For best 2025 results, consider:
# 1. Using a CSS gradient instead (remove image section)
# 2. Implementing particle.js for interactive background
# 3. Using a subtle video background with neural network visualization
#filters:
#brightness: 0.7 # Darkened for better text contrast
size: cover
position: center
parallax: false
- block: markdown
content:
title: '📚 My Research'
@@ -95,8 +70,7 @@ sections:
folders:
- event
design:
view: article-grid
columns: 1
view: card
- block: collection
id: news
content:
@@ -122,7 +96,7 @@ sections:
order: desc
design:
# Choose a layout view
view: date-title-summary
view: card
# Reduce spacing
spacing:
padding: [0, 0, 0, 0]
@@ -16,6 +16,12 @@ tags:
- Academic
- Hugo Blox
- Markdown
content_meta:
# content_type: 'Tutorial'
# difficulty: 'Beginner'
prerequisites: ['Markdown']
trending: true
---
Welcome 👋
+3
View File
@@ -20,4 +20,7 @@ sections:
view: article-grid
fill_image: false
columns: 3
show_date: false
show_read_time: false
show_read_more: false
---