add mock video

This commit is contained in:
2026-01-29 18:34:54 +07:00
parent 478c31defa
commit cf9c488012
26 changed files with 1093 additions and 455 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

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

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