first
289
README.md
Normal 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
@@ -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
@@ -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
42
package.json
Normal 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
@@ -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
BIN
public/favicon.ico
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
public/images/profile.webp
Normal file
|
After Width: | Height: | Size: 248 KiB |
27
site.config.mts
Normal 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,
|
||||||
|
],
|
||||||
|
};
|
||||||
22
src/components/FeaturedGallery.astro
Normal 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>
|
||||||
40
src/components/FeaturedWorkScroll.astro
Normal 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>
|
||||||
23
src/components/Footer.astro
Normal 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>
|
||||||
59
src/components/HeroBackground.astro
Normal 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>
|
||||||
40
src/components/LandingHero-1.astro
Normal 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>
|
||||||
33
src/components/LandingHero-2.astro
Normal 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
@@ -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>
|
||||||
82
src/components/PhotoGrid.astro
Normal 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 (原图尺寸),导致加载极其缓慢。
|
||||||
|
这里强制设置为 720px,Astro 会自动生成小体积的 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>
|
||||||
15
src/components/SocialIcon.astro
Normal 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
@@ -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.
|
||||||
18
src/data/__tests__/expect_util.ts
Normal 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;
|
||||||
|
};
|
||||||
10
src/data/__tests__/gallery/invalid-collection-gallery.yaml
Normal 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]
|
||||||
15
src/data/__tests__/gallery/invalid-gallery.yaml
Normal 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]
|
||||||
BIN
src/data/__tests__/gallery/kuku/kuku-bubble.jpg
Normal file
|
After Width: | Height: | Size: 128 KiB |
BIN
src/data/__tests__/gallery/kuku/kuku-trees.jpg
Normal file
|
After Width: | Height: | Size: 105 KiB |
BIN
src/data/__tests__/gallery/landscape.jpg
Normal file
|
After Width: | Height: | Size: 122 KiB |
BIN
src/data/__tests__/gallery/popo/popo-view.jpg
Normal file
|
After Width: | Height: | Size: 396 KiB |
34
src/data/__tests__/gallery/valid-gallery.yaml
Normal 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]
|
||||||
29
src/data/__tests__/galleryEntityFactory.test.ts
Normal 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({});
|
||||||
|
});
|
||||||
|
});
|
||||||
155
src/data/__tests__/galleryGenerator.test.ts
Normal 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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
95
src/data/__tests__/imageStore.test.ts
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
BIN
src/data/__tests__/without-exif.jpg
Normal file
|
After Width: | Height: | Size: 303 KiB |
176
src/data/gallery-generator.ts
Normal 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
@@ -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: [],
|
||||||
|
};
|
||||||
53
src/data/galleryEntityFactory.ts
Normal 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
@@ -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
@@ -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: {}
|
||||||
BIN
src/gallery/nature/L1002174.jpg
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
BIN
src/gallery/nature/nature-1.jpg
Normal file
|
After Width: | Height: | Size: 303 KiB |
BIN
src/gallery/nature/nature-10.jpg
Normal file
|
After Width: | Height: | Size: 1.3 MiB |
BIN
src/gallery/nature/nature-2.jpg
Normal file
|
After Width: | Height: | Size: 331 KiB |
BIN
src/gallery/nature/nature-3.jpg
Normal file
|
After Width: | Height: | Size: 579 KiB |
BIN
src/gallery/nature/nature-5.jpg
Normal file
|
After Width: | Height: | Size: 511 KiB |
BIN
src/gallery/nature/nature-7.jpg
Normal file
|
After Width: | Height: | Size: 299 KiB |
BIN
src/gallery/nature/nature-8.jpg
Normal file
|
After Width: | Height: | Size: 298 KiB |
BIN
src/gallery/street/stree-1.jpg
Normal file
|
After Width: | Height: | Size: 476 KiB |
BIN
src/gallery/travel/travel-1.jpg
Normal file
|
After Width: | Height: | Size: 356 KiB |
22
src/i18n/ui.ts
Normal 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;
|
||||||
|
|
||||||
|
|
||||||
41
src/layouts/MainLayout.astro
Normal 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
@@ -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
@@ -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>
|
||||||
62
src/pages/en/collections/[...collection].astro
Normal 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
@@ -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
@@ -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
@@ -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>
|
||||||
66
src/pages/zh/collections/[...collection].astro
Normal 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
@@ -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
@@ -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);
|
||||||
|
};
|
||||||
|
}
|
||||||
20
src/styles/glightbox-custom.css
Normal 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
@@ -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
@@ -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
@@ -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"]
|
||||||
|
}
|
||||||