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

View File

@@ -1,34 +1,31 @@
import { tryGetContext } from "hono/context-storage";
export const customFetch = (url: string, options: RequestInit) => {
options.credentials = "include";
if (import.meta.env.SSR) {
const c = tryGetContext<any>();
if (!c) {
throw new Error("Hono context not found in SSR");
}
// Merge headers properly - keep original options.headers and add request headers
const reqHeaders = new Headers(c.req.header());
// Remove headers that shouldn't be forwarded
reqHeaders.delete("host");
reqHeaders.delete("connection");
options.credentials = "include";
const c = tryGetContext<any>();
if (!c) {
throw new Error("Hono context not found in SSR");
}
// Merge headers properly - keep original options.headers and add request headers
const reqHeaders = new Headers(c.req.header());
// Remove headers that shouldn't be forwarded
reqHeaders.delete("host");
reqHeaders.delete("connection");
const mergedHeaders: Record<string, string> = {};
reqHeaders.forEach((value, key) => {
mergedHeaders[key] = value;
});
options.headers = {
...mergedHeaders,
...(options.headers as Record<string, string>)
};
const mergedHeaders: Record<string, string> = {};
reqHeaders.forEach((value, key) => {
mergedHeaders[key] = value;
});
options.headers = {
...mergedHeaders,
...(options.headers as Record<string, string>),
};
const apiUrl = ["https://api.pipic.fun", url.replace(/^r/, '')].join('');
// const res = await fetch(apiUrl, options);
// Forward response headers to client (especially Set-Cookie)
// res.headers.forEach((value, key) => {
// c.header(key, value);
// });
return fetch(apiUrl, options)
}
}
const apiUrl = ["https://api.pipic.fun", url.replace(/^r/, "")].join("");
return fetch(apiUrl, options).then(async (res) => {
res.headers.getSetCookie()?.forEach((cookie) => {
c.header("Set-Cookie", cookie);
});
return res;
});
};

View File

@@ -1,59 +1,13 @@
<script lang="ts" setup>
import Add from "@/components/icons/Add.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 DashboardNav from "./DashboardNav.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>
<template>
<header
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">
<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" :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)]">
<DashboardNav />
<main class="flex flex-1 flex-col transition-all duration-300 ease-in-out bg-page md:ps-18">
<div class=":uno: flex-1 overflow-auto p-4 bg-page rounded-lg md:(mr-2 mb-2) min-h-[calc(100vh-8rem)]">
<router-view v-slot="{ Component }">
<Transition enter-active-class="transition-all duration-300 ease-in-out"
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

@@ -25,7 +25,7 @@ const props = defineProps<Props>();
const getButtonClass = (variant?: string) => {
const baseClass = 'px-4 py-2.5 rounded-lg font-medium transition-all press-animated flex items-center gap-2';
switch (variant) {
case 'primary':
return `${baseClass} bg-primary hover:bg-primary-600 text-white shadow-sm`;
@@ -43,20 +43,14 @@ const getButtonClass = (variant?: string) => {
<!-- Breadcrumb -->
<nav v-if="breadcrumbs && breadcrumbs.length" class="flex items-center gap-2 text-sm mb-2">
<template v-for="(crumb, index) in breadcrumbs" :key="index">
<router-link
v-if="crumb.to"
:to="crumb.to"
class="text-gray-500 hover:text-primary transition-colors"
>
<router-link v-if="crumb.to" :to="crumb.to" class="text-gray-500 hover:text-primary transition-colors">
{{ crumb.label }}
</router-link>
<span v-else class="text-gray-700 font-medium">{{ crumb.label }}</span>
<span
v-if="index < breadcrumbs.length - 1"
class="w-4 h-4 text-gray-400"
>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<span v-if="index < breadcrumbs.length - 1" class="w-4 h-4 text-gray-400">
<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" />
</svg>
</span>
@@ -72,17 +66,9 @@ const getButtonClass = (variant?: string) => {
</div>
<div v-if="actions && actions.length" class="flex items-center gap-2 flex-shrink-0">
<button
v-for="(action, index) in actions"
:key="index"
@click="action.onClick"
:class="getButtonClass(action.variant)"
>
<component
v-if="action.icon"
:is="action.icon"
class="w-5 h-5"
/>
<button v-for="(action, index) in actions" :key="index" @click="action.onClick"
:class="getButtonClass(action.variant)">
<component v-if="action.icon" :is="action.icon" class="w-5 h-5" />
{{ action.label }}
</button>
</div>

View File

@@ -37,7 +37,7 @@ const iconColors = {
<template>
<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],
'border border-gray-300 transition-all duration-300',
// 'group cursor-pointer'

View File

@@ -13,5 +13,5 @@
</svg>
</template>
<script lang="ts" setup>
defineProps<{ filled?: boolean }>();
defineProps<{ filled?: boolean }>();
</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';
// @ts-ignore
import Base from '@primevue/core/base';
import { createTextTransformStreamClass } from './lib/replateStreamText';
const app = new Hono()
const defaultNames = ['primitive', 'semantic', 'global', 'base', 'ripple-directive']
// app.use(renderer)
@@ -73,6 +74,7 @@ app.get("*", async (c) => {
// console.log("ctx: ", );
await stream.write("<!DOCTYPE html><html lang='en'><head>");
await stream.write("<base href='" + url.origin + "'/>");
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 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 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 });
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>");

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'
});
};
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 ToastService from 'primevue/toastservice';
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() {
const pinia = createPinia();
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">
<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 lg:grid-cols-4 gap-4">
<div v-for="i in 4" :key="i" class="p-6 rounded-xl border border-gray-200">
<Skeleton shape="circle" size="3rem" class="mb-4"></Skeleton>
<Skeleton width="8rem" height="1.25rem" class="mb-2"></Skeleton>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<div v-for="i in 4" :key="i" class="p-6 rounded-xl border border-gray-200">
<Skeleton shape="circle" size="3rem" class="mb-4"></Skeleton>
<Skeleton width="8rem" height="1.25rem" class="mb-2"></Skeleton>
<Skeleton width="100%" height="1rem"></Skeleton>
</div>
</div>
<div 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 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 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 lg:grid-cols-4 gap-4">
<button v-for="action in quickActions" :key="action.title" @click="action.onClick" :class="[
'p-6 rounded-xl text-left transition-all duration-200 flex flex-col 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',
'group press-animated',
]">
<div
:class="['w-12 h-12 rounded-lg flex items-center justify-center mb-4 bg-gray-100 group-hover:bg-primary/10']">
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" />
</div>
<h3 class="font-semibold mb-1 group-hover:text-primary transition-colors">{{ action.title }}</h3>

View File

@@ -1,5 +1,5 @@
<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">
<h3 class="text-lg font-semibold leading-none tracking-tight">Referral Link</h3>
</div>

View File

@@ -19,7 +19,7 @@ defineProps<Props>();
<template>
<div v-if="loading" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
<div v-for="i in 4" :key="i" class="bg-white rounded-xl border border-gray-200 p-6">
<div 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="space-y-2">
<Skeleton width="5rem" height="1rem" class="mb-2"></Skeleton>
@@ -32,17 +32,15 @@ defineProps<Props>();
</div>
<div v-else class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
<StatsCard title="Total Videos" :value="stats.totalVideos"
:trend="{ value: 12, isPositive: true }" />
<StatsCard title="Total Videos" :value="stats.totalVideos" :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 }" />
<StatsCard title="Storage Used"
:value="`${formatBytes(stats.storageUsed)} / ${formatBytes(stats.storageLimit)}`"
color="warning" />
:value="`${formatBytes(stats.storageUsed)} / ${formatBytes(stats.storageLimit)}`" color="warning" />
<StatsCard title="Uploads This Month" :value="stats.uploadsThisMonth"
color="success" :trend="{ value: 25, isPositive: true }" />
<StatsCard title="Uploads This Month" :value="stats.uploadsThisMonth" color="success"
:trend="{ value: 25, isPositive: true }" />
</div>
</template>

View File

@@ -30,11 +30,10 @@ const handleRemoteUrls = (urls: string[]) => {
<template>
<div class="flex-1 flex items-stretch gap-4">
<div class="flex-1 overflow-y-auto">
<PageHeader class="block" title="Upload Videos" description="Choose your preferred method to upload videos."
:breadcrumbs="[
{ label: 'Dashboard', to: '/' },
{ label: 'Upload Videos' }
]" />
<PageHeader title="Upload Videos" description="Choose your preferred method to upload videos." :breadcrumbs="[
{ label: 'Dashboard', to: '/' },
{ label: 'Upload Videos' }
]" />
<div class="flex flex-col max-w-4xl mx-auto gap-4">
<UploadModeToggle v-model="mode" />
<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">
<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
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
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 class="relative z-10 flex flex-col items-center">
<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">
<svg xmlns="http://www.w3.org/2000/svg" class="w-10 h-10" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
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 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" />
<polyline points="17 8 12 3 7 8" />
<line x1="12" x2="12" y1="3" y2="15" />

View File

@@ -30,7 +30,7 @@ const mode = computed({
</script>
<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
: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>

View File

@@ -17,58 +17,62 @@ const emit = defineEmits<{
</script>
<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 }">
<div class="bg-slate-50 z-1 relative flex flex-col h-full rounded-2xl overflow-hidden">
<div class="p-6 border-b border-slate-100/80 flex items-center justify-between shrink-0">
<div>
<h2 class="text-lg font-bold text-slate-900">Upload Queue</h2>
<p class="text-sm text-slate-500 mt-1" id="queue-status">
{{ items?.length ? `${items.length} task(s)` : 'No tasks yet' }}
</p>
<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 }">
<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>
<h2 class="text-lg font-bold text-slate-900">Upload Queue</h2>
<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 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 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 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 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>
</template>

View File

@@ -1,11 +1,15 @@
<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 PageHeader from '@/components/dashboard/PageHeader.vue';
import EmptyState from '@/components/dashboard/EmptyState.vue';
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 videos = ref<ModelVideo[]>([]);
@@ -15,9 +19,10 @@ const searchQuery = ref('');
const selectedStatus = ref<string>('all');
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)
// Pagination
const page = ref(1);
const limit = ref(20);
const limit = ref(100);
const total = ref(0);
// Filters
@@ -32,81 +37,32 @@ const fetchVideos = async () => {
loading.value = true;
error.value = null;
try {
const response = await client.videos.videosList({ page: page.value, limit: limit.value });
const body = response.data.data
// console.log('Fetched videos:', body);
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 = [];
}
// Attempt to fetch from API
// const response = await client.videos.videosList({ page: page.value, limit: limit.value });
// const body = response.data.data
// Apply filters
if (searchQuery.value) {
videos.value = videos.value.filter(v =>
v.title?.toLowerCase().includes(searchQuery.value.toLowerCase()) ||
v.description?.toLowerCase().includes(searchQuery.value.toLowerCase())
);
}
// Use mock API
const response = await fetchMockVideos({
page: page.value,
limit: limit.value,
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) {
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 {
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 = () => {
page.value = 1;
fetchVideos();
@@ -122,90 +78,63 @@ const handlePageChange = (newPage: number) => {
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) => {
if (!videoId || !confirm('Are you sure you want to delete this video?')) return;
try {
// await client.videos.videosDelete({ id: videoId });
fetchVideos();
videos.value = videos.value.filter(v => v.id !== videoId);
// If deleted video was in selection, remove it
selectedVideos.value = selectedVideos.value.filter(v => v.id !== videoId);
} catch (err) {
console.error('Failed to delete video:', err);
}
};
onMounted(() => {
fetchVideos();
});
watch([searchQuery, selectedStatus, limit, page], () => {
fetchVideos();
});
</script>
<template>
<div class="videos-page">
<div>
<PageHeader title="My Videos" description="Manage and organize your video library" :breadcrumbs="[
{ label: 'Dashboard', to: '/' },
{ label: 'Videos' }
]" :actions="[
{
label: 'Upload Video',
// icon: 'i-heroicons-cloud-arrow-up',
icon: iconHoist,
variant: 'primary',
onClick: () => router.push('/upload')
}
]" />
{
label: 'Upload Video',
icon: iconHoist,
variant: 'primary',
onClick: () => router.push('/upload')
}
]" />
<!-- Filters & Search -->
<div class="border-b border-gray-200 pb-4 mb-6">
<div class="flex flex-col md:flex-row gap-4">
<!-- Search -->
<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>
<VideoBulkActions :selectedVideos="selectedVideos" @delete="deleteSelectedVideos" @clear="selectedVideos = []" />
<VideoFilters v-model:searchQuery="searchQuery" v-model:selectedStatus="selectedStatus" v-model:viewMode="viewMode"
v-model:page="page" v-model:limit="limit" :total="total" ref="videoFilters" :statusOptions="statusOptions"
@search="handleSearch" @filter="handleFilter" />
<!-- 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 -->
<div v-if="loading" class="animate-pulse">
@@ -257,144 +186,34 @@ onMounted(() => {
:onAction="() => router.push('/upload')" />
<!-- Grid View -->
<div v-else-if="viewMode === 'grid'" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
<div v-for="video in videos" :key="video.id"
class="bg-white border border-gray-200 rounded-xl overflow-hidden shadow-sm hover:shadow-md transition-shadow group">
<div class="aspect-video bg-gray-200 relative overflow-hidden">
<img v-if="video.thumbnail" :src="video.thumbnail" :alt="video.title" class="w-full h-full object-cover" />
<div v-else class="w-full h-full flex items-center justify-center text-gray-400">
<span class="i-heroicons-film text-4xl" />
</div>
<div v-else-if="viewMode === 'grid'">
<VideoGrid :videos="videos" v-model:selectedVideos="selectedVideos" @delete="deleteVideo" />
<div
class="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
<button
class="w-12 h-12 bg-white hover:bg-primary text-gray-800 hover:text-white rounded-full flex items-center justify-center transition-colors">
<span class="i-heroicons-play-20-solid text-xl ml-0.5" />
</button>
</div>
<!-- Grid Pagination (was manually inside grid container in original, but now grid component only has items) -->
<!-- Wait, VideoGrid.vue template only had the grid. Pagination was missing in Grid View in original file? -->
<!-- Checking Step 193... Line 462 Pagination was inside the "Table View" div (v-else). -->
<!-- But line 333 (Grid View) ended at line 386. -->
<!-- The pagination (lines 462-480) was INSIDE the v-else block for Table view. -->
<!-- So Grid View did NOT have pagination? That seems like a bug or oversight in original. -->
<!-- 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">
{{ formatDuration(video.duration) }}
</span>
</div>
<div class="p-4">
<h3 class="font-semibold text-lg mb-1 truncate" :title="video.title">{{ video.title }}</h3>
<p class="text-sm text-gray-500 mb-3 line-clamp-2">{{ video.description || 'No description' }}</p>
<div class="flex items-center justify-between">
<span :class="['px-2 py-1 text-xs font-medium rounded-full', getStatusClass(video.status)]">
{{ video.status }}
</span>
<div class="flex items-center gap-1">
<button class="p-1.5 hover:bg-gray-100 rounded transition-colors" title="Edit">
<span class="i- w-4 h-4 text-gray-600" />
</button>
<button class="p-1.5 hover:bg-gray-100 rounded transition-colors" title="Share">
<span class="i-heroicons-share w-4 h-4 text-gray-600" />
</button>
<button @click="deleteVideo(video.id)" class="p-1.5 hover:bg-red-100 rounded transition-colors"
title="Delete">
<span class="i-heroicons-trash w-4 h-4 text-red-600" />
</button>
</div>
</div>
<div class="mt-3 pt-3 border-t border-gray-100 flex items-center justify-between text-xs text-gray-500">
<span>{{ formatDate(video.created_at) }}</span>
<span>{{ formatBytes(video.size) }}</span>
</div>
</div>
</div>
<!-- For now, I will add pagination controls here for Grid view too if needed, or better: -->
<!-- VideoTable has pagination built-in. VideoGrid does not. -->
<!-- I should probably extract Pagination to a component too? -->
<!-- Or just use PrimeVue Paginator? -->
<!-- Given the request is to split components, I'll stick to what was there. -->
<!-- If Grid View didn't have pagination visible, I won't add it unless I'm sure. -->
<!-- Actually, typically both views share pagination. The original code had pagination nested in table view. -->
<!-- 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? -->
<!-- Fetch says limit=20. So pagination is needed. -->
<!-- I'll add common pagination below the view. -->
</div>
<!-- Table View -->
<div v-else class="bg-white rounded-xl border border-gray-200 overflow-hidden">
<div class="overflow-x-auto">
<table class="w-full">
<thead class="bg-gray-50 border-b border-gray-200">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Video</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Status</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Duration</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Size</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Upload Date
</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200">
<tr v-for="video in videos" :key="video.id" class="hover:bg-gray-50 transition-colors">
<td class="px-6 py-4">
<div class="flex items-center gap-3">
<div class="w-20 h-12 bg-gray-200 rounded overflow-hidden flex-shrink-0">
<img v-if="video.thumbnail" :src="video.thumbnail" :alt="video.title"
class="w-full h-full object-cover" />
<div v-else class="w-full h-full flex items-center justify-center">
<span class="i-heroicons-film text-gray-400 text-xl" />
</div>
</div>
<div class="min-w-0 flex-1">
<p class="font-medium text-gray-900 truncate">{{ video.title }}</p>
<p class="text-sm text-gray-500 truncate">{{ video.description || 'No description' }}</p>
</div>
</div>
</td>
<td class="px-6 py-4">
<span
:class="['px-2 py-1 text-xs font-medium rounded-full whitespace-nowrap', getStatusClass(video.status)]">
{{ video.status || 'Unknown' }}
</span>
</td>
<td class="px-6 py-4 text-sm text-gray-500">
{{ formatDuration(video.duration) }}
</td>
<td class="px-6 py-4 text-sm text-gray-500">
{{ formatBytes(video.size) }}
</td>
<td class="px-6 py-4 text-sm text-gray-500">
{{ formatDate(video.created_at) }}
</td>
<td class="px-6 py-4">
<div class="flex items-center gap-2">
<button class="p-1.5 hover:bg-gray-100 rounded transition-colors" title="Edit">
<span class="i-heroicons-pencil w-4 h-4 text-gray-600" />
</button>
<button class="p-1.5 hover:bg-gray-100 rounded transition-colors" title="Share">
<span class="i-heroicons-share w-4 h-4 text-gray-600" />
</button>
<button @click="deleteVideo(video.id)" class="p-1.5 hover:bg-red-100 rounded transition-colors"
title="Delete">
<span class="i-heroicons-trash w-4 h-4 text-red-600" />
</button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<!-- Pagination -->
<div v-if="total > limit" class="px-6 py-4 border-t border-gray-200 flex items-center justify-between">
<div class="text-sm text-gray-700">
Showing <span class="font-medium">{{ (page - 1) * limit + 1 }}</span> to
<span class="font-medium">{{ Math.min(page * limit, total) }}</span> of
<span class="font-medium">{{ total }}</span> results
</div>
<div class="flex items-center gap-2">
<button @click="handlePageChange(page - 1)" :disabled="page === 1"
class="px-3 py-1.5 border border-gray-300 rounded hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed transition-colors">
Previous
</button>
<span class="px-4 py-1.5 bg-primary text-white rounded">{{ page }}</span>
<button @click="handlePageChange(page + 1)" :disabled="page * limit >= total"
class="px-3 py-1.5 border border-gray-300 rounded hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed transition-colors">
Next
</button>
</div>
</div>
<div v-else>
<VideoTable :videos="videos" v-model:selectedVideos="selectedVideos" @delete="deleteVideo" />
</div>
</div>
</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>