feat: Implement a global upload dialog and refactor the upload UI/UX, including manifest size tracking.

This commit is contained in:
2026-02-27 03:49:54 +07:00
parent ff1d4902bc
commit a5b4028bc8
19 changed files with 538 additions and 432 deletions

View File

@@ -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)...&#10;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&#10;&#10;https://example.com/video.mp4&#10;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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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">

View File

@@ -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>