diff --git a/components.d.ts b/components.d.ts index 2f9ecd3..18b48ac 100644 --- a/components.d.ts +++ b/components.d.ts @@ -16,6 +16,7 @@ declare module 'vue' { AlertTriangleIcon: typeof import('./src/components/icons/AlertTriangleIcon.vue')['default'] ArrowDownTray: typeof import('./src/components/icons/ArrowDownTray.vue')['default'] ArrowRightIcon: typeof import('./src/components/icons/ArrowRightIcon.vue')['default'] + Avatar: typeof import('primevue/avatar')['default'] Bell: typeof import('./src/components/icons/Bell.vue')['default'] Button: typeof import('primevue/button')['default'] Chart: typeof import('./src/components/icons/Chart.vue')['default'] @@ -28,6 +29,7 @@ declare module 'vue' { CreditCardIcon: typeof import('./src/components/icons/CreditCardIcon.vue')['default'] DashboardLayout: typeof import('./src/components/DashboardLayout.vue')['default'] DashboardNav: typeof import('./src/components/DashboardNav.vue')['default'] + Dialog: typeof import('primevue/dialog')['default'] EllipsisVerticalIcon: typeof import('./src/components/icons/EllipsisVerticalIcon.vue')['default'] EmptyState: typeof import('./src/components/dashboard/EmptyState.vue')['default'] FloatLabel: typeof import('primevue/floatlabel')['default'] @@ -47,6 +49,7 @@ declare module 'vue' { PanelLeft: typeof import('./src/components/icons/PanelLeft.vue')['default'] Password: typeof import('primevue/password')['default'] PencilIcon: typeof import('./src/components/icons/PencilIcon.vue')['default'] + Popover: typeof import('primevue/popover')['default'] RootLayout: typeof import('./src/components/RootLayout.vue')['default'] RouterLink: typeof import('vue-router')['RouterLink'] RouterView: typeof import('vue-router')['RouterView'] @@ -71,6 +74,7 @@ declare global { const AlertTriangleIcon: typeof import('./src/components/icons/AlertTriangleIcon.vue')['default'] const ArrowDownTray: typeof import('./src/components/icons/ArrowDownTray.vue')['default'] const ArrowRightIcon: typeof import('./src/components/icons/ArrowRightIcon.vue')['default'] + const Avatar: typeof import('primevue/avatar')['default'] const Bell: typeof import('./src/components/icons/Bell.vue')['default'] const Button: typeof import('primevue/button')['default'] const Chart: typeof import('./src/components/icons/Chart.vue')['default'] @@ -83,6 +87,7 @@ declare global { const CreditCardIcon: typeof import('./src/components/icons/CreditCardIcon.vue')['default'] const DashboardLayout: typeof import('./src/components/DashboardLayout.vue')['default'] const DashboardNav: typeof import('./src/components/DashboardNav.vue')['default'] + const Dialog: typeof import('primevue/dialog')['default'] const EllipsisVerticalIcon: typeof import('./src/components/icons/EllipsisVerticalIcon.vue')['default'] const EmptyState: typeof import('./src/components/dashboard/EmptyState.vue')['default'] const FloatLabel: typeof import('primevue/floatlabel')['default'] @@ -102,6 +107,7 @@ declare global { const PanelLeft: typeof import('./src/components/icons/PanelLeft.vue')['default'] const Password: typeof import('primevue/password')['default'] const PencilIcon: typeof import('./src/components/icons/PencilIcon.vue')['default'] + const Popover: typeof import('primevue/popover')['default'] const RootLayout: typeof import('./src/components/RootLayout.vue')['default'] const RouterLink: typeof import('vue-router')['RouterLink'] const RouterView: typeof import('vue-router')['RouterView'] diff --git a/src/components/DashboardLayout.vue b/src/components/DashboardLayout.vue index 56ffcf5..fb77edc 100644 --- a/src/components/DashboardLayout.vue +++ b/src/components/DashboardLayout.vue @@ -1,6 +1,7 @@ @@ -20,5 +21,6 @@ import GlobalUploadIndicator from "./GlobalUploadIndicator.vue"; + diff --git a/src/components/DashboardNav.vue b/src/components/DashboardNav.vue index 6f4bb4b..a85e9a6 100644 --- a/src/components/DashboardNav.vue +++ b/src/components/DashboardNav.vue @@ -3,7 +3,7 @@ import Bell from "@/components/icons/Bell.vue"; import Home from "@/components/icons/Home.vue"; import Video from "@/components/icons/Video.vue"; import Credit from "@/components/icons/Credit.vue"; -import Upload from "@/components/icons/Upload.vue"; +// import Upload from "@/components/icons/Upload.vue"; import NotificationDrawer from "./NotificationDrawer.vue"; import { cn } from "@/lib/utils"; import { createStaticVNode, ref } from "vue"; @@ -24,7 +24,7 @@ const handleNotificationClick = (event: Event) => { const links = [ { href: "/#home", label: "app", icon: homeHoist, type: "btn", className }, { href: "/", label: "Overview", icon: Home, type: "a", className }, - { href: "/upload", label: "Upload", icon: Upload, type: "a", className }, + // { href: "/upload", label: "Upload", icon: Upload, type: "a", className }, { href: "/video", label: "Video", icon: Video, type: "a", className }, { href: "/payments-and-plans", label: "Payments & Plans", icon: Credit, type: "a", className }, { href: "/notification", label: "Notification", icon: Bell, type: "btn", className, action: handleNotificationClick, isActive: isNotificationOpen }, diff --git a/src/components/GlobalUploadIndicator.vue b/src/components/GlobalUploadIndicator.vue index 0fb2ddd..db74857 100644 --- a/src/components/GlobalUploadIndicator.vue +++ b/src/components/GlobalUploadIndicator.vue @@ -1,103 +1,134 @@ diff --git a/src/composables/useUploadQueue.ts b/src/composables/useUploadQueue.ts index e504a84..337333f 100644 --- a/src/composables/useUploadQueue.ts +++ b/src/composables/useUploadQueue.ts @@ -1,4 +1,5 @@ import { computed, ref } from 'vue'; +import { size } from 'zod'; export interface QueueItem { id: string; @@ -20,15 +21,32 @@ export interface QueueItem { const items = ref([]); +// Upload limits +const MAX_ITEMS = 5; + // Chunk upload configuration const CHUNK_SIZE = 90 * 1024 * 1024; // 90MB per chunk const MAX_PARALLEL = 3; const MAX_RETRY = 3; +// Track active XHRs per item id so we can abort them on cancel +const activeXhrs = new Map>(); + +const abortItem = (id: string) => { + const xhrs = activeXhrs.get(id); + if (xhrs) { + xhrs.forEach(xhr => xhr.abort()); + activeXhrs.delete(id); + } +}; + export function useUploadQueue() { - + + const remainingSlots = computed(() => Math.max(0, MAX_ITEMS - items.value.length)); + const addFiles = (files: FileList) => { - const newItems: QueueItem[] = Array.from(files).map((file) => ({ + const allowed = Array.from(files).slice(0, remainingSlots.value); + const newItems: QueueItem[] = allowed.map((file) => ({ id: Math.random().toString(36).substring(2, 9), name: file.name, type: 'local', @@ -45,10 +63,12 @@ export function useUploadQueue() { })); items.value.push(...newItems); + return { added: newItems.length, skipped: files.length - newItems.length }; }; const addRemoteUrls = (urls: string[]) => { - const newItems: QueueItem[] = urls.map((url) => ({ + const allowed = urls.slice(0, remainingSlots.value); + const newItems: QueueItem[] = allowed.map((url) => ({ id: Math.random().toString(36).substring(2, 9), name: url.split('/').pop() || 'Remote File', type: 'remote', @@ -64,27 +84,28 @@ export function useUploadQueue() { })); items.value.push(...newItems); + return { added: newItems.length, skipped: urls.length - newItems.length }; }; const removeItem = (id: string) => { + abortItem(id); const item = items.value.find(i => i.id === id); - if (item) { - item.cancelled = true; - } + if (item) item.cancelled = true; const index = items.value.findIndex(item => item.id === id); - if (index !== -1) { - items.value.splice(index, 1); - } + if (index !== -1) items.value.splice(index, 1); }; - + const cancelItem = (id: string) => { + abortItem(id); const item = items.value.find(i => i.id === id); if (item) { item.cancelled = true; item.status = 'error'; + item.activeChunks = 0; + item.speed = '0 MB/s'; } }; - + const startQueue = () => { items.value.forEach(item => { if (item.status === 'pending') { @@ -105,12 +126,12 @@ export function useUploadQueue() { item.status = 'uploading'; item.activeChunks = 0; item.uploadedUrls = []; - + const file = item.file; const totalChunks = Math.ceil(file.size / CHUNK_SIZE); const progressMap = new Map(); // chunk index -> uploaded bytes const queue: number[] = Array.from({ length: totalChunks }, (_, i) => i); - + const updateProgress = () => { let totalUploaded = 0; progressMap.forEach(value => { @@ -119,7 +140,7 @@ export function useUploadQueue() { const percent = Math.min((totalUploaded / file.size) * 100, 100); item.progress = parseFloat(percent.toFixed(1)); item.uploaded = formatSize(totalUploaded); - + // Calculate speed (simplified) const currentSpeed = item.activeChunks ? item.activeChunks * 2 * 1024 * 1024 : 0; item.speed = formatSize(currentSpeed) + '/s'; @@ -133,7 +154,7 @@ export function useUploadQueue() { while ((item.activeChunks || 0) < MAX_PARALLEL && queue.length > 0) { const index = queue.shift()!; item.activeChunks = (item.activeChunks || 0) + 1; - + const promise = uploadChunk(index, file, progressMap, updateProgress, item) .then(() => { item.activeChunks = (item.activeChunks || 0) - 1; @@ -149,7 +170,7 @@ export function useUploadQueue() { try { await processQueue(); - + if (!item.cancelled) { item.status = 'processing'; await completeUpload(item); @@ -183,6 +204,12 @@ export function useUploadQueue() { const xhr = new XMLHttpRequest(); xhr.open('POST', 'https://tmpfiles.org/api/v1/upload'); + // Register this XHR so it can be aborted on cancel + if (!activeXhrs.has(item.id)) activeXhrs.set(item.id, new Set()); + activeXhrs.get(item.id)!.add(xhr); + + const unregister = () => activeXhrs.get(item.id)?.delete(xhr); + xhr.upload.onprogress = (e) => { if (e.lengthComputable) { progressMap.set(index, e.loaded); @@ -190,7 +217,9 @@ export function useUploadQueue() { } }; - xhr.onload = function() { + xhr.onload = function () { + unregister(); + if (item.cancelled) return resolve(); if (xhr.status === 200) { try { const res = JSON.parse(xhr.responseText); @@ -210,7 +239,15 @@ export function useUploadQueue() { handleError(); }; - xhr.onerror = handleError; + xhr.onabort = () => { + unregister(); + resolve(); // treat abort as graceful completion — processQueue will short-circuit via item.cancelled + }; + + xhr.onerror = () => { + unregister(); + handleError(); + }; function handleError() { retry++; @@ -220,7 +257,7 @@ export function useUploadQueue() { item.status = 'error'; reject(new Error(`Failed to upload chunk ${index + 1}`)); } - }; + } xhr.send(formData); }; @@ -238,12 +275,13 @@ export function useUploadQueue() { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ filename: item.file.name, - chunks: item.uploadedUrls + chunks: item.uploadedUrls, + size: item.file.size }) }); const data = await response.json(); - + if (!response.ok) { throw new Error(data.error || 'Merge failed'); } @@ -260,15 +298,15 @@ export function useUploadQueue() { // Mock Remote Fetch Logic const startMockRemoteFetch = (id: string) => { - const item = items.value.find(i => i.id === id); + const item = items.value.find(i => i.id === id); if (!item) return; - + item.status = 'fetching'; - setTimeout(() => { - item.status = 'complete'; - item.progress = 100; - }, 3000 + Math.random() * 3000); + setTimeout(() => { + item.status = 'complete'; + item.progress = 100; + }, 3000 + Math.random() * 3000); }; @@ -295,16 +333,29 @@ export function useUploadQueue() { const pendingCount = computed(() => { return items.value.filter(i => i.status === 'pending').length; }); - + function removeAll() { + items.value = []; + } + // watch(items, (newItems) => { + // // console.log(newItems); + // if (newItems.length === 0) return; + // if (newItems.filter(i => i.status === 'pending' || i.status === 'uploading').length === 0) { + // // startQueue(); + // items.value = []; + // } + // }, { deep: true }); return { items, addFiles, addRemoteUrls, removeItem, cancelItem, + removeAll, startQueue, totalSize, completeCount, - pendingCount + pendingCount, + remainingSlots, + maxItems: MAX_ITEMS, }; } diff --git a/src/lib/utils.ts b/src/lib/utils.ts index 44b25bc..aa718c9 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -52,46 +52,45 @@ export function getImageAspectRatio(url: string): Promise { export const formatBytes = (bytes?: number) => { - if (!bytes) return '0 B'; - const k = 1024; - const sizes = ['B', 'KB', 'MB', 'GB', 'TB']; - const i = Math.floor(Math.log(bytes) / Math.log(k)); - return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; + if (!bytes) return '0 B'; + const k = 1024; + const sizes = ['B', 'KB', 'MB', 'GB', 'TB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; }; export const formatDuration = (seconds?: number) => { - if (!seconds) return '0:00'; - const h = Math.floor(seconds / 3600); - const m = Math.floor((seconds % 3600) / 60); - const s = Math.floor(seconds % 60); + if (!seconds) return '0:00'; + const h = Math.floor(seconds / 3600); + const m = Math.floor((seconds % 3600) / 60); + const s = Math.floor(seconds % 60); - if (h > 0) { - return `${h}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`; - } - return `${m}:${s.toString().padStart(2, '0')}`; + if (h > 0) { + return `${h}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`; + } + return `${m}:${s.toString().padStart(2, '0')}`; }; -export const formatDate = (dateString?: string) => { - if (!dateString) return ''; - return new Date(dateString).toLocaleDateString('en-US', { - month: 'short', - day: 'numeric', - year: 'numeric', - hour: '2-digit', - minute: '2-digit' - }); +export const formatDate = (dateString: string = "", dateOnly: boolean = false) => { + if (!dateString) return ''; + return new Date(dateString).toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', + ...(dateOnly ? {} : { hour: '2-digit', minute: '2-digit' }) + }); }; export const getStatusSeverity = (status: string = "") => { - switch (status) { - case 'success': - case 'ready': - return 'success'; - case 'failed': - return 'danger'; - case 'pending': - return 'warn'; - default: - return 'info'; - } + switch (status) { + case 'success': + case 'ready': + return 'success'; + case 'failed': + return 'danger'; + case 'pending': + return 'warn'; + default: + return 'info'; + } }; \ No newline at end of file diff --git a/src/routes/index.ts b/src/routes/index.ts index 1809d4f..f894fe6 100644 --- a/src/routes/index.ts +++ b/src/routes/index.ts @@ -91,16 +91,16 @@ const routes: RouteData[] = [ }, }, }, - { - path: "upload", - name: "upload", - component: () => import("./upload/Upload.vue"), - meta: { - head: { - title: "Upload - Holistream", - }, - }, - }, + // { + // path: "upload", + // name: "upload", + // component: () => import("./upload/Upload.vue"), + // meta: { + // head: { + // title: "Upload - Holistream", + // }, + // }, + // }, { path: "video", children: [ diff --git a/src/routes/upload/Upload.vue b/src/routes/upload/Upload.vue index 3e4f7b6..7b4d967 100644 --- a/src/routes/upload/Upload.vue +++ b/src/routes/upload/Upload.vue @@ -1,56 +1,128 @@ \ No newline at end of file diff --git a/src/routes/upload/components/RemoteUrlForm.vue b/src/routes/upload/components/RemoteUrlForm.vue index 9eeb232..35732a1 100644 --- a/src/routes/upload/components/RemoteUrlForm.vue +++ b/src/routes/upload/components/RemoteUrlForm.vue @@ -1,65 +1,55 @@