add mock video

This commit is contained in:
2026-01-29 18:34:54 +07:00
parent 478c31defa
commit cf9c488012
26 changed files with 1093 additions and 455 deletions

8
components.d.ts vendored
View File

@@ -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']

View File

@@ -1,34 +1,31 @@
import { tryGetContext } from "hono/context-storage"; 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"); }
} // Merge headers properly - keep original options.headers and add request headers
// Merge headers properly - keep original options.headers and add request headers const reqHeaders = new Headers(c.req.header());
const reqHeaders = new Headers(c.req.header()); // Remove headers that shouldn't be forwarded
// Remove headers that shouldn't be forwarded reqHeaders.delete("host");
reqHeaders.delete("host"); reqHeaders.delete("connection");
reqHeaders.delete("connection");
const mergedHeaders: Record<string, string> = {}; const mergedHeaders: Record<string, string> = {};
reqHeaders.forEach((value, key) => { reqHeaders.forEach((value, key) => {
mergedHeaders[key] = value; mergedHeaders[key] = value;
}); });
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) };
}
}

View File

@@ -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"

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

View File

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

View File

@@ -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'

View File

@@ -13,5 +13,5 @@
</svg> </svg>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
defineProps<{ filled?: boolean }>(); defineProps<{ filled?: boolean }>();
</script> </script>

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

View File

@@ -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>");

View 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));
}

View File

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

View File

@@ -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
View 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
};
};

View File

@@ -47,19 +47,19 @@ const quickActions = [
<div v-if="loading" class="mb-8"> <div v-if="loading" class="mb-8">
<Skeleton width="10rem" height="1.5rem" class="mb-4"></Skeleton> <Skeleton width="10rem" height="1.5rem" class="mb-4"></Skeleton>
<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">
<div v-for="i in 4" :key="i" class="p-6 rounded-xl border border-gray-200"> <div v-for="i in 4" :key="i" class="p-6 rounded-xl border border-gray-200">
<Skeleton shape="circle" size="3rem" class="mb-4"></Skeleton> <Skeleton shape="circle" size="3rem" class="mb-4"></Skeleton>
<Skeleton width="8rem" height="1.25rem" class="mb-2"></Skeleton> <Skeleton width="8rem" height="1.25rem" class="mb-2"></Skeleton>
<Skeleton width="100%" height="1rem"></Skeleton>
</div>
</div>
<div class="flex flex-col justify-between p-6 rounded-xl border border-gray-200">
<Skeleton width="10rem" height="2rem"></Skeleton>
<Skeleton width="100%" height="1.25rem" class="my-4"></Skeleton>
<Skeleton width="100%" height="1rem"></Skeleton> <Skeleton width="100%" height="1rem"></Skeleton>
</div> </div>
</div> </div>
<div class="flex flex-col justify-between p-6 rounded-xl border border-gray-200">
<Skeleton width="10rem" height="2rem"></Skeleton>
<Skeleton width="100%" height="1.25rem" class="my-4"></Skeleton>
<Skeleton width="100%" height="1rem"></Skeleton>
</div>
</div>
</div> </div>
<div v-else class="mb-8"> <div v-else class="mb-8">
@@ -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>

View File

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

View File

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

View File

@@ -30,11 +30,10 @@ 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' } ]" />
]" />
<div class="flex flex-col max-w-4xl mx-auto gap-4"> <div class="flex flex-col max-w-4xl mx-auto gap-4">
<UploadModeToggle v-model="mode" /> <UploadModeToggle v-model="mode" />
<InfoTip /> <InfoTip />

View File

@@ -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" />

View File

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

View File

@@ -17,58 +17,62 @@ 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> <div class="bg-surface z-1 relative flex flex-col h-full rounded-2xl overflow-hidden">
<h2 class="text-lg font-bold text-slate-900">Upload Queue</h2> <div class="p-6 border-b border-border flex items-center justify-between shrink-0">
<p class="text-sm text-slate-500 mt-1" id="queue-status"> <div>
{{ items?.length ? `${items.length} task(s)` : 'No tasks yet' }} <h2 class="text-lg font-bold text-slate-900">Upload Queue</h2>
</p> <p class="text-sm text-slate-500 mt-1" id="queue-status">
{{ items?.length ? `${items.length} task(s)` : 'No tasks yet' }}
</p>
</div>
<div class="w-10 h-10 rounded-full bg-slate-100 flex items-center justify-center text-slate-400">
<svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 12H3" />
<path d="M16 6H3" />
<path d="M12 18H3" />
<path d="m16 12 5 3-5 3v-6Z" />
</svg>
</div>
</div> </div>
<div class="w-10 h-10 rounded-full bg-slate-100 flex items-center justify-center text-slate-400">
<svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5" viewBox="0 0 24 24" fill="none" <div class="flex-1 overflow-y-auto scrollbar-thin p-6 space-y-5 relative" id="queue-list">
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> <div v-if="!items?.length" id="empty-queue"
<path d="M12 12H3" /> class="absolute inset-0 flex flex-col items-center justify-center p-8 text-center opacity-40">
<path d="M16 6H3" /> <svg xmlns="http://www.w3.org/2000/svg" class="w-32 h-32 mb-4 text-slate-300" viewBox="0 0 24 24"
<path d="M12 18H3" /> fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round"
<path d="m16 12 5 3-5 3v-6Z" /> stroke-linejoin="round">
</svg> <rect width="18" height="18" x="3" y="3" rx="2" />
<path d="M3 9h18" />
<path d="M9 21V9" />
</svg>
<p class="text-slate-400 font-medium">Empty queue!</p>
</div>
<UploadQueueItem v-for="item in items" :key="item.id" :item="item"
@remove="emit('removeItem', $event)" />
</div>
<div class="p-6 border-t border-border shrink-0">
<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-900">{{ totalSize || '0 MB' }}</span>
</div>
<button :disabled="!!(!pendingCount || pendingCount < 1)" @click="emit('startQueue')"
class="btn btn-primary w-full flex items-center justify-center gap-2 mb-3">
<svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5" 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" />
<polyline points="17 8 12 3 7 8" />
<line x1="12" y1="3" x2="12" y2="15" />
</svg>
Start Upload ({{ pendingCount }})
</button>
</div> </div>
</div> </div>
<div class="flex-1 overflow-y-auto scrollbar-thin p-6 space-y-5 relative" id="queue-list">
<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">
<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">
<rect width="18" height="18" x="3" y="3" rx="2" />
<path d="M3 9h18" />
<path d="M9 21V9" />
</svg>
<p class="text-slate-400 font-medium">Empty queue!</p>
</div>
<UploadQueueItem v-for="item in items" :key="item.id" :item="item" @remove="emit('removeItem', $event)" />
</div>
<div class="p-6 border-t-2 border-white shrink-0">
<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-900">{{ totalSize || '0 MB' }}</span>
</div>
<button :disabled="!!(!pendingCount || pendingCount < 1)" @click="emit('startQueue')"
class="btn btn-primary w-full flex items-center justify-center gap-2 mb-3">
<svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5" 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" />
<polyline points="17 8 12 3 7 8" />
<line x1="12" y1="3" x2="12" y2="15" />
</svg>
Start Upload ({{ pendingCount }})
</button>
</div>
</div>
</aside> </aside>
</template> </template>

View File

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

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

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

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

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

View File

@@ -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",