fix color
This commit is contained in:
22
components.d.ts
vendored
22
components.d.ts
vendored
@@ -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']
|
||||||
}
|
}
|
||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
103
src/components/GlobalUploadIndicator.vue
Normal file
103
src/components/GlobalUploadIndicator.vue
Normal 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>
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
|
||||||
|
|||||||
16
src/components/icons/AlertTriangleIcon.vue
Normal file
16
src/components/icons/AlertTriangleIcon.vue
Normal 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>
|
||||||
15
src/components/icons/ArrowRightIcon.vue
Normal file
15
src/components/icons/ArrowRightIcon.vue
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
<template>
|
||||||
|
<svg v-if="filled" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="currentColor"
|
||||||
|
stroke="none">
|
||||||
|
<path d="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>
|
||||||
16
src/components/icons/CheckCircleIcon.vue
Normal file
16
src/components/icons/CheckCircleIcon.vue
Normal 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>
|
||||||
14
src/components/icons/CheckMarkIcon.vue
Normal file
14
src/components/icons/CheckMarkIcon.vue
Normal 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>
|
||||||
16
src/components/icons/CreditCardIcon.vue
Normal file
16
src/components/icons/CreditCardIcon.vue
Normal 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>
|
||||||
16
src/components/icons/InfoIcon.vue
Normal file
16
src/components/icons/InfoIcon.vue
Normal 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>
|
||||||
18
src/components/icons/SettingsIcon.vue
Normal file
18
src/components/icons/SettingsIcon.vue
Normal 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>
|
||||||
17
src/components/icons/TrashIcon.vue
Normal file
17
src/components/icons/TrashIcon.vue
Normal 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>
|
||||||
16
src/components/icons/VideoIcon.vue
Normal file
16
src/components/icons/VideoIcon.vue
Normal 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>
|
||||||
17
src/components/icons/XCircleIcon.vue
Normal file
17
src/components/icons/XCircleIcon.vue
Normal 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>
|
||||||
164
src/composables/useUploadQueue.ts
Normal file
164
src/composables/useUploadQueue.ts
Normal 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
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
|||||||
@@ -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>
|
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,70 +142,65 @@ 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"
|
{ label: 'Dashboard', to: '/' },
|
||||||
description="Manage and organize your video library"
|
{ label: 'Videos' }
|
||||||
:breadcrumbs="[
|
]" :actions="[
|
||||||
{ label: 'Dashboard', to: '/' },
|
{
|
||||||
{ label: 'Videos' }
|
label: 'Upload Video',
|
||||||
]"
|
// icon: 'i-heroicons-cloud-arrow-up',
|
||||||
:actions="[
|
icon: iconHoist,
|
||||||
{
|
variant: 'primary',
|
||||||
label: 'Upload Video',
|
onClick: () => router.push('/upload')
|
||||||
// icon: 'i-heroicons-cloud-arrow-up',
|
}
|
||||||
icon: iconHoist,
|
]" />
|
||||||
variant: 'primary',
|
|
||||||
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'"
|
'px-3 py-1.5 rounded transition-colors',
|
||||||
:class="[
|
viewMode === 'table' ? 'bg-white shadow-sm' : 'hover:bg-gray-200'
|
||||||
'px-3 py-1.5 rounded transition-colors',
|
]" title="Table view">
|
||||||
viewMode === 'table' ? 'bg-white shadow-sm' : 'hover:bg-gray-200'
|
<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"
|
||||||
title="Table view"
|
stroke="currentColor">
|
||||||
>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||||
<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">
|
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'"
|
'px-3 py-1.5 rounded transition-colors',
|
||||||
:class="[
|
viewMode === 'grid' ? 'bg-white shadow-sm' : 'hover:bg-gray-200'
|
||||||
'px-3 py-1.5 rounded transition-colors',
|
]" title="Grid view">
|
||||||
viewMode === 'grid' ? 'bg-white shadow-sm' : 'hover:bg-gray-200'
|
<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"
|
||||||
title="Grid view"
|
stroke="currentColor">
|
||||||
>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||||
<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">
|
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>
|
||||||
@@ -214,67 +209,67 @@ onMounted(() => {
|
|||||||
|
|
||||||
<!-- Loading State -->
|
<!-- Loading State -->
|
||||||
<div v-if="loading" class="animate-pulse">
|
<div v-if="loading" class="animate-pulse">
|
||||||
<!-- Grid Skeleton -->
|
<!-- Grid Skeleton -->
|
||||||
<div v-if="viewMode === 'grid'" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
|
<div v-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="i in 8" :key="i" class="bg-white border border-gray-200 rounded-xl overflow-hidden">
|
<div v-for="i in 8" :key="i" class="bg-white border border-gray-200 rounded-xl overflow-hidden">
|
||||||
<Skeleton height="150px" width="100%"></Skeleton>
|
<Skeleton height="150px" width="100%"></Skeleton>
|
||||||
<div class="p-4">
|
<div class="p-4">
|
||||||
<Skeleton width="80%" height="1.5rem" class="mb-2"></Skeleton>
|
<Skeleton width="80%" height="1.5rem" class="mb-2"></Skeleton>
|
||||||
<Skeleton width="60%" height="1rem" class="mb-4"></Skeleton>
|
<Skeleton width="60%" height="1rem" class="mb-4"></Skeleton>
|
||||||
<div class="flex justify-between">
|
<div class="flex justify-between">
|
||||||
<Skeleton width="3rem" height="1rem"></Skeleton>
|
<Skeleton width="3rem" height="1rem"></Skeleton>
|
||||||
<Skeleton width="3rem" height="1rem"></Skeleton>
|
<Skeleton width="3rem" height="1rem"></Skeleton>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- Table Skeleton -->
|
</div>
|
||||||
<div v-else class="bg-white rounded-xl border border-gray-200 overflow-hidden">
|
<!-- Table Skeleton -->
|
||||||
<div class="p-4 border-b border-gray-200" v-for="i in 5" :key="i">
|
<div v-else class="bg-white rounded-xl border border-gray-200 overflow-hidden">
|
||||||
<div class="flex gap-4 items-center">
|
<div class="p-4 border-b border-gray-200" v-for="i in 5" :key="i">
|
||||||
<Skeleton width="5rem" height="3rem" class="rounded"></Skeleton>
|
<div class="flex gap-4 items-center">
|
||||||
<div class="flex-1">
|
<Skeleton width="5rem" height="3rem" class="rounded"></Skeleton>
|
||||||
<Skeleton width="40%" height="1.2rem" class="mb-2"></Skeleton>
|
<div class="flex-1">
|
||||||
<Skeleton width="30%" height="1rem"></Skeleton>
|
<Skeleton width="40%" height="1.2rem" class="mb-2"></Skeleton>
|
||||||
</div>
|
<Skeleton width="30%" height="1rem"></Skeleton>
|
||||||
<Skeleton width="10%" height="1rem"></Skeleton>
|
|
||||||
<Skeleton width="10%" height="1rem"></Skeleton>
|
|
||||||
<Skeleton width="5rem" height="2rem" borderRadius="16px"></Skeleton>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<Skeleton width="10%" height="1rem"></Skeleton>
|
||||||
|
<Skeleton width="10%" height="1rem"></Skeleton>
|
||||||
|
<Skeleton width="5rem" height="2rem" borderRadius="16px"></Skeleton>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Error State -->
|
<!-- Error State -->
|
||||||
<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>
|
||||||
|
|||||||
129
uno.config.ts
129
uno.config.ts
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user