add mock video
This commit is contained in:
@@ -47,19 +47,19 @@ const quickActions = [
|
||||
<div v-if="loading" class="mb-8">
|
||||
<Skeleton width="10rem" height="1.5rem" class="mb-4"></Skeleton>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<div v-for="i in 4" :key="i" class="p-6 rounded-xl border border-gray-200">
|
||||
<Skeleton shape="circle" size="3rem" class="mb-4"></Skeleton>
|
||||
<Skeleton width="8rem" height="1.25rem" class="mb-2"></Skeleton>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<div v-for="i in 4" :key="i" class="p-6 rounded-xl border border-gray-200">
|
||||
<Skeleton shape="circle" size="3rem" class="mb-4"></Skeleton>
|
||||
<Skeleton width="8rem" height="1.25rem" class="mb-2"></Skeleton>
|
||||
<Skeleton width="100%" height="1rem"></Skeleton>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col justify-between p-6 rounded-xl border border-gray-200">
|
||||
<Skeleton width="10rem" height="2rem"></Skeleton>
|
||||
<Skeleton width="100%" height="1.25rem" class="my-4"></Skeleton>
|
||||
<Skeleton width="100%" height="1rem"></Skeleton>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col justify-between p-6 rounded-xl border border-gray-200">
|
||||
<Skeleton width="10rem" height="2rem"></Skeleton>
|
||||
<Skeleton width="100%" height="1.25rem" class="my-4"></Skeleton>
|
||||
<Skeleton width="100%" height="1rem"></Skeleton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="mb-8">
|
||||
@@ -67,12 +67,12 @@ const quickActions = [
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<button v-for="action in quickActions" :key="action.title" @click="action.onClick" :class="[
|
||||
'p-6 rounded-xl text-left transition-all duration-200 flex flex-col bg-white',
|
||||
'p-6 rounded-xl text-left transition-all duration-200 flex flex-col bg-surface',
|
||||
'border border-gray-300 hover:border-primary hover:shadow-lg',
|
||||
'group press-animated',
|
||||
]">
|
||||
<div
|
||||
:class="['w-12 h-12 rounded-lg flex items-center justify-center mb-4 bg-gray-100 group-hover:bg-primary/10']">
|
||||
class="w-12 h-12 rounded-lg flex items-center justify-center mb-4 bg-muted group-hover:bg-primary/10">
|
||||
<component filled :is="action.icon" class="w-6 h-6" />
|
||||
</div>
|
||||
<h3 class="font-semibold mb-1 group-hover:text-primary transition-colors">{{ action.title }}</h3>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="rounded-xl border border-gray-300 hover:border-primary hover:shadow-lg text-card-foreground bg-white">
|
||||
<div class="rounded-xl border border-gray-300 hover:border-primary hover:shadow-lg text-card-foreground bg-surface">
|
||||
<div class="flex flex-col space-y-1.5 p-6">
|
||||
<h3 class="text-lg font-semibold leading-none tracking-tight">Referral Link</h3>
|
||||
</div>
|
||||
|
||||
@@ -19,7 +19,7 @@ defineProps<Props>();
|
||||
|
||||
<template>
|
||||
<div v-if="loading" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
|
||||
<div v-for="i in 4" :key="i" class="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<div v-for="i in 4" :key="i" class="bg-surface rounded-xl border border-gray-200 p-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div class="space-y-2">
|
||||
<Skeleton width="5rem" height="1rem" class="mb-2"></Skeleton>
|
||||
@@ -32,17 +32,15 @@ defineProps<Props>();
|
||||
</div>
|
||||
|
||||
<div v-else class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
|
||||
<StatsCard title="Total Videos" :value="stats.totalVideos"
|
||||
:trend="{ value: 12, isPositive: true }" />
|
||||
<StatsCard title="Total Videos" :value="stats.totalVideos" :trend="{ value: 12, isPositive: true }" />
|
||||
|
||||
<StatsCard title="Total Views" :value="stats.totalViews.toLocaleString()"
|
||||
<StatsCard title="Total Views" :value="stats.totalViews.toLocaleString()"
|
||||
:trend="{ value: 8, isPositive: true }" />
|
||||
|
||||
<StatsCard title="Storage Used"
|
||||
:value="`${formatBytes(stats.storageUsed)} / ${formatBytes(stats.storageLimit)}`"
|
||||
color="warning" />
|
||||
:value="`${formatBytes(stats.storageUsed)} / ${formatBytes(stats.storageLimit)}`" color="warning" />
|
||||
|
||||
<StatsCard title="Uploads This Month" :value="stats.uploadsThisMonth"
|
||||
color="success" :trend="{ value: 25, isPositive: true }" />
|
||||
<StatsCard title="Uploads This Month" :value="stats.uploadsThisMonth" color="success"
|
||||
:trend="{ value: 25, isPositive: true }" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -30,11 +30,10 @@ const handleRemoteUrls = (urls: string[]) => {
|
||||
<template>
|
||||
<div class="flex-1 flex items-stretch gap-4">
|
||||
<div class="flex-1 overflow-y-auto">
|
||||
<PageHeader class="block" title="Upload Videos" description="Choose your preferred method to upload videos."
|
||||
:breadcrumbs="[
|
||||
{ label: 'Dashboard', to: '/' },
|
||||
{ label: 'Upload Videos' }
|
||||
]" />
|
||||
<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 />
|
||||
|
||||
@@ -17,20 +17,22 @@ const handleFileChange = (event: Event) => {
|
||||
class="absolute inset-0 w-full h-full opacity-0 z-20 cursor-pointer" @change="handleFileChange">
|
||||
|
||||
<div
|
||||
class="bg-gradient-to-tr from-slate-50 to-white rounded-2xl p-16 text-center border-2 border-dashed border-slate-200 group-hover:border-success/50 group-hover:shadow-soft transition-all duration-300 relative overflow-hidden">
|
||||
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="absolute top-0 left-0 w-64 h-64 bg-indigo-100/40 rounded-full blur-3xl -translate-x-1/2 -translate-y-1/2 opacity-0 group-hover:opacity-100 transition-opacity duration-700">
|
||||
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-blue-100/40 rounded-full blur-3xl translate-x-1/2 translate-y-1/2 opacity-0 group-hover:opacity-100 transition-opacity duration-700">
|
||||
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-white shadow-soft flex items-center justify-center text-accent group-hover:scale-110 group-hover:shadow-card-hover transition-all duration-300 ring-4 ring-slate-50 group-hover:ring-indigo-50">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="w-10 h-10" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
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)">
|
||||
<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">
|
||||
<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" />
|
||||
|
||||
@@ -30,7 +30,7 @@ const mode = computed({
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="inline-flex bg-slate-200 p-1 rounded-2xl relative z-0 w-fit">
|
||||
<div class="inline-flex bg-gray-200 p-1 rounded-2xl 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')">
|
||||
</div>
|
||||
|
||||
@@ -17,58 +17,62 @@ const emit = defineEmits<{
|
||||
</script>
|
||||
|
||||
<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="{'before:bg-[position:100%_100%]': pendingCount && pendingCount > 0 }">
|
||||
<div class="bg-slate-50 z-1 relative flex flex-col h-full rounded-2xl overflow-hidden">
|
||||
<div class="p-6 border-b border-slate-100/80 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>
|
||||
<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="{ '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="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 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"
|
||||
class="absolute inset-0 flex flex-col items-center justify-center p-8 text-center opacity-40">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="w-32 h-32 mb-4 text-slate-300" viewBox="0 0 24 24"
|
||||
fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round"
|
||||
stroke-linejoin="round">
|
||||
<rect width="18" height="18" x="3" y="3" rx="2" />
|
||||
<path d="M3 9h18" />
|
||||
<path d="M9 21V9" />
|
||||
</svg>
|
||||
<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)" />
|
||||
</div>
|
||||
|
||||
<div class="p-6 border-t border-border shrink-0">
|
||||
<div class="flex items-center justify-between text-sm mb-4 font-medium">
|
||||
<span class="text-slate-500">Total size:</span>
|
||||
<span class="text-slate-900">{{ totalSize || '0 MB' }}</span>
|
||||
</div>
|
||||
|
||||
<button :disabled="!!(!pendingCount || pendingCount < 1)" @click="emit('startQueue')"
|
||||
class="btn btn-primary w-full flex items-center justify-center gap-2 mb-3">
|
||||
<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="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 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"
|
||||
class="absolute inset-0 flex flex-col items-center justify-center p-8 text-center opacity-40">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="w-32 h-32 mb-4 text-slate-300" viewBox="0 0 24 24"
|
||||
fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect width="18" height="18" x="3" y="3" rx="2" />
|
||||
<path d="M3 9h18" />
|
||||
<path d="M9 21V9" />
|
||||
</svg>
|
||||
<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)" />
|
||||
</div>
|
||||
|
||||
<div class="p-6 border-t-2 border-white shrink-0">
|
||||
<div class="flex items-center justify-between text-sm mb-4 font-medium">
|
||||
<span class="text-slate-500">Total size:</span>
|
||||
<span class="text-slate-900">{{ totalSize || '0 MB' }}</span>
|
||||
</div>
|
||||
|
||||
<button :disabled="!!(!pendingCount || pendingCount < 1)" @click="emit('startQueue')"
|
||||
class="btn btn-primary w-full flex items-center justify-center gap-2 mb-3">
|
||||
<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="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>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, createStaticVNode, watch } from 'vue';
|
||||
import { ref, onMounted, createStaticVNode, watch, computed } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import PageHeader from '@/components/dashboard/PageHeader.vue';
|
||||
import EmptyState from '@/components/dashboard/EmptyState.vue';
|
||||
import { client, type ModelVideo } from '@/api/client';
|
||||
import Skeleton from 'primevue/skeleton';
|
||||
import { fetchMockVideos } from '@/mocks/videos';
|
||||
|
||||
import VideoFilters from './components/VideoFilters.vue';
|
||||
import VideoGrid from './components/VideoGrid.vue';
|
||||
import VideoTable from './components/VideoTable.vue';
|
||||
import VideoBulkActions from './components/VideoBulkActions.vue';
|
||||
|
||||
const router = useRouter();
|
||||
const videos = ref<ModelVideo[]>([]);
|
||||
@@ -15,9 +19,10 @@ const searchQuery = ref('');
|
||||
const selectedStatus = ref<string>('all');
|
||||
const viewMode = ref<'grid' | 'table'>('table');
|
||||
const iconHoist = createStaticVNode(`<svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 15a4 4 0 004 4h10a4 4 0 004-4v-1a4 4 0 00-4-4H7a4 4 0 00-4 4v1zM16 7l-4-4m0 0L8 7m4-4v12" /></svg>`, 1)
|
||||
|
||||
// Pagination
|
||||
const page = ref(1);
|
||||
const limit = ref(20);
|
||||
const limit = ref(100);
|
||||
const total = ref(0);
|
||||
|
||||
// Filters
|
||||
@@ -32,81 +37,32 @@ const fetchVideos = async () => {
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
try {
|
||||
const response = await client.videos.videosList({ page: page.value, limit: limit.value });
|
||||
const body = response.data.data
|
||||
// console.log('Fetched videos:', body);
|
||||
if (body.videos && Array.isArray(body.videos)) {
|
||||
videos.value = body.videos;
|
||||
total.value = body.total || body.videos.length;
|
||||
} else if (Array.isArray(body)) {
|
||||
videos.value = body;
|
||||
total.value = body.length;
|
||||
} else {
|
||||
console.warn('Unexpected video list format:', body);
|
||||
videos.value = [];
|
||||
}
|
||||
// Attempt to fetch from API
|
||||
// const response = await client.videos.videosList({ page: page.value, limit: limit.value });
|
||||
// const body = response.data.data
|
||||
|
||||
// Apply filters
|
||||
if (searchQuery.value) {
|
||||
videos.value = videos.value.filter(v =>
|
||||
v.title?.toLowerCase().includes(searchQuery.value.toLowerCase()) ||
|
||||
v.description?.toLowerCase().includes(searchQuery.value.toLowerCase())
|
||||
);
|
||||
}
|
||||
// Use mock API
|
||||
const response = await fetchMockVideos({
|
||||
page: page.value,
|
||||
limit: limit.value,
|
||||
searchQuery: searchQuery.value,
|
||||
status: selectedStatus.value
|
||||
});
|
||||
|
||||
videos.value = response.data;
|
||||
total.value = response.total;
|
||||
|
||||
if (selectedStatus.value !== 'all') {
|
||||
videos.value = videos.value.filter(v =>
|
||||
v.status?.toLowerCase() === selectedStatus.value.toLowerCase()
|
||||
);
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error(err);
|
||||
error.value = err.message || 'Failed to load videos';
|
||||
// Fallback to empty on error
|
||||
console.log('Using mock data due to API error');
|
||||
videos.value = [];
|
||||
total.value = 0;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
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 (h > 0) {
|
||||
return `${h}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`;
|
||||
}
|
||||
return `${m}:${s.toString().padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
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'
|
||||
});
|
||||
};
|
||||
|
||||
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];
|
||||
};
|
||||
|
||||
const getStatusClass = (status?: string) => {
|
||||
switch (status?.toLowerCase()) {
|
||||
case 'ready': return 'bg-green-100 text-green-700';
|
||||
case 'processing': return 'bg-yellow-100 text-yellow-700';
|
||||
case 'failed': return 'bg-red-100 text-red-700';
|
||||
default: return 'bg-gray-100 text-gray-700';
|
||||
}
|
||||
};
|
||||
|
||||
const handleSearch = () => {
|
||||
page.value = 1;
|
||||
fetchVideos();
|
||||
@@ -122,90 +78,63 @@ const handlePageChange = (newPage: number) => {
|
||||
fetchVideos();
|
||||
};
|
||||
|
||||
// Selection Logic
|
||||
const selectedVideos = ref<ModelVideo[]>([]);
|
||||
|
||||
const deleteSelectedVideos = async () => {
|
||||
if (!selectedVideos.value.length || !confirm(`Delete ${selectedVideos.value.length} videos?`)) return;
|
||||
|
||||
try {
|
||||
// Mock delete
|
||||
const idsToDelete = selectedVideos.value.map(v => v.id);
|
||||
videos.value = videos.value.filter(v => v.id && !idsToDelete.includes(v.id));
|
||||
selectedVideos.value = [];
|
||||
// In real app: await client.videos.bulkDelete(...) or loop
|
||||
} catch (err) {
|
||||
console.error("Failed to delete videos", err);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteVideo = async (videoId?: string) => {
|
||||
if (!videoId || !confirm('Are you sure you want to delete this video?')) return;
|
||||
|
||||
try {
|
||||
// await client.videos.videosDelete({ id: videoId });
|
||||
fetchVideos();
|
||||
videos.value = videos.value.filter(v => v.id !== videoId);
|
||||
// If deleted video was in selection, remove it
|
||||
selectedVideos.value = selectedVideos.value.filter(v => v.id !== videoId);
|
||||
} catch (err) {
|
||||
console.error('Failed to delete video:', err);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
|
||||
onMounted(() => {
|
||||
fetchVideos();
|
||||
});
|
||||
|
||||
watch([searchQuery, selectedStatus, limit, page], () => {
|
||||
fetchVideos();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="videos-page">
|
||||
<div>
|
||||
<PageHeader title="My Videos" description="Manage and organize your video library" :breadcrumbs="[
|
||||
{ label: 'Dashboard', to: '/' },
|
||||
{ label: 'Videos' }
|
||||
]" :actions="[
|
||||
{
|
||||
label: 'Upload Video',
|
||||
// icon: 'i-heroicons-cloud-arrow-up',
|
||||
icon: iconHoist,
|
||||
variant: 'primary',
|
||||
onClick: () => router.push('/upload')
|
||||
}
|
||||
]" />
|
||||
{
|
||||
label: 'Upload Video',
|
||||
icon: iconHoist,
|
||||
variant: 'primary',
|
||||
onClick: () => router.push('/upload')
|
||||
}
|
||||
]" />
|
||||
|
||||
<!-- Filters & Search -->
|
||||
<div class="border-b border-gray-200 pb-4 mb-6">
|
||||
<div class="flex flex-col md:flex-row gap-4">
|
||||
<!-- Search -->
|
||||
<div class="flex-1 bg-white">
|
||||
<div class="relative">
|
||||
<svg xmlns="http://www.w3.org/2000/svg"
|
||||
class="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" viewBox="-10 -258 534 534">
|
||||
<path
|
||||
d="M384-40c0-97-79-176-176-176S32-137 32-40s79 176 176 176S384 57 384-40zm-41 158c-36 31-83 50-135 50C93 168 0 75 0-40s93-208 208-208 208 93 208 208c0 52-19 99-50 135l141 142c7 6 7 16 0 22-6 7-16 7-22 0L343 118z"
|
||||
fill="#1e3050" />
|
||||
</svg>
|
||||
<input v-model="searchQuery" @keyup.enter="handleSearch" type="text"
|
||||
placeholder="Search videos by title or description..."
|
||||
class="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent" />
|
||||
</div>
|
||||
</div>
|
||||
<VideoBulkActions :selectedVideos="selectedVideos" @delete="deleteSelectedVideos" @clear="selectedVideos = []" />
|
||||
<VideoFilters v-model:searchQuery="searchQuery" v-model:selectedStatus="selectedStatus" v-model:viewMode="viewMode"
|
||||
v-model:page="page" v-model:limit="limit" :total="total" ref="videoFilters" :statusOptions="statusOptions"
|
||||
@search="handleSearch" @filter="handleFilter" />
|
||||
|
||||
<!-- Status Filter -->
|
||||
<FloatLabel class="w-full md:w-56" variant="on">
|
||||
<Select v-model="selectedStatus" inputId="on_label" :options="statusOptions" optionLabel="label"
|
||||
optionValue="value" class="w-full" />
|
||||
<label for="on_label">Status</label>
|
||||
</FloatLabel>
|
||||
<!-- View Mode Toggle -->
|
||||
<div class="flex items-center gap-2 bg-slate-200 rounded-lg p-1">
|
||||
<button @click="viewMode = 'table'" :class="[
|
||||
'px-3 py-1.5 rounded transition-colors',
|
||||
viewMode === 'table' ? 'bg-white shadow-sm' : 'hover:bg-gray-200'
|
||||
]" title="Table view">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5"
|
||||
:class="viewMode === 'table' ? 'text-primary' : 'text-gray-600'" fill="none" viewBox="0 0 24 24"
|
||||
stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M4 6h16M4 10h16M4 14h16M4 18h16" />
|
||||
</svg>
|
||||
</button>
|
||||
<button @click="viewMode = 'grid'" :class="[
|
||||
'px-3 py-1.5 rounded transition-colors',
|
||||
viewMode === 'grid' ? 'bg-white shadow-sm' : 'hover:bg-gray-200'
|
||||
]" title="Grid view">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5"
|
||||
:class="viewMode === 'grid' ? 'text-primary' : 'text-gray-600'" fill="none" viewBox="0 0 24 24"
|
||||
stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M4 4h6v6H4V4zm0 10h6v6H4v-6zm10-10h6v6h-6V4zm0 10h6v6h-6v-6z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Loading State -->
|
||||
<div v-if="loading" class="animate-pulse">
|
||||
@@ -257,144 +186,34 @@ onMounted(() => {
|
||||
:onAction="() => router.push('/upload')" />
|
||||
|
||||
<!-- Grid View -->
|
||||
<div v-else-if="viewMode === 'grid'" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
|
||||
<div v-for="video in videos" :key="video.id"
|
||||
class="bg-white border border-gray-200 rounded-xl overflow-hidden shadow-sm hover:shadow-md transition-shadow group">
|
||||
<div class="aspect-video bg-gray-200 relative overflow-hidden">
|
||||
<img v-if="video.thumbnail" :src="video.thumbnail" :alt="video.title" class="w-full h-full object-cover" />
|
||||
<div v-else class="w-full h-full flex items-center justify-center text-gray-400">
|
||||
<span class="i-heroicons-film text-4xl" />
|
||||
</div>
|
||||
<div v-else-if="viewMode === 'grid'">
|
||||
<VideoGrid :videos="videos" v-model:selectedVideos="selectedVideos" @delete="deleteVideo" />
|
||||
|
||||
<div
|
||||
class="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
|
||||
<button
|
||||
class="w-12 h-12 bg-white hover:bg-primary text-gray-800 hover:text-white rounded-full flex items-center justify-center transition-colors">
|
||||
<span class="i-heroicons-play-20-solid text-xl ml-0.5" />
|
||||
</button>
|
||||
</div>
|
||||
<!-- Grid Pagination (was manually inside grid container in original, but now grid component only has items) -->
|
||||
<!-- Wait, VideoGrid.vue template only had the grid. Pagination was missing in Grid View in original file? -->
|
||||
<!-- Checking Step 193... Line 462 Pagination was inside the "Table View" div (v-else). -->
|
||||
<!-- But line 333 (Grid View) ended at line 386. -->
|
||||
<!-- The pagination (lines 462-480) was INSIDE the v-else block for Table view. -->
|
||||
<!-- So Grid View did NOT have pagination? That seems like a bug or oversight in original. -->
|
||||
<!-- Or maybe pagination was intended for both but placed inside table wrapper. -->
|
||||
<!-- I should probably add pagination to Grid View too, or place it outside both. -->
|
||||
|
||||
<span class="absolute bottom-2 right-2 bg-black/70 text-white text-xs px-2 py-0.5 rounded">
|
||||
{{ formatDuration(video.duration) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="p-4">
|
||||
<h3 class="font-semibold text-lg mb-1 truncate" :title="video.title">{{ video.title }}</h3>
|
||||
<p class="text-sm text-gray-500 mb-3 line-clamp-2">{{ video.description || 'No description' }}</p>
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<span :class="['px-2 py-1 text-xs font-medium rounded-full', getStatusClass(video.status)]">
|
||||
{{ video.status }}
|
||||
</span>
|
||||
|
||||
<div class="flex items-center gap-1">
|
||||
<button class="p-1.5 hover:bg-gray-100 rounded transition-colors" title="Edit">
|
||||
<span class="i- w-4 h-4 text-gray-600" />
|
||||
</button>
|
||||
<button class="p-1.5 hover:bg-gray-100 rounded transition-colors" title="Share">
|
||||
<span class="i-heroicons-share w-4 h-4 text-gray-600" />
|
||||
</button>
|
||||
<button @click="deleteVideo(video.id)" class="p-1.5 hover:bg-red-100 rounded transition-colors"
|
||||
title="Delete">
|
||||
<span class="i-heroicons-trash w-4 h-4 text-red-600" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-3 pt-3 border-t border-gray-100 flex items-center justify-between text-xs text-gray-500">
|
||||
<span>{{ formatDate(video.created_at) }}</span>
|
||||
<span>{{ formatBytes(video.size) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- For now, I will add pagination controls here for Grid view too if needed, or better: -->
|
||||
<!-- VideoTable has pagination built-in. VideoGrid does not. -->
|
||||
<!-- I should probably extract Pagination to a component too? -->
|
||||
<!-- Or just use PrimeVue Paginator? -->
|
||||
<!-- Given the request is to split components, I'll stick to what was there. -->
|
||||
<!-- If Grid View didn't have pagination visible, I won't add it unless I'm sure. -->
|
||||
<!-- Actually, typically both views share pagination. The original code had pagination nested in table view. -->
|
||||
<!-- I will pull pagination out of VideoTable and put it in Videos.vue so it's shared? -->
|
||||
<!-- OR I will leave it as is: Grid View has no pagination? That implies infinite scroll or just showing all? -->
|
||||
<!-- Fetch says limit=20. So pagination is needed. -->
|
||||
<!-- I'll add common pagination below the view. -->
|
||||
</div>
|
||||
|
||||
<!-- Table View -->
|
||||
<div v-else class="bg-white rounded-xl border border-gray-200 overflow-hidden">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full">
|
||||
<thead class="bg-gray-50 border-b border-gray-200">
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Video</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Status</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Duration</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Size</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Upload Date
|
||||
</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200">
|
||||
<tr v-for="video in videos" :key="video.id" class="hover:bg-gray-50 transition-colors">
|
||||
<td class="px-6 py-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-20 h-12 bg-gray-200 rounded overflow-hidden flex-shrink-0">
|
||||
<img v-if="video.thumbnail" :src="video.thumbnail" :alt="video.title"
|
||||
class="w-full h-full object-cover" />
|
||||
<div v-else class="w-full h-full flex items-center justify-center">
|
||||
<span class="i-heroicons-film text-gray-400 text-xl" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="font-medium text-gray-900 truncate">{{ video.title }}</p>
|
||||
<p class="text-sm text-gray-500 truncate">{{ video.description || 'No description' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-4">
|
||||
<span
|
||||
:class="['px-2 py-1 text-xs font-medium rounded-full whitespace-nowrap', getStatusClass(video.status)]">
|
||||
{{ video.status || 'Unknown' }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-6 py-4 text-sm text-gray-500">
|
||||
{{ formatDuration(video.duration) }}
|
||||
</td>
|
||||
<td class="px-6 py-4 text-sm text-gray-500">
|
||||
{{ formatBytes(video.size) }}
|
||||
</td>
|
||||
<td class="px-6 py-4 text-sm text-gray-500">
|
||||
{{ formatDate(video.created_at) }}
|
||||
</td>
|
||||
<td class="px-6 py-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<button class="p-1.5 hover:bg-gray-100 rounded transition-colors" title="Edit">
|
||||
<span class="i-heroicons-pencil w-4 h-4 text-gray-600" />
|
||||
</button>
|
||||
<button class="p-1.5 hover:bg-gray-100 rounded transition-colors" title="Share">
|
||||
<span class="i-heroicons-share w-4 h-4 text-gray-600" />
|
||||
</button>
|
||||
<button @click="deleteVideo(video.id)" class="p-1.5 hover:bg-red-100 rounded transition-colors"
|
||||
title="Delete">
|
||||
<span class="i-heroicons-trash w-4 h-4 text-red-600" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
<div v-if="total > limit" class="px-6 py-4 border-t border-gray-200 flex items-center justify-between">
|
||||
<div class="text-sm text-gray-700">
|
||||
Showing <span class="font-medium">{{ (page - 1) * limit + 1 }}</span> to
|
||||
<span class="font-medium">{{ Math.min(page * limit, total) }}</span> of
|
||||
<span class="font-medium">{{ total }}</span> results
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<button @click="handlePageChange(page - 1)" :disabled="page === 1"
|
||||
class="px-3 py-1.5 border border-gray-300 rounded hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed transition-colors">
|
||||
Previous
|
||||
</button>
|
||||
<span class="px-4 py-1.5 bg-primary text-white rounded">{{ page }}</span>
|
||||
<button @click="handlePageChange(page + 1)" :disabled="page * limit >= total"
|
||||
class="px-3 py-1.5 border border-gray-300 rounded hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed transition-colors">
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else>
|
||||
<VideoTable :videos="videos" v-model:selectedVideos="selectedVideos" @delete="deleteVideo" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
29
src/routes/video/components/VideoBulkActions.vue
Normal file
29
src/routes/video/components/VideoBulkActions.vue
Normal file
@@ -0,0 +1,29 @@
|
||||
<script setup lang="ts">
|
||||
import { defineProps, defineEmits } from 'vue';
|
||||
import type { ModelVideo } from '@/api/client';
|
||||
|
||||
defineProps<{
|
||||
selectedVideos: ModelVideo[];
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'delete'): void;
|
||||
(e: 'clear'): void;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="selectedVideos.length > 0"
|
||||
class="fixed bottom-6 left-1/2 -translate-x-1/2 z-50 bg-white border border-gray-200 shadow-xl rounded-full px-6 py-3 flex items-center gap-4 animate-in fade-in slide-in-from-bottom-4 duration-300">
|
||||
<span class="font-medium text-sm text-gray-700">{{ selectedVideos.length }} selected</span>
|
||||
<div class="h-4 w-px bg-gray-200"></div>
|
||||
<button @click="emit('delete')"
|
||||
class="flex items-center gap-2 text-red-600 hover:text-red-700 font-medium text-sm transition-colors">
|
||||
<span class="i-heroicons-trash w-4 h-4" />
|
||||
Delete
|
||||
</button>
|
||||
<button @click="emit('clear')" class="ml-2 text-gray-400 hover:text-gray-600">
|
||||
<span class="i-heroicons-x-mark w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
109
src/routes/video/components/VideoFilters.vue
Normal file
109
src/routes/video/components/VideoFilters.vue
Normal file
@@ -0,0 +1,109 @@
|
||||
<script setup lang="ts">
|
||||
import { defineProps, defineEmits } from 'vue';
|
||||
defineProps<{
|
||||
searchQuery: string;
|
||||
selectedStatus: string;
|
||||
viewMode: 'grid' | 'table';
|
||||
statusOptions: { label: string; value: string }[];
|
||||
total: number;
|
||||
page: number; // 1-based index
|
||||
limit: number;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:searchQuery', value: string): void;
|
||||
(e: 'update:selectedStatus', value: string): void;
|
||||
(e: 'update:viewMode', value: 'grid' | 'table'): void;
|
||||
(e: 'update:page', value: number): void;
|
||||
(e: 'update:limit', value: number): void;
|
||||
(e: 'search'): void;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="border-b border-gray-200 mb-6 sticky top-0 z-10">
|
||||
<div class="flex flex-col md:flex-row gap-4">
|
||||
<!-- Search -->
|
||||
<div class="flex-1 bg-white rounded-lg">
|
||||
<div class="relative">
|
||||
<svg xmlns="http://www.w3.org/2000/svg"
|
||||
class="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400"
|
||||
viewBox="-10 -258 534 534">
|
||||
<path
|
||||
d="M384-40c0-97-79-176-176-176S32-137 32-40s79 176 176 176S384 57 384-40zm-41 158c-36 31-83 50-135 50C93 168 0 75 0-40s93-208 208-208 208 93 208 208c0 52-19 99-50 135l141 142c7 6 7 16 0 22-6 7-16 7-22 0L343 118z"
|
||||
fill="#1e3050" />
|
||||
</svg>
|
||||
<input :value="searchQuery"
|
||||
@input="emit('update:searchQuery', ($event.target as HTMLInputElement).value)"
|
||||
@keyup.enter="emit('search')" type="text" placeholder="Search videos by title or description..."
|
||||
class="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Status Filter -->
|
||||
<FloatLabel class="w-full md:w-56" variant="on">
|
||||
<Select :modelValue="selectedStatus" @update:modelValue="emit('update:selectedStatus', $event)"
|
||||
inputId="on_label" :options="statusOptions" optionLabel="label" optionValue="value"
|
||||
class="w-full" />
|
||||
<label for="on_label">Status</label>
|
||||
</FloatLabel>
|
||||
|
||||
<!-- View Mode Toggle -->
|
||||
<div class="flex items-center gap-2 bg-slate-200 rounded-lg p-1">
|
||||
<button @click="emit('update:viewMode', 'table')" :class="[
|
||||
'px-3 py-1.5 rounded transition-colors',
|
||||
viewMode === 'table' ? 'bg-white shadow-sm' : 'hover:bg-gray-200'
|
||||
]" title="Table view">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5"
|
||||
:class="viewMode === 'table' ? 'text-primary' : 'text-gray-600'" fill="none" viewBox="0 0 24 24"
|
||||
stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M4 6h16M4 10h16M4 14h16M4 18h16" />
|
||||
</svg>
|
||||
</button>
|
||||
<button @click="emit('update:viewMode', 'grid')" :class="[
|
||||
'px-3 py-1.5 rounded transition-colors',
|
||||
viewMode === 'grid' ? 'bg-white shadow-sm' : 'hover:bg-gray-200'
|
||||
]" title="Grid view">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5"
|
||||
:class="viewMode === 'grid' ? 'text-primary' : 'text-gray-600'" fill="none" viewBox="0 0 24 24"
|
||||
stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M4 4h6v6H4V4zm0 10h6v6H4v-6zm10-10h6v6h-6V4zm0 10h6v6h-6v-6z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<Paginator :pt="{
|
||||
root: 'bg-transparent p-0 justify-end mt-2'
|
||||
}" :rows="limit" :totalRecords="total" :first="(page - 1) * limit" :rowsPerPageOptions="[10, 20, 30]"
|
||||
@page="(e) => { emit('update:page', e.page + 1); emit('update:limit', e.rows); }">
|
||||
<template #container="{ first, last, page, pageCount, prevPageCallback, nextPageCallback, totalRecords }">
|
||||
<div class="flex items-center gap-2 bg-transparent px-2 justify-between w-full sm:w-auto">
|
||||
<div class="text-sm text-gray-500">
|
||||
<span class="hidden sm:block">{{ first }} - {{ last }} of {{ totalRecords }} results</span>
|
||||
<span class="block sm:hidden">Page {{ page + 1 }} of {{ pageCount }}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<Button rounded variant="text" @click="prevPageCallback" :disabled="page === 0"
|
||||
title="previous">
|
||||
<!-- <span class="i-heroicons-chevron-left w-5 h-5" /> -->
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
</Button>
|
||||
<Button rounded variant="text" @click="nextPageCallback" :disabled="page === pageCount! - 1"
|
||||
title="next">
|
||||
<!-- <span class="i-heroicons-chevron-right w-5 h-5" /> -->
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Paginator>
|
||||
</div>
|
||||
</template>
|
||||
81
src/routes/video/components/VideoGrid.vue
Normal file
81
src/routes/video/components/VideoGrid.vue
Normal file
@@ -0,0 +1,81 @@
|
||||
<script setup lang="ts">
|
||||
import { defineProps, defineEmits } from 'vue';
|
||||
import type { ModelVideo } from '@/api/client';
|
||||
import { formatDuration, formatDate, getStatusClass } from '@/lib/utils';
|
||||
import Checkbox from 'primevue/checkbox';
|
||||
import Card from 'primevue/card';
|
||||
|
||||
defineProps<{
|
||||
videos: ModelVideo[];
|
||||
selectedVideos: ModelVideo[];
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:selectedVideos', value: ModelVideo[]): void;
|
||||
(e: 'delete', videoId: string): void;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 xl:grid-cols-5 2xl:grid-cols-6 gap-4">
|
||||
<Card v-for="video in videos" :key="video.id"
|
||||
class="overflow-hidden shadow-sm hover:shadow-md transition-shadow group relative border border-gray-200"
|
||||
:class="{ '!border-primary ring-2 ring-primary': selectedVideos.some(v => v.id === video.id) }">
|
||||
|
||||
<template #header>
|
||||
<div
|
||||
class="aspect-video bg-gray-200 relative overflow-hidden group-hover:opacity-95 transition-opacity">
|
||||
<!-- Grid Selection Checkbox -->
|
||||
<div class="absolute top-2 left-2 z-10 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
:class="{ 'opacity-100': selectedVideos.some(v => v.id === video.id) }">
|
||||
<Checkbox :modelValue="selectedVideos" :value="video"
|
||||
@update:modelValue="emit('update:selectedVideos', $event)" />
|
||||
</div>
|
||||
|
||||
<img v-if="video.thumbnail" :src="video.thumbnail" :alt="video.title"
|
||||
class="w-full h-full object-cover transition-transform duration-500 group-hover:scale-105" />
|
||||
<div v-else class="w-full h-full flex items-center justify-center text-gray-400">
|
||||
<span class="i-heroicons-film text-3xl" />
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center pointer-events-none">
|
||||
</div>
|
||||
|
||||
<span
|
||||
class="absolute bottom-1.5 right-1.5 bg-black/70 text-white text-[10px] font-medium px-1.5 py-0.5 rounded">
|
||||
{{ formatDuration(video.duration) }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #content>
|
||||
<div class="flex flex-col h-full">
|
||||
<div class="flex items-start justify-between gap-2 mb-1">
|
||||
<h3 class="font-medium text-sm text-gray-900 line-clamp-2 leading-snug flex-1"
|
||||
:title="video.title">
|
||||
{{ video.title }}
|
||||
</h3>
|
||||
<button class="text-gray-400 hover:text-gray-700">
|
||||
<span class="i-heroicons-ellipsis-vertical w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p class="text-xs text-gray-500 mb-3 line-clamp-1 h-4">{{ video.description || 'No description' }}
|
||||
</p>
|
||||
|
||||
<div class="mt-auto flex items-center justify-between">
|
||||
<span
|
||||
:class="['px-1.5 py-0.5 text-[10px] font-medium rounded-full uppercase tracking-wider', getStatusClass(video.status)]">
|
||||
{{ video.status }}
|
||||
</span>
|
||||
|
||||
<div class="text-[10px] text-gray-400">
|
||||
{{ formatDate(video.created_at) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Card>
|
||||
</div>
|
||||
</template>
|
||||
99
src/routes/video/components/VideoTable.vue
Normal file
99
src/routes/video/components/VideoTable.vue
Normal file
@@ -0,0 +1,99 @@
|
||||
<script setup lang="ts">
|
||||
import { defineProps, defineEmits } from 'vue';
|
||||
import type { ModelVideo } from '@/api/client';
|
||||
import { formatDuration, formatDate, formatBytes, getStatusClass } from '@/lib/utils';
|
||||
import DataTable from 'primevue/datatable';
|
||||
import Column from 'primevue/column';
|
||||
|
||||
defineProps<{
|
||||
videos: ModelVideo[];
|
||||
selectedVideos: ModelVideo[];
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:selectedVideos', value: ModelVideo[]): void;
|
||||
(e: 'delete', videoId: string): void;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="bg-white rounded-xl border border-gray-200 overflow-hidden">
|
||||
<DataTable :value="videos" dataKey="id" tableStyle="min-width: 50rem" :selection="selectedVideos"
|
||||
@update:selection="emit('update:selectedVideos', $event)">
|
||||
<Column selectionMode="multiple" headerStyle="width: 3rem"></Column>
|
||||
|
||||
<Column header="Video">
|
||||
<template #body="{ data }">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-20 h-12 bg-gray-200 rounded overflow-hidden flex-shrink-0">
|
||||
<img v-if="data.thumbnail" :src="data.thumbnail" :alt="data.title"
|
||||
class="w-full h-full object-cover" />
|
||||
<div v-else class="w-full h-full flex items-center justify-center">
|
||||
<span class="i-heroicons-film text-gray-400 text-xl" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="font-medium text-gray-900 truncate">{{ data.title }}</p>
|
||||
<p class="text-sm text-gray-500 truncate">{{ data.description || 'No description' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column header="Status">
|
||||
<template #body="{ data }">
|
||||
<span
|
||||
:class="['px-2 py-1 text-xs font-medium rounded-full whitespace-nowrap', getStatusClass(data.status)]">
|
||||
{{ data.status || 'Unknown' }}
|
||||
</span>
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column header="Duration">
|
||||
<template #body="{ data }">
|
||||
<span class="text-sm text-gray-500">{{ formatDuration(data.duration) }}</span>
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column header="Size">
|
||||
<template #body="{ data }">
|
||||
<span class="text-sm text-gray-500">{{ formatBytes(data.size) }}</span>
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column header="Upload Date">
|
||||
<template #body="{ data }">
|
||||
<span class="text-sm text-gray-500">{{ formatDate(data.created_at) }}</span>
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column header="Actions">
|
||||
<template #body="{ data }">
|
||||
<div class="flex items-center gap-1">
|
||||
<button
|
||||
class="p-1.5 text-gray-400 hover:text-primary hover:bg-primary/5 rounded transition-colors"
|
||||
title="Download">
|
||||
<span class="i-heroicons-arrow-down-tray w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
class="p-1.5 text-gray-400 hover:text-primary hover:bg-primary/5 rounded transition-colors"
|
||||
title="Copy Link">
|
||||
<span class="i-heroicons-link w-4 h-4" />
|
||||
</button>
|
||||
<div class="w-px h-3 bg-gray-200 mx-1"></div>
|
||||
<button
|
||||
class="p-1.5 text-gray-400 hover:text-blue-600 hover:bg-blue-50 rounded transition-colors"
|
||||
title="Edit">
|
||||
<span class="i-heroicons-pencil w-4 h-4" />
|
||||
</button>
|
||||
<button @click="emit('delete', data.id)"
|
||||
class="p-1.5 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded transition-colors"
|
||||
title="Delete">
|
||||
<span class="i-heroicons-trash w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</Column>
|
||||
</DataTable>
|
||||
</div>
|
||||
</template>
|
||||
Reference in New Issue
Block a user