All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m11s
5935 lines
223 KiB
XML
5935 lines
223 KiB
XML
<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">
|
||
← 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** -
|
||
快速、轻量级的静态站点生成器 - 🎨 **现代化设计** - 复古蓝色主题,支持暗色/亮色模式切换 - 📱 **完全响应式** - 适配各种设备尺寸 - 🎭 **精美动画** - 使用
|
||
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.
|
||
 
|
||
 ## ✨ 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—let’s 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 you’ve 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> |