update ui

This commit is contained in:
2026-01-23 19:04:24 +07:00
parent 7d3d33ef7e
commit 476c0eb647
14 changed files with 554 additions and 44 deletions

View File

@@ -66,7 +66,7 @@ const routes: RouteData[] = [
{
path: "",
name: "overview",
component: () => import("./add/Add.vue"),
component: () => import("./overview/Overview.vue"),
meta: {
head: {
title: 'Overview - Holistream',

View File

@@ -0,0 +1,79 @@
<script setup lang="ts">
import { client, type ModelVideo } from '@/api/client';
import PageHeader from '@/components/dashboard/PageHeader.vue';
import { onMounted, ref } from 'vue';
import QuickActions from './components/QuickActions.vue';
import RecentVideos from './components/RecentVideos.vue';
import StatsOverview from './components/StatsOverview.vue';
import StorageUsage from './components/StorageUsage.vue';
import WelcomeBanner from './components/WelcomeBanner.vue';
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 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;
}
};
onMounted(() => {
fetchDashboardData();
});
</script>
<template>
<div class="dashboard-overview">
<PageHeader title="Dashboard" description="Welcome back! Here's what's happening with your videos."
:breadcrumbs="[
{ label: 'Dashboard' }
]" />
<WelcomeBanner />
<!-- Stats Grid -->
<StatsOverview :loading="loading" :stats="stats" />
<!-- Quick Actions -->
<QuickActions :loading="loading" />
<!-- Recent Videos -->
<RecentVideos :loading="loading" :videos="recentVideos" />
<!-- Storage Usage -->
<!-- <StorageUsage :loading="loading" :stats="stats" /> -->
</div>
</template>

View File

@@ -0,0 +1,79 @@
<script setup lang="ts">
import Chart from '@/components/icons/Chart.vue';
import Credit from '@/components/icons/Credit.vue';
import Upload from '@/components/icons/Upload.vue';
import Video from '@/components/icons/Video.vue';
import Skeleton from 'primevue/skeleton';
import { useRouter } from 'vue-router';
import Referral from './Referral.vue';
interface Props {
loading: boolean;
}
defineProps<Props>();
const router = useRouter();
const quickActions = [
{
title: 'Upload Video',
description: 'Upload a new video to your library',
icon: Upload,
onClick: () => router.push('/upload')
},
{
title: 'Video Library',
description: 'Browse all your videos',
icon: Video,
onClick: () => router.push('/video')
},
{
title: 'Analytics',
description: 'Track performance & insights',
icon: Chart,
onClick: () => { }
},
{
title: 'Manage Plan',
description: 'Upgrade or change your plan',
icon: Credit,
onClick: () => router.push('/payments-and-plans')
},
];
</script>
<template>
<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 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>
<div v-else class="mb-8">
<h2 class="text-xl font-semibold mb-4">Quick Actions</h2>
<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',
'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']">
<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>
<p class="text-sm text-gray-600">{{ action.description }}</p>
</button>
</div>
<Referral />
</div>
</div>
</template>

View File

@@ -0,0 +1,134 @@
<script setup lang="ts">
import { ModelVideo } from '@/api/client';
import EmptyState from '@/components/dashboard/EmptyState.vue';
import { formatBytes, formatDate, formatDuration } from '@/lib/utils';
import Skeleton from 'primevue/skeleton';
import { useRouter } from 'vue-router';
interface Props {
loading: boolean;
videos: ModelVideo[];
}
defineProps<Props>();
const router = useRouter();
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';
}
};
</script>
<template>
<div class="mb-8">
<div v-if="loading">
<div class="flex items-center justify-between mb-4">
<Skeleton width="8rem" height="1.5rem"></Skeleton>
<Skeleton width="5rem" height="1rem"></Skeleton>
</div>
<div class="bg-white rounded-xl border border-gray-200 overflow-hidden">
<div class="p-4 border-b border-gray-200" v-for="i in 5" :key="i">
<div class="flex gap-4">
<Skeleton width="4rem" height="2.5rem" class="rounded"></Skeleton>
<div class="flex-1 space-y-2">
<Skeleton width="30%" height="1rem"></Skeleton>
<Skeleton width="20%" height="0.8rem"></Skeleton>
</div>
</div>
</div>
</div>
</div>
<div v-else>
<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>
<EmptyState v-if="videos.length === 0" title="No videos found"
description="You haven't uploaded any videos yet. Start by uploading your first video!"
imageUrl="https://cdn-icons-png.flaticon.com/512/7486/7486747.png" actionLabel="Upload Video"
:onAction="() => router.push('/upload')" />
<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 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">
{{ 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>
</div>
</template>

View File

@@ -0,0 +1,43 @@
<template>
<div class="rounded-xl border border-gray-300 hover:border-primary hover:shadow-lg text-card-foreground">
<div class="flex flex-col space-y-1.5 p-6">
<h3 class="text-2xl font-semibold leading-none tracking-tight">Referral Link</h3>
</div>
<div class="p-6 pt-0 space-y-4">
<p class="text-sm text-gray-600 font-medium">Share your referral link and earn commissions from
referred users!</p>
<div class="flex gap-2">
<InputText class="w-full" readonly type="text" :value="url" @click="copyToClipboard" />
<button class="btn btn-primary" @click="copyToClipboard" :disabled="isCopied">
<svg v-if="!isCopied" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24"
fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"
stroke-linejoin="round" class="lucide lucide-copy" aria-hidden="true">
<rect width="14" height="14" x="8" y="8" rx="2" ry="2"></rect>
<path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"></path>
</svg>
<svg v-else xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24"
fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"
stroke-linejoin="round" class="lucide lucide-check" aria-hidden="true">
<path d="M22 11.02V12a10 10 0 1 1-5.93-9.14"></path>
<path d="M22 4L12 14.01l-3-3"></path>
</svg>
</button>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { useAuthStore } from '@/stores/auth';
import { ref } from 'vue';
const auth = useAuthStore()
const isCopied = ref(false)
const url = location.origin + '/ref/' + auth.user?.username
const copyToClipboard = ($event: MouseEvent) => {
($event.target as HTMLInputElement).select()
navigator.clipboard.writeText(url)
isCopied.value = true
setTimeout(() => {
isCopied.value = false
}, 3000)
}
</script>

View File

@@ -0,0 +1,48 @@
<script setup lang="ts">
import StatsCard from '@/components/dashboard/StatsCard.vue';
import { formatBytes } from '@/lib/utils';
import Skeleton from 'primevue/skeleton';
interface Props {
loading: boolean;
stats: {
totalVideos: number;
totalViews: number;
storageUsed: number;
storageLimit: number;
uploadsThisMonth: number;
};
}
defineProps<Props>();
</script>
<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 class="flex items-center justify-between mb-4">
<div class="space-y-2">
<Skeleton width="5rem" height="1rem" class="mb-2"></Skeleton>
<Skeleton width="8rem" height="2rem"></Skeleton>
</div>
<Skeleton shape="circle" size="3rem"></Skeleton>
</div>
<Skeleton width="4rem" height="1rem"></Skeleton>
</div>
</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" 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>
</template>

View File

@@ -0,0 +1,78 @@
<script setup lang="ts">
import { formatBytes } from '@/lib/utils';
import { computed } from 'vue';
interface Props {
loading: boolean;
stats: {
totalVideos: number;
storageUsed: number;
storageLimit: number;
}
}
const props = defineProps<Props>();
const storagePercentage = computed(() => {
return Math.round((props.stats.storageUsed / props.stats.storageLimit) * 100);
});
const storageBreakdown = computed(() => {
const videoSize = props.stats.storageUsed;
const thumbSize = props.stats.totalVideos * 300 * 1024; // ~300KB per thumbnail
const otherSize = props.stats.totalVideos * 100 * 1024; // ~100KB other files
const total = videoSize + thumbSize + otherSize;
return [
{ label: 'Videos', size: videoSize, percentage: (videoSize / (total || 1)) * 100, color: 'bg-primary' },
{ label: 'Thumbnails & Assets', size: thumbSize, percentage: (thumbSize / (total || 1)) * 100, color: 'bg-blue-500' },
{ label: 'Other Files', size: otherSize, percentage: (otherSize / (total || 1)) * 100, color: 'bg-gray-400' },
];
});
</script>
<template>
<div v-if="!loading" 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>
</template>

View File

@@ -0,0 +1,16 @@
<script setup lang="ts">
import { useAuthStore } from '@/stores/auth';
const auth = useAuthStore()
</script>
<template>
<div class="bg-gradient-to-r to-success/20 p-4 sm:p-6 md:p-8 rounded-xl border-2 border-success/30 mb-8">
<h1 class="text-2xl sm:text-3xl md:text-4xl font-extrabold text-foreground mb-2">Welcome back, {{
auth.user?.username }}! 👋
</h1>
<p class="text-sm sm:text-base text-gray-600 font-medium">Here's what's happening with your content
today.</p>
</div>
</template>