Files
yingfungsolar/docs/superpowers/plans/2026-09-12-r2-image-upload.md

1000 lines
29 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# R2 Image Upload Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [x]`) syntax for tracking.
**Goal:** 後台 4 個圖片欄位(`hero_image``og_image`、案例 `imageUrl`、文章 `coverImage`)支援揀檔即時上傳去 Cloudflare R2,同時保留貼 URL。
**Architecture:** 單一 Worker 加一個 R2 binding `MEDIA``/admin/upload`(受 middleware 保護)收檔寫入 R2`/media/*` 公開讀出。瀏覽器端用 Canvas 縮到最大寬 1600px、轉 WebP 再上傳。舊圖喺換圖/刪除時由 `src/lib/media.ts``deleteMedia` 清理。**唔改 DB schema、唔加 migration。**
**Tech Stack:** Astro 7`output: "static"` + per-page SSR)、Cloudflare Workers + R2、Drizzle/D1、Zod、原生 Canvasfetch(後台唔引入 React)。
**Spec:** `docs/superpowers/specs/2026-09-12-r2-image-upload-design.md`
**重要:呢個專案冇 test / lint script。每個 task 的驗證係 `npm run build`(唯一 build 驗證)+ 有需要時 `npm run dev` 手動測。冇單元測試框架,所以步驟用 build + 手動檢查取代 TDD。**
---
## File Structure
| 檔案 | 責任 |
|---|---|
| `wrangler.jsonc` | R2 binding`MEDIA` |
| `package.json` | `r2:create` script |
| `scripts/setup.mjs` | 一鍵 setup 建 R2 bucket |
| `src/lib/env.ts` | `AppEnv.MEDIA` |
| `src/lib/media.ts`(新) | `/media/` prefix、URL↔key、`deleteMedia` |
| `src/pages/admin/upload.ts`(新) | 上傳 endpoint |
| `src/pages/media/[...key].ts`(新) | 出圖 endpoint |
| `src/components/admin/ImageField.astro`(新) | 圖片欄元件(URL 欄 + file input + 預覽 + 狀態) |
| `src/layouts/AdminLayout.astro` | client 端縮圖/上傳 script + image field 樣式 |
| `src/data/settings-fields.ts` | `FieldType``image`hero/og 改型別 |
| `src/pages/admin/settings.astro` | image 型別用 ImageField + 換圖刪舊 |
| `src/pages/admin/cases.astro` | ImageField + 換圖/刪除清理 |
| `src/pages/admin/post/[id].astro` | ImageField + 換封面/刪文清理 |
**唔郁:** `src/db/schema.ts``migrations/**``src/schemas/*`(圖片仍係字串)。
---
### Task 1: R2 binding、env 型別同 setup 指令
**Files:**
- Modify: `wrangler.jsonc`
- Modify: `package.json`
- Modify: `scripts/setup.mjs`
- Modify: `src/lib/env.ts`
- [x] **Step 1: `wrangler.jsonc` 加 R2 binding**
`"kv_namespaces": [...]` 之後(結尾 `]` 加逗號)加:
```jsonc
"r2_buckets": [
{
"binding": "MEDIA",
"bucket_name": "yingfung-solar-media"
}
]
```
完整 `wrangler.jsonc` 應該係:
```jsonc
{
"$schema": "node_modules/wrangler/config-schema.json",
"name": "yingfung-solar",
"main": "@astrojs/cloudflare/entrypoints/server",
"compatibility_date": "2026-09-10",
"compatibility_flags": ["nodejs_compat"],
"assets": {
"directory": "./dist",
"binding": "ASSETS",
"not_found_handling": "404-page"
},
"observability": {
"enabled": true,
"logs": { "enabled": true }
},
"vars": {
"SITE_NAME": "盈豐太陽能"
},
"d1_databases": [
{
"binding": "DB",
"database_name": "yingfung-solar-db",
"database_id": "PASTE_D1_DATABASE_ID_HERE"
}
],
"kv_namespaces": [
{
"binding": "CACHE",
"id": "PASTE_KV_NAMESPACE_ID_HERE"
}
],
"r2_buckets": [
{
"binding": "MEDIA",
"bucket_name": "yingfung-solar-media"
}
]
}
```
- [x] **Step 2: `package.json` 加 `r2:create`**
`"kv:create"` 之後加一行:
```json
"kv:create": "wrangler kv namespace create CACHE",
"r2:create": "wrangler r2 bucket create yingfung-solar-media",
```
- [x] **Step 3: `src/lib/env.ts` 加 `MEDIA`**
`AppEnv` 改成:
```ts
export type AppEnv = {
DB: D1Database;
CACHE: KVNamespace;
MEDIA: R2Bucket;
ADMIN_PASSWORD?: string;
AI_API_KEY?: string;
TAVILY_API_KEY?: string;
};
```
- [x] **Step 4: `scripts/setup.mjs` 加建 R2 bucket 步驟**
喺檔案上方 constant 區(`const KV_PLACEHOLDER = ...` 之後)加:
```js
const R2_BUCKET = "yingfung-solar-media";
```
喺 KV 區塊(`} else { console.log("KV id 已設定,略過。"); }`)之後、`const setPassword = await rl.question(...)` 之前加:
```js
console.log(`檢查 / 建立 R2 bucket「${R2_BUCKET}」…`);
const r2List = runSoft("npx wrangler r2 bucket list");
const r2Text = `${r2List.out || ""}\n${r2List.err || ""}`;
if (new RegExp(`\\b${R2_BUCKET}\\b`).test(r2Text)) {
console.log(" R2 bucket 已存在,略過。");
} else {
const r2 = runSoft(`npx wrangler r2 bucket create ${R2_BUCKET}`);
if (r2.ok) {
console.log(" R2 bucket 已建立。");
} else {
console.log(" 建立 R2 bucket 失敗(可能未開通 R2),請稍後手動執行: npm run r2:create");
console.log(r2.out || r2.err || "");
}
}
```
- [x] **Step 5: 重新產生 binding 型別**
Run: `npm run types`
Expected: 成功,`worker-configuration.d.ts``MEDIA: R2Bucket;` 出現喺 `Env`
Run: `rg -n "MEDIA: R2Bucket" worker-configuration.d.ts`
Expected: 有一行 match(若 `wrangler types` 讀唔到 R2 binding,手動加都唔會影響 build,但正常會出)。
- [x] **Step 6: Build 驗證**
Run: `npm run build`
Expected: 完成,冇 error。
- [x] **Step 7: Commit**
```bash
git add wrangler.jsonc package.json scripts/setup.mjs src/lib/env.ts worker-configuration.d.ts
git commit -m "Add R2 MEDIA binding and setup script"
```
---
### Task 2: `src/lib/media.ts` 媒體工具
**Files:**
- Create: `src/lib/media.ts`
- [x] **Step 1: 建立 `src/lib/media.ts`**
```ts
import type { AppEnv } from "./env";
export const MEDIA_PREFIX = "/media/";
/** 由 R2 key 砌出對外 URL。 */
export function mediaUrl(key: string): string {
return `${MEDIA_PREFIX}${key}`;
}
/** 由 /media/... URL 取出 R2 key;唔係 /media/ 開頭回 null。 */
export function mediaKey(url: string | null | undefined): string | null {
if (!url || !url.startsWith(MEDIA_PREFIX)) return null;
return url.slice(MEDIA_PREFIX.length);
}
/** 刪除 /media/... 對應嘅 R2 物件;外部 URL 或空值唔理,唔會 throw。 */
export async function deleteMedia(
env: AppEnv,
url: string | null | undefined,
): Promise<void> {
const key = mediaKey(url);
if (!key) return;
try {
await env.MEDIA.delete(key);
} catch {
// 刪除失敗唔應該阻礙儲存流程。
}
}
```
- [x] **Step 2: Build 驗證**
Run: `npm run build`
Expected: 完成,冇 error。
- [x] **Step 3: Commit**
```bash
git add src/lib/media.ts
git commit -m "Add media helper for R2 keys and cleanup"
```
---
### Task 3: 上傳同出圖 endpoints
**Files:**
- Create: `src/pages/admin/upload.ts`
- Create: `src/pages/media/[...key].ts`
- [x] **Step 1: 建立 `src/pages/admin/upload.ts`**
```ts
import type { APIRoute } from "astro";
import { getEnv } from "../../lib/env";
import { mediaUrl } from "../../lib/media";
export const prerender = false;
const SCOPES = new Set(["settings", "cases", "posts"]);
const ALLOWED: Record<string, string> = {
"image/webp": "webp",
"image/jpeg": "jpg",
"image/png": "png",
"image/gif": "gif",
};
const MAX_BYTES = 8 * 1024 * 1024;
function json(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { "Content-Type": "application/json" },
});
}
export const POST: APIRoute = async ({ request }) => {
let form: FormData;
try {
form = await request.formData();
} catch {
return json({ ok: false, error: "無法讀取上傳內容。" }, 400);
}
const file = form.get("file");
const scope = String(form.get("scope") ?? "");
if (!(file instanceof File) || file.size === 0) {
return json({ ok: false, error: "請揀選圖片檔案。" }, 400);
}
if (!SCOPES.has(scope)) {
return json({ ok: false, error: "上傳目標唔正確。" }, 400);
}
const ext = ALLOWED[file.type];
if (!ext) {
return json({ ok: false, error: "只支援 JPG / PNG / WebP / GIF 圖片。" }, 400);
}
if (file.size > MAX_BYTES) {
return json({ ok: false, error: "圖片太大(上限 8MB)。" }, 400);
}
const key = `${scope}/${crypto.randomUUID()}.${ext}`;
try {
await getEnv().MEDIA.put(key, await file.arrayBuffer(), {
httpMetadata: { contentType: file.type },
});
} catch {
return json({ ok: false, error: "上傳失敗,請稍後再試。" }, 500);
}
return json({ ok: true, url: mediaUrl(key) });
};
```
- [x] **Step 2: 建立 `src/pages/media/[...key].ts`**
```ts
import type { APIRoute } from "astro";
import { getEnv } from "../../lib/env";
export const prerender = false;
export const GET: APIRoute = async ({ params, request }) => {
const key = params.key ?? "";
if (!key) return new Response("Not found", { status: 404 });
const obj = await getEnv().MEDIA.get(key);
if (!obj) return new Response("Not found", { status: 404 });
const etag = obj.httpEtag;
if (etag && request.headers.get("if-none-match") === etag) {
return new Response(null, { status: 304, headers: { ETag: etag } });
}
const headers = new Headers();
headers.set("Content-Type", obj.httpMetadata?.contentType ?? "application/octet-stream");
headers.set("Cache-Control", "public, max-age=31536000, immutable");
if (etag) headers.set("ETag", etag);
return new Response(obj.body, { headers });
};
```
- [x] **Step 3: Build 驗證**
Run: `npm run build`
Expected: 完成,冇 error,而且 `dist/` 唔會 build 出 `/media` 靜態頁(endpoint 係 SSR)。
- [x] **Step 4: 手動測試 endpoints**
Run: `npm run dev`(另一個 terminal
Expected:
1.`http://localhost:4321/media/nope.webp` → 404。
2. 登入 `http://localhost:4321/admin` 後(需要 `.dev.vars``ADMIN_PASSWORD`),用瀏覽器 DevTools console 執行(本機 R2 係 wrangler 模擬):
```js
const fd = new FormData();
fd.append("file", new File([new Uint8Array([82,73,70,70])], "x.webp", { type: "image/webp" }));
fd.append("scope", "cases");
console.log(await (await fetch("/admin/upload", { method: "POST", body: fd })).json());
```
Expected: `{ ok: true, url: "/media/cases/<uuid>.webp" }`,跟住開嗰個 url 有 responsebody 係嗰 4 bytes)。
- [x] **Step 5: Commit**
```bash
git add src/pages/admin/upload.ts "src/pages/media/[...key].ts"
git commit -m "Add R2 upload and media serving endpoints"
```
---
### Task 4: `ImageField` 元件同後台 client script
**Files:**
- Create: `src/components/admin/ImageField.astro`
- Modify: `src/layouts/AdminLayout.astro`
- [x] **Step 1: 建立 `src/components/admin/ImageField.astro`**
```astro
---
interface Props {
name: string;
label: string;
value?: string | null;
scope: "settings" | "cases" | "posts";
id?: string;
}
const { name, label, value = "", scope, id = name } = Astro.props;
---
<div class="image-field" data-image-field data-scope={scope}>
<label for={id}>{label}</label>
<input
id={id}
name={name}
type="text"
value={value ?? ""}
placeholder="https://... 或按下面上傳"
class="image-url"
/>
<div class="image-actions">
<input type="file" accept="image/*" data-image-input />
<span class="image-status" data-image-status></span>
</div>
<img class="image-preview" data-image-preview src={value || undefined} alt="" hidden={!value} />
</div>
```
- [x] **Step 2: `AdminLayout.astro` 加 client script**
`</main>` 之後、`</body>` 之前加:
```astro
<script>
const MAX_WIDTH = 1600;
const QUALITY = 0.85;
async function toOptimised(file: File): Promise<Blob> {
if (!("createImageBitmap" in window)) return file;
try {
const bitmap = await createImageBitmap(file);
const scale = Math.min(1, MAX_WIDTH / bitmap.width);
const width = Math.max(1, Math.round(bitmap.width * scale));
const height = Math.max(1, Math.round(bitmap.height * scale));
const canvas = document.createElement("canvas");
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext("2d");
if (!ctx) return file;
ctx.drawImage(bitmap, 0, 0, width, height);
if (typeof bitmap.close === "function") bitmap.close();
const blob = await new Promise<Blob | null>((resolve) =>
canvas.toBlob(resolve, "image/webp", QUALITY),
);
return blob ?? file;
} catch {
return file;
}
}
async function upload(field: HTMLElement, file: File): Promise<void> {
const scope = field.dataset.scope ?? "";
const input = field.querySelector<HTMLInputElement>(".image-url");
const preview = field.querySelector<HTMLImageElement>(".image-preview");
const status = field.querySelector<HTMLElement>(".image-status");
if (!input || !status) return;
status.textContent = "上傳中…";
const blob = await toOptimised(file);
const body = new FormData();
body.append("file", new File([blob], file.name, { type: blob.type }));
body.append("scope", scope);
try {
const res = await fetch("/admin/upload", { method: "POST", body });
const data = (await res.json()) as { ok: boolean; url?: string; error?: string };
if (!res.ok || !data.ok || !data.url) {
status.textContent = data.error ?? "上傳失敗。";
return;
}
input.value = data.url;
if (preview) {
preview.src = data.url;
preview.hidden = false;
}
status.textContent = "✓ 已上傳";
} catch {
status.textContent = "上傳失敗,請檢查網絡。";
}
}
document.querySelectorAll<HTMLElement>("[data-image-field]").forEach((field) => {
const fileInput = field.querySelector<HTMLInputElement>("[data-image-input]");
if (!fileInput) return;
fileInput.addEventListener("change", () => {
const file = fileInput.files?.[0];
if (file) void upload(field, file);
});
});
</script>
```
- [x] **Step 3: `AdminLayout.astro` 加樣式**
`<style is:global>``.row { ... }` 之前加:
```css
.image-field { display: block; }
.image-actions { display: flex; align-items: center; gap: 10px; margin-top: 8px; flex-wrap: wrap; }
.image-actions input[type="file"] { font: inherit; font-size: 13px; max-width: 100%; }
.image-status { font-size: 12.5px; font-weight: 600; color: #0b8a5e; }
.image-preview { display: block; margin-top: 10px; max-width: 220px; border-radius: 10px; }
.image-preview[hidden] { display: none; }
```
- [x] **Step 4: Build 驗證**
Run: `npm run build`
Expected: 完成,冇 error。
- [x] **Step 5: Commit**
```bash
git add src/components/admin/ImageField.astro src/layouts/AdminLayout.astro
git commit -m "Add ImageField component and client-side upload script"
```
---
### Task 5: 網站設定圖片欄位(含換圖刪舊)
**Files:**
- Modify: `src/data/settings-fields.ts`
- Modify: `src/pages/admin/settings.astro`
- [x] **Step 1: `settings-fields.ts` 加 `image` 型別**
將第一行改成:
```ts
export type FieldType = "text" | "textarea" | "url" | "image";
```
`hero_image``og_image` 兩行改成:
```ts
{ key: "hero_image", label: "主視覺圖片", type: "image" },
```
```ts
{ key: "og_image", label: "分享圖片 (OG image)", type: "image" },
```
- [x] **Step 2: `settings.astro` 加 import 同刪舊邏輯**
將 frontmatter 改成:
```astro
---
import AdminLayout from "../../layouts/AdminLayout.astro";
import ImageField from "../../components/admin/ImageField.astro";
import { ALL_SETTING_KEYS, SETTINGS_GROUPS } from "../../data/settings-fields";
import { siteSettings } from "../../db/schema";
import { getSettings } from "../../data/content";
import { getDb } from "../../lib/db";
import { getEnv } from "../../lib/env";
import { parseForm } from "../../lib/form";
import { deleteMedia } from "../../lib/media";
import { settingsInput } from "../../schemas";
export const prerender = false;
const env = getEnv();
const db = getDb(env.DB);
const IMAGE_SETTING_KEYS = new Set(
SETTINGS_GROUPS.flatMap((g) =>
g.fields.filter((f) => f.type === "image").map((f) => f.key),
),
);
let saved = false;
let error = "";
if (Astro.request.method === "POST") {
const form = await Astro.request.formData();
const parsed = parseForm(form, settingsInput);
if (!parsed.ok) {
error = Object.values(parsed.errors)[0] ?? "設定有誤。";
} else {
const before = await getSettings(db);
const now = new Date();
const submitted = new Set(
[...form.keys()].filter((k) => ALL_SETTING_KEYS.includes(k)),
);
for (const key of ALL_SETTING_KEYS) {
if (!submitted.has(key)) continue;
const value = String((parsed.data as Record<string, unknown>)[key] ?? "");
if (IMAGE_SETTING_KEYS.has(key) && before[key] !== value) {
await deleteMedia(env, before[key]);
}
await db
.insert(siteSettings)
.values({ key, value, updatedAt: now })
.onConflictDoUpdate({ target: siteSettings.key, set: { value, updatedAt: now } });
}
saved = true;
}
}
const settings = await getSettings(db);
---
```
- [x] **Step 3: `settings.astro` 欄位 render 加 ImageField**
將 template 內 `SETTINGS_GROUPS.map((group) => ( ... ))` 的欄位 map 改成(其餘不變):
```astro
{group.fields.map((f) => (
<div style={f.type === "textarea" ? "grid-column:1/-1" : ""}>
{f.type === "image" ? (
<ImageField
name={f.key}
label={f.label}
value={settings[f.key] ?? ""}
scope="settings"
id={f.key}
/>
) : (
<>
<label for={f.key}>{f.label}</label>
{f.type === "textarea" ? (
<textarea id={f.key} name={f.key} placeholder={f.placeholder}>
{settings[f.key] ?? ""}
</textarea>
) : (
<input
id={f.key}
name={f.key}
type="text"
value={settings[f.key] ?? ""}
placeholder={f.placeholder}
/>
)}
</>
)}
</div>
))}
```
- [x] **Step 4: Build 驗證**
Run: `npm run build`
Expected: 完成,冇 error。
- [x] **Step 5: 手動測試**
Run: `npm run dev`
Expected: `/admin/settings` 的「首頁 Hero」同「SEO 設定」入面,主視覺圖片同 OG 圖變咗有檔案選擇 + 預覽;上傳後 URL 欄填 `/media/settings/<uuid>.webp`;儲存後前台首頁圖片更新;再換一張,舊 `/media/...` 變 404。
- [x] **Step 6: Commit**
```bash
git add src/data/settings-fields.ts src/pages/admin/settings.astro
git commit -m "Add image upload to site settings with old-file cleanup"
```
---
### Task 6: 完成案例圖片上傳
**Files:**
- Modify: `src/pages/admin/cases.astro`
- [x] **Step 1: 加 import 同 `env`**
喺 import 區加:
```astro
import ImageField from "../../components/admin/ImageField.astro";
import { deleteMedia } from "../../lib/media";
```
`const db = getDb(getEnv().DB);` 改成:
```astro
const env = getEnv();
const db = getDb(env.DB);
```
- [x] **Step 2: save 時比對並刪舊圖**
`if (action === "add" || action === "save")` 內嘅 `} else {`(更新分支)改成:
```astro
} else {
const id = str(form, "id");
const newImage = parsed.data.imageUrl || null;
const [current] = await db.select().from(cases).where(eq(cases.id, id)).limit(1);
if (current && current.imageUrl !== newImage) {
await deleteMedia(env, current.imageUrl);
}
await db
.update(cases)
.set({
title: parsed.data.title,
location: parsed.data.location || null,
completedAt: parsed.data.completedAt || null,
description: parsed.data.description || null,
imageUrl: newImage,
status: parsed.data.status,
updatedAt: new Date(),
})
.where(eq(cases.id, id));
}
```
- [x] **Step 3: delete 時刪圖**
`} else if (action === "delete") { ... }` 改成:
```astro
} else if (action === "delete") {
const id = str(form, "id");
const [current] = await db.select().from(cases).where(eq(cases.id, id)).limit(1);
await deleteMedia(env, current?.imageUrl);
await db.delete(cases).where(eq(cases.id, id));
}
```
- [x] **Step 4: 新增表單改用 ImageField**
將新增表單內:
```astro
<div>
<label for="a-img">圖片 URL</label>
<input id="a-img" name="imageUrl" type="text" placeholder="https://..." />
</div>
```
改成:
```astro
<div>
<ImageField name="imageUrl" label="圖片" scope="cases" id="a-img" />
</div>
```
- [x] **Step 5: 每張卡改用 ImageField 並移除舊預覽**
將每張卡內:
```astro
<div>
<label>圖片 URL</label>
<input name="imageUrl" type="text" value={item.imageUrl ?? ""} />
</div>
```
改成:
```astro
<div>
<ImageField
name="imageUrl"
label="圖片"
scope="cases"
value={item.imageUrl}
id={`img-${item.id}`}
/>
</div>
```
再移除舊嘅 standalone 預覽 blockImageField 已自帶預覽):
```astro
{item.imageUrl && (
<img src={item.imageUrl} alt={item.title} style="margin-top:14px;max-width:220px;border-radius:10px;" />
)}
```
- [x] **Step 6: Build 驗證**
Run: `npm run build`
Expected: 完成,冇 error。
- [x] **Step 7: 手動測試**
Run: `npm run dev`
Expected: `/admin/cases` 新增同每張卡都有上傳;上傳後前台「完成案例」顯示新圖;換圖後舊圖 404;刪除案例後其圖 404。
- [x] **Step 8: Commit**
```bash
git add src/pages/admin/cases.astro
git commit -m "Add image upload to cases with cleanup"
```
---
### Task 7: 文章封面上傳
**Files:**
- Modify: `src/pages/admin/post/[id].astro`
- [x] **Step 1: 加 import 同 `env`**
喺 import 區加:
```astro
import ImageField from "../../../components/admin/ImageField.astro";
import { deleteMedia } from "../../../lib/media";
```
`const db = getDb(getEnv().DB);` 改成:
```astro
const env = getEnv();
const db = getDb(env.DB);
```
- [x] **Step 2: 刪文時刪封面**
將:
```astro
if (action === "delete" && !isNew) {
await db.delete(posts).where(eq(posts.id, id!));
return Astro.redirect("/admin");
}
```
改成:
```astro
if (action === "delete" && !isNew) {
await deleteMedia(env, existing?.coverImage);
await db.delete(posts).where(eq(posts.id, id!));
return Astro.redirect("/admin");
}
```
- [x] **Step 3: 更新時比對並刪舊封面**
`} else {`(更新分支)內、`await db.update(posts)` 之前加:
```astro
if (existing!.coverImage !== coverImage) {
await deleteMedia(env, existing!.coverImage);
}
```
更新分支開頭應該係:
```astro
} else {
if (existing!.coverImage !== coverImage) {
await deleteMedia(env, existing!.coverImage);
}
await db
.update(posts)
.set({
slug,
...
```
- [x] **Step 4: 封面欄改用 ImageField**
將:
```astro
<label for="coverImage">封面圖片 URL(列表卡片用)</label>
{errors.coverImage && <span class="field-error">{errors.coverImage}</span>}
<input
id="coverImage"
name="coverImage"
type="text"
value={post.coverImage ?? ""}
placeholder="https://..."
/>
```
改成:
```astro
{errors.coverImage && <span class="field-error">{errors.coverImage}</span>}
<ImageField
name="coverImage"
label="封面圖片(列表卡片用)"
scope="posts"
value={post.coverImage}
id="coverImage"
/>
```
- [x] **Step 5: Build 驗證**
Run: `npm run build`
Expected: 完成,冇 error。
- [x] **Step 6: 手動測試**
Run: `npm run dev`
Expected: `/admin/post/new` 同上傳封面;發布後 Blog 列表/文章頁顯示新封面;換封面舊圖 404;刪文封面 404。
- [x] **Step 7: Commit**
```bash
git add "src/pages/admin/post/[id].astro"
git commit -m "Add cover image upload to blog posts with cleanup"
```
---
### Task 8: DOX 文件更新
**Files:**
- Modify: `src/AGENTS.md`
- Modify: `src/lib/AGENTS.md`
- Modify: `src/data/AGENTS.md`
- Modify: `AGENTS.md`
- [x] **Step 1: 更新 `src/AGENTS.md`**
Admin 段「- **圖片**:一律以 URL 字串處理(暫無上傳)。」改成:
```markdown
- **圖片**:圖片欄位(settings 的 `hero_image` / `og_image``cases.imageUrl``posts.coverImage`)用 `components/admin/ImageField.astro`,可揀檔即時上傳去 R2(binding `MEDIA`),亦可貼 URLclient 端縮到最大寬 1600px 並轉 WebP 先上傳。上傳 endpoint `/admin/upload`(受 middleware 保護),出圖 endpoint `/media/[...key]`(公開、`immutable` cache)。換圖/刪除時由 `lib/media.ts``deleteMedia` 清舊 R2 檔。
```
喺 Routes & SSR 段「**Endpoint**」bullet 之後加:
```markdown
- **圖片 endpoints**`/admin/upload`POST,上傳去 R2 `MEDIA`);`/media/[...key]`GET,讀 R2 出圖,設 `Cache-Control: public, max-age=31536000, immutable` 同 ETag304)。
```
- [x] **Step 2: 更新 `src/lib/AGENTS.md`**
Ownership 段改成「擁有 `src/lib/` 七個檔案:`env.ts``db.ts``auth.ts``form.ts``media.ts``ai.ts``markdown.ts`。」
`env.ts` bullet 的 `AppEnv` 清單加 `MEDIA``AppEnv``DB``CACHE``MEDIA``ADMIN_PASSWORD?`…))。
`markdown.ts` bullet 之前加:
```markdown
- **`media.ts`**`MEDIA_PREFIX``/media/`)、`mediaUrl(key)``mediaKey(url)``deleteMedia(env, url)``deleteMedia` 只處理 `/media/` 開頭嘅值,外部 URL/空值唔理、唔 throw。
```
- [x] **Step 3: 更新 `src/data/AGENTS.md`**
`settings-fields.ts` 相關:Local Contracts 內「**設定欄位**」bullet 補一句:
```markdown
- **圖片欄位**`FieldType``"image"``hero_image` / `og_image` 用呢個型別,`/admin/settings` 會 render `ImageField`(可上傳去 R2)。
```
- [x] **Step 4: 更新根 `AGENTS.md`**
「關鍵慣例與陷阱」內補一條(放喺環境變數 / 資料層附近):
```markdown
- **圖片上傳**:圖片存 Cloudflare R2binding `MEDIA`、bucket `yingfung-solar-media`),由同一個 Worker 的 `/media/*` 出圖;上傳經 `/admin/upload`client 端先縮圖轉 WebP。本機靠 `platformProxy` 模擬 R2,雲端要先 `npm run r2:create`。圖片欄位仍可貼 URL;換圖/刪除時 `lib/media.ts` 會清舊 R2 檔。
```
`scripts/` 是 project-level files owned by root,唔使另加 Child DOX。
- [x] **Step 5: Build 驗證**
Run: `npm run build`
Expected: 完成,冇 error。
- [x] **Step 6: Commit**
```bash
git add AGENTS.md src/AGENTS.md src/lib/AGENTS.md src/data/AGENTS.md
git commit -m "Update DOX docs for R2 image upload"
```
---
### Task 9: 最終端到端驗證
**Files:** 無(只驗證)
- [x] **Step 1: 全量 build**
Run: `npm run build`
Expected: 成功。
- [x] **Step 2: 端到端手動檢查(`npm run dev`**
逐項確認:
1. `/admin/settings``hero_image``og_image` 可上傳 + 預覽;儲存後前台首頁主視覺/`<meta property="og:image">` 用新圖(OG 應係絕對 URL)。
2. `/admin/cases`:新增/編輯案例可上傳;前台「完成案例」顯示新圖。
3. `/admin/post/new` 同既有文章:封面可上傳;Blog 列表/文章頁顯示新封面。
4. 亂打 `/media/xxx` → 404;正常 `/media/<key>` 有 response 同 `Cache-Control: immutable`
5. 換圖後,舊 `/media/<key>` → 404。
6. 貼外部 URL(唔上傳)→ 照樣儲存同顯示。
7. 上傳 SVG 或 >8MB 檔 → 顯示中文錯誤、唔會寫入。
8. 冇 JS(停用 JavaScript)→ URL 欄仍可手動貼,主表單可儲存。
- [x] **Step 3: 確認 git 狀態乾淨**
Run: `git status --short`
Expected: 冇未 commit 嘅改動(或只有預期內嘅檔案)。
---
## Self-Review
**Spec coverage:**
- 4 個欄位全部支援 → Task 5settings 2 個)、Task 6cases)、Task 7post)。
- Worker `/media/*` 出圖 → Task 3。
- 瀏覽器端縮圖轉 WebP → Task 4。
- 保留貼 URL → Task 4ImageField 保留 text input)+ Task 9 step 2.8。
- 自動刪舊圖 → Task 2 `deleteMedia`、Task 5/6/7 呼叫。
- 拒絕 SVG / 8MB 上限 → Task 3。
- R2 binding / env / setup / r2:create → Task 1。
- 冇 UI migration → 冇 DB task,正確。
- DOX 更新 → Task 8。
**Placeholder scan:** 冇 TBD/「add appropriate…」;每個 code step 都有完整內容。
**Type consistency:** `deleteMedia(env, url)``mediaUrl(key)``mediaKey(url)``ImageField` props`name` / `label` / `value` / `scope` / `id`)喺所有 task 一致;`scope` 值只用 `settings` / `cases` / `posts`,同 endpoint `SCOPES` 一致。