feat: add dev portfolio template and add dev-hero, portfolio, tech-stack blocks

Also, fix card view to use theme colors rather than hard-coded colors
This commit is contained in:
George Cushen committed 2026-01-05 20:38:38 +00:00
1 parent 30dd554e8d
commit 3526fb1ca1
68 files changed
+6664 -152

No files matched your search

+256
View File
@@ -0,0 +1,256 @@
/**
* Hugo Blox Animations
* Scroll-triggered reveals and micro-interactions
*/
/* Typewriter cursor */
.typewriter-cursor {
display: inline-block;
width: 3px;
margin-left: 2px;
background-color: currentColor;
animation: blink 1s step-end infinite;
}
@keyframes blink {
50% {
opacity: 0;
}
}
/* Reveal animations base */
[x-reveal] {
opacity: 0;
}
/* Stagger container */
[data-stagger] > [data-stagger-item] {
opacity: 0;
transform: translateY(20px);
transition:
opacity 0.5s ease-out,
transform 0.5s ease-out;
}
[data-stagger].revealed > [data-stagger-item] {
opacity: 1;
transform: translateY(0);
}
/* Hover enhancements for cards */
.card-hover-lift {
transition:
transform 0.3s ease-out,
box-shadow 0.3s ease-out;
}
.card-hover-lift:hover {
transform: translateY(-4px);
box-shadow: 0 12px 40px -12px rgba(0, 0, 0, 0.3);
}
/* Hover glow effect */
.hover-glow {
position: relative;
transition: all 0.3s ease-out;
}
.hover-glow::before {
content: "";
position: absolute;
inset: -2px;
border-radius: inherit;
background: linear-gradient(135deg, var(--color-primary-500), var(--color-secondary-500));
opacity: 0;
z-index: -1;
transition: opacity 0.3s ease-out;
filter: blur(8px);
}
.hover-glow:hover::before {
opacity: 0.5;
}
/* Icon bounce on hover */
.icon-bounce:hover {
animation: iconBounce 0.5s ease-out;
}
@keyframes iconBounce {
0%,
100% {
transform: translateY(0);
}
50% {
transform: translateY(-4px);
}
}
/* Scale up on hover */
.scale-hover {
transition: transform 0.2s ease-out;
}
.scale-hover:hover {
transform: scale(1.05);
}
/* Button pulse */
.btn-pulse {
position: relative;
}
.btn-pulse::after {
content: "";
position: absolute;
inset: 0;
border-radius: inherit;
box-shadow: 0 0 0 0 currentColor;
opacity: 0.4;
animation: pulse-ring 2s infinite;
}
@keyframes pulse-ring {
0% {
box-shadow: 0 0 0 0 currentColor;
opacity: 0.4;
}
100% {
box-shadow: 0 0 0 12px currentColor;
opacity: 0;
}
}
/* Float animation for background orbs */
@keyframes float {
0%,
100% {
transform: translateY(0) translateX(0);
}
25% {
transform: translateY(-20px) translateX(10px);
}
50% {
transform: translateY(-10px) translateX(-10px);
}
75% {
transform: translateY(-30px) translateX(5px);
}
}
.animate-float {
animation: float 8s ease-in-out infinite;
}
/* Slow rotate for decorative elements */
@keyframes rotate-slow {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.animate-rotate-slow {
animation: rotate-slow 30s linear infinite;
}
/* Fade in up - for scroll reveals */
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(30px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.animate-fade-in-up {
animation: fadeInUp 0.6s ease-out forwards;
}
/* Scale in - for icons/badges */
@keyframes scaleIn {
from {
opacity: 0;
transform: scale(0.8);
}
to {
opacity: 1;
transform: scale(1);
}
}
.animate-scale-in {
animation: scaleIn 0.4s ease-out forwards;
}
/* Slide in from left */
@keyframes slideInLeft {
from {
opacity: 0;
transform: translateX(-30px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
.animate-slide-in-left {
animation: slideInLeft 0.5s ease-out forwards;
}
/* Respect reduced motion */
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms;
animation-iteration-count: 1;
transition-duration: 0.01ms;
}
.typewriter-cursor {
animation: none;
opacity: 1;
}
[x-reveal],
[data-stagger] > [data-stagger-item] {
opacity: 1;
transform: none;
}
}
/* Dark mode adjustments */
.dark .hover-glow::before {
opacity: 0;
}
.dark .hover-glow:hover::before {
opacity: 0.3;
}
/* Dark mode icon visibility fix */
/* For icons that are dark/black and invisible on dark backgrounds */
.dark .dark-mode-icon svg,
.dark .dark-mode-icon img {
filter: invert(1) brightness(2);
}
/* Alternative: Add subtle background to icon containers */
.dark .dark-mode-icon {
background: rgba(255, 255, 255, 0.08);
border-radius: 0.5rem;
padding: 0.25rem;
}
/* Hover state restores some contrast */
.dark .group:hover .dark-mode-icon svg,
.dark .group:hover .dark-mode-icon img {
filter: invert(1) brightness(1.5);
}
+3
View File
@@ -41,5 +41,8 @@
/* Hugo Blox Search */
@import "hb-search.css";
/* Hugo Blox Animations (typewriter, scroll reveals) */
@import "animations.css";
/* Safelist for dynamic classes */
@import "config/safelist.css";
+111
View File
@@ -0,0 +1,111 @@
/**
* Hugo Blox Animations
* Typewriter effect and scroll-triggered animations using Alpine.js
*/
document.addEventListener("alpine:init", () => {
// Typewriter effect component
Alpine.data("typewriter", (strings = [], options = {}) => ({
strings: strings,
currentIndex: 0,
currentText: "",
isDeleting: false,
typeSpeed: options.typeSpeed || 80,
deleteSpeed: options.deleteSpeed || 50,
pauseTime: options.pauseTime || 2000,
loop: options.loop !== false,
showCursor: options.cursor !== false,
init() {
if (this.strings.length === 0) return;
this.type();
},
type() {
const current = this.strings[this.currentIndex];
if (this.isDeleting) {
this.currentText = current.substring(0, this.currentText.length - 1);
} else {
this.currentText = current.substring(0, this.currentText.length + 1);
}
let delay = this.isDeleting ? this.deleteSpeed : this.typeSpeed;
if (!this.isDeleting && this.currentText === current) {
// Finished typing, pause then delete
delay = this.pauseTime;
this.isDeleting = true;
} else if (this.isDeleting && this.currentText === "") {
// Finished deleting, move to next string
this.isDeleting = false;
this.currentIndex = (this.currentIndex + 1) % this.strings.length;
if (!this.loop && this.currentIndex === 0) {
// Don't loop, show first string permanently
this.currentText = this.strings[0];
return;
}
delay = 500; // Pause before typing next
}
setTimeout(() => this.type(), delay);
},
}));
// Scroll reveal directive
Alpine.directive("reveal", (el, {expression, modifiers}, {evaluate}) => {
const options = expression ? evaluate(expression) : {};
const delay = options.delay || 0;
const duration = options.duration || 600;
const distance = options.distance || "20px";
const direction = modifiers.includes("left") ? "left" : modifiers.includes("right") ? "right" : modifiers.includes("down") ? "down" : "up";
// Set initial state
el.style.opacity = "0";
el.style.transition = `opacity ${duration}ms ease-out, transform ${duration}ms ease-out`;
el.style.transitionDelay = `${delay}ms`;
const transforms = {
up: `translateY(${distance})`,
down: `translateY(-${distance})`,
left: `translateX(${distance})`,
right: `translateX(-${distance})`,
};
el.style.transform = transforms[direction];
// Observe intersection
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
el.style.opacity = "1";
el.style.transform = "translate(0, 0)";
observer.unobserve(el);
}
});
},
{threshold: 0.1, rootMargin: "0px 0px -50px 0px"},
);
observer.observe(el);
});
});
// Staggered reveal for groups of elements
document.addEventListener("DOMContentLoaded", () => {
// Add stagger delays to elements with data-stagger attribute
document.querySelectorAll("[data-stagger]").forEach((container) => {
const children = container.querySelectorAll("[data-stagger-item]");
const baseDelay = parseInt(container.dataset.stagger, 10) || 100;
children.forEach((child, index) => {
child.style.transitionDelay = `${index * baseDelay}ms`;
});
});
});
// Respect reduced motion preference
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
document.documentElement.classList.add("reduce-motion");
}
+189 -53
View File
@@ -6,9 +6,37 @@
{{ $page := .wcPage }}
{{ $block := .wcBlock }}
{{/* Enable Alpine.js for copy-to-clipboard functionality */}}
{{ $page.Store.Set "has_alpine" true }}
{{ $title := $block.content.title | default (i18n "block_contact_title" | default "Contact Us") | emojify | $page.RenderString }}
{{ $subtitle := $block.content.subtitle | emojify | $page.RenderString }}
{{/* Check if Visit Us card has content */}}
{{ $has_visit_content := or $block.content.address $block.content.office_hours $block.content.map_url }}
{{/* Check if Connect card has content - fallback to author profile if no email/social */}}
{{ $author := $block.content.username | default "me" }}
{{ $profile := partial "functions/get_author_profile" $author }}
{{ $email := $block.content.email | default $profile.email }}
{{ $phone := $block.content.phone }}
{{ $social := $block.content.social | default $profile.links }}
{{ $has_connect_content := or $email $phone $social $block.content.prospective }}
{{/* Determine grid layout - single column centered if only one card */}}
{{ $grid_class := "grid-cols-1 lg:grid-cols-2" }}
{{ $card_max_width := "" }}
{{ $is_single_card := false }}
{{ if and $has_visit_content (not $has_connect_content) }}
{{ $grid_class = "grid-cols-1" }}
{{ $card_max_width = "max-w-3xl mx-auto" }}
{{ $is_single_card = true }}
{{ else if and $has_connect_content (not $has_visit_content) }}
{{ $grid_class = "grid-cols-1" }}
{{ $card_max_width = "max-w-3xl mx-auto" }}
{{ $is_single_card = true }}
{{ end }}
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12 sm:py-16 lg:py-20">
{{/* Section Header */}}
@@ -27,12 +55,13 @@
</div>
{{/* Contact Cards Grid */}}
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8 lg:gap-12">
<div class="grid {{ $grid_class }} gap-8 lg:gap-12">
{{/* Visit Us Card */}}
<div class="bg-white dark:bg-gray-800 rounded-2xl shadow-xl p-8 hover:shadow-2xl transition-shadow duration-300">
{{/* Visit Us Card - Only show if has content */}}
{{ if $has_visit_content }}
<div class="{{ $card_max_width }} bg-white dark:bg-white/[0.03] backdrop-blur-xl rounded-2xl border border-gray-200 dark:border-white/[0.08] p-8 hover:bg-gray-50 dark:hover:bg-white/[0.06] hover:border-primary-300 dark:hover:border-primary-500/30 transition-all duration-300">
<div class="flex items-center mb-6">
<div class="flex-shrink-0 w-12 h-12 bg-primary-100 dark:bg-primary-900/50 rounded-full flex items-center justify-center">
<div class="flex-shrink-0 w-12 h-12 bg-primary-100 dark:bg-primary-500/10 rounded-full flex items-center justify-center border border-primary-200 dark:border-primary-500/20">
{{ partial "functions/get_icon" (dict "name" "hero/map-pin" "attributes" "class=\"w-6 h-6 text-primary-600 dark:text-primary-400\"") }}
</div>
<h3 class="ml-4 text-2xl font-bold text-gray-900 dark:text-white">
@@ -50,7 +79,7 @@
{{ end }}
{{ with $block.content.office_hours }}
<div class="pt-4 border-t border-gray-200 dark:border-gray-700">
<div class="pt-4 border-t border-gray-200 dark:border-white/10">
<h4 class="font-semibold text-gray-900 dark:text-white mb-2">{{ i18n "block_contact_office_hours" | default "Office Hours" }}</h4>
<div class="text-gray-600 dark:text-gray-300 space-y-1">
{{ range . }}
@@ -62,18 +91,116 @@
{{ with $block.content.map_url }}
<a href="{{ . }}" target="_blank" rel="noopener"
class="inline-flex items-center mt-4 px-4 py-2 bg-primary-50 dark:bg-primary-900/30 text-primary-700 dark:text-primary-300 rounded-lg hover:bg-primary-100 dark:hover:bg-primary-900/50 transition-colors">
class="inline-flex items-center mt-4 px-4 py-2 bg-primary-100 dark:bg-primary-500/10 text-primary-700 dark:text-primary-400 rounded-lg border border-primary-200 dark:border-primary-500/20 hover:bg-primary-200 dark:hover:bg-primary-500/20 hover:border-primary-300 dark:hover:border-primary-500/40 transition-all">
{{ partial "functions/get_icon" (dict "name" "hero/map" "attributes" "class=\"w-5 h-5 mr-2\"") }}
{{ i18n "block_contact_view_on_map" | default "View on Map" }}
</a>
{{ end }}
</div>
</div>
{{ end }}
{{/* Connect Card */}}
<div class="bg-white dark:bg-gray-800 rounded-2xl shadow-xl p-8 hover:shadow-2xl transition-shadow duration-300">
{{/* Connect Card - Only show if has content */}}
{{ if $has_connect_content }}
<div class="{{ $card_max_width }} bg-white dark:bg-white/[0.03] backdrop-blur-xl rounded-2xl border border-gray-200 dark:border-white/[0.08] p-8 lg:p-10 hover:bg-gray-50 dark:hover:bg-white/[0.06] hover:border-primary-300 dark:hover:border-primary-500/30 transition-all duration-300">
{{/* Filter social links - remove mailto if email is shown separately */}}
{{ $filtered_social := slice }}
{{ range $social }}
{{ if not (hasPrefix .url "mailto:") }}
{{ $filtered_social = $filtered_social | append . }}
{{ end }}
{{ end }}
{{ if $is_single_card }}
{{/* Single card layout - horizontal on desktop */}}
<div class="grid grid-cols-1 md:grid-cols-2 gap-8 lg:gap-12" x-data="{ copied: false }">
{{/* Left: Email & CTA */}}
<div class="space-y-6">
<div>
<h3 class="text-2xl font-bold text-gray-900 dark:text-white mb-2">
{{ $block.content.connect_title | default (i18n "block_contact_connect_title" | default "Let's Connect") }}
</h3>
<p class="text-gray-600 dark:text-gray-400">
{{ $block.content.text | default "I'm always open to discussing new projects, opportunities, or just having a chat." }}
</p>
</div>
{{ with $email }}
{{/* Email with click-to-copy */}}
<div class="relative">
<button
@click="navigator.clipboard.writeText('{{ . }}'); copied = true; setTimeout(() => copied = false, 2000)"
class="group flex items-center gap-3 text-left"
>
<div class="w-12 h-12 flex items-center justify-center rounded-xl bg-primary-100 dark:bg-primary-500/10 border border-primary-200 dark:border-primary-500/20 group-hover:bg-primary-200 dark:group-hover:bg-primary-500/20 group-hover:border-primary-300 dark:group-hover:border-primary-500/40 transition-all">
{{ partial "functions/get_icon" (dict "name" "hero/envelope" "attributes" "class=\"w-6 h-6 text-primary-600 dark:text-primary-400\"") }}
</div>
<div>
<p class="text-sm text-gray-500 dark:text-gray-400 group-hover:text-gray-700 dark:group-hover:text-gray-300 transition-colors">Click to copy</p>
<p class="text-lg font-medium text-gray-900 dark:text-white group-hover:text-primary-600 dark:group-hover:text-primary-400 transition-colors">{{ . }}</p>
</div>
</button>
{{/* Toast notification */}}
<div
x-show="copied"
x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="opacity-0 translate-y-1"
x-transition:enter-end="opacity-100 translate-y-0"
x-transition:leave="transition ease-in duration-150"
x-transition:leave-start="opacity-100 translate-y-0"
x-transition:leave-end="opacity-0 translate-y-1"
class="absolute left-0 -bottom-10 px-3 py-1.5 bg-emerald-500/90 text-white text-sm font-medium rounded-lg shadow-lg"
>
✓ Copied to clipboard!
</div>
</div>
{{/* Primary CTA Button */}}
<a href="mailto:{{ . }}"
class="inline-flex items-center px-6 py-3 bg-primary-600 hover:bg-primary-500 text-white font-medium rounded-xl shadow-lg shadow-primary-500/25 hover:shadow-xl hover:shadow-primary-500/30 transition-all">
{{ partial "functions/get_icon" (dict "name" "hero/paper-airplane" "attributes" "class=\"w-5 h-5 mr-2\"") }}
Send a message
</a>
{{ end }}
{{ with $phone }}
<a href="tel:{{ . }}" class="flex items-center group">
<div class="w-12 h-12 flex items-center justify-center rounded-xl bg-primary-100 dark:bg-primary-500/10 border border-primary-200 dark:border-primary-500/20 group-hover:bg-primary-200 dark:group-hover:bg-primary-500/20 group-hover:border-primary-300 dark:group-hover:border-primary-500/40 transition-all mr-3">
{{ partial "functions/get_icon" (dict "name" "hero/phone" "attributes" "class=\"w-6 h-6 text-primary-600 dark:text-primary-400\"") }}
</div>
<div>
<p class="text-sm text-gray-500 dark:text-gray-400">Call me</p>
<p class="text-lg font-medium text-gray-900 dark:text-white group-hover:text-primary-600 dark:group-hover:text-primary-400 transition-colors">{{ . }}</p>
</div>
</a>
{{ end }}
</div>
{{/* Right: Social Links */}}
{{ with $filtered_social }}
<div class="flex flex-col justify-center items-center md:items-start">
<h4 class="text-sm font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400 mb-5">{{ i18n "block_contact_follow_me" | default "Find me on" }}</h4>
<div class="flex flex-wrap gap-4">
{{ range . }}
<a href="{{ .url }}" target="_blank" rel="noopener"
class="group flex items-center justify-center w-14 h-14 rounded-xl bg-gray-100 dark:bg-white/5 border border-gray-200 dark:border-white/10 hover:bg-primary-100 dark:hover:bg-primary-500/20 hover:border-primary-300 dark:hover:border-primary-500/40 hover:scale-110 transition-all duration-300"
{{ with .label }}title="{{ . }}"{{ end }}>
<div class="text-gray-600 dark:text-gray-300 group-hover:text-primary-600 dark:group-hover:text-primary-400 transition-colors">
{{ partial "functions/get_icon" (dict "name" .icon "attributes" "class=\"w-7 h-7\"") }}
</div>
</a>
{{ end }}
</div>
</div>
{{ end }}
</div>
{{ else }}
{{/* Standard card layout (when shown alongside Visit Us) */}}
<div class="flex items-center mb-6">
<div class="flex-shrink-0 w-12 h-12 bg-primary-100 dark:bg-primary-900/50 rounded-full flex items-center justify-center">
<div class="flex-shrink-0 w-12 h-12 bg-primary-100 dark:bg-primary-500/10 rounded-full flex items-center justify-center border border-primary-200 dark:border-primary-500/20">
{{ partial "functions/get_icon" (dict "name" "hero/chat-bubble-left-right" "attributes" "class=\"w-6 h-6 text-primary-600 dark:text-primary-400\"") }}
</div>
<h3 class="ml-4 text-2xl font-bold text-gray-900 dark:text-white">
@@ -84,32 +211,39 @@
<div class="space-y-4">
{{/* Contact Methods */}}
<div class="space-y-3">
{{ with $block.content.email }}
<div class="flex items-center">
{{ partial "functions/get_icon" (dict "name" "hero/envelope" "attributes" "class=\"w-5 h-5 text-gray-500 dark:text-gray-400 mr-3\"") }}
<a href="mailto:{{ . }}" class="text-gray-700 dark:text-gray-300 hover:text-primary-600 dark:hover:text-primary-400 transition-colors">
{{ . }}
</a>
</div>
{{ with $email }}
<a href="mailto:{{ . }}" class="flex items-center group p-3 -mx-3 rounded-lg hover:bg-gray-100 dark:hover:bg-white/5 transition-colors">
<div class="w-10 h-10 flex items-center justify-center rounded-full bg-primary-100 dark:bg-primary-500/10 border border-primary-200 dark:border-primary-500/20 mr-4">
{{ partial "functions/get_icon" (dict "name" "hero/envelope" "attributes" "class=\"w-5 h-5 text-primary-600 dark:text-primary-400\"") }}
</div>
<div>
<p class="text-sm text-gray-500 dark:text-gray-400">Email</p>
<p class="text-gray-900 dark:text-white group-hover:text-primary-600 dark:group-hover:text-primary-400 transition-colors">{{ . }}</p>
</div>
</a>
{{ end }}
{{ with $block.content.phone }}
<div class="flex items-center">
{{ partial "functions/get_icon" (dict "name" "hero/phone" "attributes" "class=\"w-5 h-5 text-gray-500 dark:text-gray-400 mr-3\"") }}
<a href="tel:{{ . }}" class="text-gray-700 dark:text-gray-300 hover:text-primary-600 dark:hover:text-primary-400 transition-colors">
{{ . }}
</a>
</div>
{{ with $phone }}
<a href="tel:{{ . }}" class="flex items-center group p-3 -mx-3 rounded-lg hover:bg-gray-100 dark:hover:bg-white/5 transition-colors">
<div class="w-10 h-10 flex items-center justify-center rounded-full bg-primary-100 dark:bg-primary-500/10 border border-primary-200 dark:border-primary-500/20 mr-4">
{{ partial "functions/get_icon" (dict "name" "hero/phone" "attributes" "class=\"w-5 h-5 text-primary-600 dark:text-primary-400\"") }}
</div>
<div>
<p class="text-sm text-gray-500 dark:text-gray-400">Phone</p>
<p class="text-gray-900 dark:text-white group-hover:text-primary-600 dark:group-hover:text-primary-400 transition-colors">{{ . }}</p>
</div>
</a>
{{ end }}
{{/* Social Media */}}
{{ with $block.content.social }}
<div class="pt-4 border-t border-gray-200 dark:border-gray-700">
<h4 class="font-semibold text-gray-900 dark:text-white mb-3">{{ i18n "block_contact_follow_us" | default "Follow Us" }}</h4>
<div class="flex gap-3">
{{ with $filtered_social }}
<div class="pt-4 border-t border-gray-200 dark:border-white/10">
<h4 class="font-semibold text-gray-900 dark:text-white mb-4">{{ i18n "block_contact_follow_me" | default "Find me on" }}</h4>
<div class="flex flex-wrap gap-3">
{{ range . }}
<a href="{{ .url }}" target="_blank" rel="noopener"
class="w-10 h-10 flex items-center justify-center rounded-full bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400 hover:bg-primary-100 dark:hover:bg-primary-900/50 hover:text-primary-600 dark:hover:text-primary-400 transition-all">
class="w-11 h-11 flex items-center justify-center rounded-full bg-gray-100 dark:bg-white/5 border border-gray-200 dark:border-white/10 text-gray-600 dark:text-gray-400 hover:bg-primary-100 dark:hover:bg-primary-500/20 hover:border-primary-300 dark:hover:border-primary-500/40 hover:text-primary-600 dark:hover:text-primary-400 transition-all duration-300"
{{ with .label }}title="{{ . }}"{{ end }}>
{{ partial "functions/get_icon" (dict "name" .icon "attributes" "class=\"w-5 h-5\"") }}
</a>
{{ end }}
@@ -117,28 +251,30 @@
</div>
{{ end }}
</div>
</div>
{{ end }}
{{/* Prospective Members */}}
{{ with $block.content.prospective }}
<div class="pt-4 border-t border-gray-200 dark:border-gray-700">
<h4 class="font-semibold text-gray-900 dark:text-white mb-2">{{ .title | default (i18n "block_contact_prospective_title" | default "Prospective Members") }}</h4>
<p class="text-gray-600 dark:text-gray-300 mb-4">{{ .text }}</p>
{{ with .button }}
<a href="{{ .url }}"
class="inline-flex items-center px-4 py-2 bg-primary-600 hover:bg-primary-700 text-white rounded-lg shadow hover:shadow-lg transition-all">
{{ partial "functions/get_icon" (dict "name" "hero/user-plus" "attributes" "class=\"w-5 h-5 mr-2\"") }}
{{ .text }}
</a>
{{ end }}
</div>
{{/* Prospective Members - shown in both layouts */}}
{{ with $block.content.prospective }}
<div class="mt-6 pt-6 border-t border-gray-200 dark:border-white/10">
<h4 class="font-semibold text-gray-900 dark:text-white mb-2">{{ .title | default (i18n "block_contact_prospective_title" | default "Prospective Members") }}</h4>
<p class="text-gray-600 dark:text-gray-300 mb-4">{{ .text }}</p>
{{ with .button }}
<a href="{{ .url }}"
class="inline-flex items-center px-4 py-2 bg-primary-600 hover:bg-primary-500 text-white rounded-lg shadow-lg shadow-primary-500/25 hover:shadow-xl hover:shadow-primary-500/30 transition-all">
{{ partial "functions/get_icon" (dict "name" "hero/user-plus" "attributes" "class=\"w-5 h-5 mr-2\"") }}
{{ .text }}
</a>
{{ end }}
</div>
{{ end }}
</div>
{{ end }}
</div>
{{/* Optional Map Embed */}}
{{ with $block.content.map_embed }}
<div class="mt-12 rounded-2xl overflow-hidden shadow-xl">
<div class="mt-12 rounded-2xl overflow-hidden border border-gray-200 dark:border-white/[0.08]">
<div class="aspect-w-16 aspect-h-9 lg:aspect-h-6">
{{ . | safeHTML }}
</div>
@@ -148,33 +284,33 @@
{{/* Contact Form (optional) */}}
{{ if $block.content.show_form }}
<div class="mt-12 max-w-2xl mx-auto">
<div class="bg-white dark:bg-gray-800 rounded-2xl shadow-xl p-8">
<div class="bg-white dark:bg-white/[0.03] backdrop-blur-xl rounded-2xl border border-gray-200 dark:border-white/[0.08] p-8">
<h3 class="text-2xl font-bold text-gray-900 dark:text-white mb-6">{{ i18n "block_contact_form_title" | default "Send us a Message" }}</h3>
<form action="{{ $block.content.form_action | default "#" }}" method="POST" class="space-y-6">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-6">
<div>
<label for="name" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">{{ i18n "block_contact_form_name" | default "Name" }}</label>
<label for="name" class="block text-sm font-medium text-gray-600 dark:text-gray-400 mb-2">{{ i18n "block_contact_form_name" | default "Name" }}</label>
<input type="text" id="name" name="name" required
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent dark:bg-gray-700 dark:text-white">
class="w-full px-4 py-3 bg-gray-50 dark:bg-white/5 border border-gray-200 dark:border-white/10 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500/50 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 transition-colors">
</div>
<div>
<label for="email" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">{{ i18n "block_contact_form_email" | default "Email" }}</label>
<input type="email" id="email" name="email" required
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent dark:bg-gray-700 dark:text-white">
<label for="form-email" class="block text-sm font-medium text-gray-600 dark:text-gray-400 mb-2">{{ i18n "block_contact_form_email" | default "Email" }}</label>
<input type="email" id="form-email" name="email" required
class="w-full px-4 py-3 bg-gray-50 dark:bg-white/5 border border-gray-200 dark:border-white/10 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500/50 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 transition-colors">
</div>
</div>
<div>
<label for="subject" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">{{ i18n "block_contact_form_subject" | default "Subject" }}</label>
<label for="subject" class="block text-sm font-medium text-gray-600 dark:text-gray-400 mb-2">{{ i18n "block_contact_form_subject" | default "Subject" }}</label>
<input type="text" id="subject" name="subject" required
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent dark:bg-gray-700 dark:text-white">
class="w-full px-4 py-3 bg-gray-50 dark:bg-white/5 border border-gray-200 dark:border-white/10 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500/50 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 transition-colors">
</div>
<div>
<label for="message" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">{{ i18n "block_contact_form_message" | default "Message" }}</label>
<label for="message" class="block text-sm font-medium text-gray-600 dark:text-gray-400 mb-2">{{ i18n "block_contact_form_message" | default "Message" }}</label>
<textarea id="message" name="message" rows="5" required
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent dark:bg-gray-700 dark:text-white"></textarea>
class="w-full px-4 py-3 bg-gray-50 dark:bg-white/5 border border-gray-200 dark:border-white/10 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500/50 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 transition-colors resize-none"></textarea>
</div>
<button type="submit"
class="w-full px-6 py-3 bg-primary-600 hover:bg-primary-700 text-white font-medium rounded-lg shadow hover:shadow-lg transition-all">
class="w-full px-6 py-3 bg-primary-600 hover:bg-primary-500 text-white font-medium rounded-lg shadow-lg shadow-primary-500/25 hover:shadow-xl hover:shadow-primary-500/30 transition-all">
{{ i18n "block_contact_form_submit" | default "Send Message" }}
</button>
</form>
+8 -3
View File
@@ -20,10 +20,10 @@
{{ $button_classes := "" }}
{{ if eq $text_color_mode "light" }}
{{ $text_classes = "text-white" }}
{{ $button_classes = "text-gray-900" }}
{{ $button_classes = "text-gray-900 dark:text-white" }}
{{ else if eq $text_color_mode "dark" }}
{{ $text_classes = "text-gray-900" }}
{{ $button_classes = "text-gray-900" }}
{{ $text_classes = "text-gray-900 dark:text-white" }}
{{ $button_classes = "text-gray-900 dark:text-white" }}
{{ else }}
{{/* Auto mode - detect based on gradient colors */}}
{{ $is_dark_bg := false }}
@@ -67,6 +67,7 @@
{{ $button_link := .url }}
{{ $button_scheme := (urls.Parse $button_link).Scheme }}
{{ $button_target := "" }}
{{ $new_tab := .new_tab | default false }}
{{ if not $button_scheme }}
{{/* Check if it's a hash fragment (page anchor) */}}
{{ if hasPrefix $button_link "#" }}
@@ -77,6 +78,10 @@
{{ $button_link = $button_link | relLangURL }}
{{ end }}
{{ else if in (slice "http" "https") $button_scheme }}
{{ $new_tab = true }}
{{ end }}
{{/* Apply new tab if requested or external link */}}
{{ if $new_tab }}
{{ $button_target = "target=\"_blank\" rel=\"noopener\"" }}
{{ end }}
+134
View File
@@ -0,0 +1,134 @@
# Dev Hero Block
A modern, developer-focused hero section optimized for dark mode with gradient effects, animated backgrounds, and flexible layouts.
## Features
- **Gradient Text**: Name displayed with beautiful gradient styling
- **Animated Background**: Floating orbs with subtle grid pattern
- **Social Links**: Automatic icon detection for common platforms
- **CTA Buttons**: Primary and secondary button styling
- **Two Layouts**: Centered or split (avatar on side) layouts
- **Author Integration**: Pulls data from author profiles
- **Emoji Status Badge**: Optional personalized emoji on avatar (💻 developer, 🎨 designer, 🚀 startup, etc.)
- **Scroll Indicator**: Optional animated scroll prompt
## Usage
### Centered Layout (Default)
```yaml
- block: dev-hero
id: hero
content:
username: me
greeting: "Hi, I'm"
show_status: true
show_scroll_indicator: true
cta_buttons:
- text: View My Work
url: "#projects"
icon: arrow-down
- text: Get In Touch
url: "#contact"
icon: envelope
design:
style: centered
avatar_shape: circle
background:
color:
dark: "#0a0a0f"
```
### Split Layout
```yaml
- block: dev-hero
id: hero
content:
username: me
greeting: "Hello, I'm"
cta_buttons:
- text: View Projects
url: "#projects"
- text: Download Resume
url: "/resume.pdf"
icon: document-arrow-down
design:
style: split
avatar_shape: rounded
```
## Parameters
### Content
| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `username` | string | `me` | Author folder to pull data from |
| `title` | string | - | Override author title |
| `tagline` | string | - | Override author role |
| `bio` | string | - | Override author bio |
| `greeting` | string | - | Text above the name |
| `name_prefix` | string | - | Prefix before name (styled differently) |
| `show_status` | boolean | `false` | Show emoji status badge (uses author's `status.icon`) |
| `show_scroll_indicator` | boolean | `false` | Show scroll prompt |
| `social` | array | - | Override author social links |
| `cta_buttons` | array | - | Call-to-action buttons |
### Design
| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `style` | string | `centered` | Layout style (`centered` or `split`) |
| `avatar_shape` | string | `circle` | Avatar shape (`circle` or `rounded`) |
## Social Networks
The block automatically detects icons for these networks:
- `github` → GitHub logo
- `linkedin` → LinkedIn logo
- `twitter` → X (Twitter) logo
- `email` → Envelope icon
Other networks will use a generic link icon unless you specify a custom `icon`.
## CTA Buttons
The first button is styled as primary (filled), subsequent buttons are secondary (outlined).
```yaml
cta_buttons:
- text: Primary Button
url: "#action"
icon: arrow-right # optional
- text: Secondary Button
url: "#other"
```
## Author Data
The block automatically pulls from `/data/authors/{username}.yaml`:
- `name.display` → Name
- `role` → Tagline/role
- `bio` → Short biography
- `links` → Social links
- `avatar` → Profile image
- `status.icon` → Emoji badge (shown when `show_status: true`)
**Status Emoji Examples:**
```yaml
# In data/authors/me.yaml
status:
icon: "💻" # Developer
# Or: 🎨 Designer, 🚀 Entrepreneur, 🔬 Researcher, 🎓 Student
```
## Styling
The block includes:
- Animated gradient orbs in the background
- Subtle grid pattern overlay
- Gradient text effect on the name
- Glass morphism social buttons
- Primary/secondary CTA button styles
+278
View File
@@ -0,0 +1,278 @@
{{/* Hugo Blox: Dev Hero */}}
{{/* Documentation: https://hugoblox.com/blocks/ */}}
{{/* License: https://github.com/HugoBlox/kit/blob/main/LICENSE.md */}}
{{/* Initialise */}}
{{ $page := .wcPage }}
{{ $block := .wcBlock }}
{{/* Enable animations if typewriter or scroll animations are configured */}}
{{ $typewriter := $block.content.typewriter | default dict }}
{{ $has_animations := or $typewriter.enable $block.design.animations }}
{{ if $has_animations }}
{{ $page.Store.Set "has_animations" true }}
{{ $page.Store.Set "has_alpine" true }}
{{ end }}
{{/* Get author data from YAML */}}
{{ $username := $block.content.username | default "me" }}
{{ $author := partial "functions/get_author_profile" $username }}
{{/* Get avatar */}}
{{ $avatar := "" }}
{{ $avatar_shape := $block.design.avatar_shape | default "circle" }}
{{ with $author.avatar }}
{{ $avatar = .Fill "300x300 webp Center" }}
{{ end }}
{{/* Layout style */}}
{{ $style := $block.design.style | default "centered" }}
<div class="relative min-h-[80vh] flex items-center justify-center overflow-hidden">
{{/* Background Effects - only visible in dark mode */}}
<div class="absolute inset-0 pointer-events-none dark:block hidden">
{{/* Gradient orbs */}}
<div class="absolute top-1/4 -left-20 w-96 h-96 bg-primary-500/20 rounded-full blur-3xl animate-pulse"></div>
<div class="absolute bottom-1/4 -right-20 w-80 h-80 bg-secondary-500/20 rounded-full blur-3xl animate-pulse" style="animation-delay: 1s;"></div>
<div class="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[40rem] h-[40rem] bg-primary-600/5 rounded-full blur-3xl"></div>
{{/* Grid pattern */}}
<div class="absolute inset-0 bg-[linear-gradient(rgba(255,255,255,0.02)_1px,transparent_1px),linear-gradient(90deg,rgba(255,255,255,0.02)_1px,transparent_1px)] bg-[size:64px_64px]"></div>
</div>
{{/* Light mode subtle background */}}
<div class="absolute inset-0 pointer-events-none dark:hidden">
<div class="absolute top-1/4 -left-20 w-96 h-96 bg-primary-100 rounded-full blur-3xl"></div>
<div class="absolute bottom-1/4 -right-20 w-80 h-80 bg-secondary-100 rounded-full blur-3xl"></div>
</div>
<div class="relative z-10 mx-auto max-w-5xl px-4 sm:px-6 lg:px-8 py-20 {{ if eq $style "centered" }}text-center{{ end }}">
{{ if eq $style "split" }}
{{/* Split layout with avatar on side */}}
<div class="flex flex-col md:flex-row items-center gap-12">
{{/* Avatar */}}
{{ with $avatar }}
<div class="flex-shrink-0">
<div class="relative">
<div class="w-48 h-48 md:w-64 md:h-64 {{ if eq $avatar_shape "circle" }}rounded-full{{ else }}rounded-2xl{{ end }} overflow-hidden ring-4 ring-gray-200 dark:ring-white/10 shadow-2xl shadow-primary-500/20">
<img src="{{ .RelPermalink }}" alt="{{ $author.title }}" class="w-full h-full object-cover">
</div>
{{/* Status emoji badge */}}
{{ if $block.content.show_status }}
{{ with $author.status.icon }}
<span class="absolute bottom-2 right-2 w-12 h-12 flex items-center justify-center text-2xl bg-white dark:bg-gray-900 rounded-full border-2 border-gray-200 dark:border-white/10 shadow-lg">
{{ . | emojify }}
</span>
{{ end }}
{{ end }}
{{/* Decorative ring */}}
<div class="absolute -inset-2 {{ if eq $avatar_shape "circle" }}rounded-full{{ else }}rounded-3xl{{ end }} border border-dashed border-gray-300 dark:border-white/10 animate-spin" style="animation-duration: 30s;"></div>
</div>
</div>
{{ end }}
{{/* Content */}}
<div class="text-center md:text-left">
{{/* Greeting */}}
{{ with $block.content.greeting }}
<p class="text-primary-600 dark:text-primary-400 font-mono text-sm mb-4 flex items-center {{ if eq $style "centered" }}justify-center{{ end }} gap-2">
<span class="inline-block w-8 h-px bg-primary-500"></span>
{{ . }}
</p>
{{ end }}
{{/* Name */}}
<h1 class="text-4xl sm:text-5xl md:text-6xl font-bold mb-4">
{{ with $block.content.name_prefix }}<span class="text-gray-500 dark:text-gray-400 font-normal">{{ . }}</span> {{ end }}
<span class="bg-gradient-to-r from-gray-900 via-gray-800 to-gray-700 dark:from-white dark:via-gray-100 dark:to-gray-300 bg-clip-text text-transparent">
{{ $author.title | default $block.content.title | default (i18n "developer" | default "Developer") }}
</span>
</h1>
{{/* Role/Tagline with optional typewriter */}}
{{ $role := $author.role | default $block.content.tagline }}
{{ if and $typewriter.enable $typewriter.strings }}
<p class="text-xl sm:text-2xl text-gray-600 dark:text-gray-400 font-light mb-6 h-8">
{{ with $typewriter.prefix }}<span>{{ . }}</span> {{ end }}
<span
x-data="typewriter({{ $typewriter.strings | jsonify }}, { typeSpeed: {{ $typewriter.type_speed | default 80 }}, deleteSpeed: {{ $typewriter.delete_speed | default 50 }}, pauseTime: {{ $typewriter.pause_time | default 2000 }} })"
x-text="currentText"
class="text-primary-600 dark:text-primary-400"
></span><span class="typewriter-cursor"></span>
</p>
{{ else if $role }}
<p class="text-xl sm:text-2xl text-gray-600 dark:text-gray-400 font-light mb-6">
{{ $role | $page.RenderString }}
</p>
{{ end }}
{{/* Bio */}}
{{ with $author.bio | default $block.content.bio }}
<p class="text-gray-600 dark:text-gray-400 max-w-xl {{ if eq $style "centered" }}mx-auto{{ end }} mb-8 leading-relaxed">
{{ . | $page.RenderString }}
</p>
{{ end }}
{{/* Social Links */}}
{{ $links := $author.links | default $block.content.links }}
{{ with $links }}
<div class="flex items-center gap-4 mb-8 {{ if eq $style "centered" }}justify-center{{ end }}">
{{ range . }}
{{ $icon := .icon | default "link" }}
{{ $url := .url }}
<a
href="{{ $url }}"
target="{{ if hasPrefix $url "mailto:" }}_self{{ else }}_blank{{ end }}"
rel="noopener noreferrer"
class="w-10 h-10 flex items-center justify-center rounded-full bg-gray-100 dark:bg-white/5 border border-gray-200 dark:border-white/10 text-gray-600 dark:text-gray-400 hover:text-primary-600 dark:hover:text-white hover:bg-primary-50 dark:hover:bg-primary-500/20 hover:border-primary-300 dark:hover:border-primary-500/50 transition-all duration-300"
{{ with .label }}title="{{ . }}"{{ end }}
>
{{ partial "functions/get_icon" (dict "name" $icon "attributes" "class=\"w-5 h-5\"") }}
</a>
{{ end }}
</div>
{{ end }}
{{/* CTA Buttons */}}
{{ with $block.content.cta_buttons }}
<div class="flex flex-wrap gap-4 {{ if eq $style "centered" }}justify-center{{ end }}">
{{ range $idx, $btn := . }}
{{ $is_primary := eq $idx 0 }}
<a
href="{{ .url }}"
class="{{ if $is_primary }}bg-primary-600 hover:bg-primary-500 text-white shadow-lg shadow-primary-500/25{{ else }}bg-gray-100 dark:bg-white/5 hover:bg-gray-200 dark:hover:bg-white/10 text-gray-800 dark:text-white border border-gray-200 dark:border-white/20 hover:border-gray-300 dark:hover:border-white/30{{ end }} px-6 py-3 rounded-full font-semibold transition-all duration-300 inline-flex items-center gap-2"
>
{{ with .icon }}
{{ partial "functions/get_icon" (dict "name" . "attributes" "class=\"w-5 h-5\"") }}
{{ end }}
{{ .text }}
</a>
{{ end }}
</div>
{{ end }}
</div>
</div>
{{ else }}
{{/* Centered layout */}}
{{/* Avatar */}}
{{ with $avatar }}
<div class="mb-8">
<div class="relative inline-block">
<div class="w-32 h-32 md:w-40 md:h-40 {{ if eq $avatar_shape "circle" }}rounded-full{{ else }}rounded-2xl{{ end }} overflow-hidden ring-4 ring-gray-200 dark:ring-white/10 shadow-2xl shadow-primary-500/20 mx-auto">
<img src="{{ .RelPermalink }}" alt="{{ $author.title }}" class="w-full h-full object-cover">
</div>
{{/* Status emoji badge */}}
{{ if $block.content.show_status }}
{{ with $author.status.icon }}
<span class="absolute bottom-0 right-0 w-10 h-10 flex items-center justify-center text-xl bg-white dark:bg-gray-900 rounded-full border-2 border-gray-200 dark:border-white/10 shadow-lg">
{{ . | emojify }}
</span>
{{ end }}
{{ end }}
</div>
</div>
{{ end }}
{{/* Greeting */}}
{{ with $block.content.greeting }}
<p class="text-primary-600 dark:text-primary-400 font-mono text-sm mb-4 inline-flex items-center gap-2">
<span class="inline-block w-8 h-px bg-primary-500"></span>
{{ . }}
<span class="inline-block w-8 h-px bg-primary-500"></span>
</p>
{{ end }}
{{/* Name */}}
<h1 class="text-4xl sm:text-5xl md:text-6xl lg:text-7xl font-bold mb-6">
{{ with $block.content.name_prefix }}<span class="text-gray-500 font-normal">{{ . }}</span> {{ end }}
<span class="bg-gradient-to-r from-gray-900 via-gray-800 to-gray-700 dark:from-white dark:via-gray-100 dark:to-gray-300 bg-clip-text text-transparent">
{{ $author.title | default $block.content.title | default (i18n "developer" | default "Developer") }}
</span>
</h1>
{{/* Role/Tagline with optional typewriter */}}
{{ $role := $author.role | default $block.content.tagline }}
{{ if and $typewriter.enable $typewriter.strings }}
<p class="text-xl sm:text-2xl md:text-3xl text-gray-600 dark:text-gray-400 font-light mb-6 min-h-[2em]">
{{ with $typewriter.prefix }}<span>{{ . }}</span> {{ end }}
<span
x-data="typewriter({{ $typewriter.strings | jsonify }}, { typeSpeed: {{ $typewriter.type_speed | default 80 }}, deleteSpeed: {{ $typewriter.delete_speed | default 50 }}, pauseTime: {{ $typewriter.pause_time | default 2000 }} })"
x-text="currentText"
class="text-primary-600 dark:text-primary-400"
></span><span class="typewriter-cursor"></span>
</p>
{{ else if $role }}
<p class="text-xl sm:text-2xl md:text-3xl text-gray-600 dark:text-gray-400 font-light mb-6">
{{ $role | $page.RenderString }}
</p>
{{ end }}
{{/* Bio */}}
{{ with $author.bio | default $block.content.bio }}
<p class="text-gray-600 dark:text-gray-400 max-w-2xl mx-auto mb-8 text-lg leading-relaxed">
{{ . | $page.RenderString }}
</p>
{{ end }}
{{/* Social Links */}}
{{ $links := $author.links | default $block.content.links }}
{{ with $links }}
<div class="flex items-center justify-center gap-4 mb-10">
{{ range . }}
{{ $icon := .icon | default "link" }}
{{ $url := .url }}
<a
href="{{ $url }}"
target="{{ if hasPrefix $url "mailto:" }}_self{{ else }}_blank{{ end }}"
rel="noopener noreferrer"
class="w-12 h-12 flex items-center justify-center rounded-full bg-gray-100 dark:bg-white/5 border border-gray-200 dark:border-white/10 text-gray-600 dark:text-gray-400 hover:text-primary-600 dark:hover:text-white hover:bg-primary-50 dark:hover:bg-primary-500/20 hover:border-primary-300 dark:hover:border-primary-500/50 transition-all duration-300"
{{ with .label }}title="{{ . }}"{{ end }}
>
{{ partial "functions/get_icon" (dict "name" $icon "attributes" "class=\"w-5 h-5\"") }}
</a>
{{ end }}
</div>
{{ end }}
{{/* CTA Buttons */}}
{{ with $block.content.cta_buttons }}
<div class="flex flex-wrap justify-center gap-4">
{{ range $idx, $btn := . }}
{{ $is_primary := eq $idx 0 }}
<a
href="{{ .url }}"
class="{{ if $is_primary }}bg-primary-600 hover:bg-primary-500 text-white shadow-lg shadow-primary-500/25 hover:shadow-xl hover:shadow-primary-500/30{{ else }}bg-gray-100 dark:bg-white/5 hover:bg-gray-200 dark:hover:bg-white/10 text-gray-800 dark:text-white border border-gray-200 dark:border-white/20 hover:border-gray-300 dark:hover:border-white/30{{ end }} px-8 py-4 rounded-full font-semibold transition-all duration-300 inline-flex items-center gap-2 text-lg"
>
{{ with .icon }}
{{ partial "functions/get_icon" (dict "name" . "attributes" "class=\"w-5 h-5\"") }}
{{ end }}
{{ .text }}
</a>
{{ end }}
</div>
{{ end }}
{{ end }}
{{/* Scroll indicator - clicks to scroll to next section */}}
{{ if $block.content.show_scroll_indicator }}
{{ $scroll_target := $block.content.scroll_target | default "#projects" }}
<a
href="{{ $scroll_target }}"
class="absolute bottom-8 left-1/2 -translate-x-1/2 animate-bounce cursor-pointer group"
aria-label="{{ i18n "scroll_to_content" | default "Scroll to content" }}"
>
<div class="w-6 h-10 rounded-full border-2 border-gray-300 dark:border-white/20 group-hover:border-primary-400/50 flex items-start justify-center p-2 transition-colors">
<div class="w-1 h-2 bg-gray-400 dark:bg-white/40 group-hover:bg-primary-400 rounded-full animate-pulse transition-colors"></div>
</div>
</a>
{{ end }}
</div>
</div>
+13
View File
@@ -0,0 +1,13 @@
{
"id": "dev-hero",
"name": "Dev Hero",
"version": "1.0.0",
"license": "MIT",
"category": "hero",
"tags": ["hero", "developer", "portfolio", "introduction", "landing"],
"description": "A developer-focused hero section with gradient effects, social links, and CTA buttons - optimized for dark mode",
"author": "Hugo Blox",
"homepage": "https://hugoblox.com/blocks/",
"repository": "https://github.com/HugoBlox/kit",
"keywords": ["hugo", "hero", "developer", "portfolio", "landing-page"]
}
+207
View File
@@ -0,0 +1,207 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"$id": "https://hugoblox.com/schemas/blocks/dev-hero.json",
"title": "Dev Hero Block Schema",
"description": "Schema for the Dev Hero block - a developer-focused hero section with gradient effects",
"allOf": [
{
"$ref": "../shared/schemas/base-block.json"
},
{
"type": "object",
"properties": {
"block": {
"const": "dev-hero",
"description": "Block type identifier"
},
"content": {
"type": "object",
"description": "Content configuration for the dev hero block",
"properties": {
"username": {
"type": "string",
"description": "Author username to pull data from (matches folder name in /authors/)",
"default": "me",
"examples": ["me", "admin"]
},
"title": {
"type": "string",
"description": "Override name/title (falls back to author title)"
},
"tagline": {
"type": "string",
"description": "Override role/tagline (falls back to author role)"
},
"bio": {
"type": "string",
"description": "Override bio text (falls back to author bio)"
},
"greeting": {
"type": "string",
"description": "Small greeting text above the name",
"examples": ["Hi, I'm", "Hello, I'm", "Welcome"]
},
"name_prefix": {
"type": "string",
"description": "Prefix before the name (styled differently)",
"examples": ["I'm", "Hey, I'm"]
},
"show_status": {
"type": "boolean",
"description": "Show status emoji badge on avatar (uses author's status.icon from profile)",
"default": false
},
"show_scroll_indicator": {
"type": "boolean",
"description": "Show scroll down indicator at bottom",
"default": false
},
"scroll_target": {
"type": "string",
"description": "CSS selector for scroll indicator target",
"default": "#projects",
"examples": ["#projects", "#about", "#contact"]
},
"links": {
"type": "array",
"description": "Override social links (falls back to author links)",
"items": {
"type": "object",
"properties": {
"icon": {
"type": "string",
"description": "Icon identifier",
"examples": ["brands/github", "brands/linkedin", "brands/x", "at-symbol"]
},
"url": {
"type": "string",
"description": "Link URL"
},
"label": {
"type": "string",
"description": "Link label/title"
}
},
"required": ["icon", "url"]
}
},
"cta_buttons": {
"type": "array",
"description": "Call-to-action buttons",
"items": {
"type": "object",
"properties": {
"text": {
"type": "string",
"description": "Button text",
"examples": ["View My Work", "Download Resume", "Contact Me"]
},
"url": {
"type": "string",
"description": "Button URL",
"examples": ["#projects", "/resume.pdf", "#contact"]
},
"icon": {
"type": "string",
"description": "Button icon (optional)",
"examples": ["arrow-down", "document-arrow-down", "envelope"]
}
},
"required": ["text", "url"]
},
"examples": [
[
{ "text": "View My Work", "url": "#projects", "icon": "arrow-down" },
{ "text": "Download Resume", "url": "/resume.pdf", "icon": "document-arrow-down" }
]
]
},
"typewriter": {
"type": "object",
"description": "Typewriter animation for cycling through roles/taglines",
"properties": {
"enable": {
"type": "boolean",
"description": "Enable typewriter effect",
"default": false
},
"strings": {
"type": "array",
"description": "Array of strings to cycle through",
"items": { "type": "string" },
"examples": [["Full-Stack Developer", "Open Source Contributor", "Tech Enthusiast"]]
},
"prefix": {
"type": "string",
"description": "Static text before the animated part",
"examples": ["I'm a", "I build"]
},
"type_speed": {
"type": "integer",
"description": "Typing speed in milliseconds per character",
"default": 80
},
"delete_speed": {
"type": "integer",
"description": "Delete speed in milliseconds per character",
"default": 50
},
"pause_time": {
"type": "integer",
"description": "Pause time in milliseconds before deleting",
"default": 2000
}
}
}
},
"additionalProperties": false
},
"design": {
"type": "object",
"description": "Layout options for dev hero rendering",
"properties": {
"style": {
"type": "string",
"description": "Layout style",
"enum": ["centered", "split"],
"default": "centered"
},
"avatar_shape": {
"type": "string",
"description": "Avatar shape",
"enum": ["circle", "rounded"],
"default": "circle"
}
},
"additionalProperties": true
}
},
"additionalProperties": true,
"examples": [
{
"block": "dev-hero",
"id": "hero",
"content": {
"username": "me",
"greeting": "Hi, I'm",
"show_status": true,
"show_scroll_indicator": true,
"cta_buttons": [
{ "text": "View My Work", "url": "#projects", "icon": "arrow-down" },
{ "text": "Get In Touch", "url": "#contact", "icon": "envelope" }
]
},
"design": {
"style": "centered",
"avatar_shape": "circle",
"background": {
"color": {
"dark": "#0a0a0f"
}
}
}
}
]
}
]
}
+303
View File
@@ -0,0 +1,303 @@
# Portfolio Block
A flexible, filterable portfolio block for showcasing work with Alpine.js-powered filtering and modern glass morphism styling.
**Versatile Use Cases:** Software projects, research papers, design work, photography, teaching portfolios, case studies, publications, and more.
## Features
- **Tag Filtering**: Filter items by tags with smooth Alpine.js transitions
- **Glass Morphism**: Modern card design with backdrop blur and gradient borders
- **Status Badges**: Display status (Live, In Progress, Completed, Published, etc.)
- **Flexible Keywords**: Show skills, technologies, tools, materials, or methods
- **Custom Links**: GitHub, live sites, PDFs, videos, or any custom link type
- **Responsive Grid**: Configurable 2, 3, or 4 column layout
- **Smooth Animations**: Fade and scale transitions on filter
- **Customizable Icons**: Configure fallback icon for items without images
## Basic Usage
```yaml
- block: portfolio
id: portfolio
content:
title: "My Work"
subtitle: "A selection of recent projects"
count: 6
filters:
folders:
- projects # Or: research, portfolio, work, teaching, etc.
buttons:
- name: All
tag: '*'
- name: Category A
tag: CategoryA
- name: Category B
tag: CategoryB
default_button_index: 0
archive:
# Auto-shown if more items exist than displayed
# Optionally customize:
# enable: false # Explicitly hide
# link: "/work/" # Custom URL
# text: "Browse All" # Custom text
design:
columns: 3
fallback_icon: code-bracket # Or: academic-cap, paint-brush, camera, etc.
```
## Item Frontmatter
Each portfolio item should include the following frontmatter:
```yaml
---
title: "Item Title"
date: 2024-01-15
summary: "Brief description of the work"
tags:
- Category
- Subcategory
- Tag
tech_stack: # Or think of as: tools, methods, skills, materials
- Tool 1
- Tool 2
- Tool 3
links:
- type: custom # Or: github, live, pdf, video, etc.
url: https://example.com
label: View Project
icon: globe-alt # Optional
status: "Completed" # Or: Live, Published, In Progress, etc.
featured: true
---
```
## Parameters
### Content
| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `title` | string | - | Section title |
| `subtitle` | string | - | Section subtitle |
| `count` | integer | 6 | Number of projects to display |
| `filters.folders` | array | `["projects"]` | Content folders to query |
| `buttons` | array | `[{name: "All", tag: "*"}]` | Filter buttons |
| `default_button_index` | integer | 0 | Default active filter |
| `archive.enable` | boolean | auto | Show "View All" link (auto-shown if more projects exist, set to `false` to hide) |
| `archive.link` | string | auto | Archive URL (defaults to first folder in `filters.folders`) |
| `archive.text` | string | i18n | Archive link text (uses i18n `portfolio_view_all`) |
### Design
| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `columns` | integer | 3 | Grid columns (2, 3, or 4) |
| `fallback_icon` | string | `code-bracket` | Icon shown when item has no image. Supports all Hugo Blox icon packs (hero, brands, devicon, emoji, custom). Format: `icon-name` or `pack/icon-name` |
## Link Types
Each link supports the following properties:
- `type`: Link type (github, live, demo, or custom)
- `url`: Link URL
- `label`: Custom label text (optional, overrides default)
- `icon`: Custom icon (optional, overrides default - supports all icon packs)
| Type | Icon | Default Label |
|------|------|---------------|
| `github` | `brands/github` | "Code" |
| `live` | `globe-alt` | "Live" |
| `demo` | `play` | "Demo" |
| Other | `link` | "Link" |
**Note:** Labels are shared with the project page header, so setting them in frontmatter ensures consistency across card and page views.
**Icon Examples:**
```yaml
links:
- type: custom
url: "..."
label: "Watch Video"
icon: play-circle # Hero icon (default pack)
- type: custom
url: "..."
label: "View on GitHub"
icon: brands/github # Brand icon
- type: pdf
url: "paper.pdf"
label: "Read Paper"
icon: document-text # Hero icon
- type: custom
url: "..."
label: "Python Code"
icon: devicon/python # Devicon
```
## Status Badges
| Status | Color |
|--------|-------|
| `Live` | Emerald/Green |
| `WIP` | Amber/Yellow |
| `Archived` | Gray |
**Note:** You can use any custom status text. Color coding: emerald for active/live/published, amber for in-progress, gray for archived/completed.
## Use Case Examples
### Software Developer Portfolio
```yaml
- block: portfolio
content:
title: "Featured Projects"
filters:
folders: [projects]
buttons:
- {name: All, tag: '*'}
- {name: Full-Stack, tag: Full-Stack}
- {name: Frontend, tag: Frontend}
design:
fallback_icon: code-bracket
```
**Project frontmatter:**
```yaml
title: "E-Commerce Platform"
tags: [Full-Stack, React, Node.js]
tech_stack: [React, TypeScript, PostgreSQL]
links:
- {type: github, url: "...", label: Code}
- {type: live, url: "...", label: Demo}
status: "Live"
```
### Academic Research Portfolio
```yaml
- block: portfolio
content:
title: "Research Projects"
filters:
folders: [research]
buttons:
- {name: All, tag: '*'}
- {name: Machine Learning, tag: ML}
- {name: Computational Biology, tag: CompBio}
design:
fallback_icon: academic-cap # Or: beaker, emoji/microscope, devicon/python
columns: 2
```
**Research project frontmatter:**
```yaml
title: "Neural Network Optimization Study"
tags: [ML, Optimization, Deep Learning]
tech_stack: [Python, PyTorch, CUDA, NumPy] # Or rename to "methods", "tools"
links:
- {type: pdf, url: "paper.pdf", label: Read Paper}
- {type: code, url: "github.com/...", label: Code}
- {type: dataset, url: "...", label: Dataset}
status: "Published"
```
### Design/Creative Portfolio
```yaml
- block: portfolio
content:
title: "Design Work"
filters:
folders: [portfolio]
buttons:
- {name: All, tag: '*'}
- {name: Brand Design, tag: Branding}
- {name: UI/UX, tag: UI/UX}
- {name: Illustration, tag: Illustration}
design:
fallback_icon: paint-brush # Or: camera, emoji/art, brands/dribbble
columns: 3
```
**Design project frontmatter:**
```yaml
title: "Acme Corp Brand Identity"
tags: [Branding, Logo Design, Print]
tech_stack: [Adobe Illustrator, Photoshop, Figma] # Tools used
links:
- {type: custom, url: "behance.net/...", label: View on Behance, icon: globe-alt}
- {type: pdf, url: "case-study.pdf", label: Case Study}
status: "Completed"
```
### Teaching Portfolio
```yaml
- block: portfolio
content:
title: "Courses & Materials"
filters:
folders: [teaching]
buttons:
- {name: All, tag: '*'}
- {name: Undergraduate, tag: Undergrad}
- {name: Graduate, tag: Grad}
design:
fallback_icon: document-text
```
**Course frontmatter:**
```yaml
title: "Introduction to Data Science"
tags: [Undergrad, Statistics, Programming]
tech_stack: [Python, R, Jupyter, Pandas] # Tools taught
links:
- {type: custom, url: "syllabus.pdf", label: Syllabus, icon: document-text}
- {type: custom, url: "github.com/...", label: Course Materials, icon: brands/github}
status: "Spring 2024"
```
## Customization Tips
1. **Rename fields conceptually** - While `tech_stack` is the parameter name, you can use it for:
- Software: Technologies, frameworks, tools
- Research: Methods, techniques, software
- Design: Tools, software, materials
- Writing: Genres, themes, topics
2. **Custom link types** - Beyond `github`, `live`, `demo`:
- `pdf` for papers, reports, case studies
- `dataset` for research data
- `video` for presentations, demos
- `slides` for slide decks
- Custom types with your own labels and icons
3. **Flexible status badges** - Use any text:
- Software: "Live", "Beta", "Archived"
- Research: "Published", "Under Review", "In Progress"
- Design: "Completed", "Ongoing", "Client Work"
- Academic: "Spring 2024", "Fall 2023", "Ongoing"
4. **Icon selection** - Choose an appropriate `fallback_icon`:
- **Hero icons** (default pack, no prefix needed):
- `code-bracket` - Software/development
- `academic-cap` - Academic/education
- `beaker` - Research/science
- `paint-brush` - Art/design
- `camera` - Photography
- `document-text` - Writing/documentation
- `star` - Featured/highlights
- **Brand icons** (use `brands/` prefix):
- `brands/github` - GitHub projects
- `brands/linkedin` - Professional work
- `brands/dribbble` - Design work
- **Devicon** (use `devicon/` prefix):
- `devicon/python` - Python projects
- `devicon/react` - React projects
- `devicon/docker` - DevOps work
- **Emoji** (use `emoji/` prefix or direct unicode):
- `emoji/rocket` or 🚀
- `emoji/art` or 🎨
- `emoji/microscope` or 🔬
- **Custom icons**: Place in `assets/media/icons/<pack>/` and use `<pack>/<name>`
+267
View File
@@ -0,0 +1,267 @@
{{/* Hugo Blox: Portfolio */}}
{{/* Documentation: https://hugoblox.com/blocks/ */}}
{{/* License: https://github.com/HugoBlox/kit/blob/main/LICENSE.md */}}
{{/* Initialise */}}
{{ $page := .wcPage }}
{{ $block := .wcBlock }}
{{/* Enable Alpine.js and animations for this block */}}
{{ $page.Store.Set "has_alpine" true }}
{{ if ne $block.design.animations false }}
{{ $page.Store.Set "has_animations" true }}
{{ end }}
{{/* Query projects */}}
{{ $query := site.Pages }}
{{ $folders := $block.content.filters.folders | default (slice "projects") }}
{{ $query = where $query "Section" "in" $folders }}
{{ $query = where $query "Kind" "eq" "page" }}
{{/* Get archive page for "View All" link */}}
{{ $archive_page := "" }}
{{ $main_folder := index $folders 0 }}
{{ $archive_page = site.GetPage "Section" $main_folder }}
{{/* Sort */}}
{{ $sort_by := $block.content.sort_by | default "Date" }}
{{ $sort_by = partial "functions/get_sort_by_parameter" $sort_by }}
{{ $sort_ascending := $block.content.sort_ascending | default false }}
{{ $sort_order := cond $sort_ascending "asc" "desc" }}
{{ $query = sort $query $sort_by $sort_order }}
{{/* Store total count before limiting */}}
{{ $total_count := len $query }}
{{/* Limit */}}
{{ $count := $block.content.count | default 6 }}
{{ if gt $count 0 }}
{{ $query = first $count $query }}
{{ end }}
{{/* Get filter buttons */}}
{{ $buttons := $block.content.buttons | default (slice (dict "name" "All" "tag" "*")) }}
{{ $default_idx := $block.content.default_button_index | default 0 }}
{{ $default_tag := "*" }}
{{ with index $buttons $default_idx }}
{{ $default_tag = .tag }}
{{ end }}
{{/* Columns */}}
{{ $columns := $block.design.columns | default 3 }}
{{ $grid_class := "md:grid-cols-2 lg:grid-cols-3" }}
{{ if eq $columns 2 }}
{{ $grid_class = "md:grid-cols-2" }}
{{ else if eq $columns 4 }}
{{ $grid_class = "md:grid-cols-2 lg:grid-cols-4" }}
{{ end }}
<div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 py-16" x-data="{ activeFilter: '{{ $default_tag }}' }">
{{/* Header */}}
<div class="text-center mb-12">
{{ with $block.content.title }}
<h2 class="text-3xl sm:text-4xl font-bold text-gray-900 dark:text-white mb-4">
{{ . | emojify | $page.RenderString }}
</h2>
{{ end }}
{{ with $block.content.subtitle }}
<p class="text-lg text-gray-500 dark:text-gray-400 max-w-2xl mx-auto">
{{ . | emojify | $page.RenderString }}
</p>
{{ end }}
</div>
{{/* Filter Buttons */}}
{{ if gt (len $buttons) 1 }}
<div class="flex flex-wrap justify-center gap-2 mb-10">
{{ range $idx, $button := $buttons }}
<button
@click="activeFilter = '{{ $button.tag }}'"
:class="activeFilter === '{{ $button.tag }}'
? 'bg-primary-600 text-white border-primary-600 shadow-lg shadow-primary-500/25'
: 'bg-gray-100 dark:bg-white/5 text-gray-700 dark:text-gray-300 border-gray-200 dark:border-white/10 hover:bg-gray-200 dark:hover:bg-white/10 hover:border-gray-300 dark:hover:border-white/20'"
class="px-5 py-2.5 rounded-full text-sm font-medium border backdrop-blur-sm transition-all duration-300 ease-out cursor-pointer"
>
{{ $button.name }}
</button>
{{ end }}
</div>
{{ end }}
{{/* Project Grid with staggered reveal animation */}}
<div class="grid grid-cols-1 {{ $grid_class }} gap-6" data-stagger="100">
{{ range $index, $item := $query }}
{{/* Get tags as comma-separated string */}}
{{ $item_tags := "" }}
{{ with $item.Params.tags }}
{{ $item_tags = delimit . "," }}
{{ end }}
{{/* Get status */}}
{{ $status := $item.Params.status | default "" }}
{{ $status_class := "bg-emerald-500/20 text-emerald-600 dark:text-emerald-400 border-emerald-500/30" }}
{{ if eq $status "WIP" }}
{{ $status_class = "bg-amber-500/20 text-amber-600 dark:text-amber-400 border-amber-500/30" }}
{{ else if eq $status "Archived" }}
{{ $status_class = "bg-gray-500/20 text-gray-600 dark:text-gray-400 border-gray-500/30" }}
{{ end }}
<article
x-show="activeFilter === '*' || '{{ $item_tags }}'.toLowerCase().includes(activeFilter.toLowerCase())"
x-transition:enter="transition ease-out duration-300"
x-transition:enter-start="opacity-0 scale-95 translate-y-4"
x-transition:enter-end="opacity-100 scale-100 translate-y-0"
x-transition:leave="transition ease-in duration-200"
x-transition:leave-start="opacity-100 scale-100"
x-transition:leave-end="opacity-0 scale-95"
class="group relative flex flex-col bg-white dark:bg-white/[0.03] backdrop-blur-xl rounded-2xl border border-gray-200 dark:border-white/[0.08] overflow-hidden hover:border-primary-500/50 hover:bg-gray-50 dark:hover:bg-white/[0.06] transition-all duration-300 hover:shadow-xl hover:shadow-primary-500/10 card-hover-lift"
>
{{/* Card Header / Image */}}
<div class="relative h-48 overflow-hidden">
{{ $image := $item.Resources.GetMatch "{featured,cover,thumbnail}*" }}
{{ if $image }}
{{ if strings.Contains $image.MediaType.SubType "svg" }}
<img
src="{{ $image.RelPermalink }}"
alt="{{ $item.Title }}"
class="w-full h-full object-cover transition-transform duration-500 group-hover:scale-110"
>
{{ else }}
{{ $img := $image.Fill "600x400 webp" }}
<img
src="{{ $img.RelPermalink }}"
alt="{{ $item.Title }}"
class="w-full h-full object-cover transition-transform duration-500 group-hover:scale-110"
>
{{ end }}
{{ else }}
{{/* Gradient placeholder with configurable icon */}}
{{ $fallback_icon := $block.design.fallback_icon | default "code-bracket" }}
<div class="w-full h-full bg-gradient-to-br from-primary-600/40 via-secondary-600/30 to-primary-800/50 flex items-center justify-center">
{{ partial "functions/get_icon" (dict "name" $fallback_icon "attributes" "class=\"w-16 h-16 text-white/30\"") }}
</div>
{{ end }}
{{/* Status Badge */}}
{{ with $status }}
<div class="absolute top-3 right-3">
<span class="px-2.5 py-1 text-xs font-semibold rounded-full border backdrop-blur-sm {{ $status_class }}">
{{ . }}
</span>
</div>
{{ end }}
{{/* Gradient Overlay */}}
<div class="absolute inset-0 bg-gradient-to-t from-gray-900/90 via-gray-900/20 to-transparent"></div>
</div>
{{/* Card Content - use flex-col to push links to bottom */}}
<div class="p-5 flex flex-col flex-grow">
{{/* Title */}}
<h3 class="text-lg font-bold text-gray-900 dark:text-white mb-2 group-hover:text-primary-600 dark:group-hover:text-primary-400 transition-colors">
<a href="{{ $item.RelPermalink }}" class="stretched-link">
{{ $item.Title }}
</a>
</h3>
{{/* Summary */}}
{{ with $item.Params.summary }}
<p class="text-sm text-gray-600 dark:text-gray-400 line-clamp-2 mb-4">
{{ . }}
</p>
{{ else }}
<p class="text-sm text-gray-600 dark:text-gray-400 line-clamp-2 mb-4">
{{ $item.Summary | truncate 100 }}
</p>
{{ end }}
{{/* Tech Stack Tags - fixed 2-line height for consistency */}}
{{ with $item.Params.tech_stack }}
<div class="flex flex-wrap gap-1.5 mb-4 min-h-[3.5rem]">
{{ range first 4 . }}
<span class="px-2 py-0.5 text-xs font-medium rounded-md bg-primary-100 dark:bg-primary-500/10 text-primary-700 dark:text-primary-300 border border-primary-200 dark:border-primary-500/20 h-fit">
{{ . }}
</span>
{{ end }}
{{ if gt (len .) 4 }}
<span class="px-2 py-0.5 text-xs font-medium rounded-md bg-gray-100 dark:bg-gray-500/10 text-gray-600 dark:text-gray-400 h-fit">
+{{ sub (len .) 4 }}
</span>
{{ end }}
</div>
{{ end }}
{{/* Action Links - pushed to bottom with mt-auto */}}
{{ with $item.Params.links }}
<div class="flex items-center gap-3 pt-3 border-t border-gray-100 dark:border-white/5 mt-auto">
{{ range . }}
{{ $icon := "link" }}
{{ $label := .label | default .name }}
{{/* Fallback to i18n if no custom label provided */}}
{{ if not $label }}
{{ if eq .type "github" }}
{{ $icon = "brands/github" }}
{{ $label = i18n "portfolio_link_code" | default "Code" }}
{{ else if eq .type "live" }}
{{ $icon = "globe-alt" }}
{{ $label = i18n "portfolio_link_live" | default "Live" }}
{{ else if eq .type "demo" }}
{{ $icon = "play" }}
{{ $label = i18n "portfolio_link_demo" | default "Demo" }}
{{ else }}
{{ $label = i18n "portfolio_link_default" | default "Link" }}
{{ end }}
{{ else }}
{{/* Custom label provided, still infer icon from type if not specified */}}
{{ if eq .type "github" }}
{{ $icon = "brands/github" }}
{{ else if eq .type "live" }}
{{ $icon = "globe-alt" }}
{{ else if eq .type "demo" }}
{{ $icon = "play" }}
{{ end }}
{{ end }}
{{ $icon = .icon | default $icon }}
<a
href="{{ .url }}"
target="_blank"
rel="noopener noreferrer"
class="relative z-10 inline-flex items-center gap-1.5 text-sm text-gray-600 dark:text-gray-400 hover:text-primary-600 dark:hover:text-primary-400 transition-colors"
onclick="event.stopPropagation();"
>
{{ partial "functions/get_icon" (dict "name" $icon "attributes" "class=\"w-4 h-4\"") }}
<span>{{ $label }}</span>
</a>
{{ end }}
</div>
{{ end }}
</div>
</article>
{{ end }}
</div>
{{/* View All Link */}}
{{/* Smart default: show link if there are more items than displayed, unless explicitly disabled */}}
{{ $show_archive_link := $block.content.archive.enable | default (gt $total_count $count) }}
{{ if $show_archive_link | and $archive_page }}
{{ $archive_link := "" }}
{{ if $block.content.archive.link }}
{{ $archive_link = $block.content.archive.link | relLangURL }}
{{ else }}
{{ $archive_link = $archive_page.RelPermalink }}
{{ end }}
{{ $archive_text := $block.content.archive.text | default (i18n "portfolio_view_all" | default "View All Projects") }}
<div class="text-center mt-12">
<a
href="{{ $archive_link }}"
class="inline-flex items-center gap-2 px-6 py-3 text-sm font-medium text-gray-800 dark:text-white bg-gray-100 dark:bg-white/5 border border-gray-200 dark:border-white/10 rounded-full hover:bg-gray-200 dark:hover:bg-white/10 hover:border-gray-300 dark:hover:border-white/20 transition-all duration-300"
>
{{ $archive_text | emojify }}
{{ partial "functions/get_icon" (dict "name" "arrow-right" "attributes" "class=\"w-4 h-4\"") }}
</a>
</div>
{{ end }}
</div>
+13
View File
@@ -0,0 +1,13 @@
{
"id": "portfolio",
"name": "Portfolio",
"version": "1.0.0",
"license": "MIT",
"category": "content",
"tags": ["portfolio", "projects", "filter", "alpine", "developer", "showcase"],
"description": "Display filterable project cards with Alpine.js powered tag filtering, glass morphism styling, and smooth animations",
"author": "Hugo Blox",
"homepage": "https://hugoblox.com/blocks/",
"repository": "https://github.com/HugoBlox/kit",
"keywords": ["hugo", "portfolio", "projects", "filtering", "alpine.js", "developer"]
}
+175
View File
@@ -0,0 +1,175 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"$id": "https://hugoblox.com/schemas/blocks/portfolio.json",
"title": "Portfolio Block Schema",
"description": "Schema for the Portfolio block - displays filterable project cards with Alpine.js",
"allOf": [
{
"$ref": "../shared/schemas/base-block.json"
},
{
"type": "object",
"properties": {
"block": {
"const": "portfolio",
"description": "Block type identifier"
},
"content": {
"type": "object",
"description": "Content configuration for the portfolio block",
"properties": {
"title": {
"type": "string",
"description": "Section title (supports Markdown)",
"examples": ["Featured Projects"]
},
"subtitle": {
"type": "string",
"description": "Section subtitle (supports Markdown)",
"examples": ["Things I've built"]
},
"count": {
"type": "integer",
"description": "Maximum number of projects to display (0 means unlimited)",
"minimum": 0,
"default": 6
},
"sort_by": {
"type": "string",
"description": "Field to sort by (e.g., Date, title, weight)",
"default": "Date"
},
"sort_ascending": {
"type": "boolean",
"description": "Sort in ascending order",
"default": false
},
"filters": {
"type": "object",
"description": "Content filtering options",
"properties": {
"folders": {
"type": "array",
"description": "Content folders to query projects from",
"items": {
"type": "string"
},
"default": ["projects"],
"examples": [["projects", "software"]]
}
},
"additionalProperties": false
},
"buttons": {
"type": "array",
"description": "Filter buttons configuration",
"items": {
"type": "object",
"properties": {
"name": {
"type": "string",
"description": "Button display text",
"examples": ["All", "Frontend", "Backend"]
},
"tag": {
"type": "string",
"description": "Tag to filter by (* for all)",
"examples": ["*", "Frontend", "Full-Stack"]
}
},
"required": ["name", "tag"]
},
"default": [{ "name": "All", "tag": "*" }],
"examples": [
[
{ "name": "All", "tag": "*" },
{ "name": "Full-Stack", "tag": "Full-Stack" },
{ "name": "Frontend", "tag": "Frontend" },
{ "name": "Backend", "tag": "Backend" }
]
]
},
"default_button_index": {
"type": "integer",
"description": "Index of the default active filter button",
"minimum": 0,
"default": 0
},
"archive": {
"type": "object",
"description": "Archive link configuration",
"properties": {
"enable": {
"type": "boolean",
"description": "Show 'View All' link (defaults to true if more projects exist than displayed, set to false to explicitly hide)"
},
"link": {
"type": "string",
"description": "Override archive URL (defaults to the first folder in filters.folders)",
"examples": ["/projects/", "/work/"]
},
"text": {
"type": "string",
"description": "Override archive link text (uses i18n 'portfolio_view_all' by default)",
"examples": ["View All Projects", "See My Work", "Browse Portfolio"]
}
},
"additionalProperties": false
}
},
"additionalProperties": false
},
"design": {
"type": "object",
"description": "Layout options for portfolio rendering",
"properties": {
"columns": {
"type": "integer",
"description": "Number of columns (2, 3, or 4)",
"default": 3,
"enum": [2, 3, 4],
"examples": [2, 3, 4]
},
"fallback_icon": {
"type": "string",
"description": "Icon to display when no image is provided. Supports all Hugo Blox icon packs: hero (default), brands, devicon, emoji, or custom packs. Format: 'icon-name' or 'pack/icon-name'",
"default": "code-bracket",
"examples": ["code-bracket", "academic-cap", "beaker", "paint-brush", "brands/github", "devicon/python", "emoji/rocket"]
}
},
"additionalProperties": true
}
},
"required": ["content"],
"additionalProperties": true,
"examples": [
{
"block": "portfolio",
"id": "projects",
"content": {
"title": "Featured Projects",
"subtitle": "A selection of my recent work",
"count": 6,
"filters": {
"folders": ["projects"]
},
"buttons": [
{ "name": "All", "tag": "*" },
{ "name": "Full-Stack", "tag": "Full-Stack" },
{ "name": "Frontend", "tag": "Frontend" },
{ "name": "Backend", "tag": "Backend" }
],
"default_button_index": 0,
"archive": {
"enable": true,
"text": "View All Projects"
}
},
"design": {
"columns": 3
}
}
]
}
]
}
+188 -85
View File
@@ -1,4 +1,4 @@
{{/* Hugo Blox: Experience */}}
{{/* Hugo Blox: Experience - 2026 Redesign */}}
{{/* Documentation: https://hugoblox.com/blocks/ */}}
{{/* License: https://github.com/HugoBlox/kit/blob/main/LICENSE.md */}}
@@ -11,106 +11,209 @@
{{ $profile := partial "functions/get_author_profile" $author }}
{{ $is_education_first := $block.design.is_education_first | default false }}
<div class="flex flex-col items-center mx-auto w-full max-w-prose justify-center">
<!-- <article class="prose prose-slate lg:prose-xl dark:prose-invert">-->
<!-- {{ with $block.content.title }}-->
<!-- <h2>{{ . }}</h2>-->
<!-- {{end}}-->
<!-- </article>-->
<div class="mx-auto w-full max-w-4xl px-4 sm:px-6 lg:px-8">
<!-- Need Gap >= 3 when flex-row due to negative margin on cols (-start-3) -->
<div class="flex flex-col lg:gap-x-6 w-full px-6 sm:px-0 {{ if $is_education_first }}flex-col-reverse{{end}}">
<div class="flex flex-col gap-16 {{ if $is_education_first }}flex-col-reverse{{end}}">
{{ $experience := or $profile.experience $profile.work }}
{{ if $experience }}
<div class="w-full">
<h3 class="mb-6 text-3xl font-bold text-gray-900 dark:text-white text-center">{{ i18n "experience" | default "Experience" }}</h3>
<ol class="relative w-full border-s border-gray-200 dark:border-gray-700">
{{ range $idx, $key := $experience }}
<li class="mb-10 ms-6">
<span class="absolute flex items-center justify-center w-6 h-6 bg-primary-100 rounded-full -start-3 ring-8 ring-white dark:ring-gray-900 dark:bg-primary-900">
{{ if .icon }}
{{ partial "functions/get_icon" (dict "name" .icon "attributes" "class='w-5 h-5'") }}
{{else}}
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 256 256" class="w-5 h-5 text-primary-800 dark:text-primary-300"><path d="M216,56H176V48a24,24,0,0,0-24-24H104A24,24,0,0,0,80,48v8H40A16,16,0,0,0,24,72V200a16,16,0,0,0,16,16H216a16,16,0,0,0,16-16V72A16,16,0,0,0,216,56ZM96,48a8,8,0,0,1,8-8h48a8,8,0,0,1,8,8v8H96ZM216,72v41.61A184,184,0,0,1,128,136a184.07,184.07,0,0,1-88-22.38V72Zm0,128H40V131.64A200.19,200.19,0,0,0,128,152a200.25,200.25,0,0,0,88-20.37V200ZM104,112a8,8,0,0,1,8-8h32a8,8,0,0,1,0,16H112A8,8,0,0,1,104,112Z"></path></svg>
{{end}}
</span>
<h3 class="flex items-center mb-1 text-lg font-semibold text-gray-900 dark:text-white">{{ .role | default .position }}
<!-- <span class="bg-blue-100 text-blue-800 text-sm font-medium me-2 px-2.5 py-0.5 rounded dark:bg-blue-900 dark:text-blue-300 ms-3">Latest</span>-->
</h3>
<span class="block mb-2 text-sm font-normal leading-none text-gray-500 dark:text-gray-300">{{ .org | default .company_name }}</span>
<time class="block mb-3 text-sm font-normal leading-none text-gray-500 dark:text-gray-300">
{{ $start := or .start .date_start }}
{{ $end := or .end .date_end }}
{{ if $start }}
{{ (time $start) | time.Format ($block.design.date_format | default "January 2006") }} –
{{ end }}
{{ if $end }}
{{ (time $end) | time.Format ($block.design.date_format | default "January 2006") }}
{{ else }}
{{ i18n "present" | default "Present" }}
{{end}}
</time>
<div class="mb-4 text-base font-normal text-gray-500 dark:text-gray-300 prose max-w-none prose-slate dark:prose-invert">{{.summary | $page.RenderString}}</div>
<h2 class="mb-10 text-3xl font-bold text-gray-900 dark:text-white text-center">{{ i18n "experience" | default "Experience" }}</h2>
{{/* Timeline container */}}
<div class="relative">
{{/* Gradient timeline line */}}
<div class="absolute left-4 sm:left-6 top-0 bottom-0 w-0.5 bg-gradient-to-b from-primary-500 via-primary-400 to-primary-600/50"></div>
{{/* Experience items */}}
<div class="space-y-8">
{{ range $idx, $item := $experience }}
{{/* Card wrapper */}}
<div class="group relative pl-12 sm:pl-16">
{{/* Timeline marker */}}
<div class="absolute left-0 sm:left-2 top-6 flex items-center justify-center">
{{/* Glowing dot */}}
<div class="relative">
<div class="absolute inset-0 w-8 h-8 bg-primary-500/30 rounded-full blur-md group-hover:bg-primary-500/50 transition-all duration-300"></div>
<div class="relative w-8 h-8 bg-white dark:bg-gray-900 border-2 border-primary-500 rounded-full flex items-center justify-center group-hover:border-primary-400 group-hover:scale-110 transition-all duration-300">
{{ if $item.icon }}
{{ partial "functions/get_icon" (dict "name" $item.icon "attributes" "class='w-4 h-4 text-primary-600 dark:text-primary-400'") }}
{{else}}
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 256 256" class="w-4 h-4 text-primary-600 dark:text-primary-400"><path d="M216,56H176V48a24,24,0,0,0-24-24H104A24,24,0,0,0,80,48v8H40A16,16,0,0,0,24,72V200a16,16,0,0,0,16,16H216a16,16,0,0,0,16-16V72A16,16,0,0,0,216,56ZM96,48a8,8,0,0,1,8-8h48a8,8,0,0,1,8,8v8H96ZM216,72v41.61A184,184,0,0,1,128,136a184.07,184.07,0,0,1-88-22.38V72Zm0,128H40V131.64A200.19,200.19,0,0,0,128,152a200.25,200.25,0,0,0,88-20.37V200ZM104,112a8,8,0,0,1,8-8h32a8,8,0,0,1,0,16H112A8,8,0,0,1,104,112Z"></path></svg>
{{end}}
</div>
</div>
</div>
{{/* Content card */}}
<div class="relative bg-white dark:bg-white/[0.02] backdrop-blur-sm rounded-xl border border-gray-200 dark:border-white/[0.06] p-6 hover:bg-gray-50 dark:hover:bg-white/[0.04] hover:border-primary-300 dark:hover:border-primary-500/30 transition-all duration-300 group-hover:translate-x-1 shadow-sm dark:shadow-none">
{{/* Header row */}}
<div class="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-2 mb-3">
<div>
{{/* Role/Position */}}
<h3 class="text-xl font-bold text-gray-900 dark:text-white group-hover:text-primary-600 dark:group-hover:text-primary-400 transition-colors">
{{ $item.role | default $item.position }}
</h3>
{{/* Company */}}
<p class="text-primary-600 dark:text-primary-400 font-medium mt-1">
{{ $item.org | default $item.company_name }}
</p>
</div>
{{/* Date badge */}}
<div class="flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400 shrink-0">
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
<time>
{{ $start := or $item.start $item.date_start }}
{{ $end := or $item.end $item.date_end }}
{{ if $start }}
{{ (time $start) | time.Format ($block.design.date_format | default "Jan 2006") }} –
{{ end }}
{{ if $end }}
{{ (time $end) | time.Format ($block.design.date_format | default "Jan 2006") }}
{{ else }}
<span class="text-primary-600 dark:text-primary-400 font-medium">{{ i18n "present" | default "Present" }}</span>
{{end}}
</time>
</div>
</div>
{{/* Description */}}
{{ with $item.summary }}
<div class="text-gray-600 dark:text-gray-400 leading-relaxed">
{{ . | $page.RenderString }}
</div>
{{ end }}
{{/* Tags/Skills */}}
{{ with $item.tags }}
<div class="flex flex-wrap gap-2 mt-4">
{{ range . }}
<span class="px-2.5 py-1 text-xs font-medium rounded-full bg-primary-100 dark:bg-primary-500/10 text-primary-700 dark:text-primary-300 border border-primary-200 dark:border-primary-500/20">
{{ . }}
</span>
{{ end }}
</div>
{{ end }}
{{ with .button }}
<a href="{{.url}}" target="_blank" rel="noopener" class="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium text-gray-900 bg-white border border-gray-200 rounded-lg transition-colors hover:bg-gray-100 hover:text-gray-900 focus:z-10 focus:ring-4 focus:outline-none focus:ring-gray-200 focus:text-gray-900 dark:bg-gray-800 dark:text-gray-300 dark:border-gray-600 dark:hover:text-white dark:hover:bg-gray-700 dark:focus:text-white dark:focus:ring-gray-700">
{{ with .icon }}
{{ partial "functions/get_icon" (dict "name" . "attributes" "class='w-4 h-4'") }}
{{ end }}
{{ .text | default "Learn more" }}
</a>
{{/* Action button */}}
{{ with $item.button }}
<div class="mt-4 pt-4 border-t border-gray-100 dark:border-white/5">
<a href="{{.url}}" target="_blank" rel="noopener" class="inline-flex items-center gap-2 text-sm font-medium text-gray-500 dark:text-gray-400 hover:text-primary-600 dark:hover:text-primary-400 transition-colors">
{{ with .icon }}
{{ partial "functions/get_icon" (dict "name" . "attributes" "class='w-4 h-4'") }}
{{ end }}
{{ .text | default "Learn more" }}
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14 5l7 7m0 0l-7 7m7-7H3" />
</svg>
</a>
</div>
{{ end }}
</div>
</div>
{{ end }}
</li>
{{ end }}
</ol>
</div>
</div>
</div>
{{ end }}
{{ if $profile.education }}
<div class="w-full">
<h3 class="mb-6 text-3xl font-bold text-gray-900 dark:text-white text-center">{{ i18n "education" }}</h3>
<ol class="relative w-full border-s border-gray-200 dark:border-gray-700">
{{ range $idx, $key := $profile.education }}
<li class="mb-10 ms-6">
<span class="absolute flex items-center justify-center w-6 h-6 bg-primary-100 rounded-full -start-3 ring-8 ring-white dark:ring-gray-900 dark:bg-primary-900">
{{ if .icon }}
{{ partial "functions/get_icon" (dict "name" .icon "attributes" "class='w-5 h-5 text-primary-800 dark:text-primary-300'") }}
{{else}}
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 256 256" class="w-5 h-5 text-primary-800 dark:text-primary-300"><path d="M251.76,88.94l-120-64a8,8,0,0,0-7.52,0l-120,64a8,8,0,0,0,0,14.12L32,117.87v48.42a15.91,15.91,0,0,0,4.06,10.65C49.16,191.53,78.51,216,128,216a130,130,0,0,0,48-8.76V240a8,8,0,0,0,16,0V199.51a115.63,115.63,0,0,0,27.94-22.57A15.91,15.91,0,0,0,224,166.29V117.87l27.76-14.81a8,8,0,0,0,0-14.12ZM128,200c-43.27,0-68.72-21.14-80-33.71V126.4l76.24,40.66a8,8,0,0,0,7.52,0L176,143.47v46.34C163.4,195.69,147.52,200,128,200Zm80-33.75a97.83,97.83,0,0,1-16,14.25V134.93l16-8.53ZM188,118.94l-.22-.13-56-29.87a8,8,0,0,0-7.52,14.12L171,128l-43,22.93L25,96,128,41.07,231,96Z"></path></svg>
{{end}}
</span>
<h3 class="flex items-center mb-1 text-lg font-semibold text-gray-900 dark:text-white">{{ .degree | default .area }}
<!-- <span class="bg-blue-100 text-blue-800 text-sm font-medium me-2 px-2.5 py-0.5 rounded dark:bg-blue-900 dark:text-blue-300 ms-3">Latest</span>-->
</h3>
<span class="block mb-2 text-sm font-normal leading-none text-gray-500 dark:text-gray-300">{{ .institution }}</span>
<time class="block mb-3 text-sm font-normal leading-none text-gray-500 dark:text-gray-300">
{{ $start := or .start .date_start }}
{{ $end := or .end .date_end }}
{{ if $start }}
{{ (time $start) | time.Format ($block.design.date_format | default "January 2006") }} –
<h2 class="mb-10 text-3xl font-bold text-gray-900 dark:text-white text-center">{{ i18n "education" }}</h2>
{{/* Timeline container */}}
<div class="relative">
{{/* Gradient timeline line */}}
<div class="absolute left-4 sm:left-6 top-0 bottom-0 w-0.5 bg-gradient-to-b from-secondary-500 via-secondary-400 to-secondary-600/50"></div>
{{/* Education items */}}
<div class="space-y-8">
{{ range $idx, $item := $profile.education }}
{{/* Card wrapper */}}
<div class="group relative pl-12 sm:pl-16">
{{/* Timeline marker */}}
<div class="absolute left-0 sm:left-2 top-6 flex items-center justify-center">
{{/* Glowing dot */}}
<div class="relative">
<div class="absolute inset-0 w-8 h-8 bg-secondary-500/30 rounded-full blur-md group-hover:bg-secondary-500/50 transition-all duration-300"></div>
<div class="relative w-8 h-8 bg-white dark:bg-gray-900 border-2 border-secondary-500 rounded-full flex items-center justify-center group-hover:border-secondary-400 group-hover:scale-110 transition-all duration-300">
{{ if $item.icon }}
{{ partial "functions/get_icon" (dict "name" $item.icon "attributes" "class='w-4 h-4 text-secondary-600 dark:text-secondary-400'") }}
{{else}}
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 256 256" class="w-4 h-4 text-secondary-600 dark:text-secondary-400"><path d="M251.76,88.94l-120-64a8,8,0,0,0-7.52,0l-120,64a8,8,0,0,0,0,14.12L32,117.87v48.42a15.91,15.91,0,0,0,4.06,10.65C49.16,191.53,78.51,216,128,216a130,130,0,0,0,48-8.76V240a8,8,0,0,0,16,0V199.51a115.63,115.63,0,0,0,27.94-22.57A15.91,15.91,0,0,0,224,166.29V117.87l27.76-14.81a8,8,0,0,0,0-14.12ZM128,200c-43.27,0-68.72-21.14-80-33.71V126.4l76.24,40.66a8,8,0,0,0,7.52,0L176,143.47v46.34C163.4,195.69,147.52,200,128,200Zm80-33.75a97.83,97.83,0,0,1-16,14.25V134.93l16-8.53ZM188,118.94l-.22-.13-56-29.87a8,8,0,0,0-7.52,14.12L171,128l-43,22.93L25,96,128,41.07,231,96Z"></path></svg>
{{end}}
</div>
</div>
</div>
{{/* Content card */}}
<div class="relative bg-white dark:bg-white/[0.02] backdrop-blur-sm rounded-xl border border-gray-200 dark:border-white/[0.06] p-6 hover:bg-gray-50 dark:hover:bg-white/[0.04] hover:border-secondary-300 dark:hover:border-secondary-500/30 transition-all duration-300 group-hover:translate-x-1 shadow-sm dark:shadow-none">
{{/* Header row */}}
<div class="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-2 mb-3">
<div>
{{/* Degree */}}
<h3 class="text-xl font-bold text-gray-900 dark:text-white group-hover:text-secondary-600 dark:group-hover:text-secondary-400 transition-colors">
{{ $item.degree | default $item.area }}
</h3>
{{/* Institution */}}
<p class="text-secondary-600 dark:text-secondary-400 font-medium mt-1">
{{ $item.institution }}
</p>
</div>
{{/* Date badge */}}
<div class="flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400 shrink-0">
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
<time>
{{ $start := or $item.start $item.date_start }}
{{ $end := or $item.end $item.date_end }}
{{ if $start }}
{{ (time $start) | time.Format ($block.design.date_format | default "Jan 2006") }} –
{{ end }}
{{ if $end }}
{{ (time $end) | time.Format ($block.design.date_format | default "Jan 2006") }}
{{ else }}
<span class="text-primary-600 dark:text-primary-400 font-medium">{{ i18n "present" | default "Present" }}</span>
{{end}}
</time>
</div>
</div>
{{/* Description */}}
{{ with $item.summary }}
<div class="text-gray-600 dark:text-gray-400 leading-relaxed">
{{ . | $page.RenderString }}
</div>
{{ end }}
{{ if $end }}
{{ (time $end) | time.Format ($block.design.date_format | default "January 2006") }}
{{ else }}
{{ i18n "present" | default "Present" }}
{{end}}
</time>
<div class="mb-4 text-base font-normal text-gray-500 dark:text-gray-300 prose max-w-none prose-slate dark:prose-invert">{{.summary | $page.RenderString}}</div>
{{ with .button }}
<a href="{{.url}}" target="_blank" rel="noopener" class="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium text-gray-900 bg-white border border-gray-200 rounded-lg transition-colors hover:bg-gray-100 hover:text-gray-900 focus:z-10 focus:ring-4 focus:outline-none focus:ring-gray-200 focus:text-gray-900 dark:bg-gray-800 dark:text-gray-300 dark:border-gray-600 dark:hover:text-white dark:hover:bg-gray-700 dark:focus:text-white dark:focus:ring-gray-700">
{{ with .icon }}
{{ partial "functions/get_icon" (dict "name" . "attributes" "class='w-4 h-4'") }}
{{/* Action button */}}
{{ with $item.button }}
<div class="mt-4 pt-4 border-t border-gray-100 dark:border-white/5">
<a href="{{.url}}" target="_blank" rel="noopener" class="inline-flex items-center gap-2 text-sm font-medium text-gray-500 dark:text-gray-400 hover:text-secondary-600 dark:hover:text-secondary-400 transition-colors">
{{ with .icon }}
{{ partial "functions/get_icon" (dict "name" . "attributes" "class='w-4 h-4'") }}
{{ end }}
{{ .text | default "Learn more" }}
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14 5l7 7m0 0l-7 7m7-7H3" />
</svg>
</a>
</div>
{{ end }}
{{ .text | default "Learn more" }}
</a>
{{ end }}
</li>
</div>
</div>
{{ end }}
</ol>
</div>
</div>
</div>
{{ end }}
+108
View File
@@ -0,0 +1,108 @@
# Tech Stack Block
A visual grid display of technologies and skills organized by category, with icon support and optional proficiency indicators.
## Features
- **Category Organization**: Group technologies by type (Languages, Frontend, Backend, etc.)
- **Icon Support**: DevIcon, brand icons, or Heroicons integration
- **Proficiency Levels**: Optional skill level indicators (expert, advanced, intermediate, beginner)
- **Glass Morphism**: Modern card design with hover effects
- **Responsive Grid**: Adapts from 2 to 6 columns based on screen size
- **Multiple Styles**: Grid or flat list layout options
## Usage
```yaml
- block: tech-stack
id: skills
content:
title: "Tech Stack"
subtitle: "Technologies I use to build things"
categories:
- name: Languages
items:
- name: TypeScript
icon: devicon/typescript
level: expert
- name: Python
icon: devicon/python
level: advanced
- name: Go
icon: devicon/go
level: intermediate
- name: Frontend
items:
- name: React
icon: devicon/react
- name: Next.js
icon: devicon/nextjs
- name: Tailwind CSS
icon: devicon/tailwindcss
- name: Backend
items:
- name: Node.js
icon: devicon/nodejs
- name: PostgreSQL
icon: devicon/postgresql
- name: Redis
icon: devicon/redis
- name: DevOps
items:
- name: Docker
icon: devicon/docker
- name: AWS
icon: devicon/amazonwebservices
- name: GitHub Actions
icon: brands/github
design:
style: grid
show_levels: true
```
## Parameters
### Content
| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `title` | string | - | Section title |
| `subtitle` | string | - | Section subtitle |
| `categories` | array | required | Array of category objects |
| `categories[].name` | string | - | Category heading |
| `categories[].items` | array | required | Technologies in category |
| `categories[].items[].name` | string | required | Technology name |
| `categories[].items[].icon` | string | - | Icon identifier |
| `categories[].items[].level` | string | - | Proficiency level |
### Design
| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `style` | string | `grid` | Display style (`grid` or `list`) |
| `show_levels` | boolean | `false` | Show proficiency indicators |
## Icon Support
The block supports multiple icon sources:
- **DevIcon**: `devicon/react`, `devicon/typescript`, `devicon/nodejs`
- **Brand Icons**: `brands/github`, `brands/linkedin`
- **Heroicons**: `code-bracket`, `server`, `cloud`
## Proficiency Levels
| Level | Color | Bar Width |
|-------|-------|-----------|
| `expert` | Emerald | 100% |
| `advanced` | Primary | 75% |
| `intermediate` | Amber | 50% |
| `beginner` | Gray | 25% |
## Styling
The block uses glass morphism styling with:
- Semi-transparent backgrounds
- Backdrop blur effects
- Subtle borders that glow on hover
- Gradient hover effects
+130
View File
@@ -0,0 +1,130 @@
{{/* Hugo Blox: Tech Stack */}}
{{/* Documentation: https://hugoblox.com/blocks/ */}}
{{/* License: https://github.com/HugoBlox/kit/blob/main/LICENSE.md */}}
{{/* Initialise */}}
{{ $page := .wcPage }}
{{ $block := .wcBlock }}
{{/* Enable animations */}}
{{ if ne $block.design.animations false }}
{{ $page.Store.Set "has_animations" true }}
{{ $page.Store.Set "has_alpine" true }}
{{ end }}
{{/* Style */}}
{{ $style := $block.design.style | default "grid" }}
{{ $show_levels := $block.design.show_levels | default false }}
<div class="mx-auto max-w-6xl px-4 sm:px-6 lg:px-8 py-16">
{{/* Header */}}
<div class="text-center mb-12">
{{ with $block.content.title }}
<h2 class="text-3xl sm:text-4xl font-bold text-gray-900 dark:text-white mb-4">
{{ . | emojify | $page.RenderString }}
</h2>
{{ end }}
{{ with $block.content.subtitle }}
<p class="text-lg text-gray-500 dark:text-gray-400 max-w-2xl mx-auto">
{{ . | emojify | $page.RenderString }}
</p>
{{ end }}
</div>
{{/* Categories */}}
<div class="space-y-12">
{{ range $block.content.categories }}
<div>
{{/* Category Title */}}
{{ with .name }}
<h3 class="text-xs font-semibold uppercase tracking-widest text-primary-600 dark:text-primary-400 mb-6 flex items-center justify-center gap-4">
<span class="h-px w-12 bg-gradient-to-r from-transparent to-primary-500/50"></span>
<span>{{ . }}</span>
<span class="h-px w-12 bg-gradient-to-l from-transparent to-primary-500/50"></span>
</h3>
{{ end }}
{{/* Tech Items - Centered flex layout */}}
<div class="flex flex-wrap justify-center gap-4" data-stagger="50">
{{ range $idx, $item := .items }}
{{ $level := $item.level | default "" }}
{{ $level_width := "0%" }}
{{ $level_color := "bg-gray-500" }}
{{ if eq $level "expert" }}
{{ $level_width = "100%" }}
{{ $level_color = "bg-emerald-500" }}
{{ else if eq $level "advanced" }}
{{ $level_width = "75%" }}
{{ $level_color = "bg-primary-500" }}
{{ else if eq $level "intermediate" }}
{{ $level_width = "50%" }}
{{ $level_color = "bg-amber-500" }}
{{ else if eq $level "beginner" }}
{{ $level_width = "25%" }}
{{ $level_color = "bg-gray-400" }}
{{ end }}
<div
class="group relative w-[calc(50%-0.5rem)] sm:w-[calc(33.333%-0.75rem)] md:w-[calc(25%-0.75rem)] lg:w-[140px] bg-gray-50 dark:bg-white/[0.03] backdrop-blur-sm rounded-xl border border-gray-200 dark:border-white/[0.06] p-4 hover:bg-gray-100 dark:hover:bg-white/[0.06] hover:border-primary-300 dark:hover:border-primary-500/30 transition-all duration-300 hover:shadow-lg hover:shadow-primary-500/5 scale-hover"
>
{{/* Icon */}}
<div class="flex flex-col items-center text-center">
{{ with $item.icon }}
{{/* Add dark-icon class for icons that need inversion in dark mode */}}
{{ $dark_icons := slice "express" "vercel" "github" "nextjs" }}
{{ $icon_name := . }}
{{ $needs_invert := false }}
{{ range $dark_icons }}
{{ if in $icon_name . }}{{ $needs_invert = true }}{{ end }}
{{ end }}
<div class="w-12 h-12 mb-3 flex items-center justify-center transition-all duration-300 icon-bounce {{ if $needs_invert }}dark-mode-icon{{ end }}">
{{ partial "functions/get_icon" (dict "name" . "attributes" "class=\"w-10 h-10\"") }}
</div>
{{ end }}
{{/* Name */}}
<span class="text-sm font-medium text-gray-700 dark:text-gray-200 group-hover:text-gray-900 dark:group-hover:text-white transition-colors">
{{ $item.name }}
</span>
{{/* Level Indicator */}}
{{ if and $show_levels $level }}
<div class="mt-2 w-full">
<div class="h-1 bg-gray-200 dark:bg-white/10 rounded-full overflow-hidden">
<div class="h-full {{ $level_color }} rounded-full transition-all duration-500" style="width: {{ $level_width }}"></div>
</div>
<span class="text-xs text-gray-500 mt-1 capitalize">{{ $level }}</span>
</div>
{{ end }}
</div>
{{/* Hover glow effect */}}
<div class="absolute inset-0 rounded-xl bg-gradient-to-br from-primary-500/0 via-primary-500/0 to-secondary-500/0 group-hover:from-primary-500/5 group-hover:to-secondary-500/5 transition-all duration-500 pointer-events-none"></div>
</div>
{{ end }}
</div>
</div>
{{ end }}
</div>
{{/* Simple flat list style alternative */}}
{{ if eq $style "list" }}
<div class="flex flex-wrap justify-center gap-3 mt-8">
{{ range $block.content.categories }}
{{ range $i, $tech := .items }}
<div
class="inline-flex items-center gap-2 px-4 py-2 bg-gray-50 dark:bg-white/[0.03] backdrop-blur-sm rounded-full border border-gray-200 dark:border-white/[0.08] hover:border-primary-300 dark:hover:border-primary-500/30 hover:bg-gray-100 dark:hover:bg-white/[0.06] transition-all duration-300 scale-hover"
>
{{ with $tech.icon }}
<span class="text-gray-600 dark:text-gray-400">
{{ partial "functions/get_icon" (dict "name" . "attributes" "class=\"w-5 h-5\"") }}
</span>
{{ end }}
<span class="text-sm font-medium text-gray-700 dark:text-gray-200">{{ $tech.name }}</span>
</div>
{{ end }}
{{ end }}
</div>
{{ end }}
</div>
@@ -0,0 +1,13 @@
{
"id": "tech-stack",
"name": "Tech Stack",
"version": "1.0.0",
"license": "MIT",
"category": "content",
"tags": ["tech-stack", "skills", "technologies", "icons", "developer", "showcase"],
"description": "Display technologies and skills with icons organized by category, featuring hover effects and optional proficiency indicators",
"author": "Hugo Blox",
"homepage": "https://hugoblox.com/blocks/",
"repository": "https://github.com/HugoBlox/kit",
"keywords": ["hugo", "tech-stack", "skills", "technologies", "developer", "portfolio"]
}
+142
View File
@@ -0,0 +1,142 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"$id": "https://hugoblox.com/schemas/blocks/tech-stack.json",
"title": "Tech Stack Block Schema",
"description": "Schema for the Tech Stack block - displays technologies with icons organized by category",
"allOf": [
{
"$ref": "../shared/schemas/base-block.json"
},
{
"type": "object",
"properties": {
"block": {
"const": "tech-stack",
"description": "Block type identifier"
},
"content": {
"type": "object",
"description": "Content configuration for the tech stack block",
"properties": {
"title": {
"type": "string",
"description": "Section title (supports Markdown)",
"examples": ["Tech Stack", "Skills & Technologies"]
},
"subtitle": {
"type": "string",
"description": "Section subtitle (supports Markdown)",
"examples": ["Technologies I work with daily"]
},
"categories": {
"type": "array",
"description": "Categories of technologies",
"items": {
"type": "object",
"properties": {
"name": {
"type": "string",
"description": "Category name",
"examples": ["Languages", "Frontend", "Backend", "DevOps"]
},
"items": {
"type": "array",
"description": "Technologies in this category",
"items": {
"type": "object",
"properties": {
"name": {
"type": "string",
"description": "Technology name",
"examples": ["TypeScript", "React", "Node.js"]
},
"icon": {
"type": "string",
"description": "Icon identifier (supports devicon/, brands/, or hero icons)",
"examples": ["devicon/typescript", "devicon/react", "brands/github"]
},
"level": {
"type": "string",
"description": "Proficiency level (optional)",
"enum": ["expert", "advanced", "intermediate", "beginner"],
"examples": ["expert", "advanced"]
}
},
"required": ["name"]
}
}
},
"required": ["items"]
},
"examples": [
[
{
"name": "Languages",
"items": [
{ "name": "TypeScript", "icon": "devicon/typescript", "level": "expert" },
{ "name": "Python", "icon": "devicon/python", "level": "advanced" }
]
}
]
]
}
},
"required": ["categories"],
"additionalProperties": false
},
"design": {
"type": "object",
"description": "Layout options for tech stack rendering",
"properties": {
"style": {
"type": "string",
"description": "Display style",
"enum": ["grid", "list"],
"default": "grid"
},
"show_levels": {
"type": "boolean",
"description": "Show proficiency level indicators",
"default": false
}
},
"additionalProperties": true
}
},
"required": ["content"],
"additionalProperties": true,
"examples": [
{
"block": "tech-stack",
"id": "skills",
"content": {
"title": "Tech Stack",
"subtitle": "Technologies I use to build things",
"categories": [
{
"name": "Languages",
"items": [
{ "name": "TypeScript", "icon": "devicon/typescript", "level": "expert" },
{ "name": "Python", "icon": "devicon/python", "level": "advanced" },
{ "name": "Go", "icon": "devicon/go", "level": "intermediate" }
]
},
{
"name": "Frontend",
"items": [
{ "name": "React", "icon": "devicon/react" },
{ "name": "Next.js", "icon": "devicon/nextjs" },
{ "name": "Tailwind CSS", "icon": "devicon/tailwindcss" }
]
}
]
},
"design": {
"style": "grid",
"show_levels": true
}
}
]
}
]
}
+30
View File
@@ -263,3 +263,33 @@
- id: published_with
translation: '{hugoblox}-এর সাথে প্রকাশিত – বিনামূল্যে, {repo_link}ওপেন সোর্স{/repo_link} ওয়েবসাইট নির্মাতা যা সৃজনকারীদের ক্ষমতায়ন করে।'
# Contact info block
- id: block_contact_follow_me
translation: আমাকে খুঁজুন
# Dev Hero block
- id: scroll_to_content
translation: কনটেন্টে স্ক্রোল করুন
- id: developer
translation: ডেভেলপার
# Portfolio block
- id: portfolio_link_code
translation: কোড
- id: portfolio_link_live
translation: লাইভ
- id: portfolio_link_demo
translation: ডেমো
- id: portfolio_link_default
translation: লিংক
- id: portfolio_view_all
translation: সমস্ত প্রকল্প দেখুন
+30
View File
@@ -344,6 +344,36 @@
- id: poweredby_button
translation: 'Erstelle deine →'
# Contact info block
- id: block_contact_follow_me
translation: Finde mich auf
# Dev Hero block
- id: scroll_to_content
translation: Zum Inhalt scrollen
- id: developer
translation: Entwickler
# Portfolio block
- id: portfolio_link_code
translation: Code
- id: portfolio_link_live
translation: Live
- id: portfolio_link_demo
translation: Demo
- id: portfolio_link_default
translation: Link
- id: portfolio_view_all
translation: Alle Projekte anzeigen
# Feedback widget
- id: feedback_widget_title
+28
View File
@@ -283,6 +283,9 @@
- id: block_contact_follow_us
translation: Follow Us
- id: block_contact_follow_me
translation: Find me on
- id: block_contact_prospective_title
translation: Prospective Members
@@ -462,6 +465,31 @@
- id: poweredby_button
translation: 'Build yours →'
# Dev Hero block
- id: scroll_to_content
translation: Scroll to content
- id: developer
translation: Developer
# Portfolio block
- id: portfolio_link_code
translation: Code
- id: portfolio_link_live
translation: Live
- id: portfolio_link_demo
translation: Demo
- id: portfolio_link_default
translation: Link
- id: portfolio_view_all
translation: View All Projects
# Feedback widget
- id: feedback_widget_title
+30
View File
@@ -307,3 +307,33 @@
- id: poweredby_button
translation: 'Bouw de jouwe →'
# Contact info block
- id: block_contact_follow_me
translation: Vind mij op
# Dev Hero block
- id: scroll_to_content
translation: Scroll naar inhoud
- id: developer
translation: Ontwikkelaar
# Portfolio block
- id: portfolio_link_code
translation: Code
- id: portfolio_link_live
translation: Live
- id: portfolio_link_demo
translation: Demo
- id: portfolio_link_default
translation: Link
- id: portfolio_view_all
translation: Bekijk alle projecten
+30
View File
@@ -329,3 +329,33 @@
- id: poweredby_button
translation: '构建您的 →'
# Contact info block
- id: block_contact_follow_me
translation: 在这里找到我
# Dev Hero block
- id: scroll_to_content
translation: 滚动到内容
- id: developer
translation: 开发者
# Portfolio block
- id: portfolio_link_code
translation: 代码
- id: portfolio_link_live
translation: 在线
- id: portfolio_link_demo
translation: 演示
- id: portfolio_link_default
translation: 链接
- id: portfolio_view_all
translation: 查看所有项目
@@ -0,0 +1,7 @@
{{/* Hugo Blox: Contact Info - config */}}
{{/* Purpose: pre-declare dependencies before <head> libraries load */}}
{{ $page := .wcPage }}
{{/* Contact block uses Alpine for copy-to-clipboard + small interactions */}}
{{ $page.Store.Set "has_alpine" true }}
@@ -0,0 +1,13 @@
{{/* Hugo Blox: Dev Hero - config */}}
{{/* Purpose: pre-declare dependencies before <head> libraries load */}}
{{ $page := .wcPage }}
{{ $block := .wcBlock }}
{{ $typewriter := $block.content.typewriter | default dict }}
{{ $has_animations := or ($block.design.animations | default false) ($typewriter.enable | default false) }}
{{ if $has_animations }}
{{ $page.Store.Set "has_animations" true }}
{{ $page.Store.Set "has_alpine" true }}
{{ end }}
@@ -0,0 +1,13 @@
{{/* Hugo Blox: Portfolio - config */}}
{{/* Purpose: pre-declare dependencies before <head> libraries load */}}
{{ $page := .wcPage }}
{{ $block := .wcBlock }}
{{/* Portfolio always needs Alpine for filtering */}}
{{ $page.Store.Set "has_alpine" true }}
{{/* Enable scroll/transition helpers if animations not explicitly disabled */}}
{{ if ne $block.design.animations false }}
{{ $page.Store.Set "has_animations" true }}
{{ end }}
@@ -0,0 +1,10 @@
{{/* Hugo Blox: Tech Stack - config */}}
{{/* Purpose: pre-declare dependencies before <head> libraries load */}}
{{ $page := .wcPage }}
{{ $block := .wcBlock }}
{{ if ne $block.design.animations false }}
{{ $page.Store.Set "has_animations" true }}
{{ $page.Store.Set "has_alpine" true }}
{{ end }}
+11 -1
View File
@@ -32,9 +32,19 @@
{{/* Show site search? */}}
{{ $show_search := site.Params.hugoblox.header.search | default false }}
{{/* Hugo Blox Animations - load synchronously BEFORE Alpine.js to register components */}}
{{ if .Page.Store.Get "has_animations" }}
{{ $animations_js := resources.Get "js/hb-animations.js" | resources.Minify }}
{{- if hugo.IsProduction -}}
{{ $animations_js = $animations_js | fingerprint "sha256" }}
{{- end -}}
<script src="{{ $animations_js.RelPermalink }}"{{ if hugo.IsProduction }} integrity="{{ $animations_js.Data.Integrity }}"{{ end }}></script>
{{ end }}
{{/* Load Alpine JS extension? */}}
{{/* Alpine is needed for search modal and other interactive components */}}
{{ if or (.Page.Store.Get "has_alpine") $show_search }}
{{ $needs_alpine := or (.Page.Store.Get "has_alpine") $show_search (.Page.Store.Get "has_animations") }}
{{ if $needs_alpine }}
<script>console.log('✓ Alpine.js loading on demand');</script>
{{ $alpine_js := resources.Get "dist/lib/alpinejs/cdn.min.js" }}
{{ $alpine_js = $alpine_js | resources.Fingerprint "sha256" }}
+18 -8
View File
@@ -24,7 +24,7 @@
{{ $event_dates = partial "functions/get_event_dates" $item }}
{{ end }}
<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 }}">
<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-primary-500/10 hover:-translate-y-2 focus-within:ring-2 focus-within:ring-primary-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 }}
@@ -36,7 +36,17 @@
{{ end }}
{{ end }}
{{ with $resource }}
{{ if ne .MediaType.SubType "gif" }}
{{ if strings.Contains .MediaType.SubType "svg" }}
<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 }}"
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 if ne .MediaType.SubType "gif" }}
{{ $original_image := "" }}
{{ if $fill_image }}
{{ $original_image = .Fill (printf "800x450 %s" $anchor) }}
@@ -75,11 +85,11 @@
</a>
{{ end }}
{{ else }}
<!-- Fallback gradient with subtle pattern - make clickable for consistency -->
<!-- Fallback gradient using theme colors - make clickable for consistency -->
<a href="{{ $link }}" {{ $target | safeHTMLAttr }} class="block w-full h-full">
<div class="w-full h-full bg-gradient-to-br from-blue-500/20 to-purple-500/20 flex items-center justify-center hover:from-blue-500/30 hover:to-purple-500/30 transition-colors duration-300">
<div class="w-full h-full bg-gradient-to-br from-primary-600/40 via-secondary-600/30 to-primary-800/50 flex items-center justify-center transition-all duration-300 group-hover:from-primary-500/50 group-hover:via-secondary-500/40 group-hover:to-primary-700/60">
<div class="w-16 h-16 rounded-full bg-white/20 backdrop-blur-sm flex items-center justify-center group-hover:scale-110 transition-transform duration-300">
<svg class="w-8 h-8 text-white/60" fill="currentColor" viewBox="0 0 20 20">
<svg class="w-8 h-8 text-white/40" 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>
@@ -97,7 +107,7 @@
{{ 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 class="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-primary-100 text-primary-800 dark:bg-primary-900/40 dark:text-primary-300">
{{ . }}
</span>
{{ end }}
@@ -120,7 +130,7 @@
</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">
<h3 id="card-title-{{ $item.File.UniqueID }}" class="text-xl font-bold tracking-tight text-zinc-900 dark:text-zinc-100 group-hover:text-primary-600 dark:group-hover:text-primary-400 transition-colors duration-200 leading-tight">
<a href="{{ $link }}" {{ $target | safeHTMLAttr }} class="hover:underline">
{{ $item.Title }}
{{ if $item.Params.external_link }}
@@ -207,7 +217,7 @@
<!-- 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">
<div class="flex items-center gap-2 pt-2 text-xs text-primary-600 dark:text-primary-400 bg-primary-50 dark:bg-primary-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>
+14 -1
View File
@@ -282,7 +282,20 @@
{{end}}
{{/* Process featured image using utility with placement-specific constraints */}}
{{ if ne $featured.MediaType.SubType "gif" }}
{{ if strings.Contains $featured.MediaType.SubType "svg" }}
{{/* SVGs cannot be processed by Hugo (Fit/Fill). Render directly. */}}
<div class="article-header {{$image_container}} featured-image-wrapper mt-4 mb-16">
<div style="position: relative">
<img src="{{ $featured.RelPermalink }}"
width="100%"
data-pagefind-meta="image[src]"
alt="{{ with $image_params.alt_text }}{{.}}{{ end }}"
class="featured-image"
fetchpriority="high">
{{ with $image_params.caption }}<span class="article-header-caption">{{ . | markdownify | emojify }}</span>{{ end }}
</div>
</div>
{{ else if ne $featured.MediaType.SubType "gif" }}
{{/* Pre-fit image to placement constraints, then apply responsive processing */}}
{{ $fitted_image := $featured }}
{{ if eq $placement 2}}
+13 -1
View File
@@ -179,7 +179,19 @@
{{end}}
{{/* Process featured image using utility with placement-specific constraints */}}
{{ if ne $featured.MediaType.SubType "gif" }}
{{ if strings.Contains $featured.MediaType.SubType "svg" }}
{{/* SVGs cannot be processed by Hugo (Fit/Fill). Render directly. */}}
<div class="article-header {{$image_container}} featured-image-wrapper mt-4 mb-16">
<div style="position: relative">
<img src="{{ $featured.RelPermalink }}"
data-pagefind-meta="image[src]"
alt="{{ with $image_params.alt_text }}{{.}}{{ end }}"
class="featured-image"
fetchpriority="high">
{{ with $image_params.caption }}<span class="article-header-caption">{{ . | markdownify | emojify }}</span>{{ end }}
</div>
</div>
{{ else if ne $featured.MediaType.SubType "gif" }}
{{/* Pre-fit image to placement constraints, then apply responsive processing */}}
{{ $fitted_image := $featured }}
{{ if eq $placement 2}}