Skip to content

Instantly share code, notes, and snippets.

@jondashkyle
Created July 28, 2025 21:13
Show Gist options
  • Select an option

  • Save jondashkyle/d27098e25bd56d8994cb1cabd026ea92 to your computer and use it in GitHub Desktop.

Select an option

Save jondashkyle/d27098e25bd56d8994cb1cabd026ea92 to your computer and use it in GitHub Desktop.
media plan

Image Upload Feature Implementation Plan

Overview

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.

Goals

  1. Enable direct image uploads within block elements
  2. Support multiple media items per block via content.media array
  3. Integrate Next/Image for optimized image delivery
  4. Provide a media gallery for reusing previously uploaded images
  5. Maintain clean separation between media management and block content
  6. Associate media with both pages and users for better organization
  7. Track who uploaded each media item for transparency

Architecture Overview

Data Structure

// 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
}

Access Pattern

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.

Implementation Phases

Phase 1: Database Infrastructure

1.1 Create Supabase Migration for Media Table

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;

1.2 Add Media Usage Tracking (Optional)

-- 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);

Phase 2: Field Type Extension

2.1 Create Media Array Field Type

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
      )
    }
  }
}

2.2 Build MediaPicker Component

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>
  )
}

2.3 Build MediaThumb Component

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>
  )
}

Phase 3: Frontend Integration

3.1 Create MediaFieldInput Component

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}
    />
  )
}

3.2 Update ImageElement Component

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>
}

3.3 Update BlockImage Factory

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: []
    }
  })
}

Phase 4: Backend Support

4.1 Create Media List API Endpoint

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
  })
}

4.2 Create Media Update API Endpoint

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 })
}

4.3 Create Media Gallery Component

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>
  )
}

Phase 5: Enhanced Features

5.1 Multi-Image Support

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>
  )
}

5.2 Image Gallery Block

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
        }
      }
    }
  })
}

Configuration

Next.js Image Optimization

Update next.config.js:

module.exports = {
  images: {
    domains: [
      process.env.NEXT_PUBLIC_S3_BUCKET_URL.replace('https://', '')
    ],
    formats: ['image/avif', 'image/webp'],
  }
}

Environment Variables

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

Migration Strategy

  1. Backwards Compatibility: Keep support for existing source field while adding media array
  2. Migration Script: Optionally create script to migrate existing image blocks to new format
  3. Progressive Enhancement: New blocks use media array, old blocks continue working

Media Library Organization

Page-Level Media Library

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

User-Level Media Library

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

Media Visibility Scopes

The media gallery supports three visibility scopes:

  1. This Page: Shows only media uploaded to the current page
  2. My Uploads: Shows all media uploaded by the current user
  3. All Available: Shows all accessible media (user's uploads + public media from other users)

Security Considerations

  1. File Validation: Validate file types and sizes on both client and server
  2. Virus Scanning: Consider integrating virus scanning for uploaded files
  3. 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
  4. Rate Limiting: Implement upload rate limits per user
  5. 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
  6. Attribution Tracking:
    • Always track who uploaded each media item
    • Display uploader information in galleries
    • Maintain audit trail for compliance

Performance Optimizations

  1. Lazy Loading: Use Next/Image lazy loading for better performance
  2. Thumbnail Generation: Generate thumbnails for gallery view
  3. CDN Integration: Serve images through CloudFront or similar CDN
  4. Image Optimization: Use Next.js automatic image optimization

Testing Strategy

  1. Unit Tests: Test MediaPicker, MediaGallery components
  2. Integration Tests: Test upload flow end-to-end
  3. Visual Tests: Test image rendering in different block contexts
  4. Performance Tests: Test with large media libraries

Future Enhancements

  1. Video Support: Extend media system to support video uploads
  2. Image Editing: Basic cropping and filters
  3. AI Features: Auto-tagging, smart search
  4. Batch Operations: Multi-select and bulk actions in gallery
  5. External Sources: Integration with Unsplash, Pexels, etc.

Success Metrics

  1. Upload Success Rate: Track successful vs failed uploads
  2. Media Reuse: Track how often users reuse uploaded media
  3. Performance: Page load times with image blocks
  4. User Satisfaction: Feedback on upload experience

Timeline Estimate

  • Phase 1 (Database)
  • Phase 2 (Field Type)
  • Phase 3 (Frontend)
  • Phase 4 (Backend)
  • Phase 5 (Enhanced)
  • Testing & Polish

Total: ~12 days

Dependencies

  • Existing S3 setup and credentials
  • Supabase project with proper permissions
  • Next.js 13+ for image optimization
  • React 18+ for component implementation
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment