Admin 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: Enable admin users to upload images for experiences, events, add-ons, and menu items using Cloudflare R2 via @convex-dev/r2. Public users see uploaded images on experience detail pages and gallery browsers.
Architecture: Convex R2 component handles file uploads via signed URLs. The frontend uses useUploadFile hook. R2 keys (UUIDs) are stored in Convex tables, not full URLs. When public pages query data, Convex resolves keys to signed URLs (24h expiration) on-the-fly. Next.js <Image> handles browser caching, so expired URLs refresh automatically.
Tech Stack: @convex-dev/r2, Cloudflare R2, React hooks, Convex mutations/queries, Next.js Image
File Structure
apps/backend/convex/
├── convex.config.ts # R2 component registration
├── domains/
│ └── storage.ts # R2 client + generateUploadUrl, syncMetadata, getImageUrl
│ └── experiences.ts # MODIFY: resolve image URLs in queries
│ └── events.ts # MODIFY: resolve thumbnailUrl in queries
│ └── addOns.ts # MODIFY: resolve imageUrl in queries (if exists)
│ └── menuItems.ts # MODIFY: resolve imageUrl in queries (if exists)
apps/frontend/
├── components/ui/
│ ├── image-upload.tsx # REUSABLE: Single image upload component
│ └── gallery-upload.tsx # REUSABLE: Multi-image gallery upload
├── components/admin/
│ ├── experience-form.tsx # MODIFY: add thumbnail + gallery upload fields
│ ├── event-modal.tsx # MODIFY: add thumbnail upload field
│ └── addons-table.tsx # MODIFY: replace text input with ImageUpload
└── hooks/admin/
└── use-addon-form.ts # MODIFY: add imageUrl field to form schemaImage Fields in Schema
| Table | Field | Type | Purpose | Admin UI |
|---|---|---|---|---|
experiences | thumbnailUrl | string? | Hero/thumbnail image | Not in form yet |
experiences | gallery | string[] | Full gallery images | Not in form yet |
experienceEvents | thumbnailUrl | string? | Per-event thumbnail override | Not in form yet |
addOns | imageUrl | string? | Add-on product image | Text input only |
menuItems | imageUrl | string? | Menu item image | Not checked |
User Flows
Flow 1: Admin Uploads Experience Thumbnail
Admin → Experiences page → Edit/Create Experience → Upload thumbnail → R2 → Key stored in ConvexFlow 2: Admin Uploads Experience Gallery
Admin → Experiences page → Edit/Create Experience → Upload multiple gallery images → R2 → Keys stored in ConvexFlow 3: Admin Uploads Event Thumbnail
Admin → Events page → Edit Event modal → Upload thumbnail → R2 → Key stored in experienceEvents.thumbnailUrlFlow 4: Admin Uploads Add-on Image
Admin → Add-ons page → Create/Edit Add-on → Upload image → R2 → Key stored in addOns.imageUrlFlow 5: Public User Views Experience Gallery
Public → Experience detail page → Query experiences.getBySlug → Convex resolves keys → Signed URLs → Gallery displayFlow 6: Public User Browses All Experience Images
Public → /experiences page → Grid of experience cards → Each shows thumbnail → Click opens lightbox/galleryTask 1: Install and Configure @convex-dev/r2
Files:
-
Modify:
apps/backend/convex/convex.config.ts -
Modify:
apps/frontend/package.json -
Modify:
.env.local -
Step 1: Install @convex-dev/r2
Run:
cd /Users/curlyz/usr/hol/apps/frontend
npm install @convex-dev/r2Expected: Package installs without errors
- Step 2: Verify package installation
Run:
cat package.json | grep "@convex-dev/r2"Expected: "@convex-dev/r2": "^0.9.2", (or similar version)
- Step 3: Create R2 component config
Read existing apps/backend/convex/convex.config.ts first, then add:
// apps/backend/convex/convex.config.ts
import { defineApp } from "convex/server";
import r2 from "@convex-dev/r2/convex.config.js";
const app = defineApp();
app.use(r2);
export default app;- Step 4: Document required environment variables
Add to .env.local:
# Cloudflare R2 (for image uploads)
# Obtain from Cloudflare Dashboard → R2 → Manage R2 API Tokens
R2_BUCKET=your-bucket-name
R2_TOKEN=your-api-token
R2_ACCESS_KEY_ID=your-access-key
R2_SECRET_ACCESS_KEY=your-secret-key
R2_ENDPOINT=https://xxx.r2.cloudflarestorage.comNote: These must be set via npx convex env set for production.
- Step 5: Commit
git add apps/frontend/package.json apps/backend/convex/convex.config.ts .env.local
git commit -m "feat: install @convex-dev/r2 for image uploads"Task 2: Create Convex Storage Domain
Files:
-
Create:
apps/backend/convex/domains/storage.ts -
Step 1: Create storage domain with R2 client
// apps/backend/convex/domains/storage.ts
import { R2 } from "@convex-dev/r2";
import { components } from "../_generated/api";
import type { DataModel } from "../_generated/dataModel";
import { authenticatedMutation } from "../lib/auth";
export const r2 = new R2(components.r2);
export const { generateUploadUrl, syncMetadata } = r2.clientApi<DataModel>({
checkUpload: async (ctx, bucket) => {
// Any authenticated staff/admin can upload
const identity = ctx.identity;
if (!identity) {
throw new Error("Not authenticated");
}
return true;
},
onUpload: async (ctx, bucket, key) => {
// Log upload for debugging (optional)
console.log("File uploaded:", { bucket, key });
},
});- Step 2: Add getImageUrl query for serving images
// apps/backend/convex/domains/storage.ts (add after generateUploadUrl)
export const getImageUrl = authenticatedMutation({
args: { key: R2.key() },
handler: async (ctx, { key }) => {
return await r2.getUrl(key, { expiresIn: 60 * 60 * 24 }); // 24 hours
},
});- Step 3: Commit
git add apps/backend/convex/domains/storage.ts
git commit -m "feat: add R2 storage domain with upload URL generation"Task 3: Create Reusable Image Upload Components
Files:
-
Create:
apps/frontend/components/ui/image-upload.tsx -
Create:
apps/frontend/components/ui/gallery-upload.tsx -
Step 1: Create single image upload component
// apps/frontend/components/ui/image-upload.tsx
"use client";
import { useCallback, useState } from "react";
import { useMutation } from "convex/react";
import { api } from "~/convex/_generated/api";
import { useUploadFile } from "@convex-dev/r2/react";
import { Upload, X, Image as ImageIcon } from "lucide-react";
import { cn } from "~/lib/utils";
type ImageUploadProps = {
/** R2 key or URL to display */
value?: string;
/** Called with R2 key after successful upload */
onChange: (key: string) => void;
disabled?: boolean;
className?: string;
};
export function ImageUpload({
value,
onChange,
disabled,
className,
}: ImageUploadProps) {
const [isUploading, setIsUploading] = useState(false);
const uploadFile = useUploadFile(api.domains.storage.generateUploadUrl);
const handleFileChange = useCallback(
async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
setIsUploading(true);
try {
const key = await uploadFile(file);
onChange(key);
} catch (err) {
console.error("Upload failed:", err);
} finally {
setIsUploading(false);
}
},
[uploadFile, onChange],
);
return (
<div
className={cn(
"relative border-2 border-dashed border-[var(--color-border)] rounded-lg p-4 flex flex-col items-center justify-center gap-2 cursor-pointer hover:border-[var(--color-gold)] transition-colors",
isUploading && "opacity-50",
className,
)}
>
{value ? (
<div className="relative w-full">
<img
src={value}
alt="Uploaded"
className="w-full h-32 object-cover rounded-md"
/>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onChange("");
}}
className="absolute top-1 right-1 p-1 bg-[var(--color-surface)] rounded-full"
disabled={disabled}
>
<X className="w-4 h-4" />
</button>
</div>
) : (
<div className="flex flex-col items-center gap-2 text-[var(--color-muted-foreground)]">
<ImageIcon className="w-8 h-8" />
<span className="text-sm">Click to upload</span>
</div>
)}
<input
type="file"
accept="image/*"
onChange={handleFileChange}
disabled={disabled || isUploading}
className="absolute inset-0 opacity-0 cursor-pointer"
/>
{isUploading && (
<div className="absolute inset-0 flex items-center justify-center bg-background/50">
<Upload className="w-6 h-6 animate-pulse" />
</div>
)}
</div>
);
}- Step 2: Create gallery multi-image upload component
// apps/frontend/components/ui/gallery-upload.tsx
"use client";
import { useCallback, useState } from "react";
import { useMutation } from "convex/react";
import { api } from "~/convex/_generated/api";
import { useUploadFile } from "@convex-dev/r2/react";
import { Plus, X, Image as ImageIcon } from "lucide-react";
import { cn } from "~/lib/utils";
type GalleryUploadProps = {
/** Array of R2 keys or URLs */
value: string[];
/** Called with array of R2 keys after changes */
onChange: (keys: string[]) => void;
disabled?: boolean;
maxItems?: number;
className?: string;
};
export function GalleryUpload({
value,
onChange,
disabled,
maxItems = 10,
className,
}: GalleryUploadProps) {
const [isUploading, setIsUploading] = useState(false);
const uploadFile = useUploadFile(api.domains.storage.generateUploadUrl);
const handleFileChange = useCallback(
async (e: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(e.target.files || []);
if (files.length === 0) return;
if (value.length + files.length > maxItems) return;
setIsUploading(true);
try {
const keys = await Promise.all(files.map((f) => uploadFile(f)));
onChange([...value, ...keys]);
} catch (err) {
console.error("Upload failed:", err);
} finally {
setIsUploading(false);
}
},
[uploadFile, onChange, value, maxItems],
);
const removeImage = (index: number) => {
onChange(value.filter((_, i) => i !== index));
};
return (
<div className={cn("space-y-2", className)}>
<div className="grid grid-cols-3 gap-2">
{value.map((url, index) => (
<div key={index} className="relative group">
<img
src={url}
alt={`Gallery ${index + 1}`}
className="w-full h-24 object-cover rounded-md"
/>
<button
type="button"
onClick={() => removeImage(index)}
className="absolute top-1 right-1 p-1 bg-[var(--color-surface)] rounded-full opacity-0 group-hover:opacity-100 transition-opacity"
disabled={disabled}
>
<X className="w-3 h-3" />
</button>
</div>
))}
{value.length < maxItems && (
<label
className={cn(
"border-2 border-dashed border-[var(--color-border)] rounded-md p-2 flex flex-col items-center justify-center gap-1 cursor-pointer hover:border-[var(--color-gold)] transition-colors",
isUploading && "opacity-50",
)}
>
<Plus className="w-5 h-5 text-[var(--color-muted-foreground)]" />
<span className="text-xs text-[var(--color-muted-foreground)]">
Add
</span>
<input
type="file"
accept="image/*"
multiple
onChange={handleFileChange}
disabled={disabled || isUploading}
className="hidden"
/>
</label>
)}
</div>
{isUploading && (
<p className="text-sm text-[var(--color-muted-foreground)]">
Uploading...
</p>
)}
</div>
);
}- Step 3: Commit
git add apps/frontend/components/ui/image-upload.tsx apps/frontend/components/ui/gallery-upload.tsx
git commit -m "feat: add reusable image upload components"Task 4: Integrate Image Upload into Experience Form
Files:
-
Modify:
apps/frontend/components/admin/experience-form.tsx -
Modify:
apps/frontend/hooks/forms/use-show-form.ts -
Step 1: Read the current use-show-form.ts
Run: cat apps/frontend/hooks/forms/use-show-form.ts
Verify the form schema includes thumbnailUrl and gallery fields. If not, add them to the Zod schema.
- Step 2: Add ImageUpload to experience-form.tsx
Import at top:
import { ImageUpload } from "~/components/ui/image-upload";
import { GalleryUpload } from "~/components/ui/gallery-upload";Add after the embeddedVideo FormField (around line 347):
<FormField
control={form.control}
name="thumbnailUrl"
render={({ field }) => (
<FormItem>
<FormLabel>Thumbnail Image</FormLabel>
<FormControl>
<ImageUpload
value={field.value}
onChange={field.onChange}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="gallery"
render={({ field }) => (
<FormItem>
<FormLabel>Gallery Images</FormLabel>
<FormControl>
<GalleryUpload
value={field.value ?? []}
onChange={field.onChange}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>- Step 3: Verify build works
Run:
cd /Users/curlyz/usr/hol/apps/frontend
npm run build 2>&1 | head -50Expected: No TypeScript errors related to our changes
- Step 4: Commit
git add apps/frontend/components/admin/experience-form.tsx
git commit -m "feat: add image upload fields to experience form"Task 5: Integrate Image Upload into Event Modal
Files:
-
Modify:
apps/frontend/components/admin/event-modal.tsx -
Step 1: Read the current event-modal.tsx
Run: head -70 apps/frontend/components/admin/event-modal.tsx
Note the structure of FormFields and where to add the thumbnail upload.
- Step 2: Import ImageUpload
Add to imports at top of file:
import { ImageUpload } from "~/components/ui/image-upload";- Step 3: Add thumbnailUrl to the event prop type
Update the interface around line 50:
interface EventModalProps {
event: {
_id: string;
showTitle: string;
date: string;
time: string;
status: EventStatus;
actualCapacity: number;
bookedCount: number;
dinnerPrice?: number;
showOnlyPrice?: number;
isShowOnly?: boolean;
thumbnailUrl?: string; // ADD THIS
} | null;
open: boolean;
onClose: () => void;
}- Step 4: Add thumbnail upload field to the form
Find a good location in the form (after the price overrides section around line 216), add:
{
/* Thumbnail Upload */
}
<div className="space-y-2">
<Label className="text-sm text-[var(--color-foreground)]/60">
Event Thumbnail (optional override)
</Label>
<ImageUpload
value={event.thumbnailUrl}
onChange={(key) => {
// TODO: wire up to update mutation when thumbnailUrl is supported
console.log("Thumbnail key:", key);
}}
/>
<p className="text-xs text-[var(--color-muted-foreground)]">
Overrides the experience thumbnail if set
</p>
</div>;- Step 5: Commit
git add apps/frontend/components/admin/event-modal.tsx
git commit -m "feat: add thumbnail upload to event modal"Task 6: Integrate Image Upload into Add-on Form
Files:
-
Modify:
apps/frontend/components/admin/addons-table.tsx -
Modify:
apps/frontend/hooks/admin/use-addon-form.ts -
Step 1: Read use-addon-form.ts
Run: cat apps/frontend/hooks/admin/use-addon-form.ts
Verify the form schema has imageUrl field.
- Step 2: Replace text input with ImageUpload in AddonFormDialog
In apps/frontend/components/admin/addons-table.tsx, replace the imageUrl FormField (around line 207-222):
<FormField
control={form.control}
name="imageUrl"
render={({ field }) => (
<FormItem>
<FormLabel>{m.admin_addons_form_fields_imageUrl()}</FormLabel>
<FormControl>
<ImageUpload value={field.value} onChange={field.onChange} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>- Step 3: Commit
git add apps/frontend/components/admin/addons-table.tsx
git commit -m "feat: add image upload to add-on form"Task 7: Add Image URL Resolution to Convex Queries
Files:
- Modify:
apps/backend/convex/domains/experiences.ts - Modify:
apps/backend/convex/domains/events.ts - Modify:
apps/backend/convex/domains/addOns.ts(if exists)
7A: Experience Queries - Resolve on Read
- Step 1: Modify experiences listActive to resolve image URLs
// apps/backend/convex/domains/experiences.ts
// At the top, import r2:
import { r2 } from "./storage";
// Modify listActive handler:
export const listActive = zQuery({
args: {},
handler: async (ctx) => {
const all = await ctx.db.query("experiences").collect();
const active = all.filter((e) => e.status === "ACTIVE");
// Resolve R2 keys to signed URLs for public consumption
return Promise.all(
active.map(async (exp) => ({
...exp,
thumbnailUrl: exp.thumbnailUrl
? await r2.getUrl(exp.thumbnailUrl, { expiresIn: 60 * 60 * 24 })
: undefined,
gallery: await Promise.all(
exp.gallery.map((key) => r2.getUrl(key, { expiresIn: 60 * 60 * 24 })),
),
})),
);
},
});- Step 2: Modify getBySlug similarly
Add same image URL resolution to the getBySlug handler.
- Step 3: Commit
git add apps/backend/convex/domains/experiences.ts
git commit -m "feat: resolve R2 image URLs in experience queries"7B: Event Queries - Resolve Thumbnail
- Step 4: Modify listForSchedulePreview to resolve thumbnail URLs
// apps/backend/convex/domains/events.ts
// At the top, import r2:
import { r2 } from "./storage";
// In listForSchedulePreview handler, update the image resolution:
// Before (around line 790):
// const image = evt.thumbnailUrl || experience.thumbnailUrl || "";
// After:
const image = evt.thumbnailUrl
? await r2.getUrl(evt.thumbnailUrl, { expiresIn: 60 * 60 * 24 })
: experience.thumbnailUrl
? await r2.getUrl(experience.thumbnailUrl, { expiresIn: 60 * 60 * 24 })
: "";Note: This query already has logic to prefer event thumbnail over experience thumbnail. Just need to resolve R2 keys to URLs.
- Step 5: Commit
git add apps/backend/convex/domains/events.ts
git commit -m "feat: resolve R2 image URLs in event queries"7C: Add-on Queries - Resolve Image URL
- Step 6: Check if addOns domain exists
Run: ls apps/backend/convex/domains/
If addOns.ts exists, add similar URL resolution. If not, skip.
- Step 7: Commit
git add apps/backend/convex/domains/
git commit -m "feat: resolve R2 image URLs in addon queries"Task 8: Create Public Gallery Browser Component
Files:
-
Create:
apps/frontend/components/ui/image-gallery-browser.tsx -
Step 1: Create ImageGalleryBrowser component
This component provides a full-screen/lightbox gallery experience for browsing all images of an experience.
// apps/frontend/components/ui/image-gallery-browser.tsx
"use client";
import { useState } from "react";
import Image from "next/image";
import { X, ChevronLeft, ChevronRight } from "lucide-react";
import { cn } from "~/lib/utils";
type ImageGalleryBrowserProps = {
images: string[];
title?: string;
className?: string;
};
export function ImageGalleryBrowser({
images,
title,
className,
}: ImageGalleryBrowserProps) {
const [selectedIndex, setSelectedIndex] = useState<number | null>(null);
const openLightbox = (index: number) => setSelectedIndex(index);
const closeLightbox = () => setSelectedIndex(null);
const goNext = () => {
if (selectedIndex === null) return;
setSelectedIndex((selectedIndex + 1) % images.length);
};
const goPrev = () => {
if (selectedIndex === null) return;
setSelectedIndex((selectedIndex - 1 + images.length) % images.length);
};
if (images.length === 0) return null;
return (
<>
{/* Thumbnail Grid */}
<div className={cn("grid grid-cols-3 md:grid-cols-4 gap-2", className)}>
{images.map((src, index) => (
<button
key={index}
onClick={() => openLightbox(index)}
className="relative aspect-square overflow-hidden rounded-lg group"
>
<Image
src={src}
alt={`${title ?? "Image"} ${index + 1}`}
fill
className="object-cover transition-transform duration-300 group-hover:scale-105"
/>
</button>
))}
</div>
{/* Lightbox */}
{selectedIndex !== null && (
<div className="fixed inset-0 z-50 bg-background/95 flex items-center justify-center">
{/* Close button */}
<button
onClick={closeLightbox}
className="absolute top-4 right-4 p-2 text-foreground hover:text-[var(--color-gold)] transition-colors"
>
<X className="w-8 h-8" />
</button>
{/* Navigation */}
{images.length > 1 && (
<>
<button
onClick={goPrev}
className="absolute left-4 p-2 text-foreground hover:text-[var(--color-gold)] transition-colors"
>
<ChevronLeft className="w-8 h-8" />
</button>
<button
onClick={goNext}
className="absolute right-4 p-2 text-foreground hover:text-[var(--color-gold)] transition-colors"
>
<ChevronRight className="w-8 h-8" />
</button>
</>
)}
{/* Image */}
<div className="relative w-full h-full max-w-5xl max-h-[90vh] mx-4">
<Image
src={images[selectedIndex]}
alt={`${title ?? "Image"} ${selectedIndex + 1}`}
fill
className="object-contain"
/>
</div>
{/* Counter */}
<div className="absolute bottom-4 left-1/2 -translate-x-1/2 text-foreground">
{selectedIndex + 1} / {images.length}
</div>
</div>
)}
</>
);
}- Step 2: Commit
git add apps/frontend/components/ui/image-gallery-browser.tsx
git commit -m "feat: add image gallery browser component with lightbox"Task 9: Integrate Gallery Browser into Experience Pages
Files:
-
Modify:
apps/frontend/components/shows/show-detail-client.tsx -
Modify:
apps/frontend/components/experiences/dinner-theater/dinner-theater-client.tsx -
Step 1: Replace gallery display with ImageGalleryBrowser in show-detail-client.tsx
Find the gallery section (around line 84-107), replace with:
{
/* Photo Gallery */
}
{
show.gallery && show.gallery.length > 0 && (
<section className="bg-surface py-16 px-4">
<div className="max-w-[1440px] mx-auto">
<Heading level="h2" className="font-serif" align="center">
{m.shows_gallery()}
</Heading>
<div className="mt-8">
<ImageGalleryBrowser
images={show.gallery}
title={show.title}
className="mt-6"
/>
</div>
</div>
</section>
);
}- Step 2: Commit
git add apps/frontend/components/shows/show-detail-client.tsx
git commit -m "feat: integrate image gallery browser in show detail page"Task 10: Verify R2 Configuration in Cloudflare
This task requires manual Cloudflare setup — cannot be automated.
- Step 1: Create R2 bucket
In Cloudflare Dashboard:
- Go to R2 → Create bucket
- Name:
hol-images(or similar) - Save
- Step 2: Add CORS policy
In bucket settings → CORS:
[
{
"AllowedOrigins": ["https://your-domain.com", "http://localhost:3000"],
"AllowedMethods": ["GET", "PUT"],
"AllowedHeaders": ["Content-Type"]
}
]- Step 3: Create API token
- R2 → Manage R2 API Tokens → Create API Token
- Permissions: Object Read & Write
- Specify bucket: select your bucket
- Copy the credentials
- Step 4: Set environment variables
npx convex env set R2_BUCKET your-bucket-name
npx convex env set R2_TOKEN your-token
npx convex env set R2_ACCESS_KEY_ID your-access-key-id
npx convex env set R2_SECRET_ACCESS_KEY your-secret-key
npx convex env set R2_ENDPOINT https://xxx.r2.cloudflarestorage.comVerification Checklist
After completing all tasks:
- Admin experience form shows thumbnail and gallery upload fields
- Admin event modal shows thumbnail upload field
- Admin add-on form shows image upload field
- Uploaded images appear in the admin forms after upload
- Public experience detail pages display uploaded gallery with lightbox
- Public schedule/homepage shows uploaded thumbnails
- Images load correctly on all public pages
- No console errors during upload or display flow
Notes
- R2 keys vs URLs: The
thumbnailUrl,gallery[], andimageUrlfields store R2 object keys (UUIDs), not full URLs. URLs are generated on-read viar2.getUrl()with 24h expiration. - Browser caching: Next.js
<Image>caches images. If a signed URL expires but the browser has a cached copy, it still displays. When the cache refreshes, Convex generates a fresh signed URL. - Event thumbnail override: Events can have a
thumbnailUrlthat overrides the experience thumbnail. The resolution logic inlistForSchedulePreviewhandles this. - Reusable components:
ImageUploadandGalleryUploadcan be reused across all admin forms (experiences, events, add-ons, menu items). - Future enhancement: Add image deletion mutation to remove orphaned R2 objects.