This commit is contained in:
2025-11-26 13:48:13 +08:00
commit 82a13f4aad
62 changed files with 10778 additions and 0 deletions

289
README.md Normal file
View File

@@ -0,0 +1,289 @@
# 📸 Astro Photography Portfolio Template
A modern, fast, and highly customizable photography portfolio template built with [Astro](https://astro.build).
Ideal for photographers who want to showcase their work through a sleek, performant, and professional website.
## ✨ Features
- Lightning-fast performance with Astro
- Fully responsive design
- Optimized image loading and handling
- Easy to customize
- Easy to organized gallery via a yaml file
- Multiple albums support
- Image zoom capabilities
- Automatic deployment to GitHub pages
- Script to automatically create a gallery from images
## 🚀 Getting Started
### Prerequisites
- Check [AstroJS](https://docs.astro.build/en/install-and-setup/) documentation for prerequisites
- Basic knowledge of Astro and web development
### Installation
1. click "Use this template" on GitHub
2. Clone your newly created template
3. Install dependencies:
```bash
npm install
# or
yarn install
```
3. Start the development server:
```bash
npm run dev
# or
yarn dev
```
## 📝 Make it your own
### Configuration
Edit the `astro.config.ts` file to update your github pages details:
```typescript
export default defineConfig({
site: '<github pages domain>',
base: '<repository name>',
// ...
});
```
Edit the `site.config.mts` file to update your personal information:
```typescript
export default {
title: 'SR',
favicon: 'favicon.ico',
owner: 'Sara Richard',
// ... Other configurations
};
```
### Customize site icon
Replace `public/favicon.ico` with your icon and change the configuration
if your file has a different name/location.
### Customize the About page
- Replace the profile image (see [site.config.mts](site.config.mts) for configuration)
- Edit content in [about page](./src/pages/about.astro)
### Adding Your Photos
1. Place your images in the `src/gallery/<album>` directory
2. Update the gallery details in `src/gallery/gallery.yaml`. Optionally, you can run `npm run generate` to generate a
gallery.yaml file from the images in the directory.
3. Update meta-data for images in the `src/gallery/gallery.yaml` file.
4. Images are automatically optimized during build
### Adding photos to the featured section
"featured" is a builtin collection, and images can be added to it by specifying it in the collections parameter like any
other collection.
# 📸 Phase 1 二次开发技术文档
项目名称: Astro Photography Portfolio (Customized)
版本: Phase 1 Completed
日期: 2025-11-26
## 1. 核心功能变更概览
本次二次开发主要针对原版模版进行了跨平台兼容性修复、国际化 (i18n) 重构、视觉交互升级以及性能优化。
### 🛠️ 系统与脚本优化
1. **跨平台路径修复 (`npm run generate`)**
- **问题**: 原脚本在 Windows 下生成的 `gallery.yaml` 路径包含反斜杠 `\`,导致 Astro 在 Linux/Mac 环境或部署时无法识别图片。
- **解决**: 修改 `src/data/gallery-generator.ts`,引入 `toUnixPath` 函数,强制将所有路径分隔符转换为 Unix 风格 `/`
- **新增**: 脚本现在会自动识别文件名中包含 `_featured` 的图片,并自动添加 `featured` 标签。
- **新增**: 图片生成顺序改为按 EXIF 拍摄时间倒序排列(最新的在前)。
2. **EXIF 数据透传与展示**
- **问题**: 原版 `gallery.yaml` 虽然有 EXIF 数据,但前端并未读取和展示。
- **解决**:
- 修改 `src/data/galleryData.ts``imageStore.ts`,确保 `exif` 对象被完整传递给前端组件。
- 修改 `src/data/imageStore.ts` 中的 `builtInCollections`,添加 `cover` 到白名单,防止报错。
- 修改 `src/components/PhotoGrid.astro`,格式化 EXIF 数据 (相机、焦段、光圈、ISO) 并注入到 Lightbox (GLightbox) 的描述中。
3. **性能优化 (缩略图策略)**
- **优化**: 在 `PhotoGrid.astro` 中,强制使用 Astro 的 `<Image width={720} ... />` 组件。
- **效果**: 首页和列表页不再加载几千像素的原图,而是加载自动生成的 WebP 缩略图,大幅提升加载速度。
### 🌍 国际化 (i18n) 重构
1. **目录结构变更**
-`src/pages/*` 下的文件被移动至 `src/pages/zh/` (中文版) 和 `src/pages/en/` (英文版)。
- 根目录 `src/pages/index.astro` 仅作为重定向器,根据 `base` 路径自动跳转至默认语言 `/zh/`
- 组件引用的 Import 路径已全部修正为相对路径 (`../../../` 等)。
2. **双语支持**
- 新建 `src/i18n/ui.ts` 管理翻译字典。
- `src/components/NavBar.astro` 支持语言切换按钮,自动根据当前 URL 切换 `/zh/``/en/` 前缀。
- 首页组件 (`LandingHero`, `FeaturedGallery`) 被改造为支持 `props` 传参,允许在不同语言的 `index.astro` 中传入对应的翻译文案。
### 🎨 UI 与视觉升级
1. **全屏动态轮播背景 (`HeroBackground`)**
- 新建 `src/components/HeroBackground.astro` 组件。
- **逻辑**: 自动读取 `gallery.yaml` 中包含 `cover` 标签的图片。如果未找到,则回退显示前 3 张图片。
- **效果**: 全屏淡入淡出轮播,并添加深色遮罩以保证前景文字可读性。
2. **自适应导航栏 (`NavBar`)**
- **逻辑**: 页面顶部时背景透明、文字白色(配合深色轮播图);向下滚动后背景变白、文字变黑(保证阅读清晰度)。
- **修复**: 解决了 Astro Frontmatter 格式错误导致的页面顶部出现白色空白条和 `---` 符号的问题。
------
## 2. 使用指南 (给用户/朋友)
### 如何管理图片
所有操作均在 `src/gallery/gallery.yaml` 或文件系统中进行。
1. **添加新图片**: 将图片放入 `src/gallery/xxx/` 文件夹。
2. **生成数据**: 运行 `npm run generate`
3. **设置精选 (Featured)**:
- 方法 A: 图片文件名包含 `_featured` (如 `img_featured.jpg`),重新运行脚本。
- 方法 B: 在 `gallery.yaml` 中手动给图片添加 `- featured` 标签。
4. **设置首页轮播图 (Cover)**:
-`gallery.yaml` 中手动给图片添加 `- cover` 标签。
- *注意*: `cover` 标签不会出现在网站的分类过滤器中,仅用于首页背景。
------
## 3. 开发指南:如何添加新菜单项
如果你想添加一个全新的页面(例如:“视频 / Video”请严格按照以下步骤操作
### 第一步:创建页面文件
你需要分别为中文版和英文版创建页面文件。
1. **中文版**: 新建 `src/pages/zh/video.astro`
代码段
```
---
import MainLayout from '../../layouts/MainLayout.astro';
---
<MainLayout lang="zh" title="视频作品">
<div class="container mx-auto py-20">
<h1>我的视频</h1>
</div>
</MainLayout>
```
2. **英文版**: 新建 `src/pages/en/video.astro` (内容同上,语言改 `lang="en"`)。
### 第二步:定义菜单名称
打开 `src/i18n/ui.ts`,在字典中添加新菜单的翻译。
TypeScript
```
export const ui = {
zh: {
'nav.home': '首页',
'nav.gallery': '作品集',
'nav.about': '关于我',
'nav.video': '视频', // <--- 新增
},
en: {
'nav.home': 'Home',
'nav.gallery': 'Gallery',
'nav.about': 'About',
'nav.video': 'Video', // <--- 新增
},
};
```
### 第三步:添加到导航栏
打开 `src/components/NavBar.astro`,将新项加入 `menuItems` 数组。
TypeScript
```
// ...
const menuItems = [
{ name: t['nav.home'], link: homePath },
{ name: t['nav.gallery'], link: `${homePath}/collections` },
// 新增下面这一行
{ name: t['nav.video'], link: `${homePath}/video` },
{ name: t['nav.about'], link: `${homePath}/about` },
];
// ...
```
------
## 4. 已知待优化项 (Phase 2 计划)
1. **分类名称国际化**: 目前分类显示为文件夹名称(英文),尚未对接翻译字典。
2. **CMS 后台集成**: 计划集成 Decap CMS实现图形化管理图片和配置无需接触代码。
------
**Document End**

11
astro.config.mts Normal file
View File

@@ -0,0 +1,11 @@
import { defineConfig } from 'astro/config';
import tailwindcss from '@tailwindcss/vite';
// https://astro.build/config
export default defineConfig({
site: 'https://rockem.github.io',
base: 'astro-photography-portfolio',
vite: {
plugins: [tailwindcss()],
},
});

49
eslint.config.js Normal file
View File

@@ -0,0 +1,49 @@
import js from '@eslint/js';
import ts from '@typescript-eslint/eslint-plugin';
import tsParser from '@typescript-eslint/parser';
import astroPlugin from 'eslint-plugin-astro';
import astroParser from 'astro-eslint-parser';
import globals from 'globals';
export default [
js.configs.recommended,
{
ignores: ['dist/**', '.astro/**', 'node_modules/**', '.cache/**'],
},
{
files: ['**/*.ts'],
languageOptions: {
parser: tsParser,
parserOptions: {
project: './tsconfig.json',
sourceType: 'module',
},
globals: {
...globals.node,
...globals.browser,
},
},
plugins: {
'@typescript-eslint': ts,
},
rules: {
...ts.configs.recommended.rules,
},
},
{
files: ['**/*.astro'],
languageOptions: {
parser: astroParser,
parserOptions: {
parser: tsParser,
},
},
plugins: {
astro: astroPlugin,
},
rules: {
...astroPlugin.configs.recommended.rules,
},
},
];

8208
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

42
package.json Normal file
View File

@@ -0,0 +1,42 @@
{
"name": "astro-photography-portfolio",
"type": "module",
"version": "0.0.1",
"scripts": {
"dev": "astro dev",
"build": "astro build",
"preview": "astro preview",
"astro": "astro",
"prettier": "prettier . --write",
"test": "vitest",
"lint": "eslint . --ext .ts,.js,.astro",
"generate": "npx tsx src/data/gallery-generator.ts src/gallery"
},
"devDependencies": {
"@tailwindcss/typography": "^0.5.16",
"@tailwindcss/vite": "^4.0.14",
"@types/js-yaml": "^4.0.9",
"@types/justified-layout": "^4.1.4",
"@types/node": "^22.14.0",
"@typescript-eslint/eslint-plugin": "^8.31.0",
"@typescript-eslint/parser": "^8.31.0",
"astro": "^5.8.1",
"astro-eslint-parser": "^1.2.2",
"commander": "^13.1.0",
"eslint": "^9.25.1",
"eslint-plugin-astro": "^1.3.1",
"execa": "^9.5.2",
"exifr": "^7.1.3",
"fast-glob": "^3.3.3",
"glightbox": "^3.3.1",
"globals": "^16.0.0",
"js-yaml": "^4.1.0",
"justified-layout": "^4.1.0",
"lucide-astro": "^0.479.0",
"prettier": "3.5.3",
"prettier-plugin-astro": "^0.14.1",
"tailwindcss": "^4.0.14",
"tsx": "^4.19.3",
"vitest": "^3.1.1"
}
}

24
prettier.config.js Normal file
View File

@@ -0,0 +1,24 @@
/** @type {import("prettier").Config} */
export default {
printWidth: 100,
semi: true,
singleQuote: true,
tabWidth: 2,
trailingComma: 'all',
useTabs: true,
plugins: ['prettier-plugin-astro'],
overrides: [
{
files: ['.*', '*.md', '*.toml', '*.yml'],
options: {
useTabs: false,
},
},
{
files: ['**/*.astro'],
options: {
parser: 'astro',
},
},
],
};

0
public/.nojekyll Normal file
View File

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

BIN
public/images/profile.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 248 KiB

27
site.config.mts Normal file
View File

@@ -0,0 +1,27 @@
import type { AstroInstance } from 'astro';
import { Github, Instagram } from 'lucide-astro';
export interface SocialLink {
name: string;
url: string;
icon: AstroInstance;
}
export default {
title: 'SR',
favicon: 'favicon.ico',
owner: 'Sara Richard',
profileImage: 'profile.webp',
socialLinks: [
{
name: 'GitHub',
url: 'https://github.com/rockem/astro-photography-portfolio',
icon: Github,
} as SocialLink,
{
name: 'Instagram',
url: 'https://www.instagram.com',
icon: Instagram,
} as SocialLink,
],
};

View File

@@ -0,0 +1,22 @@
---
import PhotoGrid from './PhotoGrid.astro';
import { featuredCollectionId, getImages } from '../data/imageStore';
import { getImages } from '../data/imageStore';
import PhotoGrid from './PhotoGrid.astro';
// 新增:接收参数
const {
title = 'Featured Works',
subtitle = 'A selection of my best photographs from various adventures'
} = Astro.props;
const images = await getImages({ collection: featuredCollectionId });
---
<div class="max-w-2xl mx-auto text-center mb-16">
<h2 class="text-3xl md:text-4xl font-bold mb-4">{title}</h2>
<p class="text-gray-600 max-w-xl mx-auto">{subtitle}</p>
</div>
<div class="p-4">
<PhotoGrid images={images} />
</div>

View File

@@ -0,0 +1,40 @@
---
import { ChevronDown } from 'lucide-astro';
---
<div
id="featured-gallery-scroll"
class="absolute bottom-10 left-0 right-0 flex justify-center transition-opacity duration-300"
>
<button
onclick="scrollToContent();"
class="flex flex-col items-center text-gray-600 hover:text-gray-900 transition-colors hover:cursor-pointer"
aria-label="Scroll down to see featured work"
>
<ChevronDown size={24} />
</button>
</div>
<script is:inline>
const scrollToContent = () => {
window.scrollTo({
top: window.innerHeight,
behavior: 'smooth',
});
};
document.addEventListener('DOMContentLoaded', () => {
const button = document.getElementById('featured-gallery-scroll');
window.addEventListener('scroll', () => {
if (window.scrollY > 100) {
button.style.opacity = 0;
button.style.visibility = 'hidden';
} else {
button.style.visibility = 'visible';
button.style.opacity = 1;
}
});
});
</script>

View File

@@ -0,0 +1,23 @@
---
import siteConfig from '../../site.config.mjs';
import SocialIcon from './SocialIcon.astro';
const year = new Date().getFullYear();
const owner = siteConfig.owner;
const socialLinks = siteConfig.socialLinks;
---
<footer class="py-10 border-t border-gray-100 mt-auto">
<div class="container mx-auto">
<div class="flex flex-col md:flex-row justify-between items-center">
<div class="text-sm font-sans text-gray-500 mb-4 md:mb-0">
© {year}
{owner}. All rights reserved.
</div>
<div class="flex space-x-6">
{socialLinks.map((socialLink) => <SocialIcon socialLink={socialLink} />)}
</div>
</div>
</div>
</footer>

View File

@@ -0,0 +1,59 @@
---
import { Image } from 'astro:assets';
import { getImages } from '../data/imageStore';
// 修改点 1: 指定获取 'cover' 标签的图片
let images = await getImages({ collection: 'cover' });
// 如果你还没来得及在 yaml 里加 cover 标签,这里做一个兜底:
// 如果没找到 cover就还是显示 featured 或者前 3 张,防止首页全黑
if (images.length === 0) {
const allImages = await getImages({});
images = allImages.slice(0, 3);
}
// 修改点 2: 删除了随机排序的代码 (sort Math.random)
// 现在它会严格按照你在 gallery.yaml 里出现的顺序播放
---
<div id="hero-bg-container" class="absolute inset-0 w-full h-full overflow-hidden z-0">
{/* 遮罩层:为了配合你的白色文字,这里用半透明黑色 */}
<div class="absolute inset-0 bg-black/40 z-10"></div>
{
images.map((img, index) => (
<div
class={`hero-slide absolute inset-0 w-full h-full transition-opacity duration-[2000ms] ease-in-out ${
index === 0 ? 'opacity-100' : 'opacity-0'
}`}
data-index={index}
>
<Image
src={img.src}
width={1920}
height={1080}
format="webp"
quality={80}
class="w-full h-full object-cover"
alt="Background"
loading={index === 0 ? "eager" : "lazy"}
/>
</div>
))
}
</div>
<script>
// 轮播逻辑保持不变
const slides = document.querySelectorAll('.hero-slide');
if (slides.length > 1) {
let currentSlide = 0;
setInterval(() => {
slides[currentSlide].classList.remove('opacity-100');
slides[currentSlide].classList.add('opacity-0');
currentSlide = (currentSlide + 1) % slides.length;
slides[currentSlide].classList.remove('opacity-0');
slides[currentSlide].classList.add('opacity-100');
}, 5000);
}
</script>

View File

@@ -0,0 +1,40 @@
---
import siteConfig from '../../site.config.mjs';
const owner = siteConfig.owner;
// 移除这里的 base 定义,因为我们会从外面传入完整的链接
// const base = import.meta.env.BASE_URL;
const {
title = `${owner} Photography`,
subtitle = 'A place to showcase my photography',
btnText = 'View Gallery',
// 新增:接收跳转链接,默认指向英文版 (因为英文版通常不传参)
// 这里使用 import.meta.env.BASE_URL 确保有 base 路径时也能正常工作
btnLink = `${import.meta.env.BASE_URL}/en/collections`
} = Astro.props;
---
<div class="w-full relative z-20 px-4">
<div class="text-center mb-12">
<h1 class="text-4xl md:text-5xl lg:text-7xl font-bold leading-tight text-white drop-shadow-lg tracking-tight">
{title}
</h1>
<p class="mt-6 text-lg md:text-xl text-gray-200 drop-shadow-md font-light tracking-wide">
{subtitle}
</p>
<div class="mt-10">
<a
href={btnLink}
class="inline-block px-8 py-3 border border-white/80 rounded-full
text-white text-base md:text-lg tracking-wide
transition-all duration-300
hover:bg-white hover:text-black hover:scale-105 active:scale-95
backdrop-blur-sm bg-black/10"
>
{btnText}
</a>
</div>
</div>
</div>

View File

@@ -0,0 +1,33 @@
---
import siteConfig from '../../site.config.mjs';
const owner = siteConfig.owner;
const base = import.meta.env.BASE_URL;
---
<div
class="absolute top-[80px] bottom-[120px] left-0 right-0 z-0"
style={{
backgroundImage: 'url(/src/gallery/nature/nature-8.jpg)',
backgroundSize: 'cover',
backgroundPosition: 'center',
backgroundRepeat: 'no-repeat',
opacity: 0.6,
}}
>
</div>
<div class="container text-left mb-32 relative z-10">
<h1 class="text-3xl md:text-4xl lg:text-5xl font-bold leading-tight">
{owner} Photography
</h1>
<p class="mt-4 text-base md:text-lg text-gray-700">A place to showcase my photography</p>
<a
href=`${base}/collections`
class="mt-8 inline-block px-6 py-3 border-2 border-black rounded-md
hover:bg-black hover:text-white transition-all duration-300
text-sm md:text-base font-medium
transform hover:scale-105 active:scale-95"
>
View Gallery
</a>
</div>

110
src/components/NavBar.astro Normal file
View File

@@ -0,0 +1,110 @@
---
import siteConfig from '../../site.config.mjs';
import { languages, ui, defaultLang } from '../i18n/ui';
const { lang = defaultLang } = Astro.props;
const title = siteConfig.title;
const base = import.meta.env.BASE_URL;
const t = ui[lang as keyof typeof ui];
const langPath = lang === defaultLang ? '/zh' : `/${lang}`;
const cleanBase = base.endsWith('/') ? base.slice(0, -1) : base;
const homePath = `${cleanBase}${langPath}`;
const menuItems = [
{ name: t['nav.home'], link: homePath },
{ name: t['nav.gallery'], link: `${homePath}/collections` },
{ name: t['nav.about'], link: `${homePath}/about` },
];
const currentPath = Astro.url.pathname;
let switchLangLink = '';
let switchLabel = '';
if (currentPath.includes('/zh')) {
switchLangLink = currentPath.replace('/zh', '/en');
switchLabel = 'En';
} else if (currentPath.includes('/en')) {
switchLangLink = currentPath.replace('/en', '/zh');
switchLabel = '中';
} else {
switchLangLink = lang === 'zh' ? `${cleanBase}/en` : `${cleanBase}/zh`;
switchLabel = lang === 'zh' ? 'En' : '中';
}
// 判断是否为首页 (忽略末尾斜杠)
const normalize = (p: string) => p.replace(/\/$/, '');
const isHome = normalize(currentPath) === normalize(homePath);
---
<nav
x-data={`{ isOpen: false, scrolled: false, isHome: ${isHome} }`}
@scroll.window="scrolled = window.scrollY > 50"
class="fixed top-0 left-0 w-full z-50 transition-all duration-300"
:class="(scrolled || !isHome) ? 'py-3 text-black' : 'py-5 text-white'"
>
<div
class="absolute inset-0 transition-all duration-300 shadow-sm"
:class="(scrolled || !isHome) ? 'bg-white opacity-100' : 'opacity-0'"
></div>
<div class="container mx-auto flex justify-between items-center px-6 relative z-10">
<a href={homePath} class="text-2xl md:text-3xl font-bold tracking-tight hover:opacity-80 transition-opacity">
{title}
</a>
<div class="hidden md:flex items-center space-x-8">
{
menuItems.map((item) => (
<a
href={item.link}
class="text-sm uppercase tracking-widest font-medium hover:underline underline-offset-4"
>
{item.name}
</a>
))
}
<div class="border-l border-current pl-4 ml-2">
<a
href={switchLangLink}
class="text-sm font-bold hover:opacity-70"
>
{switchLabel}
</a>
</div>
</div>
<button @click="isOpen = !isOpen" class="md:hidden focus:outline-none">
<svg x-show="!isOpen" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"></path>
</svg>
<svg x-show="isOpen" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
</svg>
</button>
</div>
<div
x-show="isOpen"
x-transition
class="md:hidden absolute top-full left-0 w-full bg-white text-black shadow-lg"
>
<div class="flex flex-col py-4 px-6 space-y-4 text-center">
{
menuItems.map((item) => (
<a href={item.link} class="text-lg font-medium" @click="isOpen = false">
{item.name}
</a>
))
}
<div class="border-t border-gray-100 pt-4">
<a href={switchLangLink} class="inline-block px-4 py-1 bg-gray-100 rounded text-sm font-bold">
Switch to {switchLabel === '中' ? '中文' : 'English'}
</a>
</div>
</div>
</div>
</nav>

View File

@@ -0,0 +1,82 @@
---
import 'glightbox/dist/css/glightbox.css';
import '../styles/glightbox-custom.css';
import { Image } from 'astro:assets';
import type { Image as ImageType } from '../data/galleryData';
interface Props {
images: ImageType[];
}
const { images } = Astro.props;
// 辅助函数:生成 EXIF HTML 字符串
// 这样可以在 Lightbox 底部显示漂亮的参数
function getExifHTML(image: ImageType) {
const parts = [];
if (image.exif) {
if (image.exif.model) parts.push(image.exif.model);
if (image.exif.lensModel) parts.push(image.exif.lensModel);
if (image.exif.focalLength) parts.push(`${image.exif.focalLength}mm`);
if (image.exif.fNumber) parts.push(`f/${image.exif.fNumber}`);
if (image.exif.shutterSpeed) {
// 处理快门速度如果小于1秒显示分数
const ss = image.exif.shutterSpeed;
parts.push(ss >= 1 ? `${ss}s` : `1/${Math.round(1/ss)}s`);
}
if (image.exif.iso) parts.push(`ISO${image.exif.iso}`);
}
// 如果没有 EXIF只返回描述否则组合 HTML
const desc = image.description || '';
// 修改点在这里:
// 1. color: #000 (纯黑)
// 2. font-weight: 600 (加粗一点)
// 3. font-size: 0.85rem (稍微调整大小)
const exifStr = parts.length > 0
? `<div class="exif-data" style="color:#000; font-weight:600; font-size:0.85rem; margin-top:8px; line-height:1.4;">${parts.join(' <span style="color:#ccc">|</span> ')}</div>`
: '';
return desc + exifStr;
}
---
<section id="photo-grid" class="relative w-full mx-auto overflow-hidden">
{
images.map((image) => {
// 生成用于 Lightbox 的描述(包含 EXIF
const captionContent = getExifHTML(image);
return (
<a
href={image.src.src}
class="photo-item glightbox absolute transition-transform hover:scale-[1.02] hover:z-10"
data-gallery="gallery1"
data-type="image"
data-glightbox={`description: ${captionContent}`}
>
<Image
src={image.src}
quality={80}
format="webp"
/* 关键性能修复:
原代码使用了 image.src.width (原图尺寸),导致加载极其缓慢。
这里强制设置为 720pxAstro 会自动生成小体积的 WebP 缩略图。
density={[1.5, 2]} 确保高清屏下依然清晰。
*/
width={720}
densities={[1.5, 2]}
class="w-full h-full object-cover rounded-sm shadow-sm hover:shadow-lg transition-shadow"
alt={image.title || 'Photography'}
/>
</a>
)})
}
</section>
<script>
import '../scripts/photo-grid';
// 确保 GLightbox 配置允许 HTML
// 注意:你可能需要检查 ../scripts/photo-grid 里的初始化代码
// 确保它没有覆盖 slideHTML 或者允许了解析 HTML
</script>

View File

@@ -0,0 +1,15 @@
---
const { socialLink } = Astro.props;
const Icon = socialLink.icon;
---
<a
href={socialLink.url}
target="_blank"
rel="noopener noreferrer"
class="text-gray-500 hover:text-gray-800 transition-colors duration-200"
aria-label={socialLink.name}
>
<Icon size={20} />
</a>

14
src/content/about.md Normal file
View File

@@ -0,0 +1,14 @@
---
title: About me
---
### Hi, I'm Sara Richard 👋
I've been a photographer for over 10 years, focusing primarily on landscape and portrait photography. My journey began
with a simple point-and-shoot camera while traveling through the mountains of Colorado, which sparked a passion that has
taken me across the globe.
My approach to photography centers on finding the extraordinary in ordinary moments. I believe that beauty exists
everywhere in urban streets, remote wilderness, and human connections. My goal is to capture these fleeting instances
in a way that allows viewers to see the world through a different lens.
When I'm not behind the camera, you can find me hiking in national parks, exploring new cities, or enjoying a cup of
coffee at local cafés while planning my next photo adventure.

View File

@@ -0,0 +1,18 @@
import { expect } from 'vitest';
export const expectContainsOnlyObjectsWith = (objArray: unknown[], partials: unknown[]) => {
expect(objArray).toHaveLength(partials.length);
const expectedWrapped = partials.map((partial) => wrapWithObjectContaining(partial));
expect(objArray).toEqual(expect.arrayContaining(expectedWrapped));
};
const wrapWithObjectContaining = (value: unknown): unknown => {
if (value && typeof value === 'object' && !Array.isArray(value) && !(value instanceof Date)) {
const wrappedEntries = Object.entries(value).map(([key, val]) => [
key,
wrapWithObjectContaining(val),
]);
return expect.objectContaining(Object.fromEntries(wrappedEntries));
}
return value;
};

View File

@@ -0,0 +1,10 @@
collections:
- id: kuku
name: Kuku
images:
- path: kuku/kuku-trees.jpg
meta:
title: Kuku Trees
description: Beautiful trees in the kuku album
collections: [kuku, invalid]

View File

@@ -0,0 +1,15 @@
collections:
- id: kuku
name: Kuku
- id: popo
name: Popo
images:
- alt: Kuku Trees
description: Beautiful trees in the kuku album
collections: [kuku]
- path: popo/popo-view.jpg
alt: Popo View
featured: true
collections: [popo]

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 396 KiB

View File

@@ -0,0 +1,34 @@
collections:
- id: kuku
name: Kuku
- id: popo
name: Popo
images:
- path: kuku/kuku-trees.jpg
meta:
title: Kuku Trees
description: Beautiful trees in the kuku album
collections: [kuku]
exif:
captureDate: 2025-04-28T08:22:56.000Z
- path: popo/popo-view.jpg
meta:
title: Popo View
description: Amazing view from the popo album
collections: [featured, popo]
exif:
captureDate: 2025-03-27T08:22:56.000Z
- path: landscape.jpg
meta:
title: Landscape
description:
collections: [featured]
- path: popo/unknown.jpg
meta:
title: Unknown image
description: bla bla
collections: [popo]

View File

@@ -0,0 +1,29 @@
import { describe, expect, it } from 'vitest';
import { createGalleryImage } from '../galleryEntityFactory.ts';
import path from 'path';
const testGalleryPath = path.join('src', 'data', '__tests__', 'gallery');
describe('test create gallery images', () => {
it('should retrieve image with exif data', async () => {
const image = await createGalleryImage(
testGalleryPath,
path.join(testGalleryPath, 'kuku', 'kuku-trees.jpg'),
);
expect(image.exif?.captureDate).toEqual(new Date('2025-02-21T09:17:14.000Z'));
expect(image.exif?.fNumber).toEqual(8);
expect(image.exif?.focalLength).toEqual(28);
expect(image.exif?.iso).toEqual(100);
expect(image.exif?.shutterSpeed).toEqual(640);
expect(image.exif?.model).toEqual('LEICA Q3');
expect(image.exif?.lensModel).toEqual('SUMMILUX 1:1.7/28 ASPH.');
});
it('should retrieve image without exif data', async () => {
const image = await createGalleryImage(
testGalleryPath,
path.join(__dirname, 'without-exif.jpg'),
);
expect(image.exif).toEqual({});
});
});

View File

@@ -0,0 +1,155 @@
import { beforeAll, describe, expect, it } from 'vitest';
import { execa } from 'execa';
import path from 'path';
import * as fs from 'node:fs';
import {
type GalleryData,
type GalleryImage,
loadGallery,
type Meta,
type ImageExif,
} from '../galleryData.ts';
import { expectContainsOnlyObjectsWith } from './expect_util.ts';
import yaml from 'js-yaml';
const testGalleryPath = 'src/data/__tests__/gallery';
const testGalleryYaml = path.join('src/data/__tests__/gallery', 'gallery.yaml');
const scriptPath = path.resolve(__dirname, '../gallery-generator.ts');
describe('Test Gallery Generator', () => {
let gallery: GalleryData;
beforeAll(async () => {
await fs.promises.rm(path.join(testGalleryYaml), { force: true });
await generateGallery();
});
async function generateGallery() {
await execa('npx', ['tsx', scriptPath, testGalleryPath]);
gallery = await loadGallery(testGalleryYaml);
}
describe('Collections', () => {
it('should add directories as collections', () => {
expectContainsOnlyObjectsWith(gallery.collections, [{ id: 'kuku' }, { id: 'popo' }]);
});
it('should add collection camel case names', () => {
expectContainsOnlyObjectsWith(gallery.collections, [{ name: 'Kuku' }, { name: 'Popo' }]);
});
});
describe('Images', () => {
it('should add images path', () => {
expectContainsOnlyObjectsWith(gallery.images, [
{ path: 'kuku/kuku-trees.jpg' },
{ path: 'kuku/kuku-bubble.jpg' },
{ path: 'popo/popo-view.jpg' },
{ path: 'landscape.jpg' },
]);
});
it('should add images name and description', () => {
expectContainsOnlyObjectsWith(gallery.images, [
{ meta: { title: 'Kuku Trees', description: '' } },
{ meta: { title: 'Kuku Bubble', description: '' } },
{ meta: { title: 'Popo View', description: '' } },
{ meta: { title: 'Landscape', description: '' } },
]);
});
it('should add images to collection by directory', () => {
expectContainsOnlyObjectsWith(gallery.images, [
{ path: 'kuku/kuku-trees.jpg', meta: { collections: ['kuku'] } },
{ path: 'kuku/kuku-bubble.jpg', meta: { collections: ['kuku'] } },
{ path: 'popo/popo-view.jpg', meta: { collections: ['popo'] } },
{ path: 'landscape.jpg', meta: { collections: [] } },
]);
});
});
describe('Error handling', () => {
it('should fail on invalid gallery path', async () => {
await expect(execa('npx', ['tsx', scriptPath, 'invalid-path'])).rejects.toThrow(
'Invalid directory path provided.',
);
});
});
describe('Existing gallery', () => {
function findImageByPath(path: string): GalleryImage {
const image = gallery.images.find((img) => img.path === path);
if (!image) throw new Error(`Image [${path}] not found`);
return image;
}
it('should not override an existing image meta data', async () => {
const imagePath = 'kuku/kuku-trees.jpg';
const imageCustomMeta = {
title: 'Custom Title',
description: 'Custom Description',
collections: ['featured'],
};
await updateGalleryImageMeta(imagePath, imageCustomMeta);
await generateGallery();
const updatedImage = findImageByPath(imagePath);
expect(updatedImage.meta).toEqual(imageCustomMeta);
});
async function updateGalleryImageMeta(path: string, imageMeta: Meta) {
const image = findImageByPath(path);
image.meta = imageMeta;
await writeGalleryFile();
}
async function writeGalleryFile() {
await fs.promises.writeFile(testGalleryYaml, yaml.dump(gallery), 'utf8');
}
it('should not update collection if already exists', async () => {
const collectionId = 'kuku';
const customCollectionName = 'Hello Kuku';
await updateCollectionName(collectionId, customCollectionName);
await generateGallery();
const updatedCollection = findCollectionById(collectionId);
expect(updatedCollection.name).toEqual(customCollectionName);
});
async function updateCollectionName(collectionId: string, collectionName: string) {
const collection = findCollectionById(collectionId);
if (!collection) throw new Error(`Collection [${collectionId}] not found`);
collection.name = collectionName;
await writeGalleryFile();
}
function findCollectionById(collectionId: string) {
const collection = gallery.collections.find((col) => col.id === collectionId);
if (!collection) throw new Error(`Collection [${collectionId}] not found`);
return collection;
}
it('should override existing exif data', async () => {
const imagePath = 'kuku/kuku-trees.jpg';
const image = findImageByPath(imagePath);
const actualExif = Object.assign({}, image.exif);
await updateGalleryImageExif(imagePath, { ...actualExif, focalLength: 10 });
await generateGallery();
expect(findImageByPath(imagePath).exif).toEqual(actualExif);
});
async function updateGalleryImageExif(path: string, imageExif: ImageExif) {
const image = findImageByPath(path);
image.exif = imageExif;
await writeGalleryFile();
}
});
});

View File

@@ -0,0 +1,95 @@
import { describe, expect, it } from 'vitest';
import kukuTrees from './gallery/kuku/kuku-trees.jpg';
import popoView from './gallery/popo/popo-view.jpg';
import landscape from './gallery/landscape.jpg';
import { getCollections, getImages, ImageStoreError } from '../imageStore.ts';
const GALLERY = {
VALID: 'src/data/__tests__/gallery/valid-gallery.yaml',
INVALID: 'src/data/__tests__/gallery/invalid-gallery.yaml',
MISSING: 'src/data/__tests__/gallery/no-gallery.yaml',
INVALID_COLLECTION: 'src/data/__tests__/gallery/invalid-collection-gallery.yaml',
};
describe('Images Store', () => {
describe('Get Images', () => {
it('should retrieve all present images', async () => {
const imagesData = await getImages({ galleryPath: GALLERY.VALID });
expect(imagesData).toHaveLength(3);
expect(imagesData[0].src).toEqual(kukuTrees);
expect(imagesData[1].src).toEqual(popoView);
expect(imagesData[2].src).toEqual(landscape);
});
it('should retrieve images of specific collection', async () => {
const images = await getImages({ galleryPath: GALLERY.VALID, collection: 'featured' });
expect(images).toHaveLength(2);
expect(images[0].src).toEqual(popoView);
expect(images[1].src).toContain(landscape);
});
it('should retrieve title & description', async () => {
const images = await getImages({ galleryPath: GALLERY.VALID, collection: 'popo' });
expect(images).toHaveLength(1);
expect(images[0].title).toEqual('Popo View');
expect(images[0].description).toContain('popo album');
});
describe('Failures', () => {
it('should fail on a missing gallery file', async () => {
await expect(getImages({ galleryPath: GALLERY.MISSING })).rejects.toThrow(ImageStoreError);
});
it('should fail on invalid gallery file', async () => {
await expect(getImages({ galleryPath: GALLERY.INVALID })).rejects.toThrow(ImageStoreError);
});
it('should fail on invalid collection', async () => {
await expect(getImages({ galleryPath: GALLERY.INVALID_COLLECTION })).rejects.toThrow(
ImageStoreError,
);
});
});
describe('Sorting', () => {
it('should sort images by capture date', async () => {
const images = await getImages({ galleryPath: GALLERY.VALID, sortBy: 'captureDate' });
expect(images[0].src).toEqual(landscape);
expect(images[1].src).toEqual(popoView);
expect(images[2].src).toEqual(kukuTrees);
});
it('should sort images by capture date in descending order', async () => {
const images = await getImages({
galleryPath: GALLERY.VALID,
sortBy: 'captureDate',
order: 'desc',
});
expect(images[0].src).toEqual(kukuTrees);
expect(images[1].src).toEqual(popoView);
expect(images[2].src).toEqual(landscape);
});
it('should retrieve images in reverse order', async () => {
const images = await getImages({
galleryPath: GALLERY.VALID,
order: 'desc',
});
expect(images[0].src).toEqual(landscape);
expect(images[1].src).toEqual(popoView);
expect(images[2].src).toEqual(kukuTrees);
});
});
});
describe('Get Collections', () => {
it('should retrieve all collection names', async () => {
const collections = await getCollections(GALLERY.VALID);
expect(collections).toHaveLength(2);
expect(collections[0].id).toEqual('kuku');
expect(collections[0].name).toEqual('Kuku');
expect(collections[1].id).toEqual('popo');
expect(collections[1].name).toEqual('Popo');
});
});
});

Binary file not shown.

After

Width:  |  Height:  |  Size: 303 KiB

View File

@@ -0,0 +1,176 @@
import { program } from 'commander';
import * as fs from 'node:fs';
import yaml from 'js-yaml';
import path from 'path';
import fg from 'fast-glob';
import { type GalleryData, loadGallery, NullGalleryData, type GalleryImage } from './galleryData.ts';
import { createGalleryCollection, createGalleryImage } from './galleryEntityFactory.ts';
const defaultGalleryFileName = 'gallery.yaml';
// 辅助函数:强制将路径转换为 Unix 风格
function toUnixPath(filepath: string) {
return filepath.split(path.sep).join('/');
}
async function generateGalleryFile(galleryDir: string): Promise<void> {
try {
let galleryObj = await loadExistingGallery(galleryDir);
const newGalleryObj = await createGalleryObjFrom(galleryDir);
galleryObj = mergeGalleriesObj(galleryObj, newGalleryObj);
await writeGalleryYaml(galleryDir, galleryObj);
} catch (error) {
console.error('Failed to create gallery file:', error);
process.exit(1);
}
}
async function loadExistingGallery(galleryDir: string) {
const existingGalleryFile = path.join(galleryDir, defaultGalleryFileName);
if (fs.existsSync(existingGalleryFile)) {
return await loadGallery(existingGalleryFile);
}
return NullGalleryData;
}
function mergeGalleriesObj(
targetGalleryObj: GalleryData,
sourceGalleryObj: GalleryData,
): GalleryData {
// 1. 合并图片并保留旧数据
const mergedImages = getUpdatedImageList(targetGalleryObj, sourceGalleryObj);
// 2. 智能排序:按拍摄时间倒序 (最新的在最前)
// 如果没有拍摄时间,则按文件名排序作为兜底
mergedImages.sort((a, b) => {
const dateA = a.exif?.captureDate ? new Date(a.exif.captureDate).getTime() : 0;
const dateB = b.exif?.captureDate ? new Date(b.exif.captureDate).getTime() : 0;
if (dateB !== dateA) {
return dateB - dateA; // 倒序
}
return a.path.localeCompare(b.path); // 文件名兜底
});
return {
collections: getUpdatedCollectionList(targetGalleryObj, sourceGalleryObj),
images: mergedImages,
};
}
function getUpdatedImageList(targetGalleryObj: GalleryData, sourceGalleryObj: GalleryData) {
const imagesMap = new Map(targetGalleryObj.images.map((image) => [image.path, image]));
sourceGalleryObj.images.forEach((newImage) => {
const existingImage = imagesMap.get(newImage.path);
if (existingImage === undefined) {
// 如果是新图片,直接添加
imagesMap.set(newImage.path, newImage);
} else {
// 如果图片已存在,更新 EXIF但保留手动修改的 Meta (标题、描述)
// 注意:这里我们强制更新 Collections 以支持自动 Feature
// 但如果你希望手动移除 Feature 后不被脚本加回来,逻辑需要微调。
// 目前逻辑:脚本检测到 _featured 会强制加上。
// 检查是否需要合并 featured 标签
const newIsFeatured = newImage.meta.collections?.includes('featured');
const oldCollections = existingImage.meta.collections || [];
if (newIsFeatured && !oldCollections.includes('featured')) {
existingImage.meta.collections = [...oldCollections, 'featured'];
}
// 更新 EXIF (以防你重新处理了图片)
existingImage.exif = newImage.exif;
}
});
return Array.from(imagesMap.values());
}
function getUpdatedCollectionList(targetGalleryObj: GalleryData, sourceGalleryObj: GalleryData) {
const collectionsMap = new Map(
targetGalleryObj.collections.map((collection) => [collection.id, collection]),
);
sourceGalleryObj.collections.forEach((collection) => {
if (!collectionsMap.get(collection.id)) {
collectionsMap.set(collection.id, collection);
}
});
return Array.from(collectionsMap.values());
}
async function createGalleryObjFrom(galleryDir: string): Promise<GalleryData> {
const imageFiles = await fg(`${galleryDir}/**/*.{jpg,jpeg,png}`, {
dot: false,
});
return {
collections: createCollectionsFrom(imageFiles, galleryDir),
images: await createImagesFrom(imageFiles, galleryDir),
};
}
function createCollectionsFrom(imageFiles: string[], galleryDir: string) {
const uniqueDirNames = new Set(
imageFiles.map((file) => {
const relPath = toUnixPath(path.relative(galleryDir, file));
return path.posix.dirname(relPath);
}),
);
return [...uniqueDirNames]
.map((dir) => {
return createGalleryCollection(dir);
})
.filter((col) => col.id !== '.');
}
async function createImagesFrom(imageFiles: string[], galleryDir: string) {
const rawImages = await Promise.all(
imageFiles.map((file) => createGalleryImage(galleryDir, file)),
);
return rawImages.map((img) => {
// 修复路径
if (img.path) {
img.path = toUnixPath(img.path);
}
// 自动识别 Featured如果文件名包含 "_featured"
// 注意:这取决于你的文件名是大写还是小写,这里做不区分大小写匹配
const filename = path.basename(img.path).toLowerCase();
if (filename.includes('_featured') || filename.includes('-featured')) {
// 初始化 collections 数组
if (!img.meta.collections) {
img.meta.collections = [];
}
// 避免重复添加
if (!img.meta.collections.includes('featured')) {
img.meta.collections.push('featured');
}
}
return img;
});
}
async function writeGalleryYaml(galleryDir: string, galleryObj: GalleryData) {
const filePath = path.join(galleryDir, defaultGalleryFileName);
await fs.promises.writeFile(filePath, yaml.dump(galleryObj), 'utf8');
console.log('Gallery file created/updated successfully at:', filePath);
}
program.argument('<path to images directory>');
program.parse();
const directoryPath = program.args[0];
if (!directoryPath || !fs.existsSync(directoryPath)) {
console.error('Invalid directory path provided.');
process.exit(1);
}
(async () => {
await generateGalleryFile(directoryPath);
})().catch((error) => {
console.error('Unhandled error:', error);
process.exit(1);
});

102
src/data/galleryData.ts Normal file
View File

@@ -0,0 +1,102 @@
import type { ImageMetadata } from 'astro';
import path from 'path';
import { promises as fs } from 'fs';
import * as yaml from 'js-yaml';
/**
* Structure of the collections YAML file
* @property {Collection[]} collections - Array of collections
*/
export interface GalleryData {
collections: Collection[];
images: GalleryImage[];
}
/**
* Represents a collection of images
* @property {string} name - Name of the collection
* @property {GalleryImage[]} getImages - Array of images in the collection
*/
export interface Collection {
id: string;
name: string;
}
/**
* Represents an image entry in the collections YAML file
* @property {string} path - Relative path to the image file
* @property {string} alt - Alt text for accessibility and title
* @property {string} description - Detailed description of the image
* @property {string[]} collections - Array of collection IDs the image belongs to
*/
export interface GalleryImage {
path: string;
meta: Meta;
exif?: ImageExif;
}
/**
* Represents the metadata of an image
* @property {string} path - Relative path to the image file
* @property {string} title - Title of the image
* @property {string} description - Detailed description of the image
* @property {string[]} collections - Array of collection IDs the image belongs to
*/
export interface Meta {
title: string;
description: string;
collections: string[];
}
/**
* Represents the EXIF data of an image
* @property {number} [focalLength] - Focal length of the lens
* @property {number} [iso] - ISO sensitivity
* @property {number} [fNumber] - Aperture value
* @property {number} [shutterSpeed] - Shutter speed
* @property {Date} [captureDate] - Date and time of capture
* @property {string} [model] - Camera model
* @property {string} [lensModel] - Lens model
*/
export interface ImageExif {
focalLength?: number;
iso?: number;
fNumber?: number;
shutterSpeed?: number;
captureDate?: Date;
model?: string;
lensModel?: string;
}
/**
* Represents a processed image with metadata
* @property {ImageMetadata} src - Image source metadata from Astro
* @property {string} alt - Alt text for accessibility
* @property {string} description - Detailed description of the image
* @property {string[]} collections - Array of collection IDs the image belongs to
* @property {ImageExif} [exif] - (新增) EXIF data
*/
export interface Image {
src: ImageMetadata;
title: string;
description: string;
collections: string[];
exif?: ImageExif; // <--- 添加这一行
}
/**
* Type for the image module import result
* @property {ImageMetadata} default - Default export containing image metadata
*/
export type ImageModule = { default: ImageMetadata };
export const loadGallery = async (galleryPath: string): Promise<GalleryData> => {
const yamlPath = path.resolve(process.cwd(), galleryPath);
const content = await fs.readFile(yamlPath, 'utf8');
return yaml.load(content) as GalleryData;
};
export const NullGalleryData: GalleryData = {
collections: [],
images: [],
};

View File

@@ -0,0 +1,53 @@
import path from 'path';
import type { GalleryImage } from './galleryData.ts';
import exifr from 'exifr';
export const createGalleryImage = async (
galleryDir: string,
file: string,
): Promise<GalleryImage> => {
const relativePath = path.relative(galleryDir, file);
const exifData = await exifr.parse(file);
const image = {
path: relativePath,
meta: {
title: toReadableCaption(path.basename(relativePath, path.extname(relativePath))),
description: '',
collections: collectionIdForImage(relativePath),
},
exif: {},
};
if (exifData) {
image.exif = {
captureDate: exifData.DateTimeOriginal
? new Date(`${exifData.DateTimeOriginal} UTC`)
: undefined,
fNumber: exifData.FNumber,
focalLength: exifData.FocalLength,
iso: exifData.ISO,
model: exifData.Model,
shutterSpeed: 1 / exifData.ExposureTime,
lensModel: exifData.LensModel,
};
}
return image;
};
function toReadableCaption(input: string): string {
return input
.replace(/[^a-zA-Z0-9]+/g, ' ') // Replace non-alphanumerics with space
.split(' ') // Split by space
.map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()) // Capitalize
.join(' ');
}
function collectionIdForImage(relativePath: string) {
return path.dirname(relativePath) === '.' ? [] : [path.dirname(relativePath)];
}
export const createGalleryCollection = (dir: string) => {
return {
id: dir,
name: toReadableCaption(dir),
};
};

176
src/data/imageStore.ts Normal file
View File

@@ -0,0 +1,176 @@
import path from 'path';
import {
type Collection,
type GalleryData,
type GalleryImage,
type Image,
type ImageModule,
loadGallery,
} from './galleryData.ts';
/**
* Error class for image-related errors
*/
export class ImageStoreError extends Error {
constructor(message: string) {
super(message);
this.name = 'ImageStoreError';
}
}
/**
* Import all images from /src directory
*/
const imageModules = import.meta.glob('/src/**/*.{jpg,jpeg,png,gif}', {
eager: true,
});
const defaultGalleryPath = 'src/gallery/gallery.yaml';
export const featuredCollectionId = 'featured';
const builtInCollections = [featuredCollectionId, 'cover'];
/**
* Options for retrieving images from the gallery
* @property {string} [galleryPath] - Path to the gallery YAML file
* @property {string} [collection] - Collection name to filter images by
* @property {string} [sortBy] - Property to sort images by (e.g., 'captureDate')
* @property {'asc' | 'desc'} [order] - Sort order, either ascending or descending
*/
interface GetImagesOptions {
galleryPath?: string;
collection?: string;
sortBy?: 'captureDate';
order?: 'asc' | 'desc';
}
/**
* Retrieves images from a specified gallery path and optionally filters them by a collection name.
*
* @param {GetImagesOptions} [options={}] - Configuration options for retrieving the images.
* @param {string} [options.galleryPath=defaultGalleryPath] - The path to the gallery to load the images from.
* @param {string} [options.collection] - The name of the collection to filter images by. If not provided, all images are retrieved.
* @returns {Promise<Image[]>} Retrieved images.
* @throws {ImageStoreError} Throws an error if loading the gallery data fails.
*/
export const getImages = async (options: GetImagesOptions = {}): Promise<Image[]> => {
const { galleryPath = defaultGalleryPath, collection } = options;
try {
let images = (await loadGalleryData(galleryPath)).images;
images = filterImagesByCollection(collection, images);
images = sortImages(images, options);
return processImages(images, galleryPath);
} catch (error) {
throw new ImageStoreError(
`Failed to load images from ${galleryPath}: ${getErrorMsgFrom(error)}`,
);
}
};
function getErrorMsgFrom(error: unknown) {
return error instanceof Error ? error.message : 'Unknown error';
}
/**
* Loads collections data from YAML file
* @throws {ImageStoreError} If YAML file cannot be read or parsed
* @param galleryPath
*/
const loadGalleryData = async (galleryPath: string): Promise<GalleryData> => {
try {
const gallery = await loadGallery(galleryPath);
validateGalleryData(gallery);
return gallery;
} catch (error) {
throw new ImageStoreError(
`Failed to load gallery data from ${galleryPath}: ${getErrorMsgFrom(error)}`,
);
}
};
function filterImagesByCollection(collection: string | undefined, images: GalleryImage[]) {
if (collection) {
images = images.filter((image) => image.meta.collections.includes(collection));
}
return images;
}
function validateGalleryData(gallery: GalleryData) {
const collectionIds = gallery.collections.map((col) => col.id).concat(builtInCollections);
for (const image of gallery.images) {
const invalidCollections = image.meta.collections.filter((col) => !collectionIds.includes(col));
if (invalidCollections.length > 0) {
throw new ImageStoreError(
`Invalid collection(s) [${invalidCollections.join(', ')}] referenced in image: ${image.path}`,
);
}
}
}
function sortImages(images: GalleryImage[], options: GetImagesOptions) {
const { sortBy, order } = options;
let result: GalleryImage[] = images;
if (sortBy) {
result.sort((a, b) => {
const dateA = a.exif?.captureDate?.getTime() || 0;
const dateB = b.exif?.captureDate?.getTime() || 0;
return dateA - dateB;
});
}
if (order === 'desc') {
result.reverse();
}
return result;
}
/**
* Processes gallery images and returns an array of Image objects
* @param {GalleryImage[]} images - Array of images to process
* @param {string} galleryPath - Path to the collections directory
* @returns {Image[]} Array of processed images with metadata
* @throws {ImageStoreError} If an image module cannot be found
*/
const processImages = (images: GalleryImage[], galleryPath: string): Image[] => {
return images.reduce<Image[]>((acc, imageEntry) => {
const imagePath = path.posix.join('/', path.parse(galleryPath).dir, imageEntry.path);
try {
acc.push(createImageDataFor(imagePath, imageEntry));
} catch (error) {
console.warn(`[WARN] ${getErrorMsgFrom(error)}`);
}
return acc;
}, []);
};
/**
* Creates image data for a given image path and entry
* @param {string} imagePath - Path to the image file
* @param {GalleryImage} img - Gallery image entry
* @returns {Image} Processed image with metadata
* @throws {ImageStoreError} If image module cannot be found
*/
const createImageDataFor = (imagePath: string, img: GalleryImage): Image => {
const imageModule = imageModules[imagePath] as ImageModule | undefined;
if (!imageModule) {
throw new ImageStoreError(`Image not found: ${imagePath}`);
}
return {
src: imageModule.default,
title: img.meta.title,
description: img.meta.description,
collections: img.meta.collections,
exif: img.exif, // <--- 添加这一行,将 YAML 里的 EXIF 传给前端
};
};
/**
* Retrieves all collections from the gallery
* @param galleryPath - Path to the gallery YAML file
* @returns {Promise<Collection[]>} Array of collections
*/
export const getCollections = async (
galleryPath: string = defaultGalleryPath,
): Promise<Collection[]> => {
return (await loadGalleryData(galleryPath)).collections;
};

90
src/gallery/gallery.yaml Normal file
View File

@@ -0,0 +1,90 @@
collections:
- id: nature
name: Nature
- id: travel
name: Travel
- id: street
name: Street
images:
- path: nature/nature-2.jpg
meta:
title: Nature scene 2
description: Serene natural landscape capturing the essence of wilderness.
collections:
- nature
exif: {}
- path: nature/nature-3.jpg
meta:
title: Nature scene 3
description: Breathtaking natural vista showcasing environmental beauty.
collections:
- featured
- nature
exif: {}
- path: nature/nature-5.jpg
meta:
title: Nature scene 5
description: Natural landscape showing the raw beauty of the environment.
collections:
- nature
exif: {}
- path: nature/nature-7.jpg
meta:
title: Nature scene 7
description: Stunning natural landscape capturing environmental beauty.
collections:
- featured
- nature
exif: {}
- path: nature/nature-8.jpg
meta:
title: Nature scene 8
description: Beautiful natural vista showcasing environmental wonder.
collections:
- nature
exif: {}
- path: nature/L1002174.jpg
meta:
title: Travel scene 6
description: null
collections:
- featured
- nature
exif:
captureDate: 2025-03-27T10:35:21.000Z
fNumber: 8
focalLength: 28
iso: 100
model: LEICA Q3
shutterSpeed: 1250
lensModel: SUMMILUX 1:1.7/28 ASPH.
- path: nature/nature-10.jpg
meta:
title: Nature scene 10
description: Majestic natural landscape displaying environmental splendor.
collections:
- nature
exif: {}
- path: street/stree-1.jpg
meta:
title: Street photography 1
description: Urban scene capturing the essence of city life.
collections:
- featured
- street
exif: {}
- path: travel/travel-1.jpg
meta:
title: Travel scene 1
description: Journey through remarkable destinations.
collections:
- featured
- travel
exif: {}
- path: nature/nature-1.jpg
meta:
title: Nature 1
description: ''
collections:
- nature
exif: {}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 303 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 331 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 579 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 511 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 299 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 298 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 476 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 356 KiB

22
src/i18n/ui.ts Normal file
View File

@@ -0,0 +1,22 @@
// src/i18n/ui.ts
export const languages = {
zh: '中',
en: 'En',
};
export const defaultLang = 'zh';
export const ui = {
zh: {
'nav.home': '首页',
'nav.gallery': '作品集',
'nav.about': '关于我',
},
en: {
'nav.home': 'Home',
'nav.gallery': 'Gallery',
'nav.about': 'About',
},
} as const;

View File

@@ -0,0 +1,41 @@
---
import '../styles/global.css';
import siteConfig from '../../site.config.mjs';
import Footer from '../components/Footer.astro';
import NavBar from '../components/NavBar.astro';
const owner = siteConfig.owner;
const favicon = siteConfig.favicon;
const base = import.meta.env.BASE_URL;
// 接收 lang 参数
const { title, lang = 'zh' } = Astro.props;
---
<html lang={lang} class="h-full scroll-smooth">
<head>
<meta charset="utf-8" />
<link rel="icon" type="image/x-icon" href={`${base}/${favicon}`} />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
{/* 推荐字体Playfair Display 比较适合摄影标题,这里保留了原本的配置 */}
<link
href="https://fonts.googleapis.com/css2?family=Dancing+Script:wght@400;500;600;700&display=swap"
rel="stylesheet"
/>
<meta name="viewport" content="width=device-width" />
<meta name="generator" content={Astro.generator} />
<title>{title ? `${title} | ${owner}` : owner}</title>
</head>
<body class="antialiased text-gray-900">
<NavBar lang={lang} />
<main>
<slot />
</main>
<Footer />
</body>
</html>
<script defer src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js"></script>

34
src/pages/about.astro Normal file
View File

@@ -0,0 +1,34 @@
---
import MainLayout from '../../layouts/MainLayout.astro';
import { Content as AboutPage } from '../../content/about.md';
import siteConfig from '../../../../site.config.mjs';
const profileImage = siteConfig.profileImage;
const base = import.meta.env.BASE_URL;
---
<MainLayout>
<section class="pt-32 pb-20">
<div class="container-custom">
<div class="max-w-2xl mx-auto text-center mb-16">
<h1 class="text-4xl md:text-5xl font-bold mb-4">About Me</h1>
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-16 items-center">
<div>
<img
src=`${base}/images/${profileImage}`
alt="Profile image"
class="w-full h-auto rounded-lg shadow-lg border-8 my-4 border-white md:p-4"
width="720"
height="720"
/>
</div>
<div class="prose text-gray-700 mb-6">
<AboutPage />
</div>
</div>
</div>
</section>
</MainLayout>

34
src/pages/en/about.astro Normal file
View File

@@ -0,0 +1,34 @@
---
import MainLayout from '../../layouts/MainLayout.astro';
import { Content as AboutPage } from '../../content/about.md';
import siteConfig from '../../../site.config.mjs';
const profileImage = siteConfig.profileImage;
const base = import.meta.env.BASE_URL;
---
<MainLayout title="About" lang="en">
<section class="pt-32 pb-20">
<div class="container-custom">
<div class="max-w-2xl mx-auto text-center mb-16">
<h1 class="text-4xl md:text-5xl font-bold mb-4">About Me</h1>
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-16 items-center">
<div>
<img
src=`${base}/images/${profileImage}`
alt="Profile image"
class="w-full h-auto rounded-lg shadow-lg border-8 my-4 border-white md:p-4"
width="720"
height="720"
/>
</div>
<div class="prose text-gray-700 mb-6">
<AboutPage />
</div>
</div>
</div>
</section>
</MainLayout>

View File

@@ -0,0 +1,62 @@
---
// 路径修正:同样是向上三级
import MainLayout from '../../../layouts/MainLayout.astro';
import PhotoGrid from '../../../components/PhotoGrid.astro';
import { getCollections, getImages } from '../../../data/imageStore';
// 英文版保持 'All'
const allCollection = {
id: undefined,
name: 'All',
};
const collections = [allCollection, ...(await getCollections())];
const { collection } = Astro.params;
const images = await getImages(collection ? { collection } : {});
const base = import.meta.env.BASE_URL;
export const getStaticPaths = async () => {
return [{ id: undefined }, ...(await getCollections())].map((collection) => {
return {
params: { collection: collection.id },
};
});
};
// 英文前缀
const langPrefix = '/en';
---
<MainLayout lang="en">
<section class="py-16 pt-24">
<div class="container-custom">
<div class="mb-16 text-center">
<h1 class="text-4xl md:text-5xl font-bold mb-4">Gallery</h1>
<p class="text-gray-600 max-w-xl mx-auto">Explore my collection of photographic works</p>
</div>
<div class="flex justify-center mb-10">
<div class="flex flex-wrap gap-2 justify-center">
{
collections.map((collectionBtn) => (
<a href={`${base}${langPrefix}/collections/${collectionBtn.id ? collectionBtn.id : ''}`}>
<div
class={`px-4 py-2 border rounded-full text-sm font-medium transition-all ${
collectionBtn.id === collection
? 'border-black bg-black text-white'
: 'border-gray-200 text-gray-700 hover:border-black'
}`}
>
{collectionBtn.name}
</div>
</a>
))
}
</div>
</div>
<PhotoGrid images={images} />
</div>
</section>
</MainLayout>

37
src/pages/en/index.astro Normal file
View File

@@ -0,0 +1,37 @@
---
import siteConfig from '../../../site.config.mjs';
import MainLayout from '../../layouts/MainLayout.astro';
import LandingHero from '../../components/LandingHero-1.astro';
import HeroBackground from '../../components/HeroBackground.astro';
import FeaturedGallery from '../../components/FeaturedGallery.astro';
import FeaturedWorkScroll from '../../components/FeaturedWorkScroll.astro';
const base = import.meta.env.BASE_URL;
const owner = siteConfig.owner;
---
<MainLayout lang="en">
<section class="relative min-h-screen flex flex-col justify-center py-20 md:py-16 overflow-hidden">
<HeroBackground />
<LandingHero
title={`${owner} Photography`}
subtitle="A place to showcase my photography"
btnText="View Gallery"
// 修改前: btnLink={`${base}/en/collections`}
// 修改后: 加一个 /
btnLink={`${base}/en/collections`}
/>
<div class="relative z-20 mt-12">
<FeaturedWorkScroll />
</div>
</section>
<section class="featured-section py-20">
<FeaturedGallery
title="Featured Works"
subtitle="A selection of my best photographs from various adventures"
/>
</section>
</MainLayout>

15
src/pages/index.astro Normal file
View File

@@ -0,0 +1,15 @@
---
// 获取 base 路径
const base = import.meta.env.BASE_URL;
// 修复逻辑:
// 1. 先判断 base 是否以 / 结尾
// 2. 如果没有,就补上 /,然后再拼 zh/
// 3. 这样无论 base 长什么样,都能拼出正确的 /astro-photography-portfolio/zh/
const target = base.endsWith('/') ? `${base}zh/` : `${base}/zh/`;
---
<meta http-equiv="refresh" content={`0;url=${target}`} />
<script define:vars={{ target }}>
window.location.href = target;
</script>

34
src/pages/zh/about.astro Normal file
View File

@@ -0,0 +1,34 @@
---
import MainLayout from '../../layouts/MainLayout.astro';
import { Content as AboutPage } from '../../content/about.md';
import siteConfig from '../../../site.config.mjs';
const profileImage = siteConfig.profileImage;
const base = import.meta.env.BASE_URL;
---
<MainLayout title="About" lang="zh">
<section class="pt-32 pb-20">
<div class="container-custom">
<div class="max-w-2xl mx-auto text-center mb-16">
<h1 class="text-4xl md:text-5xl font-bold mb-4">About Me</h1>
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-16 items-center">
<div>
<img
src=`${base}/images/${profileImage}`
alt="Profile image"
class="w-full h-auto rounded-lg shadow-lg border-8 my-4 border-white md:p-4"
width="720"
height="720"
/>
</div>
<div class="prose text-gray-700 mb-6">
<AboutPage />
</div>
</div>
</div>
</section>
</MainLayout>

View File

@@ -0,0 +1,66 @@
---
// 路径修正:向上三级 (../../../)
import MainLayout from '../../../layouts/MainLayout.astro';
import PhotoGrid from '../../../components/PhotoGrid.astro';
import { getCollections, getImages } from '../../../data/imageStore';
import { languages, categoryNames } from '../../../i18n/ui'; // 1. 引入字典
// 汉化:将 'All' 改为 '全部'
const allCollection = {
id: undefined,
name: '全部',
};
const collections = [allCollection, ...(await getCollections())];
const { collection } = Astro.params;
const images = await getImages(collection ? { collection } : {});
const base = import.meta.env.BASE_URL;
// 这里的路径逻辑不用动Astro 会自动处理
export const getStaticPaths = async () => {
return [{ id: undefined }, ...(await getCollections())].map((collection) => {
return {
params: { collection: collection.id },
};
});
};
// 获取当前语言前缀 (用于生成正确的链接 /zh/collections/xxx)
// 因为这个文件在 /zh/ 目录下,我们可以写死或者动态判断,这里为了简单直接写死前缀
const langPrefix = '/zh';
---
<MainLayout lang="zh">
<section class="py-16 pt-24">
<div class="container-custom">
<div class="mb-16 text-center">
<h1 class="text-4xl md:text-5xl font-bold mb-4">作品集</h1>
<p class="text-gray-600 max-w-xl mx-auto">探索我通过镜头捕捉的世界</p>
</div>
<div class="flex justify-center mb-10">
<div class="flex flex-wrap gap-2 justify-center">
{
collections.map((collectionBtn) => (
// 修复链接生成逻辑:确保带上语言前缀
<a href={`${base}${langPrefix}/collections/${collectionBtn.id ? collectionBtn.id : ''}`}>
<div
class={`px-4 py-2 border rounded-full text-sm font-medium transition-all ${
collectionBtn.id === collection
? 'border-black bg-black text-white'
: 'border-gray-200 text-gray-700 hover:border-black'
}`}
>
{collectionBtn.name}
</div>
</a>
))
}
</div>
</div>
<PhotoGrid images={images} />
</div>
</section>
</MainLayout>

39
src/pages/zh/index.astro Normal file
View File

@@ -0,0 +1,39 @@
---
import MainLayout from '../../layouts/MainLayout.astro';
import LandingHero from '../../components/LandingHero-1.astro';
import HeroBackground from '../../components/HeroBackground.astro';
import FeaturedGallery from '../../components/FeaturedGallery.astro';
import FeaturedWorkScroll from '../../components/FeaturedWorkScroll.astro';
import siteConfig from '../../../site.config.mjs'; // 引入配置以获取名字
const base = import.meta.env.BASE_URL; // 确保获取了 base 路径
const owner = siteConfig.owner;
---
<MainLayout lang="zh">
<section class="relative min-h-screen flex flex-col justify-center py-20 md:py-16 overflow-hidden">
<HeroBackground />
<LandingHero
title={`${owner} 摄影作品`}
subtitle="记录光影与瞬间的展示空间"
btnText="浏览作品集"
// 修改前: btnLink={`${base}/zh/collections`}
// 修改后: 加一个 /
btnLink={`${base}/zh/collections`}
/>
<div class="relative z-20 mt-12">
<FeaturedWorkScroll />
</div>
</section>
<section class="featured-section py-20">
<FeaturedGallery
title="精选作品"
subtitle="从不同旅程中精选的最佳瞬间"
/>
</section>
</MainLayout>

161
src/scripts/photo-grid.ts Normal file
View File

@@ -0,0 +1,161 @@
import justifiedLayout from 'justified-layout';
import GLightbox from 'glightbox';
interface JustifiedLayoutResult {
/**
* Height of the container containing the justified layout.
*/
containerHeight: number;
/**
* Number of items that are in rows that aren't fully-packed.
*/
widowCount: number;
/**
* Computed positional and sizing properties of a box in the justified layout.
*/
boxes: LayoutBox[];
}
/**
* Computed positional and sizing properties of a box in the layout.
*/
interface LayoutBox {
/**
* Aspect ratio of the box.
*/
aspectRatio: number;
/**
* Distance between the top side of the box and the top boundary of the justified layout.
*/
top: number;
/**
* Width of the box in a justified layout.
*/
width: number;
/**
* Height of the box in a justified layout.
*/
height: number;
/**
* Distance between the left side of the box and the left boundary of the justified layout.
*/
left: number;
/**
* Whether or not the aspect ratio was forced.
*/
forcedAspectRatio?: boolean;
}
export async function setupGallery() {
if (typeof document === 'undefined') return;
const container = document.getElementById('photo-grid');
if (!container) {
console.error('Photo grid container not found.');
return;
}
const imageLinks = Array.from(container.querySelectorAll('.photo-item')) as HTMLElement[];
if (imageLinks.length === 0) {
console.warn('No images found inside the photo grid.');
return;
}
// Wait for all images to load
const imageElements = await waitForImagesToLoad(container);
// Get actual image dimensions after loading
const layout = createLayoutFor(imageElements, container);
console.log('Generated layout:', layout);
applyImagesStyleBasedOnLayout(imageLinks, layout);
applyContainerStyleBasedOnLayout(container, layout);
// Initialize GLightbox
GLightbox({
selector: '.glightbox',
openEffect: 'zoom',
closeEffect: 'fade',
width: 'auto',
height: 'auto',
});
}
function createLayoutFor(
imageElements: HTMLImageElement[],
container: HTMLElement,
): JustifiedLayoutResult {
const imageSizes = imageElements.map((img) => ({
width: img.naturalWidth || img.width || 300,
height: img.naturalHeight || img.height || 200,
}));
const layout = justifiedLayout(imageSizes, {
containerWidth: container.clientWidth || window.innerWidth,
targetRowHeight: 300,
boxSpacing: 10,
containerPadding: 0,
});
return layout;
}
async function waitForImagesToLoad(container: HTMLElement) {
const imageElements = Array.from(container.querySelectorAll('img')) as HTMLImageElement[];
await Promise.all(
imageElements.map(
(img) =>
new Promise((resolve) => {
if (img.complete) {
resolve(null);
} else {
img.onload = () => resolve(null);
img.onerror = () => resolve(null);
}
}),
),
);
return imageElements;
}
function applyImagesStyleBasedOnLayout(imageLinks: HTMLElement[], layout: JustifiedLayoutResult) {
imageLinks.forEach((el, i) => {
if (!layout.boxes[i]) return;
const { left, top, width, height } = layout.boxes[i];
el.style.position = 'absolute';
el.style.left = `${left}px`;
el.style.top = `${top}px`;
el.style.width = `${width}px`;
el.style.height = `${height}px`;
el.style.display = 'block';
});
}
function applyContainerStyleBasedOnLayout(container: HTMLElement, layout: JustifiedLayoutResult) {
// Ensure the parent container has relative positioning
container.style.position = 'relative';
// Set container height
container.style.height = `${layout.containerHeight}px`;
}
// Run setupGallery once the page is loaded
if (typeof window !== 'undefined') {
const debouncedSetup = debounce(setupGallery, 250);
document.addEventListener('DOMContentLoaded', setupGallery);
window.addEventListener('resize', debouncedSetup);
}
// Debounce helper
function debounce<T extends (...args: unknown[]) => unknown>(func: T, wait: number) {
let timeout: ReturnType<typeof setTimeout>;
return function executedFunction(...args: Parameters<T>) {
const later = () => {
clearTimeout(timeout);
func(...args);
};
clearTimeout(timeout);
timeout = setTimeout(later, wait);
};
}

View File

@@ -0,0 +1,20 @@
.glightbox-container .gslide-image img {
max-width: 95%;
max-height: 95%;
height: auto;
width: auto;
object-fit: contain;
display: block;
}
.glightbox-container .gslide {
display: flex;
align-items: center;
justify-content: center;
}
.glightbox-container .gslide-description {
max-width: 90%;
color: #ccc;
font-size: 0.875rem;
}

67
src/styles/global.css Normal file
View File

@@ -0,0 +1,67 @@
@import url('https://fonts.googleapis.com/css2?family=Playfair+Display:wght@400;500;600;700&family=Inter:wght@300;400;500;600&display=swap');
@import url('https://fonts.googleapis.com/css2?family=Dancing+Script:wght@400;500;600;700&display=swap');
@import 'tailwindcss';
@plugin "@tailwindcss/typography";
@config "../../tailwind.config";
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 240 10% 3.9%;
--card: 0 0% 100%;
--card-foreground: 240 10% 3.9%;
--popover: 0 0% 100%;
--popover-foreground: 240 10% 3.9%;
--primary: 240 5.9% 10%;
--primary-foreground: 0 0% 98%;
--secondary: 240 4.8% 95.9%;
--secondary-foreground: 240 5.9% 10%;
--muted: 240 4.8% 95.9%;
--muted-foreground: 240 3.8% 46.1%;
--accent: 240 4.8% 95.9%;
--accent-foreground: 240 5.9% 10%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 0 0% 98%;
--border: 240 5.9% 90%;
--input: 240 5.9% 90%;
--ring: 240 10% 3.9%;
--radius: 0.5rem;
}
body {
@apply bg-background text-foreground font-sans;
font-family: 'Inter', sans-serif;
}
h1,
h2,
h3,
h4,
h5,
h6 {
font-family: 'Playfair Display', serif;
}
#logo {
font-family: 'Dancing Script', cursive;
}
}
@layer components {
.container-custom {
@apply max-w-[1400px] mx-auto px-4 sm:px-6 lg:px-8;
}
.nav-link {
@apply relative text-base font-medium text-gray-800 transition-all duration-300 hover:text-black;
}
.nav-link::after {
@apply content-[''] absolute w-0 h-[1px] bg-black bottom-[-2px] left-0 transition-all duration-300 ease-in-out;
}
.nav-link:hover::after {
@apply w-full;
}
}

90
tailwind.config.js Normal file
View File

@@ -0,0 +1,90 @@
export default {
prefix: '',
theme: {
container: {
center: true,
padding: '2rem',
screens: {
'2xl': '1400px',
},
},
extend: {
fontFamily: {
sans: ['Inter', 'sans-serif'],
playfair: ['Playfair Display', 'serif'],
},
colors: {
border: 'hsl(var(--border))',
input: 'hsl(var(--input))',
ring: 'hsl(var(--ring))',
background: 'hsl(var(--background))',
foreground: 'hsl(var(--foreground))',
primary: {
DEFAULT: 'hsl(var(--primary))',
foreground: 'hsl(var(--primary-foreground))',
},
secondary: {
DEFAULT: 'hsl(var(--secondary))',
foreground: 'hsl(var(--secondary-foreground))',
},
destructive: {
DEFAULT: 'hsl(var(--destructive))',
foreground: 'hsl(var(--destructive-foreground))',
},
muted: {
DEFAULT: 'hsl(var(--muted))',
foreground: 'hsl(var(--muted-foreground))',
},
accent: {
DEFAULT: 'hsl(var(--accent))',
foreground: 'hsl(var(--accent-foreground))',
},
popover: {
DEFAULT: 'hsl(var(--popover))',
foreground: 'hsl(var(--popover-foreground))',
},
card: {
DEFAULT: 'hsl(var(--card))',
foreground: 'hsl(var(--card-foreground))',
},
sidebar: {
DEFAULT: 'hsl(var(--sidebar-background))',
foreground: 'hsl(var(--sidebar-foreground))',
primary: 'hsl(var(--sidebar-primary))',
'primary-foreground': 'hsl(var(--sidebar-primary-foreground))',
accent: 'hsl(var(--sidebar-accent))',
'accent-foreground': 'hsl(var(--sidebar-accent-foreground))',
border: 'hsl(var(--sidebar-border))',
ring: 'hsl(var(--sidebar-ring))',
},
},
borderRadius: {
lg: 'var(--radius)',
md: 'calc(var(--radius) - 2px)',
sm: 'calc(var(--radius) - 4px)',
},
keyframes: {
'accordion-down': {
from: {
height: '0',
},
to: {
height: 'var(--radix-accordion-content-height)',
},
},
'accordion-up': {
from: {
height: 'var(--radix-accordion-content-height)',
},
to: {
height: '0',
},
},
},
animation: {
'accordion-down': 'accordion-down 0.2s ease-out',
'accordion-up': 'accordion-up 0.2s ease-out',
},
},
},
};

15
tsconfig.json Normal file
View File

@@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "Bundler",
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"skipLibCheck": true,
"strict": true,
"types": ["astro/client"]
},
"extends": "astro/tsconfigs/strict",
"include": [".astro/types.d.ts", "src/**/*"],
"exclude": ["dist"]
}