feat: Implement video management with a data table and comprehensive plan and subscription management features.
This commit is contained in:
@@ -1,7 +1,9 @@
|
||||
import { createApp } from './main';
|
||||
import { hydrateQueryCache } from '@pinia/colada';
|
||||
import 'uno.css';
|
||||
import { createApp } from './main';
|
||||
async function render() {
|
||||
const { app, router } = createApp();
|
||||
const { app, router, queryCache } = createApp();
|
||||
hydrateQueryCache(queryCache, (window as any).$colada || {});
|
||||
router.isReady().then(() => {
|
||||
app.mount('body', true)
|
||||
})
|
||||
|
||||
15
src/components/icons/PencilIcon.vue
Normal file
15
src/components/icons/PencilIcon.vue
Normal file
@@ -0,0 +1,15 @@
|
||||
<template>
|
||||
<svg v-if="filled" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="currentColor"
|
||||
stroke="none">
|
||||
<path
|
||||
d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04c.39-.39.39-1.02 0-1.41l-2.34-2.34a.9959.9959 0 0 0-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z" />
|
||||
</svg>
|
||||
<svg v-else xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M17 3a2.828 2.828 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5L17 3z"></path>
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
defineProps<{ filled?: boolean }>();
|
||||
</script>
|
||||
@@ -1,3 +1,4 @@
|
||||
import { serializeQueryCache } from '@pinia/colada';
|
||||
import { renderSSRHead } from '@unhead/vue/server';
|
||||
import { Hono } from 'hono';
|
||||
import { contextStorage } from 'hono/context-storage';
|
||||
@@ -11,7 +12,6 @@ import { createApp } from './main';
|
||||
import { useAuthStore } from './stores/auth';
|
||||
// @ts-ignore
|
||||
import Base from '@primevue/core/base';
|
||||
import { createTextTransformStreamClass } from './lib/replateStreamText';
|
||||
const app = new Hono()
|
||||
const defaultNames = ['primitive', 'semantic', 'global', 'base', 'ripple-directive']
|
||||
// app.use(renderer)
|
||||
@@ -56,7 +56,7 @@ app.get("/.well-known/*", (c) => {
|
||||
app.get("*", async (c) => {
|
||||
const nonce = crypto.randomUUID();
|
||||
const url = new URL(c.req.url);
|
||||
const { app, router, head, pinia, bodyClass } = createApp();
|
||||
const { app, router, head, pinia, bodyClass, queryCache } = createApp();
|
||||
app.provide("honoContext", c);
|
||||
const auth = useAuthStore();
|
||||
auth.$reset();
|
||||
@@ -86,11 +86,12 @@ app.get("*", async (c) => {
|
||||
}
|
||||
await Promise.all(styleTags.filter(tag => usedStyles.has(tag.name.replace(/-(variables|style)$/, ""))).map(tag => stream.write(`<style type="text/css" data-primevue-style-id="${tag.name}">${tag.value}</style>`)));
|
||||
await stream.write(`</head><body class='${bodyClass}'>`);
|
||||
await stream.pipe(createTextTransformStreamClass(appStream, (text) => text.replace('<div id="anchor-header" class="p-4"></div>', `<div id="anchor-header" class="p-4">${ctx.teleports["#anchor-header"] || ""}</div>`).replace('<div id="anchor-top"></div>', `<div id="anchor-top">${ctx.teleports["#anchor-top"] || ""}</div>`)));
|
||||
// await stream.pipe(createTextTransformStreamClass(appStream, (text) => text.replace('<div id="anchor-header" class="p-4"></div>', `<div id="anchor-header" class="p-4">${ctx.teleports["#anchor-header"] || ""}</div>`).replace('<div id="anchor-top"></div>', `<div id="anchor-top">${ctx.teleports["#anchor-top"] || ""}</div>`)));
|
||||
await stream.pipe(appStream);
|
||||
delete ctx.teleports
|
||||
delete ctx.__teleportBuffers
|
||||
delete ctx.modules;
|
||||
Object.assign(ctx, { $p: pinia.state.value });
|
||||
Object.assign(ctx, { $p: pinia.state.value, $colada: serializeQueryCache(queryCache) });
|
||||
await stream.write(`<script type="application/json" data-ssr="true" id="__APP_DATA__" nonce="${nonce}">${htmlEscape((JSON.stringify(ctx)))}</script>`);
|
||||
await stream.write("</body></html>");
|
||||
});
|
||||
|
||||
@@ -82,11 +82,16 @@ export const formatDate = (dateString?: string) => {
|
||||
});
|
||||
};
|
||||
|
||||
export 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';
|
||||
export const getStatusSeverity = (status: string = "") => {
|
||||
switch (status) {
|
||||
case 'success':
|
||||
case 'ready':
|
||||
return 'success';
|
||||
case 'failed':
|
||||
return 'danger';
|
||||
case 'pending':
|
||||
return 'warn';
|
||||
default:
|
||||
return 'info';
|
||||
}
|
||||
};
|
||||
};
|
||||
33
src/main.ts
33
src/main.ts
@@ -1,16 +1,15 @@
|
||||
import { PiniaColada, useQueryCache } from '@pinia/colada';
|
||||
import Aura from '@primeuix/themes/aura';
|
||||
import { createHead as CSRHead } from "@unhead/vue/client";
|
||||
import { createHead as SSRHead } from "@unhead/vue/server";
|
||||
import { createPinia } from "pinia";
|
||||
import PrimeVue from 'primevue/config';
|
||||
import ToastService from 'primevue/toastservice';
|
||||
import Tooltip from 'primevue/tooltip';
|
||||
import { createSSRApp } from 'vue';
|
||||
import { RouterView } from 'vue-router';
|
||||
import { withErrorBoundary } from './lib/hoc/withErrorBoundary';
|
||||
import { vueSWR } from './lib/swr/use-swrv';
|
||||
import createAppRouter from './routes';
|
||||
import PrimeVue from 'primevue/config';
|
||||
import Aura from '@primeuix/themes/aura';
|
||||
import { createPinia } from "pinia";
|
||||
import { useAuthStore } from './stores/auth';
|
||||
import ToastService from 'primevue/toastservice';
|
||||
import Tooltip from 'primevue/tooltip';
|
||||
const bodyClass = ":uno: font-sans text-gray-800 antialiased flex flex-col min-h-screen"
|
||||
export function createApp() {
|
||||
const pinia = createPinia();
|
||||
@@ -39,6 +38,19 @@ export function createApp() {
|
||||
}
|
||||
});
|
||||
app.directive("tooltip", Tooltip)
|
||||
app.use(pinia);
|
||||
app.use(PiniaColada, {
|
||||
queryOptions: {
|
||||
refetchOnMount: false,
|
||||
refetchOnWindowFocus: false,
|
||||
ssrCatchError: true,
|
||||
}
|
||||
// optional options
|
||||
})
|
||||
// app.use(vueSWR({ revalidateOnFocus: false }));
|
||||
const queryCache = useQueryCache();
|
||||
const router = createAppRouter();
|
||||
app.use(router);
|
||||
if (!import.meta.env.SSR) {
|
||||
Object.entries(JSON.parse(document.getElementById("__APP_DATA__")?.innerText || "{}")).forEach(([key, value]) => {
|
||||
(window as any)[key] = value;
|
||||
@@ -47,10 +59,5 @@ export function createApp() {
|
||||
pinia.state.value = (window as any).$p;
|
||||
}
|
||||
}
|
||||
app.use(pinia);
|
||||
app.use(vueSWR({ revalidateOnFocus: false }));
|
||||
const router = createAppRouter();
|
||||
app.use(router);
|
||||
|
||||
return { app, router, head, pinia, bodyClass };
|
||||
return { app, router, head, pinia, bodyClass, queryCache };
|
||||
}
|
||||
@@ -1,397 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed } 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';
|
||||
import Skeleton from 'primevue/skeleton';
|
||||
|
||||
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 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="animate-pulse">
|
||||
<!-- Stats Grid Skeleton -->
|
||||
<div 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>
|
||||
|
||||
<!-- Quick Actions Skeleton -->
|
||||
<div 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>
|
||||
|
||||
<!-- Recent Videos Skeleton -->
|
||||
<div class="mb-8">
|
||||
<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>
|
||||
|
||||
<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>
|
||||
@@ -116,6 +116,16 @@ const routes: RouteData[] = [
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
path: "video/:id/edit",
|
||||
name: "video-edit",
|
||||
component: () => import("./video/EditVideo.vue"),
|
||||
meta: {
|
||||
head: {
|
||||
title: "Edit Video - Holistream",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
path: "payments-and-plans",
|
||||
name: "payments-and-plans",
|
||||
|
||||
@@ -1,16 +1,24 @@
|
||||
<script setup lang="ts">
|
||||
import { client, type ModelPlan } from '@/api/client';
|
||||
import PageHeader from '@/components/dashboard/PageHeader.vue';
|
||||
import useSWRV from '@/lib/swr';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { useQuery } from '@pinia/colada';
|
||||
import { computed, ref } from 'vue';
|
||||
import CurrentPlanCard from './components/CurrentPlanCard.vue';
|
||||
import UsageStatsCard from './components/UsageStatsCard.vue';
|
||||
import PlanList from './components/PlanList.vue';
|
||||
import PlanPaymentHistory from './components/PlanPaymentHistory.vue';
|
||||
import EditPlanDialog from './components/EditPlanDialog.vue';
|
||||
import ManageSubscriptionDialog from './components/ManageSubscriptionDialog.vue';
|
||||
|
||||
import PlanList from './components/PlanList.vue';
|
||||
import PlanPaymentHistory from './components/PlanPaymentHistory.vue';
|
||||
import UsageStatsCard from './components/UsageStatsCard.vue';
|
||||
// const ahihi = defineBasicLoader('/payments-and-plans', async to => {
|
||||
// return client.plans.plansList();
|
||||
// })
|
||||
// const { data, isLoading, reload } = ahihi();
|
||||
const { data, isPending, isLoading, refresh } = useQuery({
|
||||
// unique key for the query in the cache
|
||||
key: () => ['payments-and-plans'],
|
||||
query: () => client.plans.plansList(),
|
||||
})
|
||||
const auth = useAuthStore();
|
||||
// const plans = ref<ModelPlan[]>([]);
|
||||
const subscribing = ref<string | null>(null);
|
||||
@@ -24,7 +32,6 @@ const paymentHistory = ref([
|
||||
{ id: 'inv_003', date: 'Dec 24, 2025', amount: 19.99, plan: 'Pro Plan', status: 'failed', invoiceId: 'INV-2025-003' },
|
||||
{ id: 'inv_004', date: 'Jan 24, 2026', amount: 19.99, plan: 'Pro Plan', status: 'pending', invoiceId: 'INV-2026-001' },
|
||||
]);
|
||||
const { data, isLoading, mutate: mutatePlans } = useSWRV("r/plans", client.plans.plansList)
|
||||
|
||||
// Computed Usage (Mock if not in store)
|
||||
const storageUsed = computed(() => auth.user?.storage_used || 0); // bytes
|
||||
@@ -78,7 +85,7 @@ const savePlan = async (updatedPlan: ModelPlan) => {
|
||||
});
|
||||
|
||||
// Refresh plans
|
||||
await mutatePlans();
|
||||
await refresh();
|
||||
|
||||
showEditDialog.value = false;
|
||||
alert('Plan updated successfully');
|
||||
|
||||
126
src/routes/video/EditVideo.vue
Normal file
126
src/routes/video/EditVideo.vue
Normal file
@@ -0,0 +1,126 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { client, type ModelVideo } from '@/api/client';
|
||||
import PageHeader from '@/components/dashboard/PageHeader.vue';
|
||||
import { useToast } from 'primevue/usetoast';
|
||||
import InputText from 'primevue/inputtext';
|
||||
import Textarea from 'primevue/textarea';
|
||||
import Button from 'primevue/button';
|
||||
import Skeleton from 'primevue/skeleton';
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const toast = useToast();
|
||||
|
||||
const videoId = route.params.id as string;
|
||||
const video = ref<ModelVideo | null>(null);
|
||||
const loading = ref(true);
|
||||
const saving = ref(false);
|
||||
|
||||
const form = ref({
|
||||
title: '',
|
||||
description: '',
|
||||
});
|
||||
|
||||
const fetchVideo = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const response = await client.videos.videosDetail(videoId);
|
||||
// response is HttpResponse, response.data is the body, response.data.data is the ModelVideo
|
||||
const videoData = response.data.data;
|
||||
if (videoData) {
|
||||
video.value = videoData;
|
||||
form.value.title = videoData.title || '';
|
||||
form.value.description = videoData.description || '';
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch video:', error);
|
||||
toast.add({ severity: 'error', summary: 'Error', detail: 'Failed to load video details', life: 3000 });
|
||||
router.push('/video');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
saving.value = true;
|
||||
try {
|
||||
// Mock update - API doesn't support update yet
|
||||
console.log('Saving video:', videoId, form.value);
|
||||
await new Promise(resolve => setTimeout(resolve, 1000)); // Simulate delay
|
||||
|
||||
toast.add({ severity: 'success', summary: 'Success', detail: 'Video updated successfully', life: 3000 });
|
||||
router.push('/video');
|
||||
} catch (error) {
|
||||
console.error('Failed to save video:', error);
|
||||
toast.add({ severity: 'error', summary: 'Error', detail: 'Failed to save changes', life: 3000 });
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
router.back();
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
fetchVideo();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<PageHeader title="Edit Video" :breadcrumbs="[
|
||||
{ label: 'Dashboard', to: '/' },
|
||||
{ label: 'Videos', to: '/video' },
|
||||
{ label: 'Edit' }
|
||||
]" />
|
||||
|
||||
<div v-if="loading" class="bg-white rounded-xl border border-gray-200 p-6 space-y-4">
|
||||
<Skeleton width="100%" height="2rem" />
|
||||
<Skeleton width="100%" height="10rem" />
|
||||
<div class="flex gap-2">
|
||||
<Skeleton width="6rem" height="2.5rem" />
|
||||
<Skeleton width="6rem" height="2.5rem" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="video" class="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<div class="space-y-6">
|
||||
<!-- Preview / Info -->
|
||||
<div class="flex items-start gap-4 p-4 bg-gray-50 rounded-lg">
|
||||
<div class="w-32 h-20 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-2xl" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="font-medium text-gray-900">{{ video.title }}</h3>
|
||||
<p class="text-sm text-gray-500 mt-1">ID: {{ video.id }}</p>
|
||||
<p class="text-sm text-gray-500">Status: {{ video.status }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Form -->
|
||||
<div class="field">
|
||||
<label for="title" class="block text-sm font-medium text-gray-700 mb-1">Title</label>
|
||||
<InputText id="title" v-model="form.title" class="w-full" />
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="description" class="block text-sm font-medium text-gray-700 mb-1">Description</label>
|
||||
<Textarea id="description" v-model="form.description" rows="5" class="w-full" />
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex justify-end gap-3 pt-4 border-t border-gray-100">
|
||||
<Button label="Cancel" severity="secondary" @click="handleCancel" text />
|
||||
<Button label="Save Changes" @click="handleSave" :loading="saving" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { defineProps, defineEmits } from 'vue';
|
||||
import { defineEmits, defineProps } from 'vue';
|
||||
defineProps<{
|
||||
searchQuery: string;
|
||||
selectedStatus: string;
|
||||
@@ -21,7 +21,7 @@ const emit = defineEmits<{
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="border-b border-gray-200 mb-6 sticky top-0 z-10">
|
||||
<div class="border-b border-gray-200 mb-6">
|
||||
<div class="flex flex-col md:flex-row gap-4">
|
||||
<!-- Search -->
|
||||
<div class="flex-1 bg-white rounded-lg">
|
||||
@@ -75,7 +75,7 @@ const emit = defineEmits<{
|
||||
</div>
|
||||
</div>
|
||||
<Paginator :pt="{
|
||||
root: 'bg-transparent p-0 justify-end mt-2'
|
||||
root: '!bg-transparent !p-0 !justify-end !mt-2'
|
||||
}" :rows="limit" :totalRecords="total" :first="(page - 1) * limit" :rowsPerPageOptions="[10, 20, 30]"
|
||||
@page="(e) => { emit('update:page', e.page + 1); emit('update:limit', e.rows); }">
|
||||
<template #container="{ first, last, page, pageCount, prevPageCallback, nextPageCallback, totalRecords }">
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import { defineProps, defineEmits } from 'vue';
|
||||
import type { ModelVideo } from '@/api/client';
|
||||
import { formatDuration, formatDate, getStatusClass } from '@/lib/utils';
|
||||
import Checkbox from 'primevue/checkbox';
|
||||
import { formatDate, formatDuration, getStatusSeverity } from '@/lib/utils';
|
||||
import Card from 'primevue/card';
|
||||
import Checkbox from 'primevue/checkbox';
|
||||
import { defineEmits, defineProps } from 'vue';
|
||||
|
||||
defineProps<{
|
||||
videos: ModelVideo[];
|
||||
@@ -65,11 +65,8 @@ const emit = defineEmits<{
|
||||
</p>
|
||||
|
||||
<div class="mt-auto flex items-center justify-between">
|
||||
<span
|
||||
:class="['px-1.5 py-0.5 text-[10px] font-medium rounded-full uppercase tracking-wider', getStatusClass(video.status)]">
|
||||
{{ video.status }}
|
||||
</span>
|
||||
|
||||
<Tag :value="video.status" :severity="getStatusSeverity(video.status)"
|
||||
class="capitalize px-2 py-0.5 text-xs" />
|
||||
<div class="text-[10px] text-gray-400">
|
||||
{{ formatDate(video.created_at) }}
|
||||
</div>
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import { defineProps, defineEmits } from 'vue';
|
||||
import type { ModelVideo } from '@/api/client';
|
||||
import { formatDuration, formatDate, formatBytes, getStatusClass } from '@/lib/utils';
|
||||
import DataTable from 'primevue/datatable';
|
||||
import { formatBytes, formatDate, formatDuration, getStatusSeverity } from '@/lib/utils';
|
||||
import ArrowDownTray from '@/components/icons/ArrowDownTray.vue';
|
||||
import LinkIcon from '@/components/icons/LinkIcon.vue';
|
||||
import PencilIcon from '@/components/icons/PencilIcon.vue';
|
||||
import TrashIcon from '@/components/icons/TrashIcon.vue';
|
||||
import VideoIcon from '@/components/icons/VideoIcon.vue';
|
||||
import Column from 'primevue/column';
|
||||
import DataTable from 'primevue/datatable';
|
||||
import { defineEmits, defineProps } from 'vue';
|
||||
|
||||
defineProps<{
|
||||
videos: ModelVideo[];
|
||||
@@ -29,7 +34,7 @@ const emit = defineEmits<{
|
||||
<img v-if="data.thumbnail" :src="data.thumbnail" :alt="data.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" />
|
||||
<VideoIcon class="text-gray-400 text-xl w-5 h-5" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
@@ -42,10 +47,8 @@ const emit = defineEmits<{
|
||||
|
||||
<Column header="Status">
|
||||
<template #body="{ data }">
|
||||
<span
|
||||
:class="['px-2 py-1 text-xs font-medium rounded-full whitespace-nowrap', getStatusClass(data.status)]">
|
||||
{{ data.status || 'Unknown' }}
|
||||
</span>
|
||||
<Tag :value="data.status" :severity="getStatusSeverity(data.status)"
|
||||
class="capitalize px-2 py-0.5 text-xs" />
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
@@ -73,23 +76,23 @@ const emit = defineEmits<{
|
||||
<button
|
||||
class="p-1.5 text-gray-400 hover:text-primary hover:bg-primary/5 rounded transition-colors"
|
||||
title="Download">
|
||||
<span class="i-heroicons-arrow-down-tray w-4 h-4" />
|
||||
<ArrowDownTray class="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
class="p-1.5 text-gray-400 hover:text-primary hover:bg-primary/5 rounded transition-colors"
|
||||
title="Copy Link">
|
||||
<span class="i-heroicons-link w-4 h-4" />
|
||||
<LinkIcon class="w-4 h-4" />
|
||||
</button>
|
||||
<div class="w-px h-3 bg-gray-200 mx-1"></div>
|
||||
<button
|
||||
class="p-1.5 text-gray-400 hover:text-blue-600 hover:bg-blue-50 rounded transition-colors"
|
||||
<router-link :to="{ name: 'video-edit', params: { id: data.id } }"
|
||||
class="p-1.5 text-gray-400 hover:text-blue-600 hover:bg-blue-50 rounded transition-colors inline-block"
|
||||
title="Edit">
|
||||
<span class="i-heroicons-pencil w-4 h-4" />
|
||||
</button>
|
||||
<PencilIcon class="w-4 h-4" />
|
||||
</router-link>
|
||||
<button @click="emit('delete', data.id)"
|
||||
class="p-1.5 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded transition-colors"
|
||||
title="Delete">
|
||||
<span class="i-heroicons-trash w-4 h-4" />
|
||||
<TrashIcon class="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
Reference in New Issue
Block a user