✨ File bundle
This commit is contained in:
		
							
								
								
									
										50
									
								
								DysonNetwork.Drive/Client/src/components/BundleSelect.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										50
									
								
								DysonNetwork.Drive/Client/src/components/BundleSelect.vue
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,50 @@ | ||||
| <template> | ||||
|   <n-select | ||||
|     v-model:value="selectedBundle" | ||||
|     :options="options" | ||||
|     placeholder="Select a bundle" | ||||
|     @update:value="handleBundleChange" | ||||
|     filterable | ||||
|     remote | ||||
|     :loading="loading" | ||||
|     @search="handleSearch" | ||||
|     clearable | ||||
|   /> | ||||
| </template> | ||||
|  | ||||
| <script setup lang="ts"> | ||||
| import { NSelect } from 'naive-ui' | ||||
| import { ref, onMounted } from 'vue' | ||||
|  | ||||
| const emit = defineEmits(['update:bundle']) | ||||
|  | ||||
| const selectedBundle = ref<string | null>(null) | ||||
| const loading = ref(false) | ||||
| const options = ref<any[]>([]) | ||||
|  | ||||
| async function fetchBundles(term: string | null = null) { | ||||
|   loading.value = true | ||||
|   try { | ||||
|     const resp = await fetch(`/api/bundles/me?${term ? `term=${term}` : ''}`) | ||||
|     const data = await resp.json() | ||||
|     options.value = data.map((bundle: any) => ({ | ||||
|       label: bundle.name, | ||||
|       value: bundle.id, | ||||
|     })) | ||||
|   } catch (error) { | ||||
|     console.error('Failed to fetch bundles:', error) | ||||
|   } finally { | ||||
|     loading.value = false | ||||
|   } | ||||
| } | ||||
|  | ||||
| function handleSearch(query: string) { | ||||
|   fetchBundles(query) | ||||
| } | ||||
|  | ||||
| function handleBundleChange(value: string) { | ||||
|   emit('update:bundle', value) | ||||
| } | ||||
|  | ||||
| onMounted(() => fetchBundles()) | ||||
| </script> | ||||
| @@ -89,7 +89,7 @@ import type { SnFilePool } from '@/types/pool' | ||||
|  | ||||
| import * as tus from 'tus-js-client' | ||||
|  | ||||
| const props = defineProps<{ filePool: string | null; modeAdvanced: boolean; pools: SnFilePool[] }>() | ||||
| const props = defineProps<{ filePool: string | null; modeAdvanced: boolean; pools: SnFilePool[]; bundleId?: string }>() | ||||
|  | ||||
| const filePass = ref<string>('') | ||||
| const fileExpire = ref<number | null>(null) | ||||
| @@ -117,6 +117,7 @@ function customRequest({ | ||||
|   if (props.filePool) requestHeaders['X-FilePool'] = props.filePool | ||||
|   if (filePass.value) requestHeaders['X-FilePass'] = filePass.value | ||||
|   if (fileExpire.value) requestHeaders['X-FileExpire'] = fileExpire.value.toString() | ||||
|   if (props.bundleId) requestHeaders['X-FileBundle'] = props.bundleId | ||||
|   const upload = new tus.Upload(file.file, { | ||||
|     endpoint: '/api/tus', | ||||
|     retryDelays: [0, 3000, 5000, 10000, 20000], | ||||
|   | ||||
| @@ -15,6 +15,11 @@ const router = createRouter({ | ||||
|       name: 'files', | ||||
|       component: () => import('../views/files.vue'), | ||||
|     }, | ||||
|     { | ||||
|       path: '/bundles/:bundleId', | ||||
|       name: 'bundleDetails', | ||||
|       component: () => import('../views/bundles.vue'), | ||||
|     }, | ||||
|     { | ||||
|       path: '/dashboard', | ||||
|       name: 'dashboard', | ||||
| @@ -78,4 +83,4 @@ router.beforeEach(async (to, from, next) => { | ||||
|   } | ||||
| }) | ||||
|  | ||||
| export default router | ||||
| export default router | ||||
|   | ||||
| @@ -19,7 +19,7 @@ export const useServicesStore = defineStore('services', () => { | ||||
|   } | ||||
|  | ||||
|   function getSerivceUrl(serviceName: string, ...parts: string[]): string | null { | ||||
|     let baseUrl = services.value[serviceName] || null | ||||
|     const baseUrl = services.value[serviceName] || null | ||||
|     return baseUrl ? `${baseUrl}/${parts.join('/')}` : null | ||||
|   } | ||||
|  | ||||
|   | ||||
							
								
								
									
										142
									
								
								DysonNetwork.Drive/Client/src/views/bundles.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										142
									
								
								DysonNetwork.Drive/Client/src/views/bundles.vue
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,142 @@ | ||||
| <template> | ||||
|   <section class="min-h-full relative flex items-center justify-center"> | ||||
|     <n-spin v-if="!bundleInfo && !error" /> | ||||
|     <n-result | ||||
|       status="404" | ||||
|       title="No bundle was found" | ||||
|       :description="error" | ||||
|       v-else-if="error === '404'" | ||||
|     /> | ||||
|     <n-card class="max-w-md my-4 mx-8" v-else-if="error === '403'"> | ||||
|       <n-result | ||||
|         status="403" | ||||
|         title="Access Denied" | ||||
|         description="This bundle is protected by a passcode" | ||||
|         class="mt-5 mb-2" | ||||
|       > | ||||
|         <template #footer> | ||||
|           <n-alert v-if="passcodeError" type="error" class="mb-3"> | ||||
|             {{ passcodeError }} | ||||
|           </n-alert> | ||||
|           <n-input | ||||
|             v-model:value="passcode" | ||||
|             type="password" | ||||
|             show-password-on="mousedown" | ||||
|             placeholder="Passcode" | ||||
|             @keyup.enter="fetchBundleInfo" | ||||
|             class="mb-3" | ||||
|           /> | ||||
|           <n-button type="primary" block @click="fetchBundleInfo">Access Bundle</n-button> | ||||
|         </template> | ||||
|       </n-result> | ||||
|     </n-card> | ||||
|     <n-card class="max-w-4xl my-4 mx-8" v-else> | ||||
|       <n-grid cols="1 m:2" x-gap="16" y-gap="16" responsive="screen"> | ||||
|         <n-gi> | ||||
|           <n-card title="Content" size="small"> | ||||
|             <n-list size="small" v-if="bundleInfo.files && bundleInfo.files.length > 0" no-padding> | ||||
|               <n-list-item v-for="file in bundleInfo.files" :key="file.id"> | ||||
|                 <n-thing :title="file.name" :description="formatBytes(file.size)"> | ||||
|                   <template #header-extra> | ||||
|                     <n-button text type="primary" @click="goToFileDetails(file.id)">View</n-button> | ||||
|                   </template> | ||||
|                 </n-thing> | ||||
|               </n-list-item> | ||||
|             </n-list> | ||||
|             <n-empty v-else description="No files in this bundle" /> | ||||
|           </n-card> | ||||
|         </n-gi> | ||||
|  | ||||
|         <n-gi> | ||||
|           <n-card size="small"> | ||||
|             <h3 class="text-lg">{{ bundleInfo.name }}</h3> | ||||
|             <p class="mb-3" v-if="bundleInfo.description">{{ bundleInfo.description }}</p> | ||||
|             <div class="flex gap-2"> | ||||
|               <span class="flex-grow-1 flex items-center gap-2"> | ||||
|                 <n-icon> | ||||
|                   <calendar-today-round /> | ||||
|                 </n-icon> | ||||
|                 Expires At | ||||
|               </span> | ||||
|               <span>{{ | ||||
|                 bundleInfo.expiredAt ? new Date(bundleInfo.expiredAt).toLocaleString() : 'Never' | ||||
|               }}</span> | ||||
|             </div> | ||||
|             <div class="flex gap-2"> | ||||
|               <span class="flex-grow-1 flex items-center gap-2"> | ||||
|                 <n-icon> | ||||
|                   <lock-round /> | ||||
|                 </n-icon> | ||||
|                 Passcode Protected | ||||
|               </span> | ||||
|               <span>{{ bundleInfo.passcode ? 'Yes' : 'No' }}</span> | ||||
|             </div> | ||||
|           </n-card> | ||||
|         </n-gi> | ||||
|       </n-grid> | ||||
|     </n-card> | ||||
|   </section> | ||||
| </template> | ||||
|  | ||||
| <script setup lang="ts"> | ||||
| import { | ||||
|   NCard, | ||||
|   NResult, | ||||
|   NSpin, | ||||
|   NIcon, | ||||
|   NGrid, | ||||
|   NGi, | ||||
|   NList, | ||||
|   NListItem, | ||||
|   NThing, | ||||
|   NButton, | ||||
|   NEmpty, | ||||
|   NInput, | ||||
|   NAlert, | ||||
| } from 'naive-ui' | ||||
| import { CalendarTodayRound, LockRound } from '@vicons/material' | ||||
| import { useRoute, useRouter } from 'vue-router' | ||||
| import { onMounted, ref } from 'vue' | ||||
|  | ||||
| import { formatBytes } from './format' // Assuming format.ts is in the same directory | ||||
|  | ||||
| const route = useRoute() | ||||
| const router = useRouter() | ||||
|  | ||||
| const error = ref<string | null>(null) | ||||
| const bundleId = route.params.bundleId | ||||
| const passcode = ref<string>('') | ||||
| const passcodeError = ref<string | null>(null) | ||||
|  | ||||
| const bundleInfo = ref<any>(null) | ||||
| async function fetchBundleInfo() { | ||||
|   try { | ||||
|     let url = '/api/bundles/' + bundleId | ||||
|     if (passcode.value) { | ||||
|       url += `?passcode=${passcode.value}` | ||||
|     } | ||||
|     const resp = await fetch(url) | ||||
|     if (resp.status === 403) { | ||||
|       error.value = '403' | ||||
|       bundleInfo.value = null | ||||
|       if (passcode.value) { | ||||
|         passcodeError.value = 'Incorrect passcode.' | ||||
|       } | ||||
|       return | ||||
|     } | ||||
|     if (!resp.ok) { | ||||
|       throw new Error('Failed to fetch bundle info: ' + resp.statusText) | ||||
|     } | ||||
|     bundleInfo.value = await resp.json() | ||||
|     error.value = null | ||||
|     passcodeError.value = null | ||||
|   } catch (err) { | ||||
|     error.value = (err as Error).message | ||||
|   } | ||||
| } | ||||
| onMounted(() => fetchBundleInfo()) | ||||
|  | ||||
| function goToFileDetails(fileId: string) { | ||||
|   router.push({ path: `/files/${fileId}`, query: { passcode: passcode.value } }) | ||||
| } | ||||
| </script> | ||||
| @@ -50,17 +50,12 @@ const tableColumns: DataTableColumns<any> = [ | ||||
|         }, | ||||
|       ) | ||||
|     }, | ||||
|     maxWidth: 80, | ||||
|   }, | ||||
|   { | ||||
|     title: 'Description', | ||||
|     key: 'description', | ||||
|   }, | ||||
|   { | ||||
|     title: 'Files', | ||||
|     key: 'files', | ||||
|     render(row: any) { | ||||
|       return row.files.length | ||||
|     }, | ||||
|     maxWidth: 180, | ||||
|   }, | ||||
|   { | ||||
|     title: 'Expired At', | ||||
|   | ||||
| @@ -99,6 +99,7 @@ const tableColumns: DataTableColumns<any> = [ | ||||
|   { | ||||
|     title: 'Name', | ||||
|     key: 'name', | ||||
|     maxWidth: 180, | ||||
|     render(row: any) { | ||||
|       return h( | ||||
|         NButton, | ||||
|   | ||||
| @@ -165,6 +165,7 @@ const error = ref<string | null>(null) | ||||
|  | ||||
| const filePass = ref<string>('') | ||||
| const fileId = route.params.fileId | ||||
| const passcode = route.query.passcode as string | undefined | ||||
|  | ||||
| const progress = ref<number | undefined>(0) | ||||
|  | ||||
| @@ -177,7 +178,11 @@ const currentUrl = window.location.href | ||||
| const fileInfo = ref<any>(null) | ||||
| async function fetchFileInfo() { | ||||
|   try { | ||||
|     const resp = await fetch('/api/files/' + fileId + '/info') | ||||
|     let url = '/api/files/' + fileId + '/info' | ||||
|     if (passcode) { | ||||
|       url += `?passcode=${passcode}` | ||||
|     } | ||||
|     const resp = await fetch(url) | ||||
|     if (!resp.ok) { | ||||
|       throw new Error('Failed to fetch file info: ' + resp.statusText) | ||||
|     } | ||||
| @@ -192,7 +197,13 @@ const fileType = computed(() => { | ||||
|   if (!fileInfo.value) return 'unknown' | ||||
|   return fileInfo.value.mime_type?.split('/')[0] || 'unknown' | ||||
| }) | ||||
| const fileSource = computed(() => `/api/files/${fileId}`) | ||||
| const fileSource = computed(() => { | ||||
|   let url = `/api/files/${fileId}` | ||||
|   if (passcode) { | ||||
|     url += `?passcode=${passcode}` | ||||
|   } | ||||
|   return url | ||||
| }) | ||||
|  | ||||
| function downloadFile() { | ||||
|   if (fileInfo.value.is_encrypted && !filePass.value) { | ||||
|   | ||||
| @@ -1,133 +1,88 @@ | ||||
| <template> | ||||
|   <section class="h-full relative flex items-center justify-center"> | ||||
|   <section class="h-full relative flex flex-col items-center justify-center"> | ||||
|     <n-card class="max-w-lg my-4 mx-8" title="About" v-if="!userStore.user"> | ||||
|       <p>Welcome to the <b>Solar Drive</b></p> | ||||
|       <p>We help you upload, collect, and share files with ease in mind.</p> | ||||
|       <p>To continue, login first.</p> | ||||
|  | ||||
|       <p class="mt-4 opacity-75 text-xs"> | ||||
|         <span v-if="version == null">Loading...</span> | ||||
|         <span v-else> | ||||
|           v{{ version.version }} @ | ||||
|           {{ version.commit.substring(0, 6) }} | ||||
|           {{ version.updatedAt }} | ||||
|         </span> | ||||
|       </p> | ||||
|     </n-card> | ||||
|     <n-card class="max-w-2xl" title="Upload to Solar Network" v-else> | ||||
|       <template #header-extra> | ||||
|         <div class="flex gap-2 items-center"> | ||||
|           <p>Advance Mode</p> | ||||
|           <n-switch v-model:value="modeAdvanced" size="small" /> | ||||
|         </div> | ||||
|       </template> | ||||
|  | ||||
|       <n-collapse-transition :show="showRecycleHint"> | ||||
|         <n-alert size="small" type="warning" title="Recycle Enabled" class="mb-3"> | ||||
|           You're uploading to a pool which enabled recycle. If the file you uploaded didn't | ||||
|           referenced from the Solar Network. It will be marked and will be deleted some while later. | ||||
|         </n-alert> | ||||
|       </n-collapse-transition> | ||||
|  | ||||
|       <div class="mb-3"> | ||||
|         <file-pool-select v-model="filePool" @update:pool="currentFilePool = $event" /> | ||||
|       </div> | ||||
|  | ||||
|       <n-collapse-transition :show="modeAdvanced"> | ||||
|         <n-card title="Advance Options" size="small" class="mb-3"> | ||||
|           <div class="flex flex-col gap-3"> | ||||
|             <div> | ||||
|               <p class="pl-1 mb-0.5">File Password</p> | ||||
|               <n-input | ||||
|                 v-model:value="filePass" | ||||
|                 :disabled="!currentFilePool?.allow_encryption" | ||||
|                 placeholder="Enter password to protect the file" | ||||
|                 show-password-toggle | ||||
|                 size="large" | ||||
|                 type="password" | ||||
|                 class="mb-2" | ||||
|               /> | ||||
|               <p class="pl-1 text-xs opacity-75 mt-[-4px]"> | ||||
|                 Only available for Stellar Program and certian file pool. | ||||
|               </p> | ||||
|             </div> | ||||
|             <div> | ||||
|               <p class="pl-1 mb-0.5">File Expiration Date</p> | ||||
|               <n-date-picker | ||||
|                 v-model:value="fileExpire" | ||||
|                 type="datetime" | ||||
|                 clearable | ||||
|                 :is-date-disabled="disablePreviousDate" | ||||
|               /> | ||||
|             </div> | ||||
|     <n-card class="max-w-2xl" v-else content-style="padding: 0;"> | ||||
|       <n-tabs type="line" animated :tabs-padding="20" pane-style="padding: 20px"> | ||||
|         <template #suffix> | ||||
|           <div class="flex gap-2 items-center me-4"> | ||||
|             <p>Advance Mode</p> | ||||
|             <n-switch v-model:value="modeAdvanced" size="small" /> | ||||
|           </div> | ||||
|         </n-card> | ||||
|       </n-collapse-transition> | ||||
|         </template> | ||||
|  | ||||
|       <n-upload | ||||
|         multiple | ||||
|         directory-dnd | ||||
|         with-credentials | ||||
|         show-preview-button | ||||
|         list-type="image" | ||||
|         show-download-button | ||||
|         :custom-request="customRequest" | ||||
|         :custom-download="customDownload" | ||||
|         :create-thumbnail-url="createThumbnailUrl" | ||||
|         @preview="customPreview" | ||||
|       > | ||||
|         <n-upload-dragger> | ||||
|           <div style="margin-bottom: 12px"> | ||||
|             <n-icon size="48" :depth="3"> | ||||
|               <cloud-upload-round /> | ||||
|             </n-icon> | ||||
|         <n-tab-pane name="direct" tab="Direct Upload" :disabled="isBundleMode"> | ||||
|           <div class="mb-3"> | ||||
|             <file-pool-select v-model="filePool" @update:pool="currentFilePool = $event" /> | ||||
|           </div> | ||||
|           <upload-area | ||||
|             :filePool="filePool" | ||||
|             :pools="pools as SnFilePool[]" | ||||
|             :modeAdvanced="modeAdvanced" | ||||
|           /> | ||||
|         </n-tab-pane> | ||||
|         <n-tab-pane name="bundle" tab="Bundle Upload"> | ||||
|           <div class="mb-3"> | ||||
|             <bundle-select v-model:bundle="selectedBundleId" :disabled="isBundleMode" /> | ||||
|           </div> | ||||
|           <n-text style="font-size: 16px"> Click or drag a file to this area to upload </n-text> | ||||
|           <n-p depth="3" style="margin: 8px 0 0 0"> | ||||
|             Strictly prohibit from uploading sensitive information. For example, your bank card PIN | ||||
|             or your credit card expiry date. | ||||
|           </n-p> | ||||
|         </n-upload-dragger> | ||||
|       </n-upload> | ||||
|  | ||||
|       <p class="mt-4 opacity-75 text-xs"> | ||||
|         <span v-if="version == null">Loading...</span> | ||||
|         <span v-else> | ||||
|           v{{ version.version }} @ | ||||
|           {{ version.commit.substring(0, 6) }} | ||||
|           {{ version.updatedAt }} | ||||
|         </span> | ||||
|       </p> | ||||
|           <n-modal v-model:show="showCreateBundleModal" preset="dialog" title="Create New Bundle"> | ||||
|             <bundle-form ref="bundleFormRef" :value="newBundle" /> | ||||
|             <template #action> | ||||
|               <n-button @click="showCreateBundleModal = false">Cancel</n-button> | ||||
|               <n-button type="primary" @click="createBundle">Create</n-button> | ||||
|             </template> | ||||
|           </n-modal> | ||||
|  | ||||
|           <div class="flex justify-between"> | ||||
|             <n-button @click="showCreateBundleModal = true" class="mb-3" :disabled="isBundleMode"> | ||||
|               Create New Bundle | ||||
|             </n-button> | ||||
|             <n-button | ||||
|               type="primary" | ||||
|               :disabled="!selectedBundleId && !newBundleId && !isBundleMode" | ||||
|               @click="isBundleMode ? cancelBundleUpload() : proceedToBundleUpload()" | ||||
|             > | ||||
|               {{ isBundleMode ? 'Cancel' : 'Proceed to Upload' }} | ||||
|             </n-button> | ||||
|           </div> | ||||
|  | ||||
|           <div v-if="bundleUploadMode" class="mt-3"> | ||||
|             <upload-area | ||||
|               :filePool="filePool" | ||||
|               :pools="pools as SnFilePool[]" | ||||
|               :modeAdvanced="modeAdvanced" | ||||
|               :bundleId="currentBundleId!" | ||||
|             /> | ||||
|           </div> | ||||
|         </n-tab-pane> | ||||
|       </n-tabs> | ||||
|     </n-card> | ||||
|  | ||||
|     <p class="mt-4 opacity-75 text-xs"> | ||||
|       <span v-if="version == null">Loading...</span> | ||||
|       <span v-else> | ||||
|         v{{ version.version }} @ | ||||
|         {{ version.commit.substring(0, 6) }} | ||||
|         {{ version.updatedAt }} | ||||
|       </span> | ||||
|     </p> | ||||
|   </section> | ||||
| </template> | ||||
|  | ||||
| <script setup lang="ts"> | ||||
| import { | ||||
|   NCard, | ||||
|   NUpload, | ||||
|   NUploadDragger, | ||||
|   NIcon, | ||||
|   NText, | ||||
|   NP, | ||||
|   NInput, | ||||
|   NSwitch, | ||||
|   NCollapseTransition, | ||||
|   NDatePicker, | ||||
|   NAlert, | ||||
|   type UploadCustomRequestOptions, | ||||
|   type UploadSettledFileInfo, | ||||
|   type UploadFileInfo, | ||||
|   useMessage, | ||||
| } from 'naive-ui' | ||||
| import { NCard, NSwitch, NTabs, NTabPane, NButton, NModal } from 'naive-ui' | ||||
| import { computed, onMounted, ref } from 'vue' | ||||
| import { CloudUploadRound } from '@vicons/material' | ||||
| import { useUserStore } from '@/stores/user' | ||||
| import type { SnFilePool } from '@/types/pool' | ||||
|  | ||||
| import FilePoolSelect from '@/components/FilePoolSelect.vue' | ||||
|  | ||||
| import * as tus from 'tus-js-client' | ||||
| import UploadArea from '@/components/UploadArea.vue' | ||||
| import BundleSelect from '@/components/BundleSelect.vue' | ||||
| import BundleForm from '@/components/form/BundleForm.vue' | ||||
|  | ||||
| const userStore = useUserStore() | ||||
|  | ||||
| @@ -150,105 +105,57 @@ onMounted(() => fetchPools()) | ||||
| const modeAdvanced = ref(false) | ||||
|  | ||||
| const filePool = ref<string | null>(null) | ||||
| const filePass = ref<string>('') | ||||
| const fileExpire = ref<number | null>(null) | ||||
|  | ||||
| const currentFilePool = computed(() => { | ||||
|   if (!filePool.value) return null | ||||
|   return pools.value?.find((pool) => pool.id === filePool.value) ?? null | ||||
| }) | ||||
| const showRecycleHint = computed(() => { | ||||
|   if (!filePool.value) return true | ||||
|   return currentFilePool.value.policy_config?.enable_recycle || false | ||||
| }) | ||||
|  | ||||
| const messageDisplay = useMessage() | ||||
| const bundles = ref<any>([]) | ||||
| const selectedBundleId = ref<string | null>(null) | ||||
| const showCreateBundleModal = ref(false) | ||||
| const newBundle = ref<any>({}) | ||||
| const bundleFormRef = ref<any>(null) | ||||
| const bundleUploadMode = ref(false) | ||||
| const currentBundleId = ref<string | null>(null) | ||||
| const newBundleId = ref<string | null>(null) | ||||
| const isBundleMode = ref(false) | ||||
|  | ||||
| function customRequest({ | ||||
|   file, | ||||
|   headers, | ||||
|   withCredentials, | ||||
|   onFinish, | ||||
|   onError, | ||||
|   onProgress, | ||||
| }: UploadCustomRequestOptions) { | ||||
|   const requestHeaders: Record<string, string> = {} | ||||
|   if (filePool.value) requestHeaders['X-FilePool'] = filePool.value | ||||
|   if (filePass.value) requestHeaders['X-FilePass'] = filePass.value | ||||
|   if (fileExpire.value) requestHeaders['X-FileExpire'] = fileExpire.value.toString() | ||||
|   const upload = new tus.Upload(file.file, { | ||||
|     endpoint: '/api/tus', | ||||
|     retryDelays: [0, 3000, 5000, 10000, 20000], | ||||
|     removeFingerprintOnSuccess: false, | ||||
|     uploadDataDuringCreation: false, | ||||
|     metadata: { | ||||
|       filename: file.name, | ||||
|       'content-type': file.type ?? 'application/octet-stream', | ||||
|     }, | ||||
|     headers: { | ||||
|       'X-DirectUpload': 'true', | ||||
|       ...requestHeaders, | ||||
|       ...headers, | ||||
|     }, | ||||
|     onShouldRetry: () => false, | ||||
|     onError: function (error) { | ||||
|       if (error instanceof tus.DetailedError) { | ||||
|         const failedBody = error.originalResponse?.getBody() | ||||
|         if (failedBody != null) | ||||
|           messageDisplay.error(`Upload failed: ${failedBody}`, { | ||||
|             duration: 10000, | ||||
|             closable: true, | ||||
|           }) | ||||
|       } | ||||
|       console.error('[DRIVE] Upload failed:', error) | ||||
|       onError() | ||||
|     }, | ||||
|     onProgress: function (bytesUploaded, bytesTotal) { | ||||
|       onProgress({ percent: (bytesUploaded / bytesTotal) * 100 }) | ||||
|     }, | ||||
|     onSuccess: function (payload) { | ||||
|       const rawInfo = payload.lastResponse.getHeader('x-fileinfo') | ||||
|       const jsonInfo = JSON.parse(rawInfo as string) | ||||
|       console.log('[DRIVE] Upload successful: ', jsonInfo) | ||||
|       file.url = `/api/files/${jsonInfo.id}` | ||||
|       file.type = jsonInfo.mime_type | ||||
|       onFinish() | ||||
|     }, | ||||
|     onBeforeRequest: function (req) { | ||||
|       const xhr = req.getUnderlyingObject() | ||||
|       xhr.withCredentials = withCredentials | ||||
|     }, | ||||
|   }) | ||||
|   upload.findPreviousUploads().then(function (previousUploads) { | ||||
|     if (previousUploads.length) { | ||||
|       upload.resumeFromPreviousUpload(previousUploads[0]) | ||||
| async function createBundle() { | ||||
|   try { | ||||
|     await bundleFormRef.value?.formRef?.validate() | ||||
|     const resp = await fetch('/api/bundles', { | ||||
|       method: 'POST', | ||||
|       headers: { | ||||
|         'Content-Type': 'application/json', | ||||
|       }, | ||||
|       body: JSON.stringify(newBundle.value), | ||||
|     }) | ||||
|     if (!resp.ok) { | ||||
|       throw new Error('Failed to create bundle') | ||||
|     } | ||||
|     upload.start() | ||||
|   }) | ||||
|     const createdBundle = await resp.json() | ||||
|     bundles.value.push(createdBundle) | ||||
|     selectedBundleId.value = createdBundle.id | ||||
|     newBundleId.value = createdBundle.id | ||||
|     showCreateBundleModal.value = false | ||||
|     newBundle.value = {} | ||||
|   } catch (error) { | ||||
|     console.error('Failed to create bundle:', error) | ||||
|   } | ||||
| } | ||||
|  | ||||
| function createThumbnailUrl( | ||||
|   _file: File | null, | ||||
|   fileInfo: UploadSettledFileInfo, | ||||
| ): string | undefined { | ||||
|   if (!fileInfo) return undefined | ||||
|   return fileInfo.url ?? undefined | ||||
| function proceedToBundleUpload() { | ||||
|   currentBundleId.value = selectedBundleId.value || newBundleId.value | ||||
|   bundleUploadMode.value = true | ||||
|   isBundleMode.value = true | ||||
| } | ||||
|  | ||||
| function customDownload(file: UploadFileInfo) { | ||||
|   const { url } = file | ||||
|   if (!url) return | ||||
|   window.open(url.replace('/api', ''), '_blank') | ||||
| } | ||||
|  | ||||
| function customPreview(file: UploadFileInfo, detail: { event: MouseEvent }) { | ||||
|   detail.event.preventDefault() | ||||
|   const { url } = file | ||||
|   if (!url) return | ||||
|   window.open(url.replace('/api', ''), '_blank') | ||||
| } | ||||
|  | ||||
| function disablePreviousDate(ts: number) { | ||||
|   return ts <= Date.now() | ||||
| function cancelBundleUpload() { | ||||
|   bundleUploadMode.value = false | ||||
|   isBundleMode.value = false | ||||
|   currentBundleId.value = null | ||||
|   selectedBundleId.value = null | ||||
|   newBundleId.value = null | ||||
| } | ||||
| </script> | ||||
|   | ||||
		Reference in New Issue
	
	Block a user