update ui
This commit is contained in:
2
components.d.ts
vendored
2
components.d.ts
vendored
@@ -15,6 +15,7 @@ declare module 'vue' {
|
||||
Add: typeof import('./src/components/icons/Add.vue')['default']
|
||||
Bell: typeof import('./src/components/icons/Bell.vue')['default']
|
||||
Button: typeof import('primevue/button')['default']
|
||||
Chart: typeof import('./src/components/icons/Chart.vue')['default']
|
||||
Checkbox: typeof import('primevue/checkbox')['default']
|
||||
CheckIcon: typeof import('./src/components/icons/CheckIcon.vue')['default']
|
||||
Credit: typeof import('./src/components/icons/Credit.vue')['default']
|
||||
@@ -47,6 +48,7 @@ declare global {
|
||||
const Add: typeof import('./src/components/icons/Add.vue')['default']
|
||||
const Bell: typeof import('./src/components/icons/Bell.vue')['default']
|
||||
const Button: typeof import('primevue/button')['default']
|
||||
const Chart: typeof import('./src/components/icons/Chart.vue')['default']
|
||||
const Checkbox: typeof import('primevue/checkbox')['default']
|
||||
const CheckIcon: typeof import('./src/components/icons/CheckIcon.vue')['default']
|
||||
const Credit: typeof import('./src/components/icons/Credit.vue')['default']
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { VNode } from 'vue';
|
||||
|
||||
interface Trend {
|
||||
value: number;
|
||||
isPositive: boolean;
|
||||
@@ -7,7 +9,7 @@ interface Trend {
|
||||
interface Props {
|
||||
title: string;
|
||||
value: string | number;
|
||||
icon?: string;
|
||||
icon?: string | VNode;
|
||||
trend?: Trend;
|
||||
color?: 'primary' | 'success' | 'warning' | 'danger' | 'info';
|
||||
}
|
||||
@@ -34,24 +36,12 @@ const iconColors = {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
:class="[
|
||||
'stats-card relative overflow-hidden rounded-2xl p-6 bg-gradient-to-br',
|
||||
gradients[color],
|
||||
'border border-white/50 shadow-sm hover:shadow-md transition-all duration-300',
|
||||
'group cursor-pointer'
|
||||
]"
|
||||
>
|
||||
<!-- Background Icon (decorative) -->
|
||||
<div
|
||||
v-if="icon"
|
||||
:class="[
|
||||
'absolute -right-4 -bottom-4 opacity-10 group-hover:opacity-20 transition-opacity',
|
||||
icon,
|
||||
'text-8xl'
|
||||
]"
|
||||
/>
|
||||
|
||||
<div :class="[
|
||||
'stats-card relative overflow-hidden rounded-2xl p-6 bg-gradient-to-br',
|
||||
gradients[color],
|
||||
'border border-white/50 shadow-sm hover:shadow-md transition-all duration-300',
|
||||
'group cursor-pointer'
|
||||
]">
|
||||
<!-- Content -->
|
||||
<div class="relative z-10">
|
||||
<div class="flex items-start justify-between mb-3">
|
||||
@@ -60,32 +50,25 @@ const iconColors = {
|
||||
<p class="text-3xl font-bold text-gray-900">{{ value }}</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="icon"
|
||||
:class="[
|
||||
'w-12 h-12 rounded-xl flex items-center justify-center',
|
||||
'bg-white/80 shadow-sm',
|
||||
iconColors[color]
|
||||
]"
|
||||
>
|
||||
<span :class="[icon, 'w-6 h-6']" />
|
||||
<div v-if="icon" :class="[
|
||||
'w-12 h-12 rounded-xl flex items-center justify-center',
|
||||
'bg-white/80 shadow-sm',
|
||||
iconColors[color]
|
||||
]">
|
||||
<component :is="icon" class="w-6 h-6" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Trend Indicator -->
|
||||
<div v-if="trend" class="flex items-center gap-1 text-sm">
|
||||
<span
|
||||
:class="[
|
||||
'flex items-center gap-1 font-medium',
|
||||
trend.isPositive ? 'text-success' : 'text-danger'
|
||||
]"
|
||||
>
|
||||
<span
|
||||
:class="[
|
||||
'w-4 h-4',
|
||||
trend.isPositive ? 'i-heroicons-arrow-trending-up' : 'i-heroicons-arrow-trending-down'
|
||||
]"
|
||||
/>
|
||||
<span :class="[
|
||||
'flex items-center gap-1 font-medium',
|
||||
trend.isPositive ? 'text-success' : 'text-danger'
|
||||
]">
|
||||
<span :class="[
|
||||
'w-4 h-4',
|
||||
trend.isPositive ? 'i-heroicons-arrow-trending-up' : 'i-heroicons-arrow-trending-down'
|
||||
]" />
|
||||
{{ Math.abs(trend.value) }}%
|
||||
</span>
|
||||
<span class="text-gray-500">vs last month</span>
|
||||
|
||||
7
src/components/icons/Chart.vue
Normal file
7
src/components/icons/Chart.vue
Normal file
@@ -0,0 +1,7 @@
|
||||
<template>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" v-if="filled" viewBox="0 0 580 524"><path d="M10 234v112c0 46 38 84 84 84s84-38 84-84V234c0-46-38-84-84-84s-84 38-84 84zM206 94v252c0 46 38 84 84 84s84-38 84-84V94c0-46-38-84-84-84s-84 38-84 84zm196 56v196c0 46 38 84 84 84s84-38 84-84V150c0-46-38-84-84-84s-84 38-84 84z" fill="#a6acb9"/><path d="M10 500c0-8 6-14 14-14h532c8 0 14 6 14 14s-6 14-14 14H24c-8 0-14-6-14-14z" fill="#1e3050"/></svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" v-else viewBox="-10 -226 532 468"><path d="M272-184c9 0 16 7 16 16v352c0 9-7 16-16 16h-32c-9 0-16-7-16-16v-352c0-9 7-16 16-16h32zm-32-32c-26 0-48 22-48 48v352c0 27 22 48 48 48h32c27 0 48-21 48-48v-352c0-26-21-48-48-48h-32zM80 8c9 0 16 7 16 16v160c0 9-7 16-16 16H48c-9 0-16-7-16-16V24c0-9 7-16 16-16h32zM48-24C22-24 0-2 0 24v160c0 27 22 48 48 48h32c27 0 48-21 48-48V24c0-26-21-48-48-48H48zm384-96h32c9 0 16 7 16 16v288c0 9-7 16-16 16h-32c-9 0-16-7-16-16v-288c0-9 7-16 16-16zm-48 16v288c0 27 22 48 48 48h32c27 0 48-21 48-48v-288c0-26-21-48-48-48h-32c-26 0-48 22-48 48z" fill="#1e3050"/></svg>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
defineProps<{ filled?: boolean }>();
|
||||
</script>
|
||||
@@ -50,3 +50,34 @@ export function getImageAspectRatio(url: string): Promise<AspectInfo> {
|
||||
}
|
||||
|
||||
|
||||
|
||||
export 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];
|
||||
};
|
||||
|
||||
export 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')}`;
|
||||
};
|
||||
|
||||
export 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'
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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',
|
||||
|
||||
79
src/routes/overview/Overview.vue
Normal file
79
src/routes/overview/Overview.vue
Normal 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>
|
||||
79
src/routes/overview/components/QuickActions.vue
Normal file
79
src/routes/overview/components/QuickActions.vue
Normal 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>
|
||||
134
src/routes/overview/components/RecentVideos.vue
Normal file
134
src/routes/overview/components/RecentVideos.vue
Normal 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>
|
||||
43
src/routes/overview/components/Referral.vue
Normal file
43
src/routes/overview/components/Referral.vue
Normal 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>
|
||||
48
src/routes/overview/components/StatsOverview.vue
Normal file
48
src/routes/overview/components/StatsOverview.vue
Normal 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>
|
||||
78
src/routes/overview/components/StorageUsage.vue
Normal file
78
src/routes/overview/components/StorageUsage.vue
Normal 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>
|
||||
16
src/routes/overview/components/WelcomeBanner.vue
Normal file
16
src/routes/overview/components/WelcomeBanner.vue
Normal 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>
|
||||
@@ -19,7 +19,7 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
format: "json",
|
||||
}).then(r => r.json()).then(r => {
|
||||
if (r.data) {
|
||||
user.value = r.data as ModelUser;
|
||||
user.value = r.data.user as ModelUser;
|
||||
}
|
||||
}).catch(() => {}).finally(() => {
|
||||
initialized.value = true;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { defineConfig, presetAttributify, presetTypography, presetWind4, transformerCompileClass, transformerVariantGroup } from 'unocss'
|
||||
import { defineConfig, presetAttributify, presetTypography, presetWind4, transformerCompileClass, transformerVariantGroup } from 'unocss';
|
||||
import { presetBootstrapBtn } from "./bootstrap_btn";
|
||||
|
||||
export default defineConfig({
|
||||
@@ -69,6 +69,16 @@ export default defineConfig({
|
||||
light: "#e2e6ea",
|
||||
dark: "#e2e6ea",
|
||||
},
|
||||
foreground: {
|
||||
DEFAULT: "#111827",
|
||||
light: "#374151",
|
||||
dark: "#000000",
|
||||
},
|
||||
muted: {
|
||||
DEFAULT: "#f3f4f6",
|
||||
light: "#fafafa",
|
||||
dark: "#e5e7eb",
|
||||
}
|
||||
},
|
||||
boxShadow: {
|
||||
"primary-box": "2px 2px 10px #aff6b8",
|
||||
|
||||
Reference in New Issue
Block a user