feat: Implement initial Vue 3 application structure with SSR, routing, authentication, and core dashboard components.
This commit is contained in:
28
components.d.ts
vendored
28
components.d.ts
vendored
@@ -13,30 +13,32 @@ export {}
|
||||
declare module 'vue' {
|
||||
export interface GlobalComponents {
|
||||
Add: typeof import('./src/components/icons/Add.vue')['default']
|
||||
AddFilled: typeof import('./src/components/icons/AddFilled.vue')['default']
|
||||
Bell: typeof import('./src/components/icons/Bell.vue')['default']
|
||||
BellFilled: typeof import('./src/components/icons/BellFilled.vue')['default']
|
||||
Button: typeof import('primevue/button')['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']
|
||||
DashboardLayout: typeof import('./src/components/DashboardLayout.vue')['default']
|
||||
DashboardSidebar: typeof import('./src/components/dashboard/DashboardSidebar.vue')['default']
|
||||
DashboardTopbar: typeof import('./src/components/dashboard/DashboardTopbar.vue')['default']
|
||||
EmptyState: typeof import('./src/components/dashboard/EmptyState.vue')['default']
|
||||
Home: typeof import('./src/components/icons/Home.vue')['default']
|
||||
HomeFilled: typeof import('./src/components/icons/HomeFilled.vue')['default']
|
||||
IconField: typeof import('primevue/iconfield')['default']
|
||||
InputIcon: typeof import('primevue/inputicon')['default']
|
||||
InputText: typeof import('primevue/inputtext')['default']
|
||||
Layout: typeof import('./src/components/icons/Layout.vue')['default']
|
||||
LayoutFilled: typeof import('./src/components/icons/LayoutFilled.vue')['default']
|
||||
Message: typeof import('primevue/message')['default']
|
||||
OverlayPanel: typeof import('primevue/overlaypanel')['default']
|
||||
PageHeader: typeof import('./src/components/dashboard/PageHeader.vue')['default']
|
||||
Password: typeof import('primevue/password')['default']
|
||||
RootLayout: typeof import('./src/components/RootLayout.vue')['default']
|
||||
RouterLink: typeof import('vue-router')['RouterLink']
|
||||
RouterView: typeof import('vue-router')['RouterView']
|
||||
StatsCard: typeof import('./src/components/dashboard/StatsCard.vue')['default']
|
||||
TestIcon: typeof import('./src/components/icons/TestIcon.vue')['default']
|
||||
Toast: typeof import('primevue/toast')['default']
|
||||
Upload: typeof import('./src/components/icons/Upload.vue')['default']
|
||||
UploadFilled: typeof import('./src/components/icons/UploadFilled.vue')['default']
|
||||
Video: typeof import('./src/components/icons/Video.vue')['default']
|
||||
VideoFilled: typeof import('./src/components/icons/VideoFilled.vue')['default']
|
||||
VueHead: typeof import('./src/components/VueHead.tsx')['default']
|
||||
}
|
||||
}
|
||||
@@ -44,29 +46,31 @@ declare module 'vue' {
|
||||
// For TSX support
|
||||
declare global {
|
||||
const Add: typeof import('./src/components/icons/Add.vue')['default']
|
||||
const AddFilled: typeof import('./src/components/icons/AddFilled.vue')['default']
|
||||
const Bell: typeof import('./src/components/icons/Bell.vue')['default']
|
||||
const BellFilled: typeof import('./src/components/icons/BellFilled.vue')['default']
|
||||
const Button: typeof import('primevue/button')['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']
|
||||
const DashboardLayout: typeof import('./src/components/DashboardLayout.vue')['default']
|
||||
const DashboardSidebar: typeof import('./src/components/dashboard/DashboardSidebar.vue')['default']
|
||||
const DashboardTopbar: typeof import('./src/components/dashboard/DashboardTopbar.vue')['default']
|
||||
const EmptyState: typeof import('./src/components/dashboard/EmptyState.vue')['default']
|
||||
const Home: typeof import('./src/components/icons/Home.vue')['default']
|
||||
const HomeFilled: typeof import('./src/components/icons/HomeFilled.vue')['default']
|
||||
const IconField: typeof import('primevue/iconfield')['default']
|
||||
const InputIcon: typeof import('primevue/inputicon')['default']
|
||||
const InputText: typeof import('primevue/inputtext')['default']
|
||||
const Layout: typeof import('./src/components/icons/Layout.vue')['default']
|
||||
const LayoutFilled: typeof import('./src/components/icons/LayoutFilled.vue')['default']
|
||||
const Message: typeof import('primevue/message')['default']
|
||||
const OverlayPanel: typeof import('primevue/overlaypanel')['default']
|
||||
const PageHeader: typeof import('./src/components/dashboard/PageHeader.vue')['default']
|
||||
const Password: typeof import('primevue/password')['default']
|
||||
const RootLayout: typeof import('./src/components/RootLayout.vue')['default']
|
||||
const RouterLink: typeof import('vue-router')['RouterLink']
|
||||
const RouterView: typeof import('vue-router')['RouterView']
|
||||
const StatsCard: typeof import('./src/components/dashboard/StatsCard.vue')['default']
|
||||
const TestIcon: typeof import('./src/components/icons/TestIcon.vue')['default']
|
||||
const Toast: typeof import('primevue/toast')['default']
|
||||
const Upload: typeof import('./src/components/icons/Upload.vue')['default']
|
||||
const UploadFilled: typeof import('./src/components/icons/UploadFilled.vue')['default']
|
||||
const Video: typeof import('./src/components/icons/Video.vue')['default']
|
||||
const VideoFilled: typeof import('./src/components/icons/VideoFilled.vue')['default']
|
||||
const VueHead: typeof import('./src/components/VueHead.tsx')['default']
|
||||
}
|
||||
@@ -2,18 +2,19 @@ import { tryGetContext } from "hono/context-storage";
|
||||
|
||||
export const customFetch = async (url: string, options: RequestInit) => {
|
||||
options.credentials = "include";
|
||||
if (!options.headers) {
|
||||
options.headers = {};
|
||||
}
|
||||
if (import.meta.env.SSR) {
|
||||
const c = tryGetContext<any>();
|
||||
if (!c) {
|
||||
throw new Error("Hono context not found in SSR");
|
||||
}
|
||||
// Object.entries(c.req.header()).forEach(([k, v]) => {
|
||||
// Object.assign(options.headers!, { [k]: v });
|
||||
// });
|
||||
return await c.get("fetch")(url, options);
|
||||
Object.assign(options, {
|
||||
headers: c.req.header()
|
||||
});
|
||||
const res = await fetch(url, options);
|
||||
res.headers.forEach((value, key) => {
|
||||
c.header(key, value);
|
||||
});
|
||||
return res;
|
||||
}
|
||||
return fetch(url, options);
|
||||
}
|
||||
@@ -13,13 +13,18 @@ const auth = useAuthStore();
|
||||
|
||||
const className = ":uno: w-12 h-12 p-2 rounded-2xl hover:bg-primary/15 flex press-animated items-center justify-center";
|
||||
const homeHoist = createStaticVNode(`<img class="h-8 w-8" src="/apple-touch-icon.png" alt="Logo" />`, 1);
|
||||
const profileHoist = createStaticVNode(`<div class="h-[38px] w-[38px] rounded-full m-a ring-2 ring flex press-animated">
|
||||
<img class="h-8 w-8 rounded-full m-a ring-1 ring-white"
|
||||
src="https://picsum.photos/seed/user123/40/40.jpg" alt="User avatar" />
|
||||
</div>`, 1);
|
||||
const links = [
|
||||
{ href: "/fdsfsd", label: "app", icon: homeHoist, type: "btn" },
|
||||
{ href: "/", label: "Home", icon: Home, type: "a" },
|
||||
{ href: "/upload", label: "Upload", icon: Upload, type: "a" },
|
||||
{ href: "/video", label: "Video", icon: Video, type: "a" },
|
||||
{ href: "/plans", label: "Plans", icon: Credit, type: "a" },
|
||||
{ href: "/notification", label: "Notification", icon: Bell, type: "a" },
|
||||
{ href: "/fdsfsd", label: "app", icon: homeHoist, type: "btn", className },
|
||||
{ href: "/", label: "Overview", icon: Home, type: "a", className },
|
||||
{ href: "/upload", label: "Upload", icon: Upload, type: "a", className },
|
||||
{ href: "/video", label: "Video", icon: Video, type: "a", className },
|
||||
{ href: "/plans", label: "Plans", icon: Credit, type: "a", className },
|
||||
{ href: "/notification", label: "Notification", icon: Bell, type: "a", className },
|
||||
{ href: "/profile", label: "Profile", icon: profileHoist, type: "a", className: 'w-12 h-12 rounded-2xl hover:bg-primary/15 flex' },
|
||||
];
|
||||
</script>
|
||||
<template>
|
||||
@@ -27,15 +32,9 @@ const links = [
|
||||
class=":uno: fixed left-0 w-18 flex flex-col items-center pt-4 gap-6 z-41 max-h-screen h-screen border-r border-gray-200 bg-white">
|
||||
<component :is="i.type === 'a' ? 'router-link' : 'div'" v-for="i in links" :key="i.label"
|
||||
v-bind="i.type === 'a' ? { to: i.href } : {}" v-tooltip="i.label"
|
||||
:class="cn(className, $route.path === i.href && 'bg-primary/15')">
|
||||
:class="cn(i.className, $route.path === i.href && 'bg-primary/15')">
|
||||
<component :is="i.icon" :filled="$route.path === i.href" />
|
||||
</component>
|
||||
<div class="w-12 h-12 rounded-2xl hover:bg-primary/15 flex">
|
||||
<button class="h-[38px] w-[38px] rounded-full m-a ring-2 ring flex press-animated" @click="auth.logout()">
|
||||
<img class="h-8 w-8 rounded-full m-a ring-1 ring-white"
|
||||
src="https://picsum.photos/seed/user123/40/40.jpg" alt="User avatar" />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
<main class="flex flex-1 overflow-hidden md:ps-18">
|
||||
<div class="flex-1 overflow-auto p-4 bg-white rounded-lg md:(mr-2 mb-2) min-h-[calc(100vh-8rem)]">
|
||||
|
||||
53
src/components/dashboard/EmptyState.vue
Normal file
53
src/components/dashboard/EmptyState.vue
Normal file
@@ -0,0 +1,53 @@
|
||||
<script setup lang="ts">
|
||||
interface Props {
|
||||
title: string;
|
||||
description?: string;
|
||||
icon?: string;
|
||||
actionLabel?: string;
|
||||
onAction?: () => void;
|
||||
imageUrl?: string;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="empty-state flex flex-col items-center justify-center py-12 px-6 text-center">
|
||||
<!-- Icon or Image -->
|
||||
<div v-if="imageUrl" class="mb-6">
|
||||
<img :src="imageUrl" :alt="title" class="w-64 h-64 object-contain opacity-80" />
|
||||
</div>
|
||||
<div
|
||||
v-else-if="icon"
|
||||
class="mb-6 w-24 h-24 rounded-full bg-gray-100 flex items-center justify-center"
|
||||
>
|
||||
<span :class="[icon, 'w-12 h-12 text-gray-400']" />
|
||||
</div>
|
||||
<div v-else class="mb-6 w-24 h-24 rounded-full bg-gray-100 flex items-center justify-center">
|
||||
<span class="i-heroicons-inbox w-12 h-12 text-gray-400" />
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<h3 class="text-xl font-semibold text-gray-900 mb-2">{{ title }}</h3>
|
||||
<p v-if="description" class="text-gray-600 mb-6 max-w-md">{{ description }}</p>
|
||||
|
||||
<!-- Action Button -->
|
||||
<button
|
||||
v-if="actionLabel && onAction"
|
||||
@click="onAction"
|
||||
class="px-6 py-3 bg-primary hover:bg-primary-600 text-white rounded-lg font-medium transition-colors press-animated flex items-center gap-2"
|
||||
>
|
||||
<span class="i-heroicons-plus w-5 h-5" />
|
||||
{{ actionLabel }}
|
||||
</button>
|
||||
|
||||
<!-- Slot for custom actions -->
|
||||
<slot name="actions" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.empty-state {
|
||||
min-height: 400px;
|
||||
}
|
||||
</style>
|
||||
79
src/components/dashboard/PageHeader.vue
Normal file
79
src/components/dashboard/PageHeader.vue
Normal file
@@ -0,0 +1,79 @@
|
||||
<script setup lang="ts">
|
||||
interface Breadcrumb {
|
||||
label: string;
|
||||
to?: string;
|
||||
}
|
||||
|
||||
interface Action {
|
||||
label: string;
|
||||
icon?: string;
|
||||
variant?: 'primary' | 'secondary' | 'danger';
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
description?: string;
|
||||
breadcrumbs?: Breadcrumb[];
|
||||
actions?: Action[];
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
const getButtonClass = (variant?: string) => {
|
||||
const baseClass = 'px-4 py-2.5 rounded-lg font-medium transition-all press-animated flex items-center gap-2';
|
||||
|
||||
switch (variant) {
|
||||
case 'primary':
|
||||
return `${baseClass} bg-primary hover:bg-primary-600 text-white shadow-sm`;
|
||||
case 'danger':
|
||||
return `${baseClass} bg-danger hover:bg-danger-600 text-white shadow-sm`;
|
||||
case 'secondary':
|
||||
default:
|
||||
return `${baseClass} bg-white hover:bg-gray-50 text-gray-700 border border-gray-300`;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page-header mb-6">
|
||||
<!-- Breadcrumb -->
|
||||
<nav v-if="breadcrumbs && breadcrumbs.length" class="flex items-center gap-2 text-sm mb-2">
|
||||
<template v-for="(crumb, index) in breadcrumbs" :key="index">
|
||||
<router-link
|
||||
v-if="crumb.to"
|
||||
:to="crumb.to"
|
||||
class="text-gray-500 hover:text-primary transition-colors"
|
||||
>
|
||||
{{ crumb.label }}
|
||||
</router-link>
|
||||
<span v-else class="text-gray-700 font-medium">{{ crumb.label }}</span>
|
||||
|
||||
<span
|
||||
v-if="index < breadcrumbs.length - 1"
|
||||
class="i-heroicons-chevron-right w-4 h-4 text-gray-400"
|
||||
/>
|
||||
</template>
|
||||
</nav>
|
||||
|
||||
<!-- Title & Actions -->
|
||||
<div class="flex items-start justify-between gap-4 flex-wrap">
|
||||
<div class="flex-1 min-w-0">
|
||||
<h1 class="text-3xl font-bold text-gray-900 mb-1">{{ title }}</h1>
|
||||
<p v-if="description" class="text-gray-600">{{ description }}</p>
|
||||
</div>
|
||||
|
||||
<div v-if="actions && actions.length" class="flex items-center gap-2 flex-shrink-0">
|
||||
<button
|
||||
v-for="(action, index) in actions"
|
||||
:key="index"
|
||||
@click="action.onClick"
|
||||
:class="getButtonClass(action.variant)"
|
||||
>
|
||||
<span v-if="action.icon" :class="[action.icon, 'w-5 h-5']" />
|
||||
{{ action.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
105
src/components/dashboard/StatsCard.vue
Normal file
105
src/components/dashboard/StatsCard.vue
Normal file
@@ -0,0 +1,105 @@
|
||||
<script setup lang="ts">
|
||||
interface Trend {
|
||||
value: number;
|
||||
isPositive: boolean;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
value: string | number;
|
||||
icon?: string;
|
||||
trend?: Trend;
|
||||
color?: 'primary' | 'success' | 'warning' | 'danger' | 'info';
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
color: 'primary'
|
||||
});
|
||||
|
||||
const gradients = {
|
||||
primary: 'from-primary/20 to-primary/5',
|
||||
success: 'from-success/20 to-success/5',
|
||||
warning: 'from-yellow-100 to-yellow-50',
|
||||
danger: 'from-danger/20 to-danger/5',
|
||||
info: 'from-info/20 to-info/5',
|
||||
};
|
||||
|
||||
const iconColors = {
|
||||
primary: 'text-primary',
|
||||
success: 'text-success',
|
||||
warning: 'text-yellow-600',
|
||||
danger: 'text-danger',
|
||||
info: 'text-info',
|
||||
};
|
||||
</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'
|
||||
]"
|
||||
/>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="relative z-10">
|
||||
<div class="flex items-start justify-between mb-3">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-600 mb-1">{{ title }}</p>
|
||||
<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>
|
||||
</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'
|
||||
]"
|
||||
/>
|
||||
{{ Math.abs(trend.value) }}%
|
||||
</span>
|
||||
<span class="text-gray-500">vs last month</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.stats-card {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.stats-card:hover {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
</style>
|
||||
@@ -36,7 +36,7 @@ app.get("*", async (c) => {
|
||||
app.provide("honoContext", c);
|
||||
const auth = useAuthStore();
|
||||
auth.$reset();
|
||||
auth.initialized = false;
|
||||
// auth.initialized = false;
|
||||
await auth.init();
|
||||
await router.push(url.pathname);
|
||||
await router.isReady();
|
||||
|
||||
16
src/lib/directives/clickOutside.ts
Normal file
16
src/lib/directives/clickOutside.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import type { Directive } from 'vue';
|
||||
|
||||
export const vClickOutside: Directive = {
|
||||
mounted(el, binding) {
|
||||
el.__clickOutsideHandler__ = (event: Event) => {
|
||||
if (!(el === event.target || el.contains(event.target as Node))) {
|
||||
binding.value(event);
|
||||
}
|
||||
};
|
||||
document.addEventListener('click', el.__clickOutsideHandler__);
|
||||
},
|
||||
unmounted(el) {
|
||||
document.removeEventListener('click', el.__clickOutsideHandler__);
|
||||
delete el.__clickOutsideHandler__;
|
||||
},
|
||||
};
|
||||
@@ -43,12 +43,12 @@ export function createApp() {
|
||||
Object.entries(JSON.parse(document.getElementById("__APP_DATA__")?.innerText || "{}")).forEach(([key, value]) => {
|
||||
(window as any)[key] = value;
|
||||
});
|
||||
if ((window as any).$p ) {
|
||||
if ((window as any).$p) {
|
||||
pinia.state.value = (window as any).$p;
|
||||
}
|
||||
}
|
||||
app.use(pinia);
|
||||
app.use(vueSWR({revalidateOnFocus: false}));
|
||||
app.use(vueSWR({ revalidateOnFocus: false }));
|
||||
const router = createAppRouter();
|
||||
app.use(router);
|
||||
|
||||
|
||||
@@ -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>
|
||||
<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>
|
||||
@@ -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>
|
||||
@@ -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',
|
||||
},
|
||||
}
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -12,23 +12,30 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
|
||||
// Initial check for session could go here if there was a /me endpoint or token check
|
||||
async function init() {
|
||||
// if (initialized.value) return;
|
||||
const response = await client.request<
|
||||
ResponseResponse & {
|
||||
data?: ModelUser;
|
||||
},
|
||||
ResponseResponse
|
||||
>({
|
||||
if (initialized.value) return;
|
||||
await client.request({
|
||||
path: '/me',
|
||||
method: 'GET'
|
||||
});
|
||||
if (response.ok) {
|
||||
// user.value = response.data?.data;
|
||||
if (response.data?.data) {
|
||||
user.value = response.data.data;
|
||||
method: 'GET',
|
||||
format: "json",
|
||||
}).then(r => r.json()).then(r => {
|
||||
if (r.data) {
|
||||
user.value = r.data as ModelUser;
|
||||
}
|
||||
}).catch(() => {}).finally(() => {
|
||||
initialized.value = true;
|
||||
}
|
||||
});
|
||||
// client.request<
|
||||
// ResponseResponse & {
|
||||
// data?: ModelUser;
|
||||
// },
|
||||
// ResponseResponse
|
||||
// >({
|
||||
// path: '/me',
|
||||
// method: 'GET'
|
||||
// }).then(console.log)
|
||||
// .finally(() => {
|
||||
// initialized.value = true;
|
||||
// });
|
||||
}
|
||||
|
||||
async function login(username: string, password: string) {
|
||||
|
||||
Reference in New Issue
Block a user