diff --git a/packages/sn/package.json b/packages/sn/package.json index dd1c5f0..fa0c84a 100644 --- a/packages/sn/package.json +++ b/packages/sn/package.json @@ -7,7 +7,7 @@ "name": "LittleSheep", "email": "littlesheep.code@hotmail.com" }, - "version": "0.0.1", + "version": "0.0.2", "tsup": { "entry": [ "src/index.ts" diff --git a/packages/sn/src/attachment.ts b/packages/sn/src/attachment.ts index 052a3f2..2da9f95 100644 --- a/packages/sn/src/attachment.ts +++ b/packages/sn/src/attachment.ts @@ -46,3 +46,141 @@ export async function listAttachment(id: string[]): Promise { }) return resp.data.data } + +type MultipartProgress = { + value: number | null + current: number + total: number +} + +type MultipartInfo = { + rid: string + fileChunks: Record + 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 { + 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 => { + 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 { + const mimetypeMap: Record = { + 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 { + 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 { + 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]}` + } +} diff --git a/packages/sn/src/matrix/release.ts b/packages/sn/src/matrix/release.ts new file mode 100644 index 0000000..b609089 --- /dev/null +++ b/packages/sn/src/matrix/release.ts @@ -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 + 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 +} diff --git a/src/components/matrix/MaReleaseForm.tsx b/src/components/matrix/MaReleaseForm.tsx new file mode 100644 index 0000000..7d5b4cb --- /dev/null +++ b/src/components/matrix/MaReleaseForm.tsx @@ -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 + attachments: string[] +} + +export default function MaReleaseForm({ + onSubmit, + onSuccess, + parent, + defaultValue, +}: { + onSubmit: (data: MatrixReleaseForm) => Promise + onSuccess?: () => void + parent: MaProduct + defaultValue?: unknown +}) { + const { handleSubmit, register } = useForm({ + defaultValues: {}, + }) + + const router = useRouter() + + const [assets, setAssets] = useState<{ k: string; v: string }[]>([]) + + function addAsset() { + setAssets((val) => [...val, { k: '', v: '' }]) + } + + const [error, setError] = useState(null) + const [busy, setBusy] = useState(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 ( +
+ + + } severity="error"> + {error} + + + + + + + Type + + + + + + + + + + + + + Assets + + {assets.map(({ k, v }, idx) => ( + + + { + setAssets((data) => + data.map((ele, index) => (index == idx ? { k: val.target.value, v: ele.v } : ele)), + ) + }} + /> + + + { + setAssets((data) => + data.map((ele, index) => (index == idx ? { v: val.target.value, k: ele.k } : ele)), + ) + }} + /> + + + { + setAssets((data) => data.filter((_, index) => index != idx)) + }} + > + + + + + ))} + + + + + + + + + + + +
+ ) +} diff --git a/src/pages/console/matrix/index.tsx b/src/pages/console/matrix/index.tsx index bd86384..916162d 100644 --- a/src/pages/console/matrix/index.tsx +++ b/src/pages/console/matrix/index.tsx @@ -47,7 +47,9 @@ export default function MatrixMarketplace() { {p.description} - + + + diff --git a/src/pages/console/matrix/products/[id]/index.tsx b/src/pages/console/matrix/products/[id]/index.tsx new file mode 100644 index 0000000..d2a0844 --- /dev/null +++ b/src/pages/console/matrix/products/[id]/index.tsx @@ -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('/cgi/ma/products/' + id) + + return getConsoleStaticProps({ + props: { + title: `Product "${data.name}"`, + product: data, + }, + }) +}) satisfies GetServerSideProps<{ product: MaProduct }> + +export default function ProductDetails({ product }: InferGetServerSidePropsType) { + return ( + + + + + {product.name} + + {product.description} + + + + + Releases + + + + + + + + + ) +} diff --git a/src/pages/console/matrix/products/[id]/releases/new.tsx b/src/pages/console/matrix/products/[id]/releases/new.tsx new file mode 100644 index 0000000..8a617d9 --- /dev/null +++ b/src/pages/console/matrix/products/[id]/releases/new.tsx @@ -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('/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) { + async function onSubmit(data: MatrixReleaseForm) { + await sni.post(`/cgi/ma/products/${product.id}/releases`, data) + } + + return ( + + + + + Create a release + + for {product.name} + + + + + + ) +} diff --git a/src/pages/console/matrix/products/new.tsx b/src/pages/console/matrix/products/new.tsx index 378db1a..ae671b4 100644 --- a/src/pages/console/matrix/products/new.tsx +++ b/src/pages/console/matrix/products/new.tsx @@ -7,7 +7,7 @@ import MaProductForm, { MatrixProductForm } from '@/components/matrix/MaProductF export async function getStaticProps() { return getConsoleStaticProps({ props: { - title: 'Matrix', + title: 'New Product', }, }) }