Website portfolio tối giản cho một designer/team nhỏ: một portfolio, một admin, không database. Toàn bộ JSON và ảnh nằm trên Cloudflare R2, đăng nhập admin qua Cloudflare Access, deploy trên Vercel.
Người dùng admin: xem TUTORIAL.md — cách chỉnh homepage, project, autosave và publish.
Kiến trúc / ADR: ARD-portflow-showcase.md, ADR-0001, ADR-0002.
| Route | Mô tả |
|---|---|
/ |
Portfolio public (release đã publish) |
/{projectSlug} |
Chi tiết project đã publish |
/admin |
Trang quản trị duy nhất (Cloudflare Access + JWT verification) |
/admin?page=home |
Edit homepage (setup + layout) |
/admin?project={id} |
Edit project |
/api/admin/* |
Write API, bắt buộc requireAdmin() |
pnpm install
cp .env.example .env.local # rồi điền giá trị thật
pnpm dev.env.local hiện có sẵn placeholder — cần cập nhật trước khi upload/publish hoạt động:
ADMIN_EMAIL— email admin duy nhất được phép (vẫn dùng khiADMIN_AUTH_MODE=passwordcho identity/session).ADMIN_AUTH_MODE—cloudflare_access(mặc định) hoặcpassword.- Cloudflare Access (
ADMIN_AUTH_MODE=cloudflare_access):CF_ACCESS_TEAM_DOMAIN,CF_ACCESS_AUDS— từ Zero Trust (hai Access applications cho/admin*và/api/admin*, Audience tags phân tách bằng dấu phẩy). - Password (
ADMIN_AUTH_MODE=password):ADMIN_USERNAME,ADMIN_PASSWORD(≥ 8 ký tự). Yếu hơn Access nhiều — chỉ dùng khi chưa có custom domain Cloudflare; form login tại/admin. R2_ACCOUNT_ID,R2_ACCESS_KEY_ID,R2_SECRET_ACCESS_KEY— API token R2 quyền tối thiểu trên 2 bucket.R2_PRIVATE_BUCKET,R2_PUBLIC_BUCKET,R2_PUBLIC_BASE_URL— bucket private (JSON) + bucket public (ảnh) + custom domain của bucket public.UPLOAD_TOKEN_SECRET— chuỗi ngẫu nhiên ≥ 32 ký tự (cũng dùng ký session cookie khi password mode).
Khi dev local, DEV_ADMIN_BYPASS=true (đặt trong .env.development.local, chỉ được load khi next dev) cho phép vào /admin không cần Cloudflare Access. Chỉ hoạt động với NODE_ENV=development — build sẽ fail nếu biến này là true trong môi trường build/Preview/Production.
pnpm dev # dev server
pnpm lint # eslint
pnpm typecheck # tsc --noEmit
pnpm test # vitest unit + application tests
pnpm test:e2e # playwright (cần dev server / R2 test bucket)
pnpm build # production build
pnpm check # lint + typecheck + test + buildsrc/modules/{site,project,asset,publishing,access,layout,rich-text}— DDD-lite:domain(schema Zod, invariant),application(use case + port),infrastructure(R2/Cloudflare adapter),presentation(React).- Nội dung dạng block (ADR-0001/0002): project và trang chủ đều là
rows[] → columns[] → blocks[](image, rich-text; trang chủ thêm 2 system blockprofile+project-grid). Editor full-page tại/admin?project={id}và/admin?page=home, autosave debounce + revision check. Hướng dẫn thao tác UI: TUTORIAL.md. - Rich text lưu dạng Tiptap/ProseMirror JSON (không HTML) — paragraph/heading/list + bold/italic/underline + font size/family allowlist; server validate bằng Zod; public render qua static renderer, không bundle editor.
- Theme: mỗi project/trang chủ có
{backgroundColor, textColor}dạng#RRGGBB; contrast thấp chỉ cảnh báo. - Schema versions: project draft v3, site draft v2, release project v3, release site v2. Bản cũ (project/release-project v2, site/release-site v1) được normalize khi đọc — không rewrite R2, release cũ bất biến. Chi tiết migration: ADR-0002.
src/lib— env (Zod, server-only), R2 ObjectStore + key builder, API error contract, rate limit, logger.src/stores— Zustand:upload-store(queue upload, concurrency 3),admin-ui-store.- Nội dung: draft → (autosave, revision check) → R2 private; Publish tạo release snapshot bất biến, ghi
content/current.jsonsau cùng — publish fail không ảnh hưởng release đang chạy. - Upload: warm-up (presigned PUT 90s + finalize token) → browser PUT thẳng lên R2 staging → complete (verify checksum/size, copy sang public bucket, idempotent).
- R2: tạo 2 bucket (private/public), API token quyền tối thiểu, gắn custom domain cho bucket public, cấu hình CORS cho origin app (PUT + header
content-type,x-amz-checksum-sha256), lifecycle rule tự xóauploads/staging/*. - Cloudflare Access: 2 self-hosted applications (
/admin*,/api/admin*), policy allow đúngADMIN_EMAIL, lấy cả 2 Audience tags bỏ vàoCF_ACCESS_AUDS. - Vercel: import repo, đặt env riêng cho Preview/Production (không bật
DEV_ADMIN_BYPASS), gắn production domain qua Cloudflare proxy. - Kiểm tra: vào
/adminqua domain Cloudflare (login OTP/IdP); gọi thẳng*.vercel.app/api/admin/contentphải trả401.
- 401 UNAUTHENTICATED trên
/admin— request không đi qua Cloudflare proxy hoặc thiếu headerCf-Access-Jwt-Assertion; kiểm tra DNS proxy (đám mây cam) và Access application path. - 403 FORBIDDEN — đăng nhập bằng email khác
ADMIN_EMAIL. - Upload fail ngay khi PUT — kiểm tra CORS của bucket private (origin app, method PUT, headers
content-type,x-amz-checksum-sha256). STORAGE_UNAVAILABLE— credential R2 sai hoặc bucket không tồn tại; xem server logs (JSON, córequestId).- Build fail
DEV_ADMIN_BYPASS— biến này đangtrueở môi trường không phải development; xóa khỏi Vercel env. - 409 REVISION_CONFLICT khi Save — nội dung đã bị sửa ở tab khác; reload trang admin.