Files
portfolio/repomix-output.xml
vorpax f05aa5a4df
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m11s
add wiki and status
2026-01-25 21:53:36 +01:00

5935 lines
223 KiB
XML
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<file_summary> This section contains a summary of this file. <purpose>
This file contains a packed representation of the entire repository's contents.
It is designed to be easily consumable by AI systems for analysis, code review,
or other automated processes.
</purpose>
<file_format>
The content is organized as follows:
1. This summary section
2. Repository information
3. Directory structure
4. Repository files (if enabled)
5. Multiple file entries, each consisting of:
- File path as an attribute
- Full contents of the file
</file_format>
<usage_guidelines>
- This file should be treated as read-only. Any changes should be made to the
original repository files, not this packed version.
- When processing this file, use the file path to distinguish
between different files in the repository.
- Be aware that this file may contain sensitive information. Handle it with
the same level of security as you would the original repository.
</usage_guidelines>
<notes>
- Some files may have been excluded based on .gitignore rules and Repomix's configuration
- Binary files are not included in this packed representation. Please refer to the Repository
Structure section for a complete list of file paths, including binary files
- Files matching patterns in .gitignore are excluded
- Files matching default ignore patterns are excluded
- Files are sorted by Git change count (files with more changes are at the bottom)
</notes>
</file_summary>
<directory_structure>
.gitea/
workflows/
build-image.yml
debug.yml
assets/
homelab.png
lab.png
logo.png
public/
assets/
blog/
cover-brutalism.jpg
cover-dreamcore.jpg
cover-free-icon-library.jpg
cover-neobrutalism.jpg
cover-portfolio-site.jpg
cover-retro.jpg
cover-retrofuturism.jpg
cover-spacepunk.jpg
experiences/
company.jpg
home/
color-picker.svg
gradientshub.jpg
social/
social-behance.jpg
social-dribbble.jpg
social-email.jpg
social-figma.jpg
social-gitea.png
social-github.jpg
social-gumroad.jpg
social-linkedin.png
social-twitter.jpg
social-xiaohongshu.jpg
stack/
astro.png
bootstrap.png
cloudflare.png
css.png
github.png
html.png
js.png
netlify.png
nextjs.png
nodejs.png
npm.png
nuxtjs.png
tailwind.png
vercel.png
vscode.png
tools/
game/
01-game-cassette.png
02-game-cassette.png
03-game-cassette.png
04-game-cassette.png
05-game-cassette.png
logo/
ricoui.png
tool-gradienthub.svg
tool-inspoweb.png
tool-ricoog.svg
tool-todo.png
tool-uiuxdeck.svg
deck.png
dreamcore.jpg
dreamcore.mp4
keyboard.png
mac.png
mini-btn.png
retro-computer.png
retro-computer.svg
spin-2.png
spin-bar.png
spin-bar.svg
spin.png
spin.svg
tool-icon-bg.png
works/
3d-valentines.jpg
3d-valentines.mp4
gradientshub.jpg
gradientshub.mp4
inspoweb.jpg
luonmodels.jpg
luonmodels.mp4
ricoog.jpg
ricoog.mp4
ricoui.jpg
ricoui.mp4
uiuxdeck.jpg
avatar.png
freelance.png
hec-ia.png
homelab.png
logo copy.png
logo.jpg
logo.png
fonts/
favicon.png
InstrumentSerif-Italic.woff2
InstrumentSerif-Regular.woff2
Inter-Variable.woff2
og.jpg
robots.txt
src/
assets/
js/
main.js
freelance.png
homelab.png
logo.jpg
logo.png
collections/
experiences.json
featuredwork.json
menu.json
social.json
works.json
components/
cards/
BlogCard.astro
SocialCard.astro
WorkCard.astro
elements/
AboutExperience.astro
PageHeader.astro
SectionHeader.astro
SeparatorLine.astro
home/
HeroCard.astro
sections/
BlogSection.astro
Explore.astro
FeaturedWork.astro
Footer.astro
Header.astro
WorksSection.astro
ui/
AnimatedText.astro
Button.astro
Logo.astro
Matter.astro
Tools.astro
TopBg.astro
widgets/
ActionBar.astro
Meta.astro
OptimizedImage.astro
Pagination.astro
Toc.astro
ToTop.astro
TrackGa.astro
config/
site.js
layouts/
Layout.astro
Meta.astro
PageLayout.astro
PostLayout.astro
pages/
blog/
page/
[page].astro
[...slug].astro
index.astro
work/
freelance.astro
hec-ia.astro
homelab.astro
404.astro
about.astro
index.astro
infrastructure.astro
rss.xml.js
works.astro
styles/
aos-custom.css
article-enhancements.css
article.css
global.css
types/
matter.d.ts
content.config.js
env.d.ts
.editorconfig
.env.example
.gitignore
astro.config.mjs
CONTRIBUTING.md
docker-compose.yml
Dockerfile
nginx.conf
package.json
pnpm-workspace.yaml
README-zh.md
README.md
tailwind.config.mjs
tsconfig.json
</directory_structure>
<files> This section contains the contents of the repository's files. <file
path=".gitea/workflows/debug.yml">
name: Debug Context
on: workflow_dispatch
jobs:
debug:
runs-on: ubuntu-latest
steps:
- name: Print Gitea context
run: |
echo "server_url: ${{ gitea.server_url }}"
echo "api_url: ${{ gitea.api_url }}"
echo "repository: ${{ gitea.repository }}"
</file>
<file
path="public/robots.txt">
User-agent: *
Allow: /
</file>
<file path="src/assets/js/main.js"> // 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");
};
</file>
<file
path="src/components/cards/BlogCard.astro"> --- 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">
</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">
{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>
</a>
</div>
</div>
</article>
</file>
<file
path="src/components/cards/SocialCard.astro"> --- 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>
</file>
<file
path="src/components/cards/WorkCard.astro"> --- 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> )} </file>
<file
path="src/components/elements/AboutExperience.astro"> --- 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>
</file>
<file
path="src/components/elements/PageHeader.astro"> --- 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>
</file>
<file
path="src/components/elements/SeparatorLine.astro"> --- 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>
</file>
<file
path="src/components/home/HeroCard.astro"> --- // Define properties accepted by the component
interface Props { imageUrl: string; // Required title?: string; // Optional, default "Home" link?:
string; // Optional, can be empty } // Set default values and receive incoming properties const {
imageUrl, title = "Home", link = "" } = Astro.props; --- <div
class="relative z-20 w-full web-case" data-href= {link}>
<div
class="case-solid relative w-full h-full p-3 border-3 border-solid border-primary dark:border-primary"
>
<div
class="case-square absolute top-[-5px] left-[-5px] border-2 border-solid border-primary dark:border-primary"></div>
<div
class="case-square absolute top-[-5px] right-[-5px] border-2 border-solid border-primary dark:border-primary"></div>
<div
class="case-square absolute bottom-[-5px] left-[-5px] border-2 border-solid border-primary dark:border-primary">
</div>
<div
class="case-square absolute bottom-[-5px] right-[-5px] border-2 border-solid border-primary dark:border-primary"></div>
<div class="relative z-20 w-full">
<div class="relative top-0 left-0 w-full aspect-[6/7] overflow-hidden rounded-xl case-go">
<img
src= {imageUrl}
loading="eager"
decoding="auto"
class="absolute top-0 left-0 right-0 z-30 w-full mx-auto object-cover"
/>
</div>
</div>
</div>
<div>
<svg width="200" height="43" viewBox="0 0 200 43" fill="none"
xmlns="http://www.w3.org/2000/svg"
class="pointer-events-none color-picker absolute left-[-20px] top-[45%] rounded-[10px] bg-white overflow-hidden z-30 max-w-[40%]"
style="box-shadow: -2px 4px 12px rgba(81, 74, 163, 0.1);">
<rect width="200" height="43" rx="10" fill="white" />
<mask id="mask0_458_539" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="42" y="15"
width="112" height="16">
<path
d="M44.6391 27.1243H42.3858V16.3243H50.1324V18.2977H44.6391V20.6977H49.6791V22.671H44.6391V27.1243ZM54.5597 27.271C53.4486 27.271 52.5953 26.9777 51.9997 26.391C51.4042 25.8043 51.1064 25.0043 51.1064 23.991V19.1243H53.3197V23.5643C53.3197 24.7199 53.9153 25.2977 55.1064 25.2977C55.7197 25.2977 56.1908 25.1421 56.5197 24.831C56.8575 24.5199 57.0264 24.0754 57.0264 23.4977V19.1243H59.2397V24.9377H60.3331V27.1243H58.0931V25.8177H57.5331C57.2131 26.2888 56.822 26.6488 56.3597 26.8977C55.8975 27.1466 55.2975 27.271 54.5597 27.271ZM61.787 27.1243V21.311H60.6937V19.1243H62.9337V20.431H63.4937C63.8048 19.9777 64.1959 19.6221 64.667 19.3643C65.1381 19.1066 65.7248 18.9777 66.427 18.9777C67.5381 18.9777 68.3959 19.2843 69.0004 19.8977C69.6137 20.5021 69.9204 21.3154 69.9204 22.3377V27.1243H67.707V22.751C67.707 21.551 67.0892 20.951 65.8537 20.951C64.6181 20.951 64.0004 21.551 64.0004 22.751V27.1243H61.787ZM72.0933 27.1243V21.311H70.9999V19.1243H73.2399V20.431H73.7999C74.1111 19.9777 74.5022 19.6221 74.9733 19.3643C75.4444 19.1066 76.0311 18.9777 76.7333 18.9777C77.8444 18.9777 78.7022 19.2843 79.3066 19.8977C79.9199 20.5021 80.2266 21.3154 80.2266 22.3377V27.1243H78.0133V22.751C78.0133 21.551 77.3955 20.951 76.1599 20.951C74.9244 20.951 74.3066 21.551 74.3066 22.751V27.1243H72.0933ZM85.6395 27.2843C84.7684 27.2843 84.0129 27.1154 83.3729 26.7777C82.7417 26.431 82.2529 25.9466 81.9062 25.3243C81.5595 24.6932 81.3862 23.951 81.3862 23.0977C81.3862 22.2532 81.5551 21.5243 81.8929 20.911C82.2306 20.2888 82.7106 19.8088 83.3329 19.471C83.964 19.1332 84.7062 18.9643 85.5595 18.9643C86.8129 18.9643 87.7951 19.3199 88.5062 20.031C89.2173 20.7421 89.5729 21.7154 89.5729 22.951V23.6977H83.5062C83.5951 24.2932 83.8173 24.7466 84.1729 25.0577C84.5373 25.3688 85.0217 25.5243 85.6262 25.5243C86.5329 25.5243 87.1017 25.1999 87.3329 24.551H89.5462C89.3684 25.4043 88.9329 26.0754 88.2395 26.5643C87.5551 27.0443 86.6884 27.2843 85.6395 27.2843ZM83.5595 22.231H87.4529C87.3017 21.2177 86.6751 20.711 85.5729 20.711C84.4706 20.711 83.7995 21.2177 83.5595 22.231ZM90.8287 27.1243V15.9243H93.042V27.1243H90.8287ZM98.6701 27.1243V16.3243H102.923C104.097 16.3243 105.106 16.5421 105.95 16.9777C106.803 17.4132 107.461 18.0354 107.923 18.8443C108.395 19.6532 108.63 20.6132 108.63 21.7243C108.63 22.8266 108.395 23.7821 107.923 24.591C107.461 25.3999 106.803 26.0266 105.95 26.471C105.097 26.9066 104.088 27.1243 102.923 27.1243H98.6701ZM100.923 25.2043H102.923C104.043 25.2043 104.892 24.8888 105.47 24.2577C106.057 23.6177 106.35 22.7732 106.35 21.7243C106.35 20.6666 106.057 19.8221 105.47 19.191C104.892 18.5599 104.043 18.2443 102.923 18.2443H100.923V25.2043ZM110.06 27.1243V19.1243H112.273V27.1243H110.06ZM111.167 18.3377C110.829 18.3377 110.54 18.2221 110.3 17.991C110.069 17.751 109.953 17.4621 109.953 17.1243C109.953 16.7866 110.069 16.5021 110.3 16.271C110.54 16.031 110.829 15.911 111.167 15.911C111.504 15.911 111.789 16.031 112.02 16.271C112.26 16.5021 112.38 16.7866 112.38 17.1243C112.38 17.4621 112.26 17.751 112.02 17.991C111.789 18.2221 111.504 18.3377 111.167 18.3377ZM117.473 27.2843C116.3 27.2843 115.371 27.0399 114.687 26.551C114.011 26.0532 113.647 25.3599 113.593 24.471H115.727C115.798 24.8621 115.98 25.151 116.273 25.3377C116.576 25.5154 117.007 25.6043 117.567 25.6043C118.482 25.6043 118.94 25.351 118.94 24.8443C118.94 24.6399 118.869 24.4799 118.727 24.3643C118.585 24.2399 118.358 24.1466 118.047 24.0843L116.26 23.7377C114.66 23.4354 113.86 22.6843 113.86 21.4843C113.86 20.7199 114.167 20.111 114.78 19.6577C115.393 19.1954 116.242 18.9643 117.327 18.9643C118.402 18.9643 119.256 19.1999 119.887 19.671C120.518 20.1421 120.865 20.7999 120.927 21.6443H118.793C118.705 21.271 118.531 20.9999 118.273 20.831C118.025 20.6621 117.665 20.5777 117.193 20.5777C116.358 20.5777 115.94 20.8177 115.94 21.2977C115.94 21.6443 116.211 21.871 116.753 21.9777L118.58 22.3243C119.416 22.4843 120.029 22.7554 120.42 23.1377C120.811 23.5199 121.007 24.0266 121.007 24.6577C121.007 25.4843 120.7 26.1288 120.087 26.591C119.482 27.0532 118.611 27.2843 117.473 27.2843ZM122.737 30.3243V21.311H121.644V19.1243H123.884V20.431H124.444C124.728 19.9599 125.11 19.5999 125.59 19.351C126.07 19.0932 126.644 18.9643 127.31 18.9643C128.137 18.9643 128.835 19.1377 129.404 19.4843C129.973 19.831 130.404 20.3199 130.697 20.951C130.99 21.5732 131.137 22.2977 131.137 23.1243C131.137 23.9599 130.99 24.6888 130.697 25.311C130.413 25.9332 129.999 26.4177 129.457 26.7643C128.915 27.111 128.261 27.2843 127.497 27.2843C126.91 27.2843 126.404 27.1777 125.977 26.9643C125.559 26.7421 125.235 26.431 125.004 26.031H124.95V30.3243H122.737ZM126.897 25.3643C127.573 25.3643 128.079 25.1643 128.417 24.7643C128.755 24.3643 128.924 23.8177 128.924 23.1243C128.924 22.431 128.755 21.8843 128.417 21.4843C128.079 21.0843 127.573 20.8843 126.897 20.8843C126.301 20.8843 125.826 21.0532 125.47 21.391C125.124 21.7199 124.95 22.1777 124.95 22.7643V23.4843C124.95 24.071 125.124 24.5332 125.47 24.871C125.826 25.1999 126.301 25.3643 126.897 25.3643ZM132.401 27.1243V15.9243H134.614V27.1243H132.401ZM138.894 27.271C137.952 27.271 137.214 27.0443 136.681 26.591C136.147 26.1377 135.881 25.5332 135.881 24.7777C135.881 23.9866 136.156 23.3777 136.707 22.951C137.259 22.5243 138.05 22.311 139.081 22.311H141.374V22.0043C141.374 21.1421 140.859 20.711 139.827 20.711C138.93 20.711 138.387 21.0221 138.201 21.6443H136.001C136.179 20.7643 136.592 20.0977 137.241 19.6443C137.89 19.191 138.752 18.9643 139.827 18.9643C141.01 18.9643 141.916 19.2354 142.547 19.7777C143.179 20.311 143.494 21.0843 143.494 22.0977V24.9377H144.534V27.1243H142.294V25.8177H141.734C141.459 26.2799 141.09 26.6399 140.627 26.8977C140.165 27.1466 139.587 27.271 138.894 27.271ZM139.227 25.591C139.85 25.591 140.361 25.4266 140.761 25.0977C141.17 24.7688 141.374 24.3688 141.374 23.8977V23.7777H139.187C138.387 23.7777 137.987 24.0754 137.987 24.671C137.987 24.9643 138.099 25.191 138.321 25.351C138.543 25.511 138.845 25.591 139.227 25.591ZM145.537 30.3243V28.551H147.297L147.923 27.0577L144.47 19.1243H146.817L149.057 24.511H149.11L151.297 19.1243H153.563L150.137 27.1243L149.483 28.671C149.261 29.1954 148.999 29.5999 148.697 29.8843C148.403 30.1777 147.959 30.3243 147.363 30.3243H145.537Z"
fill="#161A1F" />
</mask>
<g mask="url(#mask0_458_539)">
<rect width="159" height="43" transform="translate(41)" fill="#514AA3" />
</g>
<g opacity="0.4">
<mask id="mask1_458_539" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="8" y="14"
width="26" height="15">
<path
d="M18.1248 14.009H10.8504C10.2991 14.009 9.7705 14.228 9.38072 14.6178C8.99095 15.0076 8.77197 15.5362 8.77197 16.0874V17.6462C8.77197 17.9219 8.88146 18.1862 9.07635 18.3811C9.27124 18.576 9.53556 18.6854 9.81117 18.6854C10.0868 18.6854 10.3511 18.576 10.546 18.3811C10.7409 18.1862 10.8504 17.9219 10.8504 17.6462V16.3472C10.8504 16.2783 10.8777 16.2123 10.9265 16.1635C10.9752 16.1148 11.0413 16.0874 11.1102 16.0874H13.1886C13.2575 16.0874 13.3236 16.1148 13.3723 16.1635C13.421 16.2123 13.4484 16.2783 13.4484 16.3472V26.2196C13.4484 26.2886 13.421 26.3546 13.3723 26.4034C13.3236 26.4521 13.2575 26.4794 13.1886 26.4794H12.4092C12.1336 26.4794 11.8692 26.5889 11.6744 26.7838C11.4795 26.9787 11.37 27.243 11.37 27.5187C11.37 27.7943 11.4795 28.0586 11.6744 28.2535C11.8692 28.4484 12.1336 28.5579 12.4092 28.5579H16.566C16.8416 28.5579 17.1059 28.4484 17.3008 28.2535C17.4957 28.0586 17.6052 27.7943 17.6052 27.5187C17.6052 27.243 17.4957 26.9787 17.3008 26.7838C17.1059 26.5889 16.8416 26.4794 16.566 26.4794H15.7866C15.7177 26.4794 15.6516 26.4521 15.6029 26.4034C15.5542 26.3546 15.5268 26.2886 15.5268 26.2196V16.3472C15.5268 16.2783 15.5542 16.2123 15.6029 16.1635C15.6516 16.1148 15.7177 16.0874 15.7866 16.0874H17.865C17.9339 16.0874 18 16.1148 18.0487 16.1635C18.0974 16.2123 18.1248 16.2783 18.1248 16.3472V17.6462C18.1248 17.9219 18.2343 18.1862 18.4292 18.3811C18.624 18.576 18.8884 18.6854 19.164 18.6854C19.4396 18.6854 19.7039 18.576 19.8988 18.3811C20.0937 18.1862 20.2032 17.9219 20.2032 17.6462V16.0874C20.2032 15.5362 19.9842 15.0076 19.5944 14.6178C19.2047 14.228 18.676 14.009 18.1248 14.009Z"
fill="#929DAB" />
<path
d="M31.1141 14.009H23.8397C23.2884 14.009 22.7598 14.228 22.37 14.6178C21.9802 15.0076 21.7612 15.5362 21.7612 16.0874V17.6462C21.7612 17.9219 21.8707 18.1862 22.0656 18.3811C22.2605 18.576 22.5248 18.6854 22.8004 18.6854C23.0761 18.6854 23.3404 18.576 23.5353 18.3811C23.7302 18.1862 23.8397 17.9219 23.8397 17.6462V16.3472C23.8397 16.2783 23.867 16.2123 23.9157 16.1635C23.9645 16.1148 24.0305 16.0874 24.0995 16.0874H26.1779C26.2468 16.0874 26.3128 16.1148 26.3616 16.1635C26.4103 16.2123 26.4377 16.2783 26.4377 16.3472V26.2196C26.4377 26.2886 26.4103 26.3546 26.3616 26.4034C26.3128 26.4521 26.2468 26.4794 26.1779 26.4794H25.3985C25.1228 26.4794 24.8585 26.5889 24.6636 26.7838C24.4687 26.9787 24.3593 27.243 24.3593 27.5187C24.3593 27.7943 24.4687 28.0586 24.6636 28.2535C24.8585 28.4484 25.1228 28.5579 25.3985 28.5579H29.5553C29.8309 28.5579 30.0952 28.4484 30.2901 28.2535C30.485 28.0586 30.5945 27.7943 30.5945 27.5187C30.5945 27.243 30.485 26.9787 30.2901 26.7838C30.0952 26.5889 29.8309 26.4794 29.5553 26.4794H28.7759C28.707 26.4794 28.6409 26.4521 28.5921 26.4034C28.5434 26.3546 28.5161 26.2886 28.5161 26.2196V16.3472C28.5161 16.2783 28.5434 16.2123 28.5921 16.1635C28.6409 16.1148 28.707 16.0874 28.7759 16.0874H30.8543C30.9232 16.0874 30.9892 16.1148 31.038 16.1635C31.0867 16.2123 31.1141 16.2783 31.1141 16.3472V17.6462C31.1141 17.9219 31.2235 18.1862 31.4184 18.3811C31.6133 18.576 31.8776 18.6854 32.1533 18.6854C32.4289 18.6854 32.6932 18.576 32.8881 18.3811C33.083 18.1862 33.1925 17.9219 33.1925 17.6462V16.0874C33.1925 15.5362 32.9735 15.0076 32.5837 14.6178C32.1939 14.228 31.6653 14.009 31.1141 14.009Z"
fill="#929DAB" />
</mask>
<g mask="url(#mask1_458_539)">
<rect width="28" height="25" transform="translate(9 9)" fill="#001A56" />
</g>
</g>
<mask id="mask2_458_539" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="172" y="15"
width="17" height="13">
<path d="M173.709 22.3152L177.728 26.3835L187.375 16.6164" stroke="#4A3AFF"
stroke-width="2.32184" stroke-linecap="round" stroke-linejoin="round" />
</mask>
<g mask="url(#mask2_458_539)">
<rect width="18" height="15" transform="translate(172 14)" fill="#514AA3" />
</g>
</svg>
</div>
<div>
<svg width="275" class="pointer-events-none color" height="66" viewBox="0 0 275 66"
fill="none" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<rect x="0.5" y="0.5" width="274" height="65" rx="11.5" fill="white" />
<rect x="0.5" y="0.5" width="274" height="65" rx="11.5" stroke="#F7F7F7" />
<rect x="13.4194" y="12.4294" width="40.6958" height="40.6959" rx="12.9702" fill="#5255FF" />
<path
d="M41.7782 25.2216C42.5592 26.0027 42.5592 27.269 41.7782 28.0501L38.9498 30.8785L36.1213 28.0501L38.9498 25.2216C39.7308 24.4406 40.9971 24.4406 41.7782 25.2216ZM43.1924 23.8074C41.6303 22.2453 39.0976 22.2453 37.5355 23.8074L34.7071 26.6359L34.3535 26.2826C33.963 25.8921 33.3299 25.8921 32.9393 26.2826C32.5488 26.6732 32.5488 27.3063 32.9393 27.6969L33.2929 28.0504L27.0782 34.2651C26.5198 34.8235 26.1392 35.5347 25.9843 36.3091L25.6335 38.0633C25.556 38.4504 25.3657 38.806 25.0865 39.0852L24.1005 40.0713C23.71 40.4618 23.71 41.095 24.1005 41.4855L25.5147 42.8997C25.9052 43.2902 26.5384 43.2902 26.9289 42.8997L27.915 41.9137C28.1942 41.6345 28.5498 41.4442 28.9369 41.3667L30.6911 41.0159C31.4655 40.861 32.1767 40.4804 32.7351 39.922L38.9498 33.7073L39.3033 34.0608C39.6938 34.4513 40.327 34.4513 40.7175 34.0608C41.108 33.6703 41.108 33.0371 40.7175 32.6466L40.364 32.2931L43.1924 29.4643C44.7545 27.9022 44.7545 25.3695 43.1924 23.8074ZM34.7071 29.4647L37.5355 32.2931L31.3208 38.5078C31.0416 38.787 30.686 38.9773 30.2989 39.0547L28.5447 39.4056C27.7703 39.5604 27.0591 39.9411 26.5007 40.4995C27.0591 39.9411 27.4398 39.2299 27.5946 38.4555L27.9455 36.7013C28.0229 36.3142 28.2132 35.9586 28.4924 35.6794L34.7071 29.4647Z"
fill="white" />
<rect x="62.8359" y="12.4294" width="200.573" height="14.5342" rx="7.26712"
fill="url(#paint0_linear_458_506)" />
<g filter="url(#filter0_d_458_506)">
<circle cx="187.645" cy="19.4909" r="8.45164" fill="#3354FF" />
<circle cx="187.645" cy="19.4909" r="9.71231" stroke="white" stroke-width="2.52134" />
</g>
<mask id="mask0_458_506" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="179" y="11"
width="18" height="17">
<circle cx="187.645" cy="19.4909" r="8.45164" fill="#5255FF" />
</mask>
<g mask="url(#mask0_458_506)">
<rect width="674.169" height="674.169" transform="translate(-137.097 -305.326)"
fill="#3354FF" />
</g>
<rect x="62.8359" y="38.5911" width="200.573" height="14.5342" rx="7.26712"
fill="url(#pattern0_458_506)" fill-opacity="0.1" />
<mask id="mask1_458_506" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="62" y="38"
width="202" height="16">
<rect x="62.8364" y="38.5911" width="200.573" height="14.5342" rx="7.26712"
fill="url(#paint1_linear_458_506)" />
</mask>
<g mask="url(#mask1_458_506)">
<rect width="674.169" height="674.169" transform="translate(-57.6865 -174.492)"
fill="#3354FF" />
</g>
<g filter="url(#filter1_d_458_506)">
<circle cx="256.142" cy="45.8582" r="7.26712" fill="#3354FF" />
<circle cx="256.142" cy="45.8582" r="8.52779" stroke="white" stroke-width="2.52134" />
</g>
<mask id="mask2_458_506" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="248" y="38"
width="16" height="16">
<circle cx="256.143" cy="45.8582" r="7.26712" fill="#5255FF" />
</mask>
<defs>
<filter id="filter0_d_458_506" x="169.405" y="1.25079" width="36.4805" height="36.4802"
filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix" />
<feColorMatrix in="SourceAlpha" type="matrix"
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha" />
<feOffset />
<feGaussianBlur stdDeviation="3.63356" />
<feComposite in2="hardAlpha" operator="out" />
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.25 0" />
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_458_506" />
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_458_506"
result="shape" />
</filter>
<pattern id="pattern0_458_506" patternContentUnits="objectBoundingBox" width="0.0724638"
height="1">
<use xlink:href="#image0_458_506" transform="scale(0.00113225 0.015625)" />
</pattern>
<filter id="filter1_d_458_506" x="239.086" y="28.8026" width="34.1114" height="34.1111"
filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix" />
<feColorMatrix in="SourceAlpha" type="matrix"
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha" />
<feOffset />
<feGaussianBlur stdDeviation="3.63356" />
<feComposite in2="hardAlpha" operator="out" />
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.3 0" />
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_458_506" />
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_458_506"
result="shape" />
</filter>
<linearGradient id="paint0_linear_458_506" x1="62.8359" y1="12.4294" x2="263.408"
y2="12.4294" gradientUnits="userSpaceOnUse">
<stop stop-color="#FF0000" />
<stop offset="0.145833" stop-color="#FF6B00" />
<stop offset="0.25" stop-color="#FFE600" />
<stop offset="0.401042" stop-color="#24FF00" />
<stop offset="0.515625" stop-color="#00FFF0" />
<stop offset="0.661458" stop-color="#0029FF" />
<stop offset="0.734375" stop-color="#8F00FF" stop-opacity="0.784615" />
<stop offset="0.869792" stop-color="#FF00E5" />
<stop offset="1" stop-color="#FF0000" />
</linearGradient>
<linearGradient id="paint1_linear_458_506" x1="62.8364" y1="45.8582" x2="263.409"
y2="45.8582" gradientUnits="userSpaceOnUse">
<stop stop-color="#C4C4C4" stop-opacity="0" />
<stop offset="1" stop-color="#5E5E5E" />
</linearGradient>
<image id="image0_458_506" width="64" height="64" preserveAspectRatio="none"
xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAAAXNSR0IArs4c6QAAAONJREFUeF7t20EOhEAIRFG4/6F7DvEnYeFzryQIv6pBd2behOu9dPvsbog+k+NLgArQAqmJcw9iAAhSgZKB3IJkkAySQTJ4CiE+gA8oBeg0mH3Ai084P89HhqwEqIA209ICsQdjAeaZIgaAYKxBDMCAYy8fXwAIgiAIcoJpJEYGI4VjB3YrbC9gL2AvkCB43cM5PgZgAAZgQFnNZAhdGykQBEEQBEEQDBmgAm2glM/z+QUYisYUGoldO7kY32IEAzCg6RgIRgjFAsw+AgRBMNYgBmCAT2TCYfoPPz/HCqQCX1eBHzHnv7C7WhBSAAAAAElFTkSuQmCC" />
</defs>
</svg>
</div>
{title && ( <div
class="absolute left-[32px] text-center top-[-22px] h-[22px] leading-[22px] w-auto px-3 overflow-hidden rounded-tl-[6px] rounded-tr-[6px] bg-primary dark:bg-primary"
>
<p
class="text-[11px] font-normal text-center text-white"
>
{title}
</p>
</div> )} <div class="cursor-indicator">
<svg xmlns="http://www.w3.org/2000/svg" width="28" height="28" viewBox="0 0 24 24"
fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"
stroke-linejoin="round" class="arrow-icon">
<line x1="7" y1="17" x2="17" y2="7"></line>
<polyline points="7 7 17 7 17 17"></polyline>
</svg>
</div>
</div>
<style>
.color {
box-shadow: -2px 4px 12px rgba(81, 74, 163, 0.1);
position: absolute;
right: -36px;
top:65%;
max-width:50%;
z-index:30;
border-radius:10px;
}
@media (max-width: 768px) {
.color {
right: -16px;
}
}
.case-square {
background:#fff;
height:12px;
width:12px;
}
/* Add styles for parallax effect */
.web-case, .color-picker, .color {
transition: transform 0.3s cubic-bezier(0.16, 1, 0.3, 1);
will-change: transform;
}
/* Hover style */
.case-go {
position: relative;
cursor: pointer; /* Default use pointer style */
}
/* When custom cursor is active, hide default mouse */
.case-go.cursor-active {
cursor: none !important;
}
/* Use global style to hide default mouse */
html.cursor-hidden .case-go {
cursor: none !important;
}
.cursor-indicator {
position: absolute; /* Relative to parent container */
top: 0;
left: 0;
background-color: white;
width: 56px;
height: 56px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 3px 15px rgba(0, 0, 0, 0.15);
z-index: 1000;
opacity: 0;
pointer-events: none;
transform: translate(-50%, -50%) scale(0.5);
transition: opacity 0.4s cubic-bezier(0.16, 1, 0.3, 1),
transform 0.5s cubic-bezier(0.34, 1.56, 0.64, 1);
}
.arrow-icon {
color: var(--color-primary);
width: 24px;
height: 24px;
}
/* Limit the maximum movement range of parallax effect */
@media (min-width: 1400px) {
.web-case {
overflow: visible; /* Allow parallax effect to exceed container */
max-width: 100%;
}
}
@media (min-width: 960px) {
.web-case {
overflow: visible; /* Allow parallax effect to exceed container */
max-width: 90%;
}
}
@media (min-width: 768px) {
.web-case {
overflow: visible; /* Allow parallax effect to exceed container */
max-width: 100%;
}
.color-picker {
left: -40px; /* Adjust position, prevent exceeding */
max-width: 35%;
}
.color {
right: -25px; /* Adjust position, prevent exceeding */
max-width: 45%;
}
}
</style>
<script>
document.addEventListener('DOMContentLoaded', () => { // Get all needed elements const webCase =
document.querySelector('.web-case') as HTMLElement; const colorPicker =
document.querySelector('.color-picker') as HTMLElement; const color =
document.querySelector('.color') as HTMLElement; const cursorIndicator =
document.querySelector('.cursor-indicator') as HTMLElement; const caseGo =
document.querySelector('.case-go') as HTMLElement; const html = document.documentElement; // Check
if on mobile device const isMobile = window.innerWidth < 768;
// If on mobile device, do not enable parallax and custom cursor effect
if (isMobile) {
return;
}
// Strength coefficient of parallax effect
const webCaseStrength = 25;
const colorPickerStrength = 40;
const colorStrength = 50;
// Global variables, used to store mouse position and animation state
let mouseX = 0;
let mouseY = 0;
let targetX = 0;
let targetY = 0;
let cursorX = 0;
let cursorY = 0;
let isHovering = false;
// Smooth follow interpolation coefficient
const cursorEasing = 0.12;
const parallaxEasing = 0.08;
// Listen for global mouse movement - parallax effect
document.addEventListener('mousemove', (e: MouseEvent) => { // Get the position relative to the
viewport center (normalized to -0.5 ~ 0.5) mouseX = (e.clientX / window.innerWidth) - 0.5; mouseY
= (e.clientY / window.innerHeight) - 0.5; }); // Handle mouse events for clickable areas
separately - custom cursor if (caseGo) { // Mouse enters clickable area
caseGo.addEventListener('mouseenter', () => { isHovering = true;
html.classList.add('cursor-hidden'); if (cursorIndicator) { cursorIndicator.style.opacity = '1';
cursorIndicator.style.transform = 'translate(-50%, -50%) scale(1)'; } }); // Mouse leaves
clickable area caseGo.addEventListener('mouseleave', () => { isHovering = false;
html.classList.remove('cursor-hidden'); if (cursorIndicator) { cursorIndicator.style.opacity =
'0'; cursorIndicator.style.transform = 'translate(-50%, -50%) scale(0.5)'; } }); // Mouse moves in
clickable area caseGo.addEventListener('mousemove', (e: MouseEvent) => { if (!isHovering) return;
// Get the position relative to the clickable area const rect = caseGo.getBoundingClientRect();
targetX = e.clientX - rect.left; targetY = e.clientY - rect.top; }); // Click event
caseGo.addEventListener('click', () => { const href = webCase?.getAttribute('data-href'); if
(href) { window.open(href, '_blank'); } }); } // Separate animation loop - parallax effect
function updateParallax() { // Update parallax effect of main card if (webCase) { const
targetWebCaseX = mouseX * webCaseStrength; const targetWebCaseY = mouseY * webCaseStrength; // Get
current transformation matrix const currentTransform = new
DOMMatrix(getComputedStyle(webCase).transform); const currentX = currentTransform.m41 || 0; const
currentY = currentTransform.m42 || 0; // Smooth transition const newX = currentX + (targetWebCaseX
- currentX) * parallaxEasing; const newY = currentY + (targetWebCaseY - currentY) *
parallaxEasing; webCase.style.transform = `translate3d(${newX}px, ${newY}px, 0)`; } // Update
parallax effect of color picker if (colorPicker) { const targetColorPickerX = mouseX *
colorPickerStrength; const targetColorPickerY = mouseY * colorPickerStrength; const
currentTransform = new DOMMatrix(getComputedStyle(colorPicker).transform); const currentX =
currentTransform.m41 || 0; const currentY = currentTransform.m42 || 0; const newX = currentX +
(targetColorPickerX - currentX) * parallaxEasing; const newY = currentY + (targetColorPickerY -
currentY) * parallaxEasing; colorPicker.style.transform = `translate3d(${newX}px, ${newY}px, 0)`;
} // Update parallax effect of color bar if (color) { const targetColorX = mouseX * colorStrength;
const targetColorY = mouseY * colorStrength; const currentTransform = new
DOMMatrix(getComputedStyle(color).transform); const currentX = currentTransform.m41 || 0; const
currentY = currentTransform.m42 || 0; const newX = currentX + (targetColorX - currentX) *
parallaxEasing; const newY = currentY + (targetColorY - currentY) * parallaxEasing;
color.style.transform = `translate3d(${newX}px, ${newY}px, 0)`; }
requestAnimationFrame(updateParallax); } // Separate animation loop - custom cursor function
updateCursor() { if (isHovering && cursorIndicator) { // Smooth follow cursorX += (targetX -
cursorX) * cursorEasing; cursorY += (targetY - cursorY) * cursorEasing; cursorIndicator.style.left
= `${cursorX}px`; cursorIndicator.style.top = `${cursorY}px`; }
requestAnimationFrame(updateCursor); } // Start two separate animation loops updateParallax();
updateCursor(); });
</script>
</file>
<file
path="src/components/sections/FeaturedWork.astro"> --- 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>
</file>
<file path="src/components/sections/Footer.astro"> --- 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>
</file>
<file
path="src/components/sections/WorksSection.astro"> --- 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>
</file>
<file
path="src/components/ui/AnimatedText.astro"> --- 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>
</file>
<file path="src/components/ui/Tools.astro">
<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>
</file>
<file
path="src/components/widgets/Meta.astro"> --- 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" />
</file>
<file
path="src/components/widgets/OptimizedImage.astro"> --- /** * 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
}
</file>
<file path="
src/ components/ widgets/ Pagination.astro">
---
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>
</file>
<file path=" src/ components/ widgets/ Toc.astro">
---
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>
</file>
<file path=" src/ components/ widgets/ ToTop.astro">
<!-- 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>
</file>
<file path=" src/ components/ widgets/ TrackGa.astro">
---
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>
)}
</file>
<file path=" src/ layouts/ Layout.astro">
---
import Meta from './Meta.astro';
import TopBg from " @/ components/ ui/ TopBg.astro";
import Footer from " @/ components/ sections/ Footer.astro";
import Header from " @/ components/ sections/ Header.astro";
import TrackGa from " @/ components/ widgets/ TrackGa.astro";
interface Props {
title?: string | undefined;
description?: string | undefined;
keywords?: string | undefined;
}
const {
title,
description,
keywords
} = Astro.props;
import " ../ styles/ global.css";
import " aos/ dist/ aos.css";
import " ../ styles/ aos-custom.css";
import '../styles/article.css';
import '../styles/article-enhancements.css';
---
<html lang="">
<head>
<meta charset="
UTF-8" />
<Meta
title = {title}
description = {description}
keywords = {keywords}
/>
<TrackGa />
<!-- Used to add dark mode right away, adding here prevents any flicker -->
<script is:inline>
if (typeof Storage !== 'undefined') {
if (
localStorage.getItem('dark_mode') &&
localStorage.getItem('dark_mode') == 'true'
) {
document.documentElement.classList.add('dark')
}
}
</script>
<link rel=" icon" type=" image/ x-icon" href="/ favicon.png" />
</head>
<body class=" antialiased bg-bg-primary dark:bg-bg-primary-dark max-w-full overflow-x-hidden">
<TopBg />
<Header />
<slot />
<Footer />
<script src=" ../ assets/ js/ main.js"></script>
</body>
</html>
</file>
<file path=" src/ layouts/ PageLayout.astro">
---
import Layout from " @/ layouts/ Layout.astro";
interface Props {
title?: string | undefined;
keywords?: string | undefined;
description?: string | undefined;
}
const { title, description,keywords} = Astro.props;
---
<Layout title={title} description={description} keywords={keywords}>
<main class=" post-wrapper">
<slot />
</main>
</Layout>
</file>
<file path=" src/ layouts/ PostLayout.astro">
---
import Layout from " @/ layouts/ Layout.astro";
interface Props {
title?: string | undefined;
keywords?: string | undefined;
description?: string | undefined;
}
const { title, description,keywords} = Astro.props;
---
<Layout title={title} description={description} keywords={keywords}>
<main class=" post-wrapper">
<slot />
</main>
</Layout>
</file>
<file path=" src/ pages/ blog/ [...slug].astro">
---
import { getCollection, render } from " astro:content";
import PostLayout from " @/ layouts/ PostLayout.astro";
import Toc from '@/components/widgets/Toc.astro';
// 根据 Astro v5/v6对于 SSG 模式下的动态路由,需要使用 getStaticPaths()
// 来告诉 Astro 在构建时生成哪些页面
export async function getStaticPaths() {
const postEntries = await getCollection(" post");
return postEntries.map((entry) => {
// 根据 Astro v5 官方文档entry.id 就是条目的唯一标识符
// 对于 [...slug] 路由,需要将 entry.id 转换为 slug 数组
// entry.id 已经是相对于集合根目录的路径,例如 " dir/ index" 或 " dir"
// 注意:对于 [...slug] 路由params.slug 应该是字符串Astro 会自动处理
const slugString = entry.id;
return {
params: { slug: slugString },
props: { entry },
};
});
}
// 使用 [...slug] 路由来处理包含斜杠的路径(官方推荐)
// slug 参数是数组格式,需要转换为字符串来匹配 entry.id
const { slug } = Astro.params;
const slugString = Array.isArray(slug) ? slug.join('/') : (slug || '');
// 从 props 获取 entry在 SSG 模式下通过 getStaticPaths 传递)
const { entry } = Astro.props;
// 如果找不到文章,返回 404
if (!entry) {
return Astro.redirect("/ 404");
}
const { Content, headings } = await render(entry);
const filtered = headings.filter(h => h.depth <= 2);
---
<PostLayout title={entry.data.title} description={entry.data.description}>
<!-- Reading progress bar -->
<div id=" reading-progress" class=" reading-progress" style=" transform: scaleX(0);"></div>
<div class=" post article">
<main class=" relative site-container mx-auto my-8 md:my-12">
<!-- Article header -->
<header class=" article-header">
<!-- Tags -->
<div class=" article-tags">
{entry.data.tags && entry.data.tags.length > 0 ? (
entry.data.tags.map(tag => (
<span class=" article-tag letter-spacing-wide text-xs">{tag}</span>
))
) : (
<span class=" article-tag text-xs">Article</span>
)}
</div>
<!-- Title -->
<h1 class=" article-title font-brand">
{entry.data.title}
</h1>
<!-- Metadata -->
<div class=" article-meta text-sm text-neutral-500 dark:text-neutral-400">
<time datetime={entry.data.publishDate.toISOString()}>
{entry.data.publishDate.toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric'
})}
</time>
{entry.data.read && (
<>
<span class=" article-meta-divider"></span>
<span>{entry.data.read} min read</span>
</>
)}
</div>
</header>
<!-- Content area -->
<div class=" relative flex flex-col lg:flex-row gap-8 lg:gap-12 xl:gap-16">
<!-- Left TOC - Fixed on desktop -->
<aside class=" relative hidden lg:block lg:w-40 xl:w-50 flex-shrink-0">
<div class=" toc-sticky-wrapper">
<Toc headings={filtered} />
</div>
</aside>
<!-- Article content -->
<div class=" relative flex-1 min-w-0 w-full lg:max-w-none">
<article class=" article-wrapper">
<div class=" article-content">
<Content />
</div>
</article>
</div>
</div>
</main>
</div>
</PostLayout>
<style>
.toc-sticky-wrapper {
position: sticky;
top: 8rem;
}
</style>
<script>
// Reading progress bar
function updateReadingProgress() {
const progressBar = document.getElementById('reading-progress');
if (!progressBar) return;
const windowHeight = window.innerHeight;
const documentHeight = document.documentElement.scrollHeight;
const scrollTop = window.scrollY;
const scrollPercentage = (scrollTop / (documentHeight - windowHeight)) * 100;
progressBar.style.transform = `scaleX(${Math.min(scrollPercentage / 100, 1)})`;
}
// Event listener
window.addEventListener('scroll', updateReadingProgress, { passive: true });
// Initialize
document.addEventListener('DOMContentLoaded', updateReadingProgress);
updateReadingProgress();
</script>
</file>
<file path=" src/ pages/ work/ freelance.astro">
---
import PostLayout from " @/ layouts/ PostLayout.astro";
import PageHeader from " @/ components/ elements/ PageHeader.astro";
import SeparatorLine from " @/ components/ elements/ SeparatorLine.astro";
import { siteConfig } from " @/ config/ site.js";
---
<PostLayout
title=" Freelance Development"
description=" Fullstack development and automation services."
keywords=" Freelance, Developer, Python, Golang, Automation, Fullstack"
>
<div class=" work article">
<main class=" work-wrapper">
<PageHeader
title=" Freelance Development"
tags={[" Python", " Golang", " Automation"]}
className=" mb-6 md:mb-8"
>
</PageHeader>
<div class=" site-container m-auto">
<SeparatorLine />
</div>
<section class=" article-content">
<div class=" site-container mx-auto my-8 md:my-16 max-w-3xl">
<div class=" prose dark:prose-invert">
<h2 class=" text-2xl font-brand mb-4 text-neutral-800 dark:text-neutral-100">Stack</h2>
<ul class=" space-y-2 text-neutral-700 dark:text-neutral-300 mb-6">
<li><strong>Backend:</strong> Python, Golang</li>
<li><strong>Automation:</strong> Bash, scripting</li>
<li><strong>Infrastructure:</strong> Docker, Linux</li>
</ul>
<h2 class=" text-2xl font-brand mb-4 mt-8 text-neutral-800 dark:text-neutral-100">Services</h2>
<ul class=" space-y-2 text-neutral-700 dark:text-neutral-300 mb-6">
<li>Web application development</li>
<li>Automation and scripting</li>
<li>API development</li>
<li>DevOps and deployment</li>
</ul>
<h2 class=" text-2xl font-brand mb-4 mt-8 text-neutral-800 dark:text-neutral-100">Contact</h2>
<p class=" text-neutral-700 dark:text-neutral-300 leading-relaxed">
Interested in working together? Reach out at <span class=" text-primary dark:text-primary-light">{siteConfig.mail}</span>
</p>
</div>
</div>
</section>
</main>
</div>
</PostLayout>
</file>
<file path=" src/ pages/ work/ hec-ia.astro">
---
import PostLayout from " @/ layouts/ PostLayout.astro";
import PageHeader from " @/ components/ elements/ PageHeader.astro";
import SeparatorLine from " @/ components/ elements/ SeparatorLine.astro";
---
<PostLayout
title=" HEC IA"
description=" Leading technical AI workshops for business students at HEC Paris."
keywords=" HEC IA, AI, Workshops, Education, HEC Paris"
>
<div class=" work article">
<main class=" work-wrapper">
<PageHeader
title=" HEC IA"
tags={[" Education", " AI", " Workshops"]}
className=" mb-6 md:mb-8"
>
</PageHeader>
<div class=" site-container m-auto">
<SeparatorLine />
</div>
<section class=" article-content">
<div class=" site-container mx-auto my-8 md:my-16 max-w-3xl">
<div class=" prose dark:prose-invert">
<h2 class=" text-2xl font-brand mb-4 text-neutral-800 dark:text-neutral-100">Mission</h2>
<p class=" text-neutral-700 dark:text-neutral-300 leading-relaxed mb-6">
Making AI accessible to business students. Technical workshops designed for non-technical profiles.
</p>
<h2 class=" text-2xl font-brand mb-4 mt-8 text-neutral-800 dark:text-neutral-100">What We Do</h2>
<ul class=" space-y-2 text-neutral-700 dark:text-neutral-300 mb-6">
<li>Hands-on workshops on AI tools</li>
<li>Introduction to agentic AI</li>
<li>Practical ML workflows</li>
</ul>
<h2 class=" text-2xl font-brand mb-4 mt-8 text-neutral-800 dark:text-neutral-100">Impact</h2>
<p class=" text-neutral-700 dark:text-neutral-300 leading-relaxed">
Bridging the gap between business and tech. Helping future managers understand what's possible with AI.
</p>
</div>
</div>
</section>
</main>
</div>
</PostLayout>
</file>
<file path=" src/ pages/ work/ homelab.astro">
---
import PostLayout from " @/ layouts/ PostLayout.astro";
import PageHeader from " @/ components/ elements/ PageHeader.astro";
import SeparatorLine from " @/ components/ elements/ SeparatorLine.astro";
import Button from " @/ components/ ui/ Button.astro";
import { ArrowRight } from " @lucide/ astro";
---
<PostLayout
title=" Homelab Infrastructure"
description=" Self-hosted production-grade infrastructure with Kubernetes, Proxmox, and comprehensive monitoring."
keywords=" Homelab, Infrastructure, Kubernetes, Proxmox, Docker, Self-hosted"
>
<div class=" work article">
<main class=" work-wrapper">
<PageHeader
title=" Homelab Infrastructure"
tags={[" Kubernetes", " Proxmox", " Docker", " Monitoring"]}
className=" mb-6 md:mb-8"
>
</PageHeader>
<div class=" site-container m-auto">
<SeparatorLine />
</div>
<section class=" article-content">
<div class=" site-container mx-auto my-8 md:my-16 max-w-3xl">
<div class=" prose dark:prose-invert">
<h2 class=" text-2xl font-brand mb-4 text-neutral-800 dark:text-neutral-100">The Story</h2>
<p class=" text-neutral-700 dark:text-neutral-300 leading-relaxed mb-6">
What started as a simple Raspberry Pi running Pi-hole evolved into a full production-grade infrastructure. The goal was to learn by doing: understanding how enterprise systems work by building them myself.
</p>
<h2 class=" text-2xl font-brand mb-4 mt-8 text-neutral-800 dark:text-neutral-100">Current Stack</h2>
<ul class=" space-y-2 text-neutral-700 dark:text-neutral-300 mb-6">
<li><strong>Virtualization:</strong> Proxmox VE</li>
<li><strong>Orchestration:</strong> Kubernetes</li>
<li><strong>Containers:</strong> Docker</li>
<li><strong>Monitoring:</strong> Grafana, Prometheus, InfluxDB</li>
<li><strong>Reverse Proxy:</strong> Caddy, FRP</li>
<li><strong>Auth:</strong> Authentik (OIDC)</li>
</ul>
<h2 class=" text-2xl font-brand mb-4 mt-8 text-neutral-800 dark:text-neutral-100">What I Learned</h2>
<p class=" text-neutral-700 dark:text-neutral-300 leading-relaxed mb-6">
Building infrastructure from scratch taught me more than any course could. Debugging network issues at 2am, recovering from failed upgrades, designing for redundancy - these experiences shaped how I approach technical problems.
</p>
<div class=" mt-12">
<Button url="/ infrastructure" type=" fill">
View Full Architecture <ArrowRight size={16} />
</Button>
</div>
</div>
</div>
</section>
</main>
</div>
</PostLayout>
</file>
<file path=" src/ pages/ 404.astro">
---
import PageLayout from " @/ layouts/ PageLayout.astro";
import PageHeader from " @/ components/ elements/ PageHeader.astro";
import Button from " @/ components/ ui/ Button.astro";
---
<PageLayout title=" Page Not Found">
<section class=" site-container m-auto min-h-[50vh] flex flex-col items-center justify-center">
<PageHeader
title=" 404 - Page Not Found"
description=" Button to go back to home page"
/>
<div class=" mt-0 flex justify-center">
<Button url="/">Go Home Page</Button>
</section>
</PageLayout>
</file>
<file path="
src/ pages/ infrastructure.astro">
---
import Layout from " @/ layouts/ Layout.astro";
import { Server, Database, Shield, Globe, Activity, Lock } from " @lucide/ astro";
---
<Layout
title=" Infrastructure"
description=" Technical overview of my homelab infrastructure."
keywords=" Infrastructure, Homelab, Kubernetes, Proxmox, Docker, Monitoring"
>
<div class=" site-container mx-auto my-8 md:my-16 px-4">
<header class=" mb-12">
<h1 class=" text-4xl font-brand mb-4 text-neutral-800 dark:text-neutral-100">Infrastructure</h1>
<p class=" text-neutral-600 dark:text-neutral-400">Technical overview of my homelab stack.</p>
</header>
<div class=" grid gap-8 md:grid-cols-2 lg:grid-cols-3">
<!-- Virtualization -->
<div class=" p-6 rounded-xl bg-neutral-50 dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800">
<div class=" flex items-center gap-3 mb-4">
<Server size={24} class=" text-primary dark:text-primary-light" />
<h2 class=" text-xl font-medium text-neutral-800 dark:text-neutral-100">Virtualization</h2>
</div>
<ul class=" space-y-2 text-neutral-600 dark:text-neutral-400 text-sm">
<li><strong>Proxmox VE</strong> - Hypervisor</li>
<li>Multiple VMs and LXC containers</li>
<li>ZFS storage pools</li>
</ul>
</div>
<!-- Orchestration -->
<div class=" p-6 rounded-xl bg-neutral-50 dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800">
<div class=" flex items-center gap-3 mb-4">
<Database size={24} class=" text-primary dark:text-primary-light" />
<h2 class=" text-xl font-medium text-neutral-800 dark:text-neutral-100">Orchestration</h2>
</div>
<ul class=" space-y-2 text-neutral-600 dark:text-neutral-400 text-sm">
<li><strong>Kubernetes</strong> - Container orchestration</li>
<li><strong>Docker</strong> - Containerization</li>
<li>GitOps deployment workflow</li>
</ul>
</div>
<!-- PKI & Security -->
<div class=" p-6 rounded-xl bg-neutral-50 dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800">
<div class=" flex items-center gap-3 mb-4">
<Shield size={24} class=" text-primary dark:text-primary-light" />
<h2 class=" text-xl font-medium text-neutral-800 dark:text-neutral-100">PKI & Security</h2>
</div>
<ul class=" space-y-2 text-neutral-600 dark:text-neutral-400 text-sm">
<li><strong>step-ca</strong> - Internal Certificate Authority</li>
<li>Automated certificate provisioning</li>
<li>mTLS for service communication</li>
</ul>
</div>
<!-- DNS -->
<div class=" p-6 rounded-xl bg-neutral-50 dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800">
<div class=" flex items-center gap-3 mb-4">
<Globe size={24} class=" text-primary dark:text-primary-light" />
<h2 class=" text-xl font-medium text-neutral-800 dark:text-neutral-100">DNS</h2>
</div>
<ul class=" space-y-2 text-neutral-600 dark:text-neutral-400 text-sm">
<li><strong>Technitium</strong> - DNS Server</li>
<li>Split-horizon DNS</li>
<li>Internal service discovery</li>
</ul>
</div>
<!-- Monitoring -->
<div class=" p-6 rounded-xl bg-neutral-50 dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800">
<div class=" flex items-center gap-3 mb-4">
<Activity size={24} class=" text-primary dark:text-primary-light" />
<h2 class=" text-xl font-medium text-neutral-800 dark:text-neutral-100">Monitoring</h2>
</div>
<ul class=" space-y-2 text-neutral-600 dark:text-neutral-400 text-sm">
<li><strong>Grafana</strong> - Visualization</li>
<li><strong>Prometheus</strong> - Metrics</li>
<li><strong>InfluxDB</strong> - Time-series data</li>
</ul>
</div>
<!-- Access & Auth -->
<div class=" p-6 rounded-xl bg-neutral-50 dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800">
<div class=" flex items-center gap-3 mb-4">
<Lock size={24} class=" text-primary dark:text-primary-light" />
<h2 class=" text-xl font-medium text-neutral-800 dark:text-neutral-100">Access & Auth</h2>
</div>
<ul class=" space-y-2 text-neutral-600 dark:text-neutral-400 text-sm">
<li><strong>Authentik</strong> - Identity Provider</li>
<li>OIDC/SAML SSO</li>
<li><strong>Caddy</strong> - Reverse proxy</li>
<li><strong>FRP</strong> - Tunnel access</li>
</ul>
</div>
</div>
<div class=" mt-12 p-6 rounded-xl bg-neutral-100 dark:bg-neutral-800/ 50 border border-neutral-200 dark:border-neutral-700">
<h2 class=" text-xl font-medium mb-4 text-neutral-800 dark:text-neutral-100">Architecture Notes</h2>
<p class=" text-neutral-600 dark:text-neutral-400 text-sm leading-relaxed">
Everything runs on-premise. Services communicate over an internal network with mTLS. External access goes through authenticated reverse proxy. Monitoring covers infrastructure and application metrics.
</p>
</div>
<div class=" mt-8">
<a href="/ work/ homelab" class=" text-primary dark:text-primary-light hover:underline text-sm">
&larr; Back to Homelab project
</a>
</div>
</div>
</Layout>
</file>
<file path=" src/ pages/ works.astro">
---
import WorksSection from " @/ components/ sections/ WorksSection.astro";
import PageLayout from " @/ layouts/ PageLayout.astro";
import PageHeader from " @/ components/ elements/ PageHeader.astro";
---
<PageLayout title=" My Works">
<section class=" site-container m-auto">
<PageHeader
title=" Works"
description=" Here are some of my recent projects. I'm always working on something new, so check back often!"
/>
<WorksSection />
</section>
</PageLayout>
</file>
<file path="src/content.config.js">
// 1. 从 `astro:content` 导入工具函数
import { defineCollection, z } from ' astro:content';
// 2. 导入加载器
import { glob } from ' astro/ loaders';
// 3. 定义你的集合
const post = defineCollection({
// 使用 glob 加载器从 src/content/post 目录加载所有 .mdx 文件
loader: glob({ pattern: ' **/ *.{md,mdx}', base: ' ./ src/ content/ post' }),
// Type-check frontmatter using a schema
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(),
}),
});
// 4. 导出一个 `collections` 对象来注册你的集合
export const collections = { post };
</file>
<file path="src/env.d.ts">
/// <reference path="../.astro/types.d.ts" />
/// <reference types="astro/client" />
</file>
<file path=".editorconfig">
# EditorConfig is awesome: https://EditorConfig.org
# top-most EditorConfig file
root = true
[*]
indent_style = space
indent_size = 2
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = false
insert_final_newline = false
</file>
<file path=".env.example">
# Site Configuration
PUBLIC_SITE_URL=https://your-domain.com
# Analytics
# Google Analytics 4 ID (鏍煎紡: G-XXXXXXXXXX)
PUBLIC_GA4_ID=
# Umami Analytics ID (鏍煎紡: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx)
PUBLIC_UMAMI_ID=
</file>
<file path=".gitignore">
# build output
dist/
# generated types
.astro/
# dependencies
node_modules/
# logs
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# environment variables
.env
.env.local
.env.production
.env.production.local
# macOS-specific files
.DS_Store
# IDE
.idea/
.vscode/
*.swp
*.swo
*~
# OS
Thumbs.db
.DS_Store
# Temporary files
*.tmp
*.temp
.cache/
</file>
<file path="CONTRIBUTING.md">
# 贡献指南
感谢你对这个项目的关注!我们欢迎所有形式的贡献。
## 如何贡献
### 报告 Bug
如果你发现了 bug
1. 查看 [Issues](https://github.com/ricocc/ricoui-portfolio/issues) 确认该 bug 尚未被报告
2. 如果是一个新的 bug请创建一个新的 Issue包含
- 清晰的问题描述
- 复现步骤
- 预期行为
- 实际行为
- 截图(如果有)
- 环境信息(浏览器、操作系统等)
### 提出新功能
如果你有改进建议或新功能想法:
1. 查看现有 Issues 确认没有类似提议
2. 创建一个新的 Feature Request Issue描述
- 功能描述
- 使用场景
- 可能的实现方案(可选)
### 提交代码
1. **Fork 项目**
```bash
git clone https://github.com/your-username/ricoui-portfolio.git
cd ricoui-portfolio
```
2. **创建分支**
```bash
git checkout -b feature/your-feature-name
# 或
git checkout -b fix/your-bug-fix
```
3. **安装依赖**
```bash
pnpm install
```
4. **进行修改**
- 编写代码
- 确保代码通过检查:`pnpm check`
- 测试你的修改
5. **提交更改**
```bash
git add .
git commit -m "feat: 添加新功能描述"
# 或
git commit -m "fix: 修复bug描述"
```
提交信息请遵循 [Conventional Commits](https://www.conventionalcommits.org/) 规范:
- `feat:` 新功能
- `fix:` Bug 修复
- `docs:` 文档更新
- `style:` 代码格式调整(不影响功能)
- `refactor:` 代码重构
- `perf:` 性能优化
- `test:` 测试相关
- `chore:` 构建/工具相关
6. **推送并创建 Pull Request**
```bash
git push origin feature/your-feature-name
```
然后在 GitHub 上创建 Pull Request。
## 代码规范
- 使用 Biome 进行代码检查和格式化
- 运行 `pnpm check` 确保代码符合规范
- 保持代码简洁、可读
- 添加必要的注释
## 开发环境
- Node.js >= 18
- pnpm (推荐) 或 npm/yarn
- 现代浏览器Chrome、Firefox、Safari、Edge
## 测试
在提交 PR 前,请确保:
- [ ] 代码通过 `pnpm check`
- [ ] 在本地运行 `pnpm dev` 测试正常
- [ ] 构建通过 `pnpm build`
- [ ] 测试了不同的浏览器和设备
## 问题?
如果遇到问题,可以:
- 查看 [Issues](https://github.com/ricocc/ricoui-portfolio/issues)
- 创建新的 Issue 提问
- 联系维护者hello@ricoui.com
再次感谢你的贡献!🎉
</file>
<file path="docker-compose.yml">
services:
portfolio:
build: .
ports:
- "8080:80"
restart: unless-stopped
</file>
<file path="Dockerfile">
# Stage 1: Build
FROM node:20-alpine AS builder
WORKDIR /app
# Install pnpm
RUN corepack enable && corepack prepare pnpm@latest --activate
# Copy package files
COPY package.json pnpm-lock.yaml ./
# Install dependencies
RUN pnpm install --frozen-lockfile
# Copy source files
COPY . .
# Build the site
RUN pnpm build
# Stage 2: Serve with nginx
FROM nginx:alpine AS runner
# Copy built files
COPY --from=builder /app/dist /usr/share/nginx/html
# Copy nginx configuration
COPY nginx.conf /etc/nginx/conf.d/default.conf
# Expose port
EXPOSE 80
# Start nginx
CMD ["nginx", "-g", "daemon off;"]
</file>
<file path="nginx.conf">
server {
listen 80;
listen [::]:80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# Security headers
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
# Gzip compression
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_proxied any;
gzip_types
text/plain
text/css
text/xml
text/javascript
application/javascript
application/json
application/xml
application/rss+xml
application/atom+xml
image/svg+xml;
# Static file caching
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 1y;
add_header Cache-Control "public, immutable";
access_log off;
}
# HTML files - no cache for dynamic updates
location ~* \.html$ {
expires -1;
add_header Cache-Control "no-store, no-cache, must-revalidate";
}
# Main location block
location / {
try_files $uri $uri/ $uri.html =404;
}
# Custom error pages
error_page 404 /404.html;
location = /404.html {
internal;
}
}
</file>
<file path="pnpm-workspace.yaml">
ignoredBuiltDependencies:
- ' @biomejs/ biome'
- ' @tailwindcss/ oxide'
- esbuild
- sharp
</file>
<file path="tailwind.config.mjs">
/** @type {import(' tailwindcss').Config} */
export default {
darkMode: "class",
content: ["./src/**/*.{astro,html,js,jsx,md,mdx,svelte,ts,tsx,vue}"],
theme: {
extend: {
},
},
plugins: [require("@tailwindcss/typography")],
};
</file>
<file path="tsconfig.json">
{
"extends": "astro/tsconfigs/base",
"compilerOptions": {
"strictNullChecks": true,
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
},
"typeRoots": ["./node_modules/@types", "./src/types",".vscode", "dist"]
}
}
</file>
<file path="src/collections/menu.json">
[
{
"name": "Home",
"url": "/"
},
{
"name": "Projects",
"url": "/works"
},
{
"name": "About",
"url": "/about"
}
]
</file>
<file path="src/components/elements/SectionHeader.astro">
---
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-4xl font-brand text-center tracking-normal sm: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>
</file>
<file path="src/components/sections/BlogSection.astro">
---
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 => {
// 根据 Astro v5 官方文档entry.id 就是条目的唯一标识符
// 直接使用 entry.id 生成 URL对于 [...slug] 路由id 已经是正确的路径
const link = `/blog/${post.id}`;
return {
...post,
data: {
...post.data,
link
}
};
});
// 为特色文章创建带链接的版本
const featuredPostWithLink = featuredPost ? {
...featuredPost,
data: {
...featuredPost.data,
link: `/blog/${featuredPost.id}`
}
} : 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>
</file>
<file path="src/components/ui/Button.astro">
---
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-primary/20 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-neutral-700 dark:text-neutral-300`}>
<slot />
</div>
) : type === "fill" ? (
<span class={`flex gap-1 items-center justify-center ${size === "md" ? "text-sm" : currentSizeClasses.fontSize} font-medium text-center text-neutral-50`}>
<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>
</file>
<file path="src/components/ui/Logo.astro">
<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>
</file>
<file path="src/components/ui/Matter.astro">
---
---
<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";
(() => {
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;
}
// Config (enlarge element size & reduce edge gaps)
const pad = 2; // Inner padding; smaller value sticks closer to edges
const margin = 1; // Distance between static boundaries and container edges; smaller value sticks closer
const count = 15; // Number of elements
const noRotate = false; // Set to true to disable rotation
const scaleFactor = 0.75; // Global scaling factor: >1 enlarge, <1 shrink
// Dimensions
let { width: cw, height: ch } = container.getBoundingClientRect();
let W = Math.max(cw - pad, 100);
let H = Math.max(ch - pad, 100);
// Physics engine
const engine = Matter.Engine.create();
const world = engine.world;
// Mouse drag (events fall on canvasHost)
const mouseConstraint = Matter.MouseConstraint.create(engine, {
mouse: Matter.Mouse.create(canvasHost),
constraint: { render: { visible: false }, stiffness: 1 },
});
Matter.World.add(world, mouseConstraint);
// Boundaries (ground and side walls)
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]);
// Icon paths (adjust as needed)
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",
];
// Preload images to obtain naturalWidth/Height
function loadImage(url: string) {
return new Promise<HTMLImageElement>((resolve, reject) => {
const img = new Image();
img.decoding = "async";
img.loading = "eager"; // Load as soon as lazy-load is triggered
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);
}
// Sizing strategy: significantly increase target area range (adapted for 720x480)
function computeAreaRange() {
// Use container area as the base; increase icon area proportion
// 720*480 ≈ 345600; here a single icon area is roughly between 1/20 ~ 1/10 of the container area
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();
// Icon rigid body class (rectangle, size based on 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;
// Target area (random within range)
const A = randRange(areaMin, areaMax);
// Derive width/height: w = sqrt(A*r), h = w/r
let w = Math.sqrt(A * r);
let h = w / r;
// Global scale up or down
w *= scaleFactor;
h *= scaleFactor;
this.w = w;
this.h = h;
// Physics rectangle body
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, // No rotation: set infinite inertia
});
// DOM: container + 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"; // or "cover"
node.alt = node.alt || "icon";
this.el.appendChild(node);
domLayer.appendChild(this.el); // domLayer is ensured to be non-null here
}
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)`;
}
}
}
// Create icon rigid bodies
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));
// Start engine
const runner = Matter.Runner.create();
Matter.Runner.run(runner, engine);
// After each physics step, sync DOM
Matter.Events.on(engine, "afterUpdate", () => {
iconsBodies.forEach((it) => it.update());
});
// Responsive sizing: update boundary positions and recompute area range
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;
});
}
// Utility functions (also usable outside the 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>
</file>
<file path="src/components/ui/TopBg.astro">
<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>
</file>
<file path="src/components/widgets/ActionBar.astro">
---
// 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-1 sm:gap-2 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-10 sm:w-10 rounded-full 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-1 sm:gap-1 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 text-sm hover:bg-yellow-200 transition-colors whitespace-nowrap"
>
{visitLabel}
</a>
</div>
</div>
</div>
</file>
<file path="src/layouts/Meta.astro">
---
import { siteConfig } from ' @/ config/ site.js';
export interface Props {
title?: string;
description?: string;
keywords?: string;
url?: string;
ogImage?: string;
twitterHandle?: string;
}
const {
title,
description,
keywords = "",
url = siteConfig.url, //
ogImage = "/og.jpg",
twitterHandle = siteConfig.social.twitterName || '',
} = Astro.props;
---
<!-- Meta Tags -->
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" type="image/svg+xml" href="/favicon.png" />
<title>{title}</title>
<meta name="description" content={description} />
<meta name="keywords" content={keywords} />
<meta name="author" content={siteConfig.author} />
<!-- Open Graph Tags -->
<meta property="og:type" content="website" />
<meta property="og:title" content={title} />
<meta property="og:description" content={description} />
<meta property="og:url" content={url} />
<meta property="og:image" content={ogImage} />
<meta property="og:site_name" content={title} />
<!-- Twitter Meta Tags -->
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content={title} />
<meta name="twitter:description" content={description} />
<meta name="twitter:image" content={ogImage} />
<meta name="twitter:site" content={twitterHandle} />
<meta name="twitter:creator" content={twitterHandle} />
</file>
<file path="src/pages/blog/index.astro">
---
import Layout from "@/layouts/Layout.astro";
import PageHeader from "@/components/elements/PageHeader.astro";
import BlogSection from "@/components/sections/BlogSection.astro";
// 使用与分页页面相同的每页文章数量
const POSTS_PER_PAGE = 6;
// 博客首页显示第一页内容
const currentPage = 1;
---
<Layout title="Blog">
<section class="relative z-20 container mx-auto my-12 px-7 lg:px-0">
<PageHeader
title="My Blog"
description="Explore my thoughts on design and development, the convergence of thinking and innovation. Feel free to follow the updates!"
/>
</section>
<section class="relative z-20 mx-auto my-12 lg:px-0">
<div class="mt-12">
<BlogSection
title=""
listPage={true}
pagination={{
enable: true,
currentPage: currentPage
}}
postsPerPage={POSTS_PER_PAGE}
/>
</div>
</section>
</Layout>
</file>
<file path="src/pages/rss.xml.js">
import rss from "@astrojs/rss";
import { getCollection } from "astro:content";
export async function GET(context) {
const blog = await getCollection('
post');
return rss({
title: ' Rico Portfolio Template Astro',
description: ' Astro Blog Template by Rico UI',
site: context.site,
items: blog.map((post) => {
const link = `/blog/${post.id}/`;
return {
title: post.data.title,
pubDate: post.data.publishDate,
description: post.data.description,
link,
stylesheet: '/ rss/ pretty-feed-v3.xsl',
};
}),
});
}
</file>
<file path="src/styles/global.css">
/* Self-hosted fonts */
@font-face {
font-family: ' Inter';
font-style: normal;
font-weight: 100 900;
font-display: swap;
src: url('/ fonts/ Inter-Variable.woff2') format(' woff2');
}
@font-face {
font-family: ' Instrument Serif';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('/ fonts/ InstrumentSerif-Regular.woff2') format(' woff2');
}
@font-face {
font-family: ' Instrument Serif';
font-style: italic;
font-weight: 400;
font-display: swap;
src: url('/ fonts/ InstrumentSerif-Italic.woff2') format(' woff2');
}
@import "tailwindcss";
/* Path relative to current CSS file */
@config "../../tailwind.config.mjs";
/* Define theme font mappings */
@theme {
--font-brand: "Instrument Serif", ui-serif, Georgia, serif;
--font-sans: "Inter", ui-sans-serif, system-ui, sans-serif;
--font-body: "Inter", ui-sans-serif, system-ui, sans-serif;
--max-screen: 1200px;
--inner-screen: 800px;
--color-neutral-50: #f8fafc;
--color-neutral-100: #f1f5f9;
--color-neutral-200: #e2e8f0;
--color-neutral-300: #cbd5e1;
--color-neutral-400: #94a3b8;
--color-neutral-500: #64748b;
--color-neutral-600: #475569;
--color-neutral-700: #334155;
--color-neutral-800: #1e293b;
--color-neutral-900: #0f172a;
--color-neutral-950: #020617;
/* Violet theme #707BC2 */
--color-primary: #707BC2;
--color-primary-rgb: 112, 123, 194;
--color-primary-strong: #5B67B3;
--color-primary-dark: #8B94D4;
--color-primary-light: #A5ADE0;
--color-primary-light-dark: #9BA3D8;
--color-primary-lighter: #B8BEE8;
/* Article-specific variables */
--color-primary-bg: var(--color-bg-primary-light);
--color-primary-bg-light: var(--color-bg-secondary);
--color-btn-primary: #707BC2;
--color-btn-primary-hover: #5B67B3;
--color-btn-primary-dark: #8B94D4;
--color-btn-primary-dark-hover: #707BC2;
/* Accent colors */
--color-accent: #707BC2;
--color-accent-light: #A5ADE0;
/* Background colors */
--color-bg-primary: #f8fafc;
--color-bg-secondary: #fff;
--color-bg-primary-light: #f1f5f9;
--color-bg-primary-deep: #f8fafc;
--color-bg-primary-dark: #0f172a;
--color-bg-secondary-dark: #1e293b;
/* Text colors */
--color-text-primary: #5B67B3;
--color-text-secondary: #475569;
--color-text-tertiary: #64748b;
--color-text-primary-dark: #A5ADE0;
--color-text-secondary-dark: #cbd5e1;
--color-text-tertiary-dark: #94a3b8;
}
/* Dark mode variable overrides */
html.dark {
--color-primary-rgb: 139, 148, 212;
--color-primary-light-dark: #8B94D4;
--color-primary-lighter: #A5ADE0;
--color-primary-bg: rgba(112, 123, 194, 0.15);
--color-primary-bg-light: rgba(112, 123, 194, 0.05);
}
/* Add typography styles */
@layer components {
.font-brand{
font-weight: 400!important;
}
.prose {
max-width: 65ch;
}
.prose img {
border-radius: 30px;
}
.container{
max-width: var(--max-screen);
margin-left: auto;
margin-right: auto;
padding-left: 1rem;
padding-right: 1rem;
}
.site-container {
max-width: var(--max-screen);
margin-left: auto;
margin-right: auto;
padding-left: 1rem;
padding-right: 1rem;
}
.inner-container {
max-width: var(--inner-screen);
margin-left: auto;
margin-right: auto;
padding-left: 1rem;
padding-right: 1rem;
}
.gradient-title {
background: linear-gradient(to right, #707BC2, #3d4785);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
.gradient-title-light{
background: linear-gradient(to right, #A5ADE0, #707BC2);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
}
html,body{
color: var(--color-text-secondary);
}
html.dark,html.dark body{
color: var(--color-text-secondary-dark);
}
h1,h2,h3{
color:var(--color-text-primary);
}
html.dark h1,html.dark h2,html.dark h3{
color:var(--color-text-primary-dark);
}
/* Custom CSS styles */
#sun {
transform: rotate(0);
}
#moon {
transform: rotate(0);
}
#darkToggle:hover #sun {
transform: rotate(360deg);
}
#darkToggle:hover #moon {
transform: rotate(45deg);
}
html.dark #darkToggle:hover .horizon {
border-color: #718096 !important;
}
html{
font-family: var(--font-sans);
scroll-behavior: smooth;
}
html.dark {
color-scheme: dark;
}
.horizon .setting {
animation: 1s ease 0s 1 setting;
}
.horizon .rising {
animation: 1s ease 0s 1 rising;
}
@keyframes setting {
0% {
transform: translate3d(0, 10px, 0);
}
40% {
transform: translate3d(0, -2px, 0);
}
to {
transform: translate3d(0, 30px, 0);
}
}
@keyframes rising {
0% {
opacity: 0;
transform: translate3d(0, 30px, 0);
}
40% {
opacity: 1;
transform: translate3d(0, -2px, 0);
}
to {
opacity: 1;
transform: translate3d(0, 10px, 0);
}
}
html::-webkit-scrollbar,
body::-webkit-scrollbar,
div::-webkit-scrollbar {
width: 6px !important;
}
html::-webkit-scrollbar-track,
body::-webkit-scrollbar-track,
div::-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;
}
html::-webkit-scrollbar-thumb,
body::-webkit-scrollbar-thumb,
div::-webkit-scrollbar-thumb {
background: var(--color-primary-light) !important;
border-radius: 6px !important;
}
html::-webkit-scrollbar-thumb:hover,
body::-webkit-scrollbar-thumb:hover,
div::-webkit-scrollbar-thumb:hover {
background: var(--color-primary) !important;
}
html::-webkit-scrollbar-thumb:active,
body::-webkit-scrollbar-thumb:active,
div::-webkit-scrollbar-thumb:active {
background: var(--color-primary-dark) !important;
}
</file>
<file path="astro.config.mjs">
import tailwindcss from "@tailwindcss/vite";
import { defineConfig } from "astro/config";
import mdx from "@astrojs/mdx";
import sitemap from "@astrojs/sitemap";
import { fileURLToPath } from "url";
import path from "path";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
// Get the site URL from environment variables, or use the default value if not set
// Note: After the first deployment, be sure to set the correct PUBLIC_SITE_URL in the .env file
const siteUrl = import.meta.env.PUBLIC_SITE_URL || ' https:// portfolio.ricoui.com/';
// https://astro.build/config
export default defineConfig({
site: siteUrl,
base: '/',
envPrefix: '
PUBLIC_',
vite: {
plugins: [tailwindcss()],
resolve: {
alias: {
' @': path.resolve(__dirname, ' ./ src')
}
}
},
server: {
port: 5200,
},
integrations: [mdx(), sitemap()],
});
</file>
<file path="package.json">
{
"name": "astro-rico-portfolio",
"type": "module",
"version": "1.0.0",
"description": "A modern, high-performance portfolio website template for designers built with Astro. Features retro blue theme, dark mode, and beautiful animations.",
"keywords": [
"astro",
"portfolio",
"designer",
"website",
"template",
"tailwindcss",
"blog",
"dark-mode",
"responsive"
],
"author": {
"name": "Ricoui",
"email": "hello@ricoui.com",
"url": "https://ricoui.com"
},
"repository": {
"type": "git",
"url": "https://github.com/ricocc/ricoui-portfolio.git"
},
"bugs": {
"url": "https://github.com/ricocc/ricoui-portfolio/issues"
},
"homepage": "https://github.com/ricocc/ricoui-portfolio#readme",
"license": "Apache-2.0",
"scripts": {
"dev": "astro dev",
"start": "astro dev",
"build": "astro check && astro build",
"preview": "astro preview",
"astro": "astro",
"check": "biome check --apply-unsafe ."
},
"devDependencies": {
"@astrojs/check": "^0.9.5",
"@biomejs/biome": "1.7.3",
"@tailwindcss/typography": "^0.5.13",
"@types/matter-js": "^0.20.2",
"astro": "^5.15.4",
"postcss": "^8.4.31",
"tailwindcss": "^4.1.14",
"typescript": "^5.4.5",
"vite": "^5.2.11"
},
"dependencies": {
"@astrojs/mdx": "^4.3.10",
"@astrojs/rss": "^4.0.13",
"@astrojs/sitemap": "^3.6.0",
"@lucide/astro": "^0.546.0",
"@tailwindcss/vite": "^4.1.14",
"aos": "^2.3.4",
"matter-js": "^0.20.0",
"motion": "^12.23.24",
"sharp": "^0.34.4"
}
}
</file>
<file path="src/collections/experiences.json">
[
{
"dates": "2024 - Present",
"role": "President",
"company": "HEC IA",
"description": "Technical workshops on AI tools for business students.",
"logo": "/assets/hec-ia.png"
},
{
"dates": "2023 - Present",
"role": "Freelance Developer",
"company": "Self-employed",
"description": "Fullstack development. Python, Golang, automation.",
"logo": "/assets/freelance.png"
}
]
</file>
<file path="src/collections/featuredwork.json">
[
{
"name": "Homelab Infrastructure",
"description": "Self-hosted production infrastructure",
"tags": [
"Kubernetes",
"Proxmox",
"Docker",
"Monitoring"
],
"image": "/assets/homelab.png",
"url": "/work/homelab",
"isShow": true
},
{
"name": "HEC IA",
"description": "AI workshops for business students",
"tags": [
"Education",
"AI",
"Workshops"
],
"image": "/assets/hec-ia.png",
"url": "/work/hec-ia",
"isShow": true
},
{
"name": "Freelance Development",
"description": "Fullstack development and automation",
"tags": [
"Python",
"Golang",
"Automation"
],
"image": "/assets/freelance.png",
"url": "/work/freelance",
"isShow": true
}
]
</file>
<file path="src/components/sections/Explore.astro">
---
import SectionHeader from "@/components/elements/SectionHeader.astro";
import { Server, Code, GraduationCap } from "@lucide/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">
<!-- Infrastructure -->
<a href="/work/homelab" 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 h-[180px] sm:h-[200px] group"
data-aos="fade-up-sm"
data-aos-delay="500"
data-aos-duration="600"
data-aos-once="true">
<div class="content">
<h3>Infrastructure</h3>
<p>Self-hosted homelab with Kubernetes, monitoring, and PKI.</p>
</div>
<div class="absolute right-6 bottom-0 top-0 flex items-center opacity-20 group-hover:opacity-40 transition-opacity duration-300">
<Server size={100} stroke-width={1} class="text-primary dark:text-primary-light" />
</div>
</a>
<!-- Development -->
<a href="/work/freelance" 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 h-[180px] sm:h-[200px] group"
data-aos="fade-up-sm"
data-aos-delay="600"
data-aos-duration="600"
data-aos-once="true">
<div class="content">
<h3>Development</h3>
<p>Fullstack projects with Python, Golang, and modern tooling.</p>
</div>
<div class="absolute right-6 bottom-0 top-0 flex items-center opacity-20 group-hover:opacity-40 transition-opacity duration-300">
<Code size={100} stroke-width={1} class="text-primary dark:text-primary-light" />
</div>
</a>
<!-- HEC IA -->
<a href="/work/hec-ia" 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 h-[180px] sm:h-[200px] group"
data-aos="fade-up-sm"
data-aos-delay="700"
data-aos-duration="600"
data-aos-once="true">
<div class="content">
<h3>HEC IA</h3>
<p>Making AI accessible to business students through workshops.</p>
</div>
<div class="absolute right-6 bottom-0 top-0 flex items-center opacity-20 group-hover:opacity-40 transition-opacity duration-300">
<GraduationCap size={100} stroke-width={1} class="text-primary dark:text-primary-light" />
</div>
</a>
</div>
</div>
</section>
<style>
.content {
position: absolute;
bottom: 0;
left: 0;
top: 0;
max-width: 65%;
padding-left: 1rem;
padding-right: 0.5rem;
display: flex;
align-items: flex-start;
flex-direction: column;
justify-content: center;
transition: all 0.3s ease-in-out;
z-index: 1;
}
@media (min-width: 640px) {
.content {
max-width: 60%;
padding-left: 1.25rem;
}
}
@media (min-width: 768px) {
.content {
max-width: 55%;
padding-left: 1.5rem;
}
}
.content h3 {
font-size: 1.25rem;
line-height: 1.1;
margin-bottom: 0.5rem;
font-family: var(--font-brand);
color: var(--color-neutral-800);
}
html.dark .content h3 {
color: var(--color-neutral-100);
}
@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.4;
font-weight: 400;
color: var(--color-neutral-500);
margin-bottom: 0;
}
html.dark .content p {
color: var(--color-neutral-400);
}
@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;
transition: all 0.3s ease-in-out;
}
a.explore-item:hover {
border-color: var(--color-primary);
transform: translateY(-2px);
}
html.dark a.explore-item:hover {
border-color: var(--color-primary-dark);
}
</style>
</file>
<file path="src/components/sections/Header.astro">
---
import { Moon, SunMedium } from "@lucide/astro";
import { siteConfig } from "@/config/site.js";
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-[#7fa1ff] 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
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>
<!-- GitHub Button (Mobile) -->
<div class="relative z-10 w-full px-5 mt-3 sm:hidden">
<Button url={siteConfig.social.github} type="fill" className="m-auto w-full justify-center ">
GitHub
</Button>
</div>
</div>
<!-- Desktop Actions -->
<div class="relative hidden sm:flex items-center gap-2 ml-4 lg:ml-6">
<!-- GitHub Button (Desktop) -->
<Button url={siteConfig.social.github} type="fill" className="md:flex mx-1">
GitHub
</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>
</file>
<file
path="src/pages/blog/page/[page].astro"> --- import Layout from "@/layouts/Layout.astro"; import
PageHeader from "@/components/elements/PageHeader.astro"; import BlogSection from
"@/components/sections/BlogSection.astro"; import { getCollection } from "astro:content"; //
定义每页显示的文章数量 export const POSTS_PER_PAGE = 6; // 根据 Astro v5/v6对于 SSG 模式下的分页路由,仍然需要使用
getStaticPaths() // 这是因为需要告诉 Astro 在构建时生成哪些页面 // 注意params 必须是字符串类型(符合 v6 要求) export async
function getStaticPaths() { 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) { posts = posts.filter((post) => post.data.featured !== true); } //
使用移除特色文章后的文章数量计算总页数 const totalPages = Math.ceil(posts.length / POSTS_PER_PAGE); // 返回所有分页路径,确保
params.page 是字符串类型v6 要求) return Array.from({ length: totalPages }, (_, i) => { const page = i +
1; return { params: { page: page.toString() }, props: { page: page.toString() } }; }); } // 从
props 获取页码(在 SSG 模式下通过 getStaticPaths 传递) const { page } = Astro.props; const currentPage =
parseInt(page); --- <Layout title= {`Blog - Page ${currentPage}`}>
<section class="relative z-20 container mx-auto my-12 px-7 lg:px-0">
<PageHeader
title="Blog"
description="Explore my thoughts on design and development, the convergence of thinking and innovation. Feel free to follow the updates!"
/>
</section>
<section class="relative z-20 mx-auto my-12 px-7 lg:px-0">
<div class="mt-12">
<BlogSection
title="All Articles"
listPage= {true}
pagination= {{
enable: true,
currentPage: currentPage
}}
postsPerPage= {POSTS_PER_PAGE}
/>
</div>
</section>
</Layout>
</file>
<file
path="src/pages/about.astro"> --- import { ArrowUpRight, Code, Server, Building, Cloud,
GraduationCap, } from "@lucide/astro"; import { siteConfig } from "@/config/site.js"; import
experiences from "@/collections/experiences.json"; import social from "@/collections/social.json";
import AboutExperience from "@/components/elements/AboutExperience.astro"; import Button from
"@/components/ui/Button.astro"; import Layout from "@/layouts/Layout.astro"; import Toc from
"@/components/widgets/Toc.astro"; // Manually define page table of contents structure const
headings = [ { depth: 2, slug: "about-me", text: "About Me" }, { depth: 2, slug: "expertise",
text: "Expertise" }, { depth: 2, slug: "experience", text: "Experience" }, { depth: 2, slug:
"education", text: "Education" }, { depth: 2, slug: "lets-connect", text: "Let's Connect" }, ];
const filtered = headings.filter(h => h.depth <= 3);
---
<Layout title="About Me">
<!-- Mobile TOC -->
<div class="block lg:hidden px-7 pt-6">
<Toc headings= {filtered} />
</div>
<!-- Main container -->
<div class="relative site-container mx-auto my-8">
<div class="flex flex-col lg:flex-row gap-8 lg:gap-12 xl:gap-16">
<!-- Left TOC - Fixed on desktop -->
<aside class="relative hidden lg:block lg:w-40 xl:w-50 flex-shrink-0">
<div class="toc-sticky-wrapper">
<Toc headings= {filtered} />
</div>
</aside>
<!-- Right main content - Adaptive width -->
<main class="relative flex-1 min-w-0 w-full lg:max-w-none">
<div
class="w-full"
data-aos="fade-up-xs"
data-aos-duration="500"
data-aos-once="true"
>
<h2
id="about-me"
class="text-2xl font-brand tracking-tight sm:text-3xl lg:text-4xl"
>
About Me
</h2>
<div
class="w-full border-b-1 border-dashed border-b-gray-200 dark:border-b-neutral-700 mt-4 mb-8"></div>
</div>
<div
class="flex items-center gap-3 mb-6"
data-aos="fade-up-xs"
data-aos-delay="100"
data-aos-duration="500"
data-aos-once="true"
>
<div class="flex items-center gap-3">
<span class="font-brand text-[24px] text-neutral-700 dark:text-neutral-100">
Alexandre</span>
<span class="text-[0.75em] text-neutral-400">|</span>
<a href= {siteConfig.social.github}
class="flex items-center gap-1 text-neutral-500 hover:text-neutral-700 dark:text-neutral-400 dark:hover:text-neutral-200 text-sm"
target="_blank">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"
class="w-4.5 h-4.5 fill-current">
<path
d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z" />
</svg>
@vorpax </a>
</div>
</div>
<p
class="text-2xl mt-2 mb-2 font-brand italic text-left text-primary-strong dark:text-primary-light"
data-aos="fade-up-xs"
data-aos-delay="200"
data-aos-duration="500"
data-aos-once="true"
>"Bridging business strategy with technical implementation."</p>
<div
class="social"
data-aos="fade-up-xs"
data-aos-delay="300"
data-aos-duration="600"
data-aos-once="true"
>
<div class="social-list flex flex-wrap gap-2 items-center justify-start mt-6 mb-8">
{social .filter(item => item.isShow !== false) .map((item, index) => ( <a
href= {item.url}
target="_blank"
rel="noopener noreferrer"
class="social-item relative group overflow-hidden rounded-xl transition-all duration-300 hover:-translate-y-2 hover:rotate-[-6deg] hover:shadow-lg"
data-aos="fade-up-xs"
data-aos-delay= {400 + (index * 50)}
data-aos-duration="400"
data-aos-once="true"
>
<img
src= {item.image}
alt= {item.name}
class="w-[96px] h-[96px] rounded-xl object-cover transition-transform duration-300 group-hover:scale-105"
/>
<div class="absolute bottom-[10px] left-[12px] z-10">
<div class="social-name font-semibold text-[10px] text-white drop-shadow-md">
{item.name}
</div>
<div class="social-username text-[9px] text-white/85 drop-shadow-sm">
@{item.username}
</div>
</div>
</a>
)) } </div>
</div>
<div
class="w-full border-b-1 border-dashed border-b-gray-200 dark:border-b-neutral-700 mt-4 mb-8"></div>
<p
class="text-neutral-700 pb-4 pt-2 leading-[1.8] dark:text-neutral-200"
data-aos="fade-up-xs"
data-aos-delay="100"
data-aos-duration="500"
data-aos-once="true"
>
Started in business prep school, now at HEC Paris. Self-taught in infrastructure and
development. I bridge business strategy with technical implementation.
</p>
<div
class="mt-8 mb-16 flex item-center justify-start gap-3"
data-aos="fade-up-xs"
data-aos-delay="200"
data-aos-duration="500"
data-aos-once="true"
>
<Button url= {siteConfig.social.linkedin} type="fill"> LinkedIn <ArrowUpRight size= {16} />
</Button>
<Button url= {siteConfig.social.github}> GitHub <ArrowUpRight size= {16} />
</Button>
</div>
<div
class="w-full border-b-1 border-dashed border-b-neutral-200 dark:border-b-neutral-700 mt-4 mb-8"></div>
<section>
<h2
id="expertise"
class="mt-8 mb-2 text-[28px] text-neutral-800 dark:text-neutral-200 font-brand"
data-aos="fade-up-xs"
data-aos-duration="500"
data-aos-once="true"
>Expertise</h2>
<div
class="list flex flex-wrap gap-3 mt-4 mb-12 w-full items-center justify-start"
data-aos="fade-up-xs"
data-aos-delay="100"
data-aos-duration="600"
data-aos-once="true"
>
<!-- Infrastructure -->
<div
class="item flex justify-start items-start flex-grow-0 flex-shrink-0 overflow-hidden gap-2 px-5 py-4 rounded-xl bg-gradient-to-b from-[f7f8f0] to-[#f1f2f9] dark:from-gray-900 dark:to-gray-800 dark:text-neutral-200">
<div class="flex justify-center items-center flex-grow-0 flex-shrink-0 gap-2">
<Server size= {22} />
<div
class="flex-grow-0 flex-shrink-0 text-base font-medium text-left text-neutral-700 dark:text-neutral-200">
Infrastructure
</div>
</div>
</div>
<!-- Fullstack Development -->
<div
class="item flex justify-start items-start flex-grow-0 flex-shrink-0 overflow-hidden gap-2 px-5 py-4 rounded-xl bg-gradient-to-b from-[f7f8f0] to-[#f1f2f9] dark:from-gray-900 dark:to-gray-800">
<div class="flex justify-center items-center flex-grow-0 flex-shrink-0 gap-2">
<Code size= {22} />
<div
class="flex-grow-0 flex-shrink-0 text-base font-medium text-left text-neutral-700 dark:text-neutral-200">
Fullstack Development
</div>
</div>
</div>
<!-- Business Strategy -->
<div
class="item flex justify-start items-start flex-grow-0 flex-shrink-0 overflow-hidden gap-2 px-5 py-4 rounded-xl bg-gradient-to-b from-[f7f8f0] to-[#f1f2f9] dark:from-gray-900 dark:to-gray-800">
<div class="flex justify-center items-center flex-grow-0 flex-shrink-0 gap-2">
<Building size= {22} />
<div
class="flex-grow-0 flex-shrink-0 text-base font-medium text-left text-neutral-700 dark:text-neutral-200">
Business Strategy
</div>
</div>
</div>
<!-- DevOps -->
<div
class="item flex justify-start items-start flex-grow-0 flex-shrink-0 overflow-hidden gap-2 px-5 py-4 rounded-xl bg-gradient-to-b from-[f7f8f0] to-[#f1f2f9] dark:from-gray-900 dark:to-gray-800">
<div class="flex justify-center items-center flex-grow-0 flex-shrink-0 gap-2">
<Cloud size= {22} />
<div
class="flex-grow-0 flex-shrink-0 text-base font-medium text-left text-neutral-700 dark:text-neutral-200">
DevOps
</div>
</div>
</div>
</div>
</section>
<div
class="w-full border-b-1 border-dashed border-b-gray-200 dark:border-b-neutral-700 mt-4 mb-8"></div>
<section class="experience">
<h2
id="experience"
class="mt-12 mb-4 text-[28px] text-neutral-800 dark:text-neutral-200 font-brand"
data-aos="fade-up-xs"
data-aos-duration="500"
data-aos-once="true"
>
Experience
</h2>
<div
class="px-5 py-10"
data-aos="fade-up-xs"
data-aos-delay="100"
data-aos-duration="600"
data-aos-once="true"
> { experiences.map((experience, index) => { return ( <div
class="pb-10 border-l border-dashed border-gray-200 last:border-l-0 dark:border-neutral-500"
data-aos="fade-up-xs"
data-aos-delay= {200 + (index * 100)}
data-aos-duration="500"
data-aos-once="true"
>
<AboutExperience
dates= {experience.dates}
role= {experience.role}
company= {experience.company}
description= {experience.description}
logo= {experience.logo}
/>
</div> ) }) } </div>
</section>
<div
class="w-full border-b-1 border-dashed border-b-gray-200 dark:border-b-neutral-700 mt-4 mb-8"></div>
<section class="education">
<h2
id="education"
class="mt-12 mb-4 text-[28px] text-neutral-800 dark:text-neutral-200 font-brand"
data-aos="fade-up-xs"
data-aos-duration="500"
data-aos-once="true"
>
Education
</h2>
<div
class="space-y-4 mt-6"
data-aos="fade-up-xs"
data-aos-delay="100"
data-aos-duration="600"
data-aos-once="true"
>
<div class="flex items-start gap-3">
<GraduationCap size= {20} class="mt-1 text-primary dark:text-primary-light" />
<div>
<p class="font-medium text-neutral-800 dark:text-neutral-100">HEC Paris</p>
<p class="text-sm text-neutral-600 dark:text-neutral-400">M1 - Master in
Management</p>
</div>
</div>
<div class="flex items-start gap-3">
<GraduationCap size= {20} class="mt-1 text-primary dark:text-primary-light" />
<div>
<p class="font-medium text-neutral-800 dark:text-neutral-100">Sorbonne
University</p>
<p class="text-sm text-neutral-600 dark:text-neutral-400">Bachelor's in
Mathematics</p>
</div>
</div>
</div>
</section>
<div
class="w-full border-b-1 border-dashed border-b-gray-200 dark:border-b-neutral-700 mt-12 mb-8"></div>
<section>
<h2
id="lets-connect"
class="mt-12 mb-4 text-[28px] text-neutral-800 dark:text-neutral-200 font-brand"
data-aos="fade-up-xs"
data-aos-duration="500"
data-aos-once="true"
>Let's Connect</h2>
<p
class="text-sm leading-6 text-neutral-700 dark:text-neutral-300"
data-aos="fade-up-xs"
data-aos-delay="100"
data-aos-duration="500"
data-aos-once="true"
> Reach out on <a
href= {siteConfig.social.linkedin}
target="_blank"
class="text-primary underline dark:text-primary-light">LinkedIn</a> or check out
my work on <a href= {siteConfig.social.github} target="_blank" class="text-primary underline dark:text-primary-light"
>GitHub</a>
. </p>
<p class="text-sm leading-6 text-neutral-700 dark:text-neutral-300 mt-2"> Email: <span
class="text-primary dark:text-primary-light">{siteConfig.mail}</span>
</p>
</section>
</main>
</div>
</div>
</Layout>
<style>
.toc-sticky-wrapper {
position: sticky;
top: 8rem;
}
</style>
</file>
<file path="src/collections/social.json">
[
{
"id": 1,
"name": "Github",
"username": "vorpax",
"image": "/assets/social/social-github.jpg",
"url": "https://github.com/vorpax",
"isShow": true
},
{
"id": 2,
"name": "LinkedIn",
"username": "alexandre-houard",
"image": "/assets/social/social-linkedin.png",
"url": "https://www.linkedin.com/in/alexandre-houard-686960279/",
"isShow": true
},
{
"id": 3,
"name": "Gitea",
"username": "vorpax",
"image": "/assets/social/social-gitea.png",
"url": "https://gitea.vorpax.dev/vorpax",
"isShow": true
}
]
</file>
<file
path="src/collections/works.json">
[
{
"name": "Homelab Infrastructure",
"description": "Self-hosted production infrastructure",
"tags": [
"Kubernetes",
"Proxmox",
"Docker",
"Monitoring"
],
"image": "/assets/homelab.png",
"url": "/work/homelab",
"isShow": true
},
{
"name": "HEC IA",
"description": "AI workshops for business students",
"tags": [
"Education",
"AI",
"Workshops"
],
"image": "/assets/hec-ia.png",
"url": "/work/hec-ia",
"isShow": true
},
{
"name": "Freelance Development",
"description": "Fullstack development and automation",
"tags": [
"Python",
"Golang",
"Automation"
],
"image": "/assets/freelance.png",
"url": "/work/freelance",
"isShow": true
}
]
</file>
<file
path="README-zh.md"> # Rico Portfolio - 设计师个人作品集网站 一个基于 Astro
构建的现代化、高性能设计师个人作品集网站模板。采用复古蓝色主题,支持暗色模式,具有精美的动画效果和优秀的用户体验。
![Astro](https://img.shields.io/badge/Astro-5.15.4-FF5D01?logo=astro&logoColor=white) ![Tailwind
CSS](https://img.shields.io/badge/Tailwind-4.1.14-38B2AC?logo=tailwind-css&logoColor=white)
![License](https://img.shields.io/badge/License-Apache%202.0-blue.svg) ## ✨ 特性 - 🚀 **基于 Astro** -
快速、轻量级的静态站点生成器 - 🎨 **现代化设计** - 复古蓝色主题,支持暗色/亮色模式切换 - 📱 **完全响应式** - 适配各种设备尺寸 - 🎭 **精美动画** - 使用
AOS 和自定义动画效果 - 📝 **博客系统** - 支持 MDX 格式的博客文章 - 🎯 **作品展示** - 优雅的作品集展示页面 - 🔍 **SEO 优化** - 内置 SEO
和社交媒体标签 - ⚡ **性能优化** - 图片优化、代码分割、懒加载 - 🌐 **国际化支持** - 易于扩展多语言支持 ## 🛠️ 技术栈 - **框架**:
[Astro](https://astro.build) 5.15.4 (兼容 v6) - **样式**: [Tailwind CSS](https://tailwindcss.com)
4.1.14 - **动画**: [AOS](https://michalsnik.github.io/aos/) - **物理引擎**:
[Matter.js](https://brm.io/matter-js/) - **内容管理**: MDX - **类型检查**: TypeScript ## 📦 安装 ### 使用包管理器
```bash # 使用 pnpm (推荐) pnpm install # 使用 npm npm install # 使用 yarn yarn install ``` ### 环境变量配置 复制
`.env.example` 文件为 `.env` 并填写相应的配置: ```bash cp .env.example .env ``` 编辑 `.env` 文件,填入你的配置: ```env #
站点 URL可选但有默认值 https://your-domain.com # 首次部署可以不设置,但建议尽快设置正确的域名以优化 SEO
PUBLIC_SITE_URL=https://your-domain.com # 分析工具(可选) PUBLIC_GA4_ID=your-google-analytics-id
PUBLIC_UMAMI_ID=your-umami-id ``` > **注意**`PUBLIC_SITE_URL` 如果没有设置,会使用默认值
`https://your-domain.com`。虽然不会报错,但建议在部署后尽快设置正确的域名,以确保 sitemap、RSS feed 和 SEO 元标签正常工作。 ## 🚀 开发
```bash # 启动开发服务器 npm run dev # 或 pnpm dev # 访问 http://localhost:4321 ``` ## 📦 构建 ```bash #
构建生产版本 npm run build # 预览构建结果 npm run preview ``` ## 📁 项目结构 ``` ├── public/ # 静态资源 │ ├── assets/
# 图片、视频等资源 │ └── favicon.png # 网站图标 ├── src/ │ ├── assets/ # 源代码资源 │ ├── collections/ #
数据集合(作品、经历等) │ ├── components/ # Astro 组件 │ │ ├── cards/ # 卡片组件 │ │ ├── sections/ # 页面区块组件 │ │ ├──
ui/ # UI 组件 │ │ └── widgets/ # 小部件 │ ├── config/ # 配置文件 │ ├── content/ # MDX 博客内容 │ ├── layouts/ #
布局组件 │ ├── pages/ # 页面路由 │ ├── scripts/ # 脚本文件 │ └── styles/ # 样式文件 ├── astro.config.mjs # Astro
配置 ├── tailwind.config.mjs # Tailwind 配置 └── package.json # 项目依赖 ``` ## 🎨 自定义配置 ### 修改网站信息 编辑
`src/config/site.js` 文件,修改网站的基本信息: ```javascript export const siteConfig = { title: "Your
Portfolio", author: "Your Name", url: "https://your-domain.com", // ... 更多配置 }; ``` ### 修改主题颜色 编辑
`src/styles/global.css` 文件中的 CSS 变量: ```css @theme { --color-primary: #2d6dc3;
--color-primary-dark: #3b7bd9; /* ... 更多颜色变量 */ } ``` ### 添加作品 在 `src/collections/works.json`
中添加你的作品信息。 ### 添加博客文章 在 `src/content/post/` 目录下创建新的 MDX 文件。项目使用 Astro v5 Content Layer API 和
`glob` 加载器来管理内容集合,确保与 Astro v6 兼容。 **注意**:此模板已完全升级到 Astro v5.15 标准,并兼容 Astro v6 - ✅ 使用新的 Content
Layer API (`glob` 加载器) - ✅ 使用 `entry.id` 替代已弃用的 `entry.slug` - ✅ 使用 `render(entry)` 替代已弃用的
`entry.render()` - ✅ 使用 `import.meta.env` 替代 `process.env` - ✅ 使用 `import.meta.glob()` 替代已弃用的
`Astro.glob()` - ✅ 所有 `getStaticPaths()` 的 params 都是字符串类型v6 要求) ## 使用素材 - **Programming
Sticker**: [Figma
rogramming-sticker-1-0](https://www.figma.com/community/file/1392100849031958853/programming-sticker-1-0)
- **Bento Cards**[Figma Bento Cards](https://www.figma.com/community/file/1231184483170475120) -
**Social Cards**: [Figma Bento
2.5d](https://www.figma.com/community/file/1232620929235403629/bento-2-5d-widgets) ## 📧 联系方式 -
**作者**: Ricoui - **博客**: [ricoui.com](https://github.com/ricocc) - **邮箱**: hello@ricoui.com -
**Twitter**: [@ricouii](https://x.com/ricouii) - **GitHub**: [@ricocc](https://github.com/ricocc)
## 💡 其他产品 - **Rico Blog** - 开源 :
[https://github.com/ricocc/public-portfolio-site](https://github.com/ricocc/public-portfolio-site)
- **OG Gallery**: [ricoog.com](https://ricoog.com/) ## 🙏 致谢 - [Astro](https://astro.build) -
优秀的静态站点生成器 - [Tailwind CSS](https://tailwindcss.com) - 实用优先的 CSS 框架 ## 📝 更新日志 ### 最新更新 (2024) -
**升级到 Astro 5.15.4** - 完全符合 Astro v5.15 标准,兼容 Astro v6 - **内容集合升级** - 使用新的 Content Layer
API移除了所有旧版 API - **API 现代化** - 所有已弃用的 API 已更新为最新标准 - **性能优化** - 优化了构建和运行时性能 查看
[CHANGELOG.md](CHANGELOG.md) 了解完整版本更新历史。 ## 关于作者 我是Rico网页/UI设计师热衷于做些有趣和创意的作品。拥有 UI/UX
设计工作经验,目前专注于网页设计和视觉落地,以及开发项目探索。我平时在博客<a href="https://ricoui.com/" target="_blank">Rico's Blog</a>更新内容。也可以关注我的小红书
[@Rico的设计漫想](https://www.xiaohongshu.com/user/profile/5f2b6903000000000101f51f) 和 推特
[@ricouii](https://x.com/ricouii). 或者添加我的微信,交个朋友 <img src="https://ricoui.com/assets/wechat.png"
alt="ricocc-wechat" width="280" height="auto" style="display:inline-block;margin:12px;"> ## 💜
支持作者 如果觉得有所帮助的话,一点点支持就可以大大激励创作者的热情,感谢! <img src="https://ricoui.com/assets/zanshangma.jpg"
alt="ricocc-wechat" width="280" height="auto" style="display:inline-block;margin:12px;"> --- ⭐
如果这个项目对你有帮助,请给一个 Star
</file>
<file
path="README.md"> # Rico Portfolio - Designer Portfolio Website > [中文文档](README-zh.md) | English
A modern, high-performance designer portfolio website template built with Astro. Features a retro
blue theme, dark mode support, beautiful animations, and excellent user experience.
![Astro](https://img.shields.io/badge/Astro-5.15.4-FF5D01?logo=astro&logoColor=white) ![Tailwind
CSS](https://img.shields.io/badge/Tailwind-4.1.14-38B2AC?logo=tailwind-css&logoColor=white)
![License](https://img.shields.io/badge/License-Apache%202.0-blue.svg) ## ✨ Features - 🚀 **Built
with Astro** - Fast and lightweight static site generator - 🎨 **Modern Design** - Retro blue
theme with dark/light mode toggle - 📱 **Fully Responsive** - Adapts to all device sizes - 🎭
**Beautiful Animations** - Using AOS and custom animation effects - 📝 **Blog System** - Supports
MDX format blog posts - 🎯 **Portfolio Showcase** - Elegant portfolio showcase pages - 🔍 **SEO
Optimized** - Built-in SEO and social media tags - ⚡ **Performance Optimized** - Image
optimization, code splitting, lazy loading - 🌐 **i18n Support** - Easy to extend for
multi-language support ## 🛠️ Tech Stack - **Framework**: [Astro](https://astro.build) 5.15.4 (v6
compatible) - **Styling**: [Tailwind CSS](https://tailwindcss.com) 4.1.14 - **Animations**:
[AOS](https://michalsnik.github.io/aos/) - **Physics Engine**:
[Matter.js](https://brm.io/matter-js/) - **Content Management**: MDX - **Type Checking**:
TypeScript ## 📦 Installation ### Using Package Manager ```bash # Using pnpm (recommended) pnpm
install # Using npm npm install # Using yarn yarn install ``` ### Environment Variables
Configuration Copy `.env.example` to `.env` and fill in the corresponding configuration: ```bash
cp .env.example .env ``` Edit the `.env` file and fill in your configuration: ```env # Site URL
(optional, but has default value https://your-domain.com) # You can skip this on first deployment,
but it's recommended to set the correct domain as soon as possible to optimize SEO
PUBLIC_SITE_URL=https://your-domain.com # Analytics (optional)
PUBLIC_GA4_ID=your-google-analytics-id PUBLIC_UMAMI_ID=your-umami-id ``` > **Note**: If
`PUBLIC_SITE_URL` is not set, it will use the default value `https://your-domain.com`. While it
won't cause errors, it's recommended to set the correct domain after deployment to ensure sitemap,
RSS feed, and SEO meta tags work properly. ## 🚀 Development ```bash # Start development server
npm run dev # or pnpm dev # Visit http://localhost:4321 ``` ## 📦 Build ```bash # Build for
production npm run build # Preview build result npm run preview ``` ## 📁 Project Structure ```
├── public/ # Static assets │ ├── assets/ # Images, videos, etc. │ └── favicon.png # Site favicon
├── src/ │ ├── assets/ # Source assets │ ├── collections/ # Data collections (works, experiences,
etc.) │ ├── components/ # Astro components │ │ ├── cards/ # Card components │ │ ├── sections/ #
Section components │ │ ├── ui/ # UI components │ │ └── widgets/ # Widgets │ ├── config/ #
Configuration files │ ├── content/ # MDX blog content │ ├── layouts/ # Layout components │ ├──
pages/ # Page routes │ ├── scripts/ # Script files │ └── styles/ # Style files ├──
astro.config.mjs # Astro configuration ├── tailwind.config.mjs # Tailwind configuration └──
package.json # Project dependencies ``` ## 🎨 Customization ### Modify Site Information Edit the
`src/config/site.js` file to modify the site's basic information: ```javascript export const
siteConfig = { title: "Your Portfolio", author: "Your Name", url: "https://your-domain.com", //
... more configuration }; ``` ### Modify Theme Colors Edit the CSS variables in the
`src/styles/global.css` file: ```css @theme { --color-primary: #2d6dc3; --color-primary-dark:
#3b7bd9; /* ... more color variables */ } ``` ### Add Works Add your work information in
`src/collections/works.json`. ### Add Blog Posts Create new MDX files in the `src/content/post/`
directory. The project uses Astro v5 Content Layer API with `glob` loader for content collections,
ensuring compatibility with Astro v6. **Note**: This template has been fully upgraded to Astro
v5.15 standards and is compatible with Astro v6: - ✅ Uses new Content Layer API (`glob` loader) -
✅ Uses `entry.id` instead of deprecated `entry.slug` - ✅ Uses `render(entry)` instead of
deprecated `entry.render()` - ✅ Uses `import.meta.env` instead of `process.env` - ✅ Uses
`import.meta.glob()` instead of deprecated `Astro.glob()` - ✅ All `getStaticPaths()` params are
string type (v6 requirement) ## Figma Assets - **Programming Sticker**: [Figma
rogramming-sticker-1-0](https://www.figma.com/community/file/1392100849031958853/programming-sticker-1-0)
- **Bento Cards**[Figma Bento Cards](https://www.figma.com/community/file/1231184483170475120) -
**Social Cards**: [Figma Bento
2.5d](https://www.figma.com/community/file/1232620929235403629/bento-2-5d-widgets) ## 📧 Contact -
**Author**: Ricoui - **Blog**: [ricoui.com](https://github.com/ricocc) - **Email**:
hello@ricoui.com - **Twitter**: [@ricouii](https://x.com/ricouii) - **GitHub**:
[@ricocc](https://github.com/ricocc) ## 💡 Other Products - **Rico Blog** - Open Source:
[https://github.com/ricocc/public-portfolio-site](https://github.com/ricocc/public-portfolio-site)
- **OG Gallery**: [ricoog.com](https://ricoog.com/) ## 🙏 Acknowledgments -
[Astro](https://astro.build) - Excellent static site generator - [Tailwind
CSS](https://tailwindcss.com) - Utility-first CSS framework - All developers who contributed to
this project ## About the Author I'm Rico, a web/UI designer passionate about creating fun and
creative work. I have experience in UI/UX design and am currently focused on web design, visual
implementation, and exploring development projects. I regularly update my blog on <a
href="https://ricoui.com/" target="_blank">Rico's Blog</a>. You can also follow me on
Xiaohongshu [@Rico的设计漫想](https://www.xiaohongshu.com/user/profile/5f2b6903000000000101f51f) 和 X
[@ricouii](https://x.com/ricouii). Or add me on WeChat—lets be friends. <img
src="https://ricoui.com/assets/wechat.png" alt="ricocc-wechat" width="280" height="auto"
style="display:inline-block;margin:12px;"> ## 💜 Support the Author If youve found this
helpful, even a small contribution can greatly encourage creators. Thank you! <img
src="https://ricoui.com/assets/zanshangma.jpg" alt="ricocc-wechat" width="280" height="auto"
style="display:inline-block;margin:12px;">
<a href="https://ko-fi.com/T6T817U4KZ" target="_blank"
style="display:inline-block;margin:.5rem auto 1rem;" data-astro-cid-wlrjxfd7="">
<img height="44" style=" border:0px;height:44px;"
src="https://storage.ko-fi.com/cdn/kofi2.png?v=6" alt="Buy Me a Coffee at ko-fi.com"
data-astro-cid-wlrjxfd7="">
</a> ## 📝 Changelog ### Latest Updates (2024) - **Upgraded
to Astro 5.15.4** - Fully compliant with Astro v5.15 standards and compatible with Astro v6 -
**Content Collections Upgrade** - Using new Content Layer API, all legacy APIs removed - **API
Modernization** - All deprecated APIs updated to latest standards - **Performance Optimization** -
Optimized build and runtime performance ⭐ If this project helps you, please give it a Star!
</file>
<file path="src/pages/index.astro"> ---
import Button from "../components/ui/Button.astro"; import { siteConfig } from "@/config/site";
import Layout from "@/layouts/Layout.astro"; import FeaturedWork from
"../components/sections/FeaturedWork.astro"; import SocialCard from
"@/components/cards/SocialCard.astro"; import AnimatedText from
"@/components/ui/AnimatedText.astro"; import Explore from "@/components/sections/Explore.astro";
--- <Layout
title="Alexandre | Vorpax"
description="M1 HEC Paris | President HEC IA | Freelance Fullstack Developer | Infrastructure Enthusiast"
keywords="Alexandre H, Portfolio, Developer, Infrastructure, Homelab, HEC Paris, Engineering"
>
<div
class="relative site-container z-20 w-full mx-auto mt-16 px-4 md:mt-18 lg:mt-20 xl:px-0"
>
<div
class="relative w-full px-4 flex flex-col items-center justify-between md:flex-row mb-16"
>
<div
class="relative w-full md:max-w-[520px] md:w-1/2 text-center sm:text-left sm:-mt-8"
>
<!-- Title - AnimatedText animation -->
<h1 class="mb-4">
<AnimatedText
content="Hi, I'm Alexandre"
delay= {0.1}
duration= {0.5}
stagger= {0.08}
class="text-5xl text-pri leading-tight md:text-4xl lg:text-6xl font-brand"
/>
</h1>
<!-- Tagline -->
<div class="mb-4">
<AnimatedText
content="Engineering, Infrastructure and Management"
delay= {0.3}
duration= {0.5}
stagger= {0.02}
class="text-xl font-medium text-primary dark:text-primary-light"
/>
</div>
<!-- Description -->
<div class="mb-4">
<AnimatedText
content="M1 at HEC Paris. President of HEC IA. Freelance fullstack developer. Infrastructure enthusiast."
delay= {0.5}
duration= {0.5}
stagger= {0.015}
class="text-base text-neutral-700 dark:text-neutral-300"
/>
</div>
<!-- Button - AOS animation -->
<div
class=""
data-aos="fade-up-sm"
data-aos-delay="900"
data-aos-duration="500"
data-aos-once="true"
>
<Button
url= {siteConfig.social.github}
type="fill"
className="m-auto mt-4 text-center sm:text-left sm:m-0 sm:mt-6 max-w-[200px]"
>
View GitHub
</Button>
</div>
<!-- Social Cards - AOS animation -->
<div
class="social mt-8 mb-8"
data-aos="fade-up-sm"
data-aos-delay="1000"
data-aos-duration="600"
data-aos-once="true"
>
<SocialCard displaySocialIds= {[1, 2, 3]} />
</div>
</div>
<!-- Empty space on the right for now, can add hero image later -->
<div
class="relative justify-end w-full mt-16 md:flex md:pl-10 md:w-1/2 md:mt-0 md:translate-y-4 xl:translate-y-0"
data-aos="fade-left-sm"
data-aos-delay="300"
data-aos-duration="800"
data-aos-once="true"
>
</div>
</div>
</div>
<section
class="mt-26 mb-12"
data-aos="fade-up-sm"
data-aos-delay="100"
data-aos-duration="600"
data-aos-once="true"
>
<Explore title="Explore" />
</section>
<section
class="mt-26 mb-12"
data-aos="fade-up-sm"
data-aos-delay="100"
data-aos-duration="600"
data-aos-once="true"
>
<FeaturedWork
title="Projects"
description="What I've been working on."
limit= {3}
/>
</section>
</Layout>
</file>
<file
path=".gitea/workflows/build-image.yml">
name: Build and Push Docker Image
on:
push:
branches:
- main
- master
env:
REGISTRY: "gitea.vorpax.dev"
IMAGE_NAME: "vorpax_admin/vorpax-portfolio-main"
jobs:
build-and-push:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Gitea Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ gitea.actor }}
password: ${{ secrets.REGISTRY_TOKEN }}
- name: Extract metadata (tags, labels)
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=ref,event=branch
type=sha,prefix=
type=raw,value=latest,enable=${{ gitea.ref == format('refs/heads/{0}', 'main') || gitea.ref ==
format('refs/heads/{0}', 'master') }}
- name: Build and push Docker image
uses: docker/build-push-action@v6
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
</file>
<file
path="src/config/site.js"> // Get site URL from environment variable, use default value if not
set const SITE_URL = import.meta.env.PUBLIC_SITE_URL || 'https://vorpax.dev'; export const
siteConfig = { title: "Alexandre | Vorpax", author: "vorpax", url: SITE_URL, mail: "contact at
vorpax dot dev", resume: "/assets/resume.pdf", utm: { source: `${SITE_URL}`, medium: "referral",
campaign: "navigation", }, meta: { title: "Alexandre | Business x Engineering x Infrastructure",
description: "M1 HEC Paris | President HEC IA | Freelance Fullstack Developer | Infrastructure
Enthusiast", keywords: "portfolio, developer, infrastructure, homelab, HEC Paris, engineering,
fullstack", image: `${SITE_URL}/og.jpg`, twitterHandle: "", }, social: { github:
"https://github.com/vorpax", linkedin: "https://www.linkedin.com/in/alexandre-houard-686960279/",
gitea: "https://gitea.vorpax.dev/vorpax" }, }; // Footer social links export const socialLinks = [
{ name: 'Github', url: 'https://github.com/vorpax', icon: `<svg class="icon ic-github ic-social"
viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" 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"
fill="currentColor"></path>
</svg>` }, { name: 'LinkedIn', url:
'https://www.linkedin.com/in/alexandre-houard-686960279/', icon: `<svg
class="icon ic-linkedin ic-social" viewBox="0 0 1024 1024" version="1.1"
xmlns="http://www.w3.org/2000/svg" width="256" height="256">
<path
d="M847.7 112H176.3c-35.5 0-64.3 28.8-64.3 64.3v671.4c0 35.5 28.8 64.3 64.3 64.3h671.4c35.5 0 64.3-28.8 64.3-64.3V176.3c0-35.5-28.8-64.3-64.3-64.3zM365 798H229V404h136v394zm-68-448c-43.6 0-79-35.4-79-79s35.4-79 79-79 79 35.4 79 79-35.4 79-79 79zm519 448H680V607c0-45.6-0.8-104.2-63.5-104.2-63.5 0-73.3 49.6-73.3 100.9V798H407V404h130.4v53.9h1.8c18.2-34.4 62.6-70.7 128.8-70.7 137.9 0 163.3 90.7 163.3 208.6V798z"
fill="currentColor"></path>
</svg>` }, { name: 'RSS', url: '/rss.xml', icon: `<svg
class="icon ic-rss ic-social" viewBox="0 0 1024 1024" version="1.1"
xmlns="http://www.w3.org/2000/svg" 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"
fill="currentColor"></path>
</svg>` }, ]; </file>
</files>