feat: Implement initial Vue 3 application structure with SSR, routing, authentication, and core dashboard components.

This commit is contained in:
2026-01-19 00:37:35 +07:00
parent 9f521c76f4
commit eed14fa0e5
14 changed files with 1029 additions and 127 deletions

View File

@@ -1,3 +1,353 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { useRouter } from 'vue-router';
import PageHeader from '@/components/dashboard/PageHeader.vue';
import StatsCard from '@/components/dashboard/StatsCard.vue';
import { client, type ModelVideo } from '@/api/client';
const router = useRouter();
const loading = ref(true);
const recentVideos = ref<ModelVideo[]>([]);
// Mock stats data (in real app, fetch from API)
const stats = ref({
totalVideos: 0,
totalViews: 0,
storageUsed: 0,
storageLimit: 10737418240, // 10GB in bytes
uploadsThisMonth: 0
});
const quickActions = [
{
title: 'Upload Video',
description: 'Upload a new video to your library',
icon: 'i-heroicons-cloud-arrow-up',
color: 'bg-gradient-to-br from-primary/20 to-primary/5',
iconColor: 'text-primary',
onClick: () => router.push('/upload')
},
{
title: 'Video Library',
description: 'Browse all your videos',
icon: 'i-heroicons-film',
color: 'bg-gradient-to-br from-blue-100 to-blue-50',
iconColor: 'text-blue-600',
onClick: () => router.push('/video')
},
{
title: 'Analytics',
description: 'Track performance & insights',
icon: 'i-heroicons-chart-bar',
color: 'bg-gradient-to-br from-purple-100 to-purple-50',
iconColor: 'text-purple-600',
onClick: () => {}
},
{
title: 'Manage Plan',
description: 'Upgrade or change your plan',
icon: 'i-heroicons-credit-card',
color: 'bg-gradient-to-br from-orange-100 to-orange-50',
iconColor: 'text-orange-600',
onClick: () => router.push('/plans')
},
];
const fetchDashboardData = async () => {
loading.value = true;
try {
// Fetch recent videos
const response = await client.videos.videosList({ page: 1, limit: 5 });
const body = response.data as any;
if (body.data && Array.isArray(body.data)) {
recentVideos.value = body.data;
stats.value.totalVideos = body.data.length;
} else if (Array.isArray(body)) {
recentVideos.value = body;
stats.value.totalVideos = body.length;
}
// Calculate mock stats
stats.value.totalViews = recentVideos.value.reduce((sum, v: any) => sum + (v.views || 0), 0);
stats.value.storageUsed = recentVideos.value.reduce((sum, v) => sum + (v.size || 0), 0);
stats.value.uploadsThisMonth = recentVideos.value.filter(v => {
const uploadDate = new Date(v.created_at || '');
const now = new Date();
return uploadDate.getMonth() === now.getMonth() && uploadDate.getFullYear() === now.getFullYear();
}).length;
} catch (err) {
console.error('Failed to fetch dashboard data:', err);
} finally {
loading.value = false;
}
};
const formatBytes = (bytes: number) => {
if (bytes === 0) 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 formatDuration = (seconds?: number) => {
if (!seconds) return '0:00';
const m = Math.floor(seconds / 60);
const s = Math.floor(seconds % 60);
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'
});
};
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 storagePercentage = computed(() => {
return Math.round((stats.value.storageUsed / stats.value.storageLimit) * 100);
});
const storageBreakdown = computed(() => {
const videoSize = stats.value.storageUsed;
const thumbSize = stats.value.totalVideos * 300 * 1024; // ~300KB per thumbnail
const otherSize = stats.value.totalVideos * 100 * 1024; // ~100KB other files
const total = videoSize + thumbSize + otherSize;
return [
{ label: 'Videos', size: videoSize, percentage: (videoSize / total) * 100, color: 'bg-primary' },
{ label: 'Thumbnails & Assets', size: thumbSize, percentage: (thumbSize / total) * 100, color: 'bg-blue-500' },
{ label: 'Other Files', size: otherSize, percentage: (otherSize / total) * 100, color: 'bg-gray-400' },
];
});
onMounted(() => {
fetchDashboardData();
});
</script>
<template>
<div>Add video</div>
</template>
<div class="dashboard-overview">
<PageHeader
title="Dashboard"
description="Welcome back! Here's what's happening with your videos."
:breadcrumbs="[
{ label: 'Dashboard' }
]"
/>
<!-- Loading State -->
<div v-if="loading" class="flex justify-center items-center py-20">
<div class="i-svg-spinners-180-ring-with-bg text-4xl text-primary"></div>
</div>
<div v-else>
<!-- Stats Grid -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
<StatsCard
title="Total Videos"
:value="stats.totalVideos"
icon="i-heroicons-film"
color="primary"
:trend="{ value: 12, isPositive: true }"
/>
<StatsCard
title="Total Views"
:value="stats.totalViews.toLocaleString()"
icon="i-heroicons-eye"
color="info"
:trend="{ value: 8, isPositive: true }"
/>
<StatsCard
title="Storage Used"
:value="`${formatBytes(stats.storageUsed)} / ${formatBytes(stats.storageLimit)}`"
icon="i-heroicons-server"
color="warning"
/>
<StatsCard
title="Uploads This Month"
:value="stats.uploadsThisMonth"
icon="i-heroicons-arrow-up-tray"
color="success"
:trend="{ value: 25, isPositive: true }"
/>
</div>
<!-- Quick Actions -->
<div class="mb-8">
<h2 class="text-xl font-semibold mb-4">Quick Actions</h2>
<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',
'border border-gray-200 hover:border-primary hover:shadow-lg',
'group press-animated',
action.color
]"
>
<div :class="['w-12 h-12 rounded-lg flex items-center justify-center mb-4 bg-white/80', action.iconColor]">
<span :class="[action.icon, 'w-6 h-6']" />
</div>
<h3 class="font-semibold mb-1 group-hover:text-primary transition-colors">{{ action.title }}</h3>
<p class="text-sm text-gray-600">{{ action.description }}</p>
</button>
</div>
</div>
<!-- Recent Videos -->
<div class="mb-8">
<div class="flex items-center justify-between mb-4">
<h2 class="text-xl font-semibold">Recent Videos</h2>
<router-link
to="/video"
class="text-sm text-primary hover:underline font-medium flex items-center gap-1"
>
View all
<span class="i-heroicons-arrow-right w-4 h-4" />
</router-link>
</div>
<div v-if="recentVideos.length === 0" class="bg-white rounded-xl border border-gray-200 p-8 text-center">
<div class="w-16 h-16 rounded-full bg-gray-100 flex items-center justify-center mx-auto mb-4">
<span class="i-heroicons-film w-8 h-8 text-gray-400" />
</div>
<p class="text-gray-600 mb-4">No videos yet</p>
<router-link
to="/upload"
class="inline-flex items-center gap-2 px-4 py-2 bg-primary hover:bg-primary-600 text-white rounded-lg font-medium transition-colors"
>
<span class="i-heroicons-plus w-5 h-5" />
Upload your first video
</router-link>
</div>
<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">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 recentVideos" :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-16 h-10 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">
<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', 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">
{{ 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 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>
</div>
</div>
<!-- Storage Usage -->
<div class="bg-white rounded-xl border border-gray-200 p-6">
<h2 class="text-xl font-semibold mb-4">Storage Usage</h2>
<div class="mb-4">
<div class="flex items-center justify-between mb-2">
<span class="text-sm font-medium text-gray-700">
{{ formatBytes(stats.storageUsed) }} of {{ formatBytes(stats.storageLimit) }} used
</span>
<span class="text-sm font-medium" :class="storagePercentage > 80 ? 'text-danger' : 'text-gray-700'">
{{ storagePercentage }}%
</span>
</div>
<div class="h-3 bg-gray-200 rounded-full overflow-hidden">
<div
class="h-full transition-all duration-500 rounded-full"
:class="storagePercentage > 80 ? 'bg-danger' : 'bg-primary'"
:style="{ width: `${storagePercentage}%` }"
/>
</div>
</div>
<div class="space-y-2">
<div
v-for="item in storageBreakdown"
:key="item.label"
class="flex items-center justify-between text-sm"
>
<div class="flex items-center gap-2">
<div :class="['w-3 h-3 rounded-sm', item.color]" />
<span class="text-gray-700">{{ item.label }}</span>
</div>
<span class="text-gray-500">{{ formatBytes(item.size) }}</span>
</div>
</div>
<div v-if="storagePercentage > 80" class="mt-4 p-3 bg-yellow-50 border border-yellow-200 rounded-lg">
<div class="flex gap-2">
<span class="i-heroicons-exclamation-triangle w-5 h-5 text-yellow-600 flex-shrink-0 mt-0.5" />
<div>
<p class="text-sm font-medium text-yellow-800">Storage running low</p>
<p class="text-sm text-yellow-700 mt-1">
Consider upgrading your plan to get more storage.
<router-link to="/plans" class="underline font-medium">View plans</router-link>
</p>
</div>
</div>
</div>
</div>
</div>
</div>
</template>

View File

@@ -39,7 +39,7 @@ import { z } from 'zod';
import { useAuthStore } from '@/stores/auth';
import { useToast } from "primevue/usetoast";
import { forgotPassword } from '@/lib/firebase';
import { client } from '@/api/client';
const auth = useAuthStore();
const toast = useToast();
@@ -56,11 +56,18 @@ const resolver = zodResolver(
const onFormSubmit = ({ valid, values }: FormSubmitEvent) => {
if (valid) {
forgotPassword(values.email).then(() => {
toast.add({ severity: 'success', summary: 'Success', detail: 'Reset link sent', life: 3000 });
}).catch(() => {
toast.add({ severity: 'error', summary: 'Error', detail: auth.error, life: 3000 });
});
client.auth.forgotPasswordCreate({ email: values.email })
.then(() => {
toast.add({ severity: 'success', summary: 'Success', detail: 'Reset link sent', life: 3000 });
})
.catch((error) => {
toast.add({ severity: 'error', summary: 'Error', detail: error.message || 'An error occurred', life: 3000 });
});
// forgotPassword(values.email).then(() => {
// toast.add({ severity: 'success', summary: 'Success', detail: 'Reset link sent', life: 3000 });
// }).catch(() => {
// toast.add({ severity: 'error', summary: 'Error', detail: auth.error, life: 3000 });
// });
}
};
</script>

View File

@@ -112,13 +112,23 @@ const routes: RouteData[] = [
{
path: "notification",
name: "notification",
component: () => import("./add/Add.vue"),
component: () => import("./add/Add.vue"), // TODO: create notification page
meta: {
head: {
title: 'Notification - Holistream',
},
}
},
{
path: "profile",
name: "profile",
component: () => import("./add/Add.vue"), // TODO: create profile page
meta: {
head: {
title: 'Profile - Holistream',
},
}
},
],
},
{

View File

@@ -1,73 +1,61 @@
<template>
<div class="p-6">
<div class="flex justify-between items-center mb-6">
<h1 class="text-3xl font-bold">My Videos</h1>
<router-link to="/upload" class="bg-primary-600 hover:bg-primary-700 text-white px-4 py-2 rounded transition-colors flex items-center">
<span class="i-heroicons-cloud-arrow-up mr-2"></span>
Upload Video
</router-link>
</div>
<div v-if="loading" class="flex justify-center">
<div class="i-svg-spinners-180-ring-with-bg text-4xl"></div>
</div>
<div v-else-if="error" class="text-red-500">
{{ error }}
</div>
<div v-else-if="videos.length === 0" class="text-center text-gray-500 py-10">
<p>No videos found. Upload your first video!</p>
</div>
<div v-else 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="border rounded-lg overflow-hidden shadow-sm hover:shadow-md transition-shadow bg-white dark:bg-gray-800">
<div class="aspect-video bg-gray-200 dark:bg-gray-700 relative group">
<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-video-camera text-4xl"></span>
</div>
<div class="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
<button class="text-white bg-primary-600 p-2 rounded-full hover:bg-primary-700" title="Play">
<span class="i-heroicons-play-20-solid text-xl"></span>
</button>
</div>
<span class="absolute bottom-2 right-2 bg-black/70 text-white text-xs px-1 rounded">{{ formatDuration(video.duration || 0) }}</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-2 truncate">{{ video.description || 'No description' }}</p>
<div class="flex justify-between items-center text-xs text-gray-400">
<span>{{ formatDate(video.created_at) }}</span>
<span :class="getStatusClass(video.status)">{{ video.status }}</span>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } 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';
const router = useRouter();
const videos = ref<ModelVideo[]>([]);
const loading = ref(true);
const error = ref<string | null>(null);
const searchQuery = ref('');
const selectedStatus = ref<string>('all');
const viewMode = ref<'grid' | 'table'>('table');
// Pagination
const page = ref(1);
const limit = ref(20);
const total = ref(0);
// Filters
const statusOptions = [
{ label: 'All Status', value: 'all' },
{ label: 'Ready', value: 'ready' },
{ label: 'Processing', value: 'processing' },
{ label: 'Failed', value: 'failed' },
];
const fetchVideos = async () => {
loading.value = true;
error.value = null;
try {
const response = await client.videos.videosList({ page: 1, limit: 50 });
// Based on docs.json, schema might be incomplete, assuming standard response structure
// response.data is the body. The body should have 'data' containing the list?
const body = response.data as any; // Cast because generated type didn't have data field
const response = await client.videos.videosList({ page: page.value, limit: limit.value });
const body = response.data as any;
if (body.data && Array.isArray(body.data)) {
videos.value = body.data;
videos.value = body.data;
total.value = body.total || body.data.length;
} else if (Array.isArray(body)) {
videos.value = body;
videos.value = body;
total.value = body.length;
} else {
console.warn('Unexpected video list format:', body);
videos.value = [];
console.warn('Unexpected video list format:', body);
videos.value = [];
}
// 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())
);
}
if (selectedStatus.value !== 'all') {
videos.value = videos.value.filter(v =>
v.status?.toLowerCase() === selectedStatus.value.toLowerCase()
);
}
} catch (err: any) {
console.error(err);
@@ -77,27 +65,310 @@ const fetchVideos = async () => {
}
};
const formatDuration = (seconds: number) => {
const m = Math.floor(seconds / 60);
const s = Math.floor(seconds % 60);
return `${m}:${s.toString().padStart(2, '0')}`;
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();
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 'text-green-500';
case 'processing': return 'text-yellow-500';
case 'failed': return 'text-red-500';
default: return 'text-gray-500';
}
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();
};
const handleFilter = () => {
page.value = 1;
fetchVideos();
};
const handlePageChange = (newPage: number) => {
page.value = newPage;
fetchVideos();
};
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();
} catch (err) {
console.error('Failed to delete video:', err);
}
};
onMounted(() => {
fetchVideos();
});
</script>
<template>
<div class="videos-page">
<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',
variant: 'primary',
onClick: () => router.push('/upload')
}
]"
/>
<!-- Filters & Search -->
<div class="bg-white rounded-xl border border-gray-200 p-4 mb-6">
<div class="flex flex-col md:flex-row gap-4">
<!-- Search -->
<div class="flex-1">
<div class="relative">
<span class="absolute left-3 top-1/2 -translate-y-1/2 i-heroicons-magnifying-glass w-5 h-5 text-gray-400" />
<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.5 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent"
/>
</div>
</div>
<!-- Status Filter -->
<select
v-model="selectedStatus"
@change="handleFilter"
class="px-4 py-2.5 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent"
>
<option v-for="option in statusOptions" :key="option.value" :value="option.value">
{{ option.label }}
</option>
</select>
<!-- View Mode Toggle -->
<div class="flex items-center gap-2 bg-gray-100 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"
>
<span class="i-heroicons-list-bullet w-5 h-5" :class="viewMode === 'table' ? 'text-primary' : 'text-gray-600'" />
</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"
>
<span class="i-heroicons-squares-2x2 w-5 h-5" :class="viewMode === 'grid' ? 'text-primary' : 'text-gray-600'" />
</button>
</div>
</div>
</div>
<!-- Loading State -->
<div v-if="loading" class="flex justify-center items-center py-20">
<div class="i-svg-spinners-180-ring-with-bg text-4xl text-primary"></div>
</div>
<!-- Error State -->
<div v-else-if="error" class="bg-red-50 border border-red-200 rounded-xl p-6 text-center">
<span class="i-heroicons-exclamation-circle text-red-500 text-4xl mb-3 inline-block" />
<p class="text-red-700 font-medium">{{ error }}</p>
<button @click="fetchVideos" class="mt-4 px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg transition-colors">
Try Again
</button>
</div>
<!-- Empty State -->
<EmptyState
v-else-if="videos.length === 0"
title="No videos found"
description="You haven't uploaded any videos yet. Start by uploading your first video!"
icon="i-heroicons-film"
actionLabel="Upload Video"
: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 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>
<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>
</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>
</div>
</template>