Skip to content

Commit acdb8e4

Browse files
perf: complete site audit optimizations
1 parent 8f2955e commit acdb8e4

36 files changed

Lines changed: 491 additions & 252 deletions

docs/GALLERY_PAGE_DEV_GUIDE.md

Lines changed: 11 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -71,22 +71,18 @@ Gallery 是 **Evan 的摄影作品集页面**,展示 `Photography/` 目录下
7171

7272
### 3.3 图片资源策略(重要约束)
7373

74-
1. **图片在提交前预处理**`npm run images:protect -- --apply` 使用 `sharp` 生成不超过 1600px 长边的 JPEG/WebP 展示衍生图,写入版权元数据并叠加 `© Evan Gong · evangong.tech` 水印。`import.meta.glob(..., { query: '?url' })` 取到的是受保护展示图路径,不是相机原图
75-
2. **批量取图范式**(Home 既有,可复用)
74+
1. **图片在提交前预处理**`npm run images:protect -- --apply` 使用 `sharp` 生成不超过 1600px 长边的 JPEG/WebP 展示衍生图,写入版权元数据并叠加 `© Evan Gong · evangong.tech` 水印。Gallery 运行时读取 `src/data/photoCatalog.js`,浏览器 URL 指向 `/Photography/...`,不使用 `/public/...` glob
75+
2. **批量取图范式**
7676
```js
77-
// 仅取根目录(非递归)
78-
const rootPhotos = Object.values(
79-
import.meta.glob('/Photography/*.{jpeg,jpg,png}', { eager: true, query: '?url', import: 'default' })
80-
)
81-
// 递归取全部(含子目录)—— Gallery 推荐用此
82-
const allPhotos = Object.values(
83-
import.meta.glob('/Photography/**/*.{jpeg,jpg,png}', { eager: true, query: '?url', import: 'default' })
84-
)
77+
import { getPhotosByCategory } from '../../data/photoCatalog.js'
78+
79+
const rootPhotos = getPhotosByCategory('paris')
80+
const allPhotos = getPhotosByCategory('beijing')
8581
```
8682
3. **单图 import**`import x from '/Photography/Paris/IMG_1598.jpg'`(根目录绝对路径)或 `import x from '../../assets/xxx'`(src 内相对路径)。
8783
4. **展示图体积**:原图不进入仓库;展示图由预处理脚本限制尺寸和质量。仍然建议:
8884
- 缩略图与展示图同源(用 CSS `object-fit` + 固定容器尺寸控制视觉体积);
89-
- 全部 `<img>``loading="lazy"`(见第九节 PageTransition 约束)
85+
- 使用 `ProtectedImage`,补齐 `alt`、尺寸、`sizes``decoding` 和懒加载;只有明确的首屏关键图标记 `data-transition-critical="true"`
9086
- 新增或替换照片后运行 `npm run images:protect -- --apply`,并用 `npm run images:protect -- --check` 验证清单。
9187
5. **分类元数据缺失**:根目录 6 张未归类,无 EXIF/标题/拍摄信息。若 Gallery 要展示标题/地点/年份,需**手工补一份元数据常量**(见第八节建议结构)。
9288

@@ -279,7 +275,7 @@ Gallery 应采用 **Home 未用的布局形式**——优先 **CSS 网格 / Maso
279275

280276
- **无 API 调用**:零 fetch / axios。
281277
- **数据内联**:延续模块级常量范式,在 `Gallery.jsx` 顶部定义图集数据。
282-
- **图片资源**`import.meta.glob('/Photography/**/*.{jpeg,jpg,png}', { eager: true, query: '?url', import: 'default' })` **递归取全部**(注意 Home 用的是非递归 `/Photography/*`,Gallery 要含子目录应用 `/**/*`
278+
- **图片资源**Gallery 使用 `src/data/photoCatalog.js` 从保护 manifest 读取分类、宽高和运行时 URL;Home 的精选图片仍可使用独立受保护展示图
283279

284280
### 8.2 状态管理
285281

@@ -289,15 +285,12 @@ Gallery 应采用 **Home 未用的布局形式**——优先 **CSS 网格 / Maso
289285

290286
### 8.3 Gallery 数据结构建议
291287

292-
由于 `import.meta.glob` 只给 URL 数组(无元数据),且根目录 6 张未归类,建议**手工补一份元数据常量**,按地域分组
288+
图片目录已经由 `src/data/photoCatalog.js` 从保护 manifest 生成,包含分类、宽高、alt 和展示路径,不再维护第二份手工 URL 清单
293289

294290
```js
295291
const si = (slug) => `/icons/${slug}.svg`
296292

297-
// 用 import.meta.glob 递归取全部图,再按路径前缀匹配到分组
298-
const photoModules = import.meta.glob('/Photography/**/*.{jpeg,jpg,png}', {
299-
eager: true, query: '?url', import: 'default'
300-
})
293+
import { PHOTO_CATEGORIES, getPhotosByCategory } from '../../data/photoCatalog.js'
301294

302295
// 分组定义(标题/简介/代表图标可按需)
303296
const GALLERY_GROUPS = [
@@ -308,10 +301,7 @@ const GALLERY_GROUPS = [
308301
{ id: 'unsorted', label: 'Unsorted', path: '/Photography/' }, // 根目录
309302
]
310303

311-
// 运行时把 glob 结果按 path 前缀分桶
312-
function buildGalleryData(modules, groups) {
313-
// ...按 groups[i].path 过滤,根目录需排除子目录文件
314-
}
304+
const beijingPhotos = getPhotosByCategory('beijing')
315305
```
316306

317307
****更简单:直接单图 `import` 列出每张(11 张可控),手工标注 `{ src, title, location, year, group }`**推荐后者**——数量少、可控、可补标题。

index.html

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44
<meta charset="UTF-8" />
55
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
66
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
7+
<meta name="description" content="Evan Gong — programming, AI, robotics, 3D printing, photography, and tangible digital experiences." />
8+
<meta name="theme-color" content="#000000" />
79
<title>Evan Gong | Personal Website</title>
810
</head>
911
<body>

scripts/compress-photos.js

Lines changed: 0 additions & 56 deletions
This file was deleted.

src/App.css

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,16 @@
55
background: var(--color-black);
66
}
77

8+
.route-loading {
9+
min-height: 60vh;
10+
display: grid;
11+
place-items: center;
12+
color: rgba(255, 255, 255, 0.72);
13+
font-size: 0.8rem;
14+
letter-spacing: 0.14em;
15+
text-transform: uppercase;
16+
}
17+
818
main {
919
position: relative;
1020
width: 100%;
@@ -408,4 +418,3 @@ main {
408418

409419
/* ===== Flying Posters section ===== */
410420

411-

src/App.jsx

Lines changed: 101 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import StaggeredMenu from './components/StaggeredMenu/StaggeredMenu.jsx'
44
import Footer from './components/Footer/Footer.jsx'
55
import Home from './components/Home/Home.jsx'
66
import PageTransition from './components/PageTransition/PageTransition.jsx'
7+
import { NavContext } from './navContext.js'
78
import logoUrl from './assets/EvanGongIcon.png'
89
import './App.css'
910

@@ -47,7 +48,7 @@ function waitForImagesReady(container, timeout = IMAGE_LOAD_TIMEOUT) {
4748
return new Promise((resolve) => {
4849
const settle = () => requestAnimationFrame(() => requestAnimationFrame(resolve))
4950
if (!container) return settle()
50-
const imgs = Array.from(container.querySelectorAll('img'))
51+
const imgs = Array.from(container.querySelectorAll('img[data-transition-critical="true"]'))
5152
const pending = imgs.filter((img) => {
5253
if (img.loading === 'lazy') return false
5354
return !img.complete || img.naturalWidth === 0
@@ -74,6 +75,93 @@ function waitForImagesReady(container, timeout = IMAGE_LOAD_TIMEOUT) {
7475
})
7576
}
7677

78+
const PAGE_META = {
79+
'/': {
80+
title: 'Evan Gong | Programming, AI, Robotics & Photography',
81+
description: 'Evan Gong builds tangible experiences across programming, AI, robotics, 3D printing, and photography.'
82+
},
83+
'/about': {
84+
title: 'About Evan Gong | Evan Gong',
85+
description: 'Learn about Evan Gong, his technical interests, creative practice, and current skills.'
86+
},
87+
'/projects': {
88+
title: 'Featured Projects | Evan Gong',
89+
description: 'Selected software, AI, robotics, hardware, and interactive projects by Evan Gong.'
90+
},
91+
'/gallery': {
92+
title: 'Photography Gallery | Evan Gong',
93+
description: 'Photography by Evan Gong from Paris, Chaoshan, Beijing, and elsewhere.'
94+
},
95+
'/blog': {
96+
title: 'Field Notes | Evan Gong',
97+
description: 'Technical notes and reflections on AI agents, hardware, software, and learning.'
98+
},
99+
'/awards': {
100+
title: 'Awards & Recognition | Evan Gong',
101+
description: 'Selected awards, competition results, program milestones, and project records.'
102+
},
103+
'/blog/hardware-agent-runtime': {
104+
title: 'Giving AI Agents a Safe Path to Real Hardware | Evan Gong',
105+
description: 'Hardware Agent Runtime connects coding agents to embedded devices through observable hardware-in-the-loop workflows.'
106+
},
107+
'/blog/kards-ai-simulator': {
108+
title: 'Teaching a Card Game Agent to Think in States | Evan Gong',
109+
description: 'Kards AI turns a complex card game into a deterministic environment for simulation and reinforcement-learning research.'
110+
},
111+
'/blog/openkyrozen-agent': {
112+
title: 'Building an Agent That Learns From Its Work | Evan Gong',
113+
description: 'OpenKyrozen explores how an AI agent can improve through the work it already performs.'
114+
}
115+
}
116+
117+
function setMeta(attribute, key, content) {
118+
let element = document.head.querySelector(`meta[${attribute}="${key}"]`)
119+
if (!element) {
120+
element = document.createElement('meta')
121+
element.setAttribute(attribute, key)
122+
document.head.appendChild(element)
123+
}
124+
element.setAttribute('content', content)
125+
}
126+
127+
function PageMeta() {
128+
const { pathname } = useLocation()
129+
130+
useEffect(() => {
131+
const isBlogPost = pathname.startsWith('/blog/')
132+
const slug = isBlogPost ? pathname.split('/').filter(Boolean).at(-1) : ''
133+
const meta = PAGE_META[pathname] || (isBlogPost
134+
? {
135+
title: `${slug.replace(/-/g, ' ')} | Evan Gong`,
136+
description: 'A field note by Evan Gong.'
137+
}
138+
: PAGE_META['/'])
139+
const canonicalUrl = `https://evangong.tech${pathname === '/' ? '/' : pathname}`
140+
141+
document.title = meta.title
142+
setMeta('name', 'description', meta.description)
143+
setMeta('property', 'og:title', meta.title)
144+
setMeta('property', 'og:description', meta.description)
145+
setMeta('property', 'og:type', isBlogPost ? 'article' : 'website')
146+
setMeta('property', 'og:url', canonicalUrl)
147+
setMeta('name', 'twitter:card', 'summary_large_image')
148+
149+
let canonical = document.head.querySelector('link[rel="canonical"]')
150+
if (!canonical) {
151+
canonical = document.createElement('link')
152+
canonical.rel = 'canonical'
153+
document.head.appendChild(canonical)
154+
}
155+
canonical.href = canonicalUrl
156+
}, [pathname])
157+
158+
return null
159+
}
160+
161+
function RouteFallback() {
162+
return <div className="route-loading" role="status" aria-live="polite">Loading page…</div>
163+
}
164+
77165
// StaggeredMenu renders menu items as <a href={link}> (official React Bits
78166
// implementation — left untouched). To enable client-side routing without
79167
// modifying the official component source, we intercept clicks on
@@ -204,7 +292,9 @@ function Layout() {
204292
useClientSideNav(triggerTransition)
205293

206294
return (
207-
<div className="app">
295+
<NavContext.Provider value={triggerTransition}>
296+
<div className="app">
297+
<PageMeta />
208298
{!isGalleryCategory && (
209299
<StaggeredMenu
210300
position="right"
@@ -224,37 +314,37 @@ function Layout() {
224314
<Routes>
225315
<Route path="/" element={<Home />} />
226316
<Route path="/about" element={
227-
<Suspense fallback={null}>
317+
<Suspense fallback={<RouteFallback />}>
228318
<About />
229319
</Suspense>
230320
} />
231321
<Route path="/projects" element={
232-
<Suspense fallback={null}>
322+
<Suspense fallback={<RouteFallback />}>
233323
<Projects />
234324
</Suspense>
235325
} />
236326
<Route path="/gallery" element={
237-
<Suspense fallback={null}>
327+
<Suspense fallback={<RouteFallback />}>
238328
<Gallery />
239329
</Suspense>
240330
} />
241331
<Route path="/gallery/:category" element={
242-
<Suspense fallback={null}>
332+
<Suspense fallback={<RouteFallback />}>
243333
<GalleryCategory />
244334
</Suspense>
245335
} />
246336
<Route path="/blog" element={
247-
<Suspense fallback={null}>
337+
<Suspense fallback={<RouteFallback />}>
248338
<Blog />
249339
</Suspense>
250340
} />
251341
<Route path="/blog/:slug" element={
252-
<Suspense fallback={null}>
342+
<Suspense fallback={<RouteFallback />}>
253343
<BlogPost />
254344
</Suspense>
255345
} />
256346
<Route path="/awards" element={
257-
<Suspense fallback={null}>
347+
<Suspense fallback={<RouteFallback />}>
258348
<Awards />
259349
</Suspense>
260350
} />
@@ -263,7 +353,8 @@ function Layout() {
263353
</main>
264354
{!isGalleryCategory && <Footer />}
265355
<PageTransition phase={phase} />
266-
</div>
356+
</div>
357+
</NavContext.Provider>
267358
)
268359
}
269360

src/components/ASCIIText/ASCIIText.jsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -269,7 +269,7 @@ class CanvAscii {
269269
try {
270270
await document.fonts.load('600 200px "IBM Plex Mono"');
271271
await document.fonts.load('500 12px "IBM Plex Mono"');
272-
} catch (e) {
272+
} catch {
273273
// Font loading failed, continue with fallback
274274
}
275275
await document.fonts.ready;

src/components/Awards/Awards.jsx

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { useEffect, useMemo, useRef, useState } from 'react'
22
import Lenis from 'lenis'
33
import GlitchText from '../GlitchText/GlitchText.jsx'
44
import { awards, awardsPage } from './awardsData.js'
5+
import ProtectedImage from '../ProtectedImage/ProtectedImage.jsx'
56
import './Awards.css'
67

78
const monthFormatter = new Intl.DateTimeFormat('en', { month: 'short', year: 'numeric' })
@@ -46,7 +47,7 @@ function MediaPanel({ award, media, onOpen }) {
4647
onClick={() => onOpen({ ...media, awardTitle: award.title })}
4748
>
4849
{media.src ? (
49-
<img src={media.src} alt={media.alt} loading="lazy" />
50+
<ProtectedImage src={media.src} alt={media.alt} loading="lazy" sizes="(max-width: 700px) 90vw, 30rem" />
5051
) : (
5152
<span className="award-media__mock" aria-hidden="true">
5253
<span>MOCK MEDIA</span>
@@ -283,10 +284,12 @@ export default function Awards() {
283284
<div className="awards-media-dialog__content">
284285
<div className="awards-media-dialog__visual">
285286
{activeMedia.src ? (
286-
<img
287+
<ProtectedImage
287288
className="awards-media-dialog__image"
288289
src={activeMedia.src}
289290
alt={activeMedia.alt}
291+
loading="eager"
292+
sizes="90vw"
290293
/>
291294
) : (
292295
<div className="awards-media-dialog__mock" aria-hidden="true">

src/components/Ballpit/Ballpit.jsx

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -43,8 +43,8 @@ class _Three {
4343
document.addEventListener('visibilitychange', this.#v.bind(this));
4444
}
4545
#y() { window.removeEventListener('resize', this.#f.bind(this)); this.#r?.disconnect(); this.#o?.disconnect(); document.removeEventListener('visibilitychange', this.#v.bind(this)); }
46-
#u(e) { this.#s = e[0].isIntersecting; this.#s ? this.#w() : this.#z(); }
47-
#v() { if (this.#s) { document.hidden ? this.#z() : this.#w(); } }
46+
#u(e) { this.#s = e[0].isIntersecting; if (this.#s) this.#w(); else this.#z(); }
47+
#v() { if (this.#s) { if (document.hidden) this.#z(); else this.#w(); } }
4848
#f() { if (this.#a) clearTimeout(this.#a); this.#a = setTimeout(this.resize.bind(this), 100); }
4949
resize() {
5050
let w, h;
@@ -239,11 +239,12 @@ function createBallpit(e, t = {}) {
239239
const Ballpit = ({ className = '', followCursor = true, ...props }) => {
240240
const canvasRef = useRef(null);
241241
const spheresInstanceRef = useRef(null);
242+
const configRef = useRef({ followCursor, ...props });
242243

243244
useEffect(() => {
244245
const canvas = canvasRef.current;
245246
if (!canvas) return;
246-
spheresInstanceRef.current = createBallpit(canvas, { followCursor, ...props });
247+
spheresInstanceRef.current = createBallpit(canvas, configRef.current);
247248
return () => { if (spheresInstanceRef.current) spheresInstanceRef.current.dispose(); };
248249
}, []);
249250

0 commit comments

Comments
 (0)