Skip to content

Commit f61b12a

Browse files
dengzhaofunclaude
andauthored
Add media library (R2 / S3-compatible) + banner cover picker (#28)
New admin module for uploading, organizing, and embedding image assets across the product. Storage is abstracted behind an `ObjectStorage` interface so the same code runs against Cloudflare R2 today and an S3-compatible endpoint (AWS S3, MinIO, Aliyun OSS) when the project moves off Cloudflare — driver is selected via `STORAGE_DRIVER=r2|s3`. Server - `src/lib/storage/` — `ObjectStorage` interface, R2 binding impl, and SigV4 S3 impl with a hand-rolled signer (Workers-friendly, zero new deps). `sigv4.test.ts` covers signature shape + payload hashing. - `src/modules/media-library/` — folder tree (adjacency list, per-org lazy-created `isDefault` root), asset CRUD, worker-mediated upload (10 MB / image MIME whitelist), and presigned-upload scaffolding for future direct-to-storage flows. Worker-proxy `/object/*` route for local dev when no CDN is configured. - `src/schema/media-library.ts` + drizzle migration 0027 — `media_folders` (self-FK with ON DELETE RESTRICT, partial unique indexes) and `media_assets` (bigint size, unique object_key). - `secureHeaders()` switched to `crossOriginResourcePolicy: "cross-origin"` so `<img>` loads from cross-origin admin work. This worker is already designed for cross-origin callers via the existing CORS config; the default CORP was actively fighting that intent. - `AppDeps` gains `storage: ObjectStorage`, constructed lazily from env so drizzle-kit and other Node entrypoints don't need R2 bindings. Admin - `/media-library` route with folder tree + asset grid + multi-file upload. `MediaPickerDialog` component reused by `BannerForm` — imageUrl fields switched from plain text inputs to thumbnail + picker ("from library" or "upload new to default folder"). Banner schema and API unchanged. - `api-client` extended with `upload(FormData)` and `resolveAssetUrl` to handle both CDN-absolute and worker-proxy-relative URLs. - i18n: 35 new paraglide keys wired through every new component plus a `media_folder_default_name` override so the auto-created default folder reads "Default uploads" / "默认上传" based on the active locale (server still stores its canonical name in the DB). - Sidebar entry `nav_media_library`. - `.env.production` pins the production worker URL for the admin Vite build so the deployed bundle no longer falls back to `localhost:8787`. Configuration - `wrangler.jsonc` — R2 binding `MEDIA_R2 → apollokit-media`. - `.dev.vars` documents `STORAGE_DRIVER`, `MEDIA_PUBLIC_URL_BASE`, and the optional S3 presign credentials. Verified - `pnpm vitest run src/lib/storage/sigv4.test.ts` passes. - Server `pnpm check-types` adds no new errors over main. - Admin `pnpm check-types` adds no new errors over main. - End-to-end smoke in both local dev and production: upload → R2 write → public URL render → banner picker flow. Tested at both `en` and `zh` locales. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent e28da81 commit f61b12a

34 files changed

Lines changed: 18866 additions & 23 deletions

apps/admin/.env.production

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
VITE_AUTH_SERVER_URL=https://apollokit-server.limitless-ai.workers.dev

apps/admin/messages/en.json

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -941,5 +941,39 @@
941941
"task_assign_revoked": "Assignment revoked",
942942
"task_assign_revoke_failed": "Failed to revoke",
943943
"nav_activity": "Activities",
944-
"nav_leaderboard": "Leaderboards"
944+
"nav_leaderboard": "Leaderboards",
945+
"nav_media_library": "Media Library",
946+
"media_header_title": "Media Library",
947+
"media_upload_button": "Upload",
948+
"media_upload_choose_file": "Choose file",
949+
"media_upload_in_progress": "Uploading…",
950+
"media_upload_progress": "Uploading {done}/{total}…",
951+
"media_upload_failed": "Upload failed",
952+
"media_folders_loading": "Loading folders…",
953+
"media_folders_failed": "Failed to load folders: {error}",
954+
"media_folder_root": "Root",
955+
"media_folder_new_root": "New root folder",
956+
"media_folder_new_sub": "New subfolder",
957+
"media_folder_cancel": "Cancel",
958+
"media_folder_placeholder": "Folder name",
959+
"media_folder_create": "Create",
960+
"media_folder_create_failed": "Failed to create folder",
961+
"media_folder_delete_confirm": "Delete empty folder \"{name}\"?",
962+
"media_folder_delete_failed": "Failed to delete",
963+
"media_folder_expand": "Expand",
964+
"media_folder_collapse": "Collapse",
965+
"media_folder_delete": "Delete",
966+
"media_assets_loading": "Loading…",
967+
"media_assets_failed": "Load failed: {error}",
968+
"media_assets_empty": "Empty — upload your first asset",
969+
"media_asset_delete_confirm": "Delete asset \"{name}\"? The object will be permanently removed from storage.",
970+
"media_asset_copy_link": "Copy link",
971+
"media_asset_delete": "Delete",
972+
"media_picker_trigger": "Select image",
973+
"media_picker_title": "Select asset",
974+
"media_picker_description": "Pick an existing asset from the drive, or upload a new one into the default folder.",
975+
"media_picker_tab_library": "From library",
976+
"media_picker_tab_upload": "Upload new",
977+
"media_picker_upload_hint": "New assets upload to the default folder — you can reorganize them later in the media library.",
978+
"media_folder_default_name": "Default uploads"
945979
}

apps/admin/messages/zh.json

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -941,5 +941,39 @@
941941
"task_assign_revoked": "已撤销分配",
942942
"task_assign_revoke_failed": "撤销失败",
943943
"nav_activity": "活动",
944-
"nav_leaderboard": "排行榜"
944+
"nav_leaderboard": "排行榜",
945+
"nav_media_library": "素材云盘",
946+
"media_header_title": "素材云盘",
947+
"media_upload_button": "上传素材",
948+
"media_upload_choose_file": "选择文件上传",
949+
"media_upload_in_progress": "上传中…",
950+
"media_upload_progress": "上传中 {done}/{total}…",
951+
"media_upload_failed": "上传失败",
952+
"media_folders_loading": "加载目录中…",
953+
"media_folders_failed": "目录加载失败:{error}",
954+
"media_folder_root": "根目录",
955+
"media_folder_new_root": "新建根目录",
956+
"media_folder_new_sub": "新建子目录",
957+
"media_folder_cancel": "取消",
958+
"media_folder_placeholder": "目录名",
959+
"media_folder_create": "新建",
960+
"media_folder_create_failed": "新建失败",
961+
"media_folder_delete_confirm": "删除空目录「{name}」?",
962+
"media_folder_delete_failed": "删除失败",
963+
"media_folder_expand": "展开",
964+
"media_folder_collapse": "折叠",
965+
"media_folder_delete": "删除",
966+
"media_assets_loading": "加载中…",
967+
"media_assets_failed": "加载失败:{error}",
968+
"media_assets_empty": "空空如也 — 上传第一张素材吧",
969+
"media_asset_delete_confirm": "删除素材「{name}」?对象会从存储中真删除。",
970+
"media_asset_copy_link": "复制链接",
971+
"media_asset_delete": "删除",
972+
"media_picker_trigger": "选择图片",
973+
"media_picker_title": "选择素材",
974+
"media_picker_description": "从云盘选一张已上传的素材,或直接上传新素材到默认目录。",
975+
"media_picker_tab_library": "从云盘选择",
976+
"media_picker_tab_upload": "上传新素材",
977+
"media_picker_upload_hint": "新素材会上传到云盘的默认目录,之后也可以在云盘里整理。",
978+
"media_folder_default_name": "默认上传"
945979
}

apps/admin/src/components/AppSidebar.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
ArrowLeftRight,
55
BookOpen,
66
CalendarCheck,
7+
FolderOpen,
78
GalleryHorizontal,
89
Gift,
910
KeyRound,
@@ -52,6 +53,7 @@ function getNavItems() {
5253
{ title: "存储箱", to: "/storage-box" as const, icon: PiggyBank },
5354
{ title: m.nav_mail(), to: "/mail" as const, icon: Mail },
5455
{ title: m.nav_banner(), to: "/banner" as const, icon: GalleryHorizontal },
56+
{ title: m.nav_media_library(), to: "/media-library" as const, icon: FolderOpen },
5557
{ title: m.nav_dialogue(), to: "/dialogue" as const, icon: MessagesSquare },
5658
{ title: m.nav_collection(), to: "/collection" as const, icon: BookOpen },
5759
{ title: m.nav_level(), to: "/level" as const, icon: Map },

apps/admin/src/components/banner/BannerForm.tsx

Lines changed: 9 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { useState } from "react"
22

33
import { LinkActionEditor } from "#/components/common/LinkActionEditor"
4+
import { MediaPickerDialog } from "#/components/media-library/MediaPickerDialog"
45
import { Button } from "#/components/ui/button"
56
import { Input } from "#/components/ui/input"
67
import { Label } from "#/components/ui/label"
@@ -135,27 +136,17 @@ export function BannerForm({
135136

136137
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
137138
<div className="space-y-1">
138-
<Label htmlFor="image-mobile">
139-
{m.banner_field_image_mobile()}
140-
</Label>
141-
<Input
142-
id="image-mobile"
143-
required
144-
placeholder="https://..."
145-
value={imageUrlMobile}
146-
onChange={(e) => setImageUrlMobile(e.target.value)}
139+
<Label>{m.banner_field_image_mobile()}</Label>
140+
<MediaPickerDialog
141+
value={imageUrlMobile || null}
142+
onChange={setImageUrlMobile}
147143
/>
148144
</div>
149145
<div className="space-y-1">
150-
<Label htmlFor="image-desktop">
151-
{m.banner_field_image_desktop()}
152-
</Label>
153-
<Input
154-
id="image-desktop"
155-
required
156-
placeholder="https://..."
157-
value={imageUrlDesktop}
158-
onChange={(e) => setImageUrlDesktop(e.target.value)}
146+
<Label>{m.banner_field_image_desktop()}</Label>
147+
<MediaPickerDialog
148+
value={imageUrlDesktop || null}
149+
onChange={setImageUrlDesktop}
159150
/>
160151
</div>
161152
</div>
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
import { Copy, Trash2 } from "lucide-react"
2+
3+
import { Button } from "#/components/ui/button"
4+
import { useDeleteMediaAsset, useMediaAssets } from "#/hooks/use-media-library"
5+
import { resolveAssetUrl } from "#/lib/api-client"
6+
import type { MediaAsset } from "#/lib/types/media-library"
7+
import * as m from "#/paraglide/messages.js"
8+
9+
interface AssetGridProps {
10+
folderId: string | null
11+
/** When set, clicking an asset calls this instead of deleting. */
12+
onSelect?: (asset: MediaAsset) => void
13+
}
14+
15+
function humanSize(n: number): string {
16+
if (n < 1024) return `${n} B`
17+
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`
18+
return `${(n / (1024 * 1024)).toFixed(2)} MB`
19+
}
20+
21+
export function AssetGrid({ folderId, onSelect }: AssetGridProps) {
22+
const { data, isPending, error } = useMediaAssets(folderId)
23+
const del = useDeleteMediaAsset()
24+
25+
if (isPending) {
26+
return (
27+
<div className="flex h-40 items-center justify-center text-muted-foreground">
28+
{m.media_assets_loading()}
29+
</div>
30+
)
31+
}
32+
if (error) {
33+
return (
34+
<div className="flex h-40 items-center justify-center text-destructive">
35+
{m.media_assets_failed({ error: error.message })}
36+
</div>
37+
)
38+
}
39+
if (!data || data.items.length === 0) {
40+
return (
41+
<div className="flex h-40 items-center justify-center text-muted-foreground">
42+
{m.media_assets_empty()}
43+
</div>
44+
)
45+
}
46+
47+
async function handleDelete(id: string, filename: string) {
48+
if (!confirm(m.media_asset_delete_confirm({ name: filename }))) return
49+
try {
50+
await del.mutateAsync(id)
51+
} catch (err) {
52+
alert(err instanceof Error ? err.message : m.media_asset_delete())
53+
}
54+
}
55+
56+
async function copyUrl(url: string) {
57+
try {
58+
await navigator.clipboard.writeText(resolveAssetUrl(url))
59+
} catch {
60+
// noop — some contexts block clipboard; user can copy manually.
61+
}
62+
}
63+
64+
return (
65+
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5">
66+
{data.items.map((asset) => {
67+
const clickable = onSelect !== undefined
68+
return (
69+
<div
70+
key={asset.id}
71+
className={`group relative flex flex-col overflow-hidden rounded-lg border bg-card shadow-sm transition hover:shadow ${
72+
clickable ? "cursor-pointer" : ""
73+
}`}
74+
onClick={clickable ? () => onSelect?.(asset) : undefined}
75+
>
76+
<div className="aspect-square w-full overflow-hidden bg-muted">
77+
<img
78+
src={resolveAssetUrl(asset.url)}
79+
alt={asset.filename}
80+
className="size-full object-cover"
81+
loading="lazy"
82+
/>
83+
</div>
84+
<div className="p-2">
85+
<div className="truncate text-xs font-medium" title={asset.filename}>
86+
{asset.filename}
87+
</div>
88+
<div className="text-xs text-muted-foreground">
89+
{humanSize(asset.size)}
90+
</div>
91+
</div>
92+
{!clickable ? (
93+
<div className="absolute right-1 top-1 flex gap-1 opacity-0 transition group-hover:opacity-100">
94+
<Button
95+
type="button"
96+
size="icon"
97+
variant="secondary"
98+
className="size-7"
99+
onClick={(e) => {
100+
e.stopPropagation()
101+
copyUrl(asset.url)
102+
}}
103+
title={m.media_asset_copy_link()}
104+
>
105+
<Copy className="size-3.5" />
106+
</Button>
107+
<Button
108+
type="button"
109+
size="icon"
110+
variant="destructive"
111+
className="size-7"
112+
onClick={(e) => {
113+
e.stopPropagation()
114+
handleDelete(asset.id, asset.filename)
115+
}}
116+
title={m.media_asset_delete()}
117+
>
118+
<Trash2 className="size-3.5" />
119+
</Button>
120+
</div>
121+
) : null}
122+
</div>
123+
)
124+
})}
125+
</div>
126+
)
127+
}

0 commit comments

Comments
 (0)