add dropzone for releases page

This commit is contained in:
2025-06-21 13:03:42 +02:00
parent 7a455d6767
commit c06f8cce04
7 changed files with 406 additions and 9 deletions

View File

@@ -19,6 +19,15 @@ import {
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import {
Dropzone,
DropzoneDescription,
DropzoneGroup,
DropzoneInput,
DropzoneTitle,
DropzoneUploadIcon,
DropzoneZone,
} from '@/components/ui/dropzone'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import {
@@ -80,10 +89,77 @@ export function ReleasesClient() {
const [smodsVersions, setSmodsVersions] = useState<string[]>(['latest'])
const [lovelyVersions, setLovelyVersions] = useState<string[]>(['latest'])
const [newBranch, setNewBranch] = useState<string>('')
const [isUploading, setIsUploading] = useState(false)
const [uploadError, setUploadError] = useState<string | null>(null)
// Fetch branches from the database
const [branches] = api.branches.getBranches.useSuspenseQuery()
console.log(branches)
// Handle file upload
const [editIsUploading, setEditIsUploading] = useState(false)
const [editUploadError, setEditUploadError] = useState<string | null>(null)
const handleFileUpload = async (files: File[], isEdit = false) => {
if (files.length === 0) return
const file = files[0]
if (!file.name.endsWith('.zip')) {
toast.error('Only zip files are allowed')
return
}
if (isEdit) {
setEditIsUploading(true)
setEditUploadError(null)
} else {
setIsUploading(true)
setUploadError(null)
}
try {
const formData = new FormData()
formData.append('file', file)
const response = await fetch('/api/upload', {
method: 'POST',
body: formData,
})
if (!response.ok) {
const errorData = await response.json()
throw new Error(errorData.error || 'Failed to upload file')
}
const data = await response.json()
// Update the URL field in the form
if (isEdit) {
editForm.setValue('url', data.url)
} else {
form.setValue('url', data.url)
}
toast.success('File uploaded successfully')
} catch (error) {
console.error('Error uploading file:', error)
if (isEdit) {
setEditUploadError(
error instanceof Error ? error.message : 'Failed to upload file'
)
} else {
setUploadError(
error instanceof Error ? error.message : 'Failed to upload file'
)
}
toast.error('Failed to upload file')
} finally {
if (isEdit) {
setEditIsUploading(false)
} else {
setIsUploading(false)
}
}
}
// Add branch mutation
const addBranch = api.branches.addBranch.useMutation({
onSuccess: () => {
@@ -283,7 +359,51 @@ export function ReleasesClient() {
</div>
<div className='grid grid-cols-1 gap-2'>
<Label htmlFor='url'>URL</Label>
<Input id='url' {...form.register('url')} />
<div className='flex flex-col gap-2'>
<Input
id='url'
{...form.register('url')}
placeholder='URL will be automatically filled after upload'
/>
<div className='rounded-md border'>
<Dropzone
onDropAccepted={(files) => handleFileUpload(files, false)}
accept={{
'application/zip': ['.zip'],
}}
maxFiles={1}
disabled={isUploading}
>
<DropzoneZone className='w-full p-4'>
<DropzoneInput />
<DropzoneGroup className='gap-2'>
{isUploading ? (
<div className='flex items-center justify-center'>
<div className='h-6 w-6 animate-spin rounded-full border-primary border-b-2' />
</div>
) : (
<DropzoneUploadIcon />
)}
<DropzoneGroup>
<DropzoneTitle>
{isUploading
? 'Uploading...'
: 'Drop zip file here or click to upload'}
</DropzoneTitle>
<DropzoneDescription>
Upload a zip archive to automatically generate a URL
</DropzoneDescription>
{uploadError && (
<p className='mt-1 text-destructive text-sm'>
{uploadError}
</p>
)}
</DropzoneGroup>
</DropzoneGroup>
</DropzoneZone>
</Dropzone>
</div>
</div>
</div>
<div className='grid grid-cols-3 gap-4'>
<div className='grid grid-cols-1 gap-2'>
@@ -353,11 +473,11 @@ export function ReleasesClient() {
</div>
</div>
<div className='grid grid-cols-1 gap-2'>
<div className='flex justify-between items-center'>
<div className='flex items-center justify-between'>
<Label htmlFor='branch-management'>Branch Management</Label>
<Button
type='button'
variant='outline'
<Button
type='button'
variant='outline'
size='sm'
onClick={() => setBranchManagementOpen(true)}
>
@@ -404,7 +524,52 @@ export function ReleasesClient() {
</div>
<div className='grid grid-cols-1 gap-2'>
<Label htmlFor='edit-url'>URL</Label>
<Input id='edit-url' {...editForm.register('url')} />
<div className='flex flex-col gap-2'>
<Input
id='edit-url'
{...editForm.register('url')}
placeholder='URL will be automatically filled after upload'
/>
<div className='rounded-md border'>
<Dropzone
onDropAccepted={(files) => handleFileUpload(files, true)}
accept={{
'application/zip': ['.zip'],
}}
maxFiles={1}
disabled={editIsUploading}
>
<DropzoneZone className='w-full p-4'>
<DropzoneInput />
<DropzoneGroup className='gap-2'>
{editIsUploading ? (
<div className='flex items-center justify-center'>
<div className='h-6 w-6 animate-spin rounded-full border-primary border-b-2'></div>
</div>
) : (
<DropzoneUploadIcon />
)}
<DropzoneGroup>
<DropzoneTitle>
{editIsUploading
? 'Uploading...'
: 'Drop zip file here or click to upload'}
</DropzoneTitle>
<DropzoneDescription>
Upload a zip archive to automatically generate a
URL
</DropzoneDescription>
{editUploadError && (
<p className='mt-1 text-destructive text-sm'>
{editUploadError}
</p>
)}
</DropzoneGroup>
</DropzoneGroup>
</DropzoneZone>
</Dropzone>
</div>
</div>
</div>
<div className='grid grid-cols-3 gap-4'>
<div className='grid grid-cols-1 gap-2'>
@@ -515,7 +680,10 @@ export function ReleasesClient() {
</AlertDialog>
{/* Branch Management Modal */}
<Dialog open={branchManagementOpen} onOpenChange={setBranchManagementOpen}>
<Dialog
open={branchManagementOpen}
onOpenChange={setBranchManagementOpen}
>
<DialogContent className='sm:max-w-[500px]'>
<DialogHeader>
<DialogTitle>Manage Branches</DialogTitle>
@@ -581,7 +749,10 @@ export function ReleasesClient() {
</div>
</div>
<DialogFooter>
<Button type='button' onClick={() => setBranchManagementOpen(false)}>
<Button
type='button'
onClick={() => setBranchManagementOpen(false)}
>
Close
</Button>
</DialogFooter>

View File

@@ -0,0 +1,50 @@
import { auth } from '@/server/auth'
import { uploadFile } from '@/server/minio'
import { NextRequest, NextResponse } from 'next/server'
export async function POST(req: NextRequest) {
try {
// Check if user is authenticated and is an admin
const session = await auth()
if (!session || session.user.role !== 'admin') {
return NextResponse.json(
{ error: 'Unauthorized' },
{ status: 401 }
)
}
// Parse the multipart form data
const formData = await req.formData()
const file = formData.get('file') as File | null
if (!file) {
return NextResponse.json(
{ error: 'No file provided' },
{ status: 400 }
)
}
// Check if the file is a zip file
if (!file.name.endsWith('.zip')) {
return NextResponse.json(
{ error: 'Only zip files are allowed' },
{ status: 400 }
)
}
// Convert the file to a buffer
const buffer = Buffer.from(await file.arrayBuffer())
// Upload the file to MinIO
const fileUrl = await uploadFile(buffer, file.name, file.type)
// Return the URL of the uploaded file
return NextResponse.json({ url: fileUrl })
} catch (error) {
console.error('Error uploading file:', error)
return NextResponse.json(
{ error: 'Failed to upload file' },
{ status: 500 }
)
}
}

View File

@@ -18,6 +18,11 @@ export const env = createEnv({
DATABASE_URL: z.string().url(),
REDIS_URL: z.string().url(),
WEBHOOK_QUERY_SECRET: z.string(),
MINIO_ENDPOINT: z.string(),
MINIO_ACCESS_KEY: z.string(),
MINIO_SECRET_KEY: z.string(),
MINIO_BUCKET_NAME: z.string(),
MINIO_USE_SSL: z.enum(['true', 'false']).default('false'),
NODE_ENV: z
.enum(['development', 'test', 'production'])
.default('development'),
@@ -46,6 +51,11 @@ export const env = createEnv({
NODE_ENV: process.env.NODE_ENV,
CRON_SECRET: process.env.CRON_SECRET,
WEBHOOK_QUERY_SECRET: process.env.WEBHOOK_QUERY_SECRET,
MINIO_ENDPOINT: process.env.MINIO_ENDPOINT,
MINIO_ACCESS_KEY: process.env.MINIO_ACCESS_KEY,
MINIO_SECRET_KEY: process.env.MINIO_SECRET_KEY,
MINIO_BUCKET_NAME: process.env.MINIO_BUCKET_NAME,
MINIO_USE_SSL: process.env.MINIO_USE_SSL,
NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL,
},
/**

43
src/server/minio.ts Normal file
View File

@@ -0,0 +1,43 @@
import { env } from '@/env'
import { Client } from 'minio'
// Create and configure the MinIO client
export const minioClient = new Client({
endPoint: env.MINIO_ENDPOINT,
useSSL: env.MINIO_USE_SSL === 'true',
accessKey: env.MINIO_ACCESS_KEY,
secretKey: env.MINIO_SECRET_KEY,
})
// Function to check if bucket exists and create it if it doesn't
export async function ensureBucketExists() {
const bucketExists = await minioClient.bucketExists(env.MINIO_BUCKET_NAME)
if (!bucketExists) {
await minioClient.makeBucket(env.MINIO_BUCKET_NAME, 'us-east-1')
}
}
// Function to upload a file to MinIO and return the URL
export async function uploadFile(
file: Buffer,
fileName: string,
contentType: string
) {
await ensureBucketExists()
// Generate a unique object name to avoid collisions
const objectName = `${Date.now()}-${fileName}`
// Upload the file to MinIO
await minioClient.putObject(
env.MINIO_BUCKET_NAME,
objectName,
file,
file.length,
{ 'Content-Type': contentType }
)
// Construct and return the URL to the uploaded file
const protocol = env.MINIO_USE_SSL === 'true' ? 'https' : 'http'
return `${protocol}://${env.MINIO_ENDPOINT}/${env.MINIO_BUCKET_NAME}/${objectName}`
}