Attachment uploading

This commit is contained in:
LittleSheep 2025-01-11 15:28:23 +08:00
parent bdcbb18592
commit cdf34ee9a1
6 changed files with 228 additions and 30 deletions

BIN
bun.lockb

Binary file not shown.

View File

@ -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"
},

View File

@ -8,7 +8,7 @@
"name": "LittleSheep",
"email": "littlesheep.code@hotmail.com"
},
"version": "0.0.8",
"version": "0.1.1",
"tsup": {
"entry": [
"src/index.ts"

View File

@ -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> {

View File

@ -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 (
<>

View 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>
)
}