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

2
components.d.ts vendored
View File

@@ -15,6 +15,7 @@ declare module 'vue' {
Add: typeof import('./src/components/icons/Add.vue')['default'] Add: typeof import('./src/components/icons/Add.vue')['default']
Bell: typeof import('./src/components/icons/Bell.vue')['default'] Bell: typeof import('./src/components/icons/Bell.vue')['default']
Button: typeof import('primevue/button')['default'] Button: typeof import('primevue/button')['default']
Chart: typeof import('./src/components/icons/Chart.vue')['default']
Checkbox: typeof import('primevue/checkbox')['default'] Checkbox: typeof import('primevue/checkbox')['default']
CheckIcon: typeof import('./src/components/icons/CheckIcon.vue')['default'] CheckIcon: typeof import('./src/components/icons/CheckIcon.vue')['default']
Credit: typeof import('./src/components/icons/Credit.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 Add: typeof import('./src/components/icons/Add.vue')['default']
const Bell: typeof import('./src/components/icons/Bell.vue')['default'] const Bell: typeof import('./src/components/icons/Bell.vue')['default']
const Button: typeof import('primevue/button')['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 Checkbox: typeof import('primevue/checkbox')['default']
const CheckIcon: typeof import('./src/components/icons/CheckIcon.vue')['default'] const CheckIcon: typeof import('./src/components/icons/CheckIcon.vue')['default']
const Credit: typeof import('./src/components/icons/Credit.vue')['default'] const Credit: typeof import('./src/components/icons/Credit.vue')['default']

View File

@@ -1,4 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { VNode } from 'vue';
interface Trend { interface Trend {
value: number; value: number;
isPositive: boolean; isPositive: boolean;
@@ -7,7 +9,7 @@ interface Trend {
interface Props { interface Props {
title: string; title: string;
value: string | number; value: string | number;
icon?: string; icon?: string | VNode;
trend?: Trend; trend?: Trend;
color?: 'primary' | 'success' | 'warning' | 'danger' | 'info'; color?: 'primary' | 'success' | 'warning' | 'danger' | 'info';
} }
@@ -34,24 +36,12 @@ const iconColors = {
</script> </script>
<template> <template>
<div <div :class="[
:class="[ 'stats-card relative overflow-hidden rounded-2xl p-6 bg-gradient-to-br',
'stats-card relative overflow-hidden rounded-2xl p-6 bg-gradient-to-br', gradients[color],
gradients[color], 'border border-white/50 shadow-sm hover:shadow-md transition-all duration-300',
'border border-white/50 shadow-sm hover:shadow-md transition-all duration-300', 'group cursor-pointer'
'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'
]"
/>
<!-- Content --> <!-- Content -->
<div class="relative z-10"> <div class="relative z-10">
<div class="flex items-start justify-between mb-3"> <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> <p class="text-3xl font-bold text-gray-900">{{ value }}</p>
</div> </div>
<div <div v-if="icon" :class="[
v-if="icon" 'w-12 h-12 rounded-xl flex items-center justify-center',
:class="[ 'bg-white/80 shadow-sm',
'w-12 h-12 rounded-xl flex items-center justify-center', iconColors[color]
'bg-white/80 shadow-sm', ]">
iconColors[color] <component :is="icon" class="w-6 h-6" />
]"
>
<span :class="[icon, 'w-6 h-6']" />
</div> </div>
</div> </div>
<!-- Trend Indicator --> <!-- Trend Indicator -->
<div v-if="trend" class="flex items-center gap-1 text-sm"> <div v-if="trend" class="flex items-center gap-1 text-sm">
<span <span :class="[
:class="[ 'flex items-center gap-1 font-medium',
'flex items-center gap-1 font-medium', trend.isPositive ? 'text-success' : 'text-danger'
trend.isPositive ? 'text-success' : 'text-danger' ]">
]" <span :class="[
> 'w-4 h-4',
<span trend.isPositive ? 'i-heroicons-arrow-trending-up' : 'i-heroicons-arrow-trending-down'
:class="[ ]" />
'w-4 h-4',
trend.isPositive ? 'i-heroicons-arrow-trending-up' : 'i-heroicons-arrow-trending-down'
]"
/>
{{ Math.abs(trend.value) }}% {{ Math.abs(trend.value) }}%
</span> </span>
<span class="text-gray-500">vs last month</span> <span class="text-gray-500">vs last month</span>

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

View File

@@ -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'
});
};

View File

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

View File

@@ -19,7 +19,7 @@ export const useAuthStore = defineStore('auth', () => {
format: "json", format: "json",
}).then(r => r.json()).then(r => { }).then(r => r.json()).then(r => {
if (r.data) { if (r.data) {
user.value = r.data as ModelUser; user.value = r.data.user as ModelUser;
} }
}).catch(() => {}).finally(() => { }).catch(() => {}).finally(() => {
initialized.value = true; initialized.value = true;

View File

@@ -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"; import { presetBootstrapBtn } from "./bootstrap_btn";
export default defineConfig({ export default defineConfig({
@@ -69,6 +69,16 @@ export default defineConfig({
light: "#e2e6ea", light: "#e2e6ea",
dark: "#e2e6ea", dark: "#e2e6ea",
}, },
foreground: {
DEFAULT: "#111827",
light: "#374151",
dark: "#000000",
},
muted: {
DEFAULT: "#f3f4f6",
light: "#fafafa",
dark: "#e5e7eb",
}
}, },
boxShadow: { boxShadow: {
"primary-box": "2px 2px 10px #aff6b8", "primary-box": "2px 2px 10px #aff6b8",