develop-updateui #1

Merged
lethdat merged 78 commits from develop-updateui into master 2026-04-02 05:59:23 +00:00
29 changed files with 867 additions and 306 deletions
Showing only changes of commit 6c4015f8c4 - Show all commits

22
components.d.ts vendored
View File

@@ -13,19 +13,26 @@ export {}
declare module 'vue' { declare module 'vue' {
export interface GlobalComponents { export interface GlobalComponents {
Add: typeof import('./src/components/icons/Add.vue')['default'] Add: typeof import('./src/components/icons/Add.vue')['default']
AlertTriangleIcon: typeof import('./src/components/icons/AlertTriangleIcon.vue')['default']
ArrowRightIcon: typeof import('./src/components/icons/ArrowRightIcon.vue')['default']
Bell: typeof import('./src/components/icons/Bell.vue')['default'] Bell: typeof import('./src/components/icons/Bell.vue')['default']
Button: typeof import('primevue/button')['default'] Button: typeof import('primevue/button')['default']
Chart: typeof import('./src/components/icons/Chart.vue')['default'] Chart: typeof import('./src/components/icons/Chart.vue')['default']
Checkbox: typeof import('primevue/checkbox')['default'] Checkbox: typeof import('primevue/checkbox')['default']
CheckCircleIcon: typeof import('./src/components/icons/CheckCircleIcon.vue')['default']
CheckIcon: typeof import('./src/components/icons/CheckIcon.vue')['default'] CheckIcon: typeof import('./src/components/icons/CheckIcon.vue')['default']
CheckMarkIcon: typeof import('./src/components/icons/CheckMarkIcon.vue')['default']
ClientOnly: typeof import('./src/components/ClientOnly.tsx')['default'] ClientOnly: typeof import('./src/components/ClientOnly.tsx')['default']
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']
DashboardLayout: typeof import('./src/components/DashboardLayout.vue')['default'] DashboardLayout: typeof import('./src/components/DashboardLayout.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']
HardDriveUpload: typeof import('./src/components/icons/HardDriveUpload.vue')['default'] HardDriveUpload: typeof import('./src/components/icons/HardDriveUpload.vue')['default']
Home: typeof import('./src/components/icons/Home.vue')['default'] Home: typeof import('./src/components/icons/Home.vue')['default']
IconField: typeof import('primevue/iconfield')['default'] IconField: typeof import('primevue/iconfield')['default']
InfoIcon: typeof import('./src/components/icons/InfoIcon.vue')['default']
InputIcon: typeof import('primevue/inputicon')['default'] InputIcon: typeof import('primevue/inputicon')['default']
InputText: typeof import('primevue/inputtext')['default'] InputText: typeof import('primevue/inputtext')['default']
Layout: typeof import('./src/components/icons/Layout.vue')['default'] Layout: typeof import('./src/components/icons/Layout.vue')['default']
@@ -38,30 +45,41 @@ declare module 'vue' {
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']
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']
Upload: typeof import('./src/components/icons/Upload.vue')['default'] Upload: typeof import('./src/components/icons/Upload.vue')['default']
Video: typeof import('./src/components/icons/Video.vue')['default'] Video: typeof import('./src/components/icons/Video.vue')['default']
VideoIcon: typeof import('./src/components/icons/VideoIcon.vue')['default']
VueHead: typeof import('./src/components/VueHead.tsx')['default'] VueHead: typeof import('./src/components/VueHead.tsx')['default']
XCircleIcon: typeof import('./src/components/icons/XCircleIcon.vue')['default']
} }
} }
// For TSX support // For TSX support
declare global { declare global {
const Add: typeof import('./src/components/icons/Add.vue')['default'] const Add: typeof import('./src/components/icons/Add.vue')['default']
const AlertTriangleIcon: typeof import('./src/components/icons/AlertTriangleIcon.vue')['default']
const ArrowRightIcon: typeof import('./src/components/icons/ArrowRightIcon.vue')['default']
const Bell: typeof import('./src/components/icons/Bell.vue')['default'] const Bell: typeof import('./src/components/icons/Bell.vue')['default']
const Button: typeof import('primevue/button')['default'] const Button: typeof import('primevue/button')['default']
const Chart: typeof import('./src/components/icons/Chart.vue')['default'] const Chart: typeof import('./src/components/icons/Chart.vue')['default']
const Checkbox: typeof import('primevue/checkbox')['default'] const Checkbox: typeof import('primevue/checkbox')['default']
const CheckCircleIcon: typeof import('./src/components/icons/CheckCircleIcon.vue')['default']
const CheckIcon: typeof import('./src/components/icons/CheckIcon.vue')['default'] const CheckIcon: typeof import('./src/components/icons/CheckIcon.vue')['default']
const CheckMarkIcon: typeof import('./src/components/icons/CheckMarkIcon.vue')['default']
const ClientOnly: typeof import('./src/components/ClientOnly.tsx')['default'] const ClientOnly: typeof import('./src/components/ClientOnly.tsx')['default']
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 DashboardLayout: typeof import('./src/components/DashboardLayout.vue')['default'] const DashboardLayout: typeof import('./src/components/DashboardLayout.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 HardDriveUpload: typeof import('./src/components/icons/HardDriveUpload.vue')['default'] const HardDriveUpload: typeof import('./src/components/icons/HardDriveUpload.vue')['default']
const Home: typeof import('./src/components/icons/Home.vue')['default'] const Home: typeof import('./src/components/icons/Home.vue')['default']
const IconField: typeof import('primevue/iconfield')['default'] const IconField: typeof import('primevue/iconfield')['default']
const InfoIcon: typeof import('./src/components/icons/InfoIcon.vue')['default']
const InputIcon: typeof import('primevue/inputicon')['default'] const InputIcon: typeof import('primevue/inputicon')['default']
const InputText: typeof import('primevue/inputtext')['default'] const InputText: typeof import('primevue/inputtext')['default']
const Layout: typeof import('./src/components/icons/Layout.vue')['default'] const Layout: typeof import('./src/components/icons/Layout.vue')['default']
@@ -74,9 +92,13 @@ declare global {
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 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 Upload: typeof import('./src/components/icons/Upload.vue')['default'] const Upload: typeof import('./src/components/icons/Upload.vue')['default']
const Video: typeof import('./src/components/icons/Video.vue')['default'] const Video: typeof import('./src/components/icons/Video.vue')['default']
const VideoIcon: typeof import('./src/components/icons/VideoIcon.vue')['default']
const VueHead: typeof import('./src/components/VueHead.tsx')['default'] const VueHead: typeof import('./src/components/VueHead.tsx')['default']
const XCircleIcon: typeof import('./src/components/icons/XCircleIcon.vue')['default']
} }

View File

@@ -6,6 +6,7 @@ import Video from "@/components/icons/Video.vue";
import Credit from "@/components/icons/Credit.vue"; import Credit from "@/components/icons/Credit.vue";
import Upload from "./icons/Upload.vue"; import Upload from "./icons/Upload.vue";
import NotificationDrawer from "./NotificationDrawer.vue"; import NotificationDrawer from "./NotificationDrawer.vue";
import GlobalUploadIndicator from "./GlobalUploadIndicator.vue";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { createStaticVNode, ref } from "vue"; import { createStaticVNode, ref } from "vue";
@@ -38,14 +39,9 @@ const links = [
<header <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"> 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"> <template v-for="i in links" :key="i.label">
<component <component :name="i.label" :is="i.type === 'a' ? 'router-link' : 'div'"
:name="i.label" v-bind="i.type === 'a' ? { to: i.href } : {}" v-tooltip="i.label" @click="i.action && i.action($event)"
:is="i.type === 'a' ? 'router-link' : 'div'" :class="cn(i.className, ($route.path === i.href || i.isActive?.value) && 'bg-primary/15')">
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 :is="i.icon" class="w-6 h-6" :filled="$route.path === i.href || i.isActive?.value" />
</component> </component>
</template> </template>
@@ -57,7 +53,7 @@ const links = [
<main class="flex flex-1 overflow-hidden md:ps-18"> <main class="flex flex-1 overflow-hidden md:ps-18">
<div class="flex-1 overflow-auto p-4 bg-white rounded-lg md:(mr-2 mb-2) min-h-[calc(100vh-8rem)]"> <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"
@@ -69,6 +65,6 @@ const links = [
</Transition> </Transition>
</router-view> </router-view>
</div> </div>
<GlobalUploadIndicator />
</main> </main>
</template> </template>

View File

@@ -0,0 +1,103 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
import { useUploadQueue } from '@/composables/useUploadQueue';
import { useRoute, useRouter } from 'vue-router';
import UploadQueueItem from '@/routes/upload/components/UploadQueueItem.vue';
const { items, totalSize, completeCount, pendingCount } = useUploadQueue();
const route = useRoute();
const router = useRouter();
const isOpen = ref(false);
const isVisible = computed(() => {
// Show if there are items AND we are NOT on the upload page
return items.value.length > 0 && route.path !== '/upload';
});
const progress = computed(() => {
if (items.value.length === 0) return 0;
const totalProgress = items.value.reduce((acc, item) => acc + (item.progress || 0), 0);
return Math.round(totalProgress / items.value.length);
});
const isUploading = computed(() => {
return items.value.some(i => i.status === 'uploading' || i.status === 'fetching');
});
const toggleOpen = () => {
isOpen.value = !isOpen.value;
};
const goToUploadPage = () => {
router.push('/upload');
isOpen.value = false;
};
</script>
<template>
<div v-if="isVisible" class="fixed bottom-6 right-6 z-50 flex flex-col items-end gap-2">
<!-- Mini Queue Popover -->
<Transition enter-active-class="transition duration-200 ease-out"
enter-from-class="opacity-0 translate-y-2 scale-95" enter-to-class="opacity-100 translate-y-0 scale-100"
leave-active-class="transition duration-150 ease-in" leave-from-class="opacity-100 translate-y-0 scale-100"
leave-to-class="opacity-0 translate-y-2 scale-95">
<div v-if="isOpen"
class="bg-white rounded-2xl shadow-xl border border-gray-100 p-4 mb-2 w-80 max-h-[60vh] flex flex-col">
<div class="flex items-center justify-between mb-3 pb-3 border-b border-gray-100">
<h3 class="font-bold text-slate-800">Uploads</h3>
<button @click="goToUploadPage" class="text-xs font-bold text-accent hover:underline">
View All
</button>
</div>
<div
class="flex-1 overflow-y-auto min-h-0 space-y-3 [&::-webkit-scrollbar]:w-1 [&::-webkit-scrollbar-track]:bg-transparent [&::-webkit-scrollbar-thumb]:bg-slate-300 [&::-webkit-scrollbar-thumb]:rounded">
<UploadQueueItem v-for="item in items" :key="item.id" :item="item" :minimal="true"
class="border-b border-slate-100 last:border-0 !rounded-none" />
</div>
</div>
</Transition>
<!-- Floating Button -->
<button @click="toggleOpen"
class="relative flex items-center gap-3 bg-white pl-4 pr-5 py-3 rounded-full shadow-[0_8px_30px_rgba(0,0,0,0.12)] border border-slate-100 hover:-translate-y-1 transition-all duration-300 group">
<!-- Progress Ring -->
<div class="relative w-10 h-10 flex items-center justify-center">
<svg class="w-full h-full -rotate-90 text-slate-100" viewBox="0 0 36 36">
<path class="stroke-current" fill="none" stroke-width="3"
d="M18 2.0845 a 15.9155 15.9155 0 0 1 0 31.831 a 15.9155 15.9155 0 0 1 0 -31.831" />
</svg>
<svg class="absolute inset-0 w-full h-full -rotate-90 text-accent transition-all duration-500"
viewBox="0 0 36 36" :style="{ strokeDasharray: `${progress}, 100` }">
<path class="stroke-current" fill="none" stroke-width="3" stroke-linecap="round"
d="M18 2.0845 a 15.9155 15.9155 0 0 1 0 31.831 a 15.9155 15.9155 0 0 1 0 -31.831" />
</svg>
<div class="absolute inset-0 flex items-center justify-center text-accent">
<svg v-if="!isUploading && completeCount === items.length" 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="M20 6 9 17l-5-5" />
</svg>
<span v-else class="text-[10px] font-bold">{{ progress }}%</span>
</div>
</div>
<div class="text-left">
<div class="text-sm font-bold text-slate-800 group-hover:text-accent transition-colors">
{{ isUploading ? 'Uploading...' : (completeCount === items.length ? 'Completed' : 'Pending') }}
</div>
<div class="text-xs text-slate-500">
{{ completeCount }} / {{ items.length }} files
</div>
</div>
<div v-if="pendingCount"
class="absolute -top-1 -right-1 w-5 h-5 bg-red-500 rounded-full flex items-center justify-center text-[10px] font-bold text-white shadow-sm border-2 border-white">
{{ pendingCount }}
</div>
</button>
</div>
</template>

View File

@@ -1,7 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref, watch } from 'vue';
import NotificationItem from '@/routes/notification/components/NotificationItem.vue'; import NotificationItem from '@/routes/notification/components/NotificationItem.vue';
import { onClickOutside } from '@vueuse/core'; import { onClickOutside } from '@vueuse/core';
import { computed, ref, watch } from 'vue';
// Emit event when visibility changes // Emit event when visibility changes
const emit = defineEmits(['change']); const emit = defineEmits(['change']);
@@ -97,7 +97,7 @@ onClickOutside(drawerRef, (event) => {
visible.value = false; visible.value = false;
} }
}, { }, {
ignore: ['.press-animated', '[name="Notification"]'] // Assuming the trigger button has this class or we can suggest adding a class to the trigger ignore: ['[name="Notification"]'] // Assuming the trigger button has this class or we can suggest adding a class to the trigger
}); });
const handleMarkRead = (id: string) => { const handleMarkRead = (id: string) => {
@@ -122,35 +122,23 @@ defineExpose({ toggle });
<template> <template>
<Teleport to="body"> <Teleport to="body">
<Transition <Transition enter-active-class="transition-all duration-300 ease-out"
enter-active-class="transition-all duration-300 ease-out" enter-from-class="opacity-0 -translate-x-4" enter-to-class="opacity-100 translate-x-0"
enter-from-class="opacity-0 -translate-x-4" leave-active-class="transition-all duration-200 ease-in" leave-from-class="opacity-100 translate-x-0"
enter-to-class="opacity-100 translate-x-0" leave-to-class="opacity-0 -translate-x-4">
leave-active-class="transition-all duration-200 ease-in" <div v-if="visible" ref="drawerRef"
leave-from-class="opacity-100 translate-x-0" class="fixed top-0 left-[80px] bottom-0 w-[380px] bg-white rounded-2xl border border-gray-300 p-3 z-50 flex flex-col shadow-lg my-3">
leave-to-class="opacity-0 -translate-x-4"
>
<div
v-if="visible"
ref="drawerRef"
class="fixed top-0 left-[80px] bottom-0 w-[380px] bg-white rounded-2xl border border-gray-300 p-3 z-50 flex flex-col shadow-lg my-3"
>
<!-- Header --> <!-- Header -->
<div class="flex items-center justify-between p-4"> <div class="flex items-center justify-between p-4">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<h3 class="font-semibold text-gray-900">Notifications</h3> <h3 class="font-semibold text-gray-900">Notifications</h3>
<span <span v-if="unreadCount > 0"
v-if="unreadCount > 0" class="px-2 py-0.5 text-xs font-medium bg-primary text-white rounded-full">
class="px-2 py-0.5 text-xs font-medium bg-primary text-white rounded-full"
>
{{ unreadCount }} {{ unreadCount }}
</span> </span>
</div> </div>
<button <button v-if="unreadCount > 0" @click="handleMarkAllRead"
v-if="unreadCount > 0" class="text-sm text-primary hover:underline font-medium">
@click="handleMarkAllRead"
class="text-sm text-primary hover:underline font-medium"
>
Mark all read Mark all read
</button> </button>
</div> </div>
@@ -158,16 +146,10 @@ defineExpose({ toggle });
<!-- Notification List --> <!-- Notification List -->
<div class="flex flex-col flex-1 overflow-y-auto gap-2"> <div class="flex flex-col flex-1 overflow-y-auto gap-2">
<template v-if="notifications.length > 0"> <template v-if="notifications.length > 0">
<div <div v-for="notification in notifications" :key="notification.id"
v-for="notification in notifications" class="border-b border-gray-50 last:border-0">
:key="notification.id" <NotificationItem :notification="notification" @mark-read="handleMarkRead"
class="border-b border-gray-50 last:border-0" @delete="handleDelete" isDrawer />
>
<NotificationItem
:notification="notification"
@mark-read="handleMarkRead"
@delete="handleDelete"
/>
</div> </div>
</template> </template>
@@ -180,11 +162,9 @@ defineExpose({ toggle });
<!-- Footer --> <!-- Footer -->
<div v-if="notifications.length > 0" class="p-3 border-t border-gray-100 bg-gray-50/50"> <div v-if="notifications.length > 0" class="p-3 border-t border-gray-100 bg-gray-50/50">
<router-link <router-link to="/notification"
to="/notification"
class="block w-full text-center text-sm text-primary font-medium hover:underline" class="block w-full text-center text-sm text-primary font-medium hover:underline"
@click="visible = false" @click="visible = false">
>
View all notifications View all notifications
</router-link> </router-link>
</div> </div>

View File

@@ -14,17 +14,17 @@ interface Props {
color?: 'primary' | 'success' | 'warning' | 'danger' | 'info'; color?: 'primary' | 'success' | 'warning' | 'danger' | 'info';
} }
const props = withDefaults(defineProps<Props>(), { withDefaults(defineProps<Props>(), {
color: 'primary' color: 'primary'
}); });
const gradients = { // const gradients = {
primary: 'from-primary/20 to-primary/5', // primary: 'from-primary/20 to-primary/5',
success: 'from-success/20 to-success/5', // success: 'from-success/20 to-success/5',
warning: 'from-yellow-100 to-yellow-50', // warning: 'from-yellow-100 to-yellow-50',
danger: 'from-danger/20 to-danger/5', // danger: 'from-danger/20 to-danger/5',
info: 'from-info/20 to-info/5', // info: 'from-info/20 to-info/5',
}; // };
const iconColors = { const iconColors = {
primary: 'text-primary', primary: 'text-primary',
@@ -37,10 +37,10 @@ const iconColors = {
<template> <template>
<div :class="[ <div :class="[
'stats-card relative overflow-hidden rounded-2xl p-6 bg-gradient-to-br', 'transform translate-y-0 relative overflow-hidden rounded-2xl p-6 bg-white',
gradients[color], // gradients[color],
'border border-white/50 shadow-sm hover:shadow-md transition-all duration-300', 'border border-gray-300 transition-all duration-300',
'group cursor-pointer' // 'group cursor-pointer'
]"> ]">
<!-- Content --> <!-- Content -->
<div class="relative z-10"> <div class="relative z-10">
@@ -65,10 +65,15 @@ const iconColors = {
'flex items-center gap-1 font-medium', 'flex items-center gap-1 font-medium',
trend.isPositive ? 'text-success' : 'text-danger' trend.isPositive ? 'text-success' : 'text-danger'
]"> ]">
<span :class="[ <!-- <span :class="[
'w-4 h-4', 'w-4 h-4',
trend.isPositive ? 'i-heroicons-arrow-trending-up' : 'i-heroicons-arrow-trending-down' trend.isPositive ? 'i-heroicons-arrow-trending-up' : 'i-heroicons-arrow-trending-down'
]" /> ]" /> -->
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path v-if="trend.isPositive" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M3 17l6-6 4 4 8-8" />
<path v-else stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 7l-6 6-4-4-8 8" />
</svg>
{{ Math.abs(trend.value) }}% {{ Math.abs(trend.value) }}%
</span> </span>
<span class="text-gray-500">vs last month</span> <span class="text-gray-500">vs last month</span>
@@ -76,13 +81,3 @@ const iconColors = {
</div> </div>
</div> </div>
</template> </template>
<style scoped>
.stats-card {
transform: translateY(0);
}
.stats-card:hover {
transform: translateY(-2px);
}
</style>

View File

@@ -0,0 +1,16 @@
<template>
<svg v-if="filled" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="currentColor"
stroke="none">
<path d="M1 21h22L12 2 1 21zm12-3h-2v-2h2v2zm0-4h-2v-4h2v4z" />
</svg>
<svg v-else xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"></path>
<line x1="12" y1="9" x2="12" y2="13"></line>
<line x1="12" y1="17" x2="12.01" y2="17"></line>
</svg>
</template>
<script lang="ts" setup>
defineProps<{ filled?: boolean }>();
</script>

View File

@@ -0,0 +1,15 @@
<template>
<svg v-if="filled" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="currentColor"
stroke="none">
<path d="M12 4l-1.41 1.41L16.17 11H4v2h12.17l-5.58 5.59L12 20l8-8z" />
</svg>
<svg v-else xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="5" y1="12" x2="19" y2="12"></line>
<polyline points="12 5 19 12 12 19"></polyline>
</svg>
</template>
<script lang="ts" setup>
defineProps<{ filled?: boolean }>();
</script>

View File

@@ -0,0 +1,16 @@
<template>
<svg v-if="filled" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="currentColor"
stroke="none">
<path
d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z" />
</svg>
<svg v-else xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"></path>
<polyline points="22 4 12 14.01 9 11.01"></polyline>
</svg>
</template>
<script lang="ts" setup>
defineProps<{ filled?: boolean }>();
</script>

View File

@@ -0,0 +1,14 @@
<template>
<svg v-if="filled" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="currentColor"
stroke="none">
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z" />
</svg>
<svg v-else xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="20 6 9 17 4 12"></polyline>
</svg>
</template>
<script lang="ts" setup>
defineProps<{ filled?: boolean }>();
</script>

View File

@@ -0,0 +1,16 @@
<template>
<svg v-if="filled" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="currentColor"
stroke="none">
<path
d="M20 4H4c-1.11 0-1.99.89-1.99 2L2 18c0 1.11.89 2 2 2h16c1.11 0 2-.89 2-2V6c0-1.11-.89-2-2-2zm0 14H4v-6h16v6zm0-10H4V6h16v2z" />
</svg>
<svg v-else xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="1" y="4" width="22" height="16" rx="2" ry="2"></rect>
<line x1="1" y1="10" x2="23" y2="10"></line>
</svg>
</template>
<script lang="ts" setup>
defineProps<{ filled?: boolean }>();
</script>

View File

@@ -0,0 +1,16 @@
<template>
<svg v-if="filled" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="currentColor"
stroke="none">
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-6h2v6zm0-8h-2V7h2v2z" />
</svg>
<svg v-else xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10"></circle>
<line x1="12" y1="16" x2="12" y2="12"></line>
<line x1="12" y1="8" x2="12.01" y2="8"></line>
</svg>
</template>
<script lang="ts" setup>
defineProps<{ filled?: boolean }>();
</script>

View File

@@ -0,0 +1,18 @@
<template>
<svg v-if="filled" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="currentColor"
stroke="none">
<path
d="M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58a.49.49 0 0 0 .12-.61l-1.92-3.32a.488.488 0 0 0-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54a.484.484 0 0 0-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96a.48.48 0 0 0-.59.22L5.09 8.87a.484.484 0 0 0 .12.61l2.03 1.58c-.05.3-.09.63-.09.94s.02.64.07.94l-2.03 1.58a.48.48 0 0 0-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.21.08.47 0 .59-.22l1.92-3.32a.48.48 0 0 0-.12-.61l-2.03-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z" />
</svg>
<svg v-else xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path
d="M12.22 2h-.44a2 2 0 0 1-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.38a2 2 0 0 0-.73-2.73l-.15-.1a2 2 0 0 1-1-1.72v-.51a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z">
</path>
<circle cx="12" cy="12" r="3"></circle>
</svg>
</template>
<script lang="ts" setup>
defineProps<{ filled?: boolean }>();
</script>

View File

@@ -0,0 +1,17 @@
<template>
<svg v-if="filled" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="currentColor"
stroke="none">
<path d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z" />
</svg>
<svg v-else xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="3 6 5 6 21 6"></polyline>
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>
<line x1="10" y1="11" x2="10" y2="17"></line>
<line x1="14" y1="11" x2="14" y2="17"></line>
</svg>
</template>
<script lang="ts" setup>
defineProps<{ filled?: boolean }>();
</script>

View File

@@ -0,0 +1,16 @@
<template>
<svg v-if="filled" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="currentColor"
stroke="none">
<path
d="M17 10.5V7c0-.55-.45-1-1-1H4c-.55 0-1 .45-1 1v10c0 .55.45 1 1 1h12c.55 0 1-.45 1-1v-3.5l4 4v-11l-4 4z" />
</svg>
<svg v-else xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polygon points="23 7 16 12 23 17 23 7"></polygon>
<rect x="1" y="5" width="15" height="14" rx="2" ry="2"></rect>
</svg>
</template>
<script lang="ts" setup>
defineProps<{ filled?: boolean }>();
</script>

View File

@@ -0,0 +1,17 @@
<template>
<svg v-if="filled" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="currentColor"
stroke="none">
<path
d="M12 2C6.47 2 2 6.47 2 12s4.47 10 10 10 10-4.47 10-10S17.53 2 12 2zm5 13.59L15.59 17 12 13.41 8.41 17 7 15.59 10.59 12 7 8.41 8.41 7 12 10.59 15.59 7 17 8.41 13.41 12 17 15.59z" />
</svg>
<svg v-else xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10"></circle>
<line x1="15" y1="9" x2="9" y2="15"></line>
<line x1="9" y1="9" x2="15" y2="15"></line>
</svg>
</template>
<script lang="ts" setup>
defineProps<{ filled?: boolean }>();
</script>

View File

@@ -0,0 +1,164 @@
import { ref, computed } from 'vue';
export interface QueueItem {
id: string;
name: string;
type: 'local' | 'remote';
status: 'uploading' | 'processing' | 'fetching' | 'complete' | 'error' | 'pending';
progress?: number;
uploaded?: string;
total?: string;
speed?: string;
thumbnail?: string;
file?: File; // Keep reference to file for local uploads
url?: string; // Keep reference to url for remote uploads
}
const items = ref<QueueItem[]>([]);
export function useUploadQueue() {
const addFiles = (files: FileList) => {
const newItems: QueueItem[] = Array.from(files).map((file) => ({
id: Math.random().toString(36).substring(2, 9),
name: file.name,
type: 'local',
status: 'pending', // Start as pending
progress: 0,
uploaded: '0 MB',
total: formatSize(file.size),
speed: '0 MB/s',
file: file,
thumbnail: undefined // We could generate a thumbnail here if needed
}));
items.value.push(...newItems);
};
const addRemoteUrls = (urls: string[]) => {
const newItems: QueueItem[] = urls.map((url) => ({
id: Math.random().toString(36).substring(2, 9),
name: url.split('/').pop() || 'Remote File',
type: 'remote',
status: 'fetching', // Remote URLs start fetching immediately or pending? User said "khi nao nhan upload". Let's use pending.
progress: 0,
uploaded: '0 MB',
total: 'Unknown',
speed: '0 MB/s',
url: url
}));
// Override status to pending for consistency with user request
newItems.forEach(i => i.status = 'pending');
items.value.push(...newItems);
};
const removeItem = (id: string) => {
const index = items.value.findIndex(item => item.id === id);
if (index !== -1) {
items.value.splice(index, 1);
}
};
const startQueue = () => {
items.value.forEach(item => {
if (item.status === 'pending') {
if (item.type === 'local') {
startMockUpload(item.id);
} else {
startMockRemoteFetch(item.id);
}
}
});
};
// Mock Upload Logic
const startMockUpload = (id: string) => {
const item = items.value.find(i => i.id === id);
if (!item) return;
item.status = 'uploading';
let progress = 0;
const totalSize = item.file ? item.file.size : 1024 * 1024 * 50; // Default 50MB if unknown
// Random speed between 1MB/s and 5MB/s
const speedBytesPerStep = (1024 * 1024) + Math.random() * (1024 * 1024 * 4);
const interval = setInterval(() => {
if (progress >= 100) {
clearInterval(interval);
item.status = 'complete';
item.progress = 100;
item.uploaded = item.total;
return;
}
// Increment progress randomly
const increment = Math.random() * 5 + 1; // 1-6% increment
progress = Math.min(progress + increment, 100);
item.progress = Math.floor(progress);
// Calculate uploaded size string
const currentBytes = (progress / 100) * totalSize;
item.uploaded = formatSize(currentBytes);
// Re-randomize speed for realism
const currentSpeed = (1024 * 1024) + Math.random() * (1024 * 1024 * 2);
item.speed = formatSize(currentSpeed) + '/s';
}, 500);
};
// Mock Remote Fetch Logic
const startMockRemoteFetch = (id: string) => {
const item = items.value.find(i => i.id === id);
if (!item) return;
item.status = 'fetching'; // Update status to fetching
// Remote fetch takes some time then completes
setTimeout(() => {
// Switch to uploading/processing phase if we wanted, or just finish
item.status = 'complete';
item.progress = 100;
}, 3000 + Math.random() * 3000);
};
const formatSize = (bytes: number): string => {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
};
const totalSize = computed(() => {
let total = 0;
items.value.forEach(item => {
if (item.file) total += item.file.size;
});
return formatSize(total);
});
const completeCount = computed(() => {
return items.value.filter(i => i.status === 'complete').length;
});
const pendingCount = computed(() => {
return items.value.filter(i => i.status === 'pending').length;
});
return {
items,
addFiles,
addRemoteUrls,
removeItem,
startQueue,
totalSize,
completeCount,
pendingCount
};
}

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" const bodyClass = ":uno: font-sans text-gray-800 antialiased flex flex-col min-h-screen bg-[#FAF8F8]"
export function createApp() { export function createApp() {
const pinia = createPinia(); const pinia = createPinia();
const app = createSSRApp(withErrorBoundary(RouterView)); const app = createSSRApp(withErrorBoundary(RouterView));

View File

@@ -1,5 +1,15 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue'; import { computed } from 'vue';
import InfoIcon from '@/components/icons/InfoIcon.vue';
import CheckCircleIcon from '@/components/icons/CheckCircleIcon.vue';
import AlertTriangleIcon from '@/components/icons/AlertTriangleIcon.vue';
import XCircleIcon from '@/components/icons/XCircleIcon.vue';
import VideoIcon from '@/components/icons/VideoIcon.vue';
import CreditCardIcon from '@/components/icons/CreditCardIcon.vue';
import SettingsIcon from '@/components/icons/SettingsIcon.vue';
import ArrowRightIcon from '@/components/icons/ArrowRightIcon.vue';
import CheckMarkIcon from '@/components/icons/CheckMarkIcon.vue';
import TrashIcon from '@/components/icons/TrashIcon.vue';
interface Props { interface Props {
notification: { notification: {
@@ -12,6 +22,7 @@ interface Props {
actionUrl?: string; actionUrl?: string;
actionLabel?: string; actionLabel?: string;
}; };
isDrawer?: boolean;
} }
const props = defineProps<Props>(); const props = defineProps<Props>();
@@ -20,17 +31,30 @@ const emit = defineEmits<{
delete: [id: string]; delete: [id: string];
}>(); }>();
const iconClass = computed(() => { const iconComponent = computed(() => {
const icons: Record<string, string> = { const icons: Record<string, any> = {
info: 'i-lucide-info text-blue-500', info: InfoIcon,
success: 'i-lucide-check-circle text-green-500', success: CheckCircleIcon,
warning: 'i-lucide-alert-triangle text-amber-500', warning: AlertTriangleIcon,
error: 'i-lucide-x-circle text-red-500', error: XCircleIcon,
video: 'i-lucide-video text-purple-500', video: VideoIcon,
payment: 'i-lucide-credit-card text-emerald-500', payment: CreditCardIcon,
system: 'i-lucide-settings text-gray-500' system: SettingsIcon
}; };
return icons[props.notification.type] || icons.info; return icons[props.notification.type] || InfoIcon;
});
const iconColorClass = computed(() => {
const colors: Record<string, string> = {
info: 'text-blue-500',
success: 'text-green-500',
warning: 'text-amber-500',
error: 'text-red-500',
video: 'text-purple-500',
payment: 'text-emerald-500',
system: 'text-gray-500'
};
return colors[props.notification.type] || 'text-blue-500';
}); });
const bgClass = computed(() => { const bgClass = computed(() => {
@@ -41,17 +65,14 @@ const bgClass = computed(() => {
</script> </script>
<template> <template>
<div <div :class="[
:class="[ 'rounded-xl p-4 border border-gray-200/80 transition-all duration-200',
'notification-item p-4 rounded-xl border border-gray-200/80 transition-all duration-200', 'flex items-start gap-4 group cursor-pointer relative',
'flex items-start gap-4 group cursor-pointer',
bgClass bgClass
]" ]" @click="emit('markRead', notification.id)">
@click="emit('markRead', notification.id)"
>
<!-- Icon --> <!-- Icon -->
<div class="flex-shrink-0 w-10 h-10 rounded-full bg-gray-100 flex items-center justify-center"> <div v-if="!isDrawer" class="flex-shrink-0 w-10 h-10 rounded-full bg-gray-100 flex items-center justify-center">
<span :class="[iconClass, 'w-5 h-5']"></span> <component :is="iconComponent" :class="[iconColorClass, 'w-5 h-5']" />
</div> </div>
<!-- Content --> <!-- Content -->
@@ -65,45 +86,32 @@ const bgClass = computed(() => {
<p class="text-sm text-gray-600 mt-1 line-clamp-2">{{ notification.message }}</p> <p class="text-sm text-gray-600 mt-1 line-clamp-2">{{ notification.message }}</p>
<!-- Action Button --> <!-- Action Button -->
<router-link <router-link v-if="notification.actionUrl" :to="notification.actionUrl"
v-if="notification.actionUrl" class="inline-flex items-center gap-1 text-sm text-primary font-medium mt-2 hover:underline">
:to="notification.actionUrl"
class="inline-flex items-center gap-1 text-sm text-primary font-medium mt-2 hover:underline"
>
{{ notification.actionLabel || 'View Details' }} {{ notification.actionLabel || 'View Details' }}
<span class="i-lucide-arrow-right w-4 h-4"></span> <ArrowRightIcon class="w-4 h-4" />
</router-link> </router-link>
</div> </div>
<!-- Actions --> <!-- Actions -->
<div class="flex-shrink-0 opacity-0 group-hover:opacity-100 transition-opacity flex items-center gap-1"> <div v-if="!isDrawer"
<button class="flex-shrink-0 opacity-0 group-hover:opacity-100 transition-opacity flex items-center gap-1">
v-if="!notification.read" <button v-if="!notification.read" @click.stop="emit('markRead', notification.id)"
@click.stop="emit('markRead', notification.id)"
class="p-2 rounded-lg hover:bg-gray-200 text-gray-500 hover:text-gray-700 transition-colors" class="p-2 rounded-lg hover:bg-gray-200 text-gray-500 hover:text-gray-700 transition-colors"
title="Mark as read" title="Mark as read">
> <CheckMarkIcon class="w-4 h-4" />
<span class="i-lucide-check w-4 h-4"></span>
</button> </button>
<button <button @click.stop="emit('delete', notification.id)"
@click.stop="emit('delete', notification.id)"
class="p-2 rounded-lg hover:bg-red-100 text-gray-500 hover:text-red-600 transition-colors" class="p-2 rounded-lg hover:bg-red-100 text-gray-500 hover:text-red-600 transition-colors"
title="Delete" title="Delete">
> <TrashIcon class="w-4 h-4" />
<span class="i-lucide-trash-2 w-4 h-4"></span>
</button> </button>
</div> </div>
<!-- Unread indicator --> <!-- Unread indicator -->
<div <div class="absolute left-2 top-1/10 -translate-y-1/2 w-2 h-2 rounded-full bg-primary">
v-if="!notification.read" </div>
class="absolute left-2 top-1/2 -translate-y-1/2 w-2 h-2 rounded-full bg-primary" <!-- <div v-if="!notification.read" class="absolute left-2 top-1/2 -translate-y-1/2 w-2 h-2 rounded-full bg-primary">
></div> </div> -->
</div> </div>
</template> </template>
<style scoped>
.notification-item {
border-radius: 8px;
}
</style>

View File

@@ -1,13 +1,13 @@
<script setup lang="ts"> <script setup lang="ts">
import { client, type ModelVideo } from '@/api/client'; import { client, type ModelVideo } from '@/api/client';
import PageHeader from '@/components/dashboard/PageHeader.vue'; import PageHeader from '@/components/dashboard/PageHeader.vue';
import { useAuthStore } from '@/stores/auth';
import { onMounted, ref } from 'vue'; import { onMounted, ref } from 'vue';
import QuickActions from './components/QuickActions.vue'; import QuickActions from './components/QuickActions.vue';
import RecentVideos from './components/RecentVideos.vue'; import RecentVideos from './components/RecentVideos.vue';
import StatsOverview from './components/StatsOverview.vue'; import StatsOverview from './components/StatsOverview.vue';
import StorageUsage from './components/StorageUsage.vue';
import WelcomeBanner from './components/WelcomeBanner.vue';
const auth = useAuthStore()
const loading = ref(true); const loading = ref(true);
const recentVideos = ref<ModelVideo[]>([]); const recentVideos = ref<ModelVideo[]>([]);
@@ -57,13 +57,11 @@ onMounted(() => {
<template> <template>
<div class="dashboard-overview"> <div class="dashboard-overview">
<PageHeader title="Dashboard" description="Welcome back! Here's what's happening with your videos." <PageHeader :title="`Welcome back, ${auth.user?.username}! 👋`" description="Here's what's happening with your videos."
:breadcrumbs="[ :breadcrumbs="[
{ label: 'Dashboard' } { label: 'Dashboard' }
]" /> ]" />
<WelcomeBanner />
<!-- Stats Grid --> <!-- Stats Grid -->
<StatsOverview :loading="loading" :stats="stats" /> <StatsOverview :loading="loading" :stats="stats" />

View File

@@ -60,7 +60,7 @@ 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', 'p-6 rounded-xl text-left transition-all duration-200 flex flex-col bg-white',
'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',

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"> <div class="rounded-xl border border-gray-300 hover:border-primary hover:shadow-lg text-card-foreground bg-white">
<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-2xl font-semibold leading-none tracking-tight">Referral Link</h3> <h3 class="text-2xl font-semibold leading-none tracking-tight">Referral Link</h3>
</div> </div>

View File

@@ -32,17 +32,17 @@ 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" icon="i-heroicons-film" color="primary" <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()" icon="i-heroicons-eye" color="info" <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)}`" icon="i-heroicons-server" :value="`${formatBytes(stats.storageUsed)} / ${formatBytes(stats.storageLimit)}`"
color="warning" /> color="warning" />
<StatsCard title="Uploads This Month" :value="stats.uploadsThisMonth" icon="i-heroicons-arrow-up-tray" <StatsCard title="Uploads This Month" :value="stats.uploadsThisMonth"
color="success" :trend="{ value: 25, isPositive: true }" /> color="success" :trend="{ value: 25, isPositive: true }" />
</div> </div>
</template> </template>

View File

@@ -7,17 +7,23 @@ import RemoteUrlForm from './components/RemoteUrlForm.vue';
import BulkActions from './components/BulkActions.vue'; import BulkActions from './components/BulkActions.vue';
import UploadQueue from './components/UploadQueue.vue'; import UploadQueue from './components/UploadQueue.vue';
import { ref } from 'vue'; import { ref } from 'vue';
import { useUploadQueue } from '@/composables/useUploadQueue';
const mode = ref<'local' | 'remote'>('local'); const mode = ref<'local' | 'remote'>('local');
const { addFiles, addRemoteUrls, items, removeItem, totalSize, completeCount, pendingCount, startQueue } = useUploadQueue();
const handlePublish = () => {
console.log('Publishing items...');
// TODO: Handle publish action
};
const handleFilesSelected = (files: FileList) => { const handleFilesSelected = (files: FileList) => {
console.log('Files selected:', files); addFiles(files);
// TODO: Handle file upload
}; };
const handleRemoteUrls = (urls: string[]) => { const handleRemoteUrls = (urls: string[]) => {
console.log('URLs submitted:', urls); addRemoteUrls(urls);
// TODO: Handle remote URL import
}; };
</script> </script>
@@ -44,6 +50,8 @@ const handleRemoteUrls = (urls: string[]) => {
<BulkActions :visible="false" :pending-count="0" /> <BulkActions :visible="false" :pending-count="0" />
</div> </div>
</div> </div>
<UploadQueue /> <UploadQueue :items="items" :total-size="totalSize" :complete-count="completeCount"
:pending-count="pendingCount" @remove-item="removeItem" @publish="handlePublish"
@start-queue="startQueue" />
</div> </div>
</template> </template>

View File

@@ -19,7 +19,7 @@ const handleSubmit = () => {
</script> </script>
<template> <template>
<div class="bg-white rounded-[2rem] shadow-soft p-10 border border-slate-100/50"> <div class="bg-gradient-to-tl from-slate-50 to-white rounded-[2rem] shadow-soft p-10 border border-gray-200">
<label class="block text-lg font-semibold text-slate-900 mb-4">Enter Video URL</label> <label class="block text-lg font-semibold text-slate-900 mb-4">Enter Video URL</label>
<div class="flex gap-4 items-start"> <div class="flex gap-4 items-start">

View File

@@ -30,7 +30,7 @@ const mode = computed({
</script> </script>
<template> <template>
<div class="inline-flex bg-slate-100 p-1 rounded-2xl relative z-0 w-fit"> <div class="inline-flex bg-slate-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

@@ -1,15 +1,18 @@
<script setup lang="ts"> <script setup lang="ts">
import UploadQueueItem, { type QueueItem } from './UploadQueueItem.vue'; import UploadQueueItem from './UploadQueueItem.vue';
import type { QueueItem } from '@/composables/useUploadQueue';
defineProps<{ defineProps<{
items?: QueueItem[]; items?: QueueItem[];
totalSize?: string; totalSize?: string;
completeCount?: number; completeCount?: number;
pendingCount?: number;
}>(); }>();
const emit = defineEmits<{ const emit = defineEmits<{
removeItem: [id: string]; removeItem: [id: string];
publish: []; publish: [];
startQueue: [];
}>(); }>();
</script> </script>
@@ -45,8 +48,7 @@ const emit = defineEmits<{
<p class="text-slate-400 font-medium">Empty queue!</p> <p class="text-slate-400 font-medium">Empty queue!</p>
</div> </div>
<UploadQueueItem v-for="item in items" :key="item.id" :item="item" <UploadQueueItem v-for="item in items" :key="item.id" :item="item" @remove="emit('removeItem', $event)" />
@remove="emit('removeItem', $event)" />
</div> </div>
<div class="p-6 border-t-2 border-white rounded-b-2xl shrink-0"> <div class="p-6 border-t-2 border-white rounded-b-2xl shrink-0">
@@ -54,9 +56,21 @@ const emit = defineEmits<{
<span class="text-slate-500">Total size:</span> <span class="text-slate-500">Total size:</span>
<span class="text-slate-900">{{ totalSize || '0 MB' }}</span> <span class="text-slate-900">{{ totalSize || '0 MB' }}</span>
</div> </div>
<button v-if="pendingCount && pendingCount > 0" @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>
<button @click="emit('publish')" <button @click="emit('publish')"
class="btn btn-outline-primary w-full flex items-center justify-center gap-2" id="btn-publish" class="btn btn-outline-primary w-full flex items-center justify-center gap-2" id="btn-publish"
:disabled="!completeCount"> :disabled="!completeCount || (pendingCount ? pendingCount > 0 : false)">
<svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5" viewBox="0 0 24 24" fill="none" <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"> stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10" /> <circle cx="12" cy="12" r="10" />

View File

@@ -1,31 +1,46 @@
<script setup lang="ts"> <script setup lang="ts">
export interface QueueItem { import type { QueueItem } from '@/composables/useUploadQueue';
id: string; import { cn } from '@/lib/utils';
name: string; import { computed } from 'vue';
type: 'local' | 'remote';
status: 'uploading' | 'processing' | 'fetching' | 'complete' | 'error';
progress?: number;
uploaded?: string;
total?: string;
speed?: string;
thumbnail?: string;
}
defineProps<{ const props = defineProps<{
item: QueueItem; item: QueueItem;
minimal?: boolean;
}>(); }>();
const emit = defineEmits<{ const emit = defineEmits<{
remove: [id: string]; remove: [id: string];
}>(); }>();
const statusLabel = computed(() => {
switch (props.item.status) {
case 'pending': return 'Pending';
case 'uploading': return 'Uploading...';
case 'processing': return 'Processing...';
case 'complete': return 'Completed';
case 'error': return 'Failed';
case 'fetching': return 'Fetching...';
default: return props.item.status;
}
});
const statusColor = computed(() => {
switch (props.item.status) {
case 'complete': return 'bg-green-500';
case 'error': return 'bg-red-500';
case 'pending': return 'bg-slate-400';
default: return 'bg-accent';
}
});
</script> </script>
<template> <template>
<!-- Local Upload Item --> <!-- Local Upload Item -->
<div v-if="item.type === 'local'" <div v-if="item.type === 'local'"
class="bg-white rounded-2xl p-5 shadow-soft border border-slate-100/50 relative group overflow-hidden transition-all hover:shadow-md"> class="bg-white rounded-2xl p-5 shadow-soft border border-slate-100/50 relative group overflow-hidden transition-all"
<div class="flex gap-4 relative z-10"> :class="{ '!p-2 !border-0 !shadow-none !rounded-xl': minimal }">
<div class="w-20 h-16 bg-slate-800 rounded-xl shrink-0 relative overflow-hidden shadow-sm"> <div :class="cn('flex gap-4 relative z-10', minimal && '!gap-2')">
<div v-if="!minimal" class="w-20 h-16 bg-slate-800 rounded-xl shrink-0 relative overflow-hidden shadow-sm">
<div class="absolute inset-0 bg-gradient-to-t from-black/60 to-transparent z-10"></div> <div class="absolute inset-0 bg-gradient-to-t from-black/60 to-transparent z-10"></div>
<img v-if="item.thumbnail" :src="item.thumbnail" class="w-full h-full object-cover opacity-80" alt=""> <img v-if="item.thumbnail" :src="item.thumbnail" class="w-full h-full object-cover opacity-80" alt="">
<div class="absolute bottom-1 left-2 z-20"> <div class="absolute bottom-1 left-2 z-20">
@@ -54,8 +69,8 @@ const emit = defineEmits<{
<div> <div>
<div class="flex justify-between text-xs text-slate-500 mb-1.5 font-medium"> <div class="flex justify-between text-xs text-slate-500 mb-1.5 font-medium">
<span class="flex items-center gap-1.5"> <span class="flex items-center gap-1.5">
<span class="w-2 h-2 rounded-full bg-accent animate-pulse"></span> <span class="w-2 h-2 rounded-full animate-pulse" :class="statusColor"></span>
Uploading... {{ statusLabel }}
</span> </span>
<span class="text-accent font-bold">{{ item.progress || 0 }}%</span> <span class="text-accent font-bold">{{ item.progress || 0 }}%</span>
</div> </div>
@@ -76,12 +91,15 @@ const emit = defineEmits<{
<!-- Remote Fetch Item --> <!-- Remote Fetch Item -->
<div v-else <div v-else
class="bg-[#F0F3FF] rounded-2xl p-5 shadow-soft border border-indigo-100/50 relative overflow-hidden group transition-all hover:shadow-md"> class="bg-[#F0F3FF] rounded-2xl p-5 shadow-soft border border-indigo-100/50 relative overflow-hidden group transition-all hover:shadow-md"
<div class="absolute inset-0 opacity-[0.03] bg-[radial-gradient(#6366F1_1px,transparent_1px)] [background-size:16px_16px]"> :class="{ '!p-3 !bg-slate-50 !border-0 !shadow-none !rounded-xl': minimal }">
<div
class="absolute inset-0 opacity-[0.03] bg-[radial-gradient(#6366F1_1px,transparent_1px)] [background-size:16px_16px]">
</div> </div>
<div class="flex gap-4 relative z-10"> <div class="flex gap-4 relative z-10" :class="{ '!gap-3': minimal }">
<div class="w-20 h-16 bg-indigo-100 rounded-xl shrink-0 flex items-center justify-center text-accent shadow-sm"> <div v-if="!minimal"
class="w-20 h-16 bg-indigo-100 rounded-xl shrink-0 flex items-center justify-center text-accent shadow-sm">
<svg xmlns="http://www.w3.org/2000/svg" class="w-8 h-8 opacity-80" viewBox="0 0 24 24" fill="none" <svg xmlns="http://www.w3.org/2000/svg" class="w-8 h-8 opacity-80" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M9 17H7A5 5 0 0 1 7 7h2" /> <path d="M9 17H7A5 5 0 0 1 7 7h2" />
@@ -104,13 +122,21 @@ const emit = defineEmits<{
</div> </div>
<div class="flex items-center gap-3 mt-3"> <div class="flex items-center gap-3 mt-3">
<div class="flex items-center gap-2 text-xs font-bold text-indigo-600 bg-white py-1.5 px-3 rounded-lg shadow-sm"> <div
<svg xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5 animate-spin" viewBox="0 0 24 24" class="flex items-center gap-2 text-xs font-bold text-indigo-600 bg-white py-1.5 px-3 rounded-lg shadow-sm">
<svg v-if="item.status === 'fetching' || item.status === 'processing'"
xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5 animate-spin" viewBox="0 0 24 24"
fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"
stroke-linejoin="round"> stroke-linejoin="round">
<path d="M21 12a9 9 0 1 1-6.219-8.56" /> <path d="M21 12a9 9 0 1 1-6.219-8.56" />
</svg> </svg>
Fetching from Google Drive... <svg v-else-if="item.status === 'complete'" xmlns="http://www.w3.org/2000/svg"
class="w-3.5 h-3.5 text-green-500" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"></path>
<polyline points="22 4 12 14.01 9 11.01"></polyline>
</svg>
{{ statusLabel }}
</div> </div>
</div> </div>
</div> </div>

View File

@@ -99,7 +99,7 @@ const formatBytes = (bytes?: number) => {
}; };
const getStatusClass = (status?: string) => { const getStatusClass = (status?: string) => {
switch(status?.toLowerCase()) { switch (status?.toLowerCase()) {
case 'ready': return 'bg-green-100 text-green-700'; case 'ready': return 'bg-green-100 text-green-700';
case 'processing': return 'bg-yellow-100 text-yellow-700'; case 'processing': return 'bg-yellow-100 text-yellow-700';
case 'failed': return 'bg-red-100 text-red-700'; case 'failed': return 'bg-red-100 text-red-700';
@@ -142,14 +142,10 @@ onMounted(() => {
<template> <template>
<div class="videos-page"> <div class="videos-page">
<PageHeader <PageHeader title="My Videos" description="Manage and organize your video library" :breadcrumbs="[
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: 'i-heroicons-cloud-arrow-up',
@@ -157,55 +153,54 @@ onMounted(() => {
variant: 'primary', variant: 'primary',
onClick: () => router.push('/upload') onClick: () => router.push('/upload')
} }
]" ]" />
/>
<!-- Filters & Search --> <!-- Filters & Search -->
<div class="bg-white border-b border-gray-200 pb-4 mb-6"> <div class="border-b border-gray-200 pb-4 mb-6">
<div class="flex flex-col md:flex-row gap-4"> <div class="flex flex-col md:flex-row gap-4">
<!-- Search --> <!-- Search -->
<div class="flex-1"> <div class="flex-1 bg-white">
<div class="relative"> <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> <svg xmlns="http://www.w3.org/2000/svg"
<input class="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" viewBox="-10 -258 534 534">
v-model="searchQuery" <path
@keyup.enter="handleSearch" 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"
type="text" fill="#1e3050" />
</svg>
<input v-model="searchQuery" @keyup.enter="handleSearch" type="text"
placeholder="Search videos by title or description..." 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" 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>
</div> </div>
<!-- Status Filter --> <!-- Status Filter -->
<FloatLabel class="w-full md:w-56" variant="on"> <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" /> <Select v-model="selectedStatus" inputId="on_label" :options="statusOptions" optionLabel="label"
optionValue="value" class="w-full" />
<label for="on_label">Status</label> <label for="on_label">Status</label>
</FloatLabel> </FloatLabel>
<!-- View Mode Toggle --> <!-- View Mode Toggle -->
<div class="flex items-center gap-2 bg-gray-100 rounded-lg p-1"> <div class="flex items-center gap-2 bg-slate-200 rounded-lg p-1">
<button <button @click="viewMode = 'table'" :class="[
@click="viewMode = 'table'"
:class="[
'px-3 py-1.5 rounded transition-colors', 'px-3 py-1.5 rounded transition-colors',
viewMode === 'table' ? 'bg-white shadow-sm' : 'hover:bg-gray-200' viewMode === 'table' ? 'bg-white shadow-sm' : 'hover:bg-gray-200'
]" ]" title="Table view">
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"
<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"> stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 10h16M4 14h16M4 18h16" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M4 6h16M4 10h16M4 14h16M4 18h16" />
</svg> </svg>
</button> </button>
<button <button @click="viewMode = 'grid'" :class="[
@click="viewMode = 'grid'"
:class="[
'px-3 py-1.5 rounded transition-colors', 'px-3 py-1.5 rounded transition-colors',
viewMode === 'grid' ? 'bg-white shadow-sm' : 'hover:bg-gray-200' viewMode === 'grid' ? 'bg-white shadow-sm' : 'hover:bg-gray-200'
]" ]" title="Grid view">
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"
<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"> stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4h6v6H4V4zm0 10h6v6H4v-6zm10-10h6v6h-6V4zm0 10h6v6h-6v-6z" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M4 4h6v6H4V4zm0 10h6v6H4v-6zm10-10h6v6h-6V4zm0 10h6v6h-6v-6z" />
</svg> </svg>
</button> </button>
</div> </div>
@@ -249,32 +244,32 @@ onMounted(() => {
<div v-else-if="error" class="bg-red-50 border border-red-200 rounded-xl p-6 text-center"> <div v-else-if="error" class="bg-red-50 border border-red-200 rounded-xl p-6 text-center">
<span class="i-heroicons-exclamation-circle text-red-500 text-4xl mb-3 inline-block" /> <span class="i-heroicons-exclamation-circle text-red-500 text-4xl mb-3 inline-block" />
<p class="text-red-700 font-medium">{{ error }}</p> <p class="text-red-700 font-medium">{{ error }}</p>
<button @click="fetchVideos" class="mt-4 px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg transition-colors"> <button @click="fetchVideos"
class="mt-4 px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg transition-colors">
Try Again Try Again
</button> </button>
</div> </div>
<!-- Empty State --> <!-- Empty State -->
<EmptyState <EmptyState v-else-if="videos.length === 0" title="No videos found"
v-else-if="videos.length === 0"
title="No videos found"
description="You haven't uploaded any videos yet. Start by uploading your first video!" description="You haven't uploaded any videos yet. Start by uploading your first video!"
imageUrl="https://cdn-icons-png.flaticon.com/512/7486/7486747.png" imageUrl="https://cdn-icons-png.flaticon.com/512/7486/7486747.png" actionLabel="Upload Video"
actionLabel="Upload Video" :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'" 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 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"> <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" /> <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"> <div v-else class="w-full h-full flex items-center justify-center text-gray-400">
<span class="i-heroicons-film text-4xl" /> <span class="i-heroicons-film text-4xl" />
</div> </div>
<div class="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center"> <div
<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"> 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" /> <span class="i-heroicons-play-20-solid text-xl ml-0.5" />
</button> </button>
</div> </div>
@@ -300,7 +295,8 @@ onMounted(() => {
<button class="p-1.5 hover:bg-gray-100 rounded transition-colors" title="Share"> <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" /> <span class="i-heroicons-share w-4 h-4 text-gray-600" />
</button> </button>
<button @click="deleteVideo(video.id)" class="p-1.5 hover:bg-red-100 rounded transition-colors" title="Delete"> <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" /> <span class="i-heroicons-trash w-4 h-4 text-red-600" />
</button> </button>
</div> </div>
@@ -324,7 +320,8 @@ onMounted(() => {
<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">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">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">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">Upload Date
</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th> <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
</tr> </tr>
</thead> </thead>
@@ -333,7 +330,8 @@ onMounted(() => {
<td class="px-6 py-4"> <td class="px-6 py-4">
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<div class="w-20 h-12 bg-gray-200 rounded overflow-hidden flex-shrink-0"> <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" /> <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"> <div v-else class="w-full h-full flex items-center justify-center">
<span class="i-heroicons-film text-gray-400 text-xl" /> <span class="i-heroicons-film text-gray-400 text-xl" />
</div> </div>
@@ -345,10 +343,12 @@ onMounted(() => {
</div> </div>
</td> </td>
<td class="px-6 py-4"> <td class="px-6 py-4">
<span :class="['px-2 py-1 text-xs font-medium rounded-full whitespace-nowrap', getStatusClass(video.status)]"> <span
:class="['px-2 py-1 text-xs font-medium rounded-full whitespace-nowrap', getStatusClass(video.status)]">
{{ video.status || 'Unknown' }} {{ video.status || 'Unknown' }}
</span> </span>
</td> <td class="px-6 py-4 text-sm text-gray-500"> </td>
<td class="px-6 py-4 text-sm text-gray-500">
{{ formatDuration(video.duration) }} {{ formatDuration(video.duration) }}
</td> </td>
<td class="px-6 py-4 text-sm text-gray-500"> <td class="px-6 py-4 text-sm text-gray-500">
@@ -365,7 +365,8 @@ onMounted(() => {
<button class="p-1.5 hover:bg-gray-100 rounded transition-colors" title="Share"> <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" /> <span class="i-heroicons-share w-4 h-4 text-gray-600" />
</button> </button>
<button @click="deleteVideo(video.id)" class="p-1.5 hover:bg-red-100 rounded transition-colors" title="Delete"> <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" /> <span class="i-heroicons-trash w-4 h-4 text-red-600" />
</button> </button>
</div> </div>
@@ -383,19 +384,13 @@ onMounted(() => {
<span class="font-medium">{{ total }}</span> results <span class="font-medium">{{ total }}</span> results
</div> </div>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<button <button @click="handlePageChange(page - 1)" :disabled="page === 1"
@click="handlePageChange(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">
: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 Previous
</button> </button>
<span class="px-4 py-1.5 bg-primary text-white rounded">{{ page }}</span> <span class="px-4 py-1.5 bg-primary text-white rounded">{{ page }}</span>
<button <button @click="handlePageChange(page + 1)" :disabled="page * limit >= total"
@click="handlePageChange(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">
: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 Next
</button> </button>
</div> </div>

View File

@@ -28,37 +28,128 @@ export default defineConfig({
colors: { colors: {
primary: { primary: {
DEFAULT: "#14a74b", DEFAULT: "#14a74b",
light: "#76da83", 50: "#effcf3",
active: "#119c45", 100: "#dcf9e2",
"active-light": "#aff6b8", 200: "#bbf0c8",
dark: "#025c15", 300: "#86efac",
400: "#4ade80",
500: "#14a74b",
600: "#16a34a",
700: "#15803d",
800: "#166534",
900: "#14532d",
950: "#052e16",
light: "#4ade80",
active: "#15803d",
"active-light": "#bbf0c8",
dark: "#14532d",
},
accent: {
DEFAULT: "#6366f1",
50: "#eef2ff",
100: "#e0e7ff",
200: "#c7d2fe",
300: "#a5b4fc",
400: "#818cf8",
500: "#6366f1",
600: "#4f46e5",
700: "#4338ca",
800: "#3730a3",
900: "#312e81",
950: "#1e1b4b",
}, },
success: { success: {
DEFAULT: "#2dc76b", DEFAULT: "#22c55e",
light: "#17c653", 50: "#f0fdf4",
100: "#dcfce7",
200: "#bbf7d0",
300: "#86efac",
400: "#4ade80",
500: "#22c55e",
600: "#16a34a",
700: "#15803d",
800: "#166534",
900: "#14532d",
950: "#052e16",
light: "#4ade80",
}, },
info: { info: {
DEFAULT: "#39a6ea", DEFAULT: "#0ea5e9",
light: "#39c1ea", 50: "#f0f9ff",
}, 100: "#e0f2fe",
danger: { 200: "#bae6fd",
DEFAULT: "#f8285a", 300: "#7dd3fc",
light: "#f8285a", 400: "#38bdf8",
active: "#d1214c", 500: "#0ea5e9",
600: "#0284c7",
700: "#0369a1",
800: "#075985",
900: "#0c4a6e",
950: "#082f49",
light: "#38bdf8",
}, },
warning: { warning: {
DEFAULT: "#f0f9ff", DEFAULT: "#f59e0b",
light: "#f0f9ff", 50: "#fffbeb",
100: "#fef3c7",
200: "#fde68a",
300: "#fcd34d",
400: "#fbbf24",
500: "#f59e0b",
600: "#d97706",
700: "#b45309",
800: "#92400e",
900: "#78350f",
950: "#451a03",
light: "#fbbf24",
},
danger: {
DEFAULT: "#ef4444",
50: "#fef2f2",
100: "#fee2e2",
200: "#fecaca",
300: "#fca5a5",
400: "#f87171",
500: "#ef4444",
600: "#dc2626",
700: "#b91c1c",
800: "#991b1b",
900: "#7f1d1d",
950: "#450a0a",
light: "#f87171",
active: "#dc2626",
}, },
secondary: { secondary: {
DEFAULT: "#fd7906", DEFAULT: "#fd7906",
light: "#fbb06f", 50: "#fff7ed",
100: "#ffedd5",
200: "#fed7aa",
300: "#fdba74",
400: "#fb923c",
500: "#fd7906",
600: "#ea580c",
700: "#c2410c",
800: "#9a3412",
900: "#7c2d12",
950: "#431407",
light: "#fb923c",
inverse: "#4b5675", inverse: "#4b5675",
dark: "#b34700", dark: "#c2410c",
}, },
dark: { dark: {
DEFAULT: "#161f2d", DEFAULT: "#111827",
light: "#4d4d4d", light: "#374151",
50: "#f9fafb",
100: "#f3f4f6",
200: "#e5e7eb",
300: "#d1d5db",
400: "#9ca3af",
500: "#6b7280",
600: "#4b5563",
700: "#374151",
800: "#1f2937",
900: "#111827",
950: "#030712",
}, },
white: { white: {
DEFAULT: "#ffffff", DEFAULT: "#ffffff",