add mock video
This commit is contained in:
8
components.d.ts
vendored
8
components.d.ts
vendored
@@ -27,6 +27,7 @@ declare module 'vue' {
|
|||||||
Credit: typeof import('./src/components/icons/Credit.vue')['default']
|
Credit: typeof import('./src/components/icons/Credit.vue')['default']
|
||||||
CreditCardIcon: typeof import('./src/components/icons/CreditCardIcon.vue')['default']
|
CreditCardIcon: typeof import('./src/components/icons/CreditCardIcon.vue')['default']
|
||||||
DashboardLayout: typeof import('./src/components/DashboardLayout.vue')['default']
|
DashboardLayout: typeof import('./src/components/DashboardLayout.vue')['default']
|
||||||
|
DashboardNav: typeof import('./src/components/DashboardNav.vue')['default']
|
||||||
EmptyState: typeof import('./src/components/dashboard/EmptyState.vue')['default']
|
EmptyState: typeof import('./src/components/dashboard/EmptyState.vue')['default']
|
||||||
FloatLabel: typeof import('primevue/floatlabel')['default']
|
FloatLabel: typeof import('primevue/floatlabel')['default']
|
||||||
GlobalUploadIndicator: typeof import('./src/components/GlobalUploadIndicator.vue')['default']
|
GlobalUploadIndicator: typeof import('./src/components/GlobalUploadIndicator.vue')['default']
|
||||||
@@ -41,12 +42,15 @@ declare module 'vue' {
|
|||||||
Message: typeof import('primevue/message')['default']
|
Message: typeof import('primevue/message')['default']
|
||||||
NotificationDrawer: typeof import('./src/components/NotificationDrawer.vue')['default']
|
NotificationDrawer: typeof import('./src/components/NotificationDrawer.vue')['default']
|
||||||
PageHeader: typeof import('./src/components/dashboard/PageHeader.vue')['default']
|
PageHeader: typeof import('./src/components/dashboard/PageHeader.vue')['default']
|
||||||
|
Paginator: typeof import('primevue/paginator')['default']
|
||||||
|
PanelLeft: typeof import('./src/components/icons/PanelLeft.vue')['default']
|
||||||
Password: typeof import('primevue/password')['default']
|
Password: typeof import('primevue/password')['default']
|
||||||
RootLayout: typeof import('./src/components/RootLayout.vue')['default']
|
RootLayout: typeof import('./src/components/RootLayout.vue')['default']
|
||||||
RouterLink: typeof import('vue-router')['RouterLink']
|
RouterLink: typeof import('vue-router')['RouterLink']
|
||||||
RouterView: typeof import('vue-router')['RouterView']
|
RouterView: typeof import('vue-router')['RouterView']
|
||||||
Select: typeof import('primevue/select')['default']
|
Select: typeof import('primevue/select')['default']
|
||||||
SettingsIcon: typeof import('./src/components/icons/SettingsIcon.vue')['default']
|
SettingsIcon: typeof import('./src/components/icons/SettingsIcon.vue')['default']
|
||||||
|
Skeleton: typeof import('primevue/skeleton')['default']
|
||||||
StatsCard: typeof import('./src/components/dashboard/StatsCard.vue')['default']
|
StatsCard: typeof import('./src/components/dashboard/StatsCard.vue')['default']
|
||||||
TestIcon: typeof import('./src/components/icons/TestIcon.vue')['default']
|
TestIcon: typeof import('./src/components/icons/TestIcon.vue')['default']
|
||||||
TrashIcon: typeof import('./src/components/icons/TrashIcon.vue')['default']
|
TrashIcon: typeof import('./src/components/icons/TrashIcon.vue')['default']
|
||||||
@@ -75,6 +79,7 @@ declare global {
|
|||||||
const Credit: typeof import('./src/components/icons/Credit.vue')['default']
|
const Credit: typeof import('./src/components/icons/Credit.vue')['default']
|
||||||
const CreditCardIcon: typeof import('./src/components/icons/CreditCardIcon.vue')['default']
|
const CreditCardIcon: typeof import('./src/components/icons/CreditCardIcon.vue')['default']
|
||||||
const DashboardLayout: typeof import('./src/components/DashboardLayout.vue')['default']
|
const DashboardLayout: typeof import('./src/components/DashboardLayout.vue')['default']
|
||||||
|
const DashboardNav: typeof import('./src/components/DashboardNav.vue')['default']
|
||||||
const EmptyState: typeof import('./src/components/dashboard/EmptyState.vue')['default']
|
const EmptyState: typeof import('./src/components/dashboard/EmptyState.vue')['default']
|
||||||
const FloatLabel: typeof import('primevue/floatlabel')['default']
|
const FloatLabel: typeof import('primevue/floatlabel')['default']
|
||||||
const GlobalUploadIndicator: typeof import('./src/components/GlobalUploadIndicator.vue')['default']
|
const GlobalUploadIndicator: typeof import('./src/components/GlobalUploadIndicator.vue')['default']
|
||||||
@@ -89,12 +94,15 @@ declare global {
|
|||||||
const Message: typeof import('primevue/message')['default']
|
const Message: typeof import('primevue/message')['default']
|
||||||
const NotificationDrawer: typeof import('./src/components/NotificationDrawer.vue')['default']
|
const NotificationDrawer: typeof import('./src/components/NotificationDrawer.vue')['default']
|
||||||
const PageHeader: typeof import('./src/components/dashboard/PageHeader.vue')['default']
|
const PageHeader: typeof import('./src/components/dashboard/PageHeader.vue')['default']
|
||||||
|
const Paginator: typeof import('primevue/paginator')['default']
|
||||||
|
const PanelLeft: typeof import('./src/components/icons/PanelLeft.vue')['default']
|
||||||
const Password: typeof import('primevue/password')['default']
|
const Password: typeof import('primevue/password')['default']
|
||||||
const RootLayout: typeof import('./src/components/RootLayout.vue')['default']
|
const RootLayout: typeof import('./src/components/RootLayout.vue')['default']
|
||||||
const RouterLink: typeof import('vue-router')['RouterLink']
|
const RouterLink: typeof import('vue-router')['RouterLink']
|
||||||
const RouterView: typeof import('vue-router')['RouterView']
|
const RouterView: typeof import('vue-router')['RouterView']
|
||||||
const Select: typeof import('primevue/select')['default']
|
const Select: typeof import('primevue/select')['default']
|
||||||
const SettingsIcon: typeof import('./src/components/icons/SettingsIcon.vue')['default']
|
const SettingsIcon: typeof import('./src/components/icons/SettingsIcon.vue')['default']
|
||||||
|
const Skeleton: typeof import('primevue/skeleton')['default']
|
||||||
const StatsCard: typeof import('./src/components/dashboard/StatsCard.vue')['default']
|
const StatsCard: typeof import('./src/components/dashboard/StatsCard.vue')['default']
|
||||||
const TestIcon: typeof import('./src/components/icons/TestIcon.vue')['default']
|
const TestIcon: typeof import('./src/components/icons/TestIcon.vue')['default']
|
||||||
const TrashIcon: typeof import('./src/components/icons/TrashIcon.vue')['default']
|
const TrashIcon: typeof import('./src/components/icons/TrashIcon.vue')['default']
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { tryGetContext } from "hono/context-storage";
|
|||||||
|
|
||||||
export const customFetch = (url: string, options: RequestInit) => {
|
export const customFetch = (url: string, options: RequestInit) => {
|
||||||
options.credentials = "include";
|
options.credentials = "include";
|
||||||
if (import.meta.env.SSR) {
|
|
||||||
const c = tryGetContext<any>();
|
const c = tryGetContext<any>();
|
||||||
if (!c) {
|
if (!c) {
|
||||||
throw new Error("Hono context not found in SSR");
|
throw new Error("Hono context not found in SSR");
|
||||||
@@ -19,16 +18,14 @@ export const customFetch = (url: string, options: RequestInit) => {
|
|||||||
});
|
});
|
||||||
options.headers = {
|
options.headers = {
|
||||||
...mergedHeaders,
|
...mergedHeaders,
|
||||||
...(options.headers as Record<string, string>)
|
...(options.headers as Record<string, string>),
|
||||||
};
|
};
|
||||||
|
|
||||||
const apiUrl = ["https://api.pipic.fun", url.replace(/^r/, '')].join('');
|
const apiUrl = ["https://api.pipic.fun", url.replace(/^r/, "")].join("");
|
||||||
// const res = await fetch(apiUrl, options);
|
return fetch(apiUrl, options).then(async (res) => {
|
||||||
|
res.headers.getSetCookie()?.forEach((cookie) => {
|
||||||
// Forward response headers to client (especially Set-Cookie)
|
c.header("Set-Cookie", cookie);
|
||||||
// res.headers.forEach((value, key) => {
|
});
|
||||||
// c.header(key, value);
|
return res;
|
||||||
// });
|
});
|
||||||
return fetch(apiUrl, options)
|
};
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,59 +1,13 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import Add from "@/components/icons/Add.vue";
|
import DashboardNav from "./DashboardNav.vue";
|
||||||
import Bell from "@/components/icons/Bell.vue";
|
|
||||||
import Home from "@/components/icons/Home.vue";
|
|
||||||
import Video from "@/components/icons/Video.vue";
|
|
||||||
import Credit from "@/components/icons/Credit.vue";
|
|
||||||
import Upload from "./icons/Upload.vue";
|
|
||||||
import NotificationDrawer from "./NotificationDrawer.vue";
|
|
||||||
import GlobalUploadIndicator from "./GlobalUploadIndicator.vue";
|
import GlobalUploadIndicator from "./GlobalUploadIndicator.vue";
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
import { createStaticVNode, ref } from "vue";
|
|
||||||
|
|
||||||
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 notificationPopover = ref<InstanceType<typeof NotificationDrawer>>();
|
|
||||||
const isNotificationOpen = ref(false);
|
|
||||||
|
|
||||||
const handleNotificationClick = (event: Event) => {
|
|
||||||
notificationPopover.value?.toggle(event);
|
|
||||||
};
|
|
||||||
const links = [
|
|
||||||
{ href: "/#home", 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: "/payments-and-plans", label: "Payments & Plans", icon: Credit, type: "a", className },
|
|
||||||
{ href: "/notification", label: "Notification", icon: Bell, type: "btn", className, action: handleNotificationClick, isActive: isNotificationOpen },
|
|
||||||
{ href: "/profile", label: "Profile", icon: profileHoist, type: "a", className: 'w-12 h-12 rounded-2xl hover:bg-primary/15 flex' },
|
|
||||||
];
|
|
||||||
|
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<header
|
<DashboardNav />
|
||||||
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">
|
<main class="flex flex-1 flex-col transition-all duration-300 ease-in-out bg-page md:ps-18">
|
||||||
<template v-for="i in links" :key="i.label">
|
<div class=":uno: flex-1 overflow-auto p-4 bg-page rounded-lg md:(mr-2 mb-2) min-h-[calc(100vh-8rem)]">
|
||||||
<component :name="i.label" :is="i.type === 'a' ? 'router-link' : 'div'"
|
|
||||||
v-bind="i.type === 'a' ? { to: i.href } : {}" v-tooltip="i.label" @click="i.action && i.action($event)"
|
|
||||||
:class="cn(i.className, ($route.path === i.href || i.isActive?.value) && 'bg-primary/15')">
|
|
||||||
<component :is="i.icon" class="w-6 h-6" :filled="$route.path === i.href || i.isActive?.value" />
|
|
||||||
</component>
|
|
||||||
</template>
|
|
||||||
<ClientOnly>
|
|
||||||
<NotificationDrawer ref="notificationPopover" @change="(val) => isNotificationOpen = val" />
|
|
||||||
</ClientOnly>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<main class="flex flex-1 overflow-hidden md:ps-18">
|
|
||||||
<div class=":uno: flex-1 overflow-auto p-4 bg-[#FAF8F8] rounded-lg md:(mr-2 mb-2) min-h-[calc(100vh-8rem)]">
|
|
||||||
<router-view v-slot="{ Component }">
|
<router-view v-slot="{ Component }">
|
||||||
<Transition enter-active-class="transition-all duration-300 ease-in-out"
|
<Transition enter-active-class="transition-all duration-300 ease-in-out"
|
||||||
enter-from-class="opacity-0 transform translate-y-4"
|
enter-from-class="opacity-0 transform translate-y-4"
|
||||||
|
|||||||
56
src/components/DashboardNav.vue
Normal file
56
src/components/DashboardNav.vue
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import Bell from "@/components/icons/Bell.vue";
|
||||||
|
import Home from "@/components/icons/Home.vue";
|
||||||
|
import Video from "@/components/icons/Video.vue";
|
||||||
|
import Credit from "@/components/icons/Credit.vue";
|
||||||
|
import Upload from "@/components/icons/Upload.vue";
|
||||||
|
import NotificationDrawer from "./NotificationDrawer.vue";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { createStaticVNode, ref } from "vue";
|
||||||
|
|
||||||
|
const className = ":uno: w-12 h-12 p-2 rounded-2xl hover:bg-primary/15 flex press-animated items-center justify-center shrink-0";
|
||||||
|
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 notificationPopover = ref<InstanceType<typeof NotificationDrawer>>();
|
||||||
|
const isNotificationOpen = ref(false);
|
||||||
|
|
||||||
|
const handleNotificationClick = (event: Event) => {
|
||||||
|
notificationPopover.value?.toggle(event);
|
||||||
|
};
|
||||||
|
|
||||||
|
const links = [
|
||||||
|
{ href: "/#home", 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: "/payments-and-plans", label: "Payments & Plans", icon: Credit, type: "a", className },
|
||||||
|
{ href: "/notification", label: "Notification", icon: Bell, type: "btn", className, action: handleNotificationClick, isActive: isNotificationOpen },
|
||||||
|
{ href: "/profile", label: "Profile", icon: profileHoist, type: "a", className: 'w-12 h-12 rounded-2xl hover:bg-primary/15 flex shrink-0' },
|
||||||
|
];
|
||||||
|
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<header
|
||||||
|
class=":uno: fixed left-0 flex flex-col items-center pt-4 gap-6 z-41 max-h-screen h-screen bg-muted transition-all duration-300 ease-in-out w-18 items-center">
|
||||||
|
|
||||||
|
<template v-for="i in links" :key="i.label">
|
||||||
|
<component :name="i.label" :is="i.type === 'a' ? 'router-link' : 'div'"
|
||||||
|
v-bind="i.type === 'a' ? { to: i.href } : {}" v-tooltip="i.label" @click="i.action && i.action($event)"
|
||||||
|
:class="cn(
|
||||||
|
i.className,
|
||||||
|
($route.path === i.href || i.isActive?.value) && 'bg-primary/15'
|
||||||
|
)">
|
||||||
|
<component :is="i.icon" class="w-6 h-6 shrink-0"
|
||||||
|
:filled="$route.path === i.href || i.isActive?.value" />
|
||||||
|
</component>
|
||||||
|
</template>
|
||||||
|
</header>
|
||||||
|
<ClientOnly>
|
||||||
|
<NotificationDrawer ref="notificationPopover" @change="(val) => isNotificationOpen = val" />
|
||||||
|
</ClientOnly>
|
||||||
|
</template>
|
||||||
@@ -43,20 +43,14 @@ const getButtonClass = (variant?: string) => {
|
|||||||
<!-- Breadcrumb -->
|
<!-- Breadcrumb -->
|
||||||
<nav v-if="breadcrumbs && breadcrumbs.length" class="flex items-center gap-2 text-sm mb-2">
|
<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">
|
<template v-for="(crumb, index) in breadcrumbs" :key="index">
|
||||||
<router-link
|
<router-link v-if="crumb.to" :to="crumb.to" class="text-gray-500 hover:text-primary transition-colors">
|
||||||
v-if="crumb.to"
|
|
||||||
:to="crumb.to"
|
|
||||||
class="text-gray-500 hover:text-primary transition-colors"
|
|
||||||
>
|
|
||||||
{{ crumb.label }}
|
{{ crumb.label }}
|
||||||
</router-link>
|
</router-link>
|
||||||
<span v-else class="text-gray-700 font-medium">{{ crumb.label }}</span>
|
<span v-else class="text-gray-700 font-medium">{{ crumb.label }}</span>
|
||||||
|
|
||||||
<span
|
<span v-if="index < breadcrumbs.length - 1" class="w-4 h-4 text-gray-400">
|
||||||
v-if="index < breadcrumbs.length - 1"
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor"
|
||||||
class="w-4 h-4 text-gray-400"
|
aria-hidden="true">
|
||||||
>
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
|
||||||
</svg>
|
</svg>
|
||||||
</span>
|
</span>
|
||||||
@@ -72,17 +66,9 @@ const getButtonClass = (variant?: string) => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="actions && actions.length" class="flex items-center gap-2 flex-shrink-0">
|
<div v-if="actions && actions.length" class="flex items-center gap-2 flex-shrink-0">
|
||||||
<button
|
<button v-for="(action, index) in actions" :key="index" @click="action.onClick"
|
||||||
v-for="(action, index) in actions"
|
:class="getButtonClass(action.variant)">
|
||||||
:key="index"
|
<component v-if="action.icon" :is="action.icon" class="w-5 h-5" />
|
||||||
@click="action.onClick"
|
|
||||||
:class="getButtonClass(action.variant)"
|
|
||||||
>
|
|
||||||
<component
|
|
||||||
v-if="action.icon"
|
|
||||||
:is="action.icon"
|
|
||||||
class="w-5 h-5"
|
|
||||||
/>
|
|
||||||
{{ action.label }}
|
{{ action.label }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ const iconColors = {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div :class="[
|
<div :class="[
|
||||||
'transform translate-y-0 relative overflow-hidden rounded-2xl p-6 bg-white',
|
'transform translate-y-0 relative overflow-hidden rounded-2xl p-6 bg-surface',
|
||||||
// gradients[color],
|
// gradients[color],
|
||||||
'border border-gray-300 transition-all duration-300',
|
'border border-gray-300 transition-all duration-300',
|
||||||
// 'group cursor-pointer'
|
// 'group cursor-pointer'
|
||||||
|
|||||||
17
src/components/icons/PanelLeft.vue
Normal file
17
src/components/icons/PanelLeft.vue
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
class?: string;
|
||||||
|
filled?: boolean;
|
||||||
|
}>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<svg :class="cn('w-6 h-6', $props.class)" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path
|
||||||
|
d="M19 21H5C3.89543 21 3 20.1046 3 19V5C3 3.89543 3.89543 3 5 3H19C20.1046 3 21 3.89543 21 5V19C21 20.1046 20.1046 21 19 21Z"
|
||||||
|
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||||
|
<path d="M9 3V21" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||||
|
</svg>
|
||||||
|
</template>
|
||||||
@@ -11,6 +11,7 @@ import { createApp } from './main';
|
|||||||
import { useAuthStore } from './stores/auth';
|
import { useAuthStore } from './stores/auth';
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
import Base from '@primevue/core/base';
|
import Base from '@primevue/core/base';
|
||||||
|
import { createTextTransformStreamClass } from './lib/replateStreamText';
|
||||||
const app = new Hono()
|
const app = new Hono()
|
||||||
const defaultNames = ['primitive', 'semantic', 'global', 'base', 'ripple-directive']
|
const defaultNames = ['primitive', 'semantic', 'global', 'base', 'ripple-directive']
|
||||||
// app.use(renderer)
|
// app.use(renderer)
|
||||||
@@ -73,6 +74,7 @@ app.get("*", async (c) => {
|
|||||||
// console.log("ctx: ", );
|
// console.log("ctx: ", );
|
||||||
await stream.write("<!DOCTYPE html><html lang='en'><head>");
|
await stream.write("<!DOCTYPE html><html lang='en'><head>");
|
||||||
await stream.write("<base href='" + url.origin + "'/>");
|
await stream.write("<base href='" + url.origin + "'/>");
|
||||||
|
|
||||||
await renderSSRHead(head).then((headString) => stream.write(headString.headTags.replace(/\n/g, "")));
|
await renderSSRHead(head).then((headString) => stream.write(headString.headTags.replace(/\n/g, "")));
|
||||||
// await stream.write(`<link href="https://fonts.googleapis.com/css2?family=Be+Vietnam+Pro:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;0,800;0,900;1,100;1,200;1,300;1,400;1,500;1,600;1,700;1,800;1,900&display=swap"rel="stylesheet"></link>`);
|
// await stream.write(`<link href="https://fonts.googleapis.com/css2?family=Be+Vietnam+Pro:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;0,800;0,900;1,100;1,200;1,300;1,400;1,500;1,600;1,700;1,800;1,900&display=swap"rel="stylesheet"></link>`);
|
||||||
await stream.write(`<link rel="preconnect" href="https://fonts.googleapis.com">`);
|
await stream.write(`<link rel="preconnect" href="https://fonts.googleapis.com">`);
|
||||||
@@ -84,7 +86,10 @@ 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 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.write(`</head><body class='${bodyClass}'>`);
|
||||||
await stream.pipe(appStream);
|
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>`)));
|
||||||
|
delete ctx.teleports
|
||||||
|
delete ctx.__teleportBuffers
|
||||||
|
delete ctx.modules;
|
||||||
Object.assign(ctx, { $p: pinia.state.value });
|
Object.assign(ctx, { $p: pinia.state.value });
|
||||||
await stream.write(`<script type="application/json" data-ssr="true" id="__APP_DATA__" nonce="${nonce}">${htmlEscape((JSON.stringify(ctx)))}</script>`);
|
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>");
|
await stream.write("</body></html>");
|
||||||
|
|||||||
123
src/lib/replateStreamText.ts
Normal file
123
src/lib/replateStreamText.ts
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
interface TextTransformStreamOptions {
|
||||||
|
encoding?: string;
|
||||||
|
handleSplitChunks?: boolean;
|
||||||
|
bufferSize?: number;
|
||||||
|
onError?: (error: Error) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Class Transformer để xử lý text transform
|
||||||
|
*/
|
||||||
|
class TextTransformer implements Transformer<Uint8Array, Uint8Array> {
|
||||||
|
private buffer: string = '';
|
||||||
|
private decoder: TextDecoder;
|
||||||
|
private encoder: TextEncoder;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private transformFn: (text: string) => string,
|
||||||
|
private options: Required<Omit<TextTransformStreamOptions, 'onError'>> & {
|
||||||
|
onError?: (error: Error) => void;
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
this.decoder = new TextDecoder(this.options.encoding);
|
||||||
|
this.encoder = new TextEncoder();
|
||||||
|
}
|
||||||
|
|
||||||
|
transform(
|
||||||
|
chunk: Uint8Array,
|
||||||
|
controller: TransformStreamDefaultController<Uint8Array>
|
||||||
|
): void {
|
||||||
|
try {
|
||||||
|
const chunkText = this.decoder.decode(chunk, { stream: true });
|
||||||
|
const fullText = this.buffer + chunkText;
|
||||||
|
this.buffer = '';
|
||||||
|
|
||||||
|
let processedText: string;
|
||||||
|
try {
|
||||||
|
processedText = this.transformFn(fullText);
|
||||||
|
} catch (transformError) {
|
||||||
|
this.handleError(transformError);
|
||||||
|
processedText = fullText;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.options.handleSplitChunks) {
|
||||||
|
this.handleSplitChunks(processedText, controller);
|
||||||
|
} else {
|
||||||
|
controller.enqueue(this.encoder.encode(processedText));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.handleError(error);
|
||||||
|
controller.enqueue(chunk);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
flush(controller: TransformStreamDefaultController<Uint8Array>): void {
|
||||||
|
try {
|
||||||
|
if (this.buffer) {
|
||||||
|
const finalText = this.decoder.decode();
|
||||||
|
const remainingText = this.buffer + finalText;
|
||||||
|
|
||||||
|
if (remainingText) {
|
||||||
|
let processedText: string;
|
||||||
|
try {
|
||||||
|
processedText = this.transformFn(remainingText);
|
||||||
|
} catch (transformError) {
|
||||||
|
this.handleError(transformError);
|
||||||
|
processedText = remainingText;
|
||||||
|
}
|
||||||
|
controller.enqueue(this.encoder.encode(processedText));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.handleError(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleSplitChunks(
|
||||||
|
text: string,
|
||||||
|
controller: TransformStreamDefaultController<Uint8Array>
|
||||||
|
): void {
|
||||||
|
const lastNewline = text.lastIndexOf('\n');
|
||||||
|
const lastSpace = text.lastIndexOf(' ');
|
||||||
|
const lastBreak = Math.max(lastNewline, lastSpace);
|
||||||
|
|
||||||
|
if (lastBreak > text.length - this.options.bufferSize && lastBreak > 0) {
|
||||||
|
const safePart = text.slice(0, lastBreak + 1);
|
||||||
|
this.buffer = text.slice(lastBreak + 1);
|
||||||
|
controller.enqueue(this.encoder.encode(safePart));
|
||||||
|
} else {
|
||||||
|
controller.enqueue(this.encoder.encode(text));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleError(error: unknown): void {
|
||||||
|
if (this.options.onError) {
|
||||||
|
this.options.onError(error instanceof Error ? error : new Error(String(error)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cách 2: Sử dụng class Transformer
|
||||||
|
*/
|
||||||
|
export function createTextTransformStreamClass(
|
||||||
|
inputStream: ReadableStream<Uint8Array>,
|
||||||
|
transformFn: (text: string) => string,
|
||||||
|
options: TextTransformStreamOptions = {}
|
||||||
|
): ReadableStream<Uint8Array> {
|
||||||
|
const {
|
||||||
|
encoding = 'utf-8',
|
||||||
|
handleSplitChunks = true,
|
||||||
|
bufferSize = 1024,
|
||||||
|
onError
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
const transformer = new TextTransformer(transformFn, {
|
||||||
|
encoding,
|
||||||
|
handleSplitChunks,
|
||||||
|
bufferSize,
|
||||||
|
onError
|
||||||
|
});
|
||||||
|
|
||||||
|
return inputStream.pipeThrough(new TransformStream(transformer));
|
||||||
|
}
|
||||||
@@ -81,3 +81,12 @@ export const formatDate = (dateString?: string) => {
|
|||||||
minute: '2-digit'
|
minute: '2-digit'
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { createPinia } from "pinia";
|
|||||||
import { useAuthStore } from './stores/auth';
|
import { useAuthStore } from './stores/auth';
|
||||||
import ToastService from 'primevue/toastservice';
|
import ToastService from 'primevue/toastservice';
|
||||||
import Tooltip from 'primevue/tooltip';
|
import Tooltip from 'primevue/tooltip';
|
||||||
const bodyClass = ":uno: font-sans text-gray-800 antialiased flex flex-col min-h-screen bg-[#FAF8F8]"
|
const bodyClass = ":uno: font-sans text-gray-800 antialiased flex flex-col min-h-screen"
|
||||||
export function createApp() {
|
export function createApp() {
|
||||||
const pinia = createPinia();
|
const pinia = createPinia();
|
||||||
const app = createSSRApp(withErrorBoundary(RouterView));
|
const app = createSSRApp(withErrorBoundary(RouterView));
|
||||||
|
|||||||
321
src/mocks/videos.ts
Normal file
321
src/mocks/videos.ts
Normal file
@@ -0,0 +1,321 @@
|
|||||||
|
import type { ModelVideo } from "@/api/client";
|
||||||
|
|
||||||
|
export const mockVideos: ModelVideo[] = [
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
title: 'Getting Started with Stream UI',
|
||||||
|
description: 'A comprehensive guide to using the new Stream UI platform for your daily tasks.',
|
||||||
|
thumbnail: 'https://picsum.photos/seed/video1/640/360',
|
||||||
|
duration: 345, // 5m 45s
|
||||||
|
status: 'ready',
|
||||||
|
size: 1024 * 1024 * 45, // 45MB
|
||||||
|
created_at: new Date(Date.now() - 1000 * 60 * 60 * 24 * 2).toISOString(), // 2 days ago
|
||||||
|
views: 12500,
|
||||||
|
url: '#'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '2',
|
||||||
|
title: 'Advanced Editing Techniques',
|
||||||
|
description: 'Learn how to edit your videos like a pro using our built-in tools.',
|
||||||
|
thumbnail: 'https://picsum.photos/seed/video2/640/360',
|
||||||
|
duration: 890, // 14m 50s
|
||||||
|
status: 'processing',
|
||||||
|
processing_status: '75%',
|
||||||
|
size: 1024 * 1024 * 128, // 128MB
|
||||||
|
created_at: new Date(Date.now() - 1000 * 60 * 60 * 5).toISOString(), // 5 hours ago
|
||||||
|
views: 0,
|
||||||
|
url: '#'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '3',
|
||||||
|
title: 'Project Alpha Demo',
|
||||||
|
description: 'Internal demonstration of the upcoming Project Alpha features.',
|
||||||
|
thumbnail: 'https://picsum.photos/seed/video3/640/360',
|
||||||
|
duration: 120, // 2m 00s
|
||||||
|
status: 'ready',
|
||||||
|
size: 1024 * 1024 * 25, // 25MB
|
||||||
|
created_at: new Date(Date.now() - 1000 * 60 * 60 * 24 * 7).toISOString(), // 1 week ago
|
||||||
|
views: 340,
|
||||||
|
url: '#'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '4',
|
||||||
|
title: 'Weekly Team Standup',
|
||||||
|
description: 'Recording of the weekly engineering team standup meeting.',
|
||||||
|
thumbnail: 'https://picsum.photos/seed/video4/640/360',
|
||||||
|
duration: 1800, // 30m 00s
|
||||||
|
status: 'ready',
|
||||||
|
size: 1024 * 1024 * 350, // 350MB
|
||||||
|
created_at: new Date(Date.now() - 1000 * 60 * 60 * 24 * 14).toISOString(), // 2 weeks ago
|
||||||
|
views: 12,
|
||||||
|
url: '#'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '5',
|
||||||
|
title: 'Funny Cat Compilation',
|
||||||
|
description: 'A collection of the funniest cat videos found on the internet.',
|
||||||
|
thumbnail: 'https://picsum.photos/seed/video5/640/360',
|
||||||
|
duration: 600, // 10m 00s
|
||||||
|
status: 'failed',
|
||||||
|
size: 1024 * 1024 * 80, // 80MB
|
||||||
|
created_at: new Date(Date.now() - 1000 * 60 * 30).toISOString(), // 30 mins ago
|
||||||
|
views: 0,
|
||||||
|
url: '#'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '6',
|
||||||
|
title: 'Product Launch Event 2024',
|
||||||
|
description: 'Full coverage of our annual product launch event in San Francisco.',
|
||||||
|
thumbnail: 'https://picsum.photos/seed/video6/640/360',
|
||||||
|
duration: 5400, // 1h 30m
|
||||||
|
status: 'ready',
|
||||||
|
size: 1024 * 1024 * 1024 * 2.5, // 2.5GB
|
||||||
|
created_at: new Date(Date.now() - 1000 * 60 * 60 * 24 * 30).toISOString(), // 1 month ago
|
||||||
|
views: 45000,
|
||||||
|
url: '#'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '7',
|
||||||
|
title: 'Tutorial: React vs Vue',
|
||||||
|
description: 'Comparing the two most popular frontend frameworks.',
|
||||||
|
thumbnail: 'https://picsum.photos/seed/video7/640/360',
|
||||||
|
duration: 1540,
|
||||||
|
status: 'ready',
|
||||||
|
size: 1024 * 1024 * 200,
|
||||||
|
created_at: new Date(Date.now() - 1000 * 60 * 60 * 24 * 3).toISOString(),
|
||||||
|
views: 8900,
|
||||||
|
url: '#'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '8',
|
||||||
|
title: 'Nature Documentary - 4K',
|
||||||
|
description: 'Breathtaking views of mountains and rivers.',
|
||||||
|
thumbnail: 'https://picsum.photos/seed/video8/640/360',
|
||||||
|
duration: 3200,
|
||||||
|
status: 'ready',
|
||||||
|
size: 1024 * 1024 * 800,
|
||||||
|
created_at: new Date(Date.now() - 1000 * 60 * 60 * 48).toISOString(),
|
||||||
|
views: 1500,
|
||||||
|
url: '#'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '9',
|
||||||
|
title: 'Nature Documentary - 4K',
|
||||||
|
description: 'Breathtaking views of mountains and rivers.',
|
||||||
|
thumbnail: 'https://picsum.photos/seed/video8/640/360',
|
||||||
|
duration: 3200,
|
||||||
|
status: 'ready',
|
||||||
|
size: 1024 * 1024 * 800,
|
||||||
|
created_at: new Date(Date.now() - 1000 * 60 * 60 * 48).toISOString(),
|
||||||
|
views: 1500,
|
||||||
|
url: '#'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '10',
|
||||||
|
title: 'Nature Documentary - 4K',
|
||||||
|
description: 'Breathtaking views of mountains and rivers.',
|
||||||
|
thumbnail: 'https://picsum.photos/seed/video8/640/360',
|
||||||
|
duration: 3200,
|
||||||
|
status: 'ready',
|
||||||
|
size: 1024 * 1024 * 800,
|
||||||
|
created_at: new Date(Date.now() - 1000 * 60 * 60 * 48).toISOString(),
|
||||||
|
views: 1500,
|
||||||
|
url: '#'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '11',
|
||||||
|
title: 'Nature Documentary - 4K',
|
||||||
|
description: 'Breathtaking views of mountains and rivers.',
|
||||||
|
thumbnail: 'https://picsum.photos/seed/video8/640/360',
|
||||||
|
duration: 3200,
|
||||||
|
status: 'ready',
|
||||||
|
size: 1024 * 1024 * 800,
|
||||||
|
created_at: new Date(Date.now() - 1000 * 60 * 60 * 48).toISOString(),
|
||||||
|
views: 1500,
|
||||||
|
url: '#'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '12',
|
||||||
|
title: 'Nature Documentary - 4K',
|
||||||
|
description: 'Breathtaking views of mountains and rivers.',
|
||||||
|
thumbnail: 'https://picsum.photos/seed/video8/640/360',
|
||||||
|
duration: 3200,
|
||||||
|
status: 'ready',
|
||||||
|
size: 1024 * 1024 * 800,
|
||||||
|
created_at: new Date(Date.now() - 1000 * 60 * 60 * 48).toISOString(),
|
||||||
|
views: 1500,
|
||||||
|
url: '#'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '13',
|
||||||
|
title: 'Nature Documentary - 4K',
|
||||||
|
description: 'Breathtaking views of mountains and rivers.',
|
||||||
|
thumbnail: 'https://picsum.photos/seed/video8/640/360',
|
||||||
|
duration: 3200,
|
||||||
|
status: 'ready',
|
||||||
|
size: 1024 * 1024 * 800,
|
||||||
|
created_at: new Date(Date.now() - 1000 * 60 * 60 * 48).toISOString(),
|
||||||
|
views: 1500,
|
||||||
|
url: '#'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '14',
|
||||||
|
title: 'Nature Documentary - 4K',
|
||||||
|
description: 'Breathtaking views of mountains and rivers.',
|
||||||
|
thumbnail: 'https://picsum.photos/seed/video8/640/360',
|
||||||
|
duration: 3200,
|
||||||
|
status: 'ready',
|
||||||
|
size: 1024 * 1024 * 800,
|
||||||
|
created_at: new Date(Date.now() - 1000 * 60 * 60 * 48).toISOString(),
|
||||||
|
views: 1500,
|
||||||
|
url: '#'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '15',
|
||||||
|
title: 'Nature Documentary - 4K',
|
||||||
|
description: 'Breathtaking views of mountains and rivers.',
|
||||||
|
thumbnail: 'https://picsum.photos/seed/video8/640/360',
|
||||||
|
duration: 3200,
|
||||||
|
status: 'ready',
|
||||||
|
size: 1024 * 1024 * 800,
|
||||||
|
created_at: new Date(Date.now() - 1000 * 60 * 60 * 48).toISOString(),
|
||||||
|
views: 1500,
|
||||||
|
url: '#'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '16',
|
||||||
|
title: 'Nature Documentary - 4K',
|
||||||
|
description: 'Breathtaking views of mountains and rivers.',
|
||||||
|
thumbnail: 'https://picsum.photos/seed/video8/640/360',
|
||||||
|
duration: 3200,
|
||||||
|
status: 'ready',
|
||||||
|
size: 1024 * 1024 * 800,
|
||||||
|
created_at: new Date(Date.now() - 1000 * 60 * 60 * 48).toISOString(),
|
||||||
|
views: 1500,
|
||||||
|
url: '#'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '17',
|
||||||
|
title: 'Nature Documentary - 4K',
|
||||||
|
description: 'Breathtaking views of mountains and rivers.',
|
||||||
|
thumbnail: 'https://picsum.photos/seed/video8/640/360',
|
||||||
|
duration: 3200,
|
||||||
|
status: 'ready',
|
||||||
|
size: 1024 * 1024 * 800,
|
||||||
|
created_at: new Date(Date.now() - 1000 * 60 * 60 * 48).toISOString(),
|
||||||
|
views: 1500,
|
||||||
|
url: '#'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '18',
|
||||||
|
title: 'Nature Documentary - 4K',
|
||||||
|
description: 'Breathtaking views of mountains and rivers.',
|
||||||
|
thumbnail: 'https://picsum.photos/seed/video8/640/360',
|
||||||
|
duration: 3200,
|
||||||
|
status: 'ready',
|
||||||
|
size: 1024 * 1024 * 800,
|
||||||
|
created_at: new Date(Date.now() - 1000 * 60 * 60 * 48).toISOString(),
|
||||||
|
views: 1500,
|
||||||
|
url: '#'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '19',
|
||||||
|
title: 'Nature Documentary - 4K',
|
||||||
|
description: 'Breathtaking views of mountains and rivers.',
|
||||||
|
thumbnail: 'https://picsum.photos/seed/video8/640/360',
|
||||||
|
duration: 3200,
|
||||||
|
status: 'ready',
|
||||||
|
size: 1024 * 1024 * 800,
|
||||||
|
created_at: new Date(Date.now() - 1000 * 60 * 60 * 48).toISOString(),
|
||||||
|
views: 1500,
|
||||||
|
url: '#'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '20',
|
||||||
|
title: 'Nature Documentary - 4K',
|
||||||
|
description: 'Breathtaking views of mountains and rivers.',
|
||||||
|
thumbnail: 'https://picsum.photos/seed/video8/640/360',
|
||||||
|
duration: 3200,
|
||||||
|
status: 'ready',
|
||||||
|
size: 1024 * 1024 * 800,
|
||||||
|
created_at: new Date(Date.now() - 1000 * 60 * 60 * 48).toISOString(),
|
||||||
|
views: 1500,
|
||||||
|
url: '#'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '21',
|
||||||
|
title: 'Nature Documentary - 4K',
|
||||||
|
description: 'Breathtaking views of mountains and rivers.',
|
||||||
|
thumbnail: 'https://picsum.photos/seed/video8/640/360',
|
||||||
|
duration: 3200,
|
||||||
|
status: 'ready',
|
||||||
|
size: 1024 * 1024 * 800,
|
||||||
|
created_at: new Date(Date.now() - 1000 * 60 * 60 * 48).toISOString(),
|
||||||
|
views: 1500,
|
||||||
|
url: '#'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '22',
|
||||||
|
title: 'Nature Documentary - 4K',
|
||||||
|
description: 'Breathtaking views of mountains and rivers.',
|
||||||
|
thumbnail: 'https://picsum.photos/seed/video8/640/360',
|
||||||
|
duration: 3200,
|
||||||
|
status: 'ready',
|
||||||
|
size: 1024 * 1024 * 800,
|
||||||
|
created_at: new Date(Date.now() - 1000 * 60 * 60 * 48).toISOString(),
|
||||||
|
views: 1500,
|
||||||
|
url: '#'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '23',
|
||||||
|
title: 'Nature Documentary - 4K',
|
||||||
|
description: 'Breathtaking views of mountains and rivers.',
|
||||||
|
thumbnail: 'https://picsum.photos/seed/video8/640/360',
|
||||||
|
duration: 3200,
|
||||||
|
status: 'ready',
|
||||||
|
size: 1024 * 1024 * 800,
|
||||||
|
created_at: new Date(Date.now() - 1000 * 60 * 60 * 48).toISOString(),
|
||||||
|
views: 1500,
|
||||||
|
url: '#'
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
interface FetchVideosParams {
|
||||||
|
page: number;
|
||||||
|
limit: number;
|
||||||
|
searchQuery?: string;
|
||||||
|
status?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const fetchMockVideos = async ({ page, limit, searchQuery, status }: FetchVideosParams) => {
|
||||||
|
// Simulate API delay
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 800));
|
||||||
|
|
||||||
|
let filtered = [...mockVideos];
|
||||||
|
|
||||||
|
// Filter by search query
|
||||||
|
if (searchQuery) {
|
||||||
|
const query = searchQuery.toLowerCase();
|
||||||
|
filtered = filtered.filter(v =>
|
||||||
|
v.title?.toLowerCase().includes(query) ||
|
||||||
|
v.description?.toLowerCase().includes(query)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter by status
|
||||||
|
if (status && status !== 'all') {
|
||||||
|
filtered = filtered.filter(v => v.status?.toLowerCase() === status.toLowerCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
const total = filtered.length;
|
||||||
|
|
||||||
|
// Pagination
|
||||||
|
const start = (page - 1) * limit;
|
||||||
|
const end = start + limit;
|
||||||
|
const data = filtered.slice(start, end);
|
||||||
|
|
||||||
|
return {
|
||||||
|
data,
|
||||||
|
total
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -67,12 +67,12 @@ const quickActions = [
|
|||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<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">
|
<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="[
|
<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 bg-white',
|
'p-6 rounded-xl text-left transition-all duration-200 flex flex-col bg-surface',
|
||||||
'border border-gray-300 hover:border-primary hover:shadow-lg',
|
'border border-gray-300 hover:border-primary hover:shadow-lg',
|
||||||
'group press-animated',
|
'group press-animated',
|
||||||
]">
|
]">
|
||||||
<div
|
<div
|
||||||
:class="['w-12 h-12 rounded-lg flex items-center justify-center mb-4 bg-gray-100 group-hover:bg-primary/10']">
|
class="w-12 h-12 rounded-lg flex items-center justify-center mb-4 bg-muted group-hover:bg-primary/10">
|
||||||
<component filled :is="action.icon" class="w-6 h-6" />
|
<component filled :is="action.icon" class="w-6 h-6" />
|
||||||
</div>
|
</div>
|
||||||
<h3 class="font-semibold mb-1 group-hover:text-primary transition-colors">{{ action.title }}</h3>
|
<h3 class="font-semibold mb-1 group-hover:text-primary transition-colors">{{ action.title }}</h3>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="rounded-xl border border-gray-300 hover:border-primary hover:shadow-lg text-card-foreground bg-white">
|
<div class="rounded-xl border border-gray-300 hover:border-primary hover:shadow-lg text-card-foreground bg-surface">
|
||||||
<div class="flex flex-col space-y-1.5 p-6">
|
<div class="flex flex-col space-y-1.5 p-6">
|
||||||
<h3 class="text-lg font-semibold leading-none tracking-tight">Referral Link</h3>
|
<h3 class="text-lg font-semibold leading-none tracking-tight">Referral Link</h3>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ defineProps<Props>();
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div v-if="loading" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
|
<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 v-for="i in 4" :key="i" class="bg-surface rounded-xl border border-gray-200 p-6">
|
||||||
<div class="flex items-center justify-between mb-4">
|
<div class="flex items-center justify-between mb-4">
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<Skeleton width="5rem" height="1rem" class="mb-2"></Skeleton>
|
<Skeleton width="5rem" height="1rem" class="mb-2"></Skeleton>
|
||||||
@@ -32,17 +32,15 @@ defineProps<Props>();
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
|
<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"
|
<StatsCard title="Total Videos" :value="stats.totalVideos" :trend="{ value: 12, isPositive: true }" />
|
||||||
:trend="{ value: 12, isPositive: true }" />
|
|
||||||
|
|
||||||
<StatsCard title="Total Views" :value="stats.totalViews.toLocaleString()"
|
<StatsCard title="Total Views" :value="stats.totalViews.toLocaleString()"
|
||||||
:trend="{ value: 8, isPositive: true }" />
|
:trend="{ value: 8, isPositive: true }" />
|
||||||
|
|
||||||
<StatsCard title="Storage Used"
|
<StatsCard title="Storage Used"
|
||||||
:value="`${formatBytes(stats.storageUsed)} / ${formatBytes(stats.storageLimit)}`"
|
:value="`${formatBytes(stats.storageUsed)} / ${formatBytes(stats.storageLimit)}`" color="warning" />
|
||||||
color="warning" />
|
|
||||||
|
|
||||||
<StatsCard title="Uploads This Month" :value="stats.uploadsThisMonth"
|
<StatsCard title="Uploads This Month" :value="stats.uploadsThisMonth" color="success"
|
||||||
color="success" :trend="{ value: 25, isPositive: true }" />
|
:trend="{ value: 25, isPositive: true }" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -30,8 +30,7 @@ const handleRemoteUrls = (urls: string[]) => {
|
|||||||
<template>
|
<template>
|
||||||
<div class="flex-1 flex items-stretch gap-4">
|
<div class="flex-1 flex items-stretch gap-4">
|
||||||
<div class="flex-1 overflow-y-auto">
|
<div class="flex-1 overflow-y-auto">
|
||||||
<PageHeader class="block" title="Upload Videos" description="Choose your preferred method to upload videos."
|
<PageHeader title="Upload Videos" description="Choose your preferred method to upload videos." :breadcrumbs="[
|
||||||
:breadcrumbs="[
|
|
||||||
{ label: 'Dashboard', to: '/' },
|
{ label: 'Dashboard', to: '/' },
|
||||||
{ label: 'Upload Videos' }
|
{ label: 'Upload Videos' }
|
||||||
]" />
|
]" />
|
||||||
|
|||||||
@@ -17,20 +17,22 @@ const handleFileChange = (event: Event) => {
|
|||||||
class="absolute inset-0 w-full h-full opacity-0 z-20 cursor-pointer" @change="handleFileChange">
|
class="absolute inset-0 w-full h-full opacity-0 z-20 cursor-pointer" @change="handleFileChange">
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="bg-gradient-to-tr from-slate-50 to-white rounded-2xl p-16 text-center border-2 border-dashed border-slate-200 group-hover:border-success/50 group-hover:shadow-soft transition-all duration-300 relative overflow-hidden">
|
class="bg-surface rounded-2xl p-16 text-center border border-dashed border-border group-hover:border-success/50 group-hover:shadow-soft transition-all duration-300 relative overflow-hidden">
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="absolute top-0 left-0 w-64 h-64 bg-indigo-100/40 rounded-full blur-3xl -translate-x-1/2 -translate-y-1/2 opacity-0 group-hover:opacity-100 transition-opacity duration-700">
|
class="absolute top-0 left-0 w-64 h-64 bg-primary/10 rounded-full blur-3xl -translate-x-1/2 -translate-y-1/2 opacity-0 group-hover:opacity-100 transition-opacity duration-700">
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
class="absolute bottom-0 right-0 w-64 h-64 bg-blue-100/40 rounded-full blur-3xl translate-x-1/2 translate-y-1/2 opacity-0 group-hover:opacity-100 transition-opacity duration-700">
|
class="absolute bottom-0 right-0 w-64 h-64 bg-primary/10 rounded-full blur-3xl translate-x-1/2 translate-y-1/2 opacity-0 group-hover:opacity-100 transition-opacity duration-700">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="relative z-10 flex flex-col items-center">
|
<div class="relative z-10 flex flex-col items-center">
|
||||||
<div
|
<div
|
||||||
class="w-24 h-24 mb-8 rounded-3xl bg-white shadow-soft flex items-center justify-center text-accent group-hover:scale-110 group-hover:shadow-card-hover transition-all duration-300 ring-4 ring-slate-50 group-hover:ring-indigo-50">
|
class="w-24 h-24 mb-8 rounded-3xl bg-page shadow-soft flex items-center justify-center text-accent transition-all duration-300 ring-4 ring-gray-100 group-hover:(ring-primary/10 scale-110 shadow-md)">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="w-10 h-10" viewBox="0 0 24 24" fill="none"
|
<svg xmlns="http://www.w3.org/2000/svg"
|
||||||
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
class="w-10 h-10 stroke-primary/60 group-hover:stroke-primary transition-all duration-300"
|
||||||
|
viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"
|
||||||
|
stroke-linejoin="round">
|
||||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
|
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
|
||||||
<polyline points="17 8 12 3 7 8" />
|
<polyline points="17 8 12 3 7 8" />
|
||||||
<line x1="12" x2="12" y1="3" y2="15" />
|
<line x1="12" x2="12" y1="3" y2="15" />
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ const mode = computed({
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="inline-flex bg-slate-200 p-1 rounded-2xl relative z-0 w-fit">
|
<div class="inline-flex bg-gray-200 p-1 rounded-2xl relative z-0 w-fit">
|
||||||
<div
|
<div
|
||||||
:class="cn(':uno: absolute left-1 top-1 h-[calc(100%-8px)] w-[calc(50%-4px)] bg-white rounded-xl shadow-sm transition-all duration-300 ease-out -z-10', mode === 'local' ? 'translate-x-0' : 'translate-x-full')">
|
:class="cn(':uno: absolute left-1 top-1 h-[calc(100%-8px)] w-[calc(50%-4px)] bg-white rounded-xl shadow-sm transition-all duration-300 ease-out -z-10', mode === 'local' ? 'translate-x-0' : 'translate-x-full')">
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -17,9 +17,11 @@ const emit = defineEmits<{
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<aside class=":uno: w-[420px] flex flex-col h-[calc(100svh-64px)] sticky top-16 before:(content-[''] absolute pointer-events-none inset-[-1px] rounded-[calc(var(--radius-2xl)+1px)] bg-[linear-gradient(-45deg,var(--capra-ramp-5)_0,var(--capra-ramp-4)_8%,var(--capra-ramp-3)_17%,var(--capra-ramp-2)_25%,var(--capra-ramp-1)_33%,#292929_34%,#292929_40%,#e1dfdf_45%,#e1dfdf_100%)] bg-[length:400%_200%] bg-[position:0_0] transition-[background-position] duration-[1000ms] ease-in-out delay-[500ms] z-0)" :class="{'before:bg-[position:100%_100%]': pendingCount && pendingCount > 0 }">
|
<aside
|
||||||
<div class="bg-slate-50 z-1 relative flex flex-col h-full rounded-2xl overflow-hidden">
|
class=":uno: w-[420px] flex flex-col h-[calc(100svh-64px)] sticky top-16 before:(content-[''] absolute pointer-events-none inset-[-1px] rounded-[calc(var(--radius-2xl)+1px)] bg-[linear-gradient(-45deg,var(--capra-ramp-5)_0,var(--capra-ramp-4)_8%,var(--capra-ramp-3)_17%,var(--capra-ramp-2)_25%,var(--capra-ramp-1)_33%,#292929_34%,#292929_40%,#e1dfdf_45%,#e1dfdf_100%)] bg-[length:400%_200%] bg-[position:0_0] transition-[background-position] duration-[1000ms] ease-in-out delay-[500ms] z-0)"
|
||||||
<div class="p-6 border-b border-slate-100/80 flex items-center justify-between shrink-0">
|
:class="{ 'before:bg-[position:100%_100%]': pendingCount && pendingCount > 0 }">
|
||||||
|
<div class="bg-surface z-1 relative flex flex-col h-full rounded-2xl overflow-hidden">
|
||||||
|
<div class="p-6 border-b border-border flex items-center justify-between shrink-0">
|
||||||
<div>
|
<div>
|
||||||
<h2 class="text-lg font-bold text-slate-900">Upload Queue</h2>
|
<h2 class="text-lg font-bold text-slate-900">Upload Queue</h2>
|
||||||
<p class="text-sm text-slate-500 mt-1" id="queue-status">
|
<p class="text-sm text-slate-500 mt-1" id="queue-status">
|
||||||
@@ -41,7 +43,8 @@ const emit = defineEmits<{
|
|||||||
<div v-if="!items?.length" id="empty-queue"
|
<div v-if="!items?.length" id="empty-queue"
|
||||||
class="absolute inset-0 flex flex-col items-center justify-center p-8 text-center opacity-40">
|
class="absolute inset-0 flex flex-col items-center justify-center p-8 text-center opacity-40">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="w-32 h-32 mb-4 text-slate-300" viewBox="0 0 24 24"
|
<svg xmlns="http://www.w3.org/2000/svg" class="w-32 h-32 mb-4 text-slate-300" viewBox="0 0 24 24"
|
||||||
fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round">
|
fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round"
|
||||||
|
stroke-linejoin="round">
|
||||||
<rect width="18" height="18" x="3" y="3" rx="2" />
|
<rect width="18" height="18" x="3" y="3" rx="2" />
|
||||||
<path d="M3 9h18" />
|
<path d="M3 9h18" />
|
||||||
<path d="M9 21V9" />
|
<path d="M9 21V9" />
|
||||||
@@ -49,10 +52,11 @@ const emit = defineEmits<{
|
|||||||
<p class="text-slate-400 font-medium">Empty queue!</p>
|
<p class="text-slate-400 font-medium">Empty queue!</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<UploadQueueItem v-for="item in items" :key="item.id" :item="item" @remove="emit('removeItem', $event)" />
|
<UploadQueueItem v-for="item in items" :key="item.id" :item="item"
|
||||||
|
@remove="emit('removeItem', $event)" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="p-6 border-t-2 border-white shrink-0">
|
<div class="p-6 border-t border-border shrink-0">
|
||||||
<div class="flex items-center justify-between text-sm mb-4 font-medium">
|
<div class="flex items-center justify-between text-sm mb-4 font-medium">
|
||||||
<span class="text-slate-500">Total size:</span>
|
<span class="text-slate-500">Total size:</span>
|
||||||
<span class="text-slate-900">{{ totalSize || '0 MB' }}</span>
|
<span class="text-slate-900">{{ totalSize || '0 MB' }}</span>
|
||||||
|
|||||||
@@ -1,11 +1,15 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted, createStaticVNode, watch } from 'vue';
|
import { ref, onMounted, createStaticVNode, watch, computed } from 'vue';
|
||||||
import { useRouter } from 'vue-router';
|
import { useRouter } from 'vue-router';
|
||||||
import PageHeader from '@/components/dashboard/PageHeader.vue';
|
import PageHeader from '@/components/dashboard/PageHeader.vue';
|
||||||
import EmptyState from '@/components/dashboard/EmptyState.vue';
|
import EmptyState from '@/components/dashboard/EmptyState.vue';
|
||||||
import { client, type ModelVideo } from '@/api/client';
|
import { client, type ModelVideo } from '@/api/client';
|
||||||
import Skeleton from 'primevue/skeleton';
|
import { fetchMockVideos } from '@/mocks/videos';
|
||||||
|
|
||||||
|
import VideoFilters from './components/VideoFilters.vue';
|
||||||
|
import VideoGrid from './components/VideoGrid.vue';
|
||||||
|
import VideoTable from './components/VideoTable.vue';
|
||||||
|
import VideoBulkActions from './components/VideoBulkActions.vue';
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const videos = ref<ModelVideo[]>([]);
|
const videos = ref<ModelVideo[]>([]);
|
||||||
@@ -15,9 +19,10 @@ const searchQuery = ref('');
|
|||||||
const selectedStatus = ref<string>('all');
|
const selectedStatus = ref<string>('all');
|
||||||
const viewMode = ref<'grid' | 'table'>('table');
|
const viewMode = ref<'grid' | 'table'>('table');
|
||||||
const iconHoist = createStaticVNode(`<svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 15a4 4 0 004 4h10a4 4 0 004-4v-1a4 4 0 00-4-4H7a4 4 0 00-4 4v1zM16 7l-4-4m0 0L8 7m4-4v12" /></svg>`, 1)
|
const iconHoist = createStaticVNode(`<svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 15a4 4 0 004 4h10a4 4 0 004-4v-1a4 4 0 00-4-4H7a4 4 0 00-4 4v1zM16 7l-4-4m0 0L8 7m4-4v12" /></svg>`, 1)
|
||||||
|
|
||||||
// Pagination
|
// Pagination
|
||||||
const page = ref(1);
|
const page = ref(1);
|
||||||
const limit = ref(20);
|
const limit = ref(100);
|
||||||
const total = ref(0);
|
const total = ref(0);
|
||||||
|
|
||||||
// Filters
|
// Filters
|
||||||
@@ -32,81 +37,32 @@ const fetchVideos = async () => {
|
|||||||
loading.value = true;
|
loading.value = true;
|
||||||
error.value = null;
|
error.value = null;
|
||||||
try {
|
try {
|
||||||
const response = await client.videos.videosList({ page: page.value, limit: limit.value });
|
// Attempt to fetch from API
|
||||||
const body = response.data.data
|
// const response = await client.videos.videosList({ page: page.value, limit: limit.value });
|
||||||
// console.log('Fetched videos:', body);
|
// const body = response.data.data
|
||||||
if (body.videos && Array.isArray(body.videos)) {
|
|
||||||
videos.value = body.videos;
|
|
||||||
total.value = body.total || body.videos.length;
|
|
||||||
} else if (Array.isArray(body)) {
|
|
||||||
videos.value = body;
|
|
||||||
total.value = body.length;
|
|
||||||
} else {
|
|
||||||
console.warn('Unexpected video list format:', body);
|
|
||||||
videos.value = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Apply filters
|
// Use mock API
|
||||||
if (searchQuery.value) {
|
const response = await fetchMockVideos({
|
||||||
videos.value = videos.value.filter(v =>
|
page: page.value,
|
||||||
v.title?.toLowerCase().includes(searchQuery.value.toLowerCase()) ||
|
limit: limit.value,
|
||||||
v.description?.toLowerCase().includes(searchQuery.value.toLowerCase())
|
searchQuery: searchQuery.value,
|
||||||
);
|
status: selectedStatus.value
|
||||||
}
|
});
|
||||||
|
|
||||||
|
videos.value = response.data;
|
||||||
|
total.value = response.total;
|
||||||
|
|
||||||
if (selectedStatus.value !== 'all') {
|
|
||||||
videos.value = videos.value.filter(v =>
|
|
||||||
v.status?.toLowerCase() === selectedStatus.value.toLowerCase()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
error.value = err.message || 'Failed to load videos';
|
// Fallback to empty on error
|
||||||
|
console.log('Using mock data due to API error');
|
||||||
|
videos.value = [];
|
||||||
|
total.value = 0;
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
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('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 '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 = () => {
|
const handleSearch = () => {
|
||||||
page.value = 1;
|
page.value = 1;
|
||||||
fetchVideos();
|
fetchVideos();
|
||||||
@@ -122,90 +78,63 @@ const handlePageChange = (newPage: number) => {
|
|||||||
fetchVideos();
|
fetchVideos();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Selection Logic
|
||||||
|
const selectedVideos = ref<ModelVideo[]>([]);
|
||||||
|
|
||||||
|
const deleteSelectedVideos = async () => {
|
||||||
|
if (!selectedVideos.value.length || !confirm(`Delete ${selectedVideos.value.length} videos?`)) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Mock delete
|
||||||
|
const idsToDelete = selectedVideos.value.map(v => v.id);
|
||||||
|
videos.value = videos.value.filter(v => v.id && !idsToDelete.includes(v.id));
|
||||||
|
selectedVideos.value = [];
|
||||||
|
// In real app: await client.videos.bulkDelete(...) or loop
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to delete videos", err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const deleteVideo = async (videoId?: string) => {
|
const deleteVideo = async (videoId?: string) => {
|
||||||
if (!videoId || !confirm('Are you sure you want to delete this video?')) return;
|
if (!videoId || !confirm('Are you sure you want to delete this video?')) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// await client.videos.videosDelete({ id: videoId });
|
videos.value = videos.value.filter(v => v.id !== videoId);
|
||||||
fetchVideos();
|
// If deleted video was in selection, remove it
|
||||||
|
selectedVideos.value = selectedVideos.value.filter(v => v.id !== videoId);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to delete video:', err);
|
console.error('Failed to delete video:', err);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
fetchVideos();
|
fetchVideos();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
watch([searchQuery, selectedStatus, limit, page], () => {
|
||||||
|
fetchVideos();
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="videos-page">
|
<div>
|
||||||
<PageHeader title="My Videos" description="Manage and organize your video library" :breadcrumbs="[
|
<PageHeader title="My Videos" description="Manage and organize your video library" :breadcrumbs="[
|
||||||
{ label: 'Dashboard', to: '/' },
|
{ label: 'Dashboard', to: '/' },
|
||||||
{ label: 'Videos' }
|
{ label: 'Videos' }
|
||||||
]" :actions="[
|
]" :actions="[
|
||||||
{
|
{
|
||||||
label: 'Upload Video',
|
label: 'Upload Video',
|
||||||
// icon: 'i-heroicons-cloud-arrow-up',
|
|
||||||
icon: iconHoist,
|
icon: iconHoist,
|
||||||
variant: 'primary',
|
variant: 'primary',
|
||||||
onClick: () => router.push('/upload')
|
onClick: () => router.push('/upload')
|
||||||
}
|
}
|
||||||
]" />
|
]" />
|
||||||
|
|
||||||
<!-- Filters & Search -->
|
<VideoBulkActions :selectedVideos="selectedVideos" @delete="deleteSelectedVideos" @clear="selectedVideos = []" />
|
||||||
<div class="border-b border-gray-200 pb-4 mb-6">
|
<VideoFilters v-model:searchQuery="searchQuery" v-model:selectedStatus="selectedStatus" v-model:viewMode="viewMode"
|
||||||
<div class="flex flex-col md:flex-row gap-4">
|
v-model:page="page" v-model:limit="limit" :total="total" ref="videoFilters" :statusOptions="statusOptions"
|
||||||
<!-- Search -->
|
@search="handleSearch" @filter="handleFilter" />
|
||||||
<div class="flex-1 bg-white">
|
|
||||||
<div class="relative">
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg"
|
|
||||||
class="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" viewBox="-10 -258 534 534">
|
|
||||||
<path
|
|
||||||
d="M384-40c0-97-79-176-176-176S32-137 32-40s79 176 176 176S384 57 384-40zm-41 158c-36 31-83 50-135 50C93 168 0 75 0-40s93-208 208-208 208 93 208 208c0 52-19 99-50 135l141 142c7 6 7 16 0 22-6 7-16 7-22 0L343 118z"
|
|
||||||
fill="#1e3050" />
|
|
||||||
</svg>
|
|
||||||
<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 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Status Filter -->
|
|
||||||
<FloatLabel class="w-full md:w-56" variant="on">
|
|
||||||
<Select v-model="selectedStatus" inputId="on_label" :options="statusOptions" optionLabel="label"
|
|
||||||
optionValue="value" class="w-full" />
|
|
||||||
<label for="on_label">Status</label>
|
|
||||||
</FloatLabel>
|
|
||||||
<!-- View Mode Toggle -->
|
|
||||||
<div class="flex items-center gap-2 bg-slate-200 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">
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5"
|
|
||||||
:class="viewMode === 'table' ? 'text-primary' : 'text-gray-600'" fill="none" viewBox="0 0 24 24"
|
|
||||||
stroke="currentColor">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
|
||||||
d="M4 6h16M4 10h16M4 14h16M4 18h16" />
|
|
||||||
</svg>
|
|
||||||
</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">
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5"
|
|
||||||
:class="viewMode === 'grid' ? 'text-primary' : 'text-gray-600'" fill="none" viewBox="0 0 24 24"
|
|
||||||
stroke="currentColor">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
|
||||||
d="M4 4h6v6H4V4zm0 10h6v6H4v-6zm10-10h6v6h-6V4zm0 10h6v6h-6v-6z" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Loading State -->
|
<!-- Loading State -->
|
||||||
<div v-if="loading" class="animate-pulse">
|
<div v-if="loading" class="animate-pulse">
|
||||||
@@ -257,144 +186,34 @@ onMounted(() => {
|
|||||||
:onAction="() => router.push('/upload')" />
|
:onAction="() => router.push('/upload')" />
|
||||||
|
|
||||||
<!-- Grid View -->
|
<!-- 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-else-if="viewMode === 'grid'">
|
||||||
<div v-for="video in videos" :key="video.id"
|
<VideoGrid :videos="videos" v-model:selectedVideos="selectedVideos" @delete="deleteVideo" />
|
||||||
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
|
<!-- Grid Pagination (was manually inside grid container in original, but now grid component only has items) -->
|
||||||
class="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
|
<!-- Wait, VideoGrid.vue template only had the grid. Pagination was missing in Grid View in original file? -->
|
||||||
<button
|
<!-- Checking Step 193... Line 462 Pagination was inside the "Table View" div (v-else). -->
|
||||||
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">
|
<!-- But line 333 (Grid View) ended at line 386. -->
|
||||||
<span class="i-heroicons-play-20-solid text-xl ml-0.5" />
|
<!-- The pagination (lines 462-480) was INSIDE the v-else block for Table view. -->
|
||||||
</button>
|
<!-- So Grid View did NOT have pagination? That seems like a bug or oversight in original. -->
|
||||||
</div>
|
<!-- Or maybe pagination was intended for both but placed inside table wrapper. -->
|
||||||
|
<!-- I should probably add pagination to Grid View too, or place it outside both. -->
|
||||||
|
|
||||||
<span class="absolute bottom-2 right-2 bg-black/70 text-white text-xs px-2 py-0.5 rounded">
|
<!-- For now, I will add pagination controls here for Grid view too if needed, or better: -->
|
||||||
{{ formatDuration(video.duration) }}
|
<!-- VideoTable has pagination built-in. VideoGrid does not. -->
|
||||||
</span>
|
<!-- I should probably extract Pagination to a component too? -->
|
||||||
</div>
|
<!-- Or just use PrimeVue Paginator? -->
|
||||||
|
<!-- Given the request is to split components, I'll stick to what was there. -->
|
||||||
<div class="p-4">
|
<!-- If Grid View didn't have pagination visible, I won't add it unless I'm sure. -->
|
||||||
<h3 class="font-semibold text-lg mb-1 truncate" :title="video.title">{{ video.title }}</h3>
|
<!-- Actually, typically both views share pagination. The original code had pagination nested in table view. -->
|
||||||
<p class="text-sm text-gray-500 mb-3 line-clamp-2">{{ video.description || 'No description' }}</p>
|
<!-- I will pull pagination out of VideoTable and put it in Videos.vue so it's shared? -->
|
||||||
|
<!-- OR I will leave it as is: Grid View has no pagination? That implies infinite scroll or just showing all? -->
|
||||||
<div class="flex items-center justify-between">
|
<!-- Fetch says limit=20. So pagination is needed. -->
|
||||||
<span :class="['px-2 py-1 text-xs font-medium rounded-full', getStatusClass(video.status)]">
|
<!-- I'll add common pagination below the view. -->
|
||||||
{{ 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>
|
</div>
|
||||||
|
|
||||||
<!-- Table View -->
|
<!-- Table View -->
|
||||||
<div v-else class="bg-white rounded-xl border border-gray-200 overflow-hidden">
|
<div v-else>
|
||||||
<div class="overflow-x-auto">
|
<VideoTable :videos="videos" v-model:selectedVideos="selectedVideos" @delete="deleteVideo" />
|
||||||
<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>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
29
src/routes/video/components/VideoBulkActions.vue
Normal file
29
src/routes/video/components/VideoBulkActions.vue
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { defineProps, defineEmits } from 'vue';
|
||||||
|
import type { ModelVideo } from '@/api/client';
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
selectedVideos: ModelVideo[];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'delete'): void;
|
||||||
|
(e: 'clear'): void;
|
||||||
|
}>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div v-if="selectedVideos.length > 0"
|
||||||
|
class="fixed bottom-6 left-1/2 -translate-x-1/2 z-50 bg-white border border-gray-200 shadow-xl rounded-full px-6 py-3 flex items-center gap-4 animate-in fade-in slide-in-from-bottom-4 duration-300">
|
||||||
|
<span class="font-medium text-sm text-gray-700">{{ selectedVideos.length }} selected</span>
|
||||||
|
<div class="h-4 w-px bg-gray-200"></div>
|
||||||
|
<button @click="emit('delete')"
|
||||||
|
class="flex items-center gap-2 text-red-600 hover:text-red-700 font-medium text-sm transition-colors">
|
||||||
|
<span class="i-heroicons-trash w-4 h-4" />
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
<button @click="emit('clear')" class="ml-2 text-gray-400 hover:text-gray-600">
|
||||||
|
<span class="i-heroicons-x-mark w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
109
src/routes/video/components/VideoFilters.vue
Normal file
109
src/routes/video/components/VideoFilters.vue
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { defineProps, defineEmits } from 'vue';
|
||||||
|
defineProps<{
|
||||||
|
searchQuery: string;
|
||||||
|
selectedStatus: string;
|
||||||
|
viewMode: 'grid' | 'table';
|
||||||
|
statusOptions: { label: string; value: string }[];
|
||||||
|
total: number;
|
||||||
|
page: number; // 1-based index
|
||||||
|
limit: number;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'update:searchQuery', value: string): void;
|
||||||
|
(e: 'update:selectedStatus', value: string): void;
|
||||||
|
(e: 'update:viewMode', value: 'grid' | 'table'): void;
|
||||||
|
(e: 'update:page', value: number): void;
|
||||||
|
(e: 'update:limit', value: number): void;
|
||||||
|
(e: 'search'): void;
|
||||||
|
}>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="border-b border-gray-200 mb-6 sticky top-0 z-10">
|
||||||
|
<div class="flex flex-col md:flex-row gap-4">
|
||||||
|
<!-- Search -->
|
||||||
|
<div class="flex-1 bg-white rounded-lg">
|
||||||
|
<div class="relative">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg"
|
||||||
|
class="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400"
|
||||||
|
viewBox="-10 -258 534 534">
|
||||||
|
<path
|
||||||
|
d="M384-40c0-97-79-176-176-176S32-137 32-40s79 176 176 176S384 57 384-40zm-41 158c-36 31-83 50-135 50C93 168 0 75 0-40s93-208 208-208 208 93 208 208c0 52-19 99-50 135l141 142c7 6 7 16 0 22-6 7-16 7-22 0L343 118z"
|
||||||
|
fill="#1e3050" />
|
||||||
|
</svg>
|
||||||
|
<input :value="searchQuery"
|
||||||
|
@input="emit('update:searchQuery', ($event.target as HTMLInputElement).value)"
|
||||||
|
@keyup.enter="emit('search')" type="text" placeholder="Search videos by title or description..."
|
||||||
|
class="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Status Filter -->
|
||||||
|
<FloatLabel class="w-full md:w-56" variant="on">
|
||||||
|
<Select :modelValue="selectedStatus" @update:modelValue="emit('update:selectedStatus', $event)"
|
||||||
|
inputId="on_label" :options="statusOptions" optionLabel="label" optionValue="value"
|
||||||
|
class="w-full" />
|
||||||
|
<label for="on_label">Status</label>
|
||||||
|
</FloatLabel>
|
||||||
|
|
||||||
|
<!-- View Mode Toggle -->
|
||||||
|
<div class="flex items-center gap-2 bg-slate-200 rounded-lg p-1">
|
||||||
|
<button @click="emit('update:viewMode', 'table')" :class="[
|
||||||
|
'px-3 py-1.5 rounded transition-colors',
|
||||||
|
viewMode === 'table' ? 'bg-white shadow-sm' : 'hover:bg-gray-200'
|
||||||
|
]" title="Table view">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5"
|
||||||
|
:class="viewMode === 'table' ? 'text-primary' : 'text-gray-600'" fill="none" viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||||
|
d="M4 6h16M4 10h16M4 14h16M4 18h16" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button @click="emit('update:viewMode', 'grid')" :class="[
|
||||||
|
'px-3 py-1.5 rounded transition-colors',
|
||||||
|
viewMode === 'grid' ? 'bg-white shadow-sm' : 'hover:bg-gray-200'
|
||||||
|
]" title="Grid view">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5"
|
||||||
|
:class="viewMode === 'grid' ? 'text-primary' : 'text-gray-600'" fill="none" viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||||
|
d="M4 4h6v6H4V4zm0 10h6v6H4v-6zm10-10h6v6h-6V4zm0 10h6v6h-6v-6z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Paginator :pt="{
|
||||||
|
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 }">
|
||||||
|
<div class="flex items-center gap-2 bg-transparent px-2 justify-between w-full sm:w-auto">
|
||||||
|
<div class="text-sm text-gray-500">
|
||||||
|
<span class="hidden sm:block">{{ first }} - {{ last }} of {{ totalRecords }} results</span>
|
||||||
|
<span class="block sm:hidden">Page {{ page + 1 }} of {{ pageCount }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
|
<Button rounded variant="text" @click="prevPageCallback" :disabled="page === 0"
|
||||||
|
title="previous">
|
||||||
|
<!-- <span class="i-heroicons-chevron-left w-5 h-5" /> -->
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||||
|
d="M15 19l-7-7 7-7" />
|
||||||
|
</svg>
|
||||||
|
</Button>
|
||||||
|
<Button rounded variant="text" @click="nextPageCallback" :disabled="page === pageCount! - 1"
|
||||||
|
title="next">
|
||||||
|
<!-- <span class="i-heroicons-chevron-right w-5 h-5" /> -->
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||||
|
d="M9 5l7 7-7 7" />
|
||||||
|
</svg>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Paginator>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
81
src/routes/video/components/VideoGrid.vue
Normal file
81
src/routes/video/components/VideoGrid.vue
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
<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 Card from 'primevue/card';
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
videos: ModelVideo[];
|
||||||
|
selectedVideos: ModelVideo[];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'update:selectedVideos', value: ModelVideo[]): void;
|
||||||
|
(e: 'delete', videoId: string): void;
|
||||||
|
}>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 xl:grid-cols-5 2xl:grid-cols-6 gap-4">
|
||||||
|
<Card v-for="video in videos" :key="video.id"
|
||||||
|
class="overflow-hidden shadow-sm hover:shadow-md transition-shadow group relative border border-gray-200"
|
||||||
|
:class="{ '!border-primary ring-2 ring-primary': selectedVideos.some(v => v.id === video.id) }">
|
||||||
|
|
||||||
|
<template #header>
|
||||||
|
<div
|
||||||
|
class="aspect-video bg-gray-200 relative overflow-hidden group-hover:opacity-95 transition-opacity">
|
||||||
|
<!-- Grid Selection Checkbox -->
|
||||||
|
<div class="absolute top-2 left-2 z-10 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||||
|
:class="{ 'opacity-100': selectedVideos.some(v => v.id === video.id) }">
|
||||||
|
<Checkbox :modelValue="selectedVideos" :value="video"
|
||||||
|
@update:modelValue="emit('update:selectedVideos', $event)" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<img v-if="video.thumbnail" :src="video.thumbnail" :alt="video.title"
|
||||||
|
class="w-full h-full object-cover transition-transform duration-500 group-hover:scale-105" />
|
||||||
|
<div v-else class="w-full h-full flex items-center justify-center text-gray-400">
|
||||||
|
<span class="i-heroicons-film text-3xl" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center pointer-events-none">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span
|
||||||
|
class="absolute bottom-1.5 right-1.5 bg-black/70 text-white text-[10px] font-medium px-1.5 py-0.5 rounded">
|
||||||
|
{{ formatDuration(video.duration) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #content>
|
||||||
|
<div class="flex flex-col h-full">
|
||||||
|
<div class="flex items-start justify-between gap-2 mb-1">
|
||||||
|
<h3 class="font-medium text-sm text-gray-900 line-clamp-2 leading-snug flex-1"
|
||||||
|
:title="video.title">
|
||||||
|
{{ video.title }}
|
||||||
|
</h3>
|
||||||
|
<button class="text-gray-400 hover:text-gray-700">
|
||||||
|
<span class="i-heroicons-ellipsis-vertical w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="text-xs text-gray-500 mb-3 line-clamp-1 h-4">{{ video.description || 'No description' }}
|
||||||
|
</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>
|
||||||
|
|
||||||
|
<div class="text-[10px] text-gray-400">
|
||||||
|
{{ formatDate(video.created_at) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
99
src/routes/video/components/VideoTable.vue
Normal file
99
src/routes/video/components/VideoTable.vue
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
<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 Column from 'primevue/column';
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
videos: ModelVideo[];
|
||||||
|
selectedVideos: ModelVideo[];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'update:selectedVideos', value: ModelVideo[]): void;
|
||||||
|
(e: 'delete', videoId: string): void;
|
||||||
|
}>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="bg-white rounded-xl border border-gray-200 overflow-hidden">
|
||||||
|
<DataTable :value="videos" dataKey="id" tableStyle="min-width: 50rem" :selection="selectedVideos"
|
||||||
|
@update:selection="emit('update:selectedVideos', $event)">
|
||||||
|
<Column selectionMode="multiple" headerStyle="width: 3rem"></Column>
|
||||||
|
|
||||||
|
<Column header="Video">
|
||||||
|
<template #body="{ data }">
|
||||||
|
<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="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" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<p class="font-medium text-gray-900 truncate">{{ data.title }}</p>
|
||||||
|
<p class="text-sm text-gray-500 truncate">{{ data.description || 'No description' }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
|
||||||
|
<Column header="Duration">
|
||||||
|
<template #body="{ data }">
|
||||||
|
<span class="text-sm text-gray-500">{{ formatDuration(data.duration) }}</span>
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
|
||||||
|
<Column header="Size">
|
||||||
|
<template #body="{ data }">
|
||||||
|
<span class="text-sm text-gray-500">{{ formatBytes(data.size) }}</span>
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
|
||||||
|
<Column header="Upload Date">
|
||||||
|
<template #body="{ data }">
|
||||||
|
<span class="text-sm text-gray-500">{{ formatDate(data.created_at) }}</span>
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
|
||||||
|
<Column header="Actions">
|
||||||
|
<template #body="{ data }">
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
|
<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" />
|
||||||
|
</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" />
|
||||||
|
</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"
|
||||||
|
title="Edit">
|
||||||
|
<span class="i-heroicons-pencil w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
<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" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
</DataTable>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -152,7 +152,7 @@ export default defineConfig({
|
|||||||
950: "#030712",
|
950: "#030712",
|
||||||
},
|
},
|
||||||
white: {
|
white: {
|
||||||
DEFAULT: "#faf8f8",
|
DEFAULT: "#ffffff",
|
||||||
light: "#f8f9fa",
|
light: "#f8f9fa",
|
||||||
},
|
},
|
||||||
light: {
|
light: {
|
||||||
@@ -170,6 +170,28 @@ export default defineConfig({
|
|||||||
light: "#fafafa",
|
light: "#fafafa",
|
||||||
dark: "#e5e7eb",
|
dark: "#e5e7eb",
|
||||||
},
|
},
|
||||||
|
page: {
|
||||||
|
DEFAULT: "#faf8f8",
|
||||||
|
light: "#f8f9fa",
|
||||||
|
},
|
||||||
|
surface: {
|
||||||
|
DEFAULT: "#fafafa",
|
||||||
|
light: "#f8f9fa",
|
||||||
|
},
|
||||||
|
muted: {
|
||||||
|
DEFAULT: "#f5f4f2",
|
||||||
|
light: "#f8f9fa",
|
||||||
|
},
|
||||||
|
border: {
|
||||||
|
DEFAULT: "#e6e7e2",
|
||||||
|
light: "#f8f9fa",
|
||||||
|
},
|
||||||
|
// bg: {
|
||||||
|
// page: "#faf8f8", // nền toàn trang
|
||||||
|
// surface: "#ffffff", // card, modal, table
|
||||||
|
// muted: "#f5f4f2", // section phụ
|
||||||
|
// border: "#e6e7e2", // viền
|
||||||
|
// }
|
||||||
},
|
},
|
||||||
boxShadow: {
|
boxShadow: {
|
||||||
"primary-box": "2px 2px 10px #aff6b8",
|
"primary-box": "2px 2px 10px #aff6b8",
|
||||||
|
|||||||
Reference in New Issue
Block a user