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}}
@@ -0,0 +1,25 @@
{
"name": "HugoBlox Codespace",
"image": "ghcr.io/HugoBlox/hugo-blox-dev:hugo0.154.2",
"updateContentCommand": "pnpm install --frozen-lockfile --prefer-offline",
"postCreateCommand": "pnpm --version && hugo version",
"customizations": {
"vscode": {
"extensions": [
"hugoblox.hugoblox-studio"
]
}
},
"mounts": [
"source=pnpm-store,target=/home/vscode/.local/share/pnpm,type=volume"
],
"remoteUser": "vscode",
"forwardPorts": [
1313
],
"portsAttributes": {
"1313": {
"label": "Hugo Server"
}
}
}
+2
View File
@@ -0,0 +1,2 @@
github: gcushen
custom: https://hugoblox.com/sponsor/
Binary file not shown.

After

Width:  |  Height:  |  Size: 485 KiB

+125
View File
@@ -0,0 +1,125 @@
name: Deploy website to GitHub Pages
env:
NODE_VERSION: '20'
on:
# Trigger the workflow every time you push to the `main` branch
push:
branches: ['main']
# Allows you to run this workflow manually from the Actions tab on GitHub
workflow_dispatch:
# Provide permission to clone the repo and deploy it to GitHub Pages
permissions:
contents: read
pages: write
id-token: write
concurrency:
group: 'pages'
cancel-in-progress: false
jobs:
# Build website
build:
if: github.repository_owner != 'HugoBlox'
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
# Fetch history for Hugo's .GitInfo and .Lastmod
fetch-depth: 0
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
- name: Setup pnpm
if: hashFiles('package.json') != ''
uses: pnpm/action-setup@v4
- name: Get Hugo Version
id: hugo-version
run: |
VERSION=$(grep "hugo_version" hugoblox.yaml | awk '{print $2}' | tr -d "'\"")
echo "HUGO_VERSION=$VERSION" >> $GITHUB_ENV
- name: Install dependencies
run: |
# Install Tailwind CLI if package.json exists
if [ -f "package.json" ]; then
echo "Installing Tailwind dependencies..."
pnpm install --no-frozen-lockfile || npm install
fi
- name: Setup Hugo
uses: peaceiris/actions-hugo@v3
with:
hugo-version: ${{ env.HUGO_VERSION }}
extended: true
# Cache dependencies (Go modules, node_modules) - stable, rarely changes
- uses: actions/cache@v4
with:
path: |
/tmp/hugo_cache_runner/
node_modules/
modules/*/node_modules/
key: ${{ runner.os }}-hugo-deps-${{ hashFiles('**/go.mod', '**/package-lock.json',
'**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-hugo-deps-
# Cache Hugo resources (processed images, CSS) - invalidates only when assets/config change
- uses: actions/cache@v4
with:
path: resources/
key: ${{ runner.os }}-hugo-resources-${{ hashFiles('assets/**/*', 'config/**/*',
'hugo.yaml', 'package.json') }}
restore-keys: |
${{ runner.os }}-hugo-resources-
- name: Setup Pages
id: pages
uses: actions/configure-pages@v5
- name: Build with Hugo
env:
HUGO_ENVIRONMENT: production
HUGO_BLOX_LICENSE: ${{ secrets.HUGO_BLOX_LICENSE }}
run: |
echo "Hugo Cache Dir: $(hugo config | grep cachedir)"
hugo --minify --baseURL "${{ steps.pages.outputs.base_url }}/"
- name: Generate Pagefind search index (if applicable)
run: |
# Check if site uses Pagefind search
if [ -f "package.json" ] && grep -q "pagefind" package.json; then
pnpm run pagefind || npx pagefind --site "public"
fi
- name: Upload artifact
uses: actions/upload-pages-artifact@v4
with:
path: ./public
# Deploy website to GitHub Pages hosting
deploy:
if: github.repository_owner != 'HugoBlox'
needs: build
# Grant GITHUB_TOKEN the permissions required to make a Pages deployment
permissions:
pages: write # to deploy to Pages
id-token: write # to verify the deployment originates from an appropriate source
# Deploy to the github-pages environment
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
runs-on: ubuntu-latest
steps:
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v4
+69
View File
@@ -0,0 +1,69 @@
# ============================================================================
# Hugo Site .gitignore
# ============================================================================
# ============================================================================
# Hugo Build Artifacts
# ============================================================================
# Generated site output
public/
# Hugo resources (processed assets)
resources/
# Hugo build lock
.hugo_build.lock
# Hugo stats
hugo_stats.json
# Auto-generated JS config
**/assets/jsconfig.json
# ============================================================================
# Node.js & Package Managers
# ============================================================================
# Dependencies
node_modules/
# ============================================================================
# Environment & Configuration
# ============================================================================
# Environment variables
.env
.env.local
.env.*.local
# ============================================================================
# Generated Content
# ============================================================================
# Pagefind search index
pagefind/
static/pagefind/
# ============================================================================
# Operating System
# ============================================================================
# macOS
.DS_Store
.DS_Store?
._*
# Windows
Thumbs.db
Desktop.ini
# Linux
*~
# ============================================================================
# Logs & Temporary Files
# ============================================================================
*.log
npm-debug.log*
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2023-present George Cushen (https://neuralgeo.com/)
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+124
View File
@@ -0,0 +1,124 @@
# [Developer Portfolio (Free)](https://hugoblox.com/templates/dev-portfolio/start?utm_source=github&utm_medium=readme)
[![Screenshot](.github/preview.png)](https://hugoblox.com/templates/dev-portfolio/start?utm_source=github&utm_medium=readme)
<h1 align="center">The Dev Portfolio That Gets You Hired</h1>
<p align="center">
<strong>Built for uni students, STEM grads, and software engineers.</strong><br/>
Showcase real projects, ship a clean blog, and stand out with a modern dark-first design — without a fragile “black box” React app.
</p>
<p align="center">
<a href="https://hugoblox.com/templates/dev-portfolio/start?utm_source=github&utm_medium=readme"><b>🚀 Deploy in 60s (Free)</b></a>
&nbsp;•&nbsp;
<a href="https://hugoblox.com/templates/?open=dev-portfolio&loading=true&utm_source=github&utm_medium=readme">Live Demo</a>
&nbsp;•&nbsp;
<a href="https://docs.hugoblox.com/guides/studio/">HugoBlox Studio (Visual Editor)</a>
</p>
<!-- 12s preview placeholder (add file and update link) -->
<!--
<p align="center">
<a href="./.github/preview.mp4"><b>▶︎ Watch 12s Preview</b></a>
</p>
-->
<p align="center">
<a href="https://discord.gg/z8wNYzb"><img src="https://img.shields.io/discord/722225264733716590?label=Join%20Discord&style=social" alt="Discord"></a>
<a href="https://github.com/HugoBlox/kit"><img src="https://img.shields.io/github/stars/HugoBlox/kit?label=Star%20HugoBlox&style=social" alt="GitHub Stars"></a>
<a href="https://x.com/BuildLore"><img src="https://img.shields.io/twitter/follow/BuildLore?label=Follow&style=social" alt="Follow on X"></a>
</p>
---
## Why this template?
Most portfolios look the same — and most “quick” site builders produce slow sites you can’t maintain.
- **⚡ Fast by default**: Hugo + Tailwind 4 + Hugo Modules → great Lighthouse scores without JS bloat.
- **🧠 AI-ready content**: Your projects and posts live in clean Markdown/YAML — readable by humans and LLMs.
- **🎯 Job-hunting optimized**: Projects, tech stack, experience timeline, and a blog layout that looks credible.
- **🌙 Dark-first (but light-mode supported)**: Looks premium in dark mode, still works perfectly in light mode.
- **🧩 Block-based**: Reorder/replace sections without rewriting templates.
---
## Who it’s for
- **Students & grads** applying to internships / MSc / PhD / junior roles
- **STEM professionals** (research, data, ML, bio, engineering) showcasing projects and writing
- **Software engineers** building a portfolio that feels modern in 2026
---
## What you get (Free)
- **Developer hero** with modern gradients + optional typewriter
- **Filterable portfolio** (tags + smooth transitions)
- **Tech stack** grid with icons
- **Experience + education timeline**
- **Blog section** (cards + featured images)
- **Contact section** with social links + copy email
---
## Get started (recommended)
### Option 1 — Online deployer (fastest)
Deploy this template in ~60 seconds:
- [Deploy in browser →](https://hugoblox.com/templates/dev-portfolio/start?utm_source=github&utm_medium=readme)
### Option 2 — HugoBlox Studio (visual editor in VS Code)
- [Studio guide →](https://docs.hugoblox.com/guides/studio/?utm_source=github&utm_medium=readme)
- [Studio AI automation plans (Free/Plus/Pro) →](https://hugoblox.com/pricing/?utm_source=github&utm_medium=readme)
### Option 3 — HugoBlox CLI (developer workflow)
Install:
```bash
npm install -g hugoblox@latest
```
Create a site:
```bash
hugoblox create site --template dev-portfolio
```
[CLI docs →](https://docs.hugoblox.com/start/cli/?utm_source=github&utm_medium=readme)
---
## Customize in 10 minutes
- **Your profile**: `data/authors/me.yaml`
- **Homepage sections**: `content/_index.md`
- **Projects**: `content/projects/<project>/index.md`
- **Blog posts**: `content/blog/<post>/index.md`
- **Theme + identity**: `config/_default/params.yaml`
---
## Want the Pro version? 💎
If you want a version that feels meaningfully more premium (and helps fund open source + research):
- [Premium template →](https://hugoblox.com/templates/dev-portfolio-pro/start?utm_source=github&utm_medium=readme)
- [HugoBlox Pro / Studio →](https://hugoblox.com/pricing?utm_source=github&utm_medium=readme)
---
## Community & support
- **Docs**: [docs.hugoblox.com →](https://docs.hugoblox.com/?utm_source=github&utm_medium=readme)
- **Discord**: [Join Discord →](https://discord.gg/z8wNYzb)
- **Issues**: [Open an issue →](https://github.com/HugoBlox/kit/issues)
---
MIT © 2016–Present [George Cushen](https://neuralgeo.com/?utm_source=github&utm_medium=readme)
Binary file not shown.

After

Width:  |  Height:  |  Size: 125 KiB

Whitespace-only changes.
@@ -0,0 +1,55 @@
# Configuration of Hugo
# Guide: https://docs.hugoblox.com/tutorial/
# Hugo Documentation: https://gohugo.io/getting-started/configuration/#all-configuration-settings
# This file is formatted using YAML syntax - learn more at https://learnxinyminutes.com/docs/yaml/
# Website name (set in params.yaml under hugoblox.branding.name)
title: ''
# Website URL
baseURL: 'https://example.com/'
############################
## LANGUAGE
############################
defaultContentLanguage: en
hasCJKLanguage: false
defaultContentLanguageInSubdir: false
removePathAccents: true
############################
## ADVANCED
############################
build:
writeStats: true
enableGitInfo: false
summaryLength: 30
pagination:
pagerSize: 10
enableEmoji: true
enableRobotsTXT: true
footnotereturnlinkcontents: <sup>^</sup>
ignoreFiles: [".ipynb_checkpoints$", "\\.Rmd$", "\\.Rmarkdown$", "_cache$"]
disableAliases: true
outputs:
home: [HTML, RSS, headers, redirects, backlinks]
section: [HTML, RSS]
imaging:
resampleFilter: lanczos
quality: 90
anchor: smart
hint: picture
timeout: 600000
taxonomies:
author: authors
tag: tags
publication_type: publication_types
markup:
_merge: deep
highlight:
lineNos: false
security:
_merge: deep
sitemap:
_merge: deep
@@ -0,0 +1,22 @@
# Languages
# Create a section for each of your site's languages.
# Documentation: https://docs.hugoblox.com/reference/language/
# Default language
en:
languageCode: en-us
# Uncomment for multi-lingual sites, and move English content into `en` sub-folder.
#contentDir: content/en
# Uncomment the lines below to configure your website in a second language.
#zh:
# languageCode: zh-Hans
# contentDir: content/zh
# title: Chinese website title...
# params:
# description: Site description in Chinese...
# menu:
# main:
# - name: 传
# url: '#about'
# weight: 1
@@ -0,0 +1,20 @@
# Navigation Links
# To link a homepage section, use a hash `#` followed by the section ID.
# The weight parameter defines the order that the links appear.
main:
- name: Projects
url: /#projects
weight: 10
- name: Skills
url: /#skills
weight: 20
- name: Experience
url: /#experience
weight: 30
- name: Blog
url: /#blog
weight: 40
- name: Contact
url: /#contact
weight: 50
@@ -0,0 +1,26 @@
############################
## HUGO MODULES
## Install or uninstall themes and plugins here.
## Docs: https://gohugo.io/hugo-modules/
############################
imports:
- path: github.com/HugoBlox/kit/modules/integrations/netlify
- path: github.com/HugoBlox/kit/modules/blox
- path: github.com/HugoBlox/kit/modules/slides
# Install any Hugo Blox within the `hugo-blox/blox/` folder
mounts:
- source: hugo-blox/blox/community
target: layouts/_partials/blox/community/
includeFiles: '**.html'
- source: hugo-blox/blox/all-access
target: layouts/_partials/blox/
includeFiles: '**.html'
- source: hugo-blox/blox
target: assets/dist/community/blox/
includeFiles: '**.css'
- source: layouts
target: layouts
- source: assets
target: assets
@@ -0,0 +1,291 @@
# ══════════════════════════════════════════════════════════════════════════════
# HUGO BLOX CONFIGURATION
# Schema: hugoblox.com/schema/v2
# Tutorial: https://docs.hugoblox.com/tutorial/
# Documentation: https://docs.hugoblox.com/
# ══════════════════════════════════════════════════════════════════════════════
hugoblox:
schema: "2.0"
# ────────────────────────────────────────────────────────────────────────────
# IDENTITY
# Site-level branding (for people/profiles, use the author system in content/authors/)
# ────────────────────────────────────────────────────────────────────────────
identity:
# Site name - shown in navbar, footer, copyright, and browser title by default
name: "Your Name"
# Legal entity for copyright notices (defaults to name if not set)
organization: ""
# Schema.org type for structured data
type: person # person | organization | local_business
# Short tagline displayed on your site
tagline: "Full-Stack Developer"
# Site meta description for search engines and social sharing
description: "Modern developer portfolio showcasing web development projects, technical blog posts, and open source contributions. Built with Hugo Blox."
# Social accounts (used for meta tags like Twitter Cards)
social:
twitter: "yourusername" # Twitter/X handle for Twitter Cards (without @)
# ────────────────────────────────────────────────────────────────────────────
# THEME
# Color and visual design system
# ────────────────────────────────────────────────────────────────────────────
theme:
# Mode: 'light', 'dark', or 'system' (follows OS preference)
mode: dark
# Theme pack: name from data/themes/ or { light: "x", dark: "y" } for mix-and-match
pack: "default"
# Optional: Override theme pack colors
colors:
primary: "" # Tailwind palette name or hex (e.g., "indigo" or "#6366f1")
secondary: ""
neutral: "" # Affects gray utilities (text-gray-*, bg-gray-*)
# Optional: Mode-specific color overrides
colors_light: {}
colors_dark: {}
# Optional: Semantic surface color overrides
surfaces:
background: ""
foreground: ""
header:
background: ""
foreground: ""
footer:
background: ""
foreground: ""
# ────────────────────────────────────────────────────────────────────────────
# TYPOGRAPHY
# Font and text sizing
# ────────────────────────────────────────────────────────────────────────────
typography:
# Font: 'sans' (Inter), 'serif' (Roboto Slab), or 'native' (system fonts)
font: "sans"
# Size: 'sm', 'md', 'lg', or 'xl' (affects base font size and scale)
size: "md"
# ────────────────────────────────────────────────────────────────────────────
# LAYOUT
# Spacing and shape tokens
# ────────────────────────────────────────────────────────────────────────────
layout:
# Border radius: 'none', 'sm', 'md', 'lg', or 'full'
radius: "md"
# Spacing density: 'compact', 'comfortable', or 'spacious'
spacing: "comfortable"
# Avatar shape in author profiles
avatar_shape: circle # circle | square | rounded
# ────────────────────────────────────────────────────────────────────────────
# HEADER
# Site header and navigation bar
# ────────────────────────────────────────────────────────────────────────────
header:
enable: true
# Header style variant
style: navbar # navbar | navbar-simple | minimal
# Stick to top on scroll
sticky: true
# Menu alignment
align: center # left | center | right
# Feature toggles
search: true
theme_toggle: true
theme_picker: true
language_switcher: true
# Call-to-action button
cta:
enable: false
text: ""
url: ""
# ────────────────────────────────────────────────────────────────────────────
# FOOTER
# Site footer
# ────────────────────────────────────────────────────────────────────────────
footer:
# Footer style variant
style: minimal # minimal | columns | centered
language_switcher: true
# Custom footer text (HTML supported)
text: ""
# ────────────────────────────────────────────────────────────────────────────
# COPYRIGHT
# Legal notices and licensing
# ────────────────────────────────────────────────────────────────────────────
copyright:
# Copyright notice - supports {year}, {name}, {license} placeholders
notice: "© {year} {name}. This work is licensed under {license}"
license:
type: cc # cc | custom
allow_derivatives: false
share_alike: true
allow_commercial: false
# ────────────────────────────────────────────────────────────────────────────
# SEO
# Search engine optimization - overrides and technical settings
# ────────────────────────────────────────────────────────────────────────────
seo:
# Browser tab title override (defaults to identity.name if not set)
title: ""
# For local_business identity type only
location:
address:
street: ""
city: ""
region: ""
postal_code: ""
country: ""
coordinates:
latitude: null
longitude: null
phone: ""
# AI/LLM crawler guidance (llms.txt)
ai:
allow:
- /
disallow: []
note: "Guidance for AI crawlers - customize to match your public content."
contact: ""
# ────────────────────────────────────────────────────────────────────────────
# CONTENT
# Content rendering and behavior
# ────────────────────────────────────────────────────────────────────────────
content:
# Math rendering
math:
enable: false
# Table of contents
toc:
enable: true
# Reading time estimates
reading_time:
enable: true
# Academic citations (disabled for developer portfolio)
citations:
style: apa # apa | mla | chicago | ieee
# ────────────────────────────────────────────────────────────────────────────
# SEARCH
# Site search
# ────────────────────────────────────────────────────────────────────────────
search:
enable: true
# Trending/suggested search terms
suggestions: []
# Quick action shortcuts in search modal
quick_actions: []
# ────────────────────────────────────────────────────────────────────────────
# COMMENTS
# User comments system
# ────────────────────────────────────────────────────────────────────────────
comments:
enable: false
provider: "" # giscus | disqus
giscus:
repo: ""
repo_id: ""
category: ""
category_id: ""
disqus:
shortname: ""
# ────────────────────────────────────────────────────────────────────────────
# ANALYTICS
# Traffic and behavior analytics
# ────────────────────────────────────────────────────────────────────────────
analytics:
google:
measurement_id: "" # Google Analytics 4: G-XXXXXXXXXX
google_tag_manager:
container_id: "" # GTM-XXXXXXX
plausible:
domain: ""
fathom:
site_id: ""
pirsch:
site_id: ""
clarity:
project_id: ""
baidu:
site_id: ""
# ────────────────────────────────────────────────────────────────────────────
# VERIFICATION
# Search engine site verification codes
# ────────────────────────────────────────────────────────────────────────────
verification:
google: ""
bing: ""
baidu: ""
yandex: ""
pinterest: ""
naver: ""
# ────────────────────────────────────────────────────────────────────────────
# REPOSITORY
# Source code repository for "Edit this page" links
# ────────────────────────────────────────────────────────────────────────────
repository:
enable: false
url: "" # e.g., https://github.com/username/repo
branch: main
content_dir: content
# ────────────────────────────────────────────────────────────────────────────
# LOCALE
# Regional formatting preferences
# ────────────────────────────────────────────────────────────────────────────
locale:
date_format: "Jan 2, 2006" # Go time format
time_format: "3:04 PM"
address_format: en-us
# ────────────────────────────────────────────────────────────────────────────
# SECURITY
# Security headers and policies (requires Netlify integration)
# ────────────────────────────────────────────────────────────────────────────
security:
csp:
policy: ""
report_only: false
frame_options: sameorigin # deny | sameorigin | allow - sameorigin allows slides embedding
permissions_policy: ""
# ────────────────────────────────────────────────────────────────────────────
# PRIVACY
# Privacy and compliance features
# ────────────────────────────────────────────────────────────────────────────
privacy:
enable: false
anonymize_analytics: true
# ────────────────────────────────────────────────────────────────────────────
# DEBUG
# Development and debugging (hidden in production)
# ────────────────────────────────────────────────────────────────────────────
debug:
enable: false
hud:
position: bottom-left # top-left | top-right | bottom-left | bottom-right
opacity: 1.0
export_logs: true
# ────────────────────────────────────────────────────────────────────────────
# PRO
# Pro subscription features and affiliate program
# ────────────────────────────────────────────────────────────────────────────
pro:
# Hide "Powered by HugoBlox" attribution
# Requires: Pro subscription to support HugoBlox via alternative means
# Note: Even with Pro, you can keep this false to support open source!
# Get Pro: https://hugoblox.com/pro
hide_attribution: false
# Affiliate referral code for rewards program
# Join affiliate program: https://hugoblox.com/affiliates
affiliate_code: ""
+259
View File
@@ -0,0 +1,259 @@
---
# Leave the homepage title empty to use the site title
title: ''
summary: ''
date: 2026-01-05
type: landing
design:
# Default section spacing
spacing: '0'
sections:
# Developer Hero - Gradient background with name, role, social, and CTAs
- block: dev-hero
id: hero
content:
username: me
greeting: "Hi, I'm"
show_status: true
show_scroll_indicator: true
typewriter:
enable: true
prefix: "I build"
strings:
- "full-stack web apps"
- "scalable APIs"
- "beautiful UIs"
- "open source tools"
type_speed: 70
delete_speed: 40
pause_time: 2500
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
animations: true
background:
color:
light: "#fafafa"
dark: "#0a0a0f"
spacing:
padding: ["6rem", "0", "4rem", "0"]
# Filterable Portfolio - Alpine.js powered project filtering
- 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 link auto-shown if more projects exist than 'count' above
# archive:
# enable: false # Set to false to explicitly hide
# text: "Browse All" # Customize text
# link: "/work/" # Custom URL
design:
columns: 3
background:
color:
light: "#ffffff"
dark: "#0d0d12"
spacing:
padding: ["4rem", "0", "4rem", "0"]
# Visual Tech Stack - Icons organized by category
- 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
- name: JavaScript
icon: devicon/javascript
- name: Python
icon: devicon/python
- name: Go
icon: devicon/go
- name: Frontend
items:
- name: React
icon: devicon/react
- name: Next.js
icon: devicon/nextjs
- name: Tailwind CSS
icon: devicon/tailwindcss
- name: Alpine.js
icon: devicon/alpinejs
- name: Backend
items:
- name: Node.js
icon: devicon/nodejs
- name: Express
icon: devicon/express
- name: PostgreSQL
icon: devicon/postgresql
- name: Redis
icon: devicon/redis
- name: DevOps
items:
- name: Docker
icon: devicon/docker
- name: AWS
icon: devicon/amazonwebservices-wordmark
- name: GitHub Actions
icon: brands/github
- name: Vercel
icon: devicon/vercel
design:
style: grid
show_levels: false
background:
color:
light: "#f5f5f5"
dark: "#08080c"
spacing:
padding: ["4rem", "0", "4rem", "0"]
# Experience Timeline
- block: resume-experience
id: experience
content:
title: Experience
date_format: Jan 2006
items:
- title: Senior Software Engineer
company: Tech Corp
company_url: ''
company_logo: ''
location: San Francisco, CA
date_start: '2023-01-01'
date_end: ''
description: |2-
* Lead development of microservices architecture serving 1M+ users
* Improved API response time by 40% through optimization
* Mentored team of 5 junior developers
* Tech stack: React, Node.js, PostgreSQL, AWS
- title: Full-Stack Developer
company: Startup Inc
company_url: ''
company_logo: ''
location: Remote
date_start: '2021-06-01'
date_end: '2022-12-31'
description: |2-
* Built and deployed 3 production applications from scratch
* Implemented CI/CD pipeline reducing deployment time by 60%
* Collaborated with design team on UI/UX improvements
* Tech stack: Next.js, Express, MongoDB, Docker
- title: Junior Developer
company: Web Agency
company_url: ''
company_logo: ''
location: New York, NY
date_start: '2020-01-01'
date_end: '2021-05-31'
description: |2-
* Developed client websites using modern web technologies
* Maintained and updated legacy codebases
* Participated in code reviews and agile ceremonies
* Tech stack: React, WordPress, PHP, MySQL
design:
columns: '1'
background:
color:
light: "#ffffff"
dark: "#0d0d12"
spacing:
padding: ["4rem", "0", "4rem", "0"]
# Recent Blog Posts
- block: collection
id: blog
content:
title: Recent Posts
subtitle: 'Thoughts on web development, tech, and more'
text: ''
filters:
folders:
- blog
exclude_featured: false
count: 3
order: desc
design:
view: card
columns: 3
background:
color:
light: "#f5f5f5"
dark: "#08080c"
spacing:
padding: ["4rem", "0", "4rem", "0"]
# Contact Section
- block: contact-info
id: contact
content:
title: Get In Touch
subtitle: "Let's build something amazing together"
text: |-
I'm always interested in hearing about new projects and opportunities.
Whether you're looking to hire, collaborate, or just want to say hi, feel free to reach out!
email: alex@example.com
autolink: true
design:
columns: '1'
background:
color:
light: "#ffffff"
dark: "#0d0d12"
spacing:
padding: ["4rem", "0", "4rem", "0"]
# CTA Card
- block: cta-card
content:
title: "Open to Opportunities"
text: |-
I'm currently looking for **senior engineering** or **tech lead** roles.
Let's connect and discuss how I can help your team.
button:
text: 'Download Resume'
url: uploads/resume.pdf
new_tab: true
design:
card:
# Light mode: soft pastel theme gradient | Dark mode: rich deep gradient
css_class: 'bg-gradient-to-br from-primary-200 via-primary-100 to-secondary-200 dark:from-primary-600 dark:via-primary-700 dark:to-secondary-700'
text_color: dark
background:
color:
light: "#f5f5f5"
dark: "#08080c"
spacing:
padding: ["4rem", "0", "6rem", "0"]
---
@@ -0,0 +1,9 @@
---
# To publish author profile pages, remove all the `build` and `cascade` settings below.
build:
render: never
cascade:
build:
render: never
list: always
---
@@ -0,0 +1,4 @@
---
title: Blog
view: article-grid
---
Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

@@ -0,0 +1,455 @@
---
title: "Building a Production-Ready REST API with Node.js and Express"
date: 2024-12-10
summary: "A comprehensive guide to building scalable, secure REST APIs with proper error handling, validation, and documentation"
tags:
- Node.js
- Express
- REST API
- Backend
- Tutorial
authors:
- me
featured: true
---
Building a REST API seems straightforward until you need to handle authentication, validation, error handling, and documentation. This guide covers best practices for production-ready APIs.
## Table of Contents
1. [Project Setup](#project-setup)
2. [Folder Structure](#folder-structure)
3. [Database Integration](#database)
4. [Authentication & Authorization](#auth)
5. [Error Handling](#errors)
6. [Validation](#validation)
7. [API Documentation](#docs)
8. [Testing](#testing)
## Project Setup {#project-setup}
Start with a solid foundation:
```bash
mkdir my-api && cd my-api
npm init -y
npm install express dotenv cors helmet compression
npm install -D typescript @types/node @types/express ts-node-dev
```
Create `tsconfig.json`:
```json
{
"compilerOptions": {
"target": "ES2020",
"module": "commonjs",
"lib": ["ES2020"],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true
}
}
```
## Folder Structure {#folder-structure}
Organize your code for maintainability:
```
src/
├── config/ # Configuration files
├── controllers/ # Route controllers
├── middleware/ # Custom middleware
├── models/ # Database models
├── routes/ # API routes
├── services/ # Business logic
├── utils/ # Helper functions
├── validators/ # Input validation
└── app.ts # App setup
```
## Database Integration {#database}
Using Prisma for type-safe database access:
```typescript
// prisma/schema.prisma
model User {
id String @id @default(uuid())
email String @unique
name String
password String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
```
```typescript
// src/config/database.ts
import { PrismaClient } from '@prisma/client'
const prisma = new PrismaClient({
log: process.env.NODE_ENV === 'development' ? ['query'] : []
})
export default prisma
```
## Authentication & Authorization {#auth}
Implement JWT-based authentication:
```typescript
// src/middleware/auth.ts
import jwt from 'jsonwebtoken'
import { Request, Response, NextFunction } from 'express'
interface JWTPayload {
userId: string
email: string
}
export const authenticate = async (
req: Request,
res: Response,
next: NextFunction
) => {
try {
const token = req.headers.authorization?.split(' ')[1]
if (!token) {
return res.status(401).json({ error: 'Authentication required' })
}
const payload = jwt.verify(
token,
process.env.JWT_SECRET!
) as JWTPayload
req.user = payload
next()
} catch (error) {
res.status(401).json({ error: 'Invalid or expired token' })
}
}
```
## Error Handling {#errors}
Centralized error handling:
```typescript
// src/middleware/errorHandler.ts
import { Request, Response, NextFunction } from 'express'
class AppError extends Error {
statusCode: number
isOperational: boolean
constructor(message: string, statusCode: number) {
super(message)
this.statusCode = statusCode
this.isOperational = true
Error.captureStackTrace(this, this.constructor)
}
}
export const errorHandler = (
err: Error,
req: Request,
res: Response,
next: NextFunction
) => {
if (err instanceof AppError) {
return res.status(err.statusCode).json({
status: 'error',
message: err.message
})
}
// Log unexpected errors
console.error('Unexpected error:', err)
res.status(500).json({
status: 'error',
message: 'Internal server error'
})
}
export { AppError }
```
## Validation {#validation}
Use Zod for runtime validation:
```typescript
// src/validators/userValidator.ts
import { z } from 'zod'
export const createUserSchema = z.object({
body: z.object({
email: z.string().email('Invalid email address'),
name: z.string().min(2, 'Name must be at least 2 characters'),
password: z.string().min(8, 'Password must be at least 8 characters')
})
})
export const validate = (schema: any) => {
return async (req: Request, res: Response, next: NextFunction) => {
try {
await schema.parseAsync({
body: req.body,
query: req.query,
params: req.params
})
next()
} catch (error) {
if (error instanceof z.ZodError) {
return res.status(400).json({
status: 'error',
errors: error.errors
})
}
next(error)
}
}
}
```
## API Documentation {#docs}
Auto-generate docs with Swagger:
```typescript
// src/config/swagger.ts
import swaggerJsdoc from 'swagger-jsdoc'
const options = {
definition: {
openapi: '3.0.0',
info: {
title: 'My API',
version: '1.0.0',
description: 'API documentation'
},
servers: [
{
url: 'http://localhost:3000',
description: 'Development server'
}
]
},
apis: ['./src/routes/*.ts']
}
export const swaggerSpec = swaggerJsdoc(options)
```
Document endpoints in route files:
```typescript
/**
* @openapi
* /api/users:
* post:
* summary: Create a new user
* tags: [Users]
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* email:
* type: string
* name:
* type: string
* password:
* type: string
* responses:
* 201:
* description: User created successfully
*/
router.post('/users', validate(createUserSchema), createUser)
```
## Testing {#testing}
Write tests with Jest and Supertest:
```typescript
// tests/users.test.ts
import request from 'supertest'
import app from '../src/app'
import prisma from '../src/config/database'
describe('User API', () => {
beforeEach(async () => {
await prisma.user.deleteMany()
})
afterAll(async () => {
await prisma.$disconnect()
})
describe('POST /api/users', () => {
it('should create a new user', async () => {
const res = await request(app)
.post('/api/users')
.send({
email: 'test@example.com',
name: 'Test User',
password: 'password123'
})
.expect(201)
expect(res.body).toHaveProperty('id')
expect(res.body.email).toBe('test@example.com')
})
it('should return 400 for invalid email', async () => {
const res = await request(app)
.post('/api/users')
.send({
email: 'invalid-email',
name: 'Test User',
password: 'password123'
})
.expect(400)
expect(res.body).toHaveProperty('errors')
})
})
})
```
## Security Best Practices
1. **Rate Limiting**
```typescript
import rateLimit from 'express-rate-limit'
const limiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100 // limit each IP to 100 requests per windowMs
})
app.use('/api/', limiter)
```
2. **Helmet** for security headers
```typescript
import helmet from 'helmet'
app.use(helmet())
```
3. **CORS** configuration
```typescript
import cors from 'cors'
app.use(cors({
origin: process.env.ALLOWED_ORIGINS?.split(','),
credentials: true
}))
```
4. **Input Sanitization**
```typescript
import mongoSanitize from 'express-mongo-sanitize'
app.use(mongoSanitize())
```
## Performance Optimization
### 1. Response Compression
```typescript
import compression from 'compression'
app.use(compression())
```
### 2. Caching
```typescript
import Redis from 'ioredis'
const redis = new Redis(process.env.REDIS_URL)
async function getCachedData(key: string) {
const cached = await redis.get(key)
if (cached) return JSON.parse(cached)
const data = await fetchFromDatabase()
await redis.setex(key, 3600, JSON.stringify(data))
return data
}
```
### 3. Database Query Optimization
```typescript
// Use pagination
const users = await prisma.user.findMany({
skip: (page - 1) * limit,
take: limit,
select: {
id: true,
email: true,
name: true
// Don't select sensitive fields like password
}
})
// Use indexes
@@index([email])
@@index([createdAt])
```
## Deployment Checklist
- [ ] Environment variables configured
- [ ] Database migrations run
- [ ] SSL/TLS certificates installed
- [ ] Rate limiting enabled
- [ ] Logging configured (Winston, Morgan)
- [ ] Error tracking (Sentry)
- [ ] Health check endpoint
- [ ] API documentation deployed
- [ ] Load testing completed
- [ ] Backup strategy implemented
## Monitoring
```typescript
// Health check endpoint
app.get('/health', async (req, res) => {
try {
await prisma.$queryRaw`SELECT 1`
res.status(200).json({ status: 'healthy', timestamp: new Date() })
} catch (error) {
res.status(503).json({ status: 'unhealthy', error: error.message })
}
})
```
## Conclusion
Building production-ready APIs requires attention to:
- **Structure**: Organized code is maintainable code
- **Security**: Authentication, validation, and rate limiting
- **Errors**: Proper error handling and logging
- **Testing**: Comprehensive test coverage
- **Documentation**: Clear API docs for consumers
- **Performance**: Caching and optimization
The complete example code is available on [GitHub](https://github.com/alexjohnson/rest-api-guide).
## Resources
- [Express Best Practices](https://expressjs.com/en/advanced/best-practice-performance.html)
- [Node.js Security Checklist](https://github.com/goldbergyoni/nodebestpractices#6-security-best-practices)
- [Prisma Documentation](https://www.prisma.io/docs)
---
Questions? Leave a comment below or reach out on [Twitter](https://twitter.com/alexjohnson)!
Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

@@ -0,0 +1,526 @@
---
title: "Docker Deployment Guide: From Development to Production"
date: 2024-10-15
summary: "Learn how to containerize your applications with Docker, optimize images, and deploy to production with best practices"
tags:
- Docker
- DevOps
- Deployment
- Tutorial
authors:
- me
featured: false
---
Docker simplifies deployment by packaging your application with all dependencies. This guide covers containerizing apps, optimizing images, and production deployment.
## Why Docker?
**Problems Docker solves**:
- ❌ "Works on my machine" syndrome
- ❌ Complex dependency management
- ❌ Environment inconsistencies
- ❌ Difficult scaling
**Benefits**:
- ✅ Consistent environments (dev = prod)
- ✅ Easy scaling
- ✅ Simplified deployment
- ✅ Isolation and security
## Basic Dockerfile
For a Node.js application:
```dockerfile
# Use official Node image
FROM node:18-alpine
# Set working directory
WORKDIR /app
# Copy package files
COPY package*.json ./
# Install dependencies
RUN npm ci --only=production
# Copy application code
COPY . .
# Expose port
EXPOSE 3000
# Start application
CMD ["node", "server.js"]
```
Build and run:
```bash
docker build -t my-app .
docker run -p 3000:3000 my-app
```
## Multi-Stage Builds (Optimization)
Reduce image size with multi-stage builds:
```dockerfile
# Stage 1: Build
FROM node:18-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
# Stage 2: Production
FROM node:18-alpine
WORKDIR /app
# Copy only necessary files from builder
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
COPY package*.json ./
EXPOSE 3000
CMD ["node", "dist/server.js"]
```
**Result**: Image size reduced from 800MB → 150MB!
## Docker Compose for Local Development
`docker-compose.yml`:
```yaml
version: '3.8'
services:
app:
build: .
ports:
- "3000:3000"
environment:
- NODE_ENV=development
- DATABASE_URL=postgresql://user:pass@db:5432/mydb
volumes:
- .:/app
- /app/node_modules
depends_on:
- db
- redis
db:
image: postgres:15-alpine
environment:
- POSTGRES_USER=user
- POSTGRES_PASSWORD=pass
- POSTGRES_DB=mydb
volumes:
- postgres_data:/var/lib/postgresql/data
ports:
- "5432:5432"
redis:
image: redis:7-alpine
ports:
- "6379:6379"
volumes:
postgres_data:
```
Start everything:
```bash
docker-compose up -d
```
## Environment Variables
Never hardcode secrets! Use `.env` file:
```env
# .env (add to .gitignore!)
DATABASE_URL=postgresql://user:pass@localhost:5432/mydb
JWT_SECRET=your-secret-key
API_KEY=your-api-key
```
Reference in docker-compose:
```yaml
services:
app:
env_file:
- .env
```
## Production Dockerfile
Optimized for production:
```dockerfile
FROM node:18-alpine AS builder
WORKDIR /app
# Copy package files
COPY package*.json ./
# Install all dependencies (including devDependencies)
RUN npm ci
# Copy source
COPY . .
# Build application
RUN npm run build
# Remove devDependencies
RUN npm prune --production
# Production stage
FROM node:18-alpine
# Add non-root user
RUN addgroup -g 1001 -S nodejs && \
adduser -S nodejs -u 1001
WORKDIR /app
# Copy built app from builder
COPY --from=builder --chown=nodejs:nodejs /app/dist ./dist
COPY --from=builder --chown=nodejs:nodejs /app/node_modules ./node_modules
COPY --from=builder --chown=nodejs:nodejs /app/package*.json ./
# Use non-root user
USER nodejs
# Health check
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD node healthcheck.js
EXPOSE 3000
CMD ["node", "dist/server.js"]
```
## Image Optimization Tips
### 1. Use .dockerignore
```
node_modules
npm-debug.log
.git
.env
.DS_Store
*.md
.vscode
coverage
.github
```
### 2. Layer Caching
```dockerfile
# ❌ Bad: Changes to code invalidate ALL layers
COPY . .
RUN npm ci
# ✅ Good: Package changes don't rebuild code
COPY package*.json ./
RUN npm ci
COPY . .
```
### 3. Use Alpine Images
```dockerfile
# Large: node:18 (900MB)
FROM node:18
# Small: node:18-alpine (150MB)
FROM node:18-alpine
```
### 4. Combine RUN Commands
```dockerfile
# ❌ Bad: Multiple layers
RUN apt-get update
RUN apt-get install -y curl
RUN apt-get clean
# ✅ Good: Single layer
RUN apt-get update && \
apt-get install -y curl && \
apt-get clean && \
rm -rf /var/lib/apt/lists/*
```
## Deploying to AWS ECS
### 1. Build and Push to ECR
```bash
# Authenticate Docker to ECR
aws ecr get-login-password --region us-east-1 | \
docker login --username AWS --password-stdin 123456789.dkr.ecr.us-east-1.amazonaws.com
# Build image
docker build -t my-app .
# Tag image
docker tag my-app:latest 123456789.dkr.ecr.us-east-1.amazonaws.com/my-app:latest
# Push to ECR
docker push 123456789.dkr.ecr.us-east-1.amazonaws.com/my-app:latest
```
### 2. ECS Task Definition
```json
{
"family": "my-app",
"networkMode": "awsvpc",
"requiresCompatibilities": ["FARGATE"],
"cpu": "256",
"memory": "512",
"containerDefinitions": [
{
"name": "my-app",
"image": "123456789.dkr.ecr.us-east-1.amazonaws.com/my-app:latest",
"portMappings": [
{
"containerPort": 3000,
"protocol": "tcp"
}
],
"environment": [
{
"name": "NODE_ENV",
"value": "production"
}
],
"secrets": [
{
"name": "DATABASE_URL",
"valueFrom": "arn:aws:secretsmanager:..."
}
],
"logConfiguration": {
"logDriver": "awslogs",
"options": {
"awslogs-group": "/ecs/my-app",
"awslogs-region": "us-east-1",
"awslogs-stream-prefix": "ecs"
}
}
}
]
}
```
## Docker Security Best Practices
### 1. Use Non-Root User
```dockerfile
# Add user
RUN addgroup -g 1001 -S nodejs && \
adduser -S nodejs -u 1001
# Switch to user
USER nodejs
```
### 2. Scan for Vulnerabilities
```bash
# Using Trivy
docker run --rm -v /var/run/docker.sock:/var/run/docker.sock \
aquasec/trivy image my-app:latest
```
### 3. Keep Base Images Updated
```dockerfile
# ✅ Pin specific version, update regularly
FROM node:18.19.0-alpine
# ❌ Avoid 'latest' tag
FROM node:latest
```
### 4. Read-Only Filesystem
```yaml
services:
app:
read_only: true
tmpfs:
- /tmp
```
## Health Checks
Simple health check endpoint:
```javascript
// healthcheck.js
const http = require('http')
const options = {
host: 'localhost',
port: 3000,
path: '/health',
timeout: 2000
}
const request = http.request(options, (res) => {
if (res.statusCode === 200) {
process.exit(0)
} else {
process.exit(1)
}
})
request.on('error', () => {
process.exit(1)
})
request.end()
```
## Monitoring & Logging
### Container Logs
```bash
# View logs
docker logs my-app
# Follow logs
docker logs -f my-app
# Last 100 lines
docker logs --tail 100 my-app
```
### Docker Stats
```bash
# View resource usage
docker stats
# Specific container
docker stats my-app
```
## Common Issues & Solutions
### Issue: Image Too Large
**Solution**: Multi-stage builds, Alpine images, .dockerignore
### Issue: Slow Builds
**Solution**: Optimize layer caching, use BuildKit
```bash
DOCKER_BUILDKIT=1 docker build -t my-app .
```
### Issue: Container Won't Start
**Solution**: Check logs, verify CMD/ENTRYPOINT
```bash
docker logs container-id
docker inspect container-id
```
## CI/CD Pipeline Example (GitHub Actions)
```yaml
name: Deploy
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v2
with:
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
aws-region: us-east-1
- name: Login to Amazon ECR
id: login-ecr
uses: aws-actions/amazon-ecr-login@v1
- name: Build and push image
env:
ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }}
ECR_REPOSITORY: my-app
IMAGE_TAG: ${{ github.sha }}
run: |
docker build -t $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG .
docker push $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG
- name: Update ECS service
run: |
aws ecs update-service --cluster my-cluster \
--service my-app --force-new-deployment
```
## Best Practices Checklist
- [ ] Use multi-stage builds
- [ ] Use .dockerignore
- [ ] Run as non-root user
- [ ] Pin base image versions
- [ ] Scan for vulnerabilities
- [ ] Implement health checks
- [ ] Use environment variables for config
- [ ] Keep images small (<500MB)
- [ ] Tag images properly (not just 'latest')
- [ ] Set up logging and monitoring
## Resources
- [Docker Documentation](https://docs.docker.com/)
- [Docker Best Practices](https://docs.docker.com/develop/dev-best-practices/)
- [Dockerfile Reference](https://docs.docker.com/engine/reference/builder/)
- [Docker Compose](https://docs.docker.com/compose/)
## Conclusion
Docker streamlines deployment by:
1. Creating consistent environments
2. Simplifying dependency management
3. Enabling easy scaling
4. Improving security through isolation
Start small, optimize incrementally, and always test in staging before production!
---
**Example repo**: [github.com/alexjohnson/docker-guide](https://github.com/alexjohnson/docker-guide)
Questions? Reach out on [Twitter](https://twitter.com/alexjohnson)!
Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

@@ -0,0 +1,397 @@
---
title: "React Performance Optimization: A Practical Guide"
date: 2024-11-22
summary: "Learn how to identify and fix performance bottlenecks in React applications with real-world examples and profiling techniques"
tags:
- React
- Performance
- JavaScript
- Frontend
- Optimization
authors:
- me
featured: true
---
React apps can become slow as they grow. This guide covers practical techniques to identify bottlenecks and optimize performance, backed by real-world examples.
## Common Performance Issues
Before optimizing, measure. Use React DevTools Profiler to identify:
- Unnecessary re-renders
- Heavy computations
- Large component trees
- Inefficient list rendering
## 1. Prevent Unnecessary Re-renders
### Problem: Parent Re-renders Cascade
```jsx
// ❌ Bad: Child re-renders on every parent update
function Parent() {
const [count, setCount] = useState(0)
return (
<div>
<button onClick={() => setCount(count + 1)}>Count: {count}</button>
<ExpensiveChild /> {/* Re-renders even though it doesn't use count! */}
</div>
)
}
```
### Solution: React.memo
```jsx
// ✅ Good: Child only re-renders when props change
const ExpensiveChild = React.memo(() => {
console.log('ExpensiveChild rendered')
return <div>{/* expensive rendering */}</div>
})
```
**When to use**: Components that render often but rarely change props.
## 2. Optimize Expensive Calculations
### Problem: Recalculating on Every Render
```jsx
// ❌ Bad: filterItems runs on every render
function ProductList({ products, searchTerm }) {
const filtered = products.filter(p =>
p.name.toLowerCase().includes(searchTerm.toLowerCase())
) // Runs even when products/searchTerm haven't changed!
return <ul>{filtered.map(p => <li key={p.id}>{p.name}</li>)}</ul>
}
```
### Solution: useMemo
```jsx
// ✅ Good: Only recalculates when dependencies change
function ProductList({ products, searchTerm }) {
const filtered = useMemo(() =>
products.filter(p =>
p.name.toLowerCase().includes(searchTerm.toLowerCase())
),
[products, searchTerm]
)
return <ul>{filtered.map(p => <li key={p.id}>{p.name}</li>)}</ul>
}
```
**When to use**: Expensive calculations that don't need to run on every render.
## 3. Stabilize Function References
### Problem: New Function on Every Render
```jsx
// ❌ Bad: handleClick creates new function every render
function Parent() {
const [count, setCount] = useState(0)
const handleClick = () => setCount(count + 1)
return <Child onClick={handleClick} /> // Child re-renders!
}
const Child = React.memo(({ onClick }) => {
return <button onClick={onClick}>Click</button>
})
```
### Solution: useCallback
```jsx
// ✅ Good: handleClick reference stays stable
function Parent() {
const [count, setCount] = useState(0)
const handleClick = useCallback(() => {
setCount(c => c + 1) // Use functional update!
}, []) // Empty deps because we use functional update
return <Child onClick={handleClick} />
}
```
**When to use**: Passing callbacks to memoized child components.
## 4. Virtual Lists for Large Datasets
### Problem: Rendering 10,000 Items
```jsx
// ❌ Bad: Renders all 10,000 items (slow!)
function LargeList({ items }) {
return (
<div>
{items.map(item => (
<div key={item.id}>{item.name}</div>
))}
</div>
)
}
```
### Solution: react-window
```jsx
// ✅ Good: Only renders visible items
import { FixedSizeList } from 'react-window'
function LargeList({ items }) {
const Row = ({ index, style }) => (
<div style={style}>{items[index].name}</div>
)
return (
<FixedSizeList
height={600}
itemCount={items.length}
itemSize={50}
width="100%"
>
{Row}
</FixedSizeList>
)
}
```
**Result**: 10,000 items render in <50ms instead of 2000ms!
## 5. Code Splitting & Lazy Loading
### Problem: Large Bundle Size
```jsx
// ❌ Bad: Everything loads upfront
import AdminPanel from './AdminPanel' // 500KB!
import UserDashboard from './UserDashboard'
function App() {
return isAdmin ? <AdminPanel /> : <UserDashboard />
}
```
### Solution: React.lazy
```jsx
// ✅ Good: Load components on demand
const AdminPanel = React.lazy(() => import('./AdminPanel'))
const UserDashboard = React.lazy(() => import('./UserDashboard'))
function App() {
return (
<Suspense fallback={<Loading />}>
{isAdmin ? <AdminPanel /> : <UserDashboard />}
</Suspense>
)
}
```
**Result**: Initial bundle: 150KB instead of 650KB!
## 6. Optimize Context Usage
### Problem: Context Causes Mass Re-renders
```jsx
// ❌ Bad: Every consumer re-renders on any state change
const AppContext = createContext()
function AppProvider({ children }) {
const [user, setUser] = useState(null)
const [theme, setTheme] = useState('light')
const [notifications, setNotifications] = useState([])
return (
<AppContext.Provider value={{ user, theme, notifications, setUser, setTheme }}>
{children}
</AppContext.Provider>
)
}
```
### Solution: Split Contexts
```jsx
// ✅ Good: Separate contexts for different concerns
const UserContext = createContext()
const ThemeContext = createContext()
const NotificationContext = createContext()
function AppProvider({ children }) {
const [user, setUser] = useState(null)
const [theme, setTheme] = useState('light')
const [notifications, setNotifications] = useState([])
return (
<UserContext.Provider value={{ user, setUser }}>
<ThemeContext.Provider value={{ theme, setTheme }}>
<NotificationContext.Provider value={{ notifications }}>
{children}
</NotificationContext.Provider>
</ThemeContext.Provider>
</UserContext.Provider>
)
}
```
**Result**: Components only re-render when their specific context changes!
## 7. Debounce Expensive Operations
### Problem: Search Triggers on Every Keystroke
```jsx
// ❌ Bad: API call on every keystroke
function SearchBox() {
const [query, setQuery] = useState('')
useEffect(() => {
searchAPI(query) // Called 10+ times as user types "javascript"!
}, [query])
return <input value={query} onChange={e => setQuery(e.target.value)} />
}
```
### Solution: Custom Debounce Hook
```jsx
// ✅ Good: API call only after user stops typing
function useDebounce(value, delay) {
const [debouncedValue, setDebouncedValue] = useState(value)
useEffect(() => {
const handler = setTimeout(() => setDebouncedValue(value), delay)
return () => clearTimeout(handler)
}, [value, delay])
return debouncedValue
}
function SearchBox() {
const [query, setQuery] = useState('')
const debouncedQuery = useDebounce(query, 300)
useEffect(() => {
if (debouncedQuery) {
searchAPI(debouncedQuery) // Called once after 300ms pause!
}
}, [debouncedQuery])
return <input value={query} onChange={e => setQuery(e.target.value)} />
}
```
## 8. Key Props Matter
### Problem: Poor Key Choices
```jsx
// ❌ Bad: Using index as key causes issues
{items.map((item, index) => (
<div key={index}>{item.name}</div>
))}
// When items reorder, React thinks they're different components!
```
### Solution: Stable, Unique Keys
```jsx
// ✅ Good: Use stable IDs
{items.map(item => (
<div key={item.id}>{item.name}</div>
))}
```
## Real-World Example: Optimizing a Dashboard
**Before**: Dashboard with 20 widgets, each fetching data, all re-rendering on any state change.
**After optimization**:
1. Split into separate contexts for user, theme, data
2. Memoized individual widgets with React.memo
3. Used useMemo for data transformations
4. Lazy loaded heavy charts
5. Debounced filter inputs
**Result**:
- Initial render: 3000ms → 800ms
- Interactions: 200ms → 50ms
- Bundle size: 800KB → 250KB (initial) + 550KB (lazy)
## Profiling Workflow
1. **React DevTools Profiler**
- Record interaction
- Identify components taking longest
- Check why they rendered (props change? parent render?)
2. **Chrome DevTools Performance**
- Record performance
- Look for long tasks (>50ms)
- Check FPS during animations
3. **Lighthouse**
- Run audit
- Focus on "Time to Interactive"
- Check bundle sizes
## Common Pitfalls
❌ **Don't** optimize prematurely
✅ **Do** measure first, optimize bottlenecks
❌ **Don't** memo everything
✅ **Do** memo expensive components
❌ **Don't** use useMemo for simple calculations
✅ **Do** use for actual performance issues
❌ **Don't** forget dependency arrays
✅ **Do** include all dependencies (or use ESLint plugin)
## Performance Checklist
- [ ] Use React DevTools Profiler to identify issues
- [ ] Memoize expensive child components
- [ ] Use useMemo for heavy calculations
- [ ] Use useCallback for callbacks to memoized children
- [ ] Implement code splitting for large components
- [ ] Use virtual lists for large datasets
- [ ] Debounce expensive operations (search, API calls)
- [ ] Use proper key props for lists
- [ ] Split contexts to minimize re-renders
- [ ] Optimize bundle size (tree shaking, minification)
## Tools & Resources
- [React DevTools](https://chrome.google.com/webstore/detail/react-developer-tools)
- [react-window](https://react-window.vercel.app/)
- [Bundle Analyzer](https://www.npmjs.com/package/webpack-bundle-analyzer)
- [Why Did You Render](https://github.com/welldone-software/why-did-you-render)
## Conclusion
Performance optimization is about:
1. **Measuring** before optimizing
2. **Identifying** actual bottlenecks
3. **Applying** targeted fixes
4. **Verifying** improvements
Don't optimize blindly—profile, fix, measure, repeat!
---
**Want to dive deeper?** Check out my [React Performance Workshop](https://github.com/alexjohnson/react-perf-workshop).
Questions? Reach out on [Twitter](https://twitter.com/alexjohnson)!
@@ -0,0 +1,26 @@
---
title: 'Projects'
date: 2024-05-19
type: landing
design:
# Section spacing
spacing: '5rem'
# Page sections
sections:
- block: collection
content:
title: Selected Projects
text: I enjoy making things. Here are a selection of projects that I have worked on over the years.
filters:
folders:
- projects
design:
view: article-grid
fill_image: false
columns: 3
show_date: false
show_read_time: false
show_read_more: false
---
Binary file not shown.

After

Width:  |  Height:  |  Size: 482 KiB

@@ -0,0 +1,167 @@
---
title: "E-Commerce Platform"
date: 2024-11-15
summary: "E-commerce API backend with Stripe payments, inventory management, and real-time webhooks"
tags:
- Backend
- Node.js
- API
- E-Commerce
tech_stack:
- React
- TypeScript
- Node.js
- Express
- PostgreSQL
- Stripe
- Redis
- Docker
links:
- type: github
url: https://github.com/alexjohnson/ecommerce-platform
label: Code
- type: live
url: https://shop-demo.example.com
label: Demo
featured: true
status: "Live"
role: "Lead Developer"
duration: "4 months"
team_size: 2
highlights:
- "Handles 10k+ concurrent users"
- "99.9% uptime SLA"
- "Processing $50k+ monthly transactions"
- "60% faster page load vs competitors"
---
A modern, scalable e-commerce platform built from scratch with performance and user experience as top priorities.
## Overview
Built a complete e-commerce solution for a mid-sized retail company looking to expand online. The platform handles everything from product catalog management to payment processing and order fulfillment.
## Key Features
### Customer-Facing
- **Product Catalog** - Dynamic filtering, sorting, and search with instant results
- **Shopping Cart** - Real-time inventory checking and price calculations
- **Checkout** - Secure payment processing via Stripe with Apple Pay/Google Pay support
- **Order Tracking** - Real-time order status updates with email notifications
- **User Accounts** - Profile management, order history, and saved addresses
### Admin Dashboard
- **Inventory Management** - Real-time stock tracking and low-stock alerts
- **Order Management** - Bulk order processing and fulfillment workflow
- **Analytics** - Sales dashboards, customer insights, and revenue reporting
- **Product Management** - Easy product creation with image uploads and variants
## Technical Highlights
### Performance Optimization
- Implemented Redis caching reducing database queries by 70%
- Optimized images with WebP format and lazy loading
- Server-side rendering for critical pages improving SEO and load times
- CDN integration for global content delivery
### Scalability
- Microservices architecture allowing independent scaling
- Horizontal scaling with load balancing
- Database read replicas for improved query performance
- Message queues for async processing (order emails, inventory updates)
### Security
- JWT authentication with refresh tokens
- Rate limiting to prevent abuse
- Input validation and sanitization
- PCI-compliant payment processing via Stripe
## Architecture
```
┌─────────────┐ ┌──────────────┐ ┌─────────────┐
│ React SPA │────▶│ REST API │────▶│ PostgreSQL │
└─────────────┘ │ (Express) │ └─────────────┘
└──────┬───────┘
│
┌──────▼───────┐
│ Redis │
│ (Cache) │
└──────────────┘
```
## Challenges & Solutions
### Challenge 1: Inventory Sync
**Problem**: Multiple users buying same product simultaneously causing overselling
**Solution**: Implemented optimistic locking with Redis to ensure inventory accuracy during concurrent purchases
### Challenge 2: Payment Processing
**Problem**: Handling payment failures gracefully while maintaining order integrity
**Solution**: Built robust state machine for order processing with automatic retry logic and customer notifications
### Challenge 3: Performance at Scale
**Problem**: Slow page loads during traffic spikes
**Solution**: Implemented multi-layer caching strategy (CDN, Redis, in-memory) and database query optimization
## Results
- **Performance**: 60% faster page load times compared to previous platform
- **Conversion**: 25% increase in conversion rate due to improved UX
- **Uptime**: 99.9% uptime over 6 months in production
- **Scale**: Successfully handled Black Friday with 10k concurrent users
- **Revenue**: Processing over $50k in monthly transactions
## Tech Stack Details
**Frontend**
- React 18 with TypeScript
- Tailwind CSS for styling
- React Query for data fetching
- React Hook Form for forms
**Backend**
- Node.js with Express
- PostgreSQL with Prisma ORM
- Redis for caching and sessions
- Bull for job queues
**Infrastructure**
- Docker containers
- AWS EC2 for hosting
- AWS S3 for image storage
- Cloudflare CDN
- GitHub Actions for CI/CD
**Payment & Services**
- Stripe for payments
- SendGrid for emails
- Sentry for error tracking
## Future Improvements
- [ ] Mobile app (React Native)
- [ ] Multi-language support
- [ ] Wishlist and product recommendations
- [ ] Live chat support
- [ ] Advanced analytics dashboard
## Screenshots
*(Screenshots would go here in production)*
## Lessons Learned
1. **Start with Performance**: Built with performance in mind from day one rather than optimizing later
2. **Testing Matters**: Comprehensive test suite caught critical bugs before production
3. **Monitor Everything**: Proper logging and monitoring essential for maintaining uptime
4. **User Feedback**: Regular user testing revealed UX issues we wouldn't have found otherwise
---
**Project Status**: ✅ Live in Production
**GitHub**: [View Source Code](https://github.com/alexjohnson/ecommerce-platform)
**Demo**: [Try it Live](https://shop-demo.example.com)
Binary file not shown.

After

Width:  |  Height:  |  Size: 467 KiB

@@ -0,0 +1,170 @@
---
title: "TaskFlow - Project Management Tool"
date: 2024-09-20
summary: "Real-time collaborative task management application with drag-and-drop Kanban boards and team features"
tags:
- Full-Stack
- Next.js
- Real-Time
- Productivity
tech_stack:
- Next.js
- TypeScript
- Prisma
- PostgreSQL
- WebSockets
- Tailwind CSS
links:
- type: github
url: https://github.com/alexjohnson/taskflow
label: Code
- type: live
url: https://taskflow-demo.example.com
label: Demo
featured: true
status: "Live"
role: "Solo Developer"
duration: "2 months"
team_size: 1
highlights:
- "Real-time collaboration with WebSockets"
- "2000+ active users"
- "Featured on Product Hunt"
---
A modern, intuitive task management tool built for remote teams. Features real-time collaboration, customizable workflows, and beautiful UI.
## Overview
TaskFlow was born out of frustration with existing project management tools being either too complex or lacking essential features. I built a solution that's powerful yet simple to use.
## Key Features
### Core Functionality
- **Kanban Boards** - Drag-and-drop interface for visual task management
- **Real-Time Sync** - See changes instantly as team members update tasks
- **Multiple Views** - Switch between Kanban, List, and Calendar views
- **Task Details** - Rich descriptions, attachments, comments, and checklists
- **Labels & Filters** - Organize and find tasks quickly
### Collaboration
- **Team Workspaces** - Separate spaces for different projects/teams
- **@Mentions** - Tag team members in comments for notifications
- **Activity Feed** - Track all changes and updates
- **Permissions** - Role-based access control (admin, member, viewer)
### Productivity
- **Keyboard Shortcuts** - Power user features for faster navigation
- **Templates** - Reusable board templates for common workflows
- **Due Dates & Reminders** - Never miss a deadline
- **Time Tracking** - Built-in timer for task duration tracking
## Technical Implementation
### Real-Time Features
Used WebSockets (Socket.io) for instant updates across all connected clients. Implemented optimistic UI updates for snappy user experience even before server confirmation.
### Drag & Drop
Built custom drag-and-drop using react-beautiful-dnd with smooth animations and mobile touch support.
### Performance
- Implemented virtual scrolling for boards with 1000+ tasks
- Optimized database queries with proper indexing
- Used Redis for session storage and caching
- Image optimization with Next.js Image component
### Authentication
- Secure auth with NextAuth.js
- Support for email/password and OAuth (Google, GitHub)
- JWT tokens with automatic refresh
## Architecture
Built as a modern monolith with Next.js API routes:
```
┌─────────────┐ ┌──────────────┐ ┌─────────────┐
│ Next.js │────▶│ API Routes │────▶│ PostgreSQL │
│ (React) │ │ (REST/WS) │ │ + Prisma │
└─────────────┘ └──────────────┘ └─────────────┘
│ │
│ ┌──────▼───────┐
└────────────▶│ Socket.io │
│ (Real-Time) │
└──────────────┘
```
## Challenges Solved
### Real-Time Conflicts
**Problem**: Multiple users editing same task simultaneously
**Solution**: Implemented operational transformation (OT) for conflict resolution and last-write-wins strategy with conflict notifications
### Mobile Performance
**Problem**: Drag-and-drop laggy on mobile devices
**Solution**: Optimized touch handlers and reduced re-renders using React.memo and useMemo
### Scale
**Problem**: Growing user base causing performance issues
**Solution**: Added Redis caching layer and optimized database queries, reducing response time by 65%
## Results
- 📈 **Users**: 2000+ active users within 3 months
- ⭐ **Product Hunt**: Featured and received 200+ upvotes
- 🚀 **Performance**: Sub-100ms API response times
- 💯 **Uptime**: 99.8% uptime since launch
- 📱 **Mobile**: 40% of traffic from mobile devices
## Tech Stack
**Frontend**
- Next.js 14 (App Router)
- TypeScript
- Tailwind CSS
- Framer Motion (animations)
- React Beautiful DnD
**Backend**
- Next.js API Routes
- Prisma ORM
- PostgreSQL
- Socket.io for WebSockets
- NextAuth.js for authentication
**Infrastructure**
- Vercel for hosting
- Supabase for PostgreSQL
- Redis Cloud for caching
- AWS S3 for file storage
## User Feedback
> "Finally, a task manager that doesn't get in my way. The real-time updates are magical!" - Sarah K., Product Manager
> "We switched from Trello and haven't looked back. TaskFlow is faster and more intuitive." - Mike R., Engineering Lead
## Open Source
TaskFlow is open source! Contributions welcome.
**License**: MIT
**GitHub**: [alexjohnson/taskflow](https://github.com/alexjohnson/taskflow)
**Demo**: [Try it live](https://taskflow-demo.example.com)
## What's Next
Currently working on:
- [ ] Mobile apps (iOS & Android)
- [ ] Gantt chart view
- [ ] Advanced reporting and analytics
- [ ] API for third-party integrations
- [ ] Offline mode support
---
**Status**: ✅ Live & Actively Maintained
**Try it**: [taskflow-demo.example.com](https://taskflow-demo.example.com)
Binary file not shown.

After

Width:  |  Height:  |  Size: 474 KiB

@@ -0,0 +1,179 @@
---
title: "WeatherNow - Real-Time Weather App"
date: 2024-06-10
summary: "Beautiful weather application with real-time data, 7-day forecasts, and interactive maps"
tags:
- Frontend
- React
- API Integration
- PWA
tech_stack:
- React
- TypeScript
- OpenWeather API
- Mapbox
- Tailwind CSS
- Vite
links:
- type: github
url: https://github.com/alexjohnson/weather-now
label: Code
- type: live
url: https://weathernow-demo.netlify.app
label: Demo
featured: false
status: "Live"
role: "Solo Developer"
duration: "3 weeks"
team_size: 1
highlights:
- "PWA with offline support"
- "5000+ monthly active users"
- "Lighthouse score: 100"
---
A fast, beautiful weather application that provides real-time weather data, forecasts, and interactive maps. Built as a Progressive Web App with offline support.
## Overview
WeatherNow started as a weekend project to learn PWA development. It evolved into a fully-featured weather app used by thousands of people daily.
## Features
### Weather Data
- **Current Conditions** - Real-time weather for any location
- **7-Day Forecast** - Detailed daily forecasts with hourly breakdown
- **Weather Alerts** - Severe weather notifications
- **Historical Data** - Past weather data and trends
### User Experience
- **Location Detection** - Automatic location based on GPS or IP
- **Search** - Find weather for any city worldwide
- **Favorites** - Save frequently checked locations
- **Units** - Toggle between metric and imperial
- **Dark Mode** - Automatic or manual theme switching
### Progressive Web App
- **Offline Support** - Access cached data without internet
- **Install** - Add to home screen like a native app
- **Fast** - Optimized for performance (Lighthouse 100)
- **Responsive** - Perfect on any device
## Technical Highlights
### Performance
- Achieved **100/100 Lighthouse score** across all categories
- Implemented service workers for offline functionality
- Optimized bundle size: < 150KB gzipped
- Lazy loading for images and components
- Prefetching for instant navigation
### Data Management
- Smart caching strategy with stale-while-revalidate
- Background sync for updated forecasts
- Efficient API usage with request batching
- Local storage for user preferences
### UI/UX
- Smooth animations with Framer Motion
- Interactive weather map with Mapbox
- Weather icons that match current conditions
- Accessible (WCAG AA compliant)
## API Integration
Integrated multiple weather APIs for comprehensive data:
```typescript
// Example: Fetching weather data
const fetchWeather = async (lat: number, lon: number) => {
const response = await fetch(
`https://api.openweathermap.org/data/2.5/weather?lat=${lat}&lon=${lon}&appid=${API_KEY}`
)
return response.json()
}
```
## Architecture
```
┌─────────────┐ ┌──────────────┐
│ React App │────▶│ OpenWeather │
│ (Vite) │ │ API │
└──────┬──────┘ └──────────────┘
│
│ ┌──────────────┐
└───────────▶│ Mapbox │
│ API │
└──────────────┘
```
## Challenges
### Challenge: API Rate Limits
**Solution**: Implemented intelligent caching and request batching to stay within free tier limits while maintaining data freshness
### Challenge: Offline Experience
**Solution**: Service workers with cache-first strategy for UI, network-first for data with graceful fallbacks
### Challenge: Performance on Slow Networks
**Solution**: Image optimization, code splitting, and Progressive rendering for instant perceived performance
## Results
- 📈 **Users**: 5000+ monthly active users
- ⚡ **Performance**: 100 Lighthouse score
- 📱 **PWA**: 40% of users installed as app
- 🌍 **Global**: Users from 50+ countries
- ⭐ **Rating**: 4.8/5 average user rating
## Tech Stack
**Frontend**
- React 18
- TypeScript
- Tailwind CSS
- Framer Motion
- Vite (build tool)
**APIs**
- OpenWeather API
- Mapbox GL JS
- IP Geolocation API
**Tools**
- Workbox (PWA)
- React Query (data fetching)
- Zustand (state management)
**Hosting**
- Netlify
- Cloudflare CDN
## Open Source
This project is open source and welcomes contributions!
**License**: MIT
**GitHub**: [alexjohnson/weather-now](https://github.com/alexjohnson/weather-now)
## Lessons Learned
1. **PWAs are powerful**: Service workers enable app-like experiences on the web
2. **Performance matters**: Users notice and appreciate fast apps
3. **Caching strategy**: Critical for offline support and API cost management
4. **Simplicity wins**: Started with core features, added more based on user feedback
## Future Plans
- [ ] Weather widgets for websites
- [ ] Social features (share weather conditions)
- [ ] Weather photography integration
- [ ] Apple Watch & Android Wear apps
- [ ] Premium features (ad-free, extended forecasts)
---
**Status**: ✅ Live & Maintained
**Try it**: [weathernow-demo.netlify.app](https://weathernow-demo.netlify.app)
**GitHub**: [@alexjohnson/weather-now](https://github.com/alexjohnson/weather-now)
@@ -0,0 +1,100 @@
schema: hugoblox/author/v1
slug: me
is_owner: true
name:
display: Alex Johnson
given: Alex
family: Johnson
postnominals: []
status:
icon: "💻" # Emoji shown on avatar badge: 💻 developer, 🎨 designer, 🚀 startup, 🔬 researcher, 🎓 student
role: Full-Stack Developer
email: alex@example.com
bio: |
Full-stack developer with 5+ years of experience building scalable web applications.
Passionate about modern JavaScript, cloud architecture, and creating delightful user experiences.
affiliations:
- name: Tech Corp
url: https://techcorp.example.com
links:
- icon: at-symbol
url: mailto:alex@example.com
label: E-mail Me
- icon: brands/github
url: https://github.com/alexjohnson
- icon: brands/linkedin
url: https://www.linkedin.com/in/alexjohnson/
- icon: brands/x
url: https://x.com/alexjohnson
interests:
- Web Development
- Cloud Architecture
- Open Source
- Developer Experience
education:
- degree: B.S. Computer Science
institution: State University
start: 2015-09-01
end: 2019-05-31
summary: |
Focus on software engineering and distributed systems.
Senior project on distributed caching systems.
button:
text: View Project
url: https://github.com/alexjohnson/senior-project
icon: brands/github
experience:
- role: Senior Software Engineer
org: Tech Corp
start: 2023-01-01
summary: |
Lead development of microservices architecture serving 1M+ users.
Improved API response time by 40% through optimization.
Mentored team of 5 junior developers.
- role: Full-Stack Developer
org: Startup Inc
start: 2021-06-01
end: 2022-12-31
summary: |
Built and deployed 3 production applications from scratch.
Implemented CI/CD pipeline reducing deployment time by 60%.
Led migration from monolith to microservices architecture.
- role: Junior Developer
org: Web Agency
start: 2020-01-01
end: 2021-05-31
summary: |
Developed client websites using React and modern web technologies.
Participated in code reviews and agile ceremonies.
skills:
- name: Development
items:
- label: TypeScript
level: 5
- label: React
level: 5
- label: Node.js
level: 5
- label: Python
level: 4
- name: DevOps
items:
- label: Docker
level: 4
- label: AWS
level: 4
- label: CI/CD
level: 5
languages:
- name: English
level: 5
label: Native
awards: []
+8
View File
@@ -0,0 +1,8 @@
module github.com/HugoBlox/kit/templates/dev-portfolio-free
go 1.19
require (
github.com/HugoBlox/kit/modules/integrations/netlify v0.0.0-20260102210655-66cba27dc652
github.com/HugoBlox/kit/modules/blox v0.0.0-20260102211810-cdaf97afb270
)
+5
View File
@@ -0,0 +1,5 @@
build:
hugo_version: '0.154.2'
template:
id: "dev-portfolio"
name: "Developer Portfolio"
+61
View File
@@ -0,0 +1,61 @@
[build]
command = """
set -e
echo "=== Starting Blog build process ==="
echo "Node version: $(node --version)"
echo "pnpm version: $(pnpm --version)"
echo "Hugo version: $(hugo version)"
echo "Go version: $(go version || echo 'Go not available')"
echo "=== Installing dependencies ==="
pnpm install --verbose --no-frozen-lockfile
echo "=== Running Hugo build ==="
hugo --gc --minify -b $URL --logLevel debug --printI18nWarnings --printPathWarnings
echo "=== Running Pagefind indexing ==="
pnpm run pagefind
echo "=== Build completed successfully ==="
"""
publish = "public"
[build.environment]
HUGO_VERSION = "0.154.2"
GO_VERSION = "1.21.5"
NODE_VERSION = "22"
# Netlify runs an implicit install step; ensure it never enforces frozen lockfiles
PNPM_FLAGS = "--no-frozen-lockfile"
HUGO_ENABLEGITINFO = "true"
HUGO_LOG_I18N_WARNINGS = "true"
HUGO_LOG_WARNINGS = "true"
FORCE_COLOR = "1"
PNPM_LOG_LEVEL = "debug"
[context.production.environment]
HUGO_ENV = "production"
[context.deploy-preview]
command = """
set -e
echo "=== Deploy Preview Build ==="
echo "Deploy URL: $DEPLOY_PRIME_URL"
pnpm install --verbose --no-frozen-lockfile
hugo --gc --minify --buildFuture -b $DEPLOY_PRIME_URL --logLevel debug --printI18nWarnings --printPathWarnings
pnpm run pagefind
"""
[context.branch-deploy]
command = """
set -e
echo "=== Branch Deploy Build ==="
echo "Deploy URL: $DEPLOY_PRIME_URL"
pnpm install --verbose --no-frozen-lockfile
hugo --gc --minify -b $DEPLOY_PRIME_URL --logLevel debug --printI18nWarnings --printPathWarnings
pnpm run pagefind
"""
[[plugins]]
package = "netlify-plugin-hugo-cache-resources"
[plugins.inputs]
debug = true
+19
View File
@@ -0,0 +1,19 @@
{
"name": "hugo-blox-dev-portfolio-free",
"version": "1.0.0",
"private": true,
"packageManager": "pnpm@10.14.0",
"description": "Modern developer portfolio template for Hugo Blox with Tailwind CSS v4 - Free version",
"scripts": {
"dev": "hugo server --disableFastRender",
"build": "hugo --minify && pnpm run pagefind",
"pagefind": "pagefind --site public"
},
"dependencies": {
"@tailwindcss/cli": "^4.1.12",
"@tailwindcss/typography": "^0.5.10",
"pagefind": "^1.4.0",
"preact": "^10.27.2",
"tailwindcss": "^4.1.12"
}
}
+714
View File
@@ -0,0 +1,714 @@
lockfileVersion: '9.0'
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
importers:
.:
dependencies:
'@tailwindcss/cli':
specifier: ^4.1.12
version: 4.1.18
'@tailwindcss/typography':
specifier: ^0.5.10
version: 0.5.19(tailwindcss@4.1.18)
pagefind:
specifier: ^1.4.0
version: 1.4.0
preact:
specifier: ^10.27.2
version: 10.28.1
tailwindcss:
specifier: ^4.1.12
version: 4.1.18
packages:
'@jridgewell/gen-mapping@0.3.13':
resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==}
'@jridgewell/remapping@2.3.5':
resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==}
'@jridgewell/resolve-uri@3.1.2':
resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==}
engines: {node: '>=6.0.0'}
'@jridgewell/sourcemap-codec@1.5.5':
resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==}
'@jridgewell/trace-mapping@0.3.31':
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
'@pagefind/darwin-arm64@1.4.0':
resolution: {integrity: sha512-2vMqkbv3lbx1Awea90gTaBsvpzgRs7MuSgKDxW0m9oV1GPZCZbZBJg/qL83GIUEN2BFlY46dtUZi54pwH+/pTQ==}
cpu: [arm64]
os: [darwin]
'@pagefind/darwin-x64@1.4.0':
resolution: {integrity: sha512-e7JPIS6L9/cJfow+/IAqknsGqEPjJnVXGjpGm25bnq+NPdoD3c/7fAwr1OXkG4Ocjx6ZGSCijXEV4ryMcH2E3A==}
cpu: [x64]
os: [darwin]
'@pagefind/freebsd-x64@1.4.0':
resolution: {integrity: sha512-WcJVypXSZ+9HpiqZjFXMUobfFfZZ6NzIYtkhQ9eOhZrQpeY5uQFqNWLCk7w9RkMUwBv1HAMDW3YJQl/8OqsV0Q==}
cpu: [x64]
os: [freebsd]
'@pagefind/linux-arm64@1.4.0':
resolution: {integrity: sha512-PIt8dkqt4W06KGmQjONw7EZbhDF+uXI7i0XtRLN1vjCUxM9vGPdtJc2mUyVPevjomrGz5M86M8bqTr6cgDp1Uw==}
cpu: [arm64]
os: [linux]
'@pagefind/linux-x64@1.4.0':
resolution: {integrity: sha512-z4oddcWwQ0UHrTHR8psLnVlz6USGJ/eOlDPTDYZ4cI8TK8PgwRUPQZp9D2iJPNIPcS6Qx/E4TebjuGJOyK8Mmg==}
cpu: [x64]
os: [linux]
'@pagefind/windows-x64@1.4.0':
resolution: {integrity: sha512-NkT+YAdgS2FPCn8mIA9bQhiBs+xmniMGq1LFPDhcFn0+2yIUEiIG06t7bsZlhdjknEQRTSdT7YitP6fC5qwP0g==}
cpu: [x64]
os: [win32]
'@parcel/watcher-android-arm64@2.5.1':
resolution: {integrity: sha512-KF8+j9nNbUN8vzOFDpRMsaKBHZ/mcjEjMToVMJOhTozkDonQFFrRcfdLWn6yWKCmJKmdVxSgHiYvTCef4/qcBA==}
engines: {node: '>= 10.0.0'}
cpu: [arm64]
os: [android]
'@parcel/watcher-darwin-arm64@2.5.1':
resolution: {integrity: sha512-eAzPv5osDmZyBhou8PoF4i6RQXAfeKL9tjb3QzYuccXFMQU0ruIc/POh30ePnaOyD1UXdlKguHBmsTs53tVoPw==}
engines: {node: '>= 10.0.0'}
cpu: [arm64]
os: [darwin]
'@parcel/watcher-darwin-x64@2.5.1':
resolution: {integrity: sha512-1ZXDthrnNmwv10A0/3AJNZ9JGlzrF82i3gNQcWOzd7nJ8aj+ILyW1MTxVk35Db0u91oD5Nlk9MBiujMlwmeXZg==}
engines: {node: '>= 10.0.0'}
cpu: [x64]
os: [darwin]
'@parcel/watcher-freebsd-x64@2.5.1':
resolution: {integrity: sha512-SI4eljM7Flp9yPuKi8W0ird8TI/JK6CSxju3NojVI6BjHsTyK7zxA9urjVjEKJ5MBYC+bLmMcbAWlZ+rFkLpJQ==}
engines: {node: '>= 10.0.0'}
cpu: [x64]
os: [freebsd]
'@parcel/watcher-linux-arm-glibc@2.5.1':
resolution: {integrity: sha512-RCdZlEyTs8geyBkkcnPWvtXLY44BCeZKmGYRtSgtwwnHR4dxfHRG3gR99XdMEdQ7KeiDdasJwwvNSF5jKtDwdA==}
engines: {node: '>= 10.0.0'}
cpu: [arm]
os: [linux]
'@parcel/watcher-linux-arm-musl@2.5.1':
resolution: {integrity: sha512-6E+m/Mm1t1yhB8X412stiKFG3XykmgdIOqhjWj+VL8oHkKABfu/gjFj8DvLrYVHSBNC+/u5PeNrujiSQ1zwd1Q==}
engines: {node: '>= 10.0.0'}
cpu: [arm]
os: [linux]
'@parcel/watcher-linux-arm64-glibc@2.5.1':
resolution: {integrity: sha512-LrGp+f02yU3BN9A+DGuY3v3bmnFUggAITBGriZHUREfNEzZh/GO06FF5u2kx8x+GBEUYfyTGamol4j3m9ANe8w==}
engines: {node: '>= 10.0.0'}
cpu: [arm64]
os: [linux]
'@parcel/watcher-linux-arm64-musl@2.5.1':
resolution: {integrity: sha512-cFOjABi92pMYRXS7AcQv9/M1YuKRw8SZniCDw0ssQb/noPkRzA+HBDkwmyOJYp5wXcsTrhxO0zq1U11cK9jsFg==}
engines: {node: '>= 10.0.0'}
cpu: [arm64]
os: [linux]
'@parcel/watcher-linux-x64-glibc@2.5.1':
resolution: {integrity: sha512-GcESn8NZySmfwlTsIur+49yDqSny2IhPeZfXunQi48DMugKeZ7uy1FX83pO0X22sHntJ4Ub+9k34XQCX+oHt2A==}
engines: {node: '>= 10.0.0'}
cpu: [x64]
os: [linux]
'@parcel/watcher-linux-x64-musl@2.5.1':
resolution: {integrity: sha512-n0E2EQbatQ3bXhcH2D1XIAANAcTZkQICBPVaxMeaCVBtOpBZpWJuf7LwyWPSBDITb7In8mqQgJ7gH8CILCURXg==}
engines: {node: '>= 10.0.0'}
cpu: [x64]
os: [linux]
'@parcel/watcher-win32-arm64@2.5.1':
resolution: {integrity: sha512-RFzklRvmc3PkjKjry3hLF9wD7ppR4AKcWNzH7kXR7GUe0Igb3Nz8fyPwtZCSquGrhU5HhUNDr/mKBqj7tqA2Vw==}
engines: {node: '>= 10.0.0'}
cpu: [arm64]
os: [win32]
'@parcel/watcher-win32-ia32@2.5.1':
resolution: {integrity: sha512-c2KkcVN+NJmuA7CGlaGD1qJh1cLfDnQsHjE89E60vUEMlqduHGCdCLJCID5geFVM0dOtA3ZiIO8BoEQmzQVfpQ==}
engines: {node: '>= 10.0.0'}
cpu: [ia32]
os: [win32]
'@parcel/watcher-win32-x64@2.5.1':
resolution: {integrity: sha512-9lHBdJITeNR++EvSQVUcaZoWupyHfXe1jZvGZ06O/5MflPcuPLtEphScIBL+AiCWBO46tDSHzWyD0uDmmZqsgA==}
engines: {node: '>= 10.0.0'}
cpu: [x64]
os: [win32]
'@parcel/watcher@2.5.1':
resolution: {integrity: sha512-dfUnCxiN9H4ap84DvD2ubjw+3vUNpstxa0TneY/Paat8a3R4uQZDLSvWjmznAY/DoahqTHl9V46HF/Zs3F29pg==}
engines: {node: '>= 10.0.0'}
'@tailwindcss/cli@4.1.18':
resolution: {integrity: sha512-sMZ+lZbDyxwjD2E0L7oRUjJ01Ffjtme5OtjvvnC+cV4CEDcbqzbp25TCpxHj6kWLU9+DlqJOiNgSOgctC2aZmg==}
hasBin: true
'@tailwindcss/node@4.1.18':
resolution: {integrity: sha512-DoR7U1P7iYhw16qJ49fgXUlry1t4CpXeErJHnQ44JgTSKMaZUdf17cfn5mHchfJ4KRBZRFA/Coo+MUF5+gOaCQ==}
'@tailwindcss/oxide-android-arm64@4.1.18':
resolution: {integrity: sha512-dJHz7+Ugr9U/diKJA0W6N/6/cjI+ZTAoxPf9Iz9BFRF2GzEX8IvXxFIi/dZBloVJX/MZGvRuFA9rqwdiIEZQ0Q==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [android]
'@tailwindcss/oxide-darwin-arm64@4.1.18':
resolution: {integrity: sha512-Gc2q4Qhs660bhjyBSKgq6BYvwDz4G+BuyJ5H1xfhmDR3D8HnHCmT/BSkvSL0vQLy/nkMLY20PQ2OoYMO15Jd0A==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [darwin]
'@tailwindcss/oxide-darwin-x64@4.1.18':
resolution: {integrity: sha512-FL5oxr2xQsFrc3X9o1fjHKBYBMD1QZNyc1Xzw/h5Qu4XnEBi3dZn96HcHm41c/euGV+GRiXFfh2hUCyKi/e+yw==}
engines: {node: '>= 10'}
cpu: [x64]
os: [darwin]
'@tailwindcss/oxide-freebsd-x64@4.1.18':
resolution: {integrity: sha512-Fj+RHgu5bDodmV1dM9yAxlfJwkkWvLiRjbhuO2LEtwtlYlBgiAT4x/j5wQr1tC3SANAgD+0YcmWVrj8R9trVMA==}
engines: {node: '>= 10'}
cpu: [x64]
os: [freebsd]
'@tailwindcss/oxide-linux-arm-gnueabihf@4.1.18':
resolution: {integrity: sha512-Fp+Wzk/Ws4dZn+LV2Nqx3IilnhH51YZoRaYHQsVq3RQvEl+71VGKFpkfHrLM/Li+kt5c0DJe/bHXK1eHgDmdiA==}
engines: {node: '>= 10'}
cpu: [arm]
os: [linux]
'@tailwindcss/oxide-linux-arm64-gnu@4.1.18':
resolution: {integrity: sha512-S0n3jboLysNbh55Vrt7pk9wgpyTTPD0fdQeh7wQfMqLPM/Hrxi+dVsLsPrycQjGKEQk85Kgbx+6+QnYNiHalnw==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
'@tailwindcss/oxide-linux-arm64-musl@4.1.18':
resolution: {integrity: sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
'@tailwindcss/oxide-linux-x64-gnu@4.1.18':
resolution: {integrity: sha512-v3gyT0ivkfBLoZGF9LyHmts0Isc8jHZyVcbzio6Wpzifg/+5ZJpDiRiUhDLkcr7f/r38SWNe7ucxmGW3j3Kb/g==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
'@tailwindcss/oxide-linux-x64-musl@4.1.18':
resolution: {integrity: sha512-bhJ2y2OQNlcRwwgOAGMY0xTFStt4/wyU6pvI6LSuZpRgKQwxTec0/3Scu91O8ir7qCR3AuepQKLU/kX99FouqQ==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
'@tailwindcss/oxide-wasm32-wasi@4.1.18':
resolution: {integrity: sha512-LffYTvPjODiP6PT16oNeUQJzNVyJl1cjIebq/rWWBF+3eDst5JGEFSc5cWxyRCJ0Mxl+KyIkqRxk1XPEs9x8TA==}
engines: {node: '>=14.0.0'}
cpu: [wasm32]
bundledDependencies:
- '@napi-rs/wasm-runtime'
- '@emnapi/core'
- '@emnapi/runtime'
- '@tybys/wasm-util'
- '@emnapi/wasi-threads'
- tslib
'@tailwindcss/oxide-win32-arm64-msvc@4.1.18':
resolution: {integrity: sha512-HjSA7mr9HmC8fu6bdsZvZ+dhjyGCLdotjVOgLA2vEqxEBZaQo9YTX4kwgEvPCpRh8o4uWc4J/wEoFzhEmjvPbA==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [win32]
'@tailwindcss/oxide-win32-x64-msvc@4.1.18':
resolution: {integrity: sha512-bJWbyYpUlqamC8dpR7pfjA0I7vdF6t5VpUGMWRkXVE3AXgIZjYUYAK7II1GNaxR8J1SSrSrppRar8G++JekE3Q==}
engines: {node: '>= 10'}
cpu: [x64]
os: [win32]
'@tailwindcss/oxide@4.1.18':
resolution: {integrity: sha512-EgCR5tTS5bUSKQgzeMClT6iCY3ToqE1y+ZB0AKldj809QXk1Y+3jB0upOYZrn9aGIzPtUsP7sX4QQ4XtjBB95A==}
engines: {node: '>= 10'}
'@tailwindcss/typography@0.5.19':
resolution: {integrity: sha512-w31dd8HOx3k9vPtcQh5QHP9GwKcgbMp87j58qi6xgiBnFFtKEAgCWnDw4qUT8aHwkCp8bKvb/KGKWWHedP0AAg==}
peerDependencies:
tailwindcss: '>=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1'
braces@3.0.3:
resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==}
engines: {node: '>=8'}
cssesc@3.0.0:
resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==}
engines: {node: '>=4'}
hasBin: true
detect-libc@1.0.3:
resolution: {integrity: sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==}
engines: {node: '>=0.10'}
hasBin: true
detect-libc@2.1.2:
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
engines: {node: '>=8'}
enhanced-resolve@5.18.4:
resolution: {integrity: sha512-LgQMM4WXU3QI+SYgEc2liRgznaD5ojbmY3sb8LxyguVkIg5FxdpTkvk72te2R38/TGKxH634oLxXRGY6d7AP+Q==}
engines: {node: '>=10.13.0'}
fill-range@7.1.1:
resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==}
engines: {node: '>=8'}
graceful-fs@4.2.11:
resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
is-extglob@2.1.1:
resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==}
engines: {node: '>=0.10.0'}
is-glob@4.0.3:
resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==}
engines: {node: '>=0.10.0'}
is-number@7.0.0:
resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==}
engines: {node: '>=0.12.0'}
jiti@2.6.1:
resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==}
hasBin: true
lightningcss-android-arm64@1.30.2:
resolution: {integrity: sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [android]
lightningcss-darwin-arm64@1.30.2:
resolution: {integrity: sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [darwin]
lightningcss-darwin-x64@1.30.2:
resolution: {integrity: sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [darwin]
lightningcss-freebsd-x64@1.30.2:
resolution: {integrity: sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [freebsd]
lightningcss-linux-arm-gnueabihf@1.30.2:
resolution: {integrity: sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA==}
engines: {node: '>= 12.0.0'}
cpu: [arm]
os: [linux]
lightningcss-linux-arm64-gnu@1.30.2:
resolution: {integrity: sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
lightningcss-linux-arm64-musl@1.30.2:
resolution: {integrity: sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
lightningcss-linux-x64-gnu@1.30.2:
resolution: {integrity: sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
lightningcss-linux-x64-musl@1.30.2:
resolution: {integrity: sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
lightningcss-win32-arm64-msvc@1.30.2:
resolution: {integrity: sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [win32]
lightningcss-win32-x64-msvc@1.30.2:
resolution: {integrity: sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [win32]
lightningcss@1.30.2:
resolution: {integrity: sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==}
engines: {node: '>= 12.0.0'}
magic-string@0.30.21:
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
micromatch@4.0.8:
resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==}
engines: {node: '>=8.6'}
mri@1.2.0:
resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==}
engines: {node: '>=4'}
node-addon-api@7.1.1:
resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==}
pagefind@1.4.0:
resolution: {integrity: sha512-z2kY1mQlL4J8q5EIsQkLzQjilovKzfNVhX8De6oyE6uHpfFtyBaqUpcl/XzJC/4fjD8vBDyh1zolimIcVrCn9g==}
hasBin: true
picocolors@1.1.1:
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
picomatch@2.3.1:
resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==}
engines: {node: '>=8.6'}
postcss-selector-parser@6.0.10:
resolution: {integrity: sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==}
engines: {node: '>=4'}
preact@10.28.1:
resolution: {integrity: sha512-u1/ixq/lVQI0CakKNvLDEcW5zfCjUQfZdK9qqWuIJtsezuyG6pk9TWj75GMuI/EzRSZB/VAE43sNWWZfiy8psw==}
source-map-js@1.2.1:
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
engines: {node: '>=0.10.0'}
tailwindcss@4.1.18:
resolution: {integrity: sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==}
tapable@2.3.0:
resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==}
engines: {node: '>=6'}
to-regex-range@5.0.1:
resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==}
engines: {node: '>=8.0'}
util-deprecate@1.0.2:
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
snapshots:
'@jridgewell/gen-mapping@0.3.13':
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
'@jridgewell/trace-mapping': 0.3.31
'@jridgewell/remapping@2.3.5':
dependencies:
'@jridgewell/gen-mapping': 0.3.13
'@jridgewell/trace-mapping': 0.3.31
'@jridgewell/resolve-uri@3.1.2': {}
'@jridgewell/sourcemap-codec@1.5.5': {}
'@jridgewell/trace-mapping@0.3.31':
dependencies:
'@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.5
'@pagefind/darwin-arm64@1.4.0':
optional: true
'@pagefind/darwin-x64@1.4.0':
optional: true
'@pagefind/freebsd-x64@1.4.0':
optional: true
'@pagefind/linux-arm64@1.4.0':
optional: true
'@pagefind/linux-x64@1.4.0':
optional: true
'@pagefind/windows-x64@1.4.0':
optional: true
'@parcel/watcher-android-arm64@2.5.1':
optional: true
'@parcel/watcher-darwin-arm64@2.5.1':
optional: true
'@parcel/watcher-darwin-x64@2.5.1':
optional: true
'@parcel/watcher-freebsd-x64@2.5.1':
optional: true
'@parcel/watcher-linux-arm-glibc@2.5.1':
optional: true
'@parcel/watcher-linux-arm-musl@2.5.1':
optional: true
'@parcel/watcher-linux-arm64-glibc@2.5.1':
optional: true
'@parcel/watcher-linux-arm64-musl@2.5.1':
optional: true
'@parcel/watcher-linux-x64-glibc@2.5.1':
optional: true
'@parcel/watcher-linux-x64-musl@2.5.1':
optional: true
'@parcel/watcher-win32-arm64@2.5.1':
optional: true
'@parcel/watcher-win32-ia32@2.5.1':
optional: true
'@parcel/watcher-win32-x64@2.5.1':
optional: true
'@parcel/watcher@2.5.1':
dependencies:
detect-libc: 1.0.3
is-glob: 4.0.3
micromatch: 4.0.8
node-addon-api: 7.1.1
optionalDependencies:
'@parcel/watcher-android-arm64': 2.5.1
'@parcel/watcher-darwin-arm64': 2.5.1
'@parcel/watcher-darwin-x64': 2.5.1
'@parcel/watcher-freebsd-x64': 2.5.1
'@parcel/watcher-linux-arm-glibc': 2.5.1
'@parcel/watcher-linux-arm-musl': 2.5.1
'@parcel/watcher-linux-arm64-glibc': 2.5.1
'@parcel/watcher-linux-arm64-musl': 2.5.1
'@parcel/watcher-linux-x64-glibc': 2.5.1
'@parcel/watcher-linux-x64-musl': 2.5.1
'@parcel/watcher-win32-arm64': 2.5.1
'@parcel/watcher-win32-ia32': 2.5.1
'@parcel/watcher-win32-x64': 2.5.1
'@tailwindcss/cli@4.1.18':
dependencies:
'@parcel/watcher': 2.5.1
'@tailwindcss/node': 4.1.18
'@tailwindcss/oxide': 4.1.18
enhanced-resolve: 5.18.4
mri: 1.2.0
picocolors: 1.1.1
tailwindcss: 4.1.18
'@tailwindcss/node@4.1.18':
dependencies:
'@jridgewell/remapping': 2.3.5
enhanced-resolve: 5.18.4
jiti: 2.6.1
lightningcss: 1.30.2
magic-string: 0.30.21
source-map-js: 1.2.1
tailwindcss: 4.1.18
'@tailwindcss/oxide-android-arm64@4.1.18':
optional: true
'@tailwindcss/oxide-darwin-arm64@4.1.18':
optional: true
'@tailwindcss/oxide-darwin-x64@4.1.18':
optional: true
'@tailwindcss/oxide-freebsd-x64@4.1.18':
optional: true
'@tailwindcss/oxide-linux-arm-gnueabihf@4.1.18':
optional: true
'@tailwindcss/oxide-linux-arm64-gnu@4.1.18':
optional: true
'@tailwindcss/oxide-linux-arm64-musl@4.1.18':
optional: true
'@tailwindcss/oxide-linux-x64-gnu@4.1.18':
optional: true
'@tailwindcss/oxide-linux-x64-musl@4.1.18':
optional: true
'@tailwindcss/oxide-wasm32-wasi@4.1.18':
optional: true
'@tailwindcss/oxide-win32-arm64-msvc@4.1.18':
optional: true
'@tailwindcss/oxide-win32-x64-msvc@4.1.18':
optional: true
'@tailwindcss/oxide@4.1.18':
optionalDependencies:
'@tailwindcss/oxide-android-arm64': 4.1.18
'@tailwindcss/oxide-darwin-arm64': 4.1.18
'@tailwindcss/oxide-darwin-x64': 4.1.18
'@tailwindcss/oxide-freebsd-x64': 4.1.18
'@tailwindcss/oxide-linux-arm-gnueabihf': 4.1.18
'@tailwindcss/oxide-linux-arm64-gnu': 4.1.18
'@tailwindcss/oxide-linux-arm64-musl': 4.1.18
'@tailwindcss/oxide-linux-x64-gnu': 4.1.18
'@tailwindcss/oxide-linux-x64-musl': 4.1.18
'@tailwindcss/oxide-wasm32-wasi': 4.1.18
'@tailwindcss/oxide-win32-arm64-msvc': 4.1.18
'@tailwindcss/oxide-win32-x64-msvc': 4.1.18
'@tailwindcss/typography@0.5.19(tailwindcss@4.1.18)':
dependencies:
postcss-selector-parser: 6.0.10
tailwindcss: 4.1.18
braces@3.0.3:
dependencies:
fill-range: 7.1.1
cssesc@3.0.0: {}
detect-libc@1.0.3: {}
detect-libc@2.1.2: {}
enhanced-resolve@5.18.4:
dependencies:
graceful-fs: 4.2.11
tapable: 2.3.0
fill-range@7.1.1:
dependencies:
to-regex-range: 5.0.1
graceful-fs@4.2.11: {}
is-extglob@2.1.1: {}
is-glob@4.0.3:
dependencies:
is-extglob: 2.1.1
is-number@7.0.0: {}
jiti@2.6.1: {}
lightningcss-android-arm64@1.30.2:
optional: true
lightningcss-darwin-arm64@1.30.2:
optional: true
lightningcss-darwin-x64@1.30.2:
optional: true
lightningcss-freebsd-x64@1.30.2:
optional: true
lightningcss-linux-arm-gnueabihf@1.30.2:
optional: true
lightningcss-linux-arm64-gnu@1.30.2:
optional: true
lightningcss-linux-arm64-musl@1.30.2:
optional: true
lightningcss-linux-x64-gnu@1.30.2:
optional: true
lightningcss-linux-x64-musl@1.30.2:
optional: true
lightningcss-win32-arm64-msvc@1.30.2:
optional: true
lightningcss-win32-x64-msvc@1.30.2:
optional: true
lightningcss@1.30.2:
dependencies:
detect-libc: 2.1.2
optionalDependencies:
lightningcss-android-arm64: 1.30.2
lightningcss-darwin-arm64: 1.30.2
lightningcss-darwin-x64: 1.30.2
lightningcss-freebsd-x64: 1.30.2
lightningcss-linux-arm-gnueabihf: 1.30.2
lightningcss-linux-arm64-gnu: 1.30.2
lightningcss-linux-arm64-musl: 1.30.2
lightningcss-linux-x64-gnu: 1.30.2
lightningcss-linux-x64-musl: 1.30.2
lightningcss-win32-arm64-msvc: 1.30.2
lightningcss-win32-x64-msvc: 1.30.2
magic-string@0.30.21:
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
micromatch@4.0.8:
dependencies:
braces: 3.0.3
picomatch: 2.3.1
mri@1.2.0: {}
node-addon-api@7.1.1: {}
pagefind@1.4.0:
optionalDependencies:
'@pagefind/darwin-arm64': 1.4.0
'@pagefind/darwin-x64': 1.4.0
'@pagefind/freebsd-x64': 1.4.0
'@pagefind/linux-arm64': 1.4.0
'@pagefind/linux-x64': 1.4.0
'@pagefind/windows-x64': 1.4.0
picocolors@1.1.1: {}
picomatch@2.3.1: {}
postcss-selector-parser@6.0.10:
dependencies:
cssesc: 3.0.0
util-deprecate: 1.0.2
preact@10.28.1: {}
source-map-js@1.2.1: {}
tailwindcss@4.1.18: {}
tapable@2.3.0: {}
to-regex-range@5.0.1:
dependencies:
is-number: 7.0.0
util-deprecate@1.0.2: {}
Binary file not shown.