364 lines
9.1 KiB
Markdown
364 lines
9.1 KiB
Markdown
|
|
# Vercel Thumbnail Generation Integration
|
||
|
|
|
||
|
|
## 🎯 Overview
|
||
|
|
|
||
|
|
**Quoter**: Dead simple file upload/download service. Just raw files.
|
||
|
|
**Vercel**: Smart thumbnail generation and optimization.
|
||
|
|
|
||
|
|
Perfect separation of concerns! 💋
|
||
|
|
|
||
|
|
## 🔗 URL Patterns for Vercel
|
||
|
|
|
||
|
|
### Quoter File URLs
|
||
|
|
```
|
||
|
|
https://quoter.discours.io/image.jpg → Original file
|
||
|
|
https://quoter.discours.io/document.pdf → Original file
|
||
|
|
```
|
||
|
|
|
||
|
|
### Vercel Thumbnail URLs (SolidJS)
|
||
|
|
```
|
||
|
|
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)
|
||
|
|
```typescript
|
||
|
|
import { defineConfig } from '@solidjs/start/config';
|
||
|
|
|
||
|
|
export default defineConfig({
|
||
|
|
server: {
|
||
|
|
preset: 'vercel',
|
||
|
|
},
|
||
|
|
vite: {
|
||
|
|
define: {
|
||
|
|
'process.env.QUOTER_URL': JSON.stringify('https://quoter.discours.io'),
|
||
|
|
},
|
||
|
|
},
|
||
|
|
});
|
||
|
|
```
|
||
|
|
|
||
|
|
### 2. Thumbnail API Route (/api/thumb/[width]/[...path].ts)
|
||
|
|
```typescript
|
||
|
|
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://quoter.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
|
||
|
|
}
|
||
|
|
);
|
||
|
|
};
|
||
|
|
```
|
||
|
|
|
||
|
|
## 📋 File Naming Conventions
|
||
|
|
|
||
|
|
### Quoter Storage (No Width Patterns)
|
||
|
|
```
|
||
|
|
✅ image.jpg → Clean filename
|
||
|
|
✅ photo-2024.png → kebab-case
|
||
|
|
✅ user-avatar.webp → descriptive names
|
||
|
|
✅ document.pdf → any file type
|
||
|
|
|
||
|
|
❌ image_300.jpg → No width patterns needed
|
||
|
|
❌ photo-thumbnail.jpg → No thumbnail suffix
|
||
|
|
❌ userAvatar.png → No camelCase
|
||
|
|
```
|
||
|
|
|
||
|
|
### URL Routing Examples
|
||
|
|
```bash
|
||
|
|
# Client requests thumbnail
|
||
|
|
GET /api/thumb/600/image.jpg
|
||
|
|
|
||
|
|
# Vercel fetches original from Quoter
|
||
|
|
GET https://quoter.discours.io/image.jpg
|
||
|
|
|
||
|
|
# Vercel generates and caches 600px thumbnail
|
||
|
|
→ Returns optimized image
|
||
|
|
```
|
||
|
|
|
||
|
|
## 🚀 Benefits of This Architecture
|
||
|
|
|
||
|
|
### 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
|
||
|
|
|
||
|
|
## 🔧 SolidJS Frontend Integration
|
||
|
|
|
||
|
|
### 1. Install Dependencies
|
||
|
|
```bash
|
||
|
|
npm install @tanstack/solid-query @solidjs/start
|
||
|
|
```
|
||
|
|
|
||
|
|
### 2. Query Client Setup (app.tsx)
|
||
|
|
```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)
|
||
|
|
```tsx
|
||
|
|
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://quoter.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)
|
||
|
|
```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://quoter.discours.io/${props.filename}`;
|
||
|
|
|
||
|
|
const fallbackUrl = () => `https://quoter.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)
|
||
|
|
```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://quoter.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>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
## 🔧 Implementation Steps
|
||
|
|
|
||
|
|
1. **Quoter**: Serve raw files only (no patterns)
|
||
|
|
2. **Vercel**: Create SolidJS API routes for thumbnails
|
||
|
|
3. **Frontend**: Use TanStack Query for data fetching
|
||
|
|
4. **CORS**: Configure Quoter to allow Vercel domain
|
||
|
|
|
||
|
|
## 📊 Request Flow
|
||
|
|
|
||
|
|
```mermaid
|
||
|
|
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
|
||
|
|
```
|
||
|
|
|
||
|
|
## 🎨 Advanced Vercel Features
|
||
|
|
|
||
|
|
### Smart Format Detection
|
||
|
|
```javascript
|
||
|
|
// Auto-serve WebP/AVIF when supported
|
||
|
|
export async function GET(request) {
|
||
|
|
const accept = request.headers.get('accept');
|
||
|
|
const supportsWebP = accept?.includes('image/webp');
|
||
|
|
const supportsAVIF = accept?.includes('image/avif');
|
||
|
|
|
||
|
|
return new ImageResponse(
|
||
|
|
// ... image component
|
||
|
|
{
|
||
|
|
format: supportsAVIF ? 'avif' : supportsWebP ? 'webp' : 'jpeg',
|
||
|
|
}
|
||
|
|
);
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
### Quality Optimization
|
||
|
|
```javascript
|
||
|
|
// Different quality for different sizes
|
||
|
|
const quality = width <= 400 ? 75 : width <= 800 ? 85 : 95;
|
||
|
|
|
||
|
|
return new ImageResponse(component, {
|
||
|
|
width,
|
||
|
|
height,
|
||
|
|
quality,
|
||
|
|
});
|
||
|
|
```
|
||
|
|
|
||
|
|
## 🔗 Integration with CORS
|
||
|
|
|
||
|
|
Update Quoter CORS whitelist:
|
||
|
|
```bash
|
||
|
|
CORS_DOWNLOAD_ORIGINS=https://discours.io,https://*.discours.io,https://*.vercel.app
|
||
|
|
```
|
||
|
|
|
||
|
|
This allows Vercel Edge Functions to fetch originals from Quoter.
|
||
|
|
|
||
|
|
## 📈 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! 🚀
|