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
No files matched your search
@@ -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);
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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>
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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>`
|
||||
@@ -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>
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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 }}
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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: সমস্ত প্রকল্প দেখুন
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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 }}
|
||||
@@ -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" }}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
github: gcushen
|
||||
custom: https://hugoblox.com/sponsor/
|
||||
|
After Width: | Height: | Size: 485 KiB |
@@ -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
|
||||
@@ -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*
|
||||
@@ -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.
|
||||
@@ -0,0 +1,124 @@
|
||||
# [Developer Portfolio (Free)](https://hugoblox.com/templates/dev-portfolio/start?utm_source=github&utm_medium=readme)
|
||||
|
||||
[](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>
|
||||
•
|
||||
<a href="https://hugoblox.com/templates/?open=dev-portfolio&loading=true&utm_source=github&utm_medium=readme">Live Demo</a>
|
||||
•
|
||||
<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)
|
||||
|
After Width: | Height: | Size: 125 KiB |
@@ -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: ""
|
||||
@@ -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
|
||||
---
|
||||
|
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)!
|
||||
|
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)!
|
||||
|
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
|
||||
---
|
||||
|
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)
|
||||
|
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)
|
||||
|
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: []
|
||||
@@ -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
|
||||
)
|
||||
@@ -0,0 +1,5 @@
|
||||
build:
|
||||
hugo_version: '0.154.2'
|
||||
template:
|
||||
id: "dev-portfolio"
|
||||
name: "Developer Portfolio"
|
||||
@@ -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
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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: {}
|
||||