✨ Attachment uploading
This commit is contained in:
parent
bdcbb18592
commit
cdf34ee9a1
@ -17,7 +17,7 @@
|
|||||||
"@mui/icons-material": "^6.3.1",
|
"@mui/icons-material": "^6.3.1",
|
||||||
"@mui/material": "^6.3.1",
|
"@mui/material": "^6.3.1",
|
||||||
"@mui/material-nextjs": "^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",
|
"@tailwindcss/typography": "^0.5.16",
|
||||||
"@toolpad/core": "^0.11.0",
|
"@toolpad/core": "^0.11.0",
|
||||||
"@vercel/speed-insights": "^1.1.0",
|
"@vercel/speed-insights": "^1.1.0",
|
||||||
@ -26,7 +26,7 @@
|
|||||||
"axios-case-converter": "^1.1.1",
|
"axios-case-converter": "^1.1.1",
|
||||||
"cookies-next": "^5.0.2",
|
"cookies-next": "^5.0.2",
|
||||||
"feed": "^4.2.2",
|
"feed": "^4.2.2",
|
||||||
"next": "15.1.3",
|
"next": "^15.1.4",
|
||||||
"next-nprogress-bar": "^2.4.3",
|
"next-nprogress-bar": "^2.4.3",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
@ -38,7 +38,7 @@
|
|||||||
"remark-parse": "^11.0.0",
|
"remark-parse": "^11.0.0",
|
||||||
"remark-rehype": "^11.1.1",
|
"remark-rehype": "^11.1.1",
|
||||||
"sitemap": "^8.0.0",
|
"sitemap": "^8.0.0",
|
||||||
"solar-js-sdk": "0.0.8",
|
"solar-js-sdk": "^0.1.1",
|
||||||
"unified": "^11.0.5",
|
"unified": "^11.0.5",
|
||||||
"zustand": "^5.0.3"
|
"zustand": "^5.0.3"
|
||||||
},
|
},
|
||||||
@ -49,7 +49,7 @@
|
|||||||
"@types/react-dom": "^19.0.2",
|
"@types/react-dom": "^19.0.2",
|
||||||
"postcss": "^8.4.49",
|
"postcss": "^8.4.49",
|
||||||
"tailwindcss": "^3.4.17",
|
"tailwindcss": "^3.4.17",
|
||||||
"eslint": "^9.17.0",
|
"eslint": "^9.18.0",
|
||||||
"eslint-config-next": "15.1.3",
|
"eslint-config-next": "15.1.3",
|
||||||
"@eslint/eslintrc": "^3.2.0"
|
"@eslint/eslintrc": "^3.2.0"
|
||||||
},
|
},
|
||||||
|
@ -8,7 +8,7 @@
|
|||||||
"name": "LittleSheep",
|
"name": "LittleSheep",
|
||||||
"email": "littlesheep.code@hotmail.com"
|
"email": "littlesheep.code@hotmail.com"
|
||||||
},
|
},
|
||||||
"version": "0.0.8",
|
"version": "0.1.1",
|
||||||
"tsup": {
|
"tsup": {
|
||||||
"entry": [
|
"entry": [
|
||||||
"src/index.ts"
|
"src/index.ts"
|
||||||
|
@ -56,7 +56,6 @@ export type MultipartProgress = {
|
|||||||
export type MultipartInfo = {
|
export type MultipartInfo = {
|
||||||
rid: string
|
rid: string
|
||||||
fileChunks: Record<string, number>
|
fileChunks: Record<string, number>
|
||||||
isUploaded: boolean
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export class UploadAttachmentTask {
|
export class UploadAttachmentTask {
|
||||||
@ -66,9 +65,9 @@ export class UploadAttachmentTask {
|
|||||||
private multipartInfo: MultipartInfo | null = null
|
private multipartInfo: MultipartInfo | null = null
|
||||||
private multipartProgress: MultipartProgress = { value: null, current: 0, total: 0 }
|
private multipartProgress: MultipartProgress = { value: null, current: 0, total: 0 }
|
||||||
|
|
||||||
loading: boolean = false
|
onProgress?: (progress: MultipartProgress) => void
|
||||||
success: boolean = false
|
onSuccess?: (success: boolean) => void
|
||||||
error: string | null = null
|
onError?: (error: string) => void
|
||||||
|
|
||||||
constructor(content: File, pool: string) {
|
constructor(content: File, pool: string) {
|
||||||
if (!content || !pool) {
|
if (!content || !pool) {
|
||||||
@ -78,8 +77,7 @@ export class UploadAttachmentTask {
|
|||||||
this.pool = pool
|
this.pool = pool
|
||||||
}
|
}
|
||||||
|
|
||||||
public async submit(): Promise<void> {
|
public async submit(): Promise<SnAttachment> {
|
||||||
this.loading = true
|
|
||||||
const limit = 3
|
const limit = 3
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@ -92,14 +90,20 @@ export class UploadAttachmentTask {
|
|||||||
const chunks = Object.keys(this.multipartInfo?.fileChunks || {})
|
const chunks = Object.keys(this.multipartInfo?.fileChunks || {})
|
||||||
this.multipartProgress.total = chunks.length
|
this.multipartProgress.total = chunks.length
|
||||||
|
|
||||||
|
let result: SnAttachment | null = null
|
||||||
|
|
||||||
const uploadChunks = async (chunk: string): Promise<void> => {
|
const uploadChunks = async (chunk: string): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
await this.uploadSingleMultipart(chunk)
|
const resp = await this.uploadOneChunk(chunk)
|
||||||
this.multipartProgress.current++
|
this.multipartProgress.current++
|
||||||
console.log(
|
console.log(
|
||||||
`[Paperclip] Uploaded multipart ${this.multipartProgress.current}/${this.multipartProgress.total}`,
|
`[Paperclip] Uploaded multipart ${this.multipartProgress.current}/${this.multipartProgress.total}`,
|
||||||
)
|
)
|
||||||
this.multipartProgress.value = 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) {
|
} catch (err) {
|
||||||
console.log(`[Paperclip] Upload multipart ${chunk} failed, retrying in 3 seconds...`)
|
console.log(`[Paperclip] Upload multipart ${chunk} failed, retrying in 3 seconds...`)
|
||||||
await this.delay(3000)
|
await this.delay(3000)
|
||||||
@ -112,15 +116,13 @@ export class UploadAttachmentTask {
|
|||||||
await Promise.all(chunkSlice.map(uploadChunks))
|
await Promise.all(chunkSlice.map(uploadChunks))
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.multipartInfo?.isUploaded) {
|
|
||||||
console.log(`[Paperclip] Entire file has been uploaded in ${this.multipartProgress.total} chunk(s)`)
|
console.log(`[Paperclip] Entire file has been uploaded in ${this.multipartProgress.total} chunk(s)`)
|
||||||
this.success = true
|
if (this.onSuccess) this.onSuccess(true)
|
||||||
}
|
|
||||||
} catch (e) {
|
return result!
|
||||||
console.error(e)
|
} catch (err: any) {
|
||||||
this.error = e instanceof Error ? e.message : String(e)
|
if (this.onError) this.onError(err.toString())
|
||||||
} finally {
|
throw err
|
||||||
this.loading = false
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -139,7 +141,7 @@ export class UploadAttachmentTask {
|
|||||||
const nameArray = this.content.name.split('.')
|
const nameArray = this.content.name.split('.')
|
||||||
nameArray.pop()
|
nameArray.pop()
|
||||||
|
|
||||||
const resp = await sni.post('/cgi/uc/attachments/fragments', {
|
const resp = await sni.post('/cgi/uc/fragments', {
|
||||||
pool: this.pool,
|
pool: this.pool,
|
||||||
size: this.content.size,
|
size: this.content.size,
|
||||||
name: this.content.name,
|
name: this.content.name,
|
||||||
@ -153,20 +155,22 @@ export class UploadAttachmentTask {
|
|||||||
this.multipartInfo = data.meta
|
this.multipartInfo = data.meta
|
||||||
}
|
}
|
||||||
|
|
||||||
private async uploadSingleMultipart(chunkId: string): Promise<void> {
|
private async uploadOneChunk(chunkId: string): Promise<SnAttachment | null> {
|
||||||
if (!this.multipartInfo) return
|
if (!this.multipartInfo) return null
|
||||||
|
|
||||||
const chunkIdx = this.multipartInfo.fileChunks[chunkId]
|
const chunkIdx = this.multipartInfo.fileChunks[chunkId]
|
||||||
const chunk = this.content.slice(chunkIdx * this.multipartSize, (chunkIdx + 1) * this.multipartSize)
|
const chunk = this.content.slice(chunkIdx * this.multipartSize, (chunkIdx + 1) * this.multipartSize)
|
||||||
|
|
||||||
const data = new FormData()
|
const resp = await sni.post(`/cgi/uc/fragments/${this.multipartInfo.rid}/${chunkId}`, chunk, {
|
||||||
data.set('file', chunk)
|
headers: { 'Content-Type': 'application/octet-stream' },
|
||||||
|
|
||||||
const resp = await sni.post(`/cgi/uc/attachments/fragments/${this.multipartInfo.rid}/${chunkId}`, data, {
|
|
||||||
timeout: 3 * 60 * 1000,
|
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> {
|
private delay(ms: number): Promise<void> {
|
||||||
|
@ -38,7 +38,11 @@ export default function App({ Component, pageProps }: AppProps) {
|
|||||||
userStore.fetchUser()
|
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 (
|
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