develop-updateui #1
6
components.d.ts
vendored
6
components.d.ts
vendored
@@ -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']
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts" setup>
|
||||
import DashboardNav from "./DashboardNav.vue";
|
||||
import GlobalUploadIndicator from "./GlobalUploadIndicator.vue";
|
||||
import Upload from "@/routes/upload/Upload.vue";
|
||||
|
||||
</script>
|
||||
|
||||
@@ -20,5 +21,6 @@ import GlobalUploadIndicator from "./GlobalUploadIndicator.vue";
|
||||
</router-view>
|
||||
</div>
|
||||
<GlobalUploadIndicator />
|
||||
<Upload />
|
||||
</main>
|
||||
</template>
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -1,103 +1,134 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue';
|
||||
import { useUploadQueue } from '@/composables/useUploadQueue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import UploadQueueItem from '@/routes/upload/components/UploadQueueItem.vue';
|
||||
import { useUIState } from '@/stores/uiState';
|
||||
|
||||
const { items, totalSize, completeCount, pendingCount } = useUploadQueue();
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const { items, completeCount, pendingCount, startQueue, removeItem, cancelItem } = useUploadQueue();
|
||||
const uiState = useUIState();
|
||||
|
||||
const isOpen = ref(false);
|
||||
const isCollapsed = ref(false);
|
||||
|
||||
const isVisible = computed(() => {
|
||||
// Show if there are items AND we are NOT on the upload page
|
||||
return items.value.length > 0 && route.path !== '/upload';
|
||||
});
|
||||
const isVisible = computed(() => items.value.length > 0);
|
||||
|
||||
const progress = computed(() => {
|
||||
const overallProgress = computed(() => {
|
||||
if (items.value.length === 0) return 0;
|
||||
const totalProgress = items.value.reduce((acc, item) => acc + (item.progress || 0), 0);
|
||||
return Math.round(totalProgress / items.value.length);
|
||||
const total = items.value.reduce((acc, item) => acc + (item.progress || 0), 0);
|
||||
return Math.round(total / items.value.length);
|
||||
});
|
||||
|
||||
const isUploading = computed(() => {
|
||||
return items.value.some(i => i.status === 'uploading' || i.status === 'fetching');
|
||||
const isUploading = computed(() =>
|
||||
items.value.some(i => i.status === 'uploading' || i.status === 'fetching' || i.status === 'processing')
|
||||
);
|
||||
|
||||
const isAllDone = computed(() =>
|
||||
items.value.length > 0 && items.value.every(i => i.status === 'complete' || i.status === 'error')
|
||||
);
|
||||
|
||||
const statusText = computed(() => {
|
||||
if (isAllDone.value) return 'All done';
|
||||
if (isUploading.value) {
|
||||
const count = items.value.filter(i => i.status === 'uploading' || i.status === 'fetching').length;
|
||||
return `Uploading ${count} file${count !== 1 ? 's' : ''}...`;
|
||||
}
|
||||
if (pendingCount.value > 0) return `${pendingCount.value} file${pendingCount.value !== 1 ? 's' : ''} waiting`;
|
||||
return 'Processing...';
|
||||
});
|
||||
|
||||
const toggleOpen = () => {
|
||||
isOpen.value = !isOpen.value;
|
||||
};
|
||||
|
||||
const goToUploadPage = () => {
|
||||
router.push('/upload');
|
||||
isOpen.value = false;
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="isVisible" class="fixed bottom-6 right-6 z-50 flex flex-col items-end gap-2">
|
||||
<Transition enter-active-class="transition-all duration-300 ease-out"
|
||||
enter-from-class="opacity-0 translate-y-4" enter-to-class="opacity-100 translate-y-0"
|
||||
leave-active-class="transition-all duration-200 ease-in"
|
||||
leave-from-class="opacity-100 translate-y-0" leave-to-class="opacity-0 translate-y-4">
|
||||
|
||||
<!-- Mini Queue Popover -->
|
||||
<Transition enter-active-class="transition duration-200 ease-out"
|
||||
enter-from-class="opacity-0 translate-y-2 scale-95" enter-to-class="opacity-100 translate-y-0 scale-100"
|
||||
leave-active-class="transition duration-150 ease-in" leave-from-class="opacity-100 translate-y-0 scale-100"
|
||||
leave-to-class="opacity-0 translate-y-2 scale-95">
|
||||
<div v-if="isOpen"
|
||||
class="bg-white rounded-2xl shadow-xl border border-gray-100 p-4 mb-2 w-80 max-h-[60vh] flex flex-col">
|
||||
<div class="flex items-center justify-between mb-3 pb-3 border-b border-gray-100">
|
||||
<h3 class="font-bold text-slate-800">Uploads</h3>
|
||||
<button @click="goToUploadPage" class="text-xs font-bold text-accent hover:underline">
|
||||
View All
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="isVisible"
|
||||
class="fixed bottom-6 right-6 z-50 w-96 rounded-2xl bg-white shadow-[0_8px_40px_rgba(0,0,0,0.16)] border border-slate-100 overflow-hidden flex flex-col"
|
||||
style="max-height: 540px;">
|
||||
|
||||
<div
|
||||
class="flex-1 overflow-y-auto min-h-0 space-y-3 [&::-webkit-scrollbar]:w-1 [&::-webkit-scrollbar-track]:bg-transparent [&::-webkit-scrollbar-thumb]:bg-slate-300 [&::-webkit-scrollbar-thumb]:rounded">
|
||||
<UploadQueueItem v-for="item in items" :key="item.id" :item="item" :minimal="true"
|
||||
class="border-b border-slate-100 last:border-0 !rounded-none" />
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
<!-- Header bar -->
|
||||
<div class="flex items-center gap-3 px-4 py-3.5 bg-slate-800 text-white shrink-0 cursor-pointer select-none"
|
||||
@click="isCollapsed = !isCollapsed">
|
||||
|
||||
<!-- Floating Button -->
|
||||
<button @click="toggleOpen"
|
||||
class="relative flex items-center gap-3 bg-white pl-4 pr-5 py-3 rounded-full shadow-[0_8px_30px_rgba(0,0,0,0.12)] border border-slate-100 hover:-translate-y-1 transition-all duration-300 group">
|
||||
<!-- Progress Ring -->
|
||||
<div class="relative w-10 h-10 flex items-center justify-center">
|
||||
<svg class="w-full h-full -rotate-90 text-slate-100" viewBox="0 0 36 36">
|
||||
<path class="stroke-current" fill="none" stroke-width="3"
|
||||
d="M18 2.0845 a 15.9155 15.9155 0 0 1 0 31.831 a 15.9155 15.9155 0 0 1 0 -31.831" />
|
||||
</svg>
|
||||
<svg class="absolute inset-0 w-full h-full -rotate-90 text-accent transition-all duration-500"
|
||||
viewBox="0 0 36 36" :style="{ strokeDasharray: `${progress}, 100` }">
|
||||
<path class="stroke-current" fill="none" stroke-width="3" stroke-linecap="round"
|
||||
d="M18 2.0845 a 15.9155 15.9155 0 0 1 0 31.831 a 15.9155 15.9155 0 0 1 0 -31.831" />
|
||||
</svg>
|
||||
|
||||
<div class="absolute inset-0 flex items-center justify-center text-accent">
|
||||
<svg v-if="!isUploading && completeCount === items.length" xmlns="http://www.w3.org/2000/svg"
|
||||
class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
|
||||
stroke-linecap="round" stroke-linejoin="round">
|
||||
<!-- Status icon -->
|
||||
<div class="relative w-6 h-6 shrink-0">
|
||||
<svg v-if="isUploading" class="w-6 h-6 animate-spin text-accent" viewBox="0 0 24 24" fill="none">
|
||||
<circle class="opacity-20" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="3" />
|
||||
<path class="opacity-90" fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||
</svg>
|
||||
<svg v-else-if="isAllDone" class="w-6 h-6 text-green-400" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M20 6 9 17l-5-5" />
|
||||
</svg>
|
||||
<span v-else class="text-[10px] font-bold">{{ progress }}%</span>
|
||||
<svg v-else class="w-6 h-6 text-slate-400" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<polyline points="12 6 12 12 16 14" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm font-semibold leading-tight truncate">{{ statusText }}</p>
|
||||
<p class="text-xs text-slate-400 leading-tight mt-0.5">
|
||||
{{ completeCount }} of {{ items.length }} complete
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Controls -->
|
||||
<div class="flex items-center gap-1.5 shrink-0">
|
||||
<!-- Start upload -->
|
||||
<button v-if="pendingCount > 0 && !isUploading" @click.stop="startQueue"
|
||||
class="flex items-center gap-1.5 text-xs font-semibold px-3 py-1.5 bg-accent hover:bg-accent/80 rounded-lg transition-all">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polygon points="5 3 19 12 5 21 5 3" />
|
||||
</svg>
|
||||
Start
|
||||
</button>
|
||||
|
||||
<!-- Add more files -->
|
||||
<button @click.stop="uiState.uploadDialogVisible = true"
|
||||
class="w-7 h-7 flex items-center justify-center text-slate-400 hover:text-white hover:bg-white/10 rounded-lg transition-all"
|
||||
title="Add more files">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M5 12h14" />
|
||||
<path d="M12 5v14" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Collapse/expand -->
|
||||
<button @click.stop="isCollapsed = !isCollapsed"
|
||||
class="w-7 h-7 flex items-center justify-center text-slate-400 hover:text-white hover:bg-white/10 rounded-lg transition-all">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4 transition-transform duration-200"
|
||||
:class="{ 'rotate-180': isCollapsed }"
|
||||
viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"
|
||||
stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="m18 15-6-6-6 6" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="text-left">
|
||||
<div class="text-sm font-bold text-slate-800 group-hover:text-accent transition-colors">
|
||||
{{ isUploading ? 'Uploading...' : (completeCount === items.length ? 'Completed' : 'Pending') }}
|
||||
</div>
|
||||
<div class="text-xs text-slate-500">
|
||||
{{ completeCount }} / {{ items.length }} files
|
||||
</div>
|
||||
<!-- Overall progress bar -->
|
||||
<div v-if="isUploading" class="h-0.5 w-full bg-slate-100 shrink-0">
|
||||
<div class="h-full bg-accent transition-all duration-500"
|
||||
:style="{ width: `${overallProgress}%` }"></div>
|
||||
</div>
|
||||
|
||||
<div v-if="pendingCount"
|
||||
class="absolute -top-1 -right-1 w-5 h-5 bg-red-500 rounded-full flex items-center justify-center text-[10px] font-bold text-white shadow-sm border-2 border-white">
|
||||
{{ pendingCount }}
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<!-- File list -->
|
||||
<Transition enter-active-class="transition-all duration-200 ease-out"
|
||||
enter-from-class="opacity-0" enter-to-class="opacity-100"
|
||||
leave-active-class="transition-all duration-150 ease-in"
|
||||
leave-from-class="opacity-100" leave-to-class="opacity-0">
|
||||
<div v-if="!isCollapsed" class="flex-1 overflow-y-auto min-h-0">
|
||||
<div class="p-3 flex flex-col gap-2">
|
||||
<UploadQueueItem v-for="item in items" :key="item.id" :item="item"
|
||||
@remove="removeItem($event)" @cancel="cancelItem($event)" />
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</Transition>
|
||||
</template>
|
||||
|
||||
@@ -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<QueueItem[]>([]);
|
||||
|
||||
// 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<string, Set<XMLHttpRequest>>();
|
||||
|
||||
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,24 +84,25 @@ 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';
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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,7 +275,8 @@ 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
|
||||
})
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -52,46 +52,45 @@ export function getImageAspectRatio(url: string): Promise<AspectInfo> {
|
||||
|
||||
|
||||
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';
|
||||
}
|
||||
};
|
||||
@@ -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: [
|
||||
|
||||
@@ -1,56 +1,128 @@
|
||||
<script setup lang="ts">
|
||||
import PageHeader from '@/components/dashboard/PageHeader.vue';
|
||||
import UploadModeToggle from './components/UploadModeToggle.vue';
|
||||
import InfoTip from './components/InfoTip.vue';
|
||||
import UploadDropzone from './components/UploadDropzone.vue';
|
||||
import RemoteUrlForm from './components/RemoteUrlForm.vue';
|
||||
import BulkActions from './components/BulkActions.vue';
|
||||
import UploadQueue from './components/UploadQueue.vue';
|
||||
import { ref } from 'vue';
|
||||
import { useUploadQueue } from '@/composables/useUploadQueue';
|
||||
import { useUIState } from '@/stores/uiState';
|
||||
|
||||
const uiState = useUIState();
|
||||
const mode = ref<'local' | 'remote'>('local');
|
||||
|
||||
const { addFiles, addRemoteUrls, items, removeItem, cancelItem, totalSize, completeCount, pendingCount, startQueue } = useUploadQueue();
|
||||
|
||||
const handlePublish = () => {
|
||||
console.log('Publishing items...');
|
||||
// TODO: Handle publish action
|
||||
};
|
||||
const { addFiles, addRemoteUrls, pendingCount, startQueue, remainingSlots, maxItems } = useUploadQueue();
|
||||
|
||||
const handleFilesSelected = (files: FileList) => {
|
||||
addFiles(files);
|
||||
uiState.uploadDialogVisible = false;
|
||||
};
|
||||
|
||||
const handleRemoteUrls = (urls: string[]) => {
|
||||
addRemoteUrls(urls);
|
||||
uiState.uploadDialogVisible = false;
|
||||
};
|
||||
|
||||
const handleStartUpload = () => {
|
||||
startQueue();
|
||||
uiState.uploadDialogVisible = false;
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex-1 flex items-stretch gap-4">
|
||||
<div class="flex-1 overflow-y-auto">
|
||||
<PageHeader title="Upload Videos" description="Choose your preferred method to upload videos." :breadcrumbs="[
|
||||
{ label: 'Dashboard', to: '/' },
|
||||
{ label: 'Upload Videos' }
|
||||
]" />
|
||||
<div class="flex flex-col max-w-4xl mx-auto gap-4">
|
||||
<UploadModeToggle v-model="mode" />
|
||||
<InfoTip />
|
||||
<Transition enter-active-class="transition-all duration-300 ease-in-out"
|
||||
enter-from-class="opacity-0 transform translate-y-4"
|
||||
enter-to-class="opacity-100 transform translate-y-0"
|
||||
leave-active-class="transition-all duration-200 ease-in-out"
|
||||
leave-from-class="opacity-100 transform translate-y-0"
|
||||
leave-to-class="opacity-0 transform -translate-y-4" mode="out-in">
|
||||
<UploadDropzone v-if="mode === 'local'" @files-selected="handleFilesSelected" />
|
||||
<RemoteUrlForm v-else @submit="handleRemoteUrls" />
|
||||
</Transition>
|
||||
<BulkActions :visible="false" :pending-count="0" />
|
||||
<Dialog v-model:visible="uiState.uploadDialogVisible" modal dismissableMask :style="{ width: '580px', maxWidth: '96vw' }">
|
||||
<template #container="{ closeCallback }">
|
||||
<div class="flex flex-col bg-white rounded-2xl overflow-hidden shadow-2xl">
|
||||
|
||||
<!-- ── Header ── -->
|
||||
<div class="flex items-center justify-between px-6 py-5 border-b border-slate-100">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-10 h-10 rounded-xl bg-accent/10 flex items-center justify-center shrink-0">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5 text-accent" viewBox="0 0 24 24"
|
||||
fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"
|
||||
stroke-linejoin="round">
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
|
||||
<polyline points="17 8 12 3 7 8" />
|
||||
<line x1="12" y1="3" x2="12" y2="15" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h2 class="font-bold text-base text-slate-900 leading-tight">Upload Videos</h2>
|
||||
<p class="text-sm text-slate-400 leading-tight mt-0.5">Add up to {{ maxItems }} videos per batch</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mode switcher -->
|
||||
<div class="flex items-center gap-0.5 bg-slate-100 rounded-xl p-1">
|
||||
<button @click="mode = 'local'"
|
||||
:class="['px-4 py-2 text-sm font-medium rounded-lg transition-all', mode === 'local' ? 'bg-white text-slate-800 shadow-sm' : 'text-slate-500 hover:text-slate-700']">
|
||||
Local
|
||||
</button>
|
||||
<button @click="mode = 'remote'"
|
||||
:class="['px-4 py-2 text-sm font-medium rounded-lg transition-all', mode === 'remote' ? 'bg-white text-slate-800 shadow-sm' : 'text-slate-500 hover:text-slate-700']">
|
||||
Remote URL
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Input area ── -->
|
||||
<div class="p-5" style="height: 320px;">
|
||||
<!-- Queue full warning -->
|
||||
<div v-if="remainingSlots === 0"
|
||||
class="h-full flex flex-col items-center justify-center gap-4 text-center">
|
||||
<div class="w-16 h-16 rounded-2xl bg-amber-50 flex items-center justify-center">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="w-8 h-8 text-amber-500" viewBox="0 0 24 24"
|
||||
fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"
|
||||
stroke-linejoin="round">
|
||||
<path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3" />
|
||||
<path d="M12 9v4" />
|
||||
<path d="M12 17h.01" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-base font-semibold text-slate-700">Queue is full</p>
|
||||
<p class="text-sm text-slate-400 mt-1">
|
||||
Maximum {{ maxItems }} videos per batch.<br>Start or clear the current queue first.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Dropzone / URL form -->
|
||||
<Transition v-else enter-active-class="transition-all duration-200 ease-out"
|
||||
enter-from-class="opacity-0 translate-y-1" enter-to-class="opacity-100 translate-y-0"
|
||||
leave-active-class="transition-all duration-150 ease-in"
|
||||
leave-from-class="opacity-100" leave-to-class="opacity-0" mode="out-in">
|
||||
<UploadDropzone v-if="mode === 'local'" :max-files="remainingSlots" @files-selected="handleFilesSelected" />
|
||||
<RemoteUrlForm v-else :max-urls="remainingSlots" @submit="handleRemoteUrls" />
|
||||
</Transition>
|
||||
</div>
|
||||
|
||||
<!-- ── Footer ── -->
|
||||
<div class="flex items-center justify-between px-6 py-4 border-t border-slate-100">
|
||||
<span class="text-sm text-slate-400">
|
||||
<span v-if="remainingSlots < maxItems">
|
||||
<span class="font-semibold"
|
||||
:class="remainingSlots === 0 ? 'text-amber-500' : 'text-slate-600'">{{ remainingSlots }}</span>
|
||||
/ {{ maxItems }} slots remaining
|
||||
</span>
|
||||
<span v-else>MP4, MOV, MKV · max 10 GB per file</span>
|
||||
</span>
|
||||
<div class="flex items-center gap-2">
|
||||
<button @click="closeCallback"
|
||||
class="px-5 py-2.5 text-sm font-medium text-slate-600 hover:text-slate-800 hover:bg-slate-100 rounded-xl transition-all">
|
||||
Close
|
||||
</button>
|
||||
<button v-if="pendingCount > 0" @click="handleStartUpload"
|
||||
class="flex items-center gap-2 px-5 py-2.5 bg-accent hover:bg-accent/90 text-white text-sm font-semibold rounded-xl transition-all shadow-sm shadow-accent/30">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4" viewBox="0 0 24 24"
|
||||
fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"
|
||||
stroke-linejoin="round">
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
|
||||
<polyline points="17 8 12 3 7 8" />
|
||||
<line x1="12" y1="3" x2="12" y2="15" />
|
||||
</svg>
|
||||
Start Upload ({{ pendingCount }})
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<UploadQueue :items="items" :total-size="totalSize" :complete-count="completeCount"
|
||||
:pending-count="pendingCount" @remove-item="removeItem" @cancel-item="cancelItem" @publish="handlePublish"
|
||||
@start-queue="startQueue" />
|
||||
</div>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
@@ -1,65 +1,55 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
|
||||
const props = defineProps<{ maxUrls?: number }>();
|
||||
const urls = ref('');
|
||||
|
||||
const emit = defineEmits<{
|
||||
submit: [urls: string[]];
|
||||
}>();
|
||||
const emit = defineEmits<{ submit: [urls: string[]] }>();
|
||||
|
||||
const handleSubmit = () => {
|
||||
const limit = props.maxUrls ?? 5;
|
||||
const urlList = urls.value
|
||||
.split('\n')
|
||||
.map(url => url.trim())
|
||||
.filter(url => url.length > 0);
|
||||
.filter(url => url.length > 0)
|
||||
.slice(0, limit);
|
||||
if (urlList.length > 0) {
|
||||
emit('submit', urlList);
|
||||
urls.value = '';
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="bg-gradient-to-tl from-slate-50 to-white rounded-2xl shadow-soft p-10 border border-gray-200">
|
||||
<label class="block text-lg font-semibold text-slate-900 mb-4">Enter Video URL</label>
|
||||
|
||||
<div class="flex gap-4 items-start">
|
||||
<div class="flex-1 relative group">
|
||||
<div class="absolute left-4 top-4 text-slate-400 group-focus-within:text-accent transition-colors">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="w-6 h-6" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<path d="M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20" />
|
||||
<path d="M2 12h20" />
|
||||
</svg>
|
||||
</div>
|
||||
<textarea v-model="urls"
|
||||
placeholder="Paste one or more links (one per line)... https://drive.google.com/file/..."
|
||||
class="w-full pl-12 pr-4 py-4 h-32 bg-slate-50/50 border-2 border-slate-100 rounded-2xl focus:border-accent focus:bg-white focus:ring-0 transition-all resize-none text-slate-800 placeholder:text-slate-400 text-base leading-relaxed font-medium"></textarea>
|
||||
</div>
|
||||
<div class="flex flex-col gap-3 h-full">
|
||||
<div class="relative flex-1">
|
||||
<textarea v-model="urls"
|
||||
placeholder="Paste video URLs here, one per line https://example.com/video.mp4 https://drive.google.com/..."
|
||||
class="w-full h-full min-h-[200px] px-4 py-3.5 bg-white border border-slate-200
|
||||
rounded-xl focus:border-accent focus:ring-2 focus:ring-accent/10 focus:outline-none
|
||||
transition-all resize-none text-base text-slate-700 placeholder:text-slate-300
|
||||
leading-relaxed font-[inherit]"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex items-center justify-between">
|
||||
<div class="flex items-center gap-2 text-sm text-slate-500 bg-slate-50 px-4 py-2 rounded-full">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2 text-sm text-slate-400">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<path d="M12 16v-4" />
|
||||
<path d="M12 8h.01" />
|
||||
</svg>
|
||||
Auto-detect Google Drive, Dropbox
|
||||
Google Drive, Dropbox supported
|
||||
</div>
|
||||
<button @click="handleSubmit"
|
||||
class="px-8 py-3.5 bg-slate-900 hover:bg-black text-white font-medium rounded-xl shadow-xl shadow-slate-200/50 transition-all active:scale-95 flex items-center gap-2">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5 text-yellow-300" viewBox="0 0 24 24" fill="none"
|
||||
class="flex items-center gap-2 px-5 py-2.5 bg-slate-800 hover:bg-slate-900 text-white
|
||||
text-sm font-semibold rounded-xl transition-all active:scale-95">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path
|
||||
d="M9.937 15.5A2 2 0 0 0 8.5 14.063l-6.135-1.582a.5.5 0 0 1 0-.962L8.5 9.936A2 2 0 0 0 9.937 8.5l1.582-6.135a.5.5 0 0 1 .963 0L14.063 8.5A2 2 0 0 0 15.5 9.937l6.135 1.581a.5.5 0 0 1 0 .964L15.5 14.063a2 2 0 0 0-1.437 1.437l-1.582 6.135a.5.5 0 0 1-.963 0z" />
|
||||
<path d="M20 3v4" />
|
||||
<path d="M22 5h-4" />
|
||||
<path d="M4 17v2" />
|
||||
<path d="M5 18H3" />
|
||||
<path d="M5 12h14" />
|
||||
<path d="m12 5 7 7-7 7" />
|
||||
</svg>
|
||||
Import & Upload Videos
|
||||
Add URLs
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,55 +1,59 @@
|
||||
<script setup lang="ts">
|
||||
const emit = defineEmits<{
|
||||
filesSelected: [files: FileList];
|
||||
}>();
|
||||
const props = defineProps<{ maxFiles?: number }>();
|
||||
const emit = defineEmits<{ filesSelected: [files: FileList] }>();
|
||||
|
||||
|
||||
const handleFileChange = (event: Event) => {
|
||||
const input = event.target as HTMLInputElement;
|
||||
if (input.files && input.files.length > 0) {
|
||||
if (!input.files || input.files.length === 0) return;
|
||||
const limit = props.maxFiles ?? 5;
|
||||
if (input.files.length > limit) {
|
||||
// Create a DataTransfer to slice to the limit
|
||||
const dt = new DataTransfer();
|
||||
Array.from(input.files).slice(0, limit).forEach(f => dt.items.add(f));
|
||||
emit('filesSelected', dt.files);
|
||||
} else {
|
||||
emit('filesSelected', input.files);
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="relative group cursor-pointer">
|
||||
<div class="relative group cursor-pointer flex-1 flex flex-col h-full">
|
||||
<input type="file" multiple accept="video/*"
|
||||
class="absolute inset-0 w-full h-full opacity-0 z-20 cursor-pointer" @change="handleFileChange">
|
||||
|
||||
<div
|
||||
class="bg-surface rounded-2xl p-16 text-center border border-dashed border-border group-hover:border-success/50 group-hover:shadow-soft transition-all duration-300 relative overflow-hidden">
|
||||
<div class="flex-1 flex flex-col items-center justify-center gap-4 rounded-xl border-2 border-dashed
|
||||
border-slate-200 group-hover:border-accent/60 group-hover:bg-accent/[0.03]
|
||||
transition-all duration-300 py-6 px-4 h-full">
|
||||
|
||||
<div
|
||||
class="absolute top-0 left-0 w-64 h-64 bg-primary/10 rounded-full blur-3xl -translate-x-1/2 -translate-y-1/2 opacity-0 group-hover:opacity-100 transition-opacity duration-700">
|
||||
</div>
|
||||
<div
|
||||
class="absolute bottom-0 right-0 w-64 h-64 bg-primary/10 rounded-full blur-3xl translate-x-1/2 translate-y-1/2 opacity-0 group-hover:opacity-100 transition-opacity duration-700">
|
||||
</div>
|
||||
|
||||
<div class="relative z-10 flex flex-col items-center">
|
||||
<div
|
||||
class="w-24 h-24 mb-8 rounded-3xl bg-page shadow-soft flex items-center justify-center text-accent transition-all duration-300 ring-4 ring-gray-100 group-hover:(ring-primary/10 scale-110 shadow-md)">
|
||||
<!-- Animated icon -->
|
||||
<div class="relative">
|
||||
<div class="w-20 h-20 rounded-2xl bg-slate-100 group-hover:bg-accent/10 flex items-center justify-center transition-all duration-300 group-hover:scale-105 group-hover:shadow-md">
|
||||
<svg xmlns="http://www.w3.org/2000/svg"
|
||||
class="w-10 h-10 stroke-primary/60 group-hover:stroke-primary transition-all duration-300"
|
||||
viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"
|
||||
stroke-linejoin="round">
|
||||
class="w-10 h-10 text-slate-400 group-hover:text-accent transition-colors duration-300"
|
||||
viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
|
||||
stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
|
||||
<polyline points="17 8 12 3 7 8" />
|
||||
<line x1="12" x2="12" y1="3" y2="15" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="absolute inset-0 rounded-2xl ring-4 ring-accent/0 group-hover:ring-accent/20 transition-all duration-300"></div>
|
||||
</div>
|
||||
|
||||
<h3 class="text-2xl font-semibold text-slate-900 mb-3">Drag and drop your videos here</h3>
|
||||
<p class="text-slate-500 text-base mb-8 max-w-md mx-auto leading-relaxed">
|
||||
Supports uploading multiple files at once. Formats MP4, MOV, MKV. Up to 10GB per file.
|
||||
<div class="text-center">
|
||||
<p class="text-base font-semibold text-slate-700 group-hover:text-slate-900 transition-colors">
|
||||
Drop videos here
|
||||
</p>
|
||||
<span class="px-8 py-3.5 btn-lg btn-primary flex items-center gap-2">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path
|
||||
d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z" />
|
||||
</svg>
|
||||
Choose Files
|
||||
<p class="text-sm text-slate-400 mt-1.5">or click anywhere to browse</p>
|
||||
</div>
|
||||
|
||||
<!-- Format badges -->
|
||||
<div class="flex items-center gap-2">
|
||||
<span v-for="fmt in ['MP4', 'MOV', 'MKV']" :key="fmt"
|
||||
class="text-xs font-semibold px-3 py-1 bg-slate-100 text-slate-500 rounded-lg tracking-wide">
|
||||
{{ fmt }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -30,14 +30,15 @@ const mode = computed({
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="inline-flex bg-gray-200 p-1 rounded-2xl relative z-0 w-fit">
|
||||
<div class="inline-flex bg-slate-100 p-0.5 rounded-lg relative z-0 w-fit">
|
||||
<div
|
||||
:class="cn(':uno: absolute left-1 top-1 h-[calc(100%-8px)] w-[calc(50%-4px)] bg-white rounded-xl shadow-sm transition-all duration-300 ease-out -z-10', mode === 'local' ? 'translate-x-0' : 'translate-x-full')">
|
||||
:class="cn(':uno: absolute left-0.5 top-0.5 h-[calc(100%-4px)] w-[calc(50%-2px)] bg-white rounded-md shadow-sm transition-all duration-300 ease-out -z-10', mode === 'local' ? 'translate-x-0' : 'translate-x-full')">
|
||||
</div>
|
||||
<button v-for="item in modeList" :key="item.id" @click="mode = item.id"
|
||||
:class="cn('flex items-center gap-2 px-6 py-3 text-sm rounded-xl transition-colors relative z-10', mode === item.id ? 'font-semibold text-slate-900' : 'font-medium text-slate-500 hover:text-slate-900 ')">
|
||||
<span class="w-5 h-5" v-html="item.icon"></span>
|
||||
:class="cn('flex items-center gap-1.5 px-3.5 py-1.5 text-xs rounded-md transition-colors relative z-10', mode === item.id ? 'font-semibold text-slate-800' : 'font-medium text-slate-500 hover:text-slate-700')">
|
||||
<span class="w-3.5 h-3.5" v-html="item.icon"></span>
|
||||
{{ item.label }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -19,26 +19,9 @@ const emit = defineEmits<{
|
||||
|
||||
<template>
|
||||
<aside
|
||||
class=":uno: w-[420px] flex flex-col h-[calc(100svh-64px)] sticky top-16 before:(content-[''] absolute pointer-events-none inset-[-1px] rounded-[calc(var(--radius-2xl)+1px)] bg-[linear-gradient(-45deg,var(--capra-ramp-5)_0,var(--capra-ramp-4)_8%,var(--capra-ramp-3)_17%,var(--capra-ramp-2)_25%,var(--capra-ramp-1)_33%,#292929_34%,#292929_40%,#e1dfdf_45%,#e1dfdf_100%)] bg-[length:400%_200%] bg-[position:0_0] transition-[background-position] duration-[1000ms] ease-in-out delay-[500ms] z-0)"
|
||||
class=":uno: w-full flex flex-col h-[calc(100svh-64px)] sticky top-16 before:(content-[''] absolute pointer-events-none inset-[-1px] rounded-[calc(var(--radius-2xl)+1px)] bg-[linear-gradient(-45deg,var(--capra-ramp-5)_0,var(--capra-ramp-4)_8%,var(--capra-ramp-3)_17%,var(--capra-ramp-2)_25%,var(--capra-ramp-1)_33%,#292929_34%,#292929_40%,#e1dfdf_45%,#e1dfdf_100%)] bg-[length:400%_200%] bg-[position:0_0] transition-[background-position] duration-[1000ms] ease-in-out delay-[500ms] z-0)"
|
||||
:class="{ 'before:bg-[position:100%_100%]': pendingCount && pendingCount > 0 }">
|
||||
<div class="bg-surface z-1 relative flex flex-col h-full rounded-2xl overflow-hidden">
|
||||
<div class="p-6 border-b border-border flex items-center justify-between shrink-0">
|
||||
<div>
|
||||
<h2 class="text-lg font-bold text-slate-900">Upload Queue</h2>
|
||||
<p class="text-sm text-slate-500 mt-1" id="queue-status">
|
||||
{{ items?.length ? `${items.length} task(s)` : 'No tasks yet' }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="w-10 h-10 rounded-full bg-slate-100 flex items-center justify-center text-slate-400">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12 12H3" />
|
||||
<path d="M16 6H3" />
|
||||
<path d="M12 18H3" />
|
||||
<path d="m16 12 5 3-5 3v-6Z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 overflow-y-auto scrollbar-thin p-6 space-y-5 relative" id="queue-list">
|
||||
<div v-if="!items?.length" id="empty-queue"
|
||||
@@ -53,8 +36,7 @@ const emit = defineEmits<{
|
||||
<p class="text-slate-400 font-medium">Empty queue!</p>
|
||||
</div>
|
||||
|
||||
<UploadQueueItem v-for="item in items" :key="item.id" :item="item"
|
||||
@remove="emit('removeItem', $event)" @cancel="emit('cancelItem', $event)" />
|
||||
|
||||
</div>
|
||||
|
||||
<div class="p-6 border-t border-border shrink-0">
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import type { QueueItem } from '@/composables/useUploadQueue';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { computed } from 'vue';
|
||||
|
||||
const props = defineProps<{
|
||||
item: QueueItem;
|
||||
minimal?: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -16,142 +14,95 @@ const emit = defineEmits<{
|
||||
const statusLabel = computed(() => {
|
||||
switch (props.item.status) {
|
||||
case 'pending': return 'Pending';
|
||||
case 'uploading': return props.item.activeChunks ? `Uploading (${props.item.activeChunks} threads)` : 'Uploading...';
|
||||
case 'uploading': return props.item.activeChunks ? `Uploading · ${props.item.activeChunks} threads` : 'Uploading...';
|
||||
case 'processing': return 'Processing...';
|
||||
case 'complete': return 'Completed';
|
||||
case 'complete': return 'Done';
|
||||
case 'error': return 'Failed';
|
||||
case 'fetching': return 'Fetching...';
|
||||
default: return props.item.status;
|
||||
}
|
||||
});
|
||||
|
||||
const statusColor = computed(() => {
|
||||
const statusVariant = computed(() => {
|
||||
switch (props.item.status) {
|
||||
case 'complete': return 'bg-green-500';
|
||||
case 'error': return 'bg-red-500';
|
||||
case 'pending': return 'bg-slate-400';
|
||||
default: return 'bg-accent';
|
||||
case 'complete': return { dot: 'bg-green-500', text: 'text-green-600', bar: 'bg-green-500' };
|
||||
case 'error': return { dot: 'bg-red-500', text: 'text-red-500', bar: 'bg-red-500' };
|
||||
case 'pending': return { dot: 'bg-slate-300', text: 'text-slate-400', bar: 'bg-slate-300' };
|
||||
default: return { dot: 'bg-accent', text: 'text-accent', bar: 'bg-accent' };
|
||||
}
|
||||
});
|
||||
|
||||
const canCancel = computed(() => {
|
||||
return props.item.status === 'uploading' || props.item.status === 'pending';
|
||||
});
|
||||
const isActive = computed(() =>
|
||||
props.item.status === 'uploading' || props.item.status === 'fetching' || props.item.status === 'processing'
|
||||
);
|
||||
|
||||
const canCancel = computed(() =>
|
||||
props.item.status === 'uploading' || props.item.status === 'pending'
|
||||
);
|
||||
|
||||
const progress = computed(() => props.item.progress || 0);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- Local Upload Item -->
|
||||
<div v-if="item.type === 'local'"
|
||||
class="bg-white rounded-2xl p-5 shadow-soft border border-slate-100/50 relative group overflow-hidden transition-all"
|
||||
:class="{ '!p-2 !border-0 !shadow-none !rounded-xl': minimal }">
|
||||
<div :class="cn('flex gap-4 relative z-10', minimal && '!gap-2')">
|
||||
<div v-if="!minimal" class="w-20 h-16 bg-slate-800 rounded-xl shrink-0 relative overflow-hidden shadow-sm">
|
||||
<div class="absolute inset-0 bg-gradient-to-t from-black/60 to-transparent z-10"></div>
|
||||
<img v-if="item.thumbnail" :src="item.thumbnail" class="w-full h-full object-cover opacity-80" alt="">
|
||||
<div class="absolute bottom-1 left-2 z-20">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4 text-white/90" viewBox="0 0 24 24"
|
||||
fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"
|
||||
stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<polygon points="10 8 16 12 10 16 10 8" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="group relative rounded-xl bg-white border border-slate-100 p-3 hover:border-gray-200 transition-all duration-200">
|
||||
<!-- Progress bar (only for uploading) -->
|
||||
<div v-if="item.type === 'local'" class="absolute z-1 h-full w-full bg-transparent rounded-xl overflow-hidden top-0 left-0">
|
||||
<div class="h-full transition-all duration-500 opacity-10"
|
||||
:class="statusVariant.bar"
|
||||
:style="{ width: `${progress}%` }">
|
||||
</div>
|
||||
</div>
|
||||
<div class="relative flex items-start gap-3 z-2">
|
||||
<!-- File type icon -->
|
||||
<div class="w-8 h-8 rounded-lg flex items-center justify-center shrink-0 my-a"
|
||||
:class="item.type === 'remote' ? 'bg-indigo-50 text-indigo-400' : 'bg-slate-100 text-slate-400'">
|
||||
<!-- Local file icon -->
|
||||
<svg v-if="item.type === 'local'" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 404 532"><path d="M26 74v384c0 27 22 48 48 48h256c27 0 48-21 48-48V197c0-4 0-8-1-11H274c-31 0-56-25-56-56V27c-3-1-7-1-10-1H74c-26 0-48 22-48 48zm64 224c0-18 14-32 32-32h96c18 0 32 14 32 32v18l40-25c10-7 24 1 24 14v83c0 12-14 20-24 13l-40-25v18c0 18-14 32-32 32h-96c-18 0-32-14-32-32v-96z" fill="#a6acb9"/><path d="M208 26c3 0 7 0 10 1v103c0 31 25 56 56 56h103c1 3 1 7 1 11v261c0 27-21 48-48 48H74c-26 0-48-21-48-48V74c0-26 22-48 48-48h134zm156 137c2 2 4 4 6 7h-96c-22 0-40-18-40-40V34c3 2 5 4 7 6l123 123zM74 10c-35 0-64 29-64 64v384c0 35 29 64 64 64h256c35 0 64-29 64-64V197c0-17-7-34-19-46L253 29c-12-12-28-19-45-19H74zm144 272c9 0 16 7 16 16v96c0 9-7 16-16 16h-96c-9 0-16-7-16-16v-96c0-9 7-16 16-16h96zm-96-16c-18 0-32 14-32 32v96c0 18 14 32 32 32h96c18 0 32-14 32-32v-18l40 25c10 7 24-1 24-13v-84c0-12-14-20-24-13l-40 25v-18c0-18-14-32-32-32h-96zm176 38v84l-48-30v-24l48-30z" fill="#1e3050"/></svg>
|
||||
<!-- Remote link icon -->
|
||||
<svg v-else xmlns="http://www.w3.org/2000/svg" viewBox="0 0 596 564"><path d="M90 258h104c2-1 5-2 8-2 3-117 56-193 99-228C185 42 94 139 90 258zm128-7c28-8 73-22 135-40-5 16-9 31-14 47h103c-3-132-72-209-112-231-39 22-107 96-112 224zm51 247c10 3 21 5 32 6-9-7-18-16-27-26-2 7-3 13-5 20zm11-38c17 22 36 37 50 45 40-22 109-99 112-231H334l-6 21c-16 55-32 110-48 164zm0 0zm79-432c44 35 97 112 99 230h112c-4-119-95-216-211-230zm0 476c116-14 207-111 211-230H458c-2 117-55 195-99 230z" fill="#a6acb9"/><path d="M570 274H458c-2 118-55 195-99 230 116-14 207-111 211-230zM269 498c10 3 21 5 32 6-9-7-18-16-27-26l6-18c18 22 36 37 50 45 40-22 109-99 112-231H335l4-16h103c-3-132-72-209-112-231-39 22-107 96-112 224l-16 5c3-117 56-193 99-228C185 42 94 139 90 258h104l-55 16H90c0 5 1 10 1 14l-16 5c0-9-1-18-1-27C74 125 189 10 330 10s256 115 256 256-115 256-256 256c-23 0-45-3-66-9l5-15zm301-240c-4-119-95-216-211-230 44 35 97 112 99 230h112zM150 414l2 5 46 92 60-205-204 60 91 46 5 2zM31 373l-21-11 23-7 231-68 18-5-5 18-68 232-7 22-60-120-94 94-6 5-11-11 5-6 95-94-100-49z" fill="#1e3050"/></svg>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 min-w-0 py-0.5 flex flex-col justify-between">
|
||||
<div class="flex justify-between items-start gap-2">
|
||||
<h4 class="text-sm font-bold text-slate-800 truncate">{{ item.name }}</h4>
|
||||
<button @click="emit('remove', item.id)"
|
||||
class="text-slate-300 hover:text-red-500 transition p-1 -mr-2 -mt-2 opacity-0 group-hover:opacity-100">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<!-- Content -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<!-- Name row -->
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<p class="text-xs font-semibold text-slate-700 truncate leading-5">{{ item.name }}</p>
|
||||
<button v-if="item.status == 'pending'" @click="emit('remove', item.id)"
|
||||
class="shrink-0 w-5 h-5 flex items-center justify-center rounded text-slate-300 hover:text-red-400 hover:bg-red-50 transition-all opacity-0 group-hover:opacity-100">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="w-3 h-3" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M18 6 6 18" />
|
||||
<path d="m6 6 12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="flex justify-between text-xs text-slate-500 mb-1.5 font-medium">
|
||||
<span class="flex items-center gap-1.5">
|
||||
<span class="w-2 h-2 rounded-full animate-pulse" :class="statusColor"></span>
|
||||
<!-- Status + progress row -->
|
||||
<div class="mt-1.5">
|
||||
<div class="flex items-center justify-between">
|
||||
<!-- Status badge -->
|
||||
<span class="flex items-center gap-1 text-[10px] font-medium" :class="statusVariant.text">
|
||||
<span class="w-1.5 h-1.5 rounded-full shrink-0" :class="[statusVariant.dot, isActive ? 'animate-pulse' : '']"></span>
|
||||
{{ statusLabel }}
|
||||
</span>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
v-if="canCancel && !minimal"
|
||||
@click="emit('cancel', item.id)"
|
||||
class="text-[10px] px-2 py-0.5 bg-red-50 text-red-500 hover:bg-red-100 rounded transition"
|
||||
>
|
||||
<!-- Progress % -->
|
||||
<span v-if="item.type === 'local' && progress > 0"
|
||||
class="text-[10px] font-bold tabular-nums" :class="statusVariant.text">
|
||||
{{ progress }}%
|
||||
</span>
|
||||
<!-- Speed -->
|
||||
<span v-if="isActive && item.speed" class="text-[10px] text-slate-400">
|
||||
{{ item.speed }}
|
||||
</span>
|
||||
<!-- Cancel button -->
|
||||
<button v-if="canCancel" @click="emit('cancel', item.id)"
|
||||
class="text-[10px] font-medium text-slate-400 hover:text-red-500 transition-colors">
|
||||
Cancel
|
||||
</button>
|
||||
<span class="text-accent font-bold">{{ item.progress || 0 }}%</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="h-1.5 w-full bg-slate-100 rounded-full overflow-hidden relative">
|
||||
<div class="absolute inset-0 bg-accent/20 animate-pulse w-full"></div>
|
||||
<div class="h-full bg-accent rounded-full relative z-10 shadow-[0_0_12px_rgba(99,102,241,0.6)] transition-all duration-500"
|
||||
:style="{ width: `${item.progress || 0}%` }">
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-between mt-2 text-[11px] text-slate-400 font-medium">
|
||||
<span>{{ item.uploaded || '0 MB' }} of {{ item.total || '0 MB' }}</span>
|
||||
<span>{{ item.speed || '0 MB/s' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Remote Fetch Item -->
|
||||
<div v-else
|
||||
class="bg-[#F0F3FF] rounded-2xl p-5 shadow-soft border border-indigo-100/50 relative overflow-hidden group transition-all hover:shadow-md"
|
||||
:class="{ '!p-3 !bg-slate-50 !border-0 !shadow-none !rounded-xl': minimal }">
|
||||
<div
|
||||
class="absolute inset-0 opacity-[0.03] bg-[radial-gradient(#6366F1_1px,transparent_1px)] [background-size:16px_16px]">
|
||||
</div>
|
||||
|
||||
<div class="flex gap-4 relative z-10" :class="{ '!gap-3': minimal }">
|
||||
<div v-if="!minimal"
|
||||
class="w-20 h-16 bg-indigo-100 rounded-xl shrink-0 flex items-center justify-center text-accent shadow-sm">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="w-8 h-8 opacity-80" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M9 17H7A5 5 0 0 1 7 7h2" />
|
||||
<path d="M15 7h2a5 5 0 1 1 0 10h-2" />
|
||||
<line x1="8" x2="16" y1="12" y2="12" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 min-w-0 py-1 flex flex-col justify-center">
|
||||
<div class="flex justify-between items-start gap-2">
|
||||
<h4 class="text-sm font-bold text-slate-800 truncate">{{ item.name }}</h4>
|
||||
<button @click="emit('remove', item.id)"
|
||||
class="text-slate-400 hover:text-red-500 transition p-1 -mr-2 -mt-2 opacity-0 group-hover:opacity-100">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M18 6 6 18" />
|
||||
<path d="m6 6 12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3 mt-3">
|
||||
<div
|
||||
class="flex items-center gap-2 text-xs font-bold text-indigo-600 bg-white py-1.5 px-3 rounded-lg shadow-sm">
|
||||
<svg v-if="item.status === 'fetching' || item.status === 'processing'"
|
||||
xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5 animate-spin" viewBox="0 0 24 24"
|
||||
fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"
|
||||
stroke-linejoin="round">
|
||||
<path d="M21 12a9 9 0 1 1-6.219-8.56" />
|
||||
</svg>
|
||||
<svg v-else-if="item.status === 'complete'" xmlns="http://www.w3.org/2000/svg"
|
||||
class="w-3.5 h-3.5 text-green-500" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"></path>
|
||||
<polyline points="22 4 12 14.01 9 11.01"></polyline>
|
||||
</svg>
|
||||
{{ statusLabel }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -10,7 +10,9 @@ import VideoFilters from './components/VideoFilters.vue';
|
||||
import VideoGrid from './components/VideoGrid.vue';
|
||||
import VideoTable from './components/VideoTable.vue';
|
||||
import VideoBulkActions from './components/VideoBulkActions.vue';
|
||||
import { useUIState } from '@/stores/uiState';
|
||||
|
||||
const uiState = useUIState();
|
||||
const router = useRouter();
|
||||
const videos = ref<ModelVideo[]>([]);
|
||||
const loading = ref(true);
|
||||
@@ -126,7 +128,7 @@ watch([searchQuery, selectedStatus, limit, page], () => {
|
||||
label: 'Upload Video',
|
||||
icon: iconHoist,
|
||||
variant: 'primary',
|
||||
onClick: () => router.push('/upload')
|
||||
onClick: () => uiState.toggleUploadDialog()
|
||||
}
|
||||
]" />
|
||||
|
||||
|
||||
@@ -66,11 +66,11 @@ const emit = defineEmits<{
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column header="Duration">
|
||||
<!-- <Column header="Duration">
|
||||
<template #body="{ data }">
|
||||
<span class="text-sm text-gray-500">{{ formatDuration(data.duration) }}</span>
|
||||
</template>
|
||||
</Column>
|
||||
</Column> -->
|
||||
|
||||
<Column header="Size">
|
||||
<template #body="{ data }">
|
||||
@@ -78,9 +78,9 @@ const emit = defineEmits<{
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column header="Upload Date">
|
||||
<Column header="Created">
|
||||
<template #body="{ data }">
|
||||
<span class="text-sm text-gray-500">{{ formatDate(data.created_at) }}</span>
|
||||
<span class="text-sm text-gray-500">{{ formatDate(data.created_at, true) }}</span>
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ export type Manifest = {
|
||||
parts: Part[]
|
||||
createdAt: number
|
||||
expiresAt: number
|
||||
size: number
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -77,7 +78,7 @@ export async function getManifest(id: string): Promise<Manifest | null> {
|
||||
const manifest: Manifest = JSON.parse(text)
|
||||
|
||||
if (manifest.expiresAt < Date.now()) {
|
||||
await deleteManifest(id).catch(() => {})
|
||||
await deleteManifest(id).catch(() => { })
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -158,6 +159,7 @@ export async function getListFiles(): Promise<string[]> {
|
||||
export function createManifest(
|
||||
filename: string,
|
||||
chunks: string[],
|
||||
size: number,
|
||||
ttlMs = 60 * 60 * 1000,
|
||||
): Manifest {
|
||||
const id = crypto.randomUUID()
|
||||
@@ -170,6 +172,7 @@ export function createManifest(
|
||||
parts: chunks.map((url, index) => ({ index, host: detectHost(url), url: formatUrl(url) })),
|
||||
createdAt: now,
|
||||
expiresAt: now + ttlMs,
|
||||
size,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -10,29 +10,27 @@ const authMiddleware: MiddlewareHandler = async (c, next) => {
|
||||
const headers = new Headers(c.req.header());
|
||||
headers.delete("host");
|
||||
headers.delete("connection");
|
||||
|
||||
try {
|
||||
const res = await fetch(`${baseAPIURL}/me`, {
|
||||
method: 'GET',
|
||||
headers: headers,
|
||||
credentials: 'include'
|
||||
});
|
||||
const data = await res.json();
|
||||
|
||||
if (data.data?.user) {
|
||||
return await next();
|
||||
return fetch(`${baseAPIURL}/me`, {
|
||||
method: 'GET',
|
||||
headers: headers,
|
||||
credentials: 'include'
|
||||
}).then(res => res.json()).then((r) => {
|
||||
if (r.data?.user) {
|
||||
return next();
|
||||
}
|
||||
throw new Error("Unauthorized");
|
||||
} catch {
|
||||
else {
|
||||
throw new Error("Unauthorized");
|
||||
}
|
||||
}).catch(() => {
|
||||
return c.json({ error: "Unauthorized" }, 401);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export function registerMergeRoutes(app: Hono) {
|
||||
app.post('/merge', authMiddleware, async (c) => {
|
||||
try {
|
||||
const body = await c.req.json();
|
||||
const { filename, chunks } = body;
|
||||
const { filename, chunks, size } = body;
|
||||
|
||||
if (!filename || !Array.isArray(chunks) || chunks.length === 0) {
|
||||
return c.json({ error: 'invalid payload' }, 400);
|
||||
@@ -41,7 +39,7 @@ export function registerMergeRoutes(app: Hono) {
|
||||
const hostError = validateChunkUrls(chunks);
|
||||
if (hostError) return c.json({ error: hostError }, 400);
|
||||
|
||||
const manifest = createManifest(filename, chunks);
|
||||
const manifest = createManifest(filename, chunks, size);
|
||||
await saveManifest(manifest);
|
||||
|
||||
return c.json({
|
||||
@@ -49,6 +47,7 @@ export function registerMergeRoutes(app: Hono) {
|
||||
id: manifest.id,
|
||||
filename: manifest.filename,
|
||||
total_parts: manifest.total_parts,
|
||||
size: manifest.size,
|
||||
});
|
||||
} catch (e: any) {
|
||||
return c.json({ error: e?.message ?? String(e) }, 500);
|
||||
|
||||
@@ -49,9 +49,9 @@ export function registerSSRRoutes(app: Hono) {
|
||||
await stream.write(headResult.headTags.replace(/\n/g, ""));
|
||||
|
||||
// Fonts & Favicon
|
||||
await stream.write(`<link rel="preconnect" href="https://fonts.googleapis.com">`);
|
||||
await stream.write(`<link href="https://fonts.googleapis.com/css2?family=Google+Sans:ital,opsz,wght@0,17..18,400..700;1,17..18,400..700&display=swap" rel="stylesheet">`);
|
||||
await stream.write('<link rel="icon" href="/favicon.ico" />');
|
||||
await stream.write('<link rel="icon" href="/favicon.ico">');
|
||||
await stream.write(`<link rel="preconnect" href="https://fonts.googleapis.com" />`);
|
||||
await stream.write(`<link href="https://fonts.googleapis.com/css2?family=Google+Sans:ital,opsz,wght@0,17..18,400..700;1,17..18,400..700&display=swap" rel="stylesheet" />`);
|
||||
|
||||
// Bootstrap scripts
|
||||
await stream.write(buildBootstrapScript());
|
||||
|
||||
13
src/stores/uiState.ts
Normal file
13
src/stores/uiState.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { defineStore } from "pinia";
|
||||
import { ref } from "vue";
|
||||
|
||||
export const useUIState = defineStore('uiState', () => {
|
||||
const uploadDialogVisible = ref(false);
|
||||
|
||||
return {
|
||||
uploadDialogVisible,
|
||||
toggleUploadDialog: () => {
|
||||
uploadDialogVisible.value = !uploadDialogVisible.value;
|
||||
},
|
||||
};
|
||||
});
|
||||
Reference in New Issue
Block a user