✨ Attachment create (multipart mode)
This commit is contained in:
		| @@ -8,7 +8,7 @@ html, body { | ||||
| html, body, #app, .v-application { | ||||
|     overflow: auto !important; | ||||
|  | ||||
|     font-family: "Comfortaa", "Noto Sans SC", "Noto Sans TC", "Noto Sans JP", sans-serif; | ||||
|     font-family: "Comfortaa", "Noto Sans SC", "Noto Sans TC", "Noto Sans JP", sans-serif !important; | ||||
|     font-optical-sizing: auto; | ||||
|     font-weight: 500; | ||||
|     font-style: normal; | ||||
|   | ||||
| @@ -2,17 +2,20 @@ | ||||
|   <v-container fluid> | ||||
|     <div class="mt-3 mb-6.5 mx-[3.5ch] text-center"> | ||||
|       <h1 class="text-2xl">{{ t("navGallery") }}</h1> | ||||
|       <span>{{ t("navGalleryCaption") }}</span> | ||||
|       <p>{{ t("navGalleryCaption") }}</p> | ||||
|       <v-btn slim size="x-small" prepend-icon="mdi-upload" variant="text" color="info" to="/gallery/new"> | ||||
|         <span>Upload new</span> | ||||
|       </v-btn> | ||||
|     </div> | ||||
|  | ||||
|     <div class="album"> | ||||
|       <v-card v-for="item in items" class="album-item mb-3" :to="`/gallery/${item.rid}`"> | ||||
|         <attachment-renderer :item="item" /> | ||||
|       </v-card> | ||||
|     </div> | ||||
|  | ||||
|       <div class="flex p-5 justify-center items-center"> | ||||
|         <v-btn variant="outlined" text="Load more" :loading="loading" @click="load" /> | ||||
|       </div> | ||||
|     <div class="flex p-5 justify-center items-center"> | ||||
|       <v-btn variant="outlined" text="Load more" :loading="loading" @click="load" /> | ||||
|     </div> | ||||
|   </v-container> | ||||
| </template> | ||||
|   | ||||
							
								
								
									
										165
									
								
								pages/gallery/new.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										165
									
								
								pages/gallery/new.vue
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,165 @@ | ||||
| <template> | ||||
|   <div class="h-[calc(100vh-80px)] flex flex-col justify-center items-center"> | ||||
|     <h1 class="text-2xl font-bold">Create Attachment</h1> | ||||
|     <p>Use Solar Network host your files</p> | ||||
|  | ||||
|     <div class="my-5 w-[640px]"> | ||||
|       <v-expand-transition> | ||||
|         <v-file-input | ||||
|           label="File input" | ||||
|           variant="solo" | ||||
|           :hide-details="true" | ||||
|           v-if="!multipartProgress.value" | ||||
|           v-model="content" | ||||
|         ></v-file-input> | ||||
|       </v-expand-transition> | ||||
|  | ||||
|       <v-expand-transition> | ||||
|         <div v-if="multipartProgress.value" class="text-center flex flex-col"> | ||||
|           <span class="text-sm"> | ||||
|             {{ success ? "Uploaded" : "Uploading" }} | ||||
|             {{ multipartProgress.current }}/{{ multipartProgress.total }} | ||||
|             {{ (multipartProgress.value * 100).toFixed(2) }}% | ||||
|           </span> | ||||
|           <p class="text-xs text-grey">{{ formatBytes(multipartSize) }} per chunk · #{{ multipartInfo?.rid }}</p> | ||||
|           <v-progress-linear class="mt-2" :model-value="multipartProgress.value" :max="1" height="4" rounded /> | ||||
|         </div> | ||||
|       </v-expand-transition> | ||||
|  | ||||
|       <v-expand-transition> | ||||
|         <div v-if="success"> | ||||
|           <div class="mt-3"> | ||||
|             <attachment-carousel :attachments="[multipartInfo.rid]" /> | ||||
|           </div> | ||||
|         </div> | ||||
|       </v-expand-transition> | ||||
|  | ||||
|       <v-expand-transition> | ||||
|         <v-alert v-if="error" variant="tonal" type="error" class="text-xs mt-3"> | ||||
|           {{ t("errorOccurred", [error]) }} | ||||
|         </v-alert> | ||||
|       </v-expand-transition> | ||||
|     </div> | ||||
|  | ||||
|     <div class="flex"> | ||||
|       <v-btn text="Upload" prepend-icon="mdi-upload" variant="plain" :loading="loading" @click="submit" /> | ||||
|       <v-btn text="Cancel" color="grey" append-icon="mdi-exit-to-app" variant="plain" to="/gallery" | ||||
|              :disabled="loading" /> | ||||
|     </div> | ||||
|  | ||||
|     <copyright class="mt-4" service="paperclip" /> | ||||
|   </div> | ||||
| </template> | ||||
|  | ||||
| <script setup lang="ts"> | ||||
| definePageMeta({ | ||||
|   middleware: ["auth"], | ||||
| }) | ||||
|  | ||||
| useHead({ | ||||
|   title: "Create Attachment", | ||||
| }) | ||||
|  | ||||
| const { t } = useI18n() | ||||
|  | ||||
| const content = ref<File | null>(null) | ||||
|  | ||||
| const error = ref<string | null>(null) | ||||
| const success = ref(false) | ||||
|  | ||||
| const multipartSize = ref(0) | ||||
| const multipartInfo = ref<any>(null) | ||||
| const multipartProgress = reactive<{ value: number | null, current: number, total: number }>({ | ||||
|   value: null, | ||||
|   current: 0, | ||||
|   total: 0, | ||||
| }) | ||||
|  | ||||
| const loading = ref(false) | ||||
|  | ||||
| async function submit() { | ||||
|   if (!content.value) return | ||||
|  | ||||
|   loading.value = true | ||||
|  | ||||
|   try { | ||||
|     await createMultipartPlaceholder() | ||||
|     console.log(`[PAPERCLIP] Multipart placeholder has been created with rid ${multipartInfo.value.rid}`) | ||||
|     let taskIdx = 0 | ||||
|     multipartProgress.value = 0 | ||||
|     multipartProgress.current = taskIdx | ||||
|     if (multipartInfo.value["file_chunks"]) { | ||||
|       multipartProgress.total = Object.keys(multipartInfo.value["file_chunks"] ?? {}).length | ||||
|     } | ||||
|     for (const chunk in multipartInfo.value["file_chunks"]) { | ||||
|       await uploadSingleMultipart(chunk) | ||||
|       taskIdx++ | ||||
|       console.log(`[PAPERCLIP] Uploaded multipart ${taskIdx}/${multipartProgress.total}`) | ||||
|       multipartProgress.value = taskIdx / multipartProgress.total | ||||
|       multipartProgress.current = taskIdx | ||||
|     } | ||||
|     if (multipartInfo.value["is_uploaded"]) { | ||||
|       console.log(`[PAPERCLIP] Entire file has been uploaded in ${multipartProgress.total} chunk(s)`) | ||||
|       success.value = true | ||||
|     } | ||||
|   } catch (e) { | ||||
|     console.error(e) | ||||
|     error.value = e as string | ||||
|   } | ||||
|  | ||||
|   loading.value = false | ||||
| } | ||||
|  | ||||
| async function createMultipartPlaceholder() { | ||||
|   if (!content.value) return | ||||
|  | ||||
|   const nameArray = content.value.name.split(".") | ||||
|   nameArray.pop() | ||||
|  | ||||
|   const resp = await solarFetch("/cgi/uc/attachments/multipart", { | ||||
|     method: "POST", | ||||
|     headers: { "Content-Type": "application/json" }, | ||||
|     body: JSON.stringify({ | ||||
|       pool: "interactive", | ||||
|       size: content.value.size, | ||||
|       name: content.value.name, | ||||
|       alt: nameArray.join("."), | ||||
|     }), | ||||
|   }) | ||||
|   if (resp.status != 200) throw new Error(await resp.text()) | ||||
|   const data = await resp.json() | ||||
|  | ||||
|   multipartSize.value = data["chunk_size"] | ||||
|   multipartInfo.value = data["meta"] | ||||
| } | ||||
|  | ||||
| async function uploadSingleMultipart(chunkId: string) { | ||||
|   if (!content.value) return | ||||
|   if (!multipartInfo.value) return | ||||
|  | ||||
|   const chunkIdx: number = multipartInfo.value["file_chunks"][chunkId] | ||||
|   const chunk = content.value.slice(chunkIdx * multipartSize.value, (chunkIdx + 1) * multipartSize.value) | ||||
|  | ||||
|   const data = new FormData() | ||||
|   data.set("file", chunk) | ||||
|  | ||||
|   const resp = await solarFetch(`/cgi/uc/attachments/multipart/${multipartInfo.value.rid}/${chunkId}`, { | ||||
|     method: "POST", | ||||
|     body: data, | ||||
|   }) | ||||
|   if (resp.status != 200) throw new Error(await resp.text()) | ||||
|   multipartInfo.value = await resp.json() | ||||
| } | ||||
|  | ||||
| function formatBytes(bytes: number, decimals = 2) { | ||||
|   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]}` | ||||
| } | ||||
| </script> | ||||
| @@ -1,8 +1,12 @@ | ||||
| export async function solarFetch(input: string | URL | globalThis.Request, init?: RequestInit) { | ||||
| export async function solarFetch(input: string, init?: RequestInit) { | ||||
|   const auth = useUserinfo() | ||||
|   const config = useRuntimeConfig() | ||||
|  | ||||
|   return await fetch(`${config.public.solarNetworkApi}${input}`, { | ||||
|   if (!input.startsWith("http")) { | ||||
|     input = `${config.public.solarNetworkApi}${input}` | ||||
|   } | ||||
|  | ||||
|   return await fetch(input, { | ||||
|     ...init, | ||||
|     headers: { | ||||
|       ...init?.headers, | ||||
|   | ||||
		Reference in New Issue
	
	Block a user