* feat: add changelog feature and update site navigation - Introduced a new Changelog page with pagination and detailed entry views. - Added components for displaying changelog entries, pagination, and entry details. - Updated site navigation to include a link to the new Changelog page. - Enhanced localization for changelog-related texts in marketing.json. * refactor: enhance Changelog page layout and entry display - Increased the number of changelog entries displayed per page from 2 to 20 for better visibility. - Improved the layout of the Changelog page by adjusting the container styles and removing unnecessary divs. - Updated the ChangelogEntry component to enhance the visual presentation of entries, including a new date badge with an icon. - Refined the CSS styles for Markdoc headings to improve typography and spacing. * refactor: enhance Changelog page functionality and layout - Increased the number of changelog entries displayed per page from 20 to 50 for improved user experience. - Updated ChangelogEntry component to make the highlight prop optional and refined the layout for better visual clarity. - Adjusted styles in ChangelogHeader and ChangelogPagination components for a more cohesive design. - Removed unnecessary order fields from changelog markdown files to streamline content management. * feat: enhance Changelog entry navigation and data loading - Refactored ChangelogEntry page to load previous and next entries for improved navigation. - Introduced ChangelogNavigation component to facilitate navigation between changelog entries. - Updated ChangelogDetail component to display navigation links and entry details. - Enhanced data fetching logic to retrieve all changelog entries alongside the current entry. - Added localization keys for navigation text in marketing.json. * Update package dependencies and enhance documentation layout - Upgraded various packages including @turbo/gen and turbo to version 2.6.0, and react-hook-form to version 7.66.0. - Updated lucide-react to version 0.552.0 across multiple packages. - Refactored documentation layout components for improved styling and structure. - Removed deprecated loading components and adjusted navigation elements for better user experience. - Added placeholder notes in changelog entries for clarity.
399 lines
8.6 KiB
Plaintext
399 lines
8.6 KiB
Plaintext
---
|
|
title: "File Uploads"
|
|
description: "Handle file uploads with Supabase Storage."
|
|
publishedAt: 2024-04-11
|
|
order: 2
|
|
status: "published"
|
|
---
|
|
|
|
> **Note:** This is mock/placeholder content for demonstration purposes.
|
|
|
|
Enable users to upload and manage files using Supabase Storage.
|
|
|
|
## Setup
|
|
|
|
### Create Storage Bucket
|
|
|
|
```sql
|
|
-- Create a public bucket for avatars
|
|
INSERT INTO storage.buckets (id, name, public)
|
|
VALUES ('avatars', 'avatars', true);
|
|
|
|
-- Create a private bucket for documents
|
|
INSERT INTO storage.buckets (id, name, public)
|
|
VALUES ('documents', 'documents', false);
|
|
```
|
|
|
|
### Set Storage Policies
|
|
|
|
```sql
|
|
-- Allow users to upload their own avatars
|
|
CREATE POLICY "Users can upload their own avatar"
|
|
ON storage.objects FOR INSERT
|
|
WITH CHECK (
|
|
bucket_id = 'avatars' AND
|
|
auth.uid()::text = (storage.foldername(name))[1]
|
|
);
|
|
|
|
-- Allow users to view their own avatars
|
|
CREATE POLICY "Users can view their own avatar"
|
|
ON storage.objects FOR SELECT
|
|
USING (
|
|
bucket_id = 'avatars' AND
|
|
auth.uid()::text = (storage.foldername(name))[1]
|
|
);
|
|
|
|
-- Allow users to delete their own avatars
|
|
CREATE POLICY "Users can delete their own avatar"
|
|
ON storage.objects FOR DELETE
|
|
USING (
|
|
bucket_id = 'avatars' AND
|
|
auth.uid()::text = (storage.foldername(name))[1]
|
|
);
|
|
```
|
|
|
|
## Upload Component
|
|
|
|
### Basic File Upload
|
|
|
|
```tsx
|
|
'use client';
|
|
|
|
import { useState } from 'react';
|
|
import { uploadFileAction } from '../_lib/actions';
|
|
|
|
export function FileUpload() {
|
|
const [uploading, setUploading] = useState(false);
|
|
const [file, setFile] = useState<File | null>(null);
|
|
|
|
const handleUpload = async () => {
|
|
if (!file) return;
|
|
|
|
setUploading(true);
|
|
|
|
const formData = new FormData();
|
|
formData.append('file', file);
|
|
|
|
const result = await uploadFileAction(formData);
|
|
|
|
if (result.success) {
|
|
toast.success('File uploaded successfully');
|
|
}
|
|
|
|
setUploading(false);
|
|
};
|
|
|
|
return (
|
|
<div>
|
|
<input
|
|
type="file"
|
|
onChange={(e) => setFile(e.files?.[0] || null)}
|
|
accept="image/*"
|
|
/>
|
|
<button
|
|
onClick={handleUpload}
|
|
disabled={!file || uploading}
|
|
>
|
|
{uploading ? 'Uploading...' : 'Upload'}
|
|
</button>
|
|
</div>
|
|
);
|
|
}
|
|
```
|
|
|
|
### Server Action
|
|
|
|
```typescript
|
|
'use server';
|
|
|
|
import { enhanceAction } from '@kit/next/actions';
|
|
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
|
|
|
export const uploadFileAction = enhanceAction(
|
|
async (formData: FormData, user) => {
|
|
const file = formData.get('file') as File;
|
|
|
|
if (!file) {
|
|
throw new Error('No file provided');
|
|
}
|
|
|
|
const client = getSupabaseServerClient();
|
|
const fileExt = file.name.split('.').pop();
|
|
const fileName = `${user.id}/${Date.now()}.${fileExt}`;
|
|
|
|
const { data, error } = await client.storage
|
|
.from('avatars')
|
|
.upload(fileName, file, {
|
|
cacheControl: '3600',
|
|
upsert: false,
|
|
});
|
|
|
|
if (error) throw error;
|
|
|
|
// Get public URL
|
|
const { data: { publicUrl } } = client.storage
|
|
.from('avatars')
|
|
.getPublicUrl(fileName);
|
|
|
|
return {
|
|
success: true,
|
|
url: publicUrl,
|
|
path: data.path,
|
|
};
|
|
},
|
|
{ auth: true }
|
|
);
|
|
```
|
|
|
|
## Drag and Drop Upload
|
|
|
|
```tsx
|
|
'use client';
|
|
|
|
import { useCallback } from 'react';
|
|
import { useDropzone } from 'react-dropzone';
|
|
|
|
export function DragDropUpload() {
|
|
const onDrop = useCallback(async (acceptedFiles: File[]) => {
|
|
for (const file of acceptedFiles) {
|
|
const formData = new FormData();
|
|
formData.append('file', file);
|
|
await uploadFileAction(formData);
|
|
}
|
|
}, []);
|
|
|
|
const { getRootProps, getInputProps, isDragActive } = useDropzone({
|
|
onDrop,
|
|
accept: {
|
|
'image/*': ['.png', '.jpg', '.jpeg', '.gif'],
|
|
},
|
|
maxSize: 5 * 1024 * 1024, // 5MB
|
|
});
|
|
|
|
return (
|
|
<div
|
|
{...getRootProps()}
|
|
className={cn(
|
|
'border-2 border-dashed rounded-lg p-8 text-center cursor-pointer',
|
|
isDragActive && 'border-primary bg-primary/10'
|
|
)}
|
|
>
|
|
<input {...getInputProps()} />
|
|
{isDragActive ? (
|
|
<p>Drop files here...</p>
|
|
) : (
|
|
<p>Drag and drop files here, or click to select</p>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
```
|
|
|
|
## File Validation
|
|
|
|
### Client-Side Validation
|
|
|
|
```typescript
|
|
function validateFile(file: File) {
|
|
const maxSize = 5 * 1024 * 1024; // 5MB
|
|
const allowedTypes = ['image/jpeg', 'image/png', 'image/gif'];
|
|
|
|
if (file.size > maxSize) {
|
|
throw new Error('File size must be less than 5MB');
|
|
}
|
|
|
|
if (!allowedTypes.includes(file.type)) {
|
|
throw new Error('File type must be JPEG, PNG, or GIF');
|
|
}
|
|
|
|
return true;
|
|
}
|
|
```
|
|
|
|
### Server-Side Validation
|
|
|
|
```typescript
|
|
export const uploadFileAction = enhanceAction(
|
|
async (formData: FormData, user) => {
|
|
const file = formData.get('file') as File;
|
|
|
|
// Validate file size
|
|
if (file.size > 5 * 1024 * 1024) {
|
|
throw new Error('File too large');
|
|
}
|
|
|
|
// Validate file type
|
|
const allowedTypes = ['image/jpeg', 'image/png', 'image/gif'];
|
|
if (!allowedTypes.includes(file.type)) {
|
|
throw new Error('Invalid file type');
|
|
}
|
|
|
|
// Validate dimensions for images
|
|
if (file.type.startsWith('image/')) {
|
|
const dimensions = await getImageDimensions(file);
|
|
if (dimensions.width > 4000 || dimensions.height > 4000) {
|
|
throw new Error('Image dimensions too large');
|
|
}
|
|
}
|
|
|
|
// Continue with upload...
|
|
},
|
|
{ auth: true }
|
|
);
|
|
```
|
|
|
|
## Image Optimization
|
|
|
|
### Resize on Upload
|
|
|
|
```typescript
|
|
import sharp from 'sharp';
|
|
|
|
export const uploadAvatarAction = enhanceAction(
|
|
async (formData: FormData, user) => {
|
|
const file = formData.get('file') as File;
|
|
const buffer = Buffer.from(await file.arrayBuffer());
|
|
|
|
// Resize image
|
|
const resized = await sharp(buffer)
|
|
.resize(200, 200, {
|
|
fit: 'cover',
|
|
position: 'center',
|
|
})
|
|
.jpeg({ quality: 90 })
|
|
.toBuffer();
|
|
|
|
const client = getSupabaseServerClient();
|
|
const fileName = `${user.id}/avatar.jpg`;
|
|
|
|
const { error } = await client.storage
|
|
.from('avatars')
|
|
.upload(fileName, resized, {
|
|
contentType: 'image/jpeg',
|
|
upsert: true,
|
|
});
|
|
|
|
if (error) throw error;
|
|
|
|
return { success: true };
|
|
},
|
|
{ auth: true }
|
|
);
|
|
```
|
|
|
|
## Progress Tracking
|
|
|
|
```tsx
|
|
'use client';
|
|
|
|
import { useState } from 'react';
|
|
|
|
export function UploadWithProgress() {
|
|
const [progress, setProgress] = useState(0);
|
|
|
|
const handleUpload = async (file: File) => {
|
|
const client = getSupabaseBrowserClient();
|
|
|
|
const { error } = await client.storage
|
|
.from('documents')
|
|
.upload(`uploads/${file.name}`, file, {
|
|
onUploadProgress: (progressEvent) => {
|
|
const percent = (progressEvent.loaded / progressEvent.total) * 100;
|
|
setProgress(Math.round(percent));
|
|
},
|
|
});
|
|
|
|
if (error) throw error;
|
|
};
|
|
|
|
return (
|
|
<div>
|
|
<input type="file" onChange={(e) => handleUpload(e.target.files![0])} />
|
|
{progress > 0 && (
|
|
<div className="w-full bg-gray-200 rounded-full h-2">
|
|
<div
|
|
className="bg-primary h-2 rounded-full transition-all"
|
|
style={{ width: `${progress}%` }}
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
```
|
|
|
|
## Downloading Files
|
|
|
|
### Get Public URL
|
|
|
|
```typescript
|
|
const { data } = client.storage
|
|
.from('avatars')
|
|
.getPublicUrl('user-id/avatar.jpg');
|
|
|
|
console.log(data.publicUrl);
|
|
```
|
|
|
|
### Download Private File
|
|
|
|
```typescript
|
|
const { data, error } = await client.storage
|
|
.from('documents')
|
|
.download('private-file.pdf');
|
|
|
|
if (data) {
|
|
const url = URL.createObjectURL(data);
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = 'file.pdf';
|
|
a.click();
|
|
}
|
|
```
|
|
|
|
### Generate Signed URL
|
|
|
|
```typescript
|
|
const { data, error } = await client.storage
|
|
.from('documents')
|
|
.createSignedUrl('private-file.pdf', 3600); // 1 hour
|
|
|
|
console.log(data.signedUrl);
|
|
```
|
|
|
|
## Deleting Files
|
|
|
|
```typescript
|
|
export const deleteFileAction = enhanceAction(
|
|
async (data, user) => {
|
|
const client = getSupabaseServerClient();
|
|
|
|
const { error } = await client.storage
|
|
.from('avatars')
|
|
.remove([data.path]);
|
|
|
|
if (error) throw error;
|
|
|
|
return { success: true };
|
|
},
|
|
{
|
|
schema: z.object({
|
|
path: z.string(),
|
|
}),
|
|
auth: true,
|
|
}
|
|
);
|
|
```
|
|
|
|
## Best Practices
|
|
|
|
1. **Validate on both sides** - Client and server
|
|
2. **Limit file sizes** - Prevent abuse
|
|
3. **Sanitize filenames** - Remove special characters
|
|
4. **Use unique names** - Prevent collisions
|
|
5. **Optimize images** - Resize before upload
|
|
6. **Set storage policies** - Control access
|
|
7. **Monitor usage** - Track storage costs
|
|
8. **Clean up unused files** - Regular maintenance
|
|
9. **Use CDN** - For public files
|
|
10. **Implement virus scanning** - For user uploads
|