Add R2 image upload implementation plan
This commit is contained in:
@@ -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<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**
|
||||||
|
|
||||||
|
```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<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`**
|
||||||
|
|
||||||
|
```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/<uuid>.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;
|
||||||
|
---
|
||||||
|
|
||||||
|
<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>` 之前加:
|
||||||
|
|
||||||
|
```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>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **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; }
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Build 驗證**
|
||||||
|
|
||||||
|
Run: `npm run build`
|
||||||
|
Expected: 完成,冇 error。
|
||||||
|
|
||||||
|
- [ ] **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`
|
||||||
|
|
||||||
|
- [ ] **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" },
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **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);
|
||||||
|
---
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **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>
|
||||||
|
))}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **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**
|
||||||
|
|
||||||
|
```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`
|
||||||
|
|
||||||
|
- [ ] **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);
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **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));
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **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));
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **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>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **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 預覽 block(ImageField 已自帶預覽):
|
||||||
|
|
||||||
|
```astro
|
||||||
|
{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**
|
||||||
|
|
||||||
|
```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`
|
||||||
|
|
||||||
|
- [ ] **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);
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **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");
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **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,
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **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"
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **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**
|
||||||
|
|
||||||
|
```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`
|
||||||
|
|
||||||
|
- [ ] **Step 1: 更新 `src/AGENTS.md`**
|
||||||
|
|
||||||
|
Admin 段「- **圖片**:一律以 URL 字串處理(暫無上傳)。」改成:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
- **圖片**:圖片欄位(settings 的 `hero_image` / `og_image`、`cases.imageUrl`、`posts.coverImage`)用 `components/admin/ImageField.astro`,可揀檔即時上傳去 R2(binding `MEDIA`),亦可貼 URL;client 端縮到最大寬 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` 同 ETag/304)。
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **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。
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: 更新 `src/data/AGENTS.md`**
|
||||||
|
|
||||||
|
`settings-fields.ts` 相關:Local Contracts 內「**設定欄位**」bullet 補一句:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
- **圖片欄位**:`FieldType` 有 `"image"`;`hero_image` / `og_image` 用呢個型別,`/admin/settings` 會 render `ImageField`(可上傳去 R2)。
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: 更新根 `AGENTS.md`**
|
||||||
|
|
||||||
|
「關鍵慣例與陷阱」內補一條(放喺環境變數 / 資料層附近):
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
- **圖片上傳**:圖片存 Cloudflare R2(binding `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**
|
||||||
|
|
||||||
|
```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:** 無(只驗證)
|
||||||
|
|
||||||
|
- [ ] **Step 1: 全量 build**
|
||||||
|
|
||||||
|
Run: `npm run build`
|
||||||
|
Expected: 成功。
|
||||||
|
|
||||||
|
- [ ] **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 欄仍可手動貼,主表單可儲存。
|
||||||
|
|
||||||
|
- [ ] **Step 3: 確認 git 狀態乾淨**
|
||||||
|
|
||||||
|
Run: `git status --short`
|
||||||
|
Expected: 冇未 commit 嘅改動(或只有預期內嘅檔案)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Self-Review
|
||||||
|
|
||||||
|
**Spec coverage:**
|
||||||
|
- 4 個欄位全部支援 → Task 5(settings 2 個)、Task 6(cases)、Task 7(post)。
|
||||||
|
- Worker `/media/*` 出圖 → Task 3。
|
||||||
|
- 瀏覽器端縮圖轉 WebP → Task 4。
|
||||||
|
- 保留貼 URL → Task 4(ImageField 保留 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` 一致。
|
||||||
Reference in New Issue
Block a user