init
193
src/assets/js/main.js
Normal file
@@ -0,0 +1,193 @@
|
||||
// Add your javascript here
|
||||
import AOS from 'aos';
|
||||
|
||||
window.darkMode = false;
|
||||
|
||||
const stickyClasses = [];
|
||||
const unstickyClasses = [];
|
||||
const stickyClassesContainer = [
|
||||
"shadow-[0px_1px_4px_0_rgba(25,33,61,0.06)]",
|
||||
"rounded-[20px]",
|
||||
"bg-[#ffffff]/65",
|
||||
"border-[#ffffff]/65",
|
||||
"dark:border-neutral-600/40",
|
||||
"dark:bg-neutral-900/60",
|
||||
"backdrop-blur-2xl",
|
||||
"backdrop-brightness-120",
|
||||
];
|
||||
const unstickyClassesContainer = [
|
||||
"shadow-none",
|
||||
"border-transparent",
|
||||
"rounded-none",
|
||||
"bg-transparent",
|
||||
];
|
||||
let headerElement = null;
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
headerElement = document.getElementById("header");
|
||||
|
||||
if (
|
||||
localStorage.getItem("dark_mode") &&
|
||||
localStorage.getItem("dark_mode") === "true"
|
||||
) {
|
||||
window.darkMode = true;
|
||||
showNight();
|
||||
} else {
|
||||
showDay();
|
||||
}
|
||||
stickyHeaderFuncionality();
|
||||
applyMenuItemClasses();
|
||||
evaluateHeaderPosition();
|
||||
mobileMenuFunctionality();
|
||||
|
||||
// 初始化 AOS
|
||||
AOS.init({
|
||||
duration: 400,
|
||||
easing: 'ease-out-cubic',
|
||||
once: true,
|
||||
offset: 20,
|
||||
delay: 0,
|
||||
});
|
||||
});
|
||||
|
||||
// window.toggleDarkMode = function(){
|
||||
// document.documentElement.classList.toggle('dark');
|
||||
// if(document.documentElement.classList.contains('dark')){
|
||||
// localStorage.setItem('dark_mode', true);
|
||||
// window.darkMode = true;
|
||||
// } else {
|
||||
// window.darkMode = false;
|
||||
// localStorage.setItem('dark_mode', false);
|
||||
// }
|
||||
// }
|
||||
|
||||
window.stickyHeaderFuncionality = () => {
|
||||
window.addEventListener("scroll", () => {
|
||||
evaluateHeaderPosition();
|
||||
});
|
||||
};
|
||||
|
||||
window.evaluateHeaderPosition = () => {
|
||||
if (window.scrollY > 48) {
|
||||
headerElement.firstElementChild.classList.add(...stickyClassesContainer);
|
||||
headerElement.firstElementChild.classList.remove(
|
||||
...unstickyClassesContainer,
|
||||
);
|
||||
headerElement.classList.add(...stickyClasses);
|
||||
headerElement.classList.remove(...unstickyClasses);
|
||||
// document.getElementById("menu").classList.add("top-[75px]");
|
||||
// document.getElementById("menu").classList.remove("top-[75px]");
|
||||
} else {
|
||||
headerElement.firstElementChild.classList.remove(...stickyClassesContainer);
|
||||
headerElement.firstElementChild.classList.add(...unstickyClassesContainer);
|
||||
headerElement.classList.add(...unstickyClasses);
|
||||
headerElement.classList.remove(...stickyClasses);
|
||||
// document.getElementById("menu").classList.remove("top-[56px]");
|
||||
// document.getElementById("menu").classList.add("top-[75px]");
|
||||
}
|
||||
};
|
||||
|
||||
document.getElementById("darkToggle").addEventListener("click", () => {
|
||||
document.documentElement.classList.add("duration-300");
|
||||
|
||||
if (document.documentElement.classList.contains("dark")) {
|
||||
localStorage.removeItem("dark_mode");
|
||||
showDay(true);
|
||||
} else {
|
||||
localStorage.setItem("dark_mode", true);
|
||||
showNight(true);
|
||||
}
|
||||
});
|
||||
|
||||
function showDay(animate) {
|
||||
document.getElementById("sun").classList.remove("setting");
|
||||
document.getElementById("moon").classList.remove("rising");
|
||||
|
||||
let timeout = 0;
|
||||
|
||||
if (animate) {
|
||||
timeout = 500;
|
||||
|
||||
document.getElementById("moon").classList.add("setting");
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
document.getElementById("dayText").classList.remove("hidden");
|
||||
document.getElementById("nightText").classList.add("hidden");
|
||||
|
||||
document.getElementById("moon").classList.add("hidden");
|
||||
document.getElementById("sun").classList.remove("hidden");
|
||||
|
||||
if (animate) {
|
||||
document.documentElement.classList.remove("dark");
|
||||
document.getElementById("sun").classList.add("rising");
|
||||
}
|
||||
}, timeout);
|
||||
}
|
||||
|
||||
function showNight(animate) {
|
||||
document.getElementById("moon").classList.remove("setting");
|
||||
document.getElementById("sun").classList.remove("rising");
|
||||
|
||||
let timeout = 0;
|
||||
|
||||
if (animate) {
|
||||
timeout = 500;
|
||||
|
||||
document.getElementById("sun").classList.add("setting");
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
document.getElementById("nightText").classList.remove("hidden");
|
||||
document.getElementById("dayText").classList.add("hidden");
|
||||
|
||||
document.getElementById("sun").classList.add("hidden");
|
||||
document.getElementById("moon").classList.remove("hidden");
|
||||
|
||||
if (animate) {
|
||||
document.documentElement.classList.add("dark");
|
||||
document.getElementById("moon").classList.add("rising");
|
||||
}
|
||||
}, timeout);
|
||||
}
|
||||
|
||||
window.applyMenuItemClasses = () => {
|
||||
const menuItems = document.querySelectorAll("#menu a");
|
||||
for (let i = 0; i < menuItems.length; i++) {
|
||||
if (menuItems[i].pathname === window.location.pathname) {
|
||||
menuItems[i].classList.add("text-neutral-900", "dark:text-white");
|
||||
}
|
||||
}
|
||||
//:class="{ 'text-neutral-900 dark:text-white': window.location.pathname == '{menu.url}', 'text-neutral-700 dark:text-neutral-400': window.location.pathname != '{menu.url}' }"
|
||||
};
|
||||
|
||||
function mobileMenuFunctionality() {
|
||||
document.getElementById("openMenu").addEventListener("click", () => {
|
||||
openMobileMenu();
|
||||
});
|
||||
|
||||
document.getElementById("closeMenu").addEventListener("click", () => {
|
||||
closeMobileMenu();
|
||||
});
|
||||
}
|
||||
|
||||
window.openMobileMenu = () => {
|
||||
document.getElementById("openMenu").classList.add("hidden");
|
||||
document.getElementById("closeMenu").classList.remove("hidden");
|
||||
document.getElementById("menu").classList.remove("hidden");
|
||||
document.getElementById("mobileMenuBackground").classList.add("opacity-0");
|
||||
document.getElementById("mobileMenuBackground").classList.remove("hidden");
|
||||
|
||||
setTimeout(() => {
|
||||
document
|
||||
.getElementById("mobileMenuBackground")
|
||||
.classList.remove("opacity-0");
|
||||
}, 1);
|
||||
};
|
||||
|
||||
window.closeMobileMenu = () => {
|
||||
document.getElementById("closeMenu").classList.add("hidden");
|
||||
document.getElementById("openMenu").classList.remove("hidden");
|
||||
document.getElementById("menu").classList.add("hidden");
|
||||
document.getElementById("mobileMenuBackground").classList.add("hidden");
|
||||
};
|
||||
BIN
src/assets/work/3dicons/01.jpg
Normal file
|
After Width: | Height: | Size: 674 KiB |
BIN
src/assets/work/3dicons/02.jpg
Normal file
|
After Width: | Height: | Size: 858 KiB |
BIN
src/assets/work/3dicons/03.jpg
Normal file
|
After Width: | Height: | Size: 390 KiB |
BIN
src/assets/work/3dicons/04.jpg
Normal file
|
After Width: | Height: | Size: 440 KiB |
BIN
src/assets/work/3dicons/05.jpg
Normal file
|
After Width: | Height: | Size: 878 KiB |
BIN
src/assets/work/3dicons/06.jpg
Normal file
|
After Width: | Height: | Size: 519 KiB |
BIN
src/assets/work/3dicons/07.jpg
Normal file
|
After Width: | Height: | Size: 689 KiB |
BIN
src/assets/work/3dicons/08.jpg
Normal file
|
After Width: | Height: | Size: 583 KiB |
BIN
src/assets/work/3dicons/preview.jpg
Normal file
|
After Width: | Height: | Size: 420 KiB |
BIN
src/assets/work/free-3d-valentines-assets/01.jpg
Normal file
|
After Width: | Height: | Size: 321 KiB |
BIN
src/assets/work/free-3d-valentines-assets/02.jpg
Normal file
|
After Width: | Height: | Size: 197 KiB |
BIN
src/assets/work/free-3d-valentines-assets/03.jpg
Normal file
|
After Width: | Height: | Size: 175 KiB |
BIN
src/assets/work/free-3d-valentines-assets/04.jpg
Normal file
|
After Width: | Height: | Size: 187 KiB |
BIN
src/assets/work/free-3d-valentines-assets/05.jpg
Normal file
|
After Width: | Height: | Size: 313 KiB |
BIN
src/assets/work/free-3d-valentines-assets/logo.png
Normal file
|
After Width: | Height: | Size: 28 KiB |
BIN
src/assets/work/luonmodels/001.jpg
Normal file
|
After Width: | Height: | Size: 215 KiB |
BIN
src/assets/work/luonmodels/002.jpg
Normal file
|
After Width: | Height: | Size: 555 KiB |
BIN
src/assets/work/luonmodels/003.jpg
Normal file
|
After Width: | Height: | Size: 897 KiB |
BIN
src/assets/work/luonmodels/004.jpg
Normal file
|
After Width: | Height: | Size: 1.0 MiB |
BIN
src/assets/work/luonmodels/005.jpg
Normal file
|
After Width: | Height: | Size: 408 KiB |
BIN
src/assets/work/luonmodels/006.jpg
Normal file
|
After Width: | Height: | Size: 506 KiB |
BIN
src/assets/work/luonmodels/007.jpg
Normal file
|
After Width: | Height: | Size: 520 KiB |
BIN
src/assets/work/luonmodels/008.jpg
Normal file
|
After Width: | Height: | Size: 611 KiB |
BIN
src/assets/work/luonmodels/009.jpg
Normal file
|
After Width: | Height: | Size: 353 KiB |
BIN
src/assets/work/luonmodels/010.jpg
Normal file
|
After Width: | Height: | Size: 443 KiB |
BIN
src/assets/work/luonmodels/011.jpg
Normal file
|
After Width: | Height: | Size: 756 KiB |
BIN
src/assets/work/luonmodels/012.jpg
Normal file
|
After Width: | Height: | Size: 399 KiB |
BIN
src/assets/work/luonmodels/013.jpg
Normal file
|
After Width: | Height: | Size: 323 KiB |
BIN
src/assets/work/luonmodels/014.jpg
Normal file
|
After Width: | Height: | Size: 42 KiB |
BIN
src/assets/work/ricoblog2024/P_01.jpg
Normal file
|
After Width: | Height: | Size: 915 KiB |
BIN
src/assets/work/ricoblog2024/P_02.jpg
Normal file
|
After Width: | Height: | Size: 359 KiB |
BIN
src/assets/work/ricoblog2024/P_03.jpg
Normal file
|
After Width: | Height: | Size: 398 KiB |
BIN
src/assets/work/ricoblog2024/P_04.jpg
Normal file
|
After Width: | Height: | Size: 337 KiB |
BIN
src/assets/work/ricoblog2024/P_05.jpg
Normal file
|
After Width: | Height: | Size: 298 KiB |
BIN
src/assets/work/ricoblog2024/P_06.jpg
Normal file
|
After Width: | Height: | Size: 1.3 MiB |
BIN
src/assets/work/ricoblog2024/P_07.jpg
Normal file
|
After Width: | Height: | Size: 130 KiB |
BIN
src/assets/work/ricoblog2024/logo.png
Normal file
|
After Width: | Height: | Size: 31 KiB |
BIN
src/assets/work/todo/todo-01.jpg
Normal file
|
After Width: | Height: | Size: 88 KiB |
BIN
src/assets/work/todo/todo-02.jpg
Normal file
|
After Width: | Height: | Size: 92 KiB |
BIN
src/assets/work/todo/todo-03.jpg
Normal file
|
After Width: | Height: | Size: 294 KiB |
BIN
src/assets/work/todo/todo-04.jpg
Normal file
|
After Width: | Height: | Size: 401 KiB |
BIN
src/assets/work/todo/todo-06.jpg
Normal file
|
After Width: | Height: | Size: 188 KiB |
17
src/collections/experiences.json
Normal file
@@ -0,0 +1,17 @@
|
||||
[
|
||||
{
|
||||
"dates": "June 2018 · Present",
|
||||
"role": "Web & Product Designer",
|
||||
"company": "Company Name",
|
||||
"description": "Leading the design of user-centric web applications and dating platforms.",
|
||||
"logo": "/assets/experiences/company.jpg"
|
||||
},
|
||||
{
|
||||
"dates": "July 2016 · June 2017",
|
||||
"role": "Web & Product Designer",
|
||||
"company": "Company Name",
|
||||
"description": "Spearheaded the design and development of e-commerce websites and online marketplaces.",
|
||||
"logo": "/assets/experiences/company.jpg"
|
||||
}
|
||||
|
||||
]
|
||||
58
src/collections/featuredwork.json
Normal file
@@ -0,0 +1,58 @@
|
||||
[
|
||||
{
|
||||
"name": "RicoOG",
|
||||
"description": "OG Inspiration Library",
|
||||
"tags": ["Open Graph", "Design", "Library"],
|
||||
"image": "/assets/works/ricoog.jpg",
|
||||
"video": "/assets/works/ricoog.mp4",
|
||||
"url": "https://ricoog.com/",
|
||||
"isShow": true
|
||||
},
|
||||
{
|
||||
"name": "GradientsHub",
|
||||
"description": "Gradient Resources & Tools",
|
||||
"tags": ["Gradient","Resources","Tools"],
|
||||
"image": "/assets/works/gradientshub.jpg",
|
||||
"video": "/assets/works/gradientshub.mp4",
|
||||
"url": "https://gradientshub.com"
|
||||
},
|
||||
{
|
||||
"name": "Luon Models",
|
||||
"description": "Company Website",
|
||||
"tags": ["Company","Website","Branding"],
|
||||
"image": "/assets/works/luonmodels.jpg",
|
||||
"video": "/assets/works/luonmodels.mp4",
|
||||
"url": "https://luonmodels.netlify.app/"
|
||||
},
|
||||
{
|
||||
"name": "Ricoui",
|
||||
"description": "Designer Portfolio",
|
||||
"tags": ["Designer","Portfolio","Open Source"],
|
||||
"image": "/assets/works/ricoui.jpg",
|
||||
"video": "/assets/works/ricoui.mp4",
|
||||
"url": "/work/ricoblog2024"
|
||||
|
||||
},
|
||||
{
|
||||
"name": "3D Valentines Assets",
|
||||
"description": "Blender Design Resources & Assets",
|
||||
"tags": ["3D","Resources","Assets"],
|
||||
"image": "/assets/works/3d-valentines.jpg",
|
||||
"video": "/assets/works/3d-valentines.mp4",
|
||||
"url": "/work/3dvalentine"
|
||||
},
|
||||
{
|
||||
"name": "UIUXDECK",
|
||||
"description": "Design Resources & Tools",
|
||||
"tags": ["Design","Resources","Tools"],
|
||||
"image": "/assets/works/uiuxdeck.jpg",
|
||||
"url": "http://uiuxdeck.com/"
|
||||
},
|
||||
{
|
||||
"name": "Inspoweb",
|
||||
"description": "Web Inspiration Library",
|
||||
"tags": ["Inspiration","Web Collection"],
|
||||
"image": "/assets/works/inspoweb.jpg",
|
||||
"url": "https://inspoweb.com/"
|
||||
}
|
||||
]
|
||||
18
src/collections/menu.json
Normal file
@@ -0,0 +1,18 @@
|
||||
[
|
||||
{
|
||||
"name": "Home",
|
||||
"url": "/"
|
||||
},
|
||||
{
|
||||
"name": "Writing",
|
||||
"url": "/blog"
|
||||
},
|
||||
{
|
||||
"name": "Works",
|
||||
"url": "/works"
|
||||
},
|
||||
{
|
||||
"name": "About",
|
||||
"url": "/about"
|
||||
}
|
||||
]
|
||||
68
src/collections/social.json
Normal file
@@ -0,0 +1,68 @@
|
||||
[
|
||||
{
|
||||
"id": 1,
|
||||
"name": "Github",
|
||||
"username": "ricocc",
|
||||
"image": "/assets/social/social-github.jpg",
|
||||
"url": "https://github.com/ricocc/"
|
||||
},
|
||||
|
||||
{
|
||||
"id": 2,
|
||||
"name": "Xiaohongshu",
|
||||
"username": "ricouii",
|
||||
"image": "/assets/social/social-xiaohongshu.jpg",
|
||||
"url": "https://www.xiaohongshu.com/user/profile/5f2b6903000000000101f51f"
|
||||
},
|
||||
|
||||
{
|
||||
"id": 3,
|
||||
"name": "Twitter",
|
||||
"username": "ricouii",
|
||||
"image": "/assets/social/social-twitter.jpg",
|
||||
"url": "https://x.com/ricouii"
|
||||
},
|
||||
|
||||
{
|
||||
"id": 4,
|
||||
"name": "Gumroad",
|
||||
"username": "ricoui",
|
||||
"image": "/assets/social/social-gumroad.jpg",
|
||||
"url": "https://ricoui.gumroad.com/"
|
||||
},
|
||||
|
||||
{
|
||||
"id": 5,
|
||||
"name": "Behance",
|
||||
"username": "ricoui",
|
||||
"image": "/assets/social/social-behance.jpg",
|
||||
"url": "https://www.behance.net/ricoui"
|
||||
},
|
||||
|
||||
{
|
||||
"id": 6,
|
||||
"name": "figma",
|
||||
"username": "ricocc",
|
||||
"image": "/assets/social/social-figma.jpg",
|
||||
"url": "https://www.figma.com/@ricocc",
|
||||
"isShow": false
|
||||
},
|
||||
|
||||
{
|
||||
"id": 7,
|
||||
"name": "Dribbble",
|
||||
"username": "ricoui",
|
||||
"image": "/assets/social/social-dribbble.jpg",
|
||||
"url": "https://dribbble.com/ricoui"
|
||||
},
|
||||
|
||||
{
|
||||
"id": 8,
|
||||
"name": "Email",
|
||||
"username": "ricocc",
|
||||
"image": "/assets/social/social-email.jpg",
|
||||
"url": "mailto:ricocc@qq.com"
|
||||
}
|
||||
|
||||
|
||||
]
|
||||
58
src/collections/works.json
Normal file
@@ -0,0 +1,58 @@
|
||||
[
|
||||
{
|
||||
"name": "RicoOG",
|
||||
"description": "OG Inspiration Library",
|
||||
"tags": ["Open Graph", "Design", "Library"],
|
||||
"image": "/assets/works/ricoog.jpg",
|
||||
"video": "/assets/works/ricoog.mp4",
|
||||
"url": "https://ricoog.com/",
|
||||
"isShow": true
|
||||
},
|
||||
{
|
||||
"name": "GradientsHub",
|
||||
"description": "Gradient Resources & Tools",
|
||||
"tags": ["Gradient","Resources","Tools"],
|
||||
"image": "/assets/works/gradientshub.jpg",
|
||||
"video": "/assets/works/gradientshub.mp4",
|
||||
"url": "https://gradientshub.com"
|
||||
},
|
||||
{
|
||||
"name": "Luon Models",
|
||||
"description": "Company Website",
|
||||
"tags": ["Company","Website","Branding"],
|
||||
"image": "/assets/works/luonmodels.jpg",
|
||||
"video": "/assets/works/luonmodels.mp4",
|
||||
"url": "https://luonmodels.netlify.app/"
|
||||
},
|
||||
{
|
||||
"name": "Ricoui",
|
||||
"description": "Designer Portfolio",
|
||||
"tags": ["Designer","Portfolio","Open Source"],
|
||||
"image": "/assets/works/ricoui.jpg",
|
||||
"video": "/assets/works/ricoui.mp4",
|
||||
"url": "/work/ricoblog2024"
|
||||
|
||||
},
|
||||
{
|
||||
"name": "3D Valentines Assets",
|
||||
"description": "Blender Design Resources & Assets",
|
||||
"tags": ["3D","Resources","Assets"],
|
||||
"image": "/assets/works/3d-valentines.jpg",
|
||||
"video": "/assets/works/3d-valentines.mp4",
|
||||
"url": "/work/3dvalentine"
|
||||
},
|
||||
{
|
||||
"name": "UIUXDECK",
|
||||
"description": "Design Resources & Tools",
|
||||
"tags": ["Design","Resources","Tools"],
|
||||
"image": "/assets/works/uiuxdeck.jpg",
|
||||
"url": "http://uiuxdeck.com/"
|
||||
},
|
||||
{
|
||||
"name": "Inspoweb",
|
||||
"description": "Web Inspiration Library",
|
||||
"tags": ["Inspiration","Web Collection"],
|
||||
"image": "/assets/works/inspoweb.jpg",
|
||||
"url": "https://inspoweb.com/"
|
||||
}
|
||||
]
|
||||
156
src/components/cards/BlogCard.astro
Normal file
@@ -0,0 +1,156 @@
|
||||
---
|
||||
const { content, layout = "vertical", "data-aos": dataAos, "data-aos-delay": dataAosDelay } = Astro.props;
|
||||
|
||||
const {
|
||||
title,
|
||||
description,
|
||||
publishDate,
|
||||
tags = [] as string[],
|
||||
img,
|
||||
img_alt,
|
||||
slug,
|
||||
link
|
||||
} = content;
|
||||
|
||||
const formattedDate = new Date(publishDate).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
});
|
||||
|
||||
const postLink = link || `/blog/${slug}`;
|
||||
|
||||
// Check if horizontal layout
|
||||
const isHorizontal = layout === "horizontal";
|
||||
---
|
||||
|
||||
<article
|
||||
class:list={[
|
||||
"group relative overflow-hidden bg-bg-secondary border-primary/35 p-4 dark:bg-neutral-900 rounded-2xl backdrop-blur-sm transition-all duration-500" ,
|
||||
{ "md:flex md:items-center": isHorizontal }
|
||||
]}
|
||||
data-aos={dataAos}
|
||||
data-aos-delay={dataAosDelay}
|
||||
>
|
||||
<!-- Image container -->
|
||||
<div
|
||||
class:list={[
|
||||
"relative overflow-hidden rounded-2xl dark:border-neutral-800/60 border-4 border-white ",
|
||||
isHorizontal ? "md:w-1/2" : "aspect-[3/2]"
|
||||
]}
|
||||
>
|
||||
<!-- Image -->
|
||||
{img ? (
|
||||
<img
|
||||
src={img}
|
||||
alt={img_alt || `Related to ${title}`}
|
||||
loading="lazy"
|
||||
class="w-full h-full object-cover transition-transform duration-500 group-hover:scale-105"
|
||||
/>
|
||||
) : (
|
||||
<div class="w-full h-full dark:from-neutral-800 dark:to-neutral-700 flex items-center justify-center">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" class="text-neutral-400">
|
||||
<rect width="18" height="18" x="3" y="3" rx="2" ry="2"></rect>
|
||||
<circle cx="9" cy="9" r="2"></circle>
|
||||
<path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21"></path>
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<!-- Gradient overlay and button shown on hover -->
|
||||
<div class="absolute inset-0 bg-gradient-to-t from-black/60 via-black/20 to-transparent opacity-0 group-hover:opacity-100 transition-all duration-500 flex items-center justify-center">
|
||||
<span class="w-14 h-14 bg-white/95 dark:bg-neutral-800/95 backdrop-blur-sm rounded-full flex items-center justify-center transform translate-y-8 opacity-0 group-hover:translate-y-0 group-hover:opacity-100 transition-all duration-500 shadow-lg hover:scale-110">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="22"
|
||||
height="22"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="text-primary"
|
||||
>
|
||||
<path d="M7 7h10v10"></path>
|
||||
<path d="M7 17 17 7"></path>
|
||||
</svg>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Article link -->
|
||||
<a class="absolute inset-0 z-10" href={postLink}>
|
||||
<span class="sr-only">Read More About {title}</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Content area -->
|
||||
<div class:list={[
|
||||
"p-6 px-2 md:p-6 flex flex-col",
|
||||
isHorizontal ? "md:w-1/2" : ""
|
||||
]}>
|
||||
<!-- Date and tags -->
|
||||
<div class="flex items-center text-sm mb-4 gap-2">
|
||||
<time class="inline-flex items-center text-neutral-500 dark:text-neutral-400 text-xs">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="mr-1.5 opacity-60">
|
||||
<rect width="18" height="18" x="3" y="4" rx="2" ry="2"></rect>
|
||||
<line x1="16" x2="16" y1="2" y2="6"></line>
|
||||
<line x1="8" x2="8" y1="2" y2="6"></line>
|
||||
<line x1="3" x2="21" y1="10" y2="10"></line>
|
||||
</svg>
|
||||
{formattedDate}
|
||||
</time>
|
||||
|
||||
{tags && tags.length > 0 && (
|
||||
<>
|
||||
<span class="text-neutral-400 dark:text-neutral-500">·</span>
|
||||
<span class="inline-flex items-center rounded-full bg-bg-secondary/8 dark:bg-bg-secondary/15 px-3 py-1 text-[11px] font-light text-primary/75 dark:text-primary-light/85 border border-primary/25 dark:border-primary/25">
|
||||
{tags[0]}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<!-- Title -->
|
||||
<a href={postLink}>
|
||||
<h2 class:list={["font-brand text-neutral-900 dark:text-white mb-3 leading-tight transition-colors duration-300 group-hover:text-primary dark:group-hover:text-primary-light line-clamp-2", isHorizontal ? "text-3xl" : "text-2xl"]}>
|
||||
{title}
|
||||
</h2>
|
||||
</a>
|
||||
|
||||
<!-- Description -->
|
||||
{description && (
|
||||
<p class="text-neutral-600 dark:text-neutral-400 line-clamp-3 mb-5 text-sm leading-relaxed">
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<!-- Read More button -->
|
||||
<div class="mt-auto pt-4">
|
||||
<a
|
||||
href={postLink}
|
||||
class="inline-flex items-center gap-2 text-sm font-semibold text-primary dark:text-primary-light transition-all duration-300 group/link"
|
||||
>
|
||||
<span class="relative">
|
||||
Read More
|
||||
<span class="absolute bottom-0 left-0 w-0 h-0.25 bg-bg-secondary dark:bg-bg-secondary-light transition-all duration-300 group-hover/link:w-full"></span>
|
||||
</span>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="transition-transform duration-300 group-hover/link:translate-x-1"
|
||||
>
|
||||
<line x1="5" y1="12" x2="19" y2="12"></line>
|
||||
<polyline points="12 5 19 12 12 19"></polyline>
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</article>
|
||||
168
src/components/cards/SocialCard.astro
Normal file
@@ -0,0 +1,168 @@
|
||||
---
|
||||
import social from "@/collections/social.json";
|
||||
const { displaySocialIds } = Astro.props;
|
||||
// 配置要显示的社交媒体 ID(留空则显示所有)
|
||||
//const displaySocialIds: number[] = [1, 2, 3]; // 只显示 ID 为 1, 2, 3 的社交媒体
|
||||
// const displaySocialIds: number[] = []; // 显示所有(当数组为空时)
|
||||
|
||||
const filteredSocial = social.filter(item => {
|
||||
if (item.isShow === false) return false;
|
||||
if (displaySocialIds.length > 0) {
|
||||
return displaySocialIds.includes(item.id);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
---
|
||||
|
||||
<div class="social-cards-wrapper">
|
||||
<div class="social-list">
|
||||
{filteredSocial.map((item, index) => (
|
||||
<a
|
||||
href={item.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="social-item"
|
||||
style={`--index: ${index}; --total: ${filteredSocial.length};`}
|
||||
>
|
||||
<img
|
||||
src={item.image}
|
||||
alt={item.name}
|
||||
class="social-image"
|
||||
/>
|
||||
<div class="social-info">
|
||||
<div class="social-username">
|
||||
@{item.username}
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.social-cards-wrapper {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.social-list {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
/* 桌面端:容器高度固定,用于堆叠效果 */
|
||||
height: 70px;
|
||||
}
|
||||
|
||||
/* 移动端:自适应高度 */
|
||||
@media (max-width: 768px) {
|
||||
.social-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem; /* gap-2 = 0.5rem */
|
||||
height: auto;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
.social-item {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border-radius: 0.75rem; /* rounded-xl */
|
||||
cursor: pointer;
|
||||
/* 分离 transform 和 z-index 的过渡 */
|
||||
|
||||
transition: transform 0.6s cubic-bezier(0.34, 1.2, 0.64, 1),
|
||||
z-index 0s linear 0.6s,
|
||||
box-shadow 0.3s ease;
|
||||
transition-delay: calc(var(--index) * 0.03s);
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
}
|
||||
/* 桌面端:堆叠效果 */
|
||||
@media (min-width: 769px) {
|
||||
.social-item {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
/* 初始状态:重叠 + 向右轻微旋转 */
|
||||
transform: translateX(calc(var(--index) * 3px)) rotate(calc(var(--index) * 5deg));
|
||||
/* ID 越小的 z-index 越大(在最上面)*/
|
||||
z-index: calc(var(--total) - var(--index));
|
||||
}
|
||||
}
|
||||
|
||||
/* 移动端:正常排列 */
|
||||
@media (max-width: 768px) {
|
||||
.social-item {
|
||||
position: relative;
|
||||
transform: none;
|
||||
transition-delay: 0s;
|
||||
}
|
||||
}
|
||||
|
||||
/* 桌面端悬停效果:向右展开 */
|
||||
@media (min-width: 769px) {
|
||||
.social-list:hover .social-item {
|
||||
/* 向右展开:ID 1 保持原地,后面的依次向右 */
|
||||
transform: translateX(calc(var(--index) * 85px)) rotate(0deg);
|
||||
/* 展开时反转 z-index:右边的卡片在上面,避免回弹时的重叠闪烁 */
|
||||
z-index: calc(var(--index) + 100);
|
||||
/* 展开时立即切换 z-index,回收时延迟切换 */
|
||||
transition: transform 0.6s cubic-bezier(0.34, 1.2, 0.64, 1),
|
||||
z-index 0s linear 0s,
|
||||
box-shadow 0.3s ease;
|
||||
transition-delay: calc(var(--index) * 0.03s);
|
||||
}
|
||||
|
||||
.social-list:hover .social-item:hover {
|
||||
/* 当前悬停的卡片:向上弹起 + 轻微旋转 */
|
||||
transform: translateX(calc(var(--index) * 80px)) translateY(-10px) rotate(-6deg) scale(1.05);
|
||||
z-index: 9999;
|
||||
/* 悬停时缩短过渡时间,反应更灵敏 */
|
||||
transition: transform 0.35s cubic-bezier(0.34, 1.5, 0.64, 1),
|
||||
z-index 0s linear 0s,
|
||||
box-shadow 0.3s ease;
|
||||
}
|
||||
}
|
||||
|
||||
/* 图片样式 */
|
||||
.social-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
border-radius: 0.75rem; /* rounded-xl */
|
||||
transition: transform 0.4s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
}
|
||||
|
||||
.social-item:hover .social-image {
|
||||
transform: scale(1.15); /* 放大更明显 */
|
||||
}
|
||||
|
||||
/* 用户名信息 */
|
||||
.social-info {
|
||||
position: absolute;
|
||||
bottom: 6px;
|
||||
left: 6px;
|
||||
z-index: 10;
|
||||
transition: opacity 0.3s;
|
||||
}
|
||||
|
||||
.social-username {
|
||||
font-size: 8px;
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
/* 悬停时添加阴影 - 增强效果 */
|
||||
.social-item:hover {
|
||||
box-shadow: 0 15px 32px rgba(0, 0, 0, 0.15),
|
||||
0 5px 12px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
/* 移动端优化 */
|
||||
@media (max-width: 768px) {
|
||||
.social-item:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
319
src/components/cards/WorkCard.astro
Normal file
@@ -0,0 +1,319 @@
|
||||
---
|
||||
interface Props {
|
||||
name: string; // Required
|
||||
image: string; // Required
|
||||
url: string; // Required
|
||||
description?: string; // Optional
|
||||
tags?: string[]; // Optional
|
||||
video?: string; // Optional
|
||||
isShow?: boolean; // Optional, default true
|
||||
layout?: 'featured' | 'grid'; // featured is full width, grid is two columns
|
||||
index?: number;
|
||||
target?: string; // Optional, default "_blank"
|
||||
}
|
||||
|
||||
const {
|
||||
name,
|
||||
description = '',
|
||||
url,
|
||||
image,
|
||||
tags = [],
|
||||
video,
|
||||
isShow = true,
|
||||
layout = 'grid',
|
||||
index = 0,
|
||||
target = "_blank"
|
||||
} = Astro.props;
|
||||
|
||||
// Generate stable id for each video for precise playback control
|
||||
const videoId = `workcard-video-${index}-${name.replace(/[^a-z0-9]+/gi, '-').toLowerCase()}`;
|
||||
---
|
||||
|
||||
<article
|
||||
class:list={[
|
||||
"group relative overflow-hidden transition-all duration-500 mb-8 bg-white/85 p-5 dark:bg-bg-secondary-dark border-[.75px] border-solid border-primary/15 rounded-2xl backdrop-blur-sm",
|
||||
layout === 'featured' ? "" : ""
|
||||
]}
|
||||
data-aos="fade-up"
|
||||
data-aos-duration="500"
|
||||
data-aos-delay={index * 100}
|
||||
data-aos-once="true"
|
||||
>
|
||||
<!-- Image/video container -->
|
||||
<div class="relative overflow-hidden aspect-video rounded-xl">
|
||||
{video ? (
|
||||
<!-- Video -->
|
||||
<video
|
||||
id={videoId}
|
||||
src={video}
|
||||
poster={image}
|
||||
autoplay
|
||||
loop
|
||||
muted
|
||||
playsinline
|
||||
webkit-playsinline
|
||||
preload="auto"
|
||||
controls={false}
|
||||
disablepictureinpicture
|
||||
controlslist="nodownload noplaybackrate nofullscreen noremoteplayback"
|
||||
class="w-full h-full object-cover transition-transform duration-700 group-hover:scale-105"
|
||||
>
|
||||
<source src={video} type="video/mp4" />
|
||||
Your browser does not support the video tag.
|
||||
</video>
|
||||
) : (
|
||||
<!-- Image -->
|
||||
<img
|
||||
src={image}
|
||||
alt={name}
|
||||
class="w-full h-full object-cover transition-transform duration-700 group-hover:scale-105 hover:shadow-xl hover:shadow-primary/5 dark:hover:shadow-primary/10"
|
||||
loading="lazy"
|
||||
/>
|
||||
)}
|
||||
|
||||
<!-- Gradient overlay shown on hover -->
|
||||
<div class="absolute inset-0 bg-gradient-to-t from-black/25 via-black/10 to-transparent opacity-0 group-hover:opacity-100 transition-all duration-500"></div>
|
||||
|
||||
<!-- Link button shown on hover -->
|
||||
<div class="absolute inset-0 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-all duration-500">
|
||||
<div class="flex items-center justify-center w-14 h-14 rounded-full bg-white/95 backdrop-blur-sm shadow-lg transform translate-y-8 group-hover:translate-y-0 transition-all duration-500 group-hover:scale-110">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="text-primary"
|
||||
>
|
||||
<path d="M7 7h10v10"></path>
|
||||
<path d="M7 17 17 7"></path>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Content area -->
|
||||
<div class:list={[
|
||||
"px-1 pt-6 pb-2",
|
||||
layout === 'featured' ? "md:px-1 md:pt-6 md:pb-2" : ""
|
||||
]}>
|
||||
<!-- Title and link icon -->
|
||||
<div class="flex items-start justify-between gap-3 mb-3">
|
||||
<h3 class:list={[
|
||||
"text-neutral-900 dark:text-white leading-tight font-brand transition-colors duration-300 group-hover:text-primary dark:group-hover:text-primary-light",
|
||||
layout === 'featured' ? "text-2xl md:text-3xl" : "text-2xl"
|
||||
]}>
|
||||
{name}
|
||||
</h3>
|
||||
|
||||
<!-- Link icon -->
|
||||
<div class="flex-shrink-0 mt-1">
|
||||
<div class="flex items-center justify-center w-8 h-8 rounded-full bg-primary/10 dark:bg-primary/20 text-primary dark:text-primary-light transition-all duration-300 group-hover:bg-primary dark:group-hover:bg-primary-light group-hover:text-white dark:group-hover:text-neutral-900 group-hover:rotate-45">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M7 7h10v10"></path>
|
||||
<path d="M7 17 17 7"></path>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tags -->
|
||||
{tags && tags.length > 0 && (
|
||||
<div class="flex flex-wrap gap-1.5 mb-3">
|
||||
{tags.map((tag) => (
|
||||
<span class="inline-flex items-center rounded-full bg-primary/8 dark:bg-primary/15 px-2.5 py-0.5 text-[10px] font-medium text-primary-dark dark:text-primary-light border border-primary/10 dark:border-primary/20">
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<!-- Description -->
|
||||
{description && (
|
||||
<p class:list={[
|
||||
"text-neutral-600 dark:text-neutral-400 leading-relaxed",
|
||||
layout === 'featured' ? "text-base md:text-lg line-clamp-2" : "text-sm line-clamp-2"
|
||||
]}>
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<!-- Link overlay for entire card -->
|
||||
<a
|
||||
href={url}
|
||||
target={target}
|
||||
rel={target === "_blank" ? "noopener noreferrer" : undefined}
|
||||
class="absolute inset-0 z-10"
|
||||
>
|
||||
<span class="sr-only">View {name}</span>
|
||||
</a>
|
||||
</article>
|
||||
|
||||
{video && (
|
||||
<script define:vars={{ videoId }}>
|
||||
// Use IIFE to avoid global pollution and ensure each video is managed independently
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
const video = document.getElementById(videoId);
|
||||
if (!video || !(video instanceof HTMLVideoElement)) return;
|
||||
|
||||
// State management
|
||||
let isPlaying = false;
|
||||
let observer = null;
|
||||
let playAttempts = 0;
|
||||
const MAX_PLAY_ATTEMPTS = 3;
|
||||
|
||||
// Force set mobile playback attributes
|
||||
video.muted = true;
|
||||
video.playsInline = true;
|
||||
video.setAttribute('playsinline', '');
|
||||
video.setAttribute('webkit-playsinline', '');
|
||||
video.removeAttribute('controls');
|
||||
|
||||
/**
|
||||
* Try to play video (with debounce and retry limit)
|
||||
*/
|
||||
function tryPlay() {
|
||||
// Prevent duplicate playback
|
||||
if (isPlaying || playAttempts >= MAX_PLAY_ATTEMPTS) return;
|
||||
|
||||
playAttempts++;
|
||||
|
||||
const playPromise = video.play();
|
||||
|
||||
if (playPromise !== undefined) {
|
||||
playPromise
|
||||
.then(() => {
|
||||
isPlaying = true;
|
||||
playAttempts = 0; // Reset count on success
|
||||
})
|
||||
.catch((error) => {
|
||||
isPlaying = false;
|
||||
console.debug(`Video autoplay attempt ${playAttempts} failed:`, error.name);
|
||||
|
||||
// If it's a user interaction issue, wait for user interaction and retry
|
||||
if (error.name === 'NotAllowedError') {
|
||||
handleUserInteraction();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle user interaction to trigger playback (common requirement on mobile)
|
||||
*/
|
||||
function handleUserInteraction() {
|
||||
const interactionEvents = ['touchstart', 'click', 'scroll'];
|
||||
|
||||
function onInteraction() {
|
||||
tryPlay();
|
||||
// Remove listeners to avoid duplicate triggers
|
||||
interactionEvents.forEach(event => {
|
||||
document.removeEventListener(event, onInteraction);
|
||||
});
|
||||
}
|
||||
|
||||
interactionEvents.forEach(event => {
|
||||
document.addEventListener(event, onInteraction, { once: true, passive: true });
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Viewport visibility management (performance optimization)
|
||||
*/
|
||||
function setupIntersectionObserver() {
|
||||
observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
entries.forEach((entry) => {
|
||||
if (entry.isIntersecting) {
|
||||
// Enter viewport, try to play
|
||||
if (video.paused) {
|
||||
playAttempts = 0; // Reset count
|
||||
tryPlay();
|
||||
}
|
||||
} else {
|
||||
// Leave viewport, pause to save resources
|
||||
if (!video.paused) {
|
||||
video.pause();
|
||||
isPlaying = false;
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
{
|
||||
root: null,
|
||||
rootMargin: '50px', // Preload
|
||||
threshold: 0.1
|
||||
}
|
||||
);
|
||||
|
||||
observer.observe(video);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle page visibility changes
|
||||
*/
|
||||
function handleVisibilityChange() {
|
||||
if (!document.hidden && !video.paused) {
|
||||
// Page is visible again and video should play
|
||||
const rect = video.getBoundingClientRect();
|
||||
const isInViewport = rect.top < window.innerHeight && rect.bottom > 0;
|
||||
|
||||
if (isInViewport) {
|
||||
playAttempts = 0;
|
||||
tryPlay();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize
|
||||
function init() {
|
||||
// Wait for video metadata to load
|
||||
if (video.readyState >= 2) {
|
||||
// HAVE_CURRENT_DATA
|
||||
tryPlay();
|
||||
} else {
|
||||
video.addEventListener('loadeddata', tryPlay, { once: true });
|
||||
}
|
||||
|
||||
// Setup viewport observer
|
||||
setupIntersectionObserver();
|
||||
|
||||
// Listen for page visibility changes
|
||||
document.addEventListener('visibilitychange', handleVisibilityChange);
|
||||
}
|
||||
|
||||
// Cleanup function (when component unmounts)
|
||||
function cleanup() {
|
||||
if (observer) {
|
||||
observer.disconnect();
|
||||
observer = null;
|
||||
}
|
||||
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
||||
}
|
||||
|
||||
// Start
|
||||
init();
|
||||
|
||||
// If it's a SPA, can cleanup on page unload
|
||||
// Astro's View Transitions will trigger this event
|
||||
document.addEventListener('astro:before-preparation', cleanup, { once: true });
|
||||
})();
|
||||
</script>
|
||||
)}
|
||||
22
src/components/elements/AboutExperience.astro
Normal file
@@ -0,0 +1,22 @@
|
||||
---
|
||||
const { logo, dates, role, company, description } = Astro.props;
|
||||
---
|
||||
|
||||
<div class="relative flex flex-col justify-start pl-12">
|
||||
<div
|
||||
class="absolute top-0 left-0 z-40 flex items-center justify-center -translate-x-1/2 bg-white border rounded-full dark:bg-neutral-800 w-14 h-14 border-neutral-300 dark:border-neutral-700"
|
||||
>
|
||||
<img src={logo} alt={company} class="w-8 h-8 rounded-full" />
|
||||
</div>
|
||||
|
||||
<p
|
||||
class="text-xs uppercase text-neutral-400 dark:text-neutral-500 trackign-widest"
|
||||
>
|
||||
{dates}
|
||||
</p>
|
||||
<h3 class="my-1 text-lg font-semibold dark:text-neutral-200">{role}</h3>
|
||||
<p class="mb-1 text-sm font-medium dark:text-neutral-400">{company}</p>
|
||||
<p class="text-sm font-light text-neutral-600 dark:text-neutral-400">
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
38
src/components/elements/PageHeader.astro
Normal file
@@ -0,0 +1,38 @@
|
||||
---
|
||||
import AnimatedText from "@/components/ui/AnimatedText.astro";
|
||||
interface Props {
|
||||
title?: string ;
|
||||
description?: string ;
|
||||
tags?: string[] ;
|
||||
className?: string ;
|
||||
}
|
||||
|
||||
const { title, description, tags = [], className = "" } = Astro.props;
|
||||
---
|
||||
|
||||
<div class={`relative z-20 w-full mx-auto mt-16 mb-16 text-center ${className}`}>
|
||||
<h2
|
||||
class="text-4xl font-brand text-center tracking-normal text-neutral-800 dark:text-neutral-100 sm:text-4xl lg:text-5xl"
|
||||
>
|
||||
{title && <AnimatedText delay={0.2} stagger={0.08} content={title} />}
|
||||
</h2>
|
||||
{description && (
|
||||
<div
|
||||
class="mt-3 text-sm leading-6 sm:mt-4 lg:mt-6 sm:leading-7 lg:leading-8 sm:text-base lg:text-lg text-neutral-700 dark:text-neutral-300 max-w-full lg:max-w-2xl m-auto"
|
||||
>
|
||||
<AnimatedText delay={0.6} stagger={0.03} duration={1} content={description} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tags && tags.length > 0 && (
|
||||
<div class="flex flex-wrap justify-center gap-2 mt-12 mb-2 ">
|
||||
{tags.map((tag) => (
|
||||
<span class="inline-flex items-center rounded-full bg-bg-secondary/8 dark:bg-bg-secondary/15 px-3 py-1 text-[11px] font-light text-primary/85 dark:text-primary-light/85 border border-primary/35 dark:border-primary/25 tracking-wide">
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
</div>
|
||||
19
src/components/elements/SectionHeader.astro
Normal file
@@ -0,0 +1,19 @@
|
||||
---
|
||||
import AnimatedText from "@/components/ui/AnimatedText.astro";
|
||||
const { title, description } = Astro.props;
|
||||
---
|
||||
|
||||
<div class="relative z-20 w-full mx-auto mt-12 mb-16 text-center">
|
||||
<h2
|
||||
class="text-3xl font-brand text-center tracking-normal text-neutral-800 dark:text-neutral-100 sm:text-4xl lg:text-5xl"
|
||||
>
|
||||
<AnimatedText delay={0.2} stagger={0.08} content={title} />
|
||||
</h2>
|
||||
{description && (
|
||||
<div
|
||||
class="mt-3 text-sm leading-6 sm:mt-4 lg:mt-6 sm:leading-7 lg:leading-8 sm:text-base lg:text-lg text-neutral-700 dark:text-neutral-300 max-w-full lg:max-w-3xl m-auto"
|
||||
>
|
||||
<AnimatedText delay={0.6} stagger={0.03} content={description} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
43
src/components/elements/SeparatorLine.astro
Normal file
@@ -0,0 +1,43 @@
|
||||
---
|
||||
interface Props {
|
||||
className?: string; // Custom CSS classes
|
||||
color?: string; // Color, defaults to neutral
|
||||
width?: "full" | "3/4" | "1/2" | "1/4"; // Width options
|
||||
spacing?: "sm" | "md" | "lg" | "xl"; // Spacing options
|
||||
thickness?: "thin" | "normal" | "thick"; // Thickness options
|
||||
}
|
||||
|
||||
// Define default values
|
||||
const {
|
||||
className = "",
|
||||
color = "border-primary/25 dark:border-primary-light/25",
|
||||
width = "full",
|
||||
spacing = "md",
|
||||
thickness = "thin"
|
||||
} = Astro.props;
|
||||
|
||||
// Width mapping
|
||||
const widthClasses = {
|
||||
"full": "w-full",
|
||||
"3/4": "w-3/4",
|
||||
"1/2": "w-1/2",
|
||||
"1/4": "w-1/4"
|
||||
};
|
||||
|
||||
// Spacing mapping
|
||||
const spacingClasses = {
|
||||
"sm": "my-2",
|
||||
"md": "my-4",
|
||||
"lg": "my-8",
|
||||
"xl": "my-12"
|
||||
};
|
||||
|
||||
// Thickness mapping
|
||||
const thicknessClasses = {
|
||||
"thin": "border-[0.5px]",
|
||||
"normal": "border-[1px]",
|
||||
"thick": "border-[2px]"
|
||||
};
|
||||
---
|
||||
|
||||
<div class={`mx-auto ${widthClasses[width]} ${spacingClasses[spacing]} ${thicknessClasses[thickness]} ${color} border-dashed ${className}`} aria-hidden="true"></div>
|
||||
416
src/components/home/HeroCard.astro
Normal file
127
src/components/sections/BlogSection.astro
Normal file
@@ -0,0 +1,127 @@
|
||||
---
|
||||
import { getCollection } from "astro:content";
|
||||
import Pagination from "@/components/widgets/Pagination.astro";
|
||||
import BlogCard from "@/components/cards/BlogCard.astro";
|
||||
import SectionHeader from "@/components/elements/SectionHeader.astro";
|
||||
import Button from "@/components/ui/Button.astro";
|
||||
import AnimatedText from "@/components/ui/AnimatedText.astro";
|
||||
// 定义组件接收的属性
|
||||
const {
|
||||
title = "Latest Articles",
|
||||
description = "",
|
||||
limit = 3,
|
||||
listPage = false,
|
||||
showViewAllButton = true,
|
||||
pagination = {
|
||||
enable: false,
|
||||
currentPage: 1
|
||||
},
|
||||
postsPerPage = 3
|
||||
} = Astro.props;
|
||||
|
||||
// 获取博客文章集合
|
||||
const allPosts = await getCollection("post");
|
||||
|
||||
// 按发布日期排序文章
|
||||
let posts = allPosts.sort(
|
||||
(a, b) => b.data.publishDate.valueOf() - a.data.publishDate.valueOf()
|
||||
);
|
||||
|
||||
// 查找特色文章
|
||||
const featuredPost = posts.find((post) => post.data.featured);
|
||||
|
||||
// 如果存在特色文章,从主列表中移除
|
||||
if (featuredPost && listPage) {
|
||||
posts = posts.filter((post) => post.data.featured !== true);
|
||||
}
|
||||
|
||||
// 计算总页数
|
||||
const totalPages = Math.ceil(posts.length / postsPerPage);
|
||||
|
||||
// 如果启用了分页,则只显示当前页的文章
|
||||
if (pagination.enable) {
|
||||
const indexOfLastPost = pagination.currentPage * postsPerPage;
|
||||
const indexOfFirstPost = indexOfLastPost - postsPerPage;
|
||||
posts = posts.slice(indexOfFirstPost, indexOfLastPost);
|
||||
} else if (!listPage) {
|
||||
// 如果不是列表页,限制显示的文章数量
|
||||
posts = posts.slice(0, limit);
|
||||
}
|
||||
|
||||
// 为每篇文章添加链接属性 - 创建新对象而不是修改原对象
|
||||
const postsWithLinks = posts.map(post => ({
|
||||
...post,
|
||||
data: {
|
||||
...post.data,
|
||||
link: `/blog/${post.slug}`
|
||||
}
|
||||
}));
|
||||
|
||||
// 为特色文章创建带链接的版本
|
||||
const featuredPostWithLink = featuredPost ? {
|
||||
...featuredPost,
|
||||
data: {
|
||||
...featuredPost.data,
|
||||
link: `/blog/${featuredPost.slug}`
|
||||
}
|
||||
} : null;
|
||||
---
|
||||
|
||||
{/* 特色文章部分 - 仅在列表页第一页显示 */}
|
||||
{listPage && featuredPostWithLink && pagination.currentPage === 1 && (
|
||||
<section class="mb-16">
|
||||
<div class="site-container">
|
||||
<h2 class="text-3xl font-brand mb-6 text-neutral-800 dark:text-white">
|
||||
<AnimatedText delay={0.75} stagger={0.08} content="Featured" />
|
||||
</h2>
|
||||
<div class=" dark:from-neutral-900 dark:to-neutral-800 p-1 rounded-2xl ">
|
||||
<BlogCard
|
||||
layout="horizontal"
|
||||
content={{ ...featuredPostWithLink.data, ...featuredPostWithLink }}
|
||||
data-aos="fade-up-sm"
|
||||
data-aos-delay="1000"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section>
|
||||
<div class="site-container space-y-10 md:space-y-16">
|
||||
{!listPage && title && (
|
||||
<SectionHeader title={title} description={description}/>
|
||||
)}
|
||||
|
||||
{/* 列表页标题 */}
|
||||
{listPage && title && (
|
||||
<div class="mx-auto">
|
||||
<h2 class="text-3xl font-brand text-neutral-800 dark:text-white">{title}</h2>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div class="grid gap-x-6 gap-y-10 md:grid-cols-2 xl:grid-cols-3">
|
||||
{postsWithLinks && postsWithLinks.map((post, index) => (
|
||||
<BlogCard
|
||||
content={{ ...post.data, ...post }}
|
||||
data-aos={!pagination.enable && (Math.floor(index / 3) % 2 === 0 ? "fade-right-sm" : "fade-left-sm")}
|
||||
data-aos-delay={!pagination.enable && ((index % 3) + 1) * 200}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{listPage && totalPages > 1 && (
|
||||
<Pagination
|
||||
collection="blog"
|
||||
currentPage={pagination.currentPage || 1}
|
||||
totalPages={totalPages}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* "View All Articles" 按钮 - 仅在非列表页且启用时显示 */}
|
||||
{!listPage && showViewAllButton && (
|
||||
<div class="flex justify-center mt-12 mb-12">
|
||||
<Button url="/blog" className="w-full max-w-60">View All Articles</Button>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
559
src/components/sections/Explore.astro
Normal file
@@ -0,0 +1,559 @@
|
||||
---
|
||||
import SectionHeader from "@/components/elements/SectionHeader.astro";
|
||||
import Matter from "@/components/ui/Matter.astro";
|
||||
import Button from "@/components/ui/Button.astro";
|
||||
import Tools from "../ui/Tools.astro";
|
||||
interface Props {
|
||||
title?: string;
|
||||
description?: string;
|
||||
}
|
||||
const {title, description
|
||||
} = Astro.props;
|
||||
|
||||
---
|
||||
|
||||
<section class="py-8 sm:py-12 md:py-16 md:pb-12 site-container">
|
||||
<div class="space-y-6 sm:space-y-8 md:space-y-8">
|
||||
<SectionHeader title={title} description={description}/>
|
||||
</div>
|
||||
|
||||
<div class="explore-content w-full relative mt-6 sm:mt-8">
|
||||
<div class="explore-list w-full relative grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3 sm:gap-4">
|
||||
<!-- 第一列 -->
|
||||
<div class="explore-column explore-column-1 w-full flex gap-3 sm:gap-4 flex-col">
|
||||
<div class="explore-item bg-bg-secondary/75 dark:bg-bg-secondary-dark relative overflow-hidden rounded-xl border border-primary/15 dark:border-neutral-700/50 explore-item-1 h-[200px] sm:h-[220px] md:h-[264px]"
|
||||
data-aos="fade-up-sm"
|
||||
data-aos-delay="500"
|
||||
data-aos-duration="600"
|
||||
data-aos-once="true">
|
||||
<div class="content">
|
||||
<h3>Design & Code</h3>
|
||||
<p>Turning ideas into beautiful, functional experiences.</p>
|
||||
<!-- <div class="mt-2 btn opacity-0 transition duration-300 ease-in-out">
|
||||
<Button type="disabled" url="#" size="sm">
|
||||
Coming Soon
|
||||
</Button>
|
||||
</div> -->
|
||||
</div>
|
||||
<div class="absolute keyboard right-[-35%] sm:right-[-50%] md:right-[-58%] bottom-0 top-0 flex items-center self-center w-[300px] sm:w-[320px] md:w-[390px] h-auto">
|
||||
<img src="/assets/tools/keyboard.png" alt="Keyboard" class="w-full h-full object-cover">
|
||||
</div>
|
||||
</div>
|
||||
<div class="explore-item bg-bg-secondary/75 dark:bg-bg-secondary-dark relative overflow-hidden rounded-xl border border-primary/15 dark:border-neutral-700/50 explore-item-2 h-[180px] sm:h-[200px]"
|
||||
data-aos="fade-up-sm"
|
||||
data-aos-delay="600"
|
||||
data-aos-duration="600"
|
||||
data-aos-once="true">
|
||||
<div class="content">
|
||||
<h3>Faves</h3>
|
||||
<p>Picked things I'm genuinely into.</p>
|
||||
</div>
|
||||
<div class="explore-figure game-container absolute right-0 bottom-0 top-0 w-full h-full">
|
||||
<div class="absolute game right-[-21%] bottom-0 top-0 mt-[-10%] flex items-center self-center w-auto h-[100px] sm:h-[120px] md:h-[130px] z-10" data-game="1">
|
||||
<img src="/assets/tools/game/05-game-cassette.png" alt="Game Cassette 5" class="w-full h-full object-cover self-center flex">
|
||||
</div>
|
||||
<div class="absolute game right-[-19%] bottom-0 top-0 mt-[-5%] flex items-center self-center w-auto h-[100px] sm:h-[120px] md:h-[130px] z-20" data-game="2">
|
||||
<img src="/assets/tools/game/04-game-cassette.png" alt="Game Cassette 4" class="w-full h-full object-cover self-center flex">
|
||||
</div>
|
||||
<div class="absolute game right-[-16%] bottom-0 top-0 mt-0 flex items-center self-center w-auto h-[100px] sm:h-[120px] md:h-[130px] z-30" data-game="3">
|
||||
<img src="/assets/tools/game/03-game-cassette.png" alt="Game Cassette 3" class="w-full h-full object-cover self-center flex">
|
||||
</div>
|
||||
<div class="absolute game right-[-13%] bottom-0 top-0 mt-[5%] flex items-center self-center w-auto h-[100px] sm:h-[120px] md:h-[130px] z-40" data-game="4">
|
||||
<img src="/assets/tools/game/02-game-cassette.png" alt="Game Cassette 2" class="w-full h-full object-cover self-center flex">
|
||||
</div>
|
||||
<div class="absolute game right-[-10%] bottom-0 top-0 mt-[10%] flex items-center self-center w-auto h-[100px] sm:h-[120px] md:h-[130px] z-50" data-game="5">
|
||||
<img src="/assets/tools/game/01-game-cassette.png" alt="Game Cassette 1" class="w-full h-full object-cover self-center flex">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 第二列 -->
|
||||
<div class="explore-column explore-column-2 w-full flex gap-3 sm:gap-4 flex-col">
|
||||
<div class="explore-item bg-bg-secondary/75 dark:bg-bg-secondary-dark relative overflow-hidden rounded-xl border border-primary/15 dark:border-neutral-700/50 explore-item-3 h-[180px] sm:h-[200px]"
|
||||
data-aos="fade-up-sm"
|
||||
data-aos-delay="700"
|
||||
data-aos-duration="600"
|
||||
data-aos-once="true">
|
||||
<div class="content">
|
||||
<h3>Writing</h3>
|
||||
<p>Style guides, design notes, and quick reads.</p>
|
||||
<div class="mt-2 btn opacity-0 transition duration-300 ease-in-out">
|
||||
<Button url="/blog" size="sm">
|
||||
Blog
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="explore-figure computer absolute right-[5%] sm:right-[-10%] md:right-[-7%] bottom-[14%] w-[120px] sm:w-[130px] md:w-[148px] h-auto">
|
||||
<img src="/assets/tools/retro-computer.png" alt="Explore 1" class="w-full h-full object-cover z-10 relative">
|
||||
<div class="computer-screen absolute inset-0 w-[92px] sm:w-[100px] md:w-[116px] h-[76px] sm:h-[86px] md:h-[96px] top-[10.5%] left-[10%] rounded-[8px] rounded-b-[3px] overflow-hidden z-20">
|
||||
<video
|
||||
id="explore-computer-video"
|
||||
src="/assets/tools/dreamcore.mp4"
|
||||
poster="/assets/tools/dreamcore.jpg"
|
||||
autoplay
|
||||
loop
|
||||
muted
|
||||
playsinline
|
||||
webkit-playsinline
|
||||
preload="auto"
|
||||
controlslist="nodownload noplaybackrate nofullscreen noremoteplayback"
|
||||
disablepictureinpicture
|
||||
class="w-full h-full object-cover"
|
||||
></video>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="explore-item bg-bg-secondary/75 dark:bg-bg-secondary-dark explore-item-3 relative overflow-hidden rounded-xl border border-primary/15 dark:border-neutral-700/50 explore-item-4 h-[200px] sm:h-[220px] md:h-[264px]"
|
||||
data-aos="fade-up-sm"
|
||||
data-aos-delay="800"
|
||||
data-aos-duration="600"
|
||||
data-aos-once="true">
|
||||
<div class="content">
|
||||
<h3>My Tools</h3>
|
||||
<p>Design tools I built to speed up my workflow!</p>
|
||||
</div>
|
||||
<div class=" explore-figure absolute machine right-[5%] sm:right-[-10%] md:right-[-7%] bottom-0 top-0 flex items-center">
|
||||
<Tools />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="explore-column explore-column-3 w-full col-span-1 sm:col-span-2 lg:col-span-1">
|
||||
<div class="explore-item bg-bg-secondary/75 dark:bg-bg-secondary-dark explore-item-3 relative overflow-hidden rounded-xl border border-primary/15 dark:border-neutral-700/50 explore-item-5"
|
||||
data-aos="fade-up-sm"
|
||||
data-aos-delay="900"
|
||||
data-aos-duration="600"
|
||||
data-aos-once="true">
|
||||
<div
|
||||
class="explore-figure tool-stack-box relative rounded-xl overflow-hidden w-full h-[480px] sm:h-[400px] md:h-[480px]"
|
||||
data-aos="fade-up-sm"
|
||||
data-aos-delay="100"
|
||||
data-aos-duration="600"
|
||||
data-aos-once="true"
|
||||
>
|
||||
<Matter />
|
||||
<div class="absolute inset-0 flex items-center justify-center text-white opacity-50">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.content{
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
top: 0;
|
||||
max-width: 45%;
|
||||
padding-left:1rem;
|
||||
padding-right:0.5rem;
|
||||
display: flex;
|
||||
align-items: self-start;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
transition: all 0.3s ease-in-out;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.content {
|
||||
max-width: 55%;
|
||||
padding-left:1.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.content {
|
||||
max-width: 50%;
|
||||
padding-left:1.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.content h3{
|
||||
font-size: 1.25rem;
|
||||
line-height: 1.1;
|
||||
margin-bottom: 0.5rem;
|
||||
font-family: var(--font-brand);
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.content h3 {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.content h3 {
|
||||
font-size: 1.75rem;
|
||||
}
|
||||
}
|
||||
|
||||
.content p{
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.3;
|
||||
font-weight: 400;
|
||||
color: var(--color-neutral-400);
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.html .dark .content p{
|
||||
color: var(--color-neutral-300);
|
||||
}
|
||||
@media (min-width: 640px) {
|
||||
.content p {
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.content p {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
}
|
||||
|
||||
.explore-item{
|
||||
position: relative;
|
||||
border-radius: 1rem;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
justify-content: start;
|
||||
align-items: center;
|
||||
}
|
||||
.explore-item:hover .btn{
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.explore-item-1:hover .keyboard{
|
||||
right: -48%;
|
||||
}
|
||||
|
||||
.explore-item-3:hover .computer{
|
||||
right: 5%;
|
||||
}
|
||||
|
||||
.explore-item-4:hover .machine{
|
||||
right: 5%;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.explore-item-1:hover .keyboard{
|
||||
right: -70%;
|
||||
}
|
||||
|
||||
.explore-item-3:hover .computer{
|
||||
right: -5%;
|
||||
}
|
||||
|
||||
.explore-item-4:hover .machine{
|
||||
right: -5%;
|
||||
}
|
||||
}
|
||||
@media (max-width: 767px) {
|
||||
.machine{
|
||||
transform: scale(0.9);
|
||||
}
|
||||
}
|
||||
.computer{
|
||||
cursor: pointer;
|
||||
transition: all .5s ease-in-out;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.keyboard,.game,.machine{
|
||||
cursor: pointer;
|
||||
transition: all .5s ease-in-out;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
|
||||
.game-container {
|
||||
perspective: 1000px;
|
||||
overflow: visible !important;
|
||||
}
|
||||
.explore-figure{
|
||||
z-index: 50;
|
||||
}
|
||||
|
||||
|
||||
.explore-item-2 {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
|
||||
.explore-column-1 {
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.explore-list {
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.explore-content {
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
|
||||
.explore-item-2 .game {
|
||||
transition: all 0.5s cubic-bezier(0.25, 0.46, 0.45, 0.94);
|
||||
}
|
||||
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.explore-item-2:hover .game[data-game="1"] {
|
||||
right: 8%;
|
||||
transform: translateX(0) scale(1.0);
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.explore-item-2:hover .game[data-game="2"] {
|
||||
right: 17%;
|
||||
transform: translateX(0) scale(1.02);
|
||||
z-index: 20;
|
||||
}
|
||||
|
||||
.explore-item-2:hover .game[data-game="3"] {
|
||||
right: 26%;
|
||||
transform: translateX(0) scale(1.05);
|
||||
z-index: 30;
|
||||
}
|
||||
|
||||
.explore-item-2:hover .game[data-game="4"] {
|
||||
right: 35%;
|
||||
transform: translateX(0) scale(1.02);
|
||||
z-index: 40;
|
||||
}
|
||||
|
||||
.explore-item-2:hover .game[data-game="5"] {
|
||||
right: 42%;
|
||||
transform: translateX(0) scale(1.0);
|
||||
z-index: 50;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.explore-item-2:hover .game[data-game="1"] {
|
||||
right: 0%;
|
||||
transform: translateX(0) scale(0.95);
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.explore-item-2:hover .game[data-game="2"] {
|
||||
right: 10%;
|
||||
transform: translateX(0) scale(0.97);
|
||||
z-index: 20;
|
||||
}
|
||||
|
||||
.explore-item-2:hover .game[data-game="3"] {
|
||||
right: 20%;
|
||||
transform: translateX(0) scale(1.0);
|
||||
z-index: 30;
|
||||
}
|
||||
|
||||
.explore-item-2:hover .game[data-game="4"] {
|
||||
right: 30%;
|
||||
transform: translateX(0) scale(0.97);
|
||||
z-index: 40;
|
||||
}
|
||||
|
||||
.explore-item-2:hover .game[data-game="5"] {
|
||||
right: 40%;
|
||||
transform: translateX(0) scale(0.95);
|
||||
z-index: 50;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.explore-item-2 .game:hover {
|
||||
transform: scale(1.1) !important;
|
||||
z-index: 100 !important;
|
||||
filter: drop-shadow(0 8px 16px rgba(0,0,0,0.25));
|
||||
transition: all 0.25s cubic-bezier(0.25, 0.46, 0.45, 0.94);
|
||||
}
|
||||
|
||||
|
||||
.explore-item-2 .game:active {
|
||||
transform: scale(0.9) !important;
|
||||
transition: all 0.1s ease;
|
||||
}
|
||||
|
||||
|
||||
@media (hover: none) {
|
||||
.explore-item-2 .game {
|
||||
transform: scale(0.9);
|
||||
}
|
||||
|
||||
.explore-item-2 .game[data-game="1"] {
|
||||
right: 0%;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.explore-item-2 .game[data-game="2"] {
|
||||
right: 10%;
|
||||
z-index: 20;
|
||||
}
|
||||
|
||||
.explore-item-2 .game[data-game="3"] {
|
||||
right: 20%;
|
||||
z-index: 30;
|
||||
}
|
||||
|
||||
.explore-item-2 .game[data-game="4"] {
|
||||
right: 30%;
|
||||
z-index: 40;
|
||||
}
|
||||
|
||||
.explore-item-2 .game[data-game="5"] {
|
||||
right: 40%;
|
||||
z-index: 50;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<script is:inline>
|
||||
(() => {
|
||||
|
||||
const videoId = 'explore-computer-video';
|
||||
const video = document.getElementById(videoId);
|
||||
if (!video || !(video instanceof HTMLVideoElement)) return;
|
||||
|
||||
|
||||
let isPlaying = false;
|
||||
let observer = null;
|
||||
let playAttempts = 0;
|
||||
const MAX_PLAY_ATTEMPTS = 3;
|
||||
|
||||
|
||||
video.muted = true;
|
||||
video.playsInline = true;
|
||||
video.setAttribute('playsinline', '');
|
||||
video.setAttribute('webkit-playsinline', '');
|
||||
video.removeAttribute('controls');
|
||||
|
||||
|
||||
function tryPlay() {
|
||||
|
||||
if (isPlaying || playAttempts >= MAX_PLAY_ATTEMPTS) return;
|
||||
|
||||
playAttempts++;
|
||||
|
||||
const playPromise = video.play();
|
||||
|
||||
if (playPromise !== undefined) {
|
||||
playPromise
|
||||
.then(() => {
|
||||
isPlaying = true;
|
||||
playAttempts = 0; // 成功后重置计数
|
||||
console.log('Video started playing successfully');
|
||||
})
|
||||
.catch((error) => {
|
||||
isPlaying = false;
|
||||
console.debug(`Video autoplay attempt ${playAttempts} failed:`, error.name);
|
||||
|
||||
// 如果是用户交互问题,等待用户交互后重试
|
||||
if (error.name === 'NotAllowedError') {
|
||||
handleUserInteraction();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
function handleUserInteraction() {
|
||||
const interactionEvents = ['touchstart', 'click', 'scroll'];
|
||||
|
||||
function onInteraction() {
|
||||
tryPlay();
|
||||
|
||||
interactionEvents.forEach(event => {
|
||||
document.removeEventListener(event, onInteraction);
|
||||
});
|
||||
}
|
||||
|
||||
interactionEvents.forEach(event => {
|
||||
document.addEventListener(event, onInteraction, { once: true, passive: true });
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
function setupIntersectionObserver() {
|
||||
observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
entries.forEach((entry) => {
|
||||
if (entry.isIntersecting) {
|
||||
|
||||
if (video.paused) {
|
||||
playAttempts = 0; // reset count
|
||||
tryPlay();
|
||||
}
|
||||
} else {
|
||||
// leave viewport, pause to save resources
|
||||
if (!video.paused) {
|
||||
video.pause();
|
||||
isPlaying = false;
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
{
|
||||
root: null,
|
||||
rootMargin: '50px', // preload
|
||||
threshold: 0.1
|
||||
}
|
||||
);
|
||||
|
||||
observer.observe(video);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle page visibility changes
|
||||
*/
|
||||
function handleVisibilityChange() {
|
||||
if (!document.hidden && !video.paused) {
|
||||
// Page is visible again and video should play
|
||||
const rect = video.getBoundingClientRect();
|
||||
const isInViewport = rect.top < window.innerHeight && rect.bottom > 0;
|
||||
|
||||
if (isInViewport) {
|
||||
playAttempts = 0;
|
||||
tryPlay();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize
|
||||
function init() {
|
||||
// Wait for video metadata to load
|
||||
if (video.readyState >= 2) {
|
||||
// HAVE_CURRENT_DATA
|
||||
tryPlay();
|
||||
} else {
|
||||
video.addEventListener('loadeddata', tryPlay, { once: true });
|
||||
}
|
||||
|
||||
// Setup viewport observer
|
||||
setupIntersectionObserver();
|
||||
|
||||
// Listen for page visibility changes
|
||||
document.addEventListener('visibilitychange', handleVisibilityChange);
|
||||
|
||||
// Special handling for mobile devices
|
||||
if (/Android|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent)) {
|
||||
console.log('Mobile device detected, setting up additional handlers');
|
||||
handleUserInteraction();
|
||||
}
|
||||
}
|
||||
|
||||
// Start
|
||||
init();
|
||||
})();
|
||||
</script>
|
||||
112
src/components/sections/FeaturedWork.astro
Normal file
@@ -0,0 +1,112 @@
|
||||
---
|
||||
import projects from "@/collections/featuredwork.json";
|
||||
import WorkCard from "@/components/cards/WorkCard.astro";
|
||||
import SectionHeader from "@/components/elements/SectionHeader.astro";
|
||||
import Button from "@/components/ui/Button.astro";
|
||||
|
||||
interface Props {
|
||||
title?: string;
|
||||
description?: string;
|
||||
showAll?: boolean;
|
||||
limit?: number; // 限制显示的项目数量
|
||||
}
|
||||
|
||||
interface Project {
|
||||
name: string;
|
||||
description?: string;
|
||||
image: string;
|
||||
url: string;
|
||||
tags?: string[];
|
||||
video?: string;
|
||||
isShow?: boolean;
|
||||
}
|
||||
|
||||
const {
|
||||
title = "Featured Work",
|
||||
description = "A selection of projects I've worked on. From web applications to design systems, each project represents a unique challenge and learning experience.",
|
||||
showAll = false,
|
||||
limit
|
||||
} = Astro.props;
|
||||
|
||||
// 获取项目列表
|
||||
let allProjects = projects as Project[];
|
||||
|
||||
// 根据 limit 或 showAll 来控制显示数量
|
||||
if (limit !== undefined) {
|
||||
// 如果指定了 limit,使用 limit
|
||||
allProjects = projects.slice(0, limit) as Project[];
|
||||
} else if (!showAll) {
|
||||
// 如果没有指定 limit,且 showAll 为 false,默认显示 6 个
|
||||
allProjects = projects.slice(0, 6) as Project[];
|
||||
}
|
||||
// 如果 showAll 为 true 且没有 limit,显示所有项目
|
||||
|
||||
// 前三个为特色项目(全宽)
|
||||
const featuredProjects = allProjects.slice(0, 3);
|
||||
|
||||
// 其余为网格项目(两栏)
|
||||
const gridProjects = allProjects.slice(3);
|
||||
|
||||
// 判断是否应该显示 "View All" 按钮
|
||||
const shouldShowViewAll = limit !== undefined
|
||||
? limit < projects.length
|
||||
: !showAll && projects.length > 6;
|
||||
---
|
||||
|
||||
<section class="py-16 md:py-16 md:pb-12">
|
||||
<div class="site-container space-y-8 md:space-y-8">
|
||||
<!-- 标题部分 -->
|
||||
<SectionHeader title={title} description={description} />
|
||||
|
||||
<!-- 特色项目 - 全宽显示 -->
|
||||
{featuredProjects.length > 0 && (
|
||||
<div class="space-y-8 md:space-y-10" data-aos-delay="750" data-aos="fade-up-sm" data-aos-duration="1000" data-aos-once="true">
|
||||
{featuredProjects
|
||||
.filter(project => project.isShow !== false)
|
||||
.map((project, index) => (
|
||||
<WorkCard
|
||||
name={project.name}
|
||||
description={project.description}
|
||||
image={project.image}
|
||||
url={project.url}
|
||||
tags={project.tags}
|
||||
video={project.video}
|
||||
isShow={project.isShow}
|
||||
layout="featured"
|
||||
index={index}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<!-- 网格项目 - 两栏显示 -->
|
||||
{gridProjects.length > 0 && (
|
||||
<div class="grid gap-6 md:gap-8 md:grid-cols-2" data-aos-delay="150" data-aos="fade-up-sm" data-aos-duration="1000" data-aos-once="true">
|
||||
{gridProjects
|
||||
.filter(project => project.isShow !== false)
|
||||
.map((project, index) => (
|
||||
<WorkCard
|
||||
name={project.name}
|
||||
description={project.description}
|
||||
image={project.image}
|
||||
url={project.url}
|
||||
tags={project.tags}
|
||||
video={project.video}
|
||||
isShow={project.isShow}
|
||||
layout="grid"
|
||||
index={index}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<!-- See More 按钮 (可选) -->
|
||||
{shouldShowViewAll && (
|
||||
<div class="flex items-center justify-center pt-4">
|
||||
<Button url="/works" className="w-full max-w-60">
|
||||
See All Works
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
86
src/components/sections/Footer.astro
Normal file
@@ -0,0 +1,86 @@
|
||||
---
|
||||
import Logo from "@/components/ui/Logo.astro";
|
||||
import ToTop from "@/components/widgets/ToTop.astro";
|
||||
import { siteConfig, socialLinks } from "@/config/site.js";
|
||||
---
|
||||
|
||||
<section
|
||||
class="text-gray-700 border-t mt-20 md:mt-48 border-dashed border-primary/15 dark:border-primary-dark/15 border-[.75px]"
|
||||
>
|
||||
<div
|
||||
class="container flex flex-col items-center py-8 mx-auto px-7 max-w-7xl sm:flex-row"
|
||||
>
|
||||
<Logo />
|
||||
<p
|
||||
class="mt-4 text-sm text-neutral-700 dark:text-neutral-100 sm:ml-4 sm:pl-4 sm:border-l sm:border-neutral-300 dark:sm:border-neutral-700 sm:mt-0"
|
||||
>
|
||||
© {new Date().getFullYear()}
|
||||
|
||||
<a href={siteConfig.url || " "} target="_blank">{siteConfig.author || " "}</a>
|
||||
</p>
|
||||
<span
|
||||
class="inline-flex justify-center mt-4 space-x-5 sm:ml-auto sm:mt-0 sm:justify-start overflow-hidden"
|
||||
>
|
||||
{socialLinks.map((social) => (
|
||||
<a
|
||||
href={social.url}
|
||||
target="_blank"
|
||||
class="footer-social-icon-link text-neutral-500 dark:text-neutral-300 "
|
||||
title={social.name}
|
||||
>
|
||||
<span class="sr-only">{social.name}</span>
|
||||
<div class="footer-social-icon text-neutral-500" >
|
||||
<Fragment set:html={social.icon} />
|
||||
</div>
|
||||
</a>
|
||||
))}
|
||||
</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 返回顶部按钮 -->
|
||||
<ToTop />
|
||||
|
||||
<style is:global>
|
||||
.footer-social-icon {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.3s ease-in-out;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.footer-social-icon svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
fill: currentColor;
|
||||
transition: all 0.3s ease-in-out;
|
||||
}
|
||||
.footer-social-icon-link:hover .footer-social-icon svg {
|
||||
width: auto;
|
||||
height: 24px;
|
||||
fill: currentColor;
|
||||
}
|
||||
|
||||
.footer-social-icon-link:hover .ic-twitter {
|
||||
color: #1DA1F2;
|
||||
}
|
||||
|
||||
.footer-social-icon-link:hover .ic-github {
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.footer-social-icon-link:hover .ic-zcool {
|
||||
color: #fff200;
|
||||
}
|
||||
|
||||
.footer-social-icon-link:hover .ic-behance {
|
||||
color: #0055ff;
|
||||
}
|
||||
|
||||
.footer-social-icon-link:hover .ic-rss {
|
||||
color: #F26522;
|
||||
}
|
||||
</style>
|
||||
397
src/components/sections/Header.astro
Normal file
@@ -0,0 +1,397 @@
|
||||
---
|
||||
import { Mail, Moon, SunMedium,Download } from "@lucide/astro";
|
||||
import menus from "@/collections/menu.json";
|
||||
import Button from "@/components/ui/Button.astro";
|
||||
import Logo from "@/components/ui/Logo.astro";
|
||||
---
|
||||
|
||||
<!-- This is an invisible div with relative position so that it takes up the height of the menu (because menu is absolute/fixed) -->
|
||||
<div class="relative w-full h-16 sm:h-20 opacity-0 pointer-events-none"></div>
|
||||
<header id="header" class="fixed top-2 sm:top-4 z-50 w-full px-3 sm:px-4 lg:px-6">
|
||||
<div id="site-container"
|
||||
class="flex items-center justify-between h-14 sm:h-15 site-container mx-auto px-4 sm:px-6 py-2.5 border-transparent border-[0.5px] transition-all duration-300"
|
||||
>
|
||||
<!-- Logo -->
|
||||
<div class="flex-shrink-0 z-50">
|
||||
<Logo />
|
||||
</div>
|
||||
|
||||
<!-- Mobile Menu Background Overlay -->
|
||||
<div
|
||||
id="mobileMenuBackground"
|
||||
class="fixed inset-0 z-20 hidden w-screen h-screen duration-300 ease-out bg-white/90 backdrop-blur-sm dark:bg-neutral-950/90"
|
||||
>
|
||||
</div>
|
||||
|
||||
<!-- Navigation -->
|
||||
<nav
|
||||
class="relative z-30 flex flex-row-reverse justify-start w-full text-sm sm:justify-end text-neutral-500 dark:text-neutral-400 sm:flex-row sm:items-center"
|
||||
>
|
||||
<!-- Mobile Menu Toggle Buttons -->
|
||||
<div class="flex items-center gap-2 sm:hidden">
|
||||
<!-- Dark Mode Toggle (Mobile) -->
|
||||
<div
|
||||
id="darkToggleMobile"
|
||||
class="flex items-center justify-center w-10 h-10 cursor-pointer rounded-full bg-gradient-to-b from-white to-[#edeefa] border-[0.5px] border-[#f3f3ff] dark:from-neutral-800 dark:to-neutral-600 dark:border-neutral-600 transition-transform duration-200 active:scale-95"
|
||||
>
|
||||
<div
|
||||
class="flex justify-center items-center w-6 h-6 relative overflow-hidden rounded-full bg-gradient-to-b from-[#897fff] to-[#4a3aff] border-[0.5px] border-[#897fff]"
|
||||
style="box-shadow: 0px 2px 3px 0 rgba(55,52,209,0.21);"
|
||||
>
|
||||
<SunMedium
|
||||
class="absolute hidden text-white w-4 h-4 transition duration-200 transform ease mobile-sun" />
|
||||
<Moon
|
||||
class="absolute hidden text-white w-4 h-4 transition duration-200 transform ease mobile-moon" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Hamburger Menu Button -->
|
||||
<div
|
||||
id="openMenu"
|
||||
class="flex items-center justify-center w-10 h-10 cursor-pointer transition-transform duration-200 active:scale-90"
|
||||
>
|
||||
<svg
|
||||
class="w-7 h-7 text-neutral-700 dark:text-neutral-200"
|
||||
fill="none"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"><path d="M4 8h16M4 16h16"></path></svg
|
||||
>
|
||||
</div>
|
||||
|
||||
<!-- Close Menu Button -->
|
||||
<div
|
||||
id="closeMenu"
|
||||
class="hidden items-center justify-center w-10 h-10 cursor-pointer transition-transform duration-200 active:scale-90"
|
||||
>
|
||||
<svg
|
||||
class="w-6 h-6 text-neutral-600 dark:text-neutral-200"
|
||||
fill="none"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"><path d="M6 18L18 6M6 6l12 12"></path></svg
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Menu Items -->
|
||||
<div
|
||||
id="menu"
|
||||
class="fixed top-[68px] sm:top-0 left-3 right-3 sm:left-0 sm:right-0 ease-out duration-300 z-40 flex-col items-center justify-start hidden w-auto h-auto text-sm pt-6 pb-5 sm:py-0 sm:relative sm:flex-row sm:flex"
|
||||
>
|
||||
<!-- Mobile Menu Background -->
|
||||
<div
|
||||
class="absolute inset-0 top-0 right-0 block w-full h-full sm:hidden"
|
||||
>
|
||||
<div
|
||||
class="relative w-full h-full bg-white/95 border border-dashed border-neutral-300 dark:border-neutral-700 backdrop-blur-md rounded-2xl dark:bg-neutral-950/95 shadow-lg"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Menu Links -->
|
||||
<div class="relative z-10 flex flex-col sm:flex-row items-center w-full sm:w-auto gap-1 sm:gap-0">
|
||||
{
|
||||
menus.map((menu) => {
|
||||
return (
|
||||
<a
|
||||
href={menu.url}
|
||||
class="relative flex items-center justify-center w-full sm:w-auto px-5 py-2.5 sm:py-2 sm:px-3 md:px-4 font-medium tracking-wide text-center duration-200 ease-out rounded-lg sm:rounded-none text-neutral-700 dark:text-neutral-200 hover:text-primary sm:hover:bg-transparent dark:hover:text-white dark:hover:bg-neutral-800/50 sm:dark:hover:bg-transparent active:scale-[0.98] sm:active:scale-100 transition-all"
|
||||
>
|
||||
{menu.name}
|
||||
</a>
|
||||
)
|
||||
})
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Contact Button (Mobile) -->
|
||||
<div class="relative z-10 w-full px-5 mt-3 sm:hidden">
|
||||
<Button url="javascript:void(0);" type="fill" className="m-auto w-full justify-center ">
|
||||
Resume <Download size={16} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Desktop Actions -->
|
||||
<div class="relative hidden sm:flex items-center gap-2 ml-4 lg:ml-6">
|
||||
<!-- Contact Button (Desktop) -->
|
||||
<Button url="javascript:void(0);" type="fill" className="md:flex mx-1">
|
||||
Resume <Download size={16} />
|
||||
</Button>
|
||||
|
||||
<span class="separator-line hidden sm:inline-block bg-[rgba(183,202,255,0.5)] mt-[20px] -translate-y-1/2 w-px h-[20px]"></span>
|
||||
|
||||
<!-- Dark Mode Toggle (Desktop) -->
|
||||
<div
|
||||
id="darkToggle"
|
||||
class="relative flex items-center h-9 px-2 gap-1.5 font-medium cursor-pointer rounded-full bg-gradient-to-b from-white to-[#edf1fa] border-[0.5px] border-[#f3f5ff] dark:from-neutral-800 dark:to-neutral-600 dark:border-neutral-600 transition-all duration-200 hover:shadow-md hover:scale-105 active:scale-95 mx-1"
|
||||
>
|
||||
<div
|
||||
class="flex justify-center items-center flex-shrink-0 w-6 h-6 relative overflow-hidden rounded-full bg-gradient-to-b from-[#85a6ff] to-[#2d6dc3] border-[0.5px] border-[#7fa1ff]"
|
||||
style="box-shadow: 0px 2px 3px 0 rgba(55,52,209,0.21);"
|
||||
>
|
||||
<SunMedium id="sun"
|
||||
class="absolute hidden text-white w-4 h-4 transition duration-200 transform ease" />
|
||||
<Moon class="absolute hidden text-white w-4 h-4 transition duration-200 transform ease"
|
||||
id="moon" />
|
||||
</div>
|
||||
<span class="hidden sm:inline-block whitespace-nowrap">
|
||||
<span id="dayText" class="flex-shrink-0 text-sm text-left text-[#6f6c8f] dark:text-neutral-400">Light</span>
|
||||
<span id="nightText" class="hidden flex-shrink-0 text-sm text-left text-[#6f6c8f] dark:text-neutral-400">Dark</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<style>
|
||||
/* Header container spacing optimization */
|
||||
#header {
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
/* Ensure mobile menu doesn't stick to edges */
|
||||
@media (max-width: 639px) {
|
||||
#menu {
|
||||
max-width: calc(100vw - 24px); /* 12px margin on each side */
|
||||
}
|
||||
}
|
||||
|
||||
/* Mobile menu animation */
|
||||
@media (max-width: 639px) {
|
||||
#menu {
|
||||
transform: translateY(-10px);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
#menu:not(.hidden) {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
}
|
||||
|
||||
/* Menu item hover effect optimization */
|
||||
nav a {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Desktop underline animation */
|
||||
@media (min-width: 640px) {
|
||||
nav a::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 50%;
|
||||
transform: translateX(-50%) scaleX(0);
|
||||
width: 70%;
|
||||
height: 2px;
|
||||
background: var(--color-primary);
|
||||
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
nav a:hover::after {
|
||||
transform: translateX(-50%) scaleX(1);
|
||||
}
|
||||
}
|
||||
|
||||
/* Mobile menu item click feedback */
|
||||
@media (max-width: 639px) {
|
||||
nav a:active {
|
||||
background-color: var(--color-neutral-100);
|
||||
}
|
||||
|
||||
.dark nav a:active {
|
||||
background-color: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* Dark mode toggle button optimization */
|
||||
#darkToggle,
|
||||
#darkToggleMobile {
|
||||
user-select: none;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
/* Mobile menu background blur effect enhancement */
|
||||
@supports (backdrop-filter: blur(12px)) {
|
||||
#mobileMenuBackground {
|
||||
backdrop-filter: blur(12px);
|
||||
}
|
||||
|
||||
@media (max-width: 639px) {
|
||||
#menu > div > div {
|
||||
backdrop-filter: blur(16px);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Ensure Logo doesn't shrink on mobile */
|
||||
.flex-shrink-0 {
|
||||
min-width: fit-content;
|
||||
}
|
||||
|
||||
/* Tablet compact layout */
|
||||
@media (min-width: 640px) and (max-width: 767px) {
|
||||
nav a {
|
||||
padding-left: 0.5rem;
|
||||
padding-right: 0.5rem;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Prevent horizontal scrolling on mobile */
|
||||
@media (max-width: 639px) {
|
||||
body {
|
||||
overflow-x: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
/* Optimize mobile touch feedback */
|
||||
@media (max-width: 639px) {
|
||||
button, a, [role="button"] {
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
/* Menu background on small screens */
|
||||
@media (max-width: 639px) {
|
||||
#mobileMenuBackground {
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s ease-out;
|
||||
}
|
||||
|
||||
#mobileMenuBackground:not(.hidden) {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* Ensure mobile menu has good shadow */
|
||||
@media (max-width: 639px) {
|
||||
#menu > div > div {
|
||||
box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.1),
|
||||
0 8px 10px -6px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.dark #menu > div > div {
|
||||
box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.3),
|
||||
0 8px 10px -6px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
// Declare global types to fix TypeScript errors
|
||||
declare global {
|
||||
interface Window {
|
||||
closeMobileMenu: () => void;
|
||||
}
|
||||
}
|
||||
|
||||
// Sync dark mode toggle between mobile and desktop
|
||||
const darkToggle = document.getElementById('darkToggle');
|
||||
const darkToggleMobile = document.getElementById('darkToggleMobile');
|
||||
|
||||
function syncDarkModeIcons() {
|
||||
const isDark = document.documentElement.classList.contains('dark');
|
||||
const sunIcons = document.querySelectorAll('#sun, .mobile-sun');
|
||||
const moonIcons = document.querySelectorAll('#moon, .mobile-moon');
|
||||
|
||||
if (isDark) {
|
||||
moonIcons.forEach(icon => icon.classList.remove('hidden'));
|
||||
sunIcons.forEach(icon => icon.classList.add('hidden'));
|
||||
document.getElementById('dayText')?.classList.add('hidden');
|
||||
document.getElementById('nightText')?.classList.remove('hidden');
|
||||
} else {
|
||||
sunIcons.forEach(icon => icon.classList.remove('hidden'));
|
||||
moonIcons.forEach(icon => icon.classList.add('hidden'));
|
||||
document.getElementById('dayText')?.classList.remove('hidden');
|
||||
document.getElementById('nightText')?.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
// Sync on initialization
|
||||
document.addEventListener('DOMContentLoaded', syncDarkModeIcons);
|
||||
|
||||
// Listen for dark mode changes
|
||||
const observer = new MutationObserver(syncDarkModeIcons);
|
||||
observer.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ['class']
|
||||
});
|
||||
|
||||
// Mobile dark mode toggle
|
||||
darkToggleMobile?.addEventListener('click', () => {
|
||||
darkToggle?.click();
|
||||
});
|
||||
|
||||
// Optimize mobile menu open/close
|
||||
const openMenu = document.getElementById('openMenu');
|
||||
const closeMenu = document.getElementById('closeMenu');
|
||||
const menu = document.getElementById('menu');
|
||||
const mobileMenuBackground = document.getElementById('mobileMenuBackground');
|
||||
|
||||
// Mobile menu open function
|
||||
function openMobileMenu() {
|
||||
menu?.classList.remove('hidden');
|
||||
mobileMenuBackground?.classList.remove('hidden');
|
||||
openMenu?.classList.add('hidden');
|
||||
closeMenu?.classList.remove('hidden');
|
||||
closeMenu?.classList.add('flex');
|
||||
document.body.style.overflow = 'hidden'; // Prevent background scrolling
|
||||
}
|
||||
|
||||
// Mobile menu close function
|
||||
function closeMobileMenu() {
|
||||
menu?.classList.add('hidden');
|
||||
mobileMenuBackground?.classList.add('hidden');
|
||||
closeMenu?.classList.add('hidden');
|
||||
closeMenu?.classList.remove('flex');
|
||||
openMenu?.classList.remove('hidden');
|
||||
document.body.style.overflow = ''; // Restore scrolling
|
||||
}
|
||||
|
||||
// Click hamburger button to open menu
|
||||
openMenu?.addEventListener('click', openMobileMenu);
|
||||
|
||||
// Click close button to close menu
|
||||
closeMenu?.addEventListener('click', closeMobileMenu);
|
||||
|
||||
// Click background to close menu
|
||||
mobileMenuBackground?.addEventListener('click', closeMobileMenu);
|
||||
|
||||
// Add click-to-close functionality for mobile menu items
|
||||
const menuLinks = menu?.querySelectorAll('a');
|
||||
menuLinks?.forEach(link => {
|
||||
link.addEventListener('click', () => {
|
||||
// Check if in mobile view
|
||||
if (window.innerWidth < 640) {
|
||||
closeMobileMenu();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Handle window resize
|
||||
window.addEventListener('resize', () => {
|
||||
if (window.innerWidth >= 640) {
|
||||
// If resizing from mobile to desktop, ensure menu state is correct
|
||||
document.body.style.overflow = '';
|
||||
if (menu?.classList.contains('hidden')) {
|
||||
menu.classList.remove('hidden');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Add to global scope so inline events can call it
|
||||
window.closeMobileMenu = closeMobileMenu;
|
||||
</script>
|
||||
50
src/components/sections/WorksSection.astro
Normal file
@@ -0,0 +1,50 @@
|
||||
---
|
||||
import works from "@/collections/works.json";
|
||||
import WorkCard from "@/components/cards/WorkCard.astro";
|
||||
|
||||
interface Props {
|
||||
limit?: number; // 限制显示的项目数量
|
||||
}
|
||||
|
||||
interface Work {
|
||||
name: string;
|
||||
description?: string;
|
||||
image: string;
|
||||
url: string;
|
||||
tags?: string[];
|
||||
video?: string;
|
||||
isShow?: boolean;
|
||||
}
|
||||
|
||||
const { limit } = Astro.props;
|
||||
|
||||
// 获取项目列表
|
||||
let allWorks = works as Work[];
|
||||
|
||||
// 如果指定了 limit,使用 limit
|
||||
if (limit !== undefined) {
|
||||
allWorks = works.slice(0, limit) as Work[];
|
||||
}
|
||||
---
|
||||
|
||||
<section class="py-8 md:py-8">
|
||||
<div class="space-y-8 md:space-y-10">
|
||||
<!-- 所有项目全宽显示 -->
|
||||
{allWorks
|
||||
.filter(work => work.isShow !== false)
|
||||
.map((work, index) => (
|
||||
<WorkCard
|
||||
name={work.name}
|
||||
description={work.description}
|
||||
image={work.image}
|
||||
url={work.url}
|
||||
tags={work.tags}
|
||||
video={work.video}
|
||||
isShow={work.isShow}
|
||||
layout="featured"
|
||||
index={index}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
91
src/components/ui/AnimatedText.astro
Normal file
@@ -0,0 +1,91 @@
|
||||
---
|
||||
interface Props {
|
||||
class?: string;
|
||||
content: string | Promise<string>;
|
||||
duration?: number;
|
||||
delay?: number;
|
||||
stagger?: number;
|
||||
}
|
||||
|
||||
let {
|
||||
content,
|
||||
duration = 0.6,
|
||||
delay = 0,
|
||||
stagger = 0.1,
|
||||
...rest
|
||||
} = Astro.props;
|
||||
|
||||
let processedContent = "";
|
||||
if (content) {
|
||||
// @ts-expect-error
|
||||
const words = content.split(" "); // Split text into words
|
||||
processedContent = words
|
||||
.map(
|
||||
(word: string) =>
|
||||
`<span class="word" style="display: inline-block; will-change: transform; transform: translateY(10px); opacity: 0; filter: blur(10px);">${word}</span>`,
|
||||
)
|
||||
.join(" ");
|
||||
}
|
||||
---
|
||||
|
||||
<div
|
||||
class:list={["animated-text", Astro.props.class]}
|
||||
set:html={processedContent}
|
||||
data-duration={duration}
|
||||
data-delay={delay}
|
||||
data-stagger={stagger}
|
||||
{...rest}
|
||||
/>
|
||||
|
||||
<script>
|
||||
import { animate, stagger, inView } from "motion";
|
||||
|
||||
const animatedTexts = document.querySelectorAll(".animated-text");
|
||||
|
||||
animatedTexts.forEach((animatedText) => {
|
||||
const duration = parseFloat(
|
||||
animatedText.getAttribute("data-duration") as string,
|
||||
);
|
||||
const delay = parseFloat(animatedText.getAttribute("data-delay") as string);
|
||||
const dataStagger = parseFloat(
|
||||
animatedText.getAttribute("data-stagger") as string,
|
||||
);
|
||||
|
||||
// Get all words
|
||||
const words = animatedText.querySelectorAll(".word");
|
||||
|
||||
// Set initial state (text remains visible with slight displacement and blur)
|
||||
words.forEach((word) => {
|
||||
// @ts-expect-error
|
||||
word.style.opacity = "0";
|
||||
// @ts-expect-error
|
||||
word.style.transform = "translateY(10px)";
|
||||
// @ts-expect-error
|
||||
word.style.filter = "blur(10px)";
|
||||
});
|
||||
|
||||
inView(animatedText, (element) => {
|
||||
const words = element.querySelectorAll(".word");
|
||||
animate(
|
||||
words,
|
||||
// @ts-expect-error
|
||||
{ opacity: 1, y: 0, filter: "blur(0px)" },
|
||||
{
|
||||
duration: duration,
|
||||
delay: stagger(dataStagger, { startDelay: delay }), // Fixed here
|
||||
easing: "ease-out",
|
||||
},
|
||||
);
|
||||
|
||||
// This will fire when the element leaves the viewport
|
||||
return () => {
|
||||
// animation.stop();
|
||||
// animate(element.querySelectorAll(".word"), {
|
||||
// opacity: 0,
|
||||
// y: 20,
|
||||
// filter: "blur(10px)",
|
||||
// });
|
||||
};
|
||||
});
|
||||
});
|
||||
</script>
|
||||
88
src/components/ui/Button.astro
Normal file
@@ -0,0 +1,88 @@
|
||||
---
|
||||
export interface Props {
|
||||
url: string; // Button link URL
|
||||
type?: "solid" | "fill" | "disabled"; // Button type, optional, default is "solid"
|
||||
className?: string; // Custom CSS classes
|
||||
target?: "_blank" | "_self" | "_parent" | "_top"; // Link target, default is "_self"
|
||||
size?: "xs" | "sm" | "md" | "lg" | "xl"; // Button size, optional, default is "md"
|
||||
}
|
||||
|
||||
const { url, type = "solid", className = "", target = "_self", size = "md" } = Astro.props;
|
||||
|
||||
// Define padding and font size for different button sizes
|
||||
const sizeClasses = {
|
||||
xs: {
|
||||
padding: "px-3 py-1.5",
|
||||
fontSize: "text-xs",
|
||||
borderRadius: "rounded-lg",
|
||||
},
|
||||
sm: {
|
||||
padding: "px-4 py-2",
|
||||
fontSize: "text-sm",
|
||||
borderRadius: "rounded-lg",
|
||||
},
|
||||
md: {
|
||||
padding: "px-5 py-[12px]",
|
||||
fontSize: "text-base",
|
||||
borderRadius: "rounded-xl",
|
||||
},
|
||||
lg: {
|
||||
padding: "px-6 py-3.5",
|
||||
fontSize: "text-lg",
|
||||
borderRadius: "rounded-xl",
|
||||
},
|
||||
xl: {
|
||||
padding: "px-8 py-4",
|
||||
fontSize: "text-xl",
|
||||
borderRadius: "rounded-2xl",
|
||||
},
|
||||
};
|
||||
|
||||
// Get classes for current size
|
||||
const currentSizeClasses = sizeClasses[size];
|
||||
---
|
||||
<a
|
||||
href={type === "disabled" ? "javascript:void(0)" : url}
|
||||
target={type === "disabled" ? undefined : target}
|
||||
class={`${
|
||||
type === "solid"
|
||||
? `flex justify-center items-center flex-grow-0 flex-shrink-0 relative gap-1.5 ${currentSizeClasses.padding} rounded-xl bg-gradient-to-b from-white to-[#edf1fa] dark:from-[#15233b] dark:to-[#0f1b2d] border-[1px] border-[#f3f5ff] dark:border-[#243757] max-w-60 transition duration-400 ease-in-out hover:translate-y-[-2px] hover:shadow-[0px_6px_9px_0_rgba(61,99,171,0.15)] dark:hover:shadow-[0px_6x_9px_0_rgba(59,123,217,0.25)]`
|
||||
: type === "fill"
|
||||
? `flex justify-center items-center flex-grow-0 flex-shrink-0 relative gap-1.5 ${currentSizeClasses.padding} rounded-xl bg-btn-primary dark:bg-btn-primary-dark border-[0] border-[rgba(138,127,255,0.2)] shadow-[0px_2px_3px_0_rgba(0,0,0,0.1)] max-w-60 transition duration-400 ease-in-out hover:bg-btn-primary-hover dark:hover:bg-btn-primary-dark-hover hover:translate-y-[-2px] hover:shadow-[0px_6px_9px_0_rgba(61,99,171,0.15)] dark:shadow-[0px_6px_9px_0_rgba(0,0,0,0.2)]`
|
||||
: type === "disabled"
|
||||
? `flex justify-center items-center flex-grow-0 flex-shrink-0 relative gap-1.5 ${currentSizeClasses.padding} rounded-xl bg-gradient-to-b from-neutral-100 to-neutral-200 border-[0.75px] border-neutral-300 max-w-60 cursor-not-allowed opacity-70`
|
||||
: `inline-flex w-auto ${currentSizeClasses.padding} mt-5 ${currentSizeClasses.fontSize} font-medium duration-400 ease-out border rounded-full bg-neutral-900 dark:bg-white dark:text-neutral-900 text-neutral-100 hover:border-neutral-700 border-neutral-900 dark:hover:border-neutral-300 hover:bg-white dark:hover:bg-black dark:hover:text-white hover:text-neutral-900 max-w-60`
|
||||
} ${className}`}
|
||||
aria-disabled={type === "disabled" ? "true" : undefined}
|
||||
tabindex={type === "disabled" ? "-1" : undefined}
|
||||
onclick={type === "disabled" ? "return false;" : undefined}
|
||||
>
|
||||
{type === "solid" ? (
|
||||
<div class={`flex gap-1 items-center justify-center ${currentSizeClasses.fontSize} font-medium text-center text-[#6f6c8f] dark:text-[#c9d7f2]`}>
|
||||
<slot />
|
||||
</div>
|
||||
) : type === "fill" ? (
|
||||
<span class={`flex gap-1 items-center justify-center ${size === "md" ? "text-sm" : currentSizeClasses.fontSize} font-medium text-center text-white`}>
|
||||
<slot />
|
||||
</span>
|
||||
) : type === "disabled" ? (
|
||||
<div class={`flex gap-1 items-center justify-center ${currentSizeClasses.fontSize} font-medium text-center text-neutral-500`}>
|
||||
<slot />
|
||||
</div>
|
||||
) : (
|
||||
<span class={currentSizeClasses.fontSize}><slot /></span>
|
||||
)}
|
||||
</a>
|
||||
|
||||
<style>
|
||||
/* Disabled button styles in dark mode */
|
||||
:global(.dark) a[aria-disabled="true"] {
|
||||
background: linear-gradient(to bottom, var(--color-neutral-800), var(--color-neutral-900));
|
||||
border-color: var(--color-neutral-700);
|
||||
color: var(--color-neutral-600);
|
||||
}
|
||||
|
||||
:global(.dark) a[aria-disabled="true"] div {
|
||||
color: var(--color-neutral-600);
|
||||
}
|
||||
</style>
|
||||
19
src/components/ui/Logo.astro
Normal file
@@ -0,0 +1,19 @@
|
||||
<a
|
||||
href="/"
|
||||
class="h-10 text-base group relative z-30 flex items-center aspect-square"
|
||||
>
|
||||
<img src="/assets/logo.png" alt="Logo" class="w-10 h-10 rounded-full" />
|
||||
</a>
|
||||
|
||||
|
||||
{/* <a
|
||||
href="/"
|
||||
class="h-5 text-base group relative z-30 flex items-center space-x-1.5 text-black dark:text-white font-semibold"
|
||||
>
|
||||
<span
|
||||
class="text-xl -translate-y-0.5 group-hover:-rotate-12 group-hover:scale-[1.2] ease-in-out duration-300"
|
||||
>✦</span
|
||||
>
|
||||
<!-- Logo Text -->
|
||||
<span class="-translate-y-0.5"> aria</span>
|
||||
</a> */}
|
||||
91
src/components/ui/Marquee.astro
Normal file
@@ -0,0 +1,91 @@
|
||||
---
|
||||
interface Props {
|
||||
class?: string; // Class of the marquee
|
||||
marqueeElements: number; // Number of elements
|
||||
marqueeElementWidth: string; // Width of the marquee
|
||||
marqueeElementWidthAuto?: boolean; // Is the width auto
|
||||
marqueeElementWidthResponsive: string; // Width of the marquee
|
||||
marqueePauseOnHover?: boolean; // Duration of the marquee
|
||||
marqueeReverse?: "reverse" | "" | undefined; // Duration of the marquee
|
||||
marqueeDuration?: string; // Duration of the marquee
|
||||
}
|
||||
|
||||
let {
|
||||
marqueeElements,
|
||||
marqueeElementWidth,
|
||||
marqueeElementWidthAuto = true,
|
||||
marqueeElementWidthResponsive,
|
||||
marqueePauseOnHover = false,
|
||||
marqueeDuration = "50s",
|
||||
marqueeReverse = "",
|
||||
} = Astro.props;
|
||||
|
||||
// Remove value of if `marqueeElementWidth`, `marqueeElementWidthResponsive` marqueeElementWidthAuto true
|
||||
if (marqueeElementWidthAuto) {
|
||||
marqueeElementWidth = "";
|
||||
marqueeElementWidthResponsive = "";
|
||||
}
|
||||
---
|
||||
|
||||
<div
|
||||
class:list={[
|
||||
"marquee",
|
||||
{
|
||||
"marquee-revers": marqueeReverse,
|
||||
"marquee-pause-on-hover": marqueePauseOnHover,
|
||||
},
|
||||
]}
|
||||
style={`--marquee-elements:${marqueeElements};--marquee-element-width-responsive:calc(${marqueeElementWidthResponsive} / var(--marquee-elements) * ${marqueeElements});--marquee-element-width:calc(${marqueeElementWidth} / var(--marquee-elements) * ${marqueeElements});${"--marquee-duration:" + marqueeDuration};--marquee-reverse:${marqueeReverse};`}>
|
||||
<div
|
||||
class:list={[
|
||||
"marquee-content flex items-center",
|
||||
{ "marquee-element-width-auto": marqueeElementWidthAuto },
|
||||
Astro.props.class,
|
||||
]}>
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
function updateMarquee() {
|
||||
const marqueeContents = document.querySelectorAll(
|
||||
".marquee-element-width-auto",
|
||||
) as NodeListOf<HTMLElement>;
|
||||
|
||||
marqueeContents.forEach((marqueeContent) => {
|
||||
const images = marqueeContent.querySelectorAll("img");
|
||||
|
||||
images.forEach((img) => {
|
||||
if (!img.complete) {
|
||||
// wait for images to load
|
||||
img.addEventListener("load", () => updateImageSize(img));
|
||||
} else {
|
||||
updateImageSize(img);
|
||||
}
|
||||
});
|
||||
|
||||
// update total width after images resized
|
||||
const totalWidth = marqueeContent.scrollWidth;
|
||||
marqueeContent.style.setProperty("--total-width", `${totalWidth / 2}px`);
|
||||
});
|
||||
}
|
||||
|
||||
function updateImageSize(img: HTMLImageElement) {
|
||||
const computed = getComputedStyle(img);
|
||||
const height = parseFloat(computed.height); // Tailwind-applied height
|
||||
const aspectRatio = img.naturalWidth / img.naturalHeight;
|
||||
const width = height * aspectRatio;
|
||||
|
||||
img.setAttribute("height", height.toString());
|
||||
img.setAttribute("width", width.toString());
|
||||
}
|
||||
|
||||
// Debounced resize handler
|
||||
let resizeTimeout: number;
|
||||
window.addEventListener("resize", () => {
|
||||
clearTimeout(resizeTimeout);
|
||||
resizeTimeout = window.setTimeout(updateMarquee, 100);
|
||||
});
|
||||
|
||||
// Initial run
|
||||
updateMarquee();
|
||||
</script>
|
||||
322
src/components/ui/Matter.astro
Normal file
@@ -0,0 +1,322 @@
|
||||
---
|
||||
---
|
||||
|
||||
<div class="matter-wrapper">
|
||||
<div id="matter" class="tricks-spacer zIndex-3d">
|
||||
<div class="tricks-view" id="tricks-view"></div>
|
||||
</div>
|
||||
<div class="tricks-matter zIndex-3d">
|
||||
<div class="tricks-canvas"></div>
|
||||
<div class="tricks-elements"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
import Matter from "matter-js"; // 修改为默认导入
|
||||
|
||||
(() => {
|
||||
// 懒加载:当 .tricks-view 进入视口时初始化
|
||||
const target = document.querySelector(".tricks-view");
|
||||
if (!target) {
|
||||
console.error("[Tricks] .tricks-view not found");
|
||||
return;
|
||||
}
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries, obs) => {
|
||||
entries.forEach((entry) => {
|
||||
if (entry.isIntersecting) {
|
||||
obs.unobserve(entry.target);
|
||||
initTricksAnimation();
|
||||
}
|
||||
});
|
||||
},
|
||||
{ threshold: 0.1 }
|
||||
);
|
||||
|
||||
observer.observe(target);
|
||||
|
||||
async function initTricksAnimation() {
|
||||
const container = document.querySelector(".tool-stack-box") || document.querySelector(".matter-wrapper");
|
||||
const canvasHost = document.querySelector(".tricks-canvas") as HTMLElement;
|
||||
const domLayer = document.querySelector(".tricks-elements") as HTMLElement; // 添加类型断言
|
||||
|
||||
if (!container || !canvasHost || !domLayer) {
|
||||
console.error(
|
||||
"[Tricks] Missing required containers (.tool-stack-box/.matter-wrapper, .tricks-canvas, .tricks-elements)"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// 配置(调大元素尺寸 & 缩小边缘间隙)
|
||||
const pad = 2; // 内边距,越小越贴边
|
||||
const margin = 1; // 静态边界与容器边缘距离,越小越贴边
|
||||
const count = 15; // 元素数量
|
||||
const noRotate = false; // 不旋转可设为 true
|
||||
const scaleFactor = 0.75; // 全局放大因子:>1 放大,<1 缩小
|
||||
|
||||
// 尺寸
|
||||
let { width: cw, height: ch } = container.getBoundingClientRect();
|
||||
let W = Math.max(cw - pad, 100);
|
||||
let H = Math.max(ch - pad, 100);
|
||||
|
||||
// 物理引擎
|
||||
const engine = Matter.Engine.create();
|
||||
const world = engine.world;
|
||||
|
||||
// 鼠标拖拽(事件落在 canvasHost 上)
|
||||
const mouseConstraint = Matter.MouseConstraint.create(engine, {
|
||||
mouse: Matter.Mouse.create(canvasHost),
|
||||
constraint: { render: { visible: false }, stiffness: 1 },
|
||||
});
|
||||
Matter.World.add(world, mouseConstraint);
|
||||
|
||||
// 边界(地面与左右墙)
|
||||
let ground = Matter.Bodies.rectangle(W / 2, H - margin, W, 14, { isStatic: true });
|
||||
let wallL = Matter.Bodies.rectangle(margin, H / 2, 14, H, { isStatic: true });
|
||||
let wallR = Matter.Bodies.rectangle(W - margin, H / 2, 14, H, { isStatic: true });
|
||||
Matter.World.add(world, [ground, wallL, wallR]);
|
||||
|
||||
// 图标路径(按需调整)
|
||||
const iconUrls = [
|
||||
"/assets/stack/astro.png",
|
||||
"/assets/stack/css.png",
|
||||
"/assets/stack/github.png",
|
||||
"/assets/stack/html.png",
|
||||
"/assets/stack/bootstrap.png",
|
||||
"/assets/stack/cloudflare.png",
|
||||
"/assets/stack/js.png",
|
||||
"/assets/stack/netlify.png",
|
||||
"/assets/stack/nextjs.png",
|
||||
"/assets/stack/nodejs.png",
|
||||
"/assets/stack/npm.png",
|
||||
"/assets/stack/tailwind.png",
|
||||
"/assets/stack/vercel.png",
|
||||
"/assets/stack/vscode.png",
|
||||
];
|
||||
|
||||
// 预加载图片,获取 naturalWidth/Height
|
||||
function loadImage(url: string) {
|
||||
return new Promise<HTMLImageElement>((resolve, reject) => {
|
||||
const img = new Image();
|
||||
img.decoding = "async";
|
||||
img.loading = "eager"; // 懒加载触发后尽快加载
|
||||
img.src = url;
|
||||
img.onload = () => resolve(img);
|
||||
img.onerror = () => reject(new Error("Image load failed: " + url));
|
||||
});
|
||||
}
|
||||
|
||||
let iconsImgs: HTMLImageElement[] = [];
|
||||
try {
|
||||
iconsImgs = await Promise.all(iconUrls.map(loadImage));
|
||||
} catch (e) {
|
||||
console.error("[Tricks] image load failed:", e);
|
||||
}
|
||||
|
||||
// 尺寸策略:显著增大目标面积区间(适配 720x480)
|
||||
function computeAreaRange() {
|
||||
// 以容器面积为基准,提高图标面积比例
|
||||
// 720*480 ≈ 345600;这里让单个图标面积大致在 1/20 ~ 1/10 容器面积之间
|
||||
const baseArea = W * H;
|
||||
const areaMin = clamp(baseArea / 20, 12000, 30000);
|
||||
const areaMax = clamp(baseArea / 10, 20000, 50000);
|
||||
return { areaMin, areaMax };
|
||||
}
|
||||
let { areaMin, areaMax } = computeAreaRange();
|
||||
|
||||
// 图标刚体类(矩形,尺寸基于 naturalWidth/Height)
|
||||
class IconBody {
|
||||
w: number;
|
||||
h: number;
|
||||
body: Matter.Body;
|
||||
el: HTMLDivElement;
|
||||
|
||||
constructor(img: HTMLImageElement) {
|
||||
const x = Math.random() * W;
|
||||
const y = Math.random() * -H;
|
||||
|
||||
const r =
|
||||
img.naturalWidth > 0 && img.naturalHeight > 0
|
||||
? img.naturalWidth / img.naturalHeight
|
||||
: 1;
|
||||
|
||||
// 目标面积(随机落在区间内)
|
||||
const A = randRange(areaMin, areaMax);
|
||||
// 推导宽高:w = sqrt(A*r), h = w/r
|
||||
let w = Math.sqrt(A * r);
|
||||
let h = w / r;
|
||||
|
||||
// 全局放大或缩小
|
||||
w *= scaleFactor;
|
||||
h *= scaleFactor;
|
||||
|
||||
this.w = w;
|
||||
this.h = h;
|
||||
|
||||
// 物理矩形刚体
|
||||
this.body = Matter.Bodies.rectangle(x, y, this.w, this.h, {
|
||||
restitution: 0.35,
|
||||
friction: 0.1,
|
||||
frictionAir: 0.02,
|
||||
density: clamp((this.w * this.h) / 40000, 0.001, 0.02),
|
||||
inertia: noRotate ? Infinity : undefined, // 不旋转:设置惯性无穷
|
||||
});
|
||||
|
||||
// DOM:容器 + img
|
||||
this.el = document.createElement("div");
|
||||
this.el.className = "tricks-circle";
|
||||
this.el.style.width = `${this.w}px`;
|
||||
this.el.style.height = `${this.h}px`;
|
||||
|
||||
const node = img.cloneNode(true) as HTMLImageElement;
|
||||
node.style.width = "100%";
|
||||
node.style.height = "100%";
|
||||
node.style.objectFit = "contain"; // 或 "cover"
|
||||
node.alt = node.alt || "icon";
|
||||
|
||||
this.el.appendChild(node);
|
||||
domLayer.appendChild(this.el); // 这里 domLayer 已经确保不为 null
|
||||
}
|
||||
|
||||
update() {
|
||||
const { x, y } = this.body.position;
|
||||
const angle = this.body.angle;
|
||||
if (noRotate) {
|
||||
this.el.style.transform = `translate(${x - this.w / 2}px, ${y - this.h / 2}px)`;
|
||||
} else {
|
||||
this.el.style.transform = `translate(${x - this.w / 2}px, ${y - this.h / 2}px) rotate(${angle}rad)`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 创建图标刚体
|
||||
const total = Math.min(iconsImgs.length, count);
|
||||
const iconsBodies: IconBody[] = [];
|
||||
for (let i = 0; i < total; i++) {
|
||||
iconsBodies.push(new IconBody(iconsImgs[i]));
|
||||
}
|
||||
Matter.World.add(world, iconsBodies.map((it) => it.body));
|
||||
|
||||
// 启动引擎
|
||||
const runner = Matter.Runner.create();
|
||||
Matter.Runner.run(runner, engine);
|
||||
|
||||
// 每次物理步进后,同步 DOM
|
||||
Matter.Events.on(engine, "afterUpdate", () => {
|
||||
iconsBodies.forEach((it) => it.update());
|
||||
});
|
||||
|
||||
// 自适应尺寸:更新边界位置,并重新计算面积区间
|
||||
window.addEventListener("resize", () => {
|
||||
const rect = container.getBoundingClientRect();
|
||||
W = Math.max(rect.width - pad, 100);
|
||||
H = Math.max(rect.height - pad, 100);
|
||||
|
||||
Matter.Body.setPosition(ground, Matter.Vector.create(W / 2, H - margin));
|
||||
Matter.Body.setPosition(wallL, Matter.Vector.create(margin, H / 2));
|
||||
Matter.Body.setPosition(wallR, Matter.Vector.create(W - margin, H / 2));
|
||||
|
||||
const range = computeAreaRange();
|
||||
areaMin = range.areaMin;
|
||||
areaMax = range.areaMax;
|
||||
});
|
||||
}
|
||||
|
||||
// 工具函数(IIFE 外也可用)
|
||||
function clamp(v: number, min: number, max: number) {
|
||||
return Math.max(min, Math.min(max, v));
|
||||
}
|
||||
function randRange(min: number, max: number) {
|
||||
return Math.random() * (max - min) + min;
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
|
||||
<style is:global>
|
||||
/* tricks css */
|
||||
.matter-wrapper {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
#matter {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.tricks-spacer {
|
||||
position: relative;
|
||||
display: -webkit-box;
|
||||
display: -webkit-flex;
|
||||
display: -ms-flexbox;
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
-webkit-box-pack: center;
|
||||
-webkit-justify-content: center;
|
||||
-ms-flex-pack: center;
|
||||
justify-content: center;
|
||||
-webkit-box-align: center;
|
||||
-webkit-align-items: center;
|
||||
-ms-flex-align: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.tricks-view {
|
||||
position: absolute;
|
||||
left: 0%;
|
||||
top: auto;
|
||||
right: 0%;
|
||||
bottom: 0%;
|
||||
height: 99%;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.tricks-matter {
|
||||
position: absolute;
|
||||
left: 0%;
|
||||
right: 0%;
|
||||
bottom: 0%;
|
||||
z-index: 1;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
|
||||
.tricks-canvas {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
left: 0;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
cursor: -webkit-grab;
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.tricks-elements,
|
||||
.tricks-spacer {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
|
||||
.tricks-circle {
|
||||
position: absolute;
|
||||
overflow: hidden;
|
||||
background: transparent;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
|
||||
.tricks-circle img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
</style>
|
||||
72
src/components/ui/Tools.astro
Normal file
@@ -0,0 +1,72 @@
|
||||
<div class="relative w-[198px] h-[180px] overflow-hidden rounded-3xl bg-[url('/assets/tools/deck.png')] bg-cover flex items-start justify-center ">
|
||||
|
||||
<div class="tools-container relative m-auto mt-[28px] ">
|
||||
<div class="tools-list grid grid-cols-4 gap-[12px]">
|
||||
<div class="relative tool-item w-[32px] h-[32px] flex items-center justify-center bg-[url('/assets/tools/tool-icon-bg.png')] bg-cover">
|
||||
<img src="/assets/tools/logo/tool-ricoog.svg" alt="" class="w-[24px] h-auto">
|
||||
</div>
|
||||
<div class="relative tool-item w-[32px] h-[32px] flex items-center justify-center bg-[url('/assets/tools/tool-icon-bg.png')] bg-cover">
|
||||
<img src="/assets/tools/logo/tool-gradienthub.svg" alt="" class="w-[22px] h-auto">
|
||||
</div>
|
||||
<div class="relative tool-item w-[32px] h-[32px] flex items-center justify-center bg-[url('/assets/tools/tool-icon-bg.png')] bg-cover">
|
||||
<img src="/assets/tools/logo/tool-uiuxdeck.svg" alt="" class="w-[20px] h-auto">
|
||||
</div>
|
||||
<div class="relative tool-item w-[32px] h-[32px] flex items-center justify-center bg-[url('/assets/tools/tool-icon-bg.png')] bg-cover">
|
||||
<img src="/assets/tools/logo/tool-inspoweb.png" alt="" class="w-[20px] h-auto">
|
||||
</div>
|
||||
<div class="relative tool-item w-[32px] h-[32px] flex items-center justify-center bg-[url('/assets/tools/tool-icon-bg.png')] bg-cover">
|
||||
<img src="/assets/tools/logo/tool-todo.png" alt="" class="w-[24px] h-auto">
|
||||
</div>
|
||||
<div class="relative tool-item w-[32px] h-[32px] flex items-center justify-center bg-[url('/assets/tools/tool-icon-bg.png')] bg-cover">
|
||||
<img src="/assets/tools/logo/ricoui.png" alt="" class="w-[26px] h-auto">
|
||||
</div>
|
||||
<div class="relative tool-item w-[32px] h-[32px] flex items-center justify-center bg-[url('/assets/tools/tool-icon-bg.png')] bg-cover">
|
||||
<!-- <img src="/assets/tools/logo/" alt="" class="w-[24px] h-auto"> -->
|
||||
</div>
|
||||
<div class="relative tool-item w-[32px] h-[32px] flex items-center justify-center bg-[url('/assets/tools/tool-icon-bg.png')] bg-cover">
|
||||
<!-- <img src="/assets/tools/logo/" alt="" class="w-[24px] h-auto"> -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bar absolute bottom-[24px] left-[18px] right-[18px] bg-[url('/assets/tools/spin-bar.svg')] bg-cover w-[calc(100%-36px)] h-[39px] flex items-center justify-center overflow-hidden rounded-[10px]">
|
||||
<div class="absolute bar-spin-inner w-auto h-full border-box overflow-hidden flex items-center justify-center gap-2 p-0 m-auto">
|
||||
<img src="/assets/tools/spin.png" alt="" class="spin object-cover w-auto h-[72%]">
|
||||
<img src="/assets/tools/spin.png" alt="" class="spin object-cover w-auto h-[72%]">
|
||||
<img src="/assets/tools/spin.png" alt="" class="spin object-cover w-auto h-[72%]">
|
||||
<img src="/assets/tools/spin.png" alt="" class="spin object-cover w-auto h-[72%]">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.tool-item{
|
||||
cursor: pointer;
|
||||
transition: transform 0.3s ease;
|
||||
|
||||
}
|
||||
.tool-item img{
|
||||
opacity: 0.75;
|
||||
transition: opacity 0.3s ease-in-out;
|
||||
}
|
||||
.tool-item:hover img{
|
||||
opacity: 1;
|
||||
}
|
||||
.spin{
|
||||
cursor: pointer;
|
||||
animation: spin 5s linear infinite;
|
||||
animation-play-state: paused;
|
||||
animation-fill-mode: forwards;
|
||||
}
|
||||
.spin:hover{
|
||||
animation-play-state: running;
|
||||
}
|
||||
@keyframes spin {
|
||||
0% {
|
||||
transform: rotate(0);
|
||||
}
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
22
src/components/ui/TopBg.astro
Normal file
@@ -0,0 +1,22 @@
|
||||
|
||||
<!-- <svg width="1920" height="717" viewBox="0 0 1920 717" fill="none" xmlns="http://www.w3.org/2000/svg" class="absolute top-0 left-0 right-0 w-full z-0 h-auto lg:top-[-20%] event-none">
|
||||
<g clip-path="url(#clip0_619_11042)">
|
||||
<g opacity="0.24" filter="url(#filter0_f_619_11042)">
|
||||
<ellipse cx="1000" cy="241" rx="1000" ry="241" transform="matrix(1 0 0 -1 0 331)" fill="#4A3AFF"/>
|
||||
</g>
|
||||
</g>
|
||||
<defs>
|
||||
<filter id="filter0_f_619_11042" x="-320" y="-471" width="2640" height="1122" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
|
||||
<feGaussianBlur stdDeviation="160" result="effect1_foregroundBlur_619_11042"/>
|
||||
</filter>
|
||||
<clipPath id="clip0_619_11042">
|
||||
<rect width="2000" height="868" fill="white" transform="translate(0 -151)"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg> -->
|
||||
|
||||
|
||||
|
||||
<svg aria-hidden="true" class="pointer-events-none absolute top-[-20%] inset-0 [z-index:-1] size-full h-[50%] fill-blue-500/50 stroke-blue-500/50 [mask-image:linear-gradient(to_bottom,_#ffffffad,_transparent)] z-2 opacity-[.30]"><defs><pattern id=":S1:" width="12" height="12" patternUnits="userSpaceOnUse" x="-1" y="-1"><path d="M.5 12V.5H12" fill="none" stroke-dasharray="0"></path></pattern></defs><rect width="100%" height="100%" stroke-width="0" fill="url(#:S1:)"></rect></svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
81
src/components/widgets/ActionBar.astro
Normal file
@@ -0,0 +1,81 @@
|
||||
---
|
||||
// local structural type to avoid version-specific imports
|
||||
type LogoInput = string | { src: string };
|
||||
interface Props {
|
||||
logo?: LogoInput; // image url or object with src or single letter
|
||||
tags?: string[]; // multiple labels like ["Blog", "Open Source"]
|
||||
url?: string; // external site url
|
||||
github?: string; // optional github url
|
||||
visitLabel?: string; // primary button text
|
||||
className?: string; // extra classes for wrapper
|
||||
}
|
||||
|
||||
const {
|
||||
logo = '',
|
||||
tags = ['Website'],
|
||||
url = '#',
|
||||
github = '',
|
||||
visitLabel = 'Visit Site',
|
||||
className = ''
|
||||
} = Astro.props as Props;
|
||||
|
||||
// Normalize logo: support URL string or Astro ImageMetadata object
|
||||
let logoSrc: string | null = null;
|
||||
if (typeof logo === 'string') {
|
||||
const isUrlLike = logo.startsWith('/') || logo.startsWith('http') || /\.(png|jpe?g|webp|svg)$/i.test(logo);
|
||||
if (isUrlLike) logoSrc = logo;
|
||||
} else if (logo && typeof logo === 'object' && 'src' in (logo as any)) {
|
||||
logoSrc = (logo as any).src as string;
|
||||
}
|
||||
---
|
||||
|
||||
<div
|
||||
class={`fixed z-[60] bottom-5 left-1/2 -translate-x-1/2 sm:bottom-6 md:bottom-8 ${className}`}
|
||||
data-aos="fade-up-sm"
|
||||
data-aos-delay="200"
|
||||
data-aos-duration="500"
|
||||
data-aos-once="true"
|
||||
>
|
||||
<div class="flex items-center gap-2 sm:gap-3 rounded-2xl bg-neutral-900/85 dark:bg-neutral-900/85 text-neutral-100 shadow-[0_6px_28px_rgba(0,0,0,0.25)] ring-1 ring-white/10 backdrop-blur-xl px-2 py-2 max-w-[92vw]">
|
||||
<!-- Logo block -->
|
||||
<div class="shrink-0 h-10 w-10 sm:h-11 sm:w-11 rounded-xl bg-neutral-800 grid place-items-center ring-1 ring-white/10 overflow-hidden">
|
||||
{logoSrc ? (
|
||||
<img src={logoSrc} alt="logo" class="h-full w-full object-cover" loading="lazy" />
|
||||
) : (
|
||||
<span class="font-semibold text-lg sm:text-xl tracking-tight">{typeof logo === 'string' && logo ? logo : 'W.'}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<!-- Middle pills (tags + optional github) -->
|
||||
<div class="flex items-center gap-2sm:gap-3 overflow-x-auto scrollbar-none max-w-[46vw] sm:max-w-[52vw] md:max-w-[56vw] pr-1">
|
||||
{tags && tags.map((t) => (
|
||||
<span class=" hidden font-light sm:inline-flex items-center h-10 px-3 sm:px-3 rounded-xl bg-neutral-800/80 ring-1 ring-white/10 text-neutral-300 text-sm whitespace-nowrap">
|
||||
{t}
|
||||
</span>
|
||||
))}
|
||||
{github && (
|
||||
<a
|
||||
href={github}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label="View on GitHub"
|
||||
class="inline-flex items-center h-10 px-3 sm:px-3 rounded-xl bg-neutral-800/80 ring-1 ring-white/10 text-neutral-200 hover:text-white hover:bg-neutral-700/80 transition-colors text-sm sm:text-[15px] whitespace-nowrap"
|
||||
>
|
||||
<svg viewBox="0 0 16 16" width="16" height="16" aria-hidden="true" class="opacity-90 size-5"><path fill="currentColor" d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0 0 16 8c0-4.42-3.58-8-8-8Z"/></svg>
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<!-- Primary button -->
|
||||
<div class="shrink-0">
|
||||
<a
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="nofollow noopener"
|
||||
class="inline-flex items-center h-10 sm:h-11 px-4 sm:px-5 rounded-xl bg-yellow-300 text-neutral-900 font-medium hover:bg-yellow-200 transition-colors whitespace-nowrap"
|
||||
>
|
||||
{visitLabel}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
59
src/components/widgets/Meta.astro
Normal file
@@ -0,0 +1,59 @@
|
||||
---
|
||||
import { siteConfig } from "@/config/site.js";
|
||||
|
||||
interface Props {
|
||||
title?: string;
|
||||
description?: string;
|
||||
keywords?: string;
|
||||
url?: string;
|
||||
image?: string;
|
||||
twitterHandle?: string;
|
||||
}
|
||||
|
||||
const {
|
||||
title = siteConfig.meta.title,
|
||||
description = siteConfig.meta.description,
|
||||
keywords = siteConfig.meta.keywords,
|
||||
url = siteConfig.url,
|
||||
image = siteConfig.meta.image,
|
||||
twitterHandle = siteConfig.social.twitterName,
|
||||
} = Astro.props;
|
||||
|
||||
// 构建完整的页面标题
|
||||
const metaTitle = title === siteConfig.meta.title ? title : `${title} | ${siteConfig.meta.title}`;
|
||||
const metaDescription = description || siteConfig.meta.description;
|
||||
const ogImage = image || siteConfig.meta.image;
|
||||
const canonicalUrl = url || siteConfig.url;
|
||||
---
|
||||
|
||||
<!-- Basic Meta Tags -->
|
||||
<title>{metaTitle}</title>
|
||||
<meta name="description" content={metaDescription} />
|
||||
<meta name="keywords" content={keywords} />
|
||||
<meta name="author" content={siteConfig.author} />
|
||||
<link rel="canonical" href={canonicalUrl} />
|
||||
|
||||
<!-- Open Graph Tags -->
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:title" content={metaTitle} />
|
||||
<meta property="og:description" content={metaDescription} />
|
||||
<meta property="og:url" content={canonicalUrl} />
|
||||
<meta property="og:image" content={ogImage} />
|
||||
<meta property="og:site_name" content={siteConfig.meta.title} />
|
||||
<meta property="og:locale" content="en_US" />
|
||||
|
||||
<!-- Twitter Meta Tags -->
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:title" content={metaTitle} />
|
||||
<meta name="twitter:description" content={metaDescription} />
|
||||
<meta name="twitter:image" content={ogImage} />
|
||||
<meta name="twitter:site" content={twitterHandle} />
|
||||
<meta name="twitter:creator" content={twitterHandle} />
|
||||
|
||||
<!-- Additional Meta Tags -->
|
||||
<meta name="robots" content="index, follow" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta charset="UTF-8" />
|
||||
|
||||
|
||||
|
||||
121
src/components/widgets/OptimizedImage.astro
Normal file
@@ -0,0 +1,121 @@
|
||||
---
|
||||
/**
|
||||
* OptimizedImage component is a modified version of Astro's built-in Image component.
|
||||
* It allows to optimize images inside public, src/assets and content folders.
|
||||
* To optimize remote images, follow: https://docs.astro.build/en/guides/images/#authorizing-remote-images
|
||||
*/
|
||||
import { Image } from "astro:assets";
|
||||
|
||||
interface InlineSvgProps {
|
||||
src: string;
|
||||
inlineSvg: true;
|
||||
alt?: string;
|
||||
width?: string;
|
||||
height?: string;
|
||||
class?: string;
|
||||
style?: any;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
interface ImageProps {
|
||||
src: string;
|
||||
alt?: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
loading?: "eager" | "lazy" | null | undefined;
|
||||
decoding?: "async" | "auto" | "sync" | null | undefined;
|
||||
formats?: string[] | "auto" | "avif" | "jpeg" | "png" | "svg" | "webp";
|
||||
class?: string;
|
||||
style?: any;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
type Props = InlineSvgProps | ImageProps;
|
||||
|
||||
// Destructuring Astro.props to get the component's props
|
||||
let {
|
||||
src,
|
||||
alt,
|
||||
inlineSvg,
|
||||
width,
|
||||
height,
|
||||
loading,
|
||||
decoding,
|
||||
formats,
|
||||
style,
|
||||
...rest
|
||||
} = Astro.props;
|
||||
|
||||
const isRemoteImage = src.startsWith("http://") || src.startsWith("https://");
|
||||
let image = src as any;
|
||||
let SVG = "" as any;
|
||||
|
||||
if (!isRemoteImage) {
|
||||
// Regular Images
|
||||
const images = import.meta.glob([
|
||||
"/src/assets/**/*.{jpeg,jpg,png,avif,tiff,gif,webp,svg,gif}",
|
||||
"/public/**/*.{jpeg,jpg,png,avif,tiff,gif,webp,svg,gif}",
|
||||
"/src/content/**/*.{jpeg,jpg,png,avif,tiff,gif,webp,svg,gif}",
|
||||
]);
|
||||
|
||||
// Get Raw Image For Inline SVG
|
||||
const imagesRaw = import.meta.glob(
|
||||
[
|
||||
"/src/assets/**/*.svg",
|
||||
"/src/content/**/*.svg",
|
||||
"/public/**/*.svg",
|
||||
],
|
||||
{
|
||||
query: "raw",
|
||||
import: "default",
|
||||
},
|
||||
);
|
||||
|
||||
if (inlineSvg && src.includes(".svg")) {
|
||||
const key = Object.keys(images).find((k) => k.includes(src));
|
||||
SVG = key ? await imagesRaw[key]() : null;
|
||||
|
||||
if (SVG.length > 1) {
|
||||
SVG = SVG.split("<svg"); // Split at the opening <svg> tag
|
||||
|
||||
if (SVG.length > 1) {
|
||||
// Convert rest object to a string of attributes
|
||||
const attributes = Object.entries(rest)
|
||||
.map(([key, value]) => `${key}="${value}"`)
|
||||
.join(" ");
|
||||
|
||||
// Add the attributes to the <svg> tag
|
||||
SVG[1] = ` data-icon="true" ${attributes} ${SVG[1]}`;
|
||||
}
|
||||
|
||||
// Join the array back into a string
|
||||
SVG = SVG.join("<svg");
|
||||
}
|
||||
}
|
||||
|
||||
const key = Object.keys(images).find((k) => k.includes(src));
|
||||
image = key ? await images[key]() : null;
|
||||
|
||||
image = image && image.default ? image.default : null;
|
||||
}
|
||||
---
|
||||
|
||||
{
|
||||
inlineSvg && SVG && src.includes(".svg") ? (
|
||||
<Fragment set:html={SVG} />
|
||||
) : image ? (
|
||||
// @ts-expect-error
|
||||
<Image
|
||||
style={style}
|
||||
alt={alt || ""}
|
||||
width={width}
|
||||
height={height}
|
||||
formats={formats}
|
||||
loading={loading}
|
||||
decoding={decoding}
|
||||
src={image}
|
||||
{...{ class: Astro.props.class }}
|
||||
{...rest}
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
186
src/components/widgets/Pagination.astro
Normal file
@@ -0,0 +1,186 @@
|
||||
---
|
||||
const { currentPage, totalPages, collection } = Astro.props;
|
||||
|
||||
// 生成页码数组
|
||||
const pageNumbers = Array.from({ length: totalPages }, (_, i) => i + 1);
|
||||
|
||||
// 确定要显示的页码范围
|
||||
const maxVisiblePages = 5;
|
||||
let startPage = Math.max(currentPage - Math.floor(maxVisiblePages / 2), 1);
|
||||
let endPage = Math.min(startPage + maxVisiblePages - 1, totalPages);
|
||||
|
||||
if (endPage - startPage + 1 < maxVisiblePages) {
|
||||
startPage = Math.max(endPage - maxVisiblePages + 1, 1);
|
||||
}
|
||||
|
||||
// 生成页面链接
|
||||
const getPageLink = (pageNum: number): string => {
|
||||
if (pageNum === 1) {
|
||||
return `/${collection}/`;
|
||||
}
|
||||
return `/${collection}/page/${pageNum}`;
|
||||
};
|
||||
---
|
||||
|
||||
<nav aria-label="Pagination" class="flex justify-center mt-12">
|
||||
<ul class="flex items-center gap-1">
|
||||
<!-- 上一页按钮 -->
|
||||
{currentPage > 1 ? (
|
||||
<li>
|
||||
<a
|
||||
href={getPageLink(currentPage - 1)}
|
||||
class="flex items-center justify-center w-10 h-10 rounded-md border border-neutral-200 dark:border-neutral-700 text-neutral-700 dark:text-neutral-300 hover:bg-neutral-100 dark:hover:bg-neutral-800 transition-colors"
|
||||
aria-label="Go to previous page"
|
||||
>
|
||||
<span class="sr-only">上一页</span>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="m15 18-6-6 6-6"></path>
|
||||
</svg>
|
||||
</a>
|
||||
</li>
|
||||
) : (
|
||||
<li>
|
||||
<span
|
||||
class="flex items-center justify-center w-10 h-10 rounded-md border border-neutral-200 dark:border-neutral-700 text-neutral-400 dark:text-neutral-600 cursor-not-allowed"
|
||||
aria-disabled="true"
|
||||
>
|
||||
<span class="sr-only">上一页</span>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="m15 18-6-6 6-6"></path>
|
||||
</svg>
|
||||
</span>
|
||||
</li>
|
||||
)}
|
||||
|
||||
<!-- 第一页 -->
|
||||
{startPage > 1 && (
|
||||
<>
|
||||
<li>
|
||||
<a
|
||||
href={getPageLink(1)}
|
||||
class="flex items-center justify-center w-10 h-10 rounded-md border border-neutral-200 dark:border-neutral-700 text-neutral-700 dark:text-neutral-300 hover:bg-neutral-100 dark:hover:bg-neutral-800 transition-colors"
|
||||
aria-label="Go to page 1"
|
||||
>
|
||||
1
|
||||
</a>
|
||||
</li>
|
||||
{startPage > 2 && (
|
||||
<li>
|
||||
<span class="flex items-center justify-center w-10 h-10 text-neutral-500 dark:text-neutral-400">
|
||||
...
|
||||
</span>
|
||||
</li>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<!-- 页码 -->
|
||||
{pageNumbers.slice(startPage - 1, endPage).map((pageNum) => (
|
||||
<li>
|
||||
<a
|
||||
href={getPageLink(pageNum)}
|
||||
class:list={[
|
||||
"flex items-center justify-center w-10 h-10 rounded-md border transition-colors",
|
||||
pageNum === currentPage
|
||||
? "bg-primary text-white border-primary dark:bg-primary dark:border-primary"
|
||||
: "border-neutral-200 dark:border-neutral-700 text-neutral-700 dark:text-neutral-300 hover:bg-neutral-100 dark:hover:bg-neutral-800"
|
||||
]}
|
||||
aria-label={`Go to page ${pageNum}`}
|
||||
aria-current={pageNum === currentPage ? "page" : undefined}
|
||||
>
|
||||
{pageNum}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
|
||||
<!-- 最后一页 -->
|
||||
{endPage < totalPages && (
|
||||
<>
|
||||
{endPage < totalPages - 1 && (
|
||||
<li>
|
||||
<span class="flex items-center justify-center w-10 h-10 text-neutral-500 dark:text-neutral-400">
|
||||
...
|
||||
</span>
|
||||
</li>
|
||||
)}
|
||||
<li>
|
||||
<a
|
||||
href={getPageLink(totalPages)}
|
||||
class="flex items-center justify-center w-10 h-10 rounded-md border border-neutral-200 dark:border-neutral-700 text-neutral-700 dark:text-neutral-300 hover:bg-neutral-100 dark:hover:bg-neutral-800 transition-colors"
|
||||
aria-label={`Go to page ${totalPages}`}
|
||||
>
|
||||
{totalPages}
|
||||
</a>
|
||||
</li>
|
||||
</>
|
||||
)}
|
||||
|
||||
<!-- 下一页按钮 -->
|
||||
{currentPage < totalPages ? (
|
||||
<li>
|
||||
<a
|
||||
href={getPageLink(currentPage + 1)}
|
||||
class="flex items-center justify-center w-10 h-10 rounded-md border border-neutral-200 dark:border-neutral-700 text-neutral-700 dark:text-neutral-300 hover:bg-neutral-100 dark:hover:bg-neutral-800 transition-colors"
|
||||
aria-label="Go to next page"
|
||||
>
|
||||
<span class="sr-only">下一页</span>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="m9 18 6-6-6-6"></path>
|
||||
</svg>
|
||||
</a>
|
||||
</li>
|
||||
) : (
|
||||
<li>
|
||||
<span
|
||||
class="flex items-center justify-center w-10 h-10 rounded-md border border-neutral-200 dark:border-neutral-700 text-neutral-400 dark:text-neutral-600 cursor-not-allowed"
|
||||
aria-disabled="true"
|
||||
>
|
||||
<span class="sr-only">下一页</span>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="m9 18 6-6-6-6"></path>
|
||||
</svg>
|
||||
</span>
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
</nav>
|
||||
116
src/components/widgets/ToTop.astro
Normal file
@@ -0,0 +1,116 @@
|
||||
<!-- Back to top button -->
|
||||
<button id="back-to-top" class="back-to-top" aria-label="Back to top">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polyline points="18 15 12 9 6 15"></polyline>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<style>
|
||||
.back-to-top {
|
||||
position: fixed;
|
||||
bottom: 2rem;
|
||||
right: 2rem;
|
||||
width: 3rem;
|
||||
height: 3rem;
|
||||
border-radius: 50%;
|
||||
background: var(--color-primary, #2d6dc3);
|
||||
color: white;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transform: translateY(1rem);
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
z-index: 1000;
|
||||
box-shadow: 0 4px 12px rgba(91, 84, 211, 0.3);
|
||||
}
|
||||
|
||||
.back-to-top:hover {
|
||||
background: var(--color-primary-light, #0066ff);
|
||||
transform: translateY(0);
|
||||
box-shadow: 0 8px 20px rgba(91, 84, 211, 0.4);
|
||||
}
|
||||
|
||||
.back-to-top:active {
|
||||
transform: translateY(0) scale(0.95);
|
||||
}
|
||||
|
||||
.back-to-top.visible {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
/* Dark mode */
|
||||
:root.dark .back-to-top {
|
||||
background: var(--color-primary-light, #c2bdff);
|
||||
color: #1a1a1a;
|
||||
box-shadow: 0 4px 12px rgba(194, 189, 255, 0.3);
|
||||
}
|
||||
|
||||
:root.dark .back-to-top:hover {
|
||||
background: var(--color-primary-lighter, #c2bdff);
|
||||
box-shadow: 0 8px 20px rgba(194, 189, 255, 0.4);
|
||||
}
|
||||
|
||||
/* Mobile adaptation */
|
||||
@media (max-width: 768px) {
|
||||
.back-to-top {
|
||||
bottom: 1.5rem;
|
||||
right: 1.5rem;
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
// Back to top button functionality
|
||||
function initBackToTop() {
|
||||
const backToTopButton = document.getElementById('back-to-top');
|
||||
if (!backToTopButton) return;
|
||||
|
||||
// Update button visibility state
|
||||
function updateBackToTopButton() {
|
||||
if (!backToTopButton) return;
|
||||
if (window.scrollY > 300) {
|
||||
backToTopButton.classList.add('visible');
|
||||
} else {
|
||||
backToTopButton.classList.remove('visible');
|
||||
}
|
||||
}
|
||||
|
||||
// Smooth scroll to top
|
||||
function scrollToTop() {
|
||||
window.scrollTo({
|
||||
top: 0,
|
||||
behavior: 'smooth'
|
||||
});
|
||||
}
|
||||
|
||||
// Scroll event listener
|
||||
window.addEventListener('scroll', updateBackToTopButton, { passive: true });
|
||||
|
||||
// Click event listener
|
||||
if (backToTopButton) {
|
||||
backToTopButton.addEventListener('click', scrollToTop);
|
||||
}
|
||||
|
||||
// Initialize
|
||||
updateBackToTopButton();
|
||||
}
|
||||
|
||||
// Initialize on page load
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', initBackToTop);
|
||||
} else {
|
||||
initBackToTop();
|
||||
}
|
||||
|
||||
// Support View Transitions
|
||||
document.addEventListener('astro:page-load', initBackToTop);
|
||||
</script>
|
||||
|
||||
591
src/components/widgets/Toc.astro
Normal file
@@ -0,0 +1,591 @@
|
||||
---
|
||||
interface Heading {
|
||||
depth: number;
|
||||
slug: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
interface HeadingNode extends Heading {
|
||||
children: HeadingNode[];
|
||||
}
|
||||
|
||||
const { headings = [] } = Astro.props as { headings: Heading[] };
|
||||
|
||||
function buildTree(list: Heading[]): HeadingNode[] {
|
||||
const root: HeadingNode[] = [];
|
||||
const stack: HeadingNode[] = [];
|
||||
list.forEach(h => {
|
||||
const node: HeadingNode = { ...h, children: [] };
|
||||
while (stack.length && stack[stack.length - 1].depth >= h.depth) {
|
||||
stack.pop();
|
||||
}
|
||||
if (stack.length === 0) {
|
||||
root.push(node);
|
||||
} else {
|
||||
stack[stack.length - 1].children.push(node);
|
||||
}
|
||||
stack.push(node);
|
||||
});
|
||||
return root;
|
||||
}
|
||||
const tree = buildTree(headings);
|
||||
---
|
||||
|
||||
<div class="toc-container">
|
||||
<div class="toc-header">
|
||||
<button id="toc-toggle" aria-expanded="false" aria-controls="toc-content">
|
||||
<span class="font-brand text-lg">TOC</span>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="chevron-down">
|
||||
<path d="m6 9 6 6 6-6"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="toc-title">
|
||||
<span class="toc-title-text font-brand text-base">TOC</span>
|
||||
<button id="toc-toggle-desktop" class="toc-toggle-desktop" aria-expanded="true" aria-controls="toc-content" aria-label="Toggle table of contents">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="chevron-up">
|
||||
<path d="m18 15-6-6-6 6"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<nav id="toc-content" class="toc" aria-label="TOC">
|
||||
<ul>
|
||||
{tree.map(node => (
|
||||
<li class={`toc-item font-brand depth-${node.depth}`}>
|
||||
<a href={`#${node.slug}`} data-depth={node.depth} class="toc-link">{node.text}</a>
|
||||
{node.children.length > 0 && (
|
||||
<ul>
|
||||
{node.children.map((c: HeadingNode) => (
|
||||
<li class={`toc-item font-brand depth-${c.depth}`}>
|
||||
<a href={`#${c.slug}`} data-depth={c.depth} class="toc-link">{c.text}</a>
|
||||
{c.children.length > 0 && (
|
||||
<ul>
|
||||
{c.children.map((cc: HeadingNode) => (
|
||||
<li class={`toc-item font-brand depth-${cc.depth}`}>
|
||||
<a href={`#${cc.slug}`} data-depth={cc.depth} class="toc-link">{cc.text}</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.toc-container {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.toc-header {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.toc {
|
||||
max-height: calc(100vh - 4rem);
|
||||
overflow-y: auto;
|
||||
font-size: 1rem;
|
||||
padding: 0;
|
||||
border-radius: 0.75rem;
|
||||
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
scrollbar-width: thin;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
|
||||
|
||||
.toc::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.toc::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
.toc::-webkit-scrollbar-thumb {
|
||||
background: #d1d5db;
|
||||
border-radius: 3px;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.toc::-webkit-scrollbar-thumb:hover {
|
||||
background: #9ca3af;
|
||||
}
|
||||
|
||||
:global(.dark) .toc::-webkit-scrollbar-thumb {
|
||||
background: #4b5563;
|
||||
}
|
||||
|
||||
:global(.dark) .toc::-webkit-scrollbar-thumb:hover {
|
||||
background: #6b7280;
|
||||
}
|
||||
|
||||
.toc-title {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1rem;
|
||||
padding-bottom: 0.75rem;
|
||||
border-bottom: 1px solid var(--color-neutral-200);
|
||||
}
|
||||
|
||||
:global(.dark) .toc-title {
|
||||
border-bottom-color: var(--color-neutral-700);
|
||||
}
|
||||
|
||||
.toc-title-text {
|
||||
letter-spacing: 1px;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
:global(.dark) .toc-title-text {
|
||||
color: var(--color-text-primary-dark);
|
||||
}
|
||||
|
||||
.toc-toggle-desktop {
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.375rem;
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: var(--color-neutral-500);
|
||||
padding: 0.375rem 0.625rem;
|
||||
border-radius: 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.025em;
|
||||
text-transform: uppercase;
|
||||
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
:global(.dark) .toc-toggle-desktop {
|
||||
color: var(--color-text-secondary-dark);
|
||||
}
|
||||
|
||||
.toc-toggle-desktop:hover {
|
||||
color: var(--color-primary);
|
||||
background: var(--color-neutral-100);
|
||||
}
|
||||
|
||||
:global(.dark) .toc-toggle-desktop:hover {
|
||||
color: var(--color-primary-light-dark);
|
||||
background: var(--color-bg-tertiary-dark);
|
||||
}
|
||||
|
||||
.toc-toggle-desktop:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
.toc-toggle-desktop svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
|
||||
|
||||
.toc-toggle-desktop[aria-expanded="false"] svg {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
|
||||
.toc ul {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding-left: 0.75rem;
|
||||
}
|
||||
.toc > ul {
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
.toc-item {
|
||||
margin: 0.25rem 0;
|
||||
}
|
||||
.toc-item a {
|
||||
display: block;
|
||||
color: var(--color-neutral-500);
|
||||
text-decoration: none;
|
||||
padding: 0.5rem 0.75rem;
|
||||
margin: 0.125rem 0;
|
||||
border-radius: 0.5rem;
|
||||
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
line-height: 1.5;
|
||||
font-weight: 400;
|
||||
font-size:1rem;
|
||||
letter-spacing: .5px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
:global(.dark) .toc-item a {
|
||||
color: var(--color-text-secondary-dark);
|
||||
}
|
||||
|
||||
.toc-item a:hover {
|
||||
color: var(--color-primary);
|
||||
background: var(--color-neutral-100);
|
||||
}
|
||||
|
||||
:global(.dark) .toc-item a:hover {
|
||||
color: var(--color-primary-light-dark);
|
||||
background: var(--color-bg-tertiary-dark);
|
||||
}
|
||||
|
||||
.toc-item a.active {
|
||||
color: var(--color-primary);
|
||||
background: var(--color-neutral-100);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
:global(.dark) .toc-item a.active {
|
||||
color: var(--color-primary-light-dark);
|
||||
background: var(--color-bg-tertiary-dark);
|
||||
}
|
||||
|
||||
.toc-item a.active::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 2px;
|
||||
height: 60%;
|
||||
background: var(--color-primary);
|
||||
border-radius: 0 2px 2px 0;
|
||||
}
|
||||
|
||||
:global(.dark) .toc-item a.active::before {
|
||||
background: var(--color-primary);
|
||||
}
|
||||
|
||||
.depth-1 { font-weight: 600; }
|
||||
.depth-2 { padding-left: 0.5rem; }
|
||||
.depth-3 { padding-left: .2em; font-size: 0.875em; }
|
||||
|
||||
/* 响应式样式 */
|
||||
@media (max-width: 1023px) {
|
||||
.toc-container {
|
||||
position: relative;
|
||||
margin: 1.5rem 0;
|
||||
}
|
||||
.toc-title {
|
||||
display: none;
|
||||
}
|
||||
.toc-header {
|
||||
display: block;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
#toc-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
padding: 0.875rem 1rem;
|
||||
background: var(--color-bg-secondary);
|
||||
border: 1px solid var(--color-neutral-200);
|
||||
border-radius: 0.75rem;
|
||||
color: var(--color-text-primary);
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.025em;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
|
||||
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
:global(.dark) #toc-toggle {
|
||||
background: var(--color-bg-secondary-dark);
|
||||
border-color: var(--color-neutral-700);
|
||||
color: var(--color-text-primary-dark);
|
||||
}
|
||||
|
||||
#toc-toggle:hover {
|
||||
background: var(--color-neutral-100);
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -2px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
:global(.dark) #toc-toggle:hover {
|
||||
background: var(--color-bg-tertiary-dark);
|
||||
border-color: var(--color-primary-light-dark);
|
||||
}
|
||||
|
||||
#toc-toggle:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
.chevron-down {
|
||||
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
|
||||
#toc-toggle[aria-expanded="true"] .chevron-down {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.toc {
|
||||
display: none;
|
||||
position: static; /* 移动端不使用 sticky */
|
||||
max-height: none;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
.toc.expanded {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.toc-container {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
z-index: 1;
|
||||
}
|
||||
.toc {
|
||||
width: 100%;
|
||||
}
|
||||
.toc-toggle-desktop {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.toc.collapsed {
|
||||
max-height: 0;
|
||||
overflow: hidden;
|
||||
padding-top: 0;
|
||||
padding-bottom: 0;
|
||||
border-width: 0;
|
||||
visibility: hidden;
|
||||
}
|
||||
.chevron-up {
|
||||
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.toc-toggle-desktop[aria-expanded="true"] .chevron-up {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
.toc-toggle-desktop[aria-expanded="false"] .chevron-up {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@media (max-width: 576px) {
|
||||
.toc-container {
|
||||
margin: 1rem 0;
|
||||
}
|
||||
#toc-toggle {
|
||||
padding: 0.5rem 0.75rem;
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
}
|
||||
|
||||
#toc-content::-webkit-scrollbar {
|
||||
width: 6px !important; }
|
||||
|
||||
#toc-content::-webkit-scrollbar-track {
|
||||
background: var(--color-neutral-200) !important;
|
||||
border-radius: 6px !important;
|
||||
-webkit-border-radius: 6px !important;
|
||||
-moz-border-radius: 6px !important;
|
||||
-ms-border-radius: 6px !important;
|
||||
-o-border-radius: 6px !important; }
|
||||
|
||||
#toc-content::-webkit-scrollbar-thumb {
|
||||
background: var(--color-neutral-300) !important;
|
||||
border-radius: 6px !important; }
|
||||
#toc-content::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--color-primary) !important; }
|
||||
#toc-content::-webkit-scrollbar-thumb:active {
|
||||
background: var(--color-primary-dark) !important; }
|
||||
|
||||
</style>
|
||||
|
||||
<script>
|
||||
// Use IIFE to avoid multiple instance conflicts
|
||||
(() => {
|
||||
// Get the current container of the component
|
||||
const containers = document.querySelectorAll('.toc-container');
|
||||
|
||||
containers.forEach((container) => {
|
||||
const tocToggle = container.querySelector('#toc-toggle');
|
||||
const tocToggleDesktop = container.querySelector('#toc-toggle-desktop');
|
||||
const tocContent = container.querySelector('#toc-content');
|
||||
|
||||
// Handle mobile fold/unfold
|
||||
if (tocToggle && tocContent) {
|
||||
tocToggle.addEventListener('click', () => {
|
||||
const expanded = tocToggle.getAttribute('aria-expanded') === 'true';
|
||||
tocToggle.setAttribute('aria-expanded', (!expanded).toString());
|
||||
tocContent.classList.toggle('expanded');
|
||||
});
|
||||
}
|
||||
|
||||
// Handle desktop fold/unfold
|
||||
if (tocToggleDesktop && tocContent) {
|
||||
tocToggleDesktop.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
const expanded = tocToggleDesktop.getAttribute('aria-expanded') === 'true';
|
||||
tocToggleDesktop.setAttribute('aria-expanded', (!expanded).toString());
|
||||
|
||||
if (expanded) {
|
||||
tocContent.classList.add('collapsed');
|
||||
const chevronUp = tocToggleDesktop.querySelector('.chevron-up');
|
||||
if (chevronUp instanceof HTMLElement) {
|
||||
chevronUp.style.transform = 'rotate(180deg)';
|
||||
}
|
||||
} else {
|
||||
tocContent.classList.remove('collapsed');
|
||||
const chevronUp = tocToggleDesktop.querySelector('.chevron-up');
|
||||
if (chevronUp instanceof HTMLElement) {
|
||||
chevronUp.style.transform = 'rotate(0)';
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Initial state - mobile default fold, desktop default display
|
||||
const isMobile = window.innerWidth < 1024;
|
||||
if (tocToggle && tocContent) {
|
||||
if (isMobile) {
|
||||
tocToggle.setAttribute('aria-expanded', 'false');
|
||||
} else {
|
||||
tocToggle.setAttribute('aria-expanded', 'true');
|
||||
}
|
||||
}
|
||||
|
||||
// Get all directory links in the current container
|
||||
const tocLinks = container.querySelectorAll('.toc-link');
|
||||
const headingElements = Array.from(document.querySelectorAll('main h1[id], main h2[id], main h3[id]'));
|
||||
|
||||
// Create mapping from ID to link
|
||||
const idToLinkMap = new Map();
|
||||
tocLinks.forEach(link => {
|
||||
const href = link.getAttribute('href');
|
||||
if (href && href.startsWith('#')) {
|
||||
const id = href.substring(1);
|
||||
idToLinkMap.set(id, link);
|
||||
}
|
||||
});
|
||||
|
||||
// Handle click on directory link
|
||||
tocLinks.forEach(link => {
|
||||
link.addEventListener('click', () => {
|
||||
tocLinks.forEach(l => l.classList.remove('active'));
|
||||
link.classList.add('active');
|
||||
|
||||
if (isMobile && tocToggle && tocContent) {
|
||||
tocToggle.setAttribute('aria-expanded', 'false');
|
||||
tocContent.classList.remove('expanded');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Use IntersectionObserver to listen to title elements
|
||||
let activeHeadingId: string | null = null;
|
||||
|
||||
const observerOptions = {
|
||||
rootMargin: '-80px 0px -60% 0px',
|
||||
threshold: [0, 0.1, 0.25, 0.5]
|
||||
};
|
||||
|
||||
const headingObserver = new IntersectionObserver((entries) => {
|
||||
const visibleHeadings = entries
|
||||
.filter(entry => entry.isIntersecting)
|
||||
.map(entry => ({
|
||||
id: entry.target.id,
|
||||
ratio: entry.intersectionRatio,
|
||||
top: entry.boundingClientRect.top
|
||||
}))
|
||||
.sort((a, b) => {
|
||||
if (Math.abs(a.ratio - b.ratio) > 0.1) return b.ratio - a.ratio;
|
||||
return a.top - b.top;
|
||||
});
|
||||
|
||||
if (visibleHeadings.length > 0) {
|
||||
const topHeadingId = visibleHeadings[0].id;
|
||||
if (topHeadingId !== activeHeadingId) {
|
||||
activeHeadingId = topHeadingId;
|
||||
|
||||
tocLinks.forEach(link => link.classList.remove('active'));
|
||||
|
||||
const activeLink = idToLinkMap.get(topHeadingId);
|
||||
if (activeLink) {
|
||||
activeLink.classList.add('active');
|
||||
|
||||
// Scroll to visible in the directory
|
||||
const tocContainer = tocContent;
|
||||
if (tocContainer && !tocContainer.classList.contains('collapsed')) {
|
||||
const linkRect = activeLink.getBoundingClientRect();
|
||||
const containerRect = tocContainer.getBoundingClientRect();
|
||||
if (linkRect.bottom > containerRect.bottom || linkRect.top < containerRect.top) {
|
||||
activeLink.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}, observerOptions);
|
||||
|
||||
headingElements.forEach(heading => {
|
||||
headingObserver.observe(heading);
|
||||
});
|
||||
|
||||
// Initial active state
|
||||
setTimeout(() => {
|
||||
const hash = window.location.hash.substring(1);
|
||||
if (hash && idToLinkMap.has(hash)) {
|
||||
const link = idToLinkMap.get(hash);
|
||||
tocLinks.forEach(l => l.classList.remove('active'));
|
||||
link.classList.add('active');
|
||||
activeHeadingId = hash;
|
||||
return;
|
||||
}
|
||||
|
||||
const visibleHeadingsInit = headingElements.filter(heading => {
|
||||
const rect = heading.getBoundingClientRect();
|
||||
return rect.top >= 0 && rect.top <= window.innerHeight / 2;
|
||||
});
|
||||
|
||||
if (visibleHeadingsInit.length > 0) {
|
||||
const topHeading = visibleHeadingsInit[0];
|
||||
const link = idToLinkMap.get(topHeading.id);
|
||||
if (link) {
|
||||
tocLinks.forEach(l => l.classList.remove('active'));
|
||||
link.classList.add('active');
|
||||
activeHeadingId = topHeading.id;
|
||||
}
|
||||
} else if (headingElements.length > 0) {
|
||||
const firstHeading = headingElements[0];
|
||||
const link = idToLinkMap.get(firstHeading.id);
|
||||
if (link) {
|
||||
tocLinks.forEach(l => l.classList.remove('active'));
|
||||
link.classList.add('active');
|
||||
activeHeadingId = firstHeading.id;
|
||||
}
|
||||
}
|
||||
}, 100);
|
||||
|
||||
// Handle hash change
|
||||
window.addEventListener('hashchange', () => {
|
||||
const hash = window.location.hash.substring(1);
|
||||
if (hash && idToLinkMap.has(hash)) {
|
||||
const link = idToLinkMap.get(hash);
|
||||
tocLinks.forEach(l => l.classList.remove('active'));
|
||||
link.classList.add('active');
|
||||
activeHeadingId = hash;
|
||||
}
|
||||
});
|
||||
}); // End forEach loop
|
||||
})(); // End IIFE
|
||||
</script>
|
||||
34
src/components/widgets/TrackGa.astro
Normal file
@@ -0,0 +1,34 @@
|
||||
---
|
||||
const GAID = import.meta.env.PUBLIC_GA4_ID;
|
||||
const UMAMIId = import.meta.env.PUBLIC_UMAMI_ID;
|
||||
// const GAID = '';
|
||||
|
||||
if (!GAID) {
|
||||
// console.error("Google Analytics ID (PUBLIC_GA4_ID) is missing!");
|
||||
}
|
||||
---
|
||||
|
||||
<!-- GA4 跟踪代码 -->
|
||||
{GAID && (
|
||||
<>
|
||||
<script is:inline async src={`https://www.googletagmanager.com/gtag/js?id=${GAID}`} ></script>
|
||||
<script is:inline define:vars={{ GAID }}>
|
||||
window.dataLayer = window.dataLayer || [];
|
||||
function gtag(){dataLayer.push(arguments);}
|
||||
gtag('js', new Date());
|
||||
gtag('config', GAID);
|
||||
</script>
|
||||
</>
|
||||
)}
|
||||
|
||||
<!-- UMAMIId 跟踪代码 -->
|
||||
{UMAMIId && (
|
||||
<script
|
||||
async
|
||||
defer
|
||||
is:inline
|
||||
data-website-id={UMAMIId}
|
||||
src="https://cloud.umami.is/script.js"
|
||||
></script>
|
||||
)}
|
||||
|
||||
74
src/config/site.js
Normal file
@@ -0,0 +1,74 @@
|
||||
// Get site URL from environment variable, use default value if not set
|
||||
// Note: Please set the correct PUBLIC_SITE_URL in .env file after first deployment
|
||||
const SITE_URL = process.env.PUBLIC_SITE_URL || 'https://portfolio.ricoui.com/';
|
||||
|
||||
export const siteConfig = {
|
||||
title: "Rico Portfolio",
|
||||
author: "Ricoui",
|
||||
url: SITE_URL,
|
||||
mail: "hello@ricoui.com",
|
||||
resume: "#",
|
||||
utm: {
|
||||
source: `${SITE_URL}`,
|
||||
medium: "referral",
|
||||
campaign: "navigation",
|
||||
},
|
||||
meta:{
|
||||
title: "Rico Portfolio",
|
||||
description: "I'm Rico, a web designer passionate about both design and code. Currently developing a personal product for the design community.",
|
||||
keywords: "web designer, portfolio, design, code, personal website",
|
||||
image: `${SITE_URL}/og.jpg`,
|
||||
twitterHandle: "ricouii",
|
||||
},
|
||||
// social links
|
||||
social:{
|
||||
twitter: "https://x.com/ricouii",
|
||||
twitterName: "ricouii",
|
||||
github: "https://github.com/ricocc",
|
||||
blog: "https://ricoui.com",
|
||||
xiaohongshu:"https://www.xiaohongshu.com/user/profile/5f2b6903000000000101f51f"
|
||||
},
|
||||
};
|
||||
|
||||
// Footer
|
||||
export const socialLinks = [
|
||||
{
|
||||
name: 'Twitter',
|
||||
url: 'https://x.com/ricouii',
|
||||
icon: `<svg class="ic-twitter icon" width="1792" height="1792" viewBox="0 0 1792 1792" xmlns="http://www.w3.org/2000/svg"><path d="M1684 408q-67 98-162 167 1 14 1 42 0 130-38 259.5t-115.5 248.5-184.5 210.5-258 146-323 54.5q-271 0-496-145 35 4 78 4 225 0 401-138-105-2-188-64.5t-114-159.5q33 5 61 5 43 0 85-11-112-23-185.5-111.5t-73.5-205.5v-4q68 38 146 41-66-44-105-115t-39-154q0-88 44-163 121 149 294.5 238.5t371.5 99.5q-8-38-8-74 0-134 94.5-228.5t228.5-94.5q140 0 236 102 109-21 205-78-37 115-142 178 93-10 186-50z" fill="currentColor"></path></svg>`
|
||||
},
|
||||
{
|
||||
name: 'Github',
|
||||
url: 'https://github.com/ricocc/',
|
||||
icon: `<svg t="1730125604816" class="icon ic-github ic-social" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="12741" width="256" height="256"><path d="M511.957333 21.333333C241.024 21.333333 21.333333 240.981333 21.333333 512c0 216.832 140.544 400.725333 335.573334 465.664 24.490667 4.394667 32.256-10.069333 32.256-23.082667 0-11.690667 0.256-44.245333 0-85.205333-136.448 29.610667-164.736-64.64-164.736-64.64-22.314667-56.704-54.4-71.765333-54.4-71.765333-44.586667-30.464 3.285333-29.824 3.285333-29.824 49.194667 3.413333 75.178667 50.517333 75.178667 50.517333 43.776 75.008 114.816 53.333333 142.762666 40.789333 4.522667-31.658667 17.152-53.376 31.189334-65.536-108.970667-12.458667-223.488-54.485333-223.488-242.602666 0-53.546667 19.114667-97.322667 50.517333-131.669334-5.034667-12.330667-21.930667-62.293333 4.778667-129.834666 0 0 41.258667-13.184 134.912 50.346666a469.802667 469.802667 0 0 1 122.88-16.554666c41.642667 0.213333 83.626667 5.632 122.88 16.554666 93.653333-63.488 134.784-50.346667 134.784-50.346666 26.752 67.541333 9.898667 117.504 4.864 129.834666 31.402667 34.346667 50.474667 78.122667 50.474666 131.669334 0 188.586667-114.730667 230.016-224.042666 242.090666 17.578667 15.232 33.578667 44.672 33.578666 90.453334v135.850666c0 13.141333 7.936 27.605333 32.853334 22.869334C862.250667 912.597333 1002.666667 728.746667 1002.666667 512 1002.666667 240.981333 783.018667 21.333333 511.957333 21.333333z" p-id="12742"></path></svg>`
|
||||
},
|
||||
{
|
||||
name: 'ZCool',
|
||||
url: 'https://www.zcool.com.cn/u/13170647',
|
||||
icon: `<svg t="1713091842741" class="ic-zcool ic-social icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="5776" width="22" height="22"><path d="M352.512 352.427C249.088 375.595 170.667 478.208 170.667 594.56a251.861 251.861 0 0 0 499.328 46.848 85.333 85.333 0 0 1 48.17-61.739c20.566-9.514 38.827-20.224 54.784-31.744-36.821-25.728-50.602-79.445-20.053-120.917a418.133 418.133 0 0 0 59.35-113.11c-46.251 21.76-98.134 39.937-156.502 53.974-53.803 12.928-102.059-27.221-105.216-77.397-50.347 24.405-113.92 43.093-198.059 61.952z m469.12 125.184c49.75 0 81.75-18.091 117.077-25.046-13.866 61.782-64.042 148.907-184.789 204.587a337.195 337.195 0 0 1-668.544-62.55c0-155.562 105.259-293.375 248.49-325.418 167.297-37.504 243.371-66.859 301.953-183.85 29.397 56.874 29.397 123.391 0 199.551 120.149-28.586 216.49-79.018 289.024-151.381 0 204.885-77.739 306.603-103.211 344.107zM572.459 645.376c24.917 6.23 24.917 35.499 24.917 43.52v43.563c0 23.978-14.592 32.981-36.01 32.981H330.367c-20.267-3.67-31.317-14.72-31.317-33.152v-40.533c0-20.267 9.216-34.987 18.389-46.08L451.968 501.93H321.152c-12.885 0-22.101-9.216-22.101-25.771-1.878-31.36 3.669-81.067 27.605-92.16 3.712 0 3.712 5.547 3.712 7.381-3.712 7.382 0 12.886 9.216 12.886h165.803c36.864 0 58.965 0 71.893-3.67 5.547-1.834 9.216 0 7.381 5.547-3.712 12.885-1.877 27.605-1.877 40.533 0 42.368 0 58.966-33.152 92.118L435.371 664.107l121.6-0.086c13.482 0 15.53-6.229 15.53-18.645z" p-id="5777" fill="currentColor"></path></svg>`
|
||||
},
|
||||
|
||||
{
|
||||
name: 'Behance',
|
||||
url: 'https://www.behance.net/ricoui',
|
||||
icon: `<svg class="ic-behance ic-social" width="20" height="20" viewBox="0 0 20 20" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_1771_115)">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M6.71692 14.0126C6.41937 14.1494 6.00062 14.2186 5.46449 14.2186H2.46336V11.0969H5.50708C6.03611 11.1017 6.44885 11.1682 6.74258 11.2951C7.26834 11.5236 7.52931 11.9413 7.52931 12.5514C7.52931 13.271 7.25906 13.7563 6.71692 14.0126ZM2.46336 6.57746H5.15439C5.74566 6.57746 6.23266 6.63878 6.61592 6.76037C7.05814 6.93648 7.27926 7.29602 7.27926 7.84268C7.27926 8.33273 7.1111 8.67551 6.77698 8.86944C6.44067 9.06336 6.00445 9.16032 5.46941 9.16032H2.46336V6.57746ZM8.22813 9.96904C8.85817 10.1871 9.33315 10.5309 9.6569 11.0021C9.97683 11.4722 10.1379 12.0446 10.1379 12.7175C10.1379 13.4115 9.95445 14.0341 9.58593 14.5839C9.35335 14.9482 9.06181 15.2558 8.7113 15.5048C8.31767 15.7915 7.85306 15.9859 7.31529 16.0929C6.77807 16.1987 6.19717 16.2501 5.56932 16.2501H0V4.54544H5.97278C7.48017 4.56641 8.54697 4.98203 9.17591 5.79232C9.55317 6.29024 9.74262 6.88511 9.74262 7.57905C9.74262 8.29238 9.55317 8.86839 9.171 9.30288C8.95699 9.54555 8.64306 9.7683 8.22813 9.96904ZM12.7798 6.54758V5.09105H18.0762V6.54758H12.7798ZM19.1874 9.05104C19.5652 9.56573 19.8076 10.1627 19.9179 10.8409C19.9828 11.2371 20.0085 11.8121 19.9976 12.5595H13.3385C13.3784 13.4269 13.6934 14.036 14.2961 14.385C14.6603 14.602 15.0992 14.711 15.6141 14.711C16.1562 14.711 16.5995 14.5784 16.9391 14.3143C17.1264 14.1707 17.2907 13.9725 17.4321 13.7189H19.872C19.8076 14.2309 19.5111 14.7535 18.9865 15.2834C18.1681 16.124 17.0215 16.5454 15.548 16.5454C14.3311 16.5454 13.2577 16.1911 12.3296 15.4804C11.3971 14.7713 10.9336 13.6151 10.9336 12.0155C10.9336 10.5154 11.3534 9.36499 12.192 8.56466C13.0328 7.7659 14.1203 7.3639 15.4601 7.3639C16.2561 7.3639 16.9719 7.49965 17.6095 7.77009C18.2483 8.04001 18.7735 8.4677 19.1874 9.05104ZM13.3975 11.0658H17.5167C17.4731 10.4646 17.259 10.0102 16.8801 9.69728C16.4969 9.38648 16.0252 9.23134 15.4612 9.23134C14.8497 9.23134 14.372 9.39644 14.0346 9.72611C13.6972 10.0558 13.4843 10.5023 13.3975 11.0658Z" fill="currentColor"></path>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_1771_115">
|
||||
<rect width="20" height="20" fill="currentColor"></rect>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>`
|
||||
},
|
||||
{
|
||||
name: 'RSS',
|
||||
url: '/rss.xml',
|
||||
icon: `<svg t="1730123988138" class="icon ic-rss ic-social " viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="11766" width="256" height="256"><path d="M329.143 768q0 45.714-32 77.714t-77.714 32-77.715-32-32-77.714 32-77.714 77.715-32 77.714 32 32 77.714z m292.571 70.286q1.143 16-9.714 27.428-10.286 12-26.857 12H508q-14.286 0-24.571-9.428T472 844.57q-12.571-130.857-105.429-223.714T142.857 515.43q-14.286-1.143-23.714-11.429t-9.429-24.571v-77.143q0-16.572 12-26.857 9.715-9.715 24.572-9.715h2.857q91.428 7.429 174.857 46T472 515.43q65.143 64.571 103.714 148t46 174.857z m292.572 1.143q1.143 15.428-10.286 26.857-10.286 11.428-26.286 11.428H796q-14.857 0-25.429-10T759.43 843.43Q752.57 720.57 701.714 610T569.43 418t-192-132.286T144 227.43q-14.286-0.572-24.286-11.143t-10-24.857v-81.715q0-16 11.429-26.285 10.286-10.286 25.143-10.286H148q149.714 7.428 286.571 68.571t243.143 168q106.857 106.286 168 243.143t68.572 286.572z" p-id="11767"></path></svg>`
|
||||
},
|
||||
|
||||
|
||||
];
|
||||
|
||||
|
||||
18
src/content/config.js
Normal file
@@ -0,0 +1,18 @@
|
||||
import { defineCollection, z } from "astro:content";
|
||||
|
||||
const postCollection = defineCollection({
|
||||
schema: z.object({
|
||||
title: z.string(),
|
||||
description: z.string(),
|
||||
publishDate: z.coerce.date(),
|
||||
read: z.number().optional(),
|
||||
tags: z.array(z.string()).optional(),
|
||||
img: z.string().optional(),
|
||||
img_alt: z.string().optional(),
|
||||
featured: z.boolean().optional(),
|
||||
}),
|
||||
});
|
||||
|
||||
export const collections = {
|
||||
post: postCollection,
|
||||
};
|
||||
BIN
src/content/post/design-style-guide-neo-brutalism/01.jpg
Normal file
|
After Width: | Height: | Size: 853 KiB |
|
After Width: | Height: | Size: 142 KiB |
BIN
src/content/post/design-style-guide-neo-brutalism/03.webp
Normal file
|
After Width: | Height: | Size: 206 KiB |
BIN
src/content/post/design-style-guide-neo-brutalism/04.jpg
Normal file
|
After Width: | Height: | Size: 158 KiB |
BIN
src/content/post/design-style-guide-neo-brutalism/05.jpg
Normal file
|
After Width: | Height: | Size: 104 KiB |
|
After Width: | Height: | Size: 121 KiB |
BIN
src/content/post/design-style-guide-neo-brutalism/07.jpg
Normal file
|
After Width: | Height: | Size: 452 KiB |
BIN
src/content/post/design-style-guide-neo-brutalism/08.jpg
Normal file
|
After Width: | Height: | Size: 632 KiB |
|
After Width: | Height: | Size: 128 KiB |
|
After Width: | Height: | Size: 170 KiB |
|
After Width: | Height: | Size: 108 KiB |
|
After Width: | Height: | Size: 203 KiB |
BIN
src/content/post/design-style-guide-neo-brutalism/14.jpg
Normal file
|
After Width: | Height: | Size: 306 KiB |
BIN
src/content/post/design-style-guide-neo-brutalism/14.webp
Normal file
|
After Width: | Height: | Size: 66 KiB |
|
After Width: | Height: | Size: 121 KiB |
BIN
src/content/post/design-style-guide-neo-brutalism/16.jpg
Normal file
|
After Width: | Height: | Size: 356 KiB |
BIN
src/content/post/design-style-guide-neo-brutalism/17.webp
Normal file
|
After Width: | Height: | Size: 104 KiB |
BIN
src/content/post/design-style-guide-neo-brutalism/18.webp
Normal file
|
After Width: | Height: | Size: 34 KiB |
158
src/content/post/design-style-guide-neo-brutalism/index.mdx
Normal file
@@ -0,0 +1,158 @@
|
||||
---
|
||||
title: Design Style Guide - Neo Brutalism
|
||||
publishDate: 2025-06-26
|
||||
read: 10
|
||||
description: Design Style Guide - Neo Brutalism
|
||||
tags:
|
||||
- Design Style
|
||||
- Neo-Brutalism
|
||||
- Neo Brutalism
|
||||
img: "/assets/blog/cover-neobrutalism.jpg"
|
||||
img_alt: "Neo Brutalism"
|
||||
---
|
||||
|
||||
In the previous article, we discussed the basics of Brutalism. Now we come to my favorite chapter - the previous article was even a setup for this one. Let's talk about "Neo-Brutalism" and its derived design styles.
|
||||
|
||||
Previous article: <a href="/blog/web-design-style-guide-brutalism" target="_blank">Web Design Style Guide: Brutalism</a>
|
||||
|
||||
In fact, popular design works currently classified as Brutalism can mostly be categorized as Neo-Brutalism. Compared to the "no design" philosophy that original Brutalism insisted on, today it still belongs to a relatively niche design culture. Neo-Brutalism, however, is more widely accepted and loved, extensively applied in independent creators' portfolio websites, creative marketing campaigns, brand planning, and experimental digital art projects.
|
||||
|
||||

|
||||
|
||||
<small class="block text-center"> Searching for neo-brutalism keywords shows popular visual design works </small>
|
||||
|
||||
## Neo-Brutalism
|
||||
|
||||
Neo-Brutalism: Also written as Neu-Brutalism or New Brutalism.
|
||||
|
||||
The rise of the internet era has accelerated everything, driving technological innovation, aesthetic evolution, and cultural change. The characteristics of the digital age need no further elaboration. Simply put, the rise of Neo-Brutalism is related to the rebellious spirit of the digital age. Designers and artists drew inspiration from mid-20th century architectural Brutalism, bringing its rugged, raw aesthetics to the modern digital stage in the form of "Neo-Brutalism," challenging current mainstream design culture and pursuing authentic, unprocessed, even "anti-UX" expression.
|
||||
|
||||

|
||||
|
||||
<small class="block text-center"> <a href="https://www.neobrutalism.dev/" target="_blank">neobrutalism.dev</a> - Open-source component library with Neo-Brutalist style component system</small>
|
||||
|
||||
### Neo-Brutalism Style Characteristics
|
||||
|
||||
Neo-Brutalism is the modern revival of Brutalism in digital design, especially prominent in web design and UI/UX fields. It inherits architectural Brutalism's "exposed structure" and function-first philosophy, combined with modern technology and digital media characteristics, forming a unique visual and interaction style. Characteristics in web and UI design include:
|
||||
|
||||
* Conflicting color schemes: Often uses bright, vivid tones like black-white, red-green-blue color combinations, creating strong visual impact, abandoning soft gradients.
|
||||
* Thick outlines and heavy shadows: Black outline strokes and shadows seem to inherit the rugged aesthetic of architecture.
|
||||
* Flat design: Designs often consist of flat elements, illustrations, patterns, and huge text blocks, these elements may bring a sense of chaos.
|
||||
* Special typography: Typography plays a very important role in Neo-Brutalism, being the element that dominates its visual effect.
|
||||
* Unique illustrations and animations: Neo-Brutalist illustrations and component design themes are more diverse. Illustration and animation designs are mostly strongly visual styles.
|
||||
* Function over form: Anti-UX interaction experience, advocating minimalism, inheriting the utilitarian essence of Brutalist architecture.
|
||||
|
||||

|
||||
|
||||
<small class="block text-center"> Sepideh Yazdi Neo-Brutalist visual style UI design work </small>
|
||||
|
||||

|
||||
|
||||
<small class="block text-center"> <a href="https://gumroad.com/" target="_blank">gumroad</a> design style is also a leader in Neo-Brutalist design </small>
|
||||
|
||||
The core of original Brutalism is "no design, no decoration." What makes Neo-Brutalism particularly interesting to me is that it presents a paradox in digital design:
|
||||
|
||||
Although it inherits Brutalism's ruggedness and imperfection, in visual presentation, it often demonstrates a prominent "intentional design sense" through deliberate irregularity and strong contrast. The unconventional visual language manifests in results as intentional design sense and strong visual impact, which actually makes Neo-Brutalism increasingly popular and sought after in the digital age - the contradiction between anti-design attitude and design-heavy outcomes.
|
||||
|
||||

|
||||
|
||||
<small class="block text-center"> The Internet's video API - <a href="https://www.mux.com/" target="_blank">mux.com</a></small>
|
||||
|
||||

|
||||
<small class="block text-center"> <a href="https://sui.io/overflow" target="_blank">sui.io/overflow</a></small>
|
||||
|
||||
Neo-Brutalism is commonly used by independent designers, creative agencies, brands, events, fashion and other scenarios, highlighting individuality and anti-mainstream style to attract young users, actually becoming part of mainstream design. Of course, design projects are absolutely complex systems. In fact, we rarely completely classify a project or website into a certain design school. We look at the complete content it expresses from design concepts, logic, and visuals.
|
||||
|
||||

|
||||
|
||||
<small class="block text-center"> Colorfolio X personal portfolio website template </small>
|
||||
|
||||
Neo-Brutalism's visual style and philosophy are particularly good choices for brands. Strong visual style and cultural characteristics make users remember the brand deeply and attract like-minded people.
|
||||
|
||||

|
||||
|
||||
<small class="block text-center"> Wonderfully weird world Le Puzz - <a href="https://lepuzz.com/" target="_blank">lepuzz.com</a> </small>
|
||||
|
||||

|
||||
|
||||
<small class="block text-center">Plant snack brand - <a href="https://goodeatn.com/" target="_blank">goodeatn.com</a></small>
|
||||
|
||||

|
||||
|
||||
<small class="block text-center">Budapest Park - <a href="https://www.budapestpark.hu/en" target="_blank">budapestpark.hu</a> </small>
|
||||
|
||||

|
||||
|
||||
<small class="block text-center"> <a href="https://www.byooooob.com/" target="_blank">byooooob.com</a> </small>
|
||||
|
||||
|
||||
|
||||
## The Development History of Brutalism
|
||||
|
||||
As we know, in design history, the cycle of nostalgia and revival is almost an eternal theme. Each era's design trends are re-examined and honored at some point in the future. We talked about Brutalism born in the mid-20th century - its unique philosophy was considered revolutionary at the time because it challenged traditional aesthetics, attempting to respond to social needs in an honest and direct way.
|
||||
|
||||
However, Brutalism was not "popular" from the beginning. It had some influence in the mid-20th century (especially in public buildings like schools, libraries, government buildings), but was also heavily criticized for its "cold," "oppressive" and unadorned appearance.
|
||||
|
||||

|
||||
<small class="block text-center"> Geisel Library </small>
|
||||
|
||||
*Geisel Library* is a landmark building designed by William Leonard Pereira in the late 1960s, a striking example of Brutalist architecture.
|
||||
|
||||
Historically, its development has been up and down:
|
||||
|
||||
* Mid-20th century (1950s-1970s): Brutalism emerged in the context of post-war reconstruction, mainly used for public buildings in Europe and North America (such as schools, government buildings), symbolizing modernism's pursuit of functionality and social equality. Its "cold" and unadorned appearance was both revolutionary and controversial.
|
||||
|
||||
* Late 20th century (1980s-early 2000s): With the rise of Postmodernism, Brutalism was criticized for its monotony and lack of humanized design, many buildings were abandoned or renovated, gradually marginalized.
|
||||
|
||||
* 21st century (2010s to present): With nostalgic sentiment and re-examination of modernism, Brutalism has gradually revived, especially in digital design and visual art fields, evolving into "Neo-Brutalism." This revival is not a simple reproduction, but combines modern aesthetics with anti-mainstream cultural demands.
|
||||
|
||||
Therefore, Brutalism's ups and downs reflect changes in design trends and social values. The advent of the digital age provided a broader stage for Neo-Brutalism. Designers reinterpreted its rugged aesthetics through graphic, web and UI design, transforming it from cold, raw architectural language into personality-filled visual expression.
|
||||
|
||||
|
||||
|
||||
## The Future of Neo-Brutalism
|
||||
|
||||
As digital design continues to evolve, Neo-Brutalism may further integrate into mainstream design language, becoming a more widely applied style. Especially as it merges with other retro styles or emerging trends, creating more diversified design possibilities, such as Cyber Brutalism, Postmodern Brutalism, etc. This is actually easy to understand - the rebellious spirit and rugged aesthetics of Brutalism have their soil in every era. "The more advanced, the more primitive." Highly digitized, fast-paced society instead stimulates desire for imperfection and authenticity.
|
||||
|
||||
### Cyber Brutalism
|
||||
|
||||
The emergence of Cyber Brutalism stems from the digital age's enthusiasm for Cyberpunk culture, and Neo-Brutalism's emphasis on "unfinished feeling" and "exposed structure." Cyberpunk culture's depiction of future technology and social alienation resonates with Neo-Brutalism's rugged aesthetics, especially in design demands for emerging technology fields like virtual reality (VR), augmented reality (AR) and Metaverse. Designers hope to create a visual language that is both futuristic and primitively rugged by combining the two.
|
||||
|
||||
It can be felt in films and games, such as the "Blade Runner" film series and the game "Cyberpunk 2077."
|
||||
|
||||

|
||||
|
||||
<small class="block text-center"> "Blade Runner 2049" </small>
|
||||
|
||||

|
||||
|
||||
|
||||
|
||||
### Industrial Brutalism
|
||||
|
||||
Strictly speaking, this is not a widely recognized or formally named design school. Brutalism itself already emphasized "function first" and "material authenticity" in the 1950s-70s, while industrial style further strengthened this utilitarian aesthetic in contemporary interior design and product design. Therefore, "Industrial Brutalism" can be seen as the convergence of Brutalism's original philosophy with modern industrial style popular trends.
|
||||
|
||||
Especially in recent years, industrial style has become popular in interior design (such as loft apartments, cafes) and product design (such as vintage mechanical equipment).
|
||||
|
||||
In visual culture, manifestations of this style can be traced back to last century's science fiction films, such as: "Alien" (1979) series, "The Terminator" (1984) series, etc. The films' depictions of future worlds, and mechanical world visions, combined with cold metallic texture and decaying industrial scenes, also reflect Industrial Brutalism's visual language. Of course, the films' aesthetic style itself is complex and diverse, cannot be simply classified, but from their aesthetics and visual language, corresponding elements and expressions can be found.
|
||||
|
||||
### Final Words
|
||||
|
||||
Looking back at last century's sci-fi films, due to computer effects level limitations, most visual effects were made using real materials. Compared to today's CG images, this highlights a raw, authentic texture.
|
||||
|
||||
While these aren't strictly Brutalist works, they all embody some of Brutalism's core philosophies to some extent. These works' visual language connection with Brutalism is more reflected in design philosophy and aesthetic pursuit rather than direct style imitation. Their influence is more reflected in inspiring designers to explore similar visual language, which is connected to Brutalism's spirit of pursuing authenticity and functionality.
|
||||
|
||||
|
||||
### Preview
|
||||
|
||||
In the next chapter, I'm still choosing topics, might want to talk about Retro-futurism or Cyberpunk-related styles, and need to think about how to write them properly.
|
||||
|
||||
Retro-futurism philosophy is very interesting, "looking at the future from the past and looking at the past from the future," whether in philosophy or visual style, it's an interesting and philosophically deep design trend.
|
||||
|
||||

|
||||
|
||||
For example, the upcoming "Fantastic Four: First Steps" has a background story set in a retro-futuristic world inspired by the 1960s. Looking forward to a retro-futuristic visual feast.
|
||||
|
||||

|
||||
|
||||

|
||||
BIN
src/content/post/design-style-guide-retro-and-vintage/01.jpg
Normal file
|
After Width: | Height: | Size: 114 KiB |
BIN
src/content/post/design-style-guide-retro-and-vintage/02.jpg
Normal file
|
After Width: | Height: | Size: 188 KiB |
BIN
src/content/post/design-style-guide-retro-and-vintage/03.jpg
Normal file
|
After Width: | Height: | Size: 1.2 MiB |