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

29 KiB
Raw Blame History

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_imageog_image、案例 imageUrl、文章 coverImage)支援揀檔即時上傳去 Cloudflare R2,同時保留貼 URL。

Architecture: 單一 Worker 加一個 R2 binding MEDIA/admin/upload(受 middleware 保護)收檔寫入 R2/media/* 公開讀出。瀏覽器端用 Canvas 縮到最大寬 1600px、轉 WebP 再上傳。舊圖喺換圖/刪除時由 src/lib/media.tsdeleteMedia 清理。唔改 DB schema、唔加 migration。

Tech Stack: Astro 7output: "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 bindingMEDIA
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 FieldTypeimagehero/og 改型別
src/pages/admin/settings.astro image 型別用 ImageField + 換圖刪舊
src/pages/admin/cases.astro ImageField + 換圖/刪除清理
src/pages/admin/post/[id].astro ImageField + 換封面/刪文清理

唔郁: src/db/schema.tsmigrations/**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": [...] 之後(結尾 ] 加逗號)加:

  "r2_buckets": [
    {
      "binding": "MEDIA",
      "bucket_name": "yingfung-solar-media"
    }
  ]

完整 wrangler.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.jsonr2:create

"kv:create" 之後加一行:

    "kv:create": "wrangler kv namespace create CACHE",
    "r2:create": "wrangler r2 bucket create yingfung-solar-media",
  • Step 3: src/lib/env.tsMEDIA

AppEnv 改成:

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 = ... 之後)加:

const R2_BUCKET = "yingfung-solar-media";

喺 KV 區塊(} else { console.log("KV id 已設定,略過。"); })之後、const setPassword = await rl.question(...) 之前加:

  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.tsMEDIA: 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
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

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 {
    // 刪除失敗唔應該阻礙儲存流程。
  }
}
  • Step 2: Build 驗證

Run: npm run build Expected: 完成,冇 error。

  • Step 3: Commit
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

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) });
};
  • Step 2: 建立 src/pages/media/[...key].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.varsADMIN_PASSWORD),用瀏覽器 DevTools console 執行(本機 R2 係 wrangler 模擬):
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)。

  • Step 5: Commit
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

---
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>
  • Step 2: AdminLayout.astro 加 client script

</main> 之後、</body> 之前加:

    <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>
  • Step 3: AdminLayout.astro 加樣式

<style is:global>.row { ... } 之前加:

  .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; }
  • Step 4: Build 驗證

Run: npm run build Expected: 完成,冇 error。

  • Step 5: Commit
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

  • Step 1: settings-fields.tsimage 型別

將第一行改成:

export type FieldType = "text" | "textarea" | "url" | "image";

hero_imageog_image 兩行改成:

      { key: "hero_image", label: "主視覺圖片", type: "image" },
      { key: "og_image", label: "分享圖片 (OG image)", type: "image" },
  • Step 2: settings.astro 加 import 同刪舊邏輯

將 frontmatter 改成:

---
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);
---
  • Step 3: settings.astro 欄位 render 加 ImageField

將 template 內 SETTINGS_GROUPS.map((group) => ( ... )) 的欄位 map 改成(其餘不變):

            {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>
            ))}
  • Step 4: Build 驗證

Run: npm run build Expected: 完成,冇 error。

  • Step 5: 手動測試

Run: npm run dev Expected: /admin/settings 的「首頁 Hero」同「SEO 設定」入面,主視覺圖片同 OG 圖變咗有檔案選擇 + 預覽;上傳後 URL 欄填 /media/settings/<uuid>.webp;儲存後前台首頁圖片更新;再換一張,舊 /media/... 變 404。

  • Step 6: Commit
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

  • Step 1: 加 import 同 env

喺 import 區加:

import ImageField from "../../components/admin/ImageField.astro";
import { deleteMedia } from "../../lib/media";

const db = getDb(getEnv().DB); 改成:

const env = getEnv();
const db = getDb(env.DB);
  • Step 2: save 時比對並刪舊圖

if (action === "add" || action === "save") 內嘅 } else {(更新分支)改成:

    } 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));
    }
  • Step 3: delete 時刪圖

} else if (action === "delete") { ... } 改成:

  } 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));
  }
  • Step 4: 新增表單改用 ImageField

將新增表單內:

        <div>
          <label for="a-img">圖片 URL</label>
          <input id="a-img" name="imageUrl" type="text" placeholder="https://..." />
        </div>

改成:

        <div>
          <ImageField name="imageUrl" label="圖片" scope="cases" id="a-img" />
        </div>
  • Step 5: 每張卡改用 ImageField 並移除舊預覽

將每張卡內:

              <div>
                <label>圖片 URL</label>
                <input name="imageUrl" type="text" value={item.imageUrl ?? ""} />
              </div>

改成:

              <div>
                <ImageField
                  name="imageUrl"
                  label="圖片"
                  scope="cases"
                  value={item.imageUrl}
                  id={`img-${item.id}`}
                />
              </div>

再移除舊嘅 standalone 預覽 blockImageField 已自帶預覽):

            {item.imageUrl && (
              <img src={item.imageUrl} alt={item.title} style="margin-top:14px;max-width:220px;border-radius:10px;" />
            )}
  • Step 6: Build 驗證

Run: npm run build Expected: 完成,冇 error。

  • Step 7: 手動測試

Run: npm run dev Expected: /admin/cases 新增同每張卡都有上傳;上傳後前台「完成案例」顯示新圖;換圖後舊圖 404;刪除案例後其圖 404。

  • Step 8: Commit
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

  • Step 1: 加 import 同 env

喺 import 區加:

import ImageField from "../../../components/admin/ImageField.astro";
import { deleteMedia } from "../../../lib/media";

const db = getDb(getEnv().DB); 改成:

const env = getEnv();
const db = getDb(env.DB);
  • Step 2: 刪文時刪封面

將:

  if (action === "delete" && !isNew) {
    await db.delete(posts).where(eq(posts.id, id!));
    return Astro.redirect("/admin");
  }

改成:

  if (action === "delete" && !isNew) {
    await deleteMedia(env, existing?.coverImage);
    await db.delete(posts).where(eq(posts.id, id!));
    return Astro.redirect("/admin");
  }
  • Step 3: 更新時比對並刪舊封面

} else {(更新分支)內、await db.update(posts) 之前加:

      if (existing!.coverImage !== coverImage) {
        await deleteMedia(env, existing!.coverImage);
      }

更新分支開頭應該係:

    } else {
      if (existing!.coverImage !== coverImage) {
        await deleteMedia(env, existing!.coverImage);
      }
      await db
        .update(posts)
        .set({
          slug,
          ...
  • Step 4: 封面欄改用 ImageField

將:

    <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://..."
    />

改成:

    {errors.coverImage && <span class="field-error">{errors.coverImage}</span>}
    <ImageField
      name="coverImage"
      label="封面圖片(列表卡片用)"
      scope="posts"
      value={post.coverImage}
      id="coverImage"
    />
  • Step 5: Build 驗證

Run: npm run build Expected: 完成,冇 error。

  • Step 6: 手動測試

Run: npm run dev Expected: /admin/post/new 同上傳封面;發布後 Blog 列表/文章頁顯示新封面;換封面舊圖 404;刪文封面 404。

  • Step 7: Commit
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

  • Step 1: 更新 src/AGENTS.md

Admin 段「- 圖片:一律以 URL 字串處理(暫無上傳)。」改成:

- **圖片**:圖片欄位(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 之後加:

- **圖片 endpoints**`/admin/upload`POST,上傳去 R2 `MEDIA`);`/media/[...key]`GET,讀 R2 出圖,設 `Cache-Control: public, max-age=31536000, immutable` 同 ETag304)。
  • Step 2: 更新 src/lib/AGENTS.md

Ownership 段改成「擁有 src/lib/ 七個檔案:env.tsdb.tsauth.tsform.tsmedia.tsai.tsmarkdown.ts。」

env.ts bullet 的 AppEnv 清單加 MEDIAAppEnvDBCACHEMEDIAADMIN_PASSWORD?…))。

markdown.ts bullet 之前加:

- **`media.ts`**`MEDIA_PREFIX``/media/`)、`mediaUrl(key)``mediaKey(url)``deleteMedia(env, url)``deleteMedia` 只處理 `/media/` 開頭嘅值,外部 URL/空值唔理、唔 throw。
  • Step 3: 更新 src/data/AGENTS.md

settings-fields.ts 相關:Local Contracts 內「設定欄位」bullet 補一句:

- **圖片欄位**`FieldType``"image"``hero_image` / `og_image` 用呢個型別,`/admin/settings` 會 render `ImageField`(可上傳去 R2)。
  • Step 4: 更新根 AGENTS.md

「關鍵慣例與陷阱」內補一條(放喺環境變數 / 資料層附近):

- **圖片上傳**:圖片存 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。

  • Step 5: Build 驗證

Run: npm run build Expected: 完成,冇 error。

  • Step 6: Commit
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: 無(只驗證)

  • Step 1: 全量 build

Run: npm run build Expected: 成功。

  • Step 2: 端到端手動檢查(npm run dev

逐項確認:

  1. /admin/settingshero_imageog_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 欄仍可手動貼,主表單可儲存。
  • 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 propsname / label / value / scope / id)喺所有 task 一致;scope 值只用 settings / cases / posts,同 endpoint SCOPES 一致。