plans
2026-05-11
2026 05 11 Admin Image Upload

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 schema

Image Fields in Schema

TableFieldTypePurposeAdmin UI
experiencesthumbnailUrlstring?Hero/thumbnail imageNot in form yet
experiencesgallerystring[]Full gallery imagesNot in form yet
experienceEventsthumbnailUrlstring?Per-event thumbnail overrideNot in form yet
addOnsimageUrlstring?Add-on product imageText input only
menuItemsimageUrlstring?Menu item imageNot checked

User Flows

Flow 1: Admin Uploads Experience Thumbnail

Admin → Experiences page → Edit/Create Experience → Upload thumbnail → R2 → Key stored in Convex

Flow 2: Admin Uploads Experience Gallery

Admin → Experiences page → Edit/Create Experience → Upload multiple gallery images → R2 → Keys stored in Convex

Flow 3: Admin Uploads Event Thumbnail

Admin → Events page → Edit Event modal → Upload thumbnail → R2 → Key stored in experienceEvents.thumbnailUrl

Flow 4: Admin Uploads Add-on Image

Admin → Add-ons page → Create/Edit Add-on → Upload image → R2 → Key stored in addOns.imageUrl

Flow 5: Public User Views Experience Gallery

Public → Experience detail page → Query experiences.getBySlug → Convex resolves keys → Signed URLs → Gallery display

Flow 6: Public User Browses All Experience Images

Public → /experiences page → Grid of experience cards → Each shows thumbnail → Click opens lightbox/gallery

Task 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/r2

Expected: 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.com

Note: 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 -50

Expected: 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:

  1. Go to R2 → Create bucket
  2. Name: hol-images (or similar)
  3. 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
  1. R2 → Manage R2 API Tokens → Create API Token
  2. Permissions: Object Read & Write
  3. Specify bucket: select your bucket
  4. 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.com

Verification 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[], and imageUrl fields store R2 object keys (UUIDs), not full URLs. URLs are generated on-read via r2.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 thumbnailUrl that overrides the experience thumbnail. The resolution logic in listForSchedulePreview handles this.
  • Reusable components: ImageUpload and GalleryUpload can be reused across all admin forms (experiences, events, add-ons, menu items).
  • Future enhancement: Add image deletion mutation to remove orphaned R2 objects.