Technical Documentation

Technical Overview

The Timeline Block system provides a complete content management solution for creating interactive timeline sections on the homepage. Built with Next.js 16, Sanity CMS, and React, it follows the established block architecture pattern while introducing server-side image upload capabilities.

Architecture Components

1. Sanity Schema

File: src/sanity/schemaTypes/homepage/timelineBlock.ts

export default defineType({
  name: 'timelineBlock',
  title: 'Timeline Section Block',
  type: 'document',
  icon: Clock,
  fields: [
    defineField({
      name: 'name',
      title: 'Block Name',
      type: 'string',
      validation: Rule => Rule.required()
    }),
    defineField({
      name: 'entries',
      title: 'Timeline Entries',
      type: 'array',
      of: [{
        type: 'object',
        fields: [
          { name: 'title', type: 'string' },
          { name: 'content', type: 'array', of: [{ type: 'block' }] }, // Portable Text
          {
            name: 'images',
            type: 'array',
            of: [{
              type: 'image',
              fields: [
                { name: 'alt', type: 'string' },
                { name: 'caption', type: 'string' }
              ]
            }]
          },
          { name: 'order', type: 'number' }
        ]
      }]
    })
  ]
})

2. TypeScript Types

File: src/types/homepage-config.ts

export interface TimelineImage {
  asset: { _ref: string; _type: string; url?: string }
  alt: string
  caption?: string
}

export interface TimelineEntry {
  title: string              // Timeline marker
  content: any[]             // Portable Text blocks
  images?: TimelineImage[]
  order: number
}

export interface TimelineBlock extends BaseBlock {
  _type: 'timelineBlock'
  heading?: string
  description?: string
  entries: TimelineEntry[]
  lineColor?: { hex: string }
}

export type HomepageBlock = BentoBlock | FeaturesBlock | DraggableCardBlock | TimelineBlock

3. Service Layer

File: src/lib/sanity/homepage.ts

GROQ Query Pattern

_type == "timelineBlock" => {
  _id,
  _type,
  name,
  heading,
  description,
  backgroundColor,
  textColor,
  lineColor,
  order,
  visible,
  fullWidth,
  entries[] {
    title,
    content,
    images[] {
      asset-> { _id, _type, url },
      alt,
      caption
    },
    order
  }
}

CRUD Methods

class HomepageService {
  static async getTimelineBlockById(blockId: string): Promise<TimelineBlock>
  static async createTimelineBlock(blockData: any): Promise<TimelineBlock>
  static async updateTimelineBlock(blockId: string, updates: any): Promise<TimelineBlock>
  static async deleteTimelineBlock(blockId: string): Promise<void>
}

API Endpoints

Timeline Block Endpoints

POST /api/homepage/timeline-blocks

Purpose: Create new timeline block and add to homepage

Request Body:

{
  "name": "Company History",
  "heading": "Our Journey",
  "description": "Timeline of milestones",
  "entries": [
    {
      "title": "2024",
      "content": [/* Portable Text blocks */],
      "images": [
        {
          "asset": { "_ref": "image-abc123", "_type": "reference" },
          "alt": "Office photo",
          "caption": "New office opening"
        }
      ],
      "order": 0
    }
  ],
  "lineColor": { "hex": "#3b82f6" },
  "backgroundColor": { "hex": "#ffffff" },
  "textColor": { "hex": "#000000" },
  "addToHomepage": true
}

Response:

{
  "success": true,
  "block": {
    "_id": "timeline-abc123",
    "_type": "timelineBlock",
    "name": "Company History",
    "entries": [...]
  }
}

GET /api/homepage/timeline-blocks/[id]

Purpose: Fetch single timeline block by ID

Response: Same structure as POST response

PATCH /api/homepage/timeline-blocks/[id]

Purpose: Update existing timeline block

Request Body: Partial updates (same structure as POST)

DELETE /api/homepage/timeline-blocks/[id]

Purpose: Remove timeline block and clean up homepage config

Image Upload Endpoints

POST /api/upload/image

Purpose: Server-side image upload to Sanity CDN

Implementation:

export async function POST(request: NextRequest) {
  const formData = await request.formData()
  const file = formData.get('file') as File

  // Validation
  if (!file.type.startsWith('image/')) {
    return NextResponse.json({ error: 'File must be an image' }, { status: 400 })
  }
  if (file.size > 5 * 1024 * 1024) {
    return NextResponse.json({ error: 'File size must be less than 5MB' }, { status: 400 })
  }

  // Convert to Buffer
  const buffer = Buffer.from(await file.arrayBuffer())

  // Upload to Sanity (CRITICAL PATTERN)
  const writeClient = client.withConfig({ useCdn: false })
  const asset = await writeClient.assets.upload('image', buffer, {
    filename: file.name,
    contentType: file.type
  })

  return NextResponse.json({
    success: true,
    asset: { _ref: asset._id, _type: 'reference', url: asset.url }
  })
}

Request: FormData with file field

Response:

{
  "success": true,
  "asset": {
    "_ref": "image-abc123-1920x1080-jpg",
    "_type": "reference",
    "url": "https://cdn.sanity.io/images/..."
  }
}

DELETE /api/upload/image/[assetId]

Purpose: Remove image asset from Sanity

Implementation:

export async function DELETE(request: NextRequest, { params }) {
  const { assetId } = await params
  const writeClient = client.withConfig({ useCdn: false })
  await writeClient.delete(assetId)
  return NextResponse.json({ success: true })
}

Sanity Client Patterns

Critical Pattern: Token Inheritance

ALWAYS use this pattern for write operations:

// ✅ CORRECT
const writeClient = client.withConfig({ useCdn: false })

NEVER override token:

// ❌ WRONG - causes "project user not found" error
const writeClient = client.withConfig({
  useCdn: false,
  token: process.env.SANITY_API_WRITE_TOKEN
})

Why It Works

The base Sanity client (src/lib/sanity.ts) is configured with token on line 14:

export const client = createClient({
  projectId: '62anct3y',
  dataset: 'production',
  apiVersion: '2024-01-01',
  token: process.env.SANITY_API_TOKEN,  // Token configured here
  useCdn: true,
})

When using client.withConfig({ useCdn: false }):

  1. Creates new client instance
  2. Inherits all properties from base client (including token)
  3. Only overrides useCdn property
  4. Maintains authentication context

This pattern is used throughout the codebase:

  • SanityService.uploadImage() (line 46)
  • SanityService.createProduct() (line 190)
  • All CRUD operations

Component Architecture

TimelineEntryEditor Component

File: src/components/admin/TimelineEntryEditor.tsx

Key Features:

  • Title and content inputs
  • Multi-file image upload
  • Image preview grid
  • Alt text and caption editing
  • Entry reordering controls

State Management:

const [uploadingImage, setUploadingImage] = useState(false)

const handleImageUpload = async (event: React.ChangeEvent<HTMLInputElement>) => {
  const files = event.target.files
  setUploadingImage(true)

  try {
    const uploadPromises = Array.from(files).map(async (file) => {
      // Validation
      if (!file.type.startsWith('image/')) return null
      if (file.size > 5 * 1024 * 1024) return null

      // Upload via utility
      const uploadedAsset = await uploadImageToSanity(file)
      return {
        asset: uploadedAsset.asset,
        alt: file.name.replace(/\.[^/.]+$/, ''),
        caption: ''
      }
    })

    const uploadedImages = await Promise.all(uploadPromises)
    handleChange('images', [...currentImages, ...uploadedImages])
    toast.success(`${uploadedImages.length} image(s) uploaded`)
  } catch (error) {
    toast.error('Failed to upload images')
  } finally {
    setUploadingImage(false)
  }
}

TimelineBlockEditor Component

File: src/components/admin/TimelineBlockEditor.tsx

Responsibilities:

  • Block metadata management
  • Entry array management (add/remove/reorder)
  • Color customization
  • Save/Cancel actions
  • Form validation

Entry Management:

const handleAddEntry = () => {
  const newEntry: Partial<TimelineEntry> = {
    title: '',
    content: [],
    images: [],
    order: entries.length
  }
  setEntries([...entries, newEntry])
}

const handleUpdateEntry = (index: number, updatedEntry: Partial<TimelineEntry>) => {
  const newEntries = [...entries]
  newEntries[index] = { ...newEntries[index], ...updatedEntry }
  setEntries(newEntries)
}

const handleRemoveEntry = (index: number) => {
  setEntries(entries.filter((_, i) => i !== index))
}

UnifiedBlockList Integration

File: src/components/admin/UnifiedBlockList.tsx

Added Support For:

// Icon mapping
const getBlockIcon = (block: HomepageBlock) => {
  if (block._type === 'timelineBlock') return <Clock className="h-4 w-4" />
  // ... other block types
}

// Description helper
const getBlockDescription = (block: HomepageBlock) => {
  if (block._type === 'timelineBlock') {
    const entriesCount = block.entries?.length || 0
    return `Timeline Section - ${entriesCount} entries`
  }
  // ... other block types
}

// CRUD endpoints
const handleSave = async (block: HomepageBlock) => {
  let endpoint = ''
  if (block._type === 'timelineBlock') {
    endpoint = `/api/homepage/timeline-blocks/${block._id}`
  }
  // ... other block types
}

Portable Text Handling

Converting Plain Text to Portable Text

const handleContentChange = (text: string) => {
  const portableText = [{
    _type: 'block',
    _key: `block-${Date.now()}`,
    style: 'normal',
    children: [{
      _type: 'span',
      _key: `span-${Date.now()}`,
      text: text,
      marks: []
    }]
  }]
  handleChange('content', portableText)
}

Extracting Text from Portable Text

const getTextFromPortableText = (): string => {
  if (!entry.content || entry.content.length === 0) return ''

  return entry.content
    .map((block: any) => {
      if (block._type === 'block' && block.children) {
        return block.children
          .map((child: any) => child.text || '')
          .join('')
      }
      return ''
    })
    .join('\n\n')
}

Performance Considerations

Image Optimization

  • CDN Delivery: All images served via Sanity CDN
  • Lazy Loading: Images load as user scrolls
  • Caching: Sanity CDN automatically caches images
  • Size Limits: 5MB max prevents excessive file sizes

Query Optimization

  • Selective Fields: GROQ queries only fetch needed fields
  • Asset Resolution: asset-> resolves asset references in single query
  • Ordering: Entries sorted client-side by order field

Component Optimization

  • Suspense Boundaries: Lazy load heavy components
  • Memoization: Use React.memo for repeated renders
  • Debouncing: Debounce search/filter operations

Security Patterns

Server-Side Upload

Why Server-Side:

  • Browser clients don't have write permissions
  • Keeps API tokens secure (server-side only)
  • Centralizes validation logic
  • Better error handling

File Validation

// Type validation
if (!file.type.startsWith('image/')) {
  throw new Error('File must be an image')
}

// Size validation
if (file.size > 5 * 1024 * 1024) {
  throw new Error('File size must be less than 5MB')
}

Content Sanitization

  • Portable Text is inherently safe (structured format)
  • No raw HTML injection possible
  • Sanity validates content structure

Testing Strategies

Unit Tests

Test individual functions:

describe('getTextFromPortableText', () => {
  it('should extract text from blocks', () => {
    const portableText = [
      {
        _type: 'block',
        children: [{ _type: 'span', text: 'Hello World' }]
      }
    ]
    expect(getTextFromPortableText(portableText)).toBe('Hello World')
  })
})

Integration Tests

Test API endpoints:

# Create timeline block
curl -X POST http://localhost:3000/api/homepage/timeline-blocks \
  -H "Content-Type: application/json" \
  -d '{"name":"Test Timeline","entries":[]}'

# Upload image
curl -X POST http://localhost:3000/api/upload/image \
  -F "file=@test-image.jpg"

E2E Tests

Test complete workflows:

  1. Navigate to /admin/ui-config/homepage
  2. Click "Create Timeline Block"
  3. Add entry with image upload
  4. Save block
  5. Verify block appears in list
  6. Edit block
  7. Delete block

Troubleshooting

Common Issues

Permission Errors

Error: Insufficient permissions; permission "create" required

Cause: Client-side upload attempted

Solution: Always use /api/upload/image endpoint

Authentication Errors

Error: project user not found for user ID...

Cause: Token override in API route

Solution: Use pattern client.withConfig({ useCdn: false })

Portable Text Issues

Error: Content not saving or displaying incorrectly

Cause: Invalid Portable Text structure

Solution: Use conversion functions in TimelineEntryEditor

Migration Guide

From Hardcoded Timeline

If migrating from hardcoded timeline component:

  1. Extract Data: Copy timeline data from component
  2. Create Block: Use admin UI to create new timeline block
  3. Upload Images: Use image upload feature for all images
  4. Verify: Check output matches original
  5. Remove: Delete hardcoded component

Database Migration

No database migration needed - Sanity handles schema evolution automatically.

Changelog

Version 2.2.0 (2025-11-04)

  • Initial implementation of Timeline Block system
  • Server-side image upload endpoints
  • Complete admin UI components
  • About page Company Timeline component
  • Fixed Sanity client authentication pattern