✨ Attachment uploading
This commit is contained in:
parent
bdcbb18592
commit
cdf34ee9a1
@ -17,7 +17,7 @@
|
||||
"@mui/icons-material": "^6.3.1",
|
||||
"@mui/material": "^6.3.1",
|
||||
"@mui/material-nextjs": "^6.3.1",
|
||||
"@mui/x-charts": "^7.23.2",
|
||||
"@mui/x-charts": "^7.23.6",
|
||||
"@tailwindcss/typography": "^0.5.16",
|
||||
"@toolpad/core": "^0.11.0",
|
||||
"@vercel/speed-insights": "^1.1.0",
|
||||
@ -26,7 +26,7 @@
|
||||
"axios-case-converter": "^1.1.1",
|
||||
"cookies-next": "^5.0.2",
|
||||
"feed": "^4.2.2",
|
||||
"next": "15.1.3",
|
||||
"next": "^15.1.4",
|
||||
"next-nprogress-bar": "^2.4.3",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
@ -38,7 +38,7 @@
|
||||
"remark-parse": "^11.0.0",
|
||||
"remark-rehype": "^11.1.1",
|
||||
"sitemap": "^8.0.0",
|
||||
"solar-js-sdk": "0.0.8",
|
||||
"solar-js-sdk": "^0.1.1",
|
||||
"unified": "^11.0.5",
|
||||
"zustand": "^5.0.3"
|
||||
},
|
||||
@ -49,7 +49,7 @@
|
||||
"@types/react-dom": "^19.0.2",
|
||||
"postcss": "^8.4.49",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"eslint": "^9.17.0",
|
||||
"eslint": "^9.18.0",
|
||||
"eslint-config-next": "15.1.3",
|
||||
"@eslint/eslintrc": "^3.2.0"
|
||||
},
|
||||
|
@ -8,7 +8,7 @@
|
||||
"name": "LittleSheep",
|
||||
"email": "littlesheep.code@hotmail.com"
|
||||
},
|
||||
"version": "0.0.8",
|
||||
"version": "0.1.1",
|
||||
"tsup": {
|
||||
"entry": [
|
||||
"src/index.ts"
|
||||
|
@ -56,7 +56,6 @@ export type MultipartProgress = {
|
||||
export type MultipartInfo = {
|
||||
rid: string
|
||||
fileChunks: Record<string, number>
|
||||
isUploaded: boolean
|
||||
}
|
||||
|
||||
export class UploadAttachmentTask {
|
||||
@ -66,9 +65,9 @@ export class UploadAttachmentTask {
|
||||
private multipartInfo: MultipartInfo | null = null
|
||||
private multipartProgress: MultipartProgress = { value: null, current: 0, total: 0 }
|
||||
|
||||
loading: boolean = false
|
||||
success: boolean = false
|
||||
error: string | null = null
|
||||
onProgress?: (progress: MultipartProgress) => void
|
||||
onSuccess?: (success: boolean) => void
|
||||
onError?: (error: string) => void
|
||||
|
||||
constructor(content: File, pool: string) {
|
||||
if (!content || !pool) {
|
||||
@ -78,8 +77,7 @@ export class UploadAttachmentTask {
|
||||
this.pool = pool
|
||||
}
|
||||
|
||||
public async submit(): Promise<void> {
|
||||
this.loading = true
|
||||
public async submit(): Promise<SnAttachment> {
|
||||
const limit = 3
|
||||
|
||||
try {
|
||||
@ -92,14 +90,20 @@ export class UploadAttachmentTask {
|
||||
const chunks = Object.keys(this.multipartInfo?.fileChunks || {})
|
||||
this.multipartProgress.total = chunks.length
|
||||
|
||||
let result: SnAttachment | null = null
|
||||
|
||||
const uploadChunks = async (chunk: string): Promise<void> => {
|
||||
try {
|
||||
await this.uploadSingleMultipart(chunk)
|
||||
const resp = await this.uploadOneChunk(chunk)
|
||||
this.multipartProgress.current++
|
||||
console.log(
|
||||
`[Paperclip] Uploaded multipart ${this.multipartProgress.current}/${this.multipartProgress.total}`,
|
||||
)
|
||||
this.multipartProgress.value = this.multipartProgress.current / this.multipartProgress.total
|
||||
|
||||
if (this.onProgress) this.onProgress(this.multipartProgress)
|
||||
|
||||
result = resp
|
||||
} catch (err) {
|
||||
console.log(`[Paperclip] Upload multipart ${chunk} failed, retrying in 3 seconds...`)
|
||||
await this.delay(3000)
|
||||
@ -112,15 +116,13 @@ export class UploadAttachmentTask {
|
||||
await Promise.all(chunkSlice.map(uploadChunks))
|
||||
}
|
||||
|
||||
if (this.multipartInfo?.isUploaded) {
|
||||
console.log(`[Paperclip] Entire file has been uploaded in ${this.multipartProgress.total} chunk(s)`)
|
||||
this.success = true
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
this.error = e instanceof Error ? e.message : String(e)
|
||||
} finally {
|
||||
this.loading = false
|
||||
console.log(`[Paperclip] Entire file has been uploaded in ${this.multipartProgress.total} chunk(s)`)
|
||||
if (this.onSuccess) this.onSuccess(true)
|
||||
|
||||
return result!
|
||||
} catch (err: any) {
|
||||
if (this.onError) this.onError(err.toString())
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
@ -139,7 +141,7 @@ export class UploadAttachmentTask {
|
||||
const nameArray = this.content.name.split('.')
|
||||
nameArray.pop()
|
||||
|
||||
const resp = await sni.post('/cgi/uc/attachments/fragments', {
|
||||
const resp = await sni.post('/cgi/uc/fragments', {
|
||||
pool: this.pool,
|
||||
size: this.content.size,
|
||||
name: this.content.name,
|
||||
@ -153,20 +155,22 @@ export class UploadAttachmentTask {
|
||||
this.multipartInfo = data.meta
|
||||
}
|
||||
|
||||
private async uploadSingleMultipart(chunkId: string): Promise<void> {
|
||||
if (!this.multipartInfo) return
|
||||
private async uploadOneChunk(chunkId: string): Promise<SnAttachment | null> {
|
||||
if (!this.multipartInfo) return null
|
||||
|
||||
const chunkIdx = this.multipartInfo.fileChunks[chunkId]
|
||||
const chunk = this.content.slice(chunkIdx * this.multipartSize, (chunkIdx + 1) * this.multipartSize)
|
||||
|
||||
const data = new FormData()
|
||||
data.set('file', chunk)
|
||||
|
||||
const resp = await sni.post(`/cgi/uc/attachments/fragments/${this.multipartInfo.rid}/${chunkId}`, data, {
|
||||
const resp = await sni.post(`/cgi/uc/fragments/${this.multipartInfo.rid}/${chunkId}`, chunk, {
|
||||
headers: { 'Content-Type': 'application/octet-stream' },
|
||||
timeout: 3 * 60 * 1000,
|
||||
})
|
||||
|
||||
this.multipartInfo = resp.data
|
||||
if (resp.data['attachment']) {
|
||||
return resp.data['attachment'] as SnAttachment
|
||||
}
|
||||
this.multipartInfo = resp.data['fragment']
|
||||
return null
|
||||
}
|
||||
|
||||
private delay(ms: number): Promise<void> {
|
||||
|
@ -38,7 +38,11 @@ export default function App({ Component, pageProps }: AppProps) {
|
||||
userStore.fetchUser()
|
||||
}, [])
|
||||
|
||||
const title = pageProps.title ? `${pageProps.title} | Solsynth LLC` : 'Solsynth LLC'
|
||||
const title = pageProps.title
|
||||
? pageProps.startsWith('Solar Network Console')
|
||||
? pageProps.title
|
||||
: `${pageProps.title} | Solsynth LLC`
|
||||
: 'Solsynth LLC'
|
||||
|
||||
return (
|
||||
<>
|
||||
|
190
src/pages/attachments/new.tsx
Normal file
190
src/pages/attachments/new.tsx
Normal file
@ -0,0 +1,190 @@
|
||||
import {
|
||||
styled,
|
||||
Button,
|
||||
Container,
|
||||
Card,
|
||||
Box,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemText,
|
||||
IconButton,
|
||||
FormControl,
|
||||
InputLabel,
|
||||
MenuItem,
|
||||
Select,
|
||||
LinearProgress,
|
||||
Typography,
|
||||
Alert,
|
||||
Collapse,
|
||||
} from '@mui/material'
|
||||
import { MultipartProgress, SnAttachment, UploadAttachmentTask } from 'solar-js-sdk'
|
||||
import { useState } from 'react'
|
||||
|
||||
import ErrorIcon from '@mui/icons-material/Error'
|
||||
import CloseIcon from '@mui/icons-material/Close'
|
||||
import CloudUploadIcon from '@mui/icons-material/CloudUpload'
|
||||
import PlayIcon from '@mui/icons-material/PlayArrow'
|
||||
|
||||
const VisuallyHiddenInput = styled('input')({
|
||||
clip: 'rect(0 0 0 0)',
|
||||
clipPath: 'inset(50%)',
|
||||
height: 1,
|
||||
overflow: 'hidden',
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
whiteSpace: 'nowrap',
|
||||
width: 1,
|
||||
})
|
||||
|
||||
interface FileUploadTask {
|
||||
file: File
|
||||
attachment?: SnAttachment
|
||||
}
|
||||
|
||||
export default function AttachmentNew() {
|
||||
const [files, setFiles] = useState<FileUploadTask[]>([])
|
||||
|
||||
const [busy, setBusy] = useState<boolean>(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const [task, setTask] = useState<FileUploadTask>()
|
||||
const [taskProgress, setTaskProgress] = useState<MultipartProgress>()
|
||||
|
||||
const [pool, setPool] = useState<string>('interactive')
|
||||
|
||||
async function upload() {
|
||||
if (files.length == 0) return
|
||||
|
||||
setBusy(true)
|
||||
|
||||
for (let idx = 0; idx < files.length; idx++) {
|
||||
if (files[idx].attachment) continue
|
||||
|
||||
try {
|
||||
const task = new UploadAttachmentTask(files[idx].file, pool)
|
||||
setTask(files[idx])
|
||||
task.onProgress = (progress) => setTaskProgress(progress)
|
||||
task.onError = (err) => setError(err)
|
||||
const attachment = await task.submit()
|
||||
setFiles((files) => files.map((f, i) => (i == idx ? { ...f, attachment } : f)))
|
||||
} catch (err: any) {
|
||||
setError(err.toString())
|
||||
setBusy(false)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
setBusy(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<Container
|
||||
sx={{
|
||||
height: 'calc(100vh - 64px)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
maxWidth="xs"
|
||||
>
|
||||
<Box sx={{ width: '100%', mx: 'auto', display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
|
||||
<Box sx={{ display: 'flex', gap: 2 }}>
|
||||
<Button
|
||||
component="label"
|
||||
role={undefined}
|
||||
variant={files.length == 0 ? 'contained' : 'text'}
|
||||
tabIndex={-1}
|
||||
startIcon={<CloudUploadIcon />}
|
||||
>
|
||||
Pick files
|
||||
<VisuallyHiddenInput
|
||||
type="file"
|
||||
onChange={(event) =>
|
||||
setFiles(
|
||||
Array.from(event.target.files ?? []).map((f) => ({
|
||||
file: f,
|
||||
})),
|
||||
)
|
||||
}
|
||||
multiple
|
||||
/>
|
||||
</Button>
|
||||
{files.length > 0 && (
|
||||
<Button
|
||||
color="success"
|
||||
variant="contained"
|
||||
startIcon={<PlayIcon />}
|
||||
disabled={busy}
|
||||
onClick={() => upload()}
|
||||
>
|
||||
Upload
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Collapse in={!!error} sx={{ width: '100%' }}>
|
||||
<Alert sx={{ mb: 5 }} icon={<ErrorIcon fontSize="inherit" />} severity="error">
|
||||
{error}
|
||||
</Alert>
|
||||
</Collapse>
|
||||
|
||||
{taskProgress && (
|
||||
<Box sx={{ mt: 5, width: '100%', textAlign: 'center' }}>
|
||||
<Typography variant="body2" gutterBottom>
|
||||
{task?.file.name ?? 'Waiting...'}
|
||||
</Typography>
|
||||
<LinearProgress
|
||||
value={taskProgress?.value ? taskProgress.value * 100 : 0}
|
||||
sx={{ borderRadius: 4 }}
|
||||
variant="determinate"
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{files.length > 0 && (
|
||||
<Box sx={{ mt: 5, width: '100%' }}>
|
||||
<Card variant="outlined">
|
||||
<List>
|
||||
{files?.map((f, idx) => (
|
||||
<ListItem
|
||||
dense
|
||||
key={idx}
|
||||
secondaryAction={
|
||||
<IconButton
|
||||
edge="end"
|
||||
aria-label="delete"
|
||||
disabled={busy}
|
||||
sx={{ marginRight: 1 }}
|
||||
onClick={() => setFiles((files) => files.filter((_, i) => i != idx))}
|
||||
>
|
||||
<CloseIcon />
|
||||
</IconButton>
|
||||
}
|
||||
>
|
||||
<ListItemText primary={f.file.name} secondary={f.attachment?.rid} />
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
</Card>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<FormControl fullWidth sx={{ mt: 5 }}>
|
||||
<InputLabel id="attachment-pool">Attachment Pool</InputLabel>
|
||||
<Select
|
||||
labelId="attachment-pool"
|
||||
value={pool}
|
||||
disabled={busy}
|
||||
label="Attachment Pool"
|
||||
onChange={(evt) => setPool(evt.target.value)}
|
||||
>
|
||||
<MenuItem value={'interactive'}>Interactive</MenuItem>
|
||||
<MenuItem value={'messaging'}>Messaging</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Box>
|
||||
</Container>
|
||||
)
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user