This plan outlines the implementation of a comprehensive image upload feature for Assemblage, enabling users to upload images directly to blocks and access them via a clean API pattern like content.media[0].src. The feature will integrate with the existing block system and use Supabase for backend storage metadata and S3 for actual file storage.
- Enable direct image uploads within block elements
- Support multiple media items per block via
content.mediaarray - Integrate Next/Image for optimized image delivery
- Provide a media gallery for reusing previously uploaded images
- Maintain clean separation between media management and block content
- Associate media with both pages and users for better organization
- Track who uploaded each media item for transparency
// Media object stored in block content
interface Media {
id: string // UUIDV7 reference to media table
src: string // Full URL to S3
width: number // Image dimensions
height: number
alt?: string // Alt text for accessibility
caption?: string // Optional caption
public?: boolean // Whether media is publicly accessible (default: true)
uploadedBy?: { // Who uploaded this media
id: string
name?: string
email?: string
}
}
// Block content with media
interface BlockContent {
media?: Media[] // Array of media objects
// ... other content fields
}Users can access media in render components via:
content.media[0].src
content.media[0].width
content.media[0].alt
content.media[0].public // Check if media is public
// etc.Note: The public property defaults to true when not specified, making media shareable by default.
Create migration file: supabase/migrations/[timestamp]_add_media_table.sql
create table public.media (
id uuid primary key default uuid_generate_v4(),
page_id uuid not null references public.pages(id) on delete cascade,
uploaded_by uuid not null references auth.users(id) on delete cascade,
type text not null check (type in ('image', 'audio', 'video')),
s3_key text not null unique,
url text not null,
mime text not null,
size bigint not null,
width int,
height int,
alt text,
caption text,
public boolean default true, -- Public by default
metadata jsonb default '{}',
created_at timestamptz default now(),
updated_at timestamptz default now()
);
-- Indexes for performance
create index media_page_id_idx on public.media(page_id);
create index media_uploaded_by_idx on public.media(uploaded_by);
create index media_type_idx on public.media(type);
create index media_created_at_idx on public.media(created_at desc);
create index media_public_idx on public.media(public);
-- RLS policies
alter table public.media enable row level security;
-- Users can view media if they can view the page or if media is public
create policy "Users can view page media" on public.media
for select using (
public = true OR
exists (
select 1 from public.pages p
where p.id = media.page_id
and (p.owner_id = auth.uid() or p.published = true)
)
);
-- Users can insert media to their own pages
create policy "Users can insert media to own pages" on public.media
for insert with check (
auth.uid() = uploaded_by and
exists (
select 1 from public.pages p
where p.id = media.page_id
and p.owner_id = auth.uid()
)
);
-- Users can update media they uploaded to their own pages
create policy "Users can update own media" on public.media
for update using (
auth.uid() = uploaded_by and
exists (
select 1 from public.pages p
where p.id = media.page_id
and p.owner_id = auth.uid()
)
);
-- Users can delete media from their own pages
create policy "Users can delete media from own pages" on public.media
for delete using (
exists (
select 1 from public.pages p
where p.id = media.page_id
and p.owner_id = auth.uid()
)
);
-- Create view for media with uploader info
create view public.media_with_uploader as
select
m.*,
p.slug as page_slug,
p.title as page_title,
u.email as uploader_email,
u.raw_user_meta_data->>'name' as uploader_name,
u.raw_user_meta_data->>'avatar_url' as uploader_avatar
from public.media m
join public.pages p on p.id = m.page_id
join auth.users u on u.id = m.uploaded_by;
-- Grant access to the view
grant select on public.media_with_uploader to authenticated;-- Track which blocks use which media
create table public.block_media (
block_id uuid not null references public.blocks(id) on delete cascade,
media_id uuid not null references public.media(id) on delete cascade,
created_at timestamptz default now(),
primary key (block_id, media_id)
);
create index block_media_media_id_idx on public.block_media(media_id);Add to /src/lib/types/fields.ts:
export interface MediaFieldType extends FieldType {
type: 'media'
multiple?: boolean
accept?: string[] // MIME types
maxSize?: number // In bytes
maxItems?: number // For arrays
}Update /src/components/Editor/fields.ts:
export const FIELD_REGISTRY: Record<string, FieldConfig> = {
// ... existing fields
media: {
component: MediaFieldInput,
defaultValue: [],
validate: (value) => {
if (!Array.isArray(value)) return false
return value.every(item =>
item.id && item.src && item.width && item.height
)
}
}
}Create /src/components/Media/MediaPicker.tsx:
interface MediaPickerProps {
value: Media[]
onChange: (value: Media[]) => void
multiple?: boolean
maxItems?: number
accept?: string[]
pageId: string // Required to associate media with page
}
export function MediaPicker({ value, onChange, multiple, maxItems, pageId }: MediaPickerProps) {
const [isOpen, setIsOpen] = useState(false)
const [activeTab, setActiveTab] = useState<'upload' | 'gallery'>('upload')
const [showPrivacySettings, setShowPrivacySettings] = useState(false)
return (
<div className="media-picker">
{/* Current selection */}
<div className="selected-media">
{value.map((media, index) => (
<MediaThumb
key={media.id}
media={media}
onRemove={() => removeMedia(index)}
onEdit={(updates) => updateMedia(index, updates)}
onTogglePrivacy={() => {
const updatedMedia = [...value]
updatedMedia[index] = {
...updatedMedia[index],
public: !updatedMedia[index].public ?? true
}
onChange(updatedMedia)
}}
/>
))}
</div>
{/* Add media button */}
<Button onClick={() => setIsOpen(true)}>
Add Image
</Button>
{/* Modal with tabs */}
<Modal open={isOpen} onClose={() => setIsOpen(false)}>
<Tabs value={activeTab} onChange={setActiveTab}>
<Tab value="upload">Upload New</Tab>
<Tab value="gallery">My Images</Tab>
</Tabs>
{activeTab === 'upload' && (
<ImageUploader
pageId={pageId}
onUpload={(media) => {
onChange([...value, media])
setIsOpen(false)
}}
/>
)}
{activeTab === 'gallery' && (
<MediaGallery
pageId={pageId}
onSelect={(media) => {
onChange([...value, media])
setIsOpen(false)
}}
/>
)}
</Modal>
</div>
)
}Create /src/components/Media/MediaThumb.tsx:
interface MediaThumbProps {
media: Media
onRemove: () => void
onEdit: (updates: Partial<Media>) => void
onTogglePrivacy: () => void
}
export function MediaThumb({ media, onRemove, onEdit, onTogglePrivacy }: MediaThumbProps) {
return (
<div className="media-thumb">
<Image
src={media.src}
alt={media.alt || ''}
width={150}
height={150}
className="object-cover"
/>
<div className="media-thumb-actions">
<button
onClick={onTogglePrivacy}
className="privacy-toggle"
title={media.public ? 'Public' : 'Private'}
>
{media.public ? 'π' : 'π'}
</button>
<button onClick={() => {
const alt = prompt('Alt text:', media.alt || '')
if (alt !== null) onEdit({ alt })
}}>
Edit Alt
</button>
<button onClick={onRemove}>
Remove
</button>
</div>
<div className="media-privacy-status">
{media.public ? 'Public' : 'Private'}
</div>
</div>
)
}Create /src/components/Editor/fields/MediaFieldInput.tsx:
export function MediaFieldInput({ value, onChange, field }: FieldInputProps) {
return (
<MediaPicker
value={value || []}
onChange={onChange}
multiple={field.multiple}
maxItems={field.maxItems}
accept={field.accept}
/>
)
}Modify /src/components/Page/Block/components/ImageBlock/ImageElement.tsx:
interface ImageElementProps {
content: {
media?: Media[]
// Backwards compatibility
source?: string
title?: string
caption?: string
}
}
export function ImageElement({ content }: ImageElementProps) {
// Support new media array
if (content.media && content.media.length > 0) {
const media = content.media[0]
return (
<figure>
<Image
src={media.src}
alt={media.alt || ''}
width={media.width}
height={media.height}
className="w-full h-auto"
/>
{media.caption && (
<figcaption>{media.caption}</figcaption>
)}
</figure>
)
}
// Fallback to legacy format
if (content.source) {
return (
<figure>
<img src={content.source} alt={content.title || ''} />
{content.caption && (
<figcaption>{content.caption}</figcaption>
)}
</figure>
)
}
return <div>No image selected</div>
}Modify /src/components/Page/Block/components/ImageBlock/BlockImage.ts:
export const BlockImage = {
element: () => ({
id: generateId(),
type: 'image',
render: ImageElement.toString(),
attributes: {
order: ['media'],
fields: {
media: {
id: 'media',
field: 'media',
label: 'Images',
default: []
}
}
}
}),
block: (elementId: string) => ({
id: generateId(),
element: elementId,
content: {
media: []
}
})
}Create /src/app/api/media/list/route.ts:
export async function GET(request: Request) {
const { searchParams } = new URL(request.url)
const pageNum = parseInt(searchParams.get('page') || '1')
const limit = parseInt(searchParams.get('limit') || '20')
const type = searchParams.get('type') || 'image'
const visibility = searchParams.get('visibility') // 'all', 'public', 'private'
const pageId = searchParams.get('pageId') // Filter by specific page
const scope = searchParams.get('scope') || 'page' // 'page', 'user', 'all'
const supabase = createRouteHandlerClient({ cookies })
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
let query = supabase
.from('media_with_uploader')
.select('*', { count: 'exact' })
.eq('type', type)
// Filter by scope
if (scope === 'page' && pageId) {
query = query.eq('page_id', pageId)
} else if (scope === 'user') {
// Show all media uploaded by the user across all their pages
query = query.eq('uploaded_by', user.id)
} else if (scope === 'all') {
// Show all media user has access to (their pages + public media)
query = query.or(`uploaded_by.eq.${user.id},public.eq.true`)
}
// Filter by visibility if specified
if (visibility === 'public') {
query = query.eq('public', true)
} else if (visibility === 'private') {
query = query.eq('public', false)
}
const { data, error, count } = await query
.order('created_at', { ascending: false })
.range((pageNum - 1) * limit, pageNum * limit - 1)
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
}
// Transform data to include uploader info
const mediaWithUploader = data?.map(item => ({
id: item.id,
src: item.url,
width: item.width,
height: item.height,
alt: item.alt,
caption: item.caption,
public: item.public,
pageId: item.page_id,
pageSlug: item.page_slug,
pageTitle: item.page_title,
uploadedBy: {
id: item.uploaded_by,
email: item.uploader_email,
name: item.uploader_name,
avatar: item.uploader_avatar
},
createdAt: item.created_at
}))
return NextResponse.json({
media: mediaWithUploader,
total: count,
page: pageNum,
limit
})
}Create /src/app/api/media/[id]/route.ts:
export async function PATCH(
request: Request,
{ params }: { params: { id: string } }
) {
const supabase = createRouteHandlerClient({ cookies })
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const body = await request.json()
const { public: isPublic, alt, caption } = body
// Update only allowed fields
const updates: any = {}
if (typeof isPublic === 'boolean') updates.public = isPublic
if (alt !== undefined) updates.alt = alt
if (caption !== undefined) updates.caption = caption
const { data, error } = await supabase
.from('media')
.update(updates)
.eq('id', params.id)
.eq('owner_id', user.id) // Ensure user owns the media
.single()
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
}
return NextResponse.json({ media: data })
}Create /src/components/Media/MediaGallery.tsx:
interface MediaGalleryProps {
onSelect: (media: Media) => void
pageId?: string
scope?: 'page' | 'user' | 'all'
}
export function MediaGallery({ onSelect, pageId, scope = 'page' }: MediaGalleryProps) {
const [media, setMedia] = useState<Media[]>([])
const [loading, setLoading] = useState(true)
const [page, setPage] = useState(1)
const [activeScope, setActiveScope] = useState(scope)
useEffect(() => {
fetchMedia()
}, [page, activeScope, pageId])
const fetchMedia = async () => {
setLoading(true)
const params = new URLSearchParams({
page: page.toString(),
type: 'image',
scope: activeScope
})
if (pageId && activeScope === 'page') {
params.append('pageId', pageId)
}
const res = await fetch(`/api/media/list?${params}`)
const data = await res.json()
setMedia(data.media)
setLoading(false)
}
return (
<div className="media-gallery">
{/* Scope selector */}
<div className="gallery-controls">
<select
value={activeScope}
onChange={(e) => setActiveScope(e.target.value as any)}
className="scope-selector"
>
<option value="page">This Page</option>
<option value="user">My Uploads</option>
<option value="all">All Available</option>
</select>
</div>
{loading ? (
<Spinner />
) : (
<div className="grid grid-cols-3 gap-4">
{media.map(item => (
<div
key={item.id}
className="media-gallery-item"
>
<button
onClick={() => onSelect(item)}
className="media-select-btn"
>
<Image
src={item.src}
alt={item.alt || ''}
width={200}
height={200}
className="object-cover"
/>
</button>
{/* Media info */}
<div className="media-info">
<div className="uploader-info">
{item.uploadedBy.avatar && (
<img
src={item.uploadedBy.avatar}
alt=""
className="avatar"
/>
)}
<span className="uploader-name">
{item.uploadedBy.name || item.uploadedBy.email}
</span>
</div>
{item.pageTitle && activeScope !== 'page' && (
<div className="page-info">
π {item.pageTitle}
</div>
)}
<div className="privacy-badge">
{item.public ? 'π Public' : 'π Private'}
</div>
</div>
</div>
))}
</div>
)}
<Pagination
page={page}
onChange={setPage}
total={total}
/>
</div>
)
}Create /src/components/Page/Block/components/ImageBlock/ImageCarousel.tsx:
export function ImageCarousel({ content }: { content: { media: Media[] } }) {
const [current, setCurrent] = useState(0)
if (!content.media || content.media.length === 0) {
return <div>No images</div>
}
return (
<div className="image-carousel">
<div className="carousel-main">
<Image
src={content.media[current].src}
alt={content.media[current].alt || ''}
width={content.media[current].width}
height={content.media[current].height}
className="w-full h-auto"
/>
</div>
{content.media.length > 1 && (
<div className="carousel-thumbs">
{content.media.map((media, index) => (
<button
key={media.id}
onClick={() => setCurrent(index)}
className={current === index ? 'active' : ''}
>
<Image
src={media.src}
alt=""
width={100}
height={100}
className="object-cover"
/>
</button>
))}
</div>
)}
</div>
)
}Create /src/components/Page/Block/components/ImageGallery/BlockImageGallery.ts:
export const BlockImageGallery = {
element: () => ({
id: generateId(),
type: 'image-gallery',
render: ImageGallery.toString(),
attributes: {
order: ['media', 'columns', 'spacing'],
fields: {
media: {
id: 'media',
field: 'media',
label: 'Gallery Images',
default: []
},
columns: {
id: 'columns',
field: 'number',
label: 'Columns',
default: 3
},
spacing: {
id: 'spacing',
field: 'number',
label: 'Spacing (px)',
default: 16
}
}
}
})
}Update next.config.js:
module.exports = {
images: {
domains: [
process.env.NEXT_PUBLIC_S3_BUCKET_URL.replace('https://', '')
],
formats: ['image/avif', 'image/webp'],
}
}Required environment variables:
NEXT_PUBLIC_S3_BUCKET_URL=https://your-bucket.s3.amazonaws.com
AWS_ACCESS_KEY_ID=your-access-key
AWS_SECRET_ACCESS_KEY=your-secret-key
AWS_REGION=us-east-1
S3_BUCKET_NAME=your-bucket- Backwards Compatibility: Keep support for existing
sourcefield while addingmediaarray - Migration Script: Optionally create script to migrate existing image blocks to new format
- Progressive Enhancement: New blocks use media array, old blocks continue working
Each page has its own media library containing all images uploaded to that specific page. This helps with:
- Organization: Media is naturally grouped by the context where it's used
- Permissions: Page access controls automatically apply to its media
- Cleanup: When a page is deleted, its media can be cleaned up
- Context: Users can easily find relevant images for each page
Users can also view all media they've uploaded across all their pages:
- Personal Gallery: "My Uploads" view shows everything a user has uploaded
- Cross-Page Reuse: Users can easily reuse images across different pages
- Attribution: Each image shows who uploaded it and when
- Management: Users can manage all their uploads from one place
The media gallery supports three visibility scopes:
- This Page: Shows only media uploaded to the current page
- My Uploads: Shows all media uploaded by the current user
- All Available: Shows all accessible media (user's uploads + public media from other users)
- File Validation: Validate file types and sizes on both client and server
- Virus Scanning: Consider integrating virus scanning for uploaded files
- Access Control:
- Media is tied to pages, inheriting page permissions
- Users can only modify media on their own pages
- Public media is accessible to all authenticated users
- Private media is only accessible via the page it belongs to
- Rate Limiting: Implement upload rate limits per user
- Privacy Controls:
- Default to public for ease of sharing
- Clear UI indicators for public/private status
- Bulk privacy update capabilities for managing multiple media items
- Attribution Tracking:
- Always track who uploaded each media item
- Display uploader information in galleries
- Maintain audit trail for compliance
- Lazy Loading: Use Next/Image lazy loading for better performance
- Thumbnail Generation: Generate thumbnails for gallery view
- CDN Integration: Serve images through CloudFront or similar CDN
- Image Optimization: Use Next.js automatic image optimization
- Unit Tests: Test MediaPicker, MediaGallery components
- Integration Tests: Test upload flow end-to-end
- Visual Tests: Test image rendering in different block contexts
- Performance Tests: Test with large media libraries
- Video Support: Extend media system to support video uploads
- Image Editing: Basic cropping and filters
- AI Features: Auto-tagging, smart search
- Batch Operations: Multi-select and bulk actions in gallery
- External Sources: Integration with Unsplash, Pexels, etc.
- Upload Success Rate: Track successful vs failed uploads
- Media Reuse: Track how often users reuse uploaded media
- Performance: Page load times with image blocks
- User Satisfaction: Feedback on upload experience
- Phase 1 (Database)
- Phase 2 (Field Type)
- Phase 3 (Frontend)
- Phase 4 (Backend)
- Phase 5 (Enhanced)
- Testing & Polish
Total: ~12 days
- Existing S3 setup and credentials
- Supabase project with proper permissions
- Next.js 13+ for image optimization
- React 18+ for component implementation