✨ Attachment uploading
This commit is contained in:
		| @@ -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> | ||||
|   ) | ||||
| } | ||||
		Reference in New Issue
	
	Block a user