feat(video): enhance video management UI and functionality
- Refactor VideoBulkActions.vue to remove unused imports. - Update VideoFilters.vue to improve search and status filtering with new UI components from PrimeVue. - Modify VideoTable.vue to enhance action buttons for editing, copying, and deleting videos, using PrimeVue Button components. - Implement saveImageFromStream function in merge.ts to handle thumbnail image uploads. - Add new animation rule for card spring effect in uno.config.ts. - Create FileUploadType.vue icon component for local and remote file uploads. - Introduce CopyVideoModal.vue for sharing video links with enhanced user experience. - Add DetailVideoModal.vue for editing video details with form validation using Zod. - Establish new display routes in display.ts for handling thumbnail and metadata updates.
This commit is contained in:
@@ -1,37 +1,81 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
|
||||
const props = defineProps<{ maxFiles?: number }>();
|
||||
const emit = defineEmits<{ filesSelected: [files: FileList] }>();
|
||||
|
||||
const isDragOver = ref(false);
|
||||
let dragCounter = 0;
|
||||
|
||||
const toVideoFiles = (files: FileList | null | undefined): FileList | null => {
|
||||
if (!files?.length) return null;
|
||||
const limit = props.maxFiles ?? 5;
|
||||
const dt = new DataTransfer();
|
||||
Array.from(files)
|
||||
.filter(f => f.type.startsWith('video/'))
|
||||
.slice(0, limit)
|
||||
.forEach(f => dt.items.add(f));
|
||||
return dt.files.length ? dt.files : null;
|
||||
};
|
||||
|
||||
const handleFileChange = (event: Event) => {
|
||||
const input = event.target as HTMLInputElement;
|
||||
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);
|
||||
const files = toVideoFiles(input.files);
|
||||
if (files) emit('filesSelected', files);
|
||||
input.value = ''; // reset so same file can be re-selected
|
||||
};
|
||||
|
||||
const onDragEnter = (e: DragEvent) => {
|
||||
e.preventDefault();
|
||||
dragCounter++;
|
||||
isDragOver.value = true;
|
||||
};
|
||||
|
||||
const onDragLeave = (e: DragEvent) => {
|
||||
e.preventDefault();
|
||||
dragCounter--;
|
||||
if (dragCounter <= 0) {
|
||||
dragCounter = 0;
|
||||
isDragOver.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const onDragOver = (e: DragEvent) => {
|
||||
e.preventDefault(); // required to allow drop
|
||||
e.stopPropagation();
|
||||
};
|
||||
|
||||
const onDrop = (e: DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
dragCounter = 0;
|
||||
isDragOver.value = false;
|
||||
const files = toVideoFiles(e.dataTransfer?.files);
|
||||
if (files) emit('filesSelected', files);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="relative group cursor-pointer flex-1 flex flex-col h-full">
|
||||
<div class="relative group cursor-pointer flex-1 flex flex-col h-full"
|
||||
@dragenter="onDragEnter" @dragleave="onDragLeave" @dragover="onDragOver" @drop="onDrop">
|
||||
<input type="file" multiple accept="video/*"
|
||||
class="absolute inset-0 w-full h-full opacity-0 z-20 cursor-pointer" @change="handleFileChange">
|
||||
|
||||
<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="[
|
||||
'flex-1 flex flex-col items-center justify-center gap-4 rounded-xl border-2 border-dashed transition-all duration-200 py-6 px-4 h-full',
|
||||
isDragOver
|
||||
? 'border-accent bg-accent/5 scale-[0.99]'
|
||||
: 'border-slate-200 group-hover:border-accent/60 group-hover:bg-accent/[0.03]'
|
||||
]">
|
||||
|
||||
<!-- 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">
|
||||
<div :class="[
|
||||
'w-20 h-20 rounded-2xl flex items-center justify-center transition-all duration-200',
|
||||
isDragOver ? 'bg-accent/10 scale-110 shadow-md' : 'bg-slate-100 group-hover:bg-accent/10 group-hover:scale-105 group-hover:shadow-md'
|
||||
]">
|
||||
<svg xmlns="http://www.w3.org/2000/svg"
|
||||
class="w-10 h-10 text-slate-400 group-hover:text-accent transition-colors duration-300"
|
||||
:class="['w-10 h-10 transition-colors duration-200', isDragOver ? 'text-accent' : 'text-slate-400 group-hover:text-accent']"
|
||||
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" />
|
||||
@@ -39,12 +83,15 @@ const handleFileChange = (event: Event) => {
|
||||
<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 :class="[
|
||||
'absolute inset-0 rounded-2xl ring-4 transition-all duration-200',
|
||||
isDragOver ? 'ring-accent/30' : 'ring-accent/0 group-hover:ring-accent/20'
|
||||
]"></div>
|
||||
</div>
|
||||
|
||||
<div class="text-center">
|
||||
<p class="text-base font-semibold text-slate-700 group-hover:text-slate-900 transition-colors">
|
||||
Drop videos here
|
||||
<p :class="['text-base font-semibold transition-colors', isDragOver ? 'text-accent' : 'text-slate-700 group-hover:text-slate-900']">
|
||||
{{ isDragOver ? 'Release to add' : 'Drop videos here' }}
|
||||
</p>
|
||||
<p class="text-sm text-slate-400 mt-1.5">or click anywhere to browse</p>
|
||||
</div>
|
||||
@@ -52,7 +99,7 @@ const handleFileChange = (event: Event) => {
|
||||
<!-- 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">
|
||||
:class="['text-xs font-semibold px-3 py-1 rounded-lg tracking-wide transition-colors', isDragOver ? 'bg-accent/10 text-accent' : 'bg-slate-100 text-slate-500']">
|
||||
{{ fmt }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import FileUploadType from '@/components/icons/FileUploadType.vue';
|
||||
import type { QueueItem } from '@/composables/useUploadQueue';
|
||||
import { computed } from 'vue';
|
||||
|
||||
@@ -37,7 +38,7 @@ const isActive = computed(() =>
|
||||
);
|
||||
|
||||
const canCancel = computed(() =>
|
||||
props.item.status === 'uploading' || props.item.status === 'pending'
|
||||
props.item.status === 'uploading'
|
||||
);
|
||||
|
||||
const progress = computed(() => props.item.progress || 0);
|
||||
@@ -56,10 +57,7 @@ const progress = computed(() => props.item.progress || 0);
|
||||
<!-- 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>
|
||||
<FileUploadType :filled="item.type === 'local'" />
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
@@ -81,10 +79,16 @@ const progress = computed(() => props.item.progress || 0);
|
||||
<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 gap-2 text-[10px] font-medium" :class="statusVariant.text">
|
||||
<!-- Size -->
|
||||
<span v-if="item.type === 'local'" >
|
||||
{{ item.total }}
|
||||
</span>
|
||||
<span class="flex items-center gap-1" :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>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<!-- Progress % -->
|
||||
|
||||
Reference in New Issue
Block a user