Files
quoter/docs/vercel-frontend-migration.md

11 KiB

🚀 Vercel Frontend Migration Guide

📋 Overview

Quoter: Simple file upload/download service (raw files only)
Vercel: Smart thumbnail generation, optimization, and global CDN

Perfect separation of concerns! 💋

🔗 URL Patterns

Quoter (Raw Files)

https://files.discours.io/image.jpg          → Original file
https://files.discours.io/document.pdf       → Original file  

Vercel (Optimized Thumbnails)

https://new.discours.io/api/thumb/300/image.jpg    → 300px width
https://new.discours.io/api/thumb/600/image.jpg    → 600px width
https://new.discours.io/api/thumb/1200/image.jpg   → 1200px width

🛠️ Vercel Configuration

1. SolidJS Start Config (app.config.ts)

import { defineConfig } from '@solidjs/start/config';

export default defineConfig({
  server: {
    preset: 'vercel',
  },
  vite: {
    define: {
      'process.env.PUBLIC_CDN_URL': JSON.stringify('https://files.discours.io'),
    },
  },
});

2. vercel.json Configuration

{
  "images": {
    "deviceSizes": [64, 128, 256, 320, 400, 640, 800, 1200, 1600],
    "imageSizes": [10, 40, 110],
    "remotePatterns": [
      {
        "protocol": "https",
        "hostname": "files.discours.io",
        "pathname": "/**"
      }
    ],
    "minimumCacheTTL": 86400,
    "dangerouslyAllowSVG": false
  },
  "functions": {
    "api/thumb/[width]/[...path].js": {
      "maxDuration": 30
    }
  }
}

3. Thumbnail API Route (/api/thumb/[width]/[...path].ts)

import { ImageResponse } from '@vercel/og';
import type { APIRoute } from '@solidjs/start';

export const GET: APIRoute = async ({ params, request }) => {
  const width = parseInt(params.width);
  const imagePath = params.path.split('/').join('/');
  const quoterUrl = `https://files.discours.io/${imagePath}`;
  
  // Fetch original from Quoter
  const response = await fetch(quoterUrl);
  if (!response.ok) {
    return new Response('Image not found', { status: 404 });
  }
  
  // Generate optimized thumbnail using @vercel/og
  return new ImageResponse(
    (
      <img 
        src={quoterUrl}
        style={{
          width: width,
          height: 'auto',
          objectFit: 'contain',
        }}
      />
    ),
    {
      width: width,
      height: Math.round(width * 0.75), // 4:3 aspect ratio
    },
  );
};

🔧 Frontend Integration

1. Install Dependencies

npm install @tanstack/solid-query @solidjs/start @vercel/og

2. Query Client Setup (app.tsx)

import { QueryClient, QueryClientProvider } from '@tanstack/solid-query';
import { Router } from '@solidjs/router';

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 1000 * 60 * 5, // 5 minutes
      refetchOnWindowFocus: false,
    },
  },
});

export default function App() {
  return (
    <QueryClientProvider client={queryClient}>
      <Router>
        {/* Your app components */}
      </Router>
    </QueryClientProvider>
  );
}

3. File Upload Hook (hooks/useFileUpload.ts)

import { createMutation, useQueryClient } from '@tanstack/solid-query';

interface UploadResponse {
  url: string;
  filename: string;
}

export function useFileUpload() {
  const queryClient = useQueryClient();
  
  return createMutation(() => ({
    mutationFn: async (file: File): Promise<UploadResponse> => {
      const formData = new FormData();
      formData.append('file', file);
      
      const response = await fetch('https://files.discours.io/', {
        method: 'POST',
        headers: {
          'Authorization': `Bearer ${getAuthToken()}`,
        },
        body: formData,
      });
      
      if (!response.ok) {
        throw new Error('Upload failed');
      }
      
      return response.json();
    },
    onSuccess: () => {
      // Invalidate user quota query
      queryClient.invalidateQueries({ queryKey: ['user'] });
    },
  }));
}

4. Image Component with Thumbnails (components/Image.tsx)

import { createSignal, Show, Switch, Match } from 'solid-js';

interface ImageProps {
  filename: string;
  width?: number;
  alt: string;
  fallback?: boolean;
}

export function Image(props: ImageProps) {
  const [loadError, setLoadError] = createSignal(false);
  const [loading, setLoading] = createSignal(true);
  
  const thumbnailUrl = () => 
    props.width 
      ? `https://new.discours.io/api/thumb/${props.width}/${props.filename}`
      : `https://files.discours.io/${props.filename}`;
  
  const fallbackUrl = () => `https://files.discours.io/${props.filename}`;
  
  return (
    <Switch>
      <Match when={loading()}>
        <div class="bg-gray-200 animate-pulse" style={{ width: `${props.width}px`, height: '200px' }} />
      </Match>
      <Match when={!loadError()}>
        <img
          src={thumbnailUrl()}
          alt={props.alt}
          loading="lazy"
          onLoad={() => setLoading(false)}
          onError={() => {
            setLoading(false);
            setLoadError(true);
          }}
        />
      </Match>
      <Match when={loadError() && props.fallback !== false}>
        <img
          src={fallbackUrl()}
          alt={props.alt}
          loading="lazy"
          onLoad={() => setLoading(false)}
        />
      </Match>
    </Switch>
  );
}

5. User Quota Component (components/UserQuota.tsx)

import { createQuery } from '@tanstack/solid-query';
import { Show, Switch, Match } from 'solid-js';

export function UserQuota() {
  const query = createQuery(() => ({
    queryKey: ['user'],
    queryFn: async () => {
      const response = await fetch('https://files.discours.io/', {
        headers: {
          'Authorization': `Bearer ${getAuthToken()}`,
        },
      });
      return response.json();
    },
  }));
  
  return (
    <Switch>
      <Match when={query.isLoading}>
        <div>Loading quota...</div>
      </Match>
      <Match when={query.isError}>
        <div>Error loading quota</div>
      </Match>
      <Match when={query.isSuccess}>
        <Show when={query.data}>
          {(data) => (
            <div>
              <p>Storage: {data().storage_used_mb}MB / {data().storage_limit_mb}MB</p>
              <div class="w-full bg-gray-200 rounded-full h-2">
                <div 
                  class="bg-blue-600 h-2 rounded-full" 
                  style={{ width: `${(data().storage_used_mb / data().storage_limit_mb) * 100}%` }}
                />
              </div>
            </div>
          )}
        </Show>
      </Match>
    </Switch>
  );
}

🎨 OpenGraph Integration

OG Image Generation (/api/og/[...slug].ts)

import { ImageResponse } from '@vercel/og';
import type { APIRoute } from '@solidjs/start';

export const GET: APIRoute = async ({ params, request }) => {
  try {
    const { searchParams } = new URL(request.url);
    
    const title = searchParams.get('title') ?? 'Default Title';
    const description = searchParams.get('description') ?? 'Default Description';
    const imageUrl = searchParams.get('image'); // URL from Quoter
    
    // Load image from Quoter if provided
    let backgroundImage = null;
    if (imageUrl) {
      try {
        const imageResponse = await fetch(imageUrl);
        const imageBuffer = await imageResponse.arrayBuffer();
        backgroundImage = `data:image/jpeg;base64,${Buffer.from(imageBuffer).toString('base64')}`;
      } catch (error) {
        console.error('Failed to load image from Quoter:', error);
      }
    }

    return new ImageResponse(
      (
        <div
          style={{
            background: backgroundImage 
              ? `url(${backgroundImage})` 
              : 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
            backgroundSize: 'cover',
            backgroundPosition: 'center',
            width: '100%',
            height: '100%',
            display: 'flex',
            flexDirection: 'column',
            alignItems: 'center',
            justifyContent: 'center',
            fontFamily: 'system-ui',
          }}
        >
          <div
            style={{
              position: 'absolute',
              top: 0,
              left: 0,
              right: 0,
              bottom: 0,
              backgroundColor: 'rgba(0, 0, 0, 0.4)',
            }}
          />
          <div
            style={{
              display: 'flex',
              flexDirection: 'column',
              alignItems: 'center',
              zIndex: 1,
              padding: '40px',
              textAlign: 'center',
            }}
          >
            <h1
              style={{
                fontSize: '72px',
                fontWeight: 'bold',
                color: 'white',
                margin: 0,
                marginBottom: '20px',
                textShadow: '2px 2px 4px rgba(0, 0, 0, 0.8)',
              }}
            >
              {title}
            </h1>
            <p
              style={{
                fontSize: '36px',
                color: '#f1f5f9',
                margin: 0,
                textShadow: '1px 1px 2px rgba(0, 0, 0, 0.8)',
              }}
            >
              {description}
            </p>
          </div>
        </div>
      ),
      {
        width: 1200,
        height: 630,
      }
    );
  } catch (e: any) {
    console.log(`${e.message}`);
    return new Response(`Failed to generate the image`, {
      status: 500,
    });
  }
};

📊 Request Flow

sequenceDiagram
    participant Client
    participant Vercel
    participant Quoter
    participant S3

    Client->>Vercel: GET /api/thumb/600/image.jpg
    Vercel->>Quoter: GET /image.jpg (original)
    Quoter->>S3: Fetch image.jpg
    S3->>Quoter: Return file data
    Quoter->>Vercel: Return original image
    Vercel->>Vercel: Generate 600px thumbnail
    Vercel->>Client: Return optimized thumbnail
    
    Note over Vercel: Cache thumbnail at edge

🎯 Migration Benefits

For Quoter

  • Simple storage: Just store original files
  • No processing: Zero thumbnail generation load
  • Fast uploads: Direct S3 storage without resizing
  • Predictable URLs: Clean file paths

For Vercel

  • Edge optimization: Global CDN caching
  • Dynamic sizing: Any width on-demand
  • Smart caching: Automatic cache invalidation
  • Format optimization: WebP/AVIF when supported

🔧 Environment Variables

# .env.local
QUOTER_API_URL=https://files.discours.io
QUOTER_AUTH_TOKEN=your_jwt_token_here

📈 Performance Benefits

  • Faster uploads: No server-side resizing in Quoter
  • Global CDN: Vercel Edge caches thumbnails worldwide
  • On-demand sizing: Generate any size when needed
  • Smart caching: Automatic cache headers and invalidation
  • Format optimization: Serve modern formats automatically

Result: Clean separation of concerns - Quoter handles storage, Vercel handles optimization! 🚀

💋 KISS & DRY: One comprehensive guide instead of 4 separate documents.