Add upload attachment ability into sdk

This commit is contained in:
LittleSheep 2025-01-10 21:40:59 +08:00
parent d8f51e305b
commit b5765877af
8 changed files with 416 additions and 3 deletions

View File

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

View File

@ -46,3 +46,141 @@ export async function listAttachment(id: string[]): Promise<SnAttachment[]> {
})
return resp.data.data
}
type MultipartProgress = {
value: number | null
current: number
total: number
}
type MultipartInfo = {
rid: string
fileChunks: Record<string, number>
isUploaded: boolean
}
class UploadAttachmentTask {
private content: File
private pool: string
private multipartSize: number = 0
private multipartInfo: MultipartInfo | null = null
private multipartProgress: MultipartProgress = { value: null, current: 0, total: 0 }
loading: boolean = false
success: boolean = false
error: string | null = null
constructor(content: File, pool: string) {
if (!content || !pool) {
throw new Error('Content and pool are required.')
}
this.content = content
this.pool = pool
}
public async submit(): Promise<void> {
this.loading = true
const limit = 3
try {
await this.createMultipartPlaceholder()
console.log(`[Paperclip] Multipart placeholder has been created with rid ${this.multipartInfo?.rid}`)
this.multipartProgress.value = 0
this.multipartProgress.current = 0
const chunks = Object.keys(this.multipartInfo?.fileChunks || {})
this.multipartProgress.total = chunks.length
const uploadChunks = async (chunk: string): Promise<void> => {
try {
await this.uploadSingleMultipart(chunk)
this.multipartProgress.current++
console.log(
`[Paperclip] Uploaded multipart ${this.multipartProgress.current}/${this.multipartProgress.total}`,
)
this.multipartProgress.value = this.multipartProgress.current / this.multipartProgress.total
} catch (err) {
console.log(`[Paperclip] Upload multipart ${chunk} failed, retrying in 3 seconds...`)
await this.delay(3000)
await uploadChunks(chunk)
}
}
for (let i = 0; i < chunks.length; i += limit) {
const chunkSlice = chunks.slice(i, i + limit)
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
}
}
private async createMultipartPlaceholder(): Promise<void> {
const mimetypeMap: Record<string, string> = {
mp4: 'video/mp4',
mov: 'video/quicktime',
mp3: 'audio/mp3',
wav: 'audio/wav',
m4a: 'audio/m4a',
}
const fileExtension = this.content.name.split('.').pop() || ''
const mimetype = mimetypeMap[fileExtension]
const nameArray = this.content.name.split('.')
nameArray.pop()
const resp = await sni.post('/cgi/uc/attachments/multipart', {
pool: this.pool,
size: this.content.size,
name: this.content.name,
alt: nameArray.join('.'),
mimetype,
})
const data = await resp.data
this.multipartSize = data.chunkSize
this.multipartInfo = data.meta
}
private async uploadSingleMultipart(chunkId: string): Promise<void> {
if (!this.multipartInfo) return
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/multipart/${this.multipartInfo.rid}/${chunkId}`, data, {
timeout: 3 * 60 * 1000,
})
this.multipartInfo = resp.data
}
private delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms))
}
public static formatBytes(bytes: number, decimals = 2): string {
if (!+bytes) return '0 Bytes'
const k = 1024
const dm = decimals < 0 ? 0 : decimals
const sizes = ['Bytes', 'KiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB', 'ZiB', 'YiB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(dm))} ${sizes[i]}`
}
}

View File

@ -0,0 +1,24 @@
export interface MaRelease {
id: number
created_at: Date
updated_at: Date
deleted_at?: Date
version: string
type: number
channel: string
assets: Record<string, any>
product_id: number
meta: MaReleaseMeta
}
export interface MaReleaseMeta {
id: number
created_at: Date
updated_at: Date
deleted_at?: Date
title: string
description: string
content: string
attachments: string[]
release_id: number
}

View File

@ -0,0 +1,166 @@
import {
Collapse,
Alert,
TextField,
Button,
Box,
FormControl,
InputLabel,
MenuItem,
Select,
Typography,
Grid2 as Grid,
IconButton,
} from '@mui/material'
import { useRouter } from 'next-nprogress-bar'
import { useState } from 'react'
import { useForm } from 'react-hook-form'
import ErrorIcon from '@mui/icons-material/Error'
import CloseIcon from '@mui/icons-material/Close'
import { MaProduct } from 'solar-js-sdk'
export interface MatrixReleaseForm {
version: string
type: number
channel: string
title: string
description: string
content: string
assets: Record<string, any>
attachments: string[]
}
export default function MaReleaseForm({
onSubmit,
onSuccess,
parent,
defaultValue,
}: {
onSubmit: (data: MatrixReleaseForm) => Promise<any>
onSuccess?: () => void
parent: MaProduct
defaultValue?: unknown
}) {
const { handleSubmit, register } = useForm<MatrixReleaseForm>({
defaultValues: {},
})
const router = useRouter()
const [assets, setAssets] = useState<{ k: string; v: string }[]>([])
function addAsset() {
setAssets((val) => [...val, { k: '', v: '' }])
}
const [error, setError] = useState<string | null>(null)
const [busy, setBusy] = useState<boolean>(false)
function callback() {
if (onSuccess) {
onSuccess()
} else {
router.push(`/console/matrix/products/${parent.id}`)
}
}
async function submit(data: MatrixReleaseForm) {
try {
setBusy(true)
await onSubmit({ ...data, assets: assets.reduce((a, { k, v }) => ({ ...a, [k]: v }), {}) })
callback()
} catch (err: any) {
setError(err.toString())
} finally {
setBusy(false)
}
}
return (
<form onSubmit={handleSubmit(submit)}>
<Box display="flex" flexDirection="column" maxWidth="sm" gap={2.5}>
<Collapse in={!!error} sx={{ width: '100%' }}>
<Alert icon={<ErrorIcon fontSize="inherit" />} severity="error">
{error}
</Alert>
</Collapse>
<TextField label="Version" placeholder="Major.Minor.Patch" {...register('version', { required: true })} />
<FormControl fullWidth>
<InputLabel id="release-type">Type</InputLabel>
<Select labelId="release-type" label="Type" {...register('type', { required: true })}>
<MenuItem value={0}>Full Release</MenuItem>
<MenuItem value={1}>Patch Release</MenuItem>
</Select>
</FormControl>
<TextField label="Title" {...register('title')} />
<TextField label="Alias" {...register('channel')} />
<TextField minRows={3} maxRows={3} multiline label="Description" {...register('description')} />
<TextField minRows={5} multiline label="Content" {...register('content')} />
<Box sx={{ mt: 3, display: 'flex', flexDirection: 'column', gap: 2 }}>
<Typography variant="subtitle1">Assets</Typography>
{assets.map(({ k, v }, idx) => (
<Grid container key={idx} spacing={2}>
<Grid size={4}>
<TextField
label="Platform"
sx={{ width: '100%' }}
value={k}
onChange={(val) => {
setAssets((data) =>
data.map((ele, index) => (index == idx ? { k: val.target.value, v: ele.v } : ele)),
)
}}
/>
</Grid>
<Grid size={7}>
<TextField
label="URL"
sx={{ width: '100%' }}
value={v}
onChange={(val) => {
setAssets((data) =>
data.map((ele, index) => (index == idx ? { v: val.target.value, k: ele.k } : ele)),
)
}}
/>
</Grid>
<Grid size={1} sx={{ display: 'grid', placeItems: 'center' }}>
<IconButton
onClick={() => {
setAssets((data) => data.filter((_, index) => index != idx))
}}
>
<CloseIcon />
</IconButton>
</Grid>
</Grid>
))}
<Box>
<Button variant="outlined" onClick={addAsset}>
Add
</Button>
</Box>
</Box>
<Box sx={{ mt: 5 }} display="flex" gap={2}>
<Button variant="contained" type="submit" disabled={busy}>
Submit
</Button>
<Button onClick={callback} variant="outlined" disabled={busy}>
Cancel
</Button>
</Box>
</Box>
</form>
)
}

View File

@ -47,7 +47,9 @@ export default function MatrixMarketplace() {
<Typography variant="body1">{p.description}</Typography>
</CardContent>
<CardActions>
<NextLink passHref href={`/console/matrix/products/${p.id}`}>
<Button size="small">Details</Button>
</NextLink>
<NextLink passHref href={`/console/matrix/products/${p.id}/edit`}>
<Button size="small">Edit</Button>
</NextLink>

View File

@ -0,0 +1,43 @@
import { ConsoleLayout, getConsoleStaticProps } from '@/components/layouts/ConsoleLayout'
import { Box, Button, Container, Typography } from '@mui/material'
import { GetServerSideProps, InferGetServerSidePropsType } from 'next'
import { sni, MaProduct } from 'solar-js-sdk'
import NextLink from 'next/link'
export const getServerSideProps: GetServerSideProps = (async (context) => {
const id = context.params!.id
const { data } = await sni.get<MaProduct>('/cgi/ma/products/' + id)
return getConsoleStaticProps({
props: {
title: `Product "${data.name}"`,
product: data,
},
})
}) satisfies GetServerSideProps<{ product: MaProduct }>
export default function ProductDetails({ product }: InferGetServerSidePropsType<typeof getServerSideProps>) {
return (
<ConsoleLayout>
<Container sx={{ py: 16, display: 'flex', flexDirection: 'column', gap: 8 }}>
<Box maxWidth="sm">
<Typography variant="h3" component="h1">
{product.name}
</Typography>
<Typography variant="subtitle1">{product.description}</Typography>
</Box>
<Box display="flex" flexDirection="column" gap={2}>
<Typography variant="h4" component="h2">
Releases
</Typography>
<NextLink passHref href={`/console/matrix/products/${product.id}/releases/new`}>
<Button variant="contained">Create a release</Button>
</NextLink>
</Box>
</Container>
</ConsoleLayout>
)
}

View File

@ -0,0 +1,40 @@
import { ConsoleLayout, getConsoleStaticProps } from '@/components/layouts/ConsoleLayout'
import { Typography, Container, Box } from '@mui/material'
import { MaProduct, sni } from 'solar-js-sdk'
import MaReleaseForm, { MatrixReleaseForm } from '@/components/matrix/MaReleaseForm'
import { GetServerSideProps, InferGetServerSidePropsType } from 'next'
export const getServerSideProps: GetServerSideProps = (async (context) => {
const id = context.params!.id
const { data } = await sni.get<MaProduct>('/cgi/ma/products/' + id)
return getConsoleStaticProps({
props: {
title: `New Release for "${data.name}"`,
product: data,
},
})
}) satisfies GetServerSideProps<{ product: MaProduct }>
export default function ReleaseNew({ product }: InferGetServerSidePropsType<typeof getServerSideProps>) {
async function onSubmit(data: MatrixReleaseForm) {
await sni.post(`/cgi/ma/products/${product.id}/releases`, data)
}
return (
<ConsoleLayout>
<Container sx={{ py: 16, display: 'flex', flexDirection: 'column', gap: 4 }}>
<Box>
<Typography variant="h3" component="h1">
Create a release
</Typography>
<Typography variant="subtitle1">for {product.name}</Typography>
</Box>
<MaReleaseForm onSubmit={onSubmit} parent={product} />
</Container>
</ConsoleLayout>
)
}

View File

@ -7,7 +7,7 @@ import MaProductForm, { MatrixProductForm } from '@/components/matrix/MaProductF
export async function getStaticProps() {
return getConsoleStaticProps({
props: {
title: 'Matrix',
title: 'New Product',
},
})
}