Skip to content

Commit

Permalink
feat(admin): new upload dialog
Browse files Browse the repository at this point in the history
  • Loading branch information
jsun969 committed Oct 28, 2023
1 parent 34d10b9 commit df310f0
Show file tree
Hide file tree
Showing 4 changed files with 148 additions and 54 deletions.
163 changes: 116 additions & 47 deletions app/admin/_components/UploadQuestionsDialog.tsx
Original file line number Diff line number Diff line change
@@ -1,50 +1,63 @@
'use client'

import { Button, List, ListItem, Text } from '@tremor/react'
import { clsxm } from '@zolplay/utils'
import {
Badge,
Button,
type Color,
Table,
TableBody,
TableCell,
TableHead,
TableHeaderCell,
TableRow,
Text,
} from '@tremor/react'
import { useRef, useState } from 'react'
import { useImmer } from 'use-immer'

import { uploadQuestion } from '~/app/admin/action'

import { Dialog } from './ui/Dialog'

type FileStatus = 'Pending' | 'Uploading' | 'Success' | 'Fail'
const fileStatusColor: Record<FileStatus, Color> = {
Pending: 'slate',
Uploading: 'amber',
Success: 'green',
Fail: 'red',
}
type QuestionFile = { file: File; status: FileStatus; isAIGenerated: boolean }

const convertFileSizeToMB = (fileSize: number) => {
return fileSize / 1024 / 1024
}

export function UploadQuestionsDialog() {
const [isOpen, setIsOpen] = useState(false)
const [files, setFiles] = useState<File[]>([])
const [files, updateFiles] = useImmer<QuestionFile[]>([])

const fileInputRef = useRef<HTMLInputElement>(null)

const [isLoading, setIsLoading] = useState(false)
const noFiles = files.length === 0
const canUpload = files.every(({ status }) => status === 'Pending')
const isLoading = files.some(({ status }) => status === 'Uploading')
const handleSubmit = async () => {
setIsLoading(true)
const results = await Promise.allSettled(
files.map(async (file) => {
const data = new FormData()
data.append('file', file)
await uploadQuestion(data)
await Promise.all(
files.map(async ({ file, isAIGenerated }, i) => {
updateFiles((draftFiles) => {
draftFiles[i]!.status = 'Uploading'
})
const fileFormData = new FormData()
fileFormData.append('file', file)
const { success } = await uploadQuestion({
fileFormData,
isAIGenerated,
})
updateFiles((draftFiles) => {
draftFiles[i]!.status = success ? 'Success' : 'Fail'
})
})
)

setIsOpen(false)
setFiles([])
setIsLoading(false)

const total = results.length
const successLength = results.filter(
(res) => res.status === 'fulfilled'
).length
const errorLength = results.filter(
(res) => res.status === 'rejected'
).length
if (total === successLength) {
alert(`${total} files uploaded successfully!`)
} else if (total > successLength) {
alert(
`${errorLength} out of ${total} files failed to upload successfully`
)
} else {
alert(`${total} files failed to upload successfully`)
}
}

return (
Expand All @@ -63,7 +76,25 @@ export function UploadQuestionsDialog() {
multiple
hidden
onChange={(e) => {
setFiles(Array.from(e.target.files || [], (file) => file))
updateFiles(
Array.from(e.target.files || [], (file) => file)
.filter((file) => {
if (convertFileSizeToMB(file.size) > 10) {
alert(`${file.name} is greater than 10 MB`)
return false
} else {
return true
}
})
.map(
(file) =>
({
file,
isAIGenerated: false,
status: 'Pending',
} satisfies QuestionFile)
)
)
}}
/>
<Text>
Expand All @@ -75,28 +106,66 @@ export function UploadQuestionsDialog() {
Cloudflare Document
</a>
</Text>
<List>
{files.map((file, i) => (
<ListItem
key={i}
className={clsxm(
file.size / 1024 / 1024 > 10 && 'text-[#ef4444]'
)}
>
<span>{file.name}</span>
<span>{(file.size / 1024 / 1024).toFixed(2)} MB</span>
</ListItem>
))}
</List>
{!noFiles && (
<Table>
<TableHead>
<TableRow>
<TableHeaderCell>Filename</TableHeaderCell>
<TableHeaderCell>Size</TableHeaderCell>
<TableHeaderCell>Status</TableHeaderCell>
<TableHeaderCell>
AI Generated{' '}
<input
type="checkbox"
checked={files.every(({ isAIGenerated }) => isAIGenerated)}
onChange={(e) => {
updateFiles((draftFiles) =>
draftFiles.forEach((file) => {
file.isAIGenerated = e.target.checked
})
)
}}
disabled={!canUpload}
/>
</TableHeaderCell>
</TableRow>
</TableHead>
<TableBody>
{files.map(({ file, status, isAIGenerated }, i) => (
<TableRow key={i}>
<TableCell>{file.name}</TableCell>
<TableCell>
{convertFileSizeToMB(file.size).toFixed(2)} MB
</TableCell>
<TableCell>
<Badge color={fileStatusColor[status]}>{status}</Badge>
</TableCell>
<TableCell className="text-right">
<input
type="checkbox"
checked={isAIGenerated}
onChange={(e) => {
updateFiles((draftFiles) => {
draftFiles[i]!.isAIGenerated = e.target.checked
})
}}
disabled={!canUpload}
/>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
<div className="flex justify-between">
<Button
onClick={() => fileInputRef.current?.click()}
variant="secondary"
>
Select Images
{noFiles ? 'Select' : 'Reselect'} Images
</Button>
<Button
disabled={files?.length === 0}
disabled={noFiles || !canUpload}
onClick={handleSubmit}
loading={isLoading}
>
Expand Down
25 changes: 18 additions & 7 deletions app/admin/action.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,21 +65,32 @@ type CloudflareUploadResponse = {
}
}

export async function uploadQuestion(data: FormData) {
export async function uploadQuestion({
fileFormData,
isAIGenerated,
}: {
fileFormData: FormData
isAIGenerated: boolean
}): Promise<{ success: boolean }> {
// Upload image to cloudflare
const url = `https://api.cloudflare.com/client/v4/accounts/${env.NEXT_PUBLIC_CLOUDFLARE_ACCOUNT_ID}/images/v1`
const fetchRes = await fetch(url, {
body: data,
const cloudflareFetchRes = await fetch(url, {
body: fileFormData,
method: 'POST',
headers: {
Authorization: 'Bearer ' + env.CLOUDFLARE_API_TOKEN,
},
})
const res: CloudflareUploadResponse = await fetchRes.json()
if (!res.success) {
throw new Error('Cloudflare Error')
const cloudflareRes: CloudflareUploadResponse =
await cloudflareFetchRes.json()
if (!cloudflareRes.success) {
// throw new Error('Cloudflare Error')
return { success: false }
}
// Add image to database
await db.insert(questions).values({ image: res.result.id })
await db
.insert(questions)
.values({ image: cloudflareRes.result.id, isAIGenerated })
revalidatePath('/admin')
return { success: true }
}
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
"react-dom": "18.2.0",
"sharp": "^0.32.6",
"sqids": "^0.3.0",
"use-immer": "^0.9.0",
"usehooks-ts": "^2.9.1",
"zod": "^3.21.4",
"zustand": "^4.3.9"
Expand Down
13 changes: 13 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

0 comments on commit df310f0

Please sign in to comment.