From 47f14b79e397f33027b2da471f94a216c0fb917e Mon Sep 17 00:00:00 2001 From: philipcheung Date: Sat, 12 Sep 2026 00:39:48 +0800 Subject: [PATCH] Add R2 image upload implementation plan --- .../plans/2026-09-12-r2-image-upload.md | 999 ++++++++++++++++++ 1 file changed, 999 insertions(+) create mode 100644 docs/superpowers/plans/2026-09-12-r2-image-upload.md diff --git a/docs/superpowers/plans/2026-09-12-r2-image-upload.md b/docs/superpowers/plans/2026-09-12-r2-image-upload.md new file mode 100644 index 0000000..fb5e73d --- /dev/null +++ b/docs/superpowers/plans/2026-09-12-r2-image-upload.md @@ -0,0 +1,999 @@ +# 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 (`- [ ]`) 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、原生 Canvas/fetch(後台唔引入 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` + +- [ ] **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" + } + ] +} +``` + +- [ ] **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", +``` + +- [ ] **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; +}; +``` + +- [ ] **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 || ""); + } + } +``` + +- [ ] **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,但正常會出)。 + +- [ ] **Step 6: Build 驗證** + +Run: `npm run build` +Expected: 完成,冇 error。 + +- [ ] **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` + +- [ ] **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 { + const key = mediaKey(url); + if (!key) return; + try { + await env.MEDIA.delete(key); + } catch { + // 刪除失敗唔應該阻礙儲存流程。 + } +} +``` + +- [ ] **Step 2: Build 驗證** + +Run: `npm run build` +Expected: 完成,冇 error。 + +- [ ] **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` + +- [ ] **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 = { + "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) }); +}; +``` + +- [ ] **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 }); +}; +``` + +- [ ] **Step 3: Build 驗證** + +Run: `npm run build` +Expected: 完成,冇 error,而且 `dist/` 唔會 build 出 `/media` 靜態頁(endpoint 係 SSR)。 + +- [ ] **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/.webp" }`,跟住開嗰個 url 有 response(body 係嗰 4 bytes)。 + +- [ ] **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` + +- [ ] **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; +--- + +
+ + +
+ + +
+ +
+``` + +- [ ] **Step 2: `AdminLayout.astro` 加 client script** + +喺 `` 之後、`` 之前加: + +```astro + +``` + +- [ ] **Step 3: `AdminLayout.astro` 加樣式** + +喺 `