develop-updateui #1
6
components.d.ts
vendored
6
components.d.ts
vendored
@@ -16,7 +16,6 @@ declare module 'vue' {
|
|||||||
AlertTriangleIcon: typeof import('./src/components/icons/AlertTriangleIcon.vue')['default']
|
AlertTriangleIcon: typeof import('./src/components/icons/AlertTriangleIcon.vue')['default']
|
||||||
ArrowDownTray: typeof import('./src/components/icons/ArrowDownTray.vue')['default']
|
ArrowDownTray: typeof import('./src/components/icons/ArrowDownTray.vue')['default']
|
||||||
ArrowRightIcon: typeof import('./src/components/icons/ArrowRightIcon.vue')['default']
|
ArrowRightIcon: typeof import('./src/components/icons/ArrowRightIcon.vue')['default']
|
||||||
Avatar: typeof import('primevue/avatar')['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']
|
||||||
@@ -32,6 +31,7 @@ declare module 'vue' {
|
|||||||
Dialog: typeof import('primevue/dialog')['default']
|
Dialog: typeof import('primevue/dialog')['default']
|
||||||
EllipsisVerticalIcon: typeof import('./src/components/icons/EllipsisVerticalIcon.vue')['default']
|
EllipsisVerticalIcon: typeof import('./src/components/icons/EllipsisVerticalIcon.vue')['default']
|
||||||
EmptyState: typeof import('./src/components/dashboard/EmptyState.vue')['default']
|
EmptyState: typeof import('./src/components/dashboard/EmptyState.vue')['default']
|
||||||
|
FileUploadType: typeof import('./src/components/icons/FileUploadType.vue')['default']
|
||||||
FloatLabel: typeof import('primevue/floatlabel')['default']
|
FloatLabel: typeof import('primevue/floatlabel')['default']
|
||||||
GlobalUploadIndicator: typeof import('./src/components/GlobalUploadIndicator.vue')['default']
|
GlobalUploadIndicator: typeof import('./src/components/GlobalUploadIndicator.vue')['default']
|
||||||
HardDriveUpload: typeof import('./src/components/icons/HardDriveUpload.vue')['default']
|
HardDriveUpload: typeof import('./src/components/icons/HardDriveUpload.vue')['default']
|
||||||
@@ -49,7 +49,6 @@ declare module 'vue' {
|
|||||||
PanelLeft: typeof import('./src/components/icons/PanelLeft.vue')['default']
|
PanelLeft: typeof import('./src/components/icons/PanelLeft.vue')['default']
|
||||||
Password: typeof import('primevue/password')['default']
|
Password: typeof import('primevue/password')['default']
|
||||||
PencilIcon: typeof import('./src/components/icons/PencilIcon.vue')['default']
|
PencilIcon: typeof import('./src/components/icons/PencilIcon.vue')['default']
|
||||||
Popover: typeof import('primevue/popover')['default']
|
|
||||||
RootLayout: typeof import('./src/components/RootLayout.vue')['default']
|
RootLayout: typeof import('./src/components/RootLayout.vue')['default']
|
||||||
RouterLink: typeof import('vue-router')['RouterLink']
|
RouterLink: typeof import('vue-router')['RouterLink']
|
||||||
RouterView: typeof import('vue-router')['RouterView']
|
RouterView: typeof import('vue-router')['RouterView']
|
||||||
@@ -74,7 +73,6 @@ declare global {
|
|||||||
const AlertTriangleIcon: typeof import('./src/components/icons/AlertTriangleIcon.vue')['default']
|
const AlertTriangleIcon: typeof import('./src/components/icons/AlertTriangleIcon.vue')['default']
|
||||||
const ArrowDownTray: typeof import('./src/components/icons/ArrowDownTray.vue')['default']
|
const ArrowDownTray: typeof import('./src/components/icons/ArrowDownTray.vue')['default']
|
||||||
const ArrowRightIcon: typeof import('./src/components/icons/ArrowRightIcon.vue')['default']
|
const ArrowRightIcon: typeof import('./src/components/icons/ArrowRightIcon.vue')['default']
|
||||||
const Avatar: typeof import('primevue/avatar')['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']
|
||||||
@@ -90,6 +88,7 @@ declare global {
|
|||||||
const Dialog: typeof import('primevue/dialog')['default']
|
const Dialog: typeof import('primevue/dialog')['default']
|
||||||
const EllipsisVerticalIcon: typeof import('./src/components/icons/EllipsisVerticalIcon.vue')['default']
|
const EllipsisVerticalIcon: typeof import('./src/components/icons/EllipsisVerticalIcon.vue')['default']
|
||||||
const EmptyState: typeof import('./src/components/dashboard/EmptyState.vue')['default']
|
const EmptyState: typeof import('./src/components/dashboard/EmptyState.vue')['default']
|
||||||
|
const FileUploadType: typeof import('./src/components/icons/FileUploadType.vue')['default']
|
||||||
const FloatLabel: typeof import('primevue/floatlabel')['default']
|
const FloatLabel: typeof import('primevue/floatlabel')['default']
|
||||||
const GlobalUploadIndicator: typeof import('./src/components/GlobalUploadIndicator.vue')['default']
|
const GlobalUploadIndicator: typeof import('./src/components/GlobalUploadIndicator.vue')['default']
|
||||||
const HardDriveUpload: typeof import('./src/components/icons/HardDriveUpload.vue')['default']
|
const HardDriveUpload: typeof import('./src/components/icons/HardDriveUpload.vue')['default']
|
||||||
@@ -107,7 +106,6 @@ declare global {
|
|||||||
const PanelLeft: typeof import('./src/components/icons/PanelLeft.vue')['default']
|
const PanelLeft: typeof import('./src/components/icons/PanelLeft.vue')['default']
|
||||||
const Password: typeof import('primevue/password')['default']
|
const Password: typeof import('primevue/password')['default']
|
||||||
const PencilIcon: typeof import('./src/components/icons/PencilIcon.vue')['default']
|
const PencilIcon: typeof import('./src/components/icons/PencilIcon.vue')['default']
|
||||||
const Popover: typeof import('primevue/popover')['default']
|
|
||||||
const RootLayout: typeof import('./src/components/RootLayout.vue')['default']
|
const RootLayout: typeof import('./src/components/RootLayout.vue')['default']
|
||||||
const RouterLink: typeof import('vue-router')['RouterLink']
|
const RouterLink: typeof import('vue-router')['RouterLink']
|
||||||
const RouterView: typeof import('vue-router')['RouterView']
|
const RouterView: typeof import('vue-router')['RouterView']
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import Bell from "@/components/icons/Bell.vue";
|
import Bell from "@/components/icons/Bell.vue";
|
||||||
|
import Credit from "@/components/icons/Credit.vue";
|
||||||
import Home from "@/components/icons/Home.vue";
|
import Home from "@/components/icons/Home.vue";
|
||||||
import Video from "@/components/icons/Video.vue";
|
import Video from "@/components/icons/Video.vue";
|
||||||
import Credit from "@/components/icons/Credit.vue";
|
|
||||||
// import Upload from "@/components/icons/Upload.vue";
|
// import Upload from "@/components/icons/Upload.vue";
|
||||||
import NotificationDrawer from "./NotificationDrawer.vue";
|
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { createStaticVNode, ref } from "vue";
|
import { createStaticVNode, ref } from "vue";
|
||||||
|
import NotificationDrawer from "./NotificationDrawer.vue";
|
||||||
|
|
||||||
const className = ":uno: w-12 h-12 p-2 rounded-2xl hover:bg-primary/15 flex press-animated items-center justify-center shrink-0";
|
const className = ":uno: w-12 h-12 p-2 rounded-2xl hover:bg-primary/15 flex press-animated items-center justify-center shrink-0";
|
||||||
const homeHoist = createStaticVNode(`<img class="h-8 w-8" src="/apple-touch-icon.png" alt="Logo" />`, 1);
|
const homeHoist = createStaticVNode(`<img class="h-8 w-8" src="/apple-touch-icon.png" alt="Logo" />`, 1);
|
||||||
@@ -25,7 +25,7 @@ const links = [
|
|||||||
{ href: "/#home", label: "app", icon: homeHoist, type: "btn", className },
|
{ href: "/#home", label: "app", icon: homeHoist, type: "btn", className },
|
||||||
{ href: "/", label: "Overview", icon: Home, type: "a", className },
|
{ href: "/", label: "Overview", icon: Home, type: "a", className },
|
||||||
// { href: "/upload", label: "Upload", icon: Upload, type: "a", className },
|
// { href: "/upload", label: "Upload", icon: Upload, type: "a", className },
|
||||||
{ href: "/video", label: "Video", icon: Video, type: "a", className },
|
{ href: "/videos", label: "Videos", icon: Video, type: "a", className },
|
||||||
{ href: "/payments-and-plans", label: "Payments & Plans", icon: Credit, type: "a", className },
|
{ href: "/payments-and-plans", label: "Payments & Plans", icon: Credit, type: "a", className },
|
||||||
{ href: "/notification", label: "Notification", icon: Bell, type: "btn", className, action: handleNotificationClick, isActive: isNotificationOpen },
|
{ href: "/notification", label: "Notification", icon: Bell, type: "btn", className, action: handleNotificationClick, isActive: isNotificationOpen },
|
||||||
{ href: "/profile", label: "Profile", icon: profileHoist, type: "a", className: 'w-12 h-12 rounded-2xl hover:bg-primary/15 flex shrink-0' },
|
{ href: "/profile", label: "Profile", icon: profileHoist, type: "a", className: 'w-12 h-12 rounded-2xl hover:bg-primary/15 flex shrink-0' },
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref } from 'vue';
|
|
||||||
import { useUploadQueue } from '@/composables/useUploadQueue';
|
import { useUploadQueue } from '@/composables/useUploadQueue';
|
||||||
import UploadQueueItem from '@/routes/upload/components/UploadQueueItem.vue';
|
import UploadQueueItem from '@/routes/upload/components/UploadQueueItem.vue';
|
||||||
import { useUIState } from '@/stores/uiState';
|
import { useUIState } from '@/stores/uiState';
|
||||||
|
import { computed, ref } from 'vue';
|
||||||
|
import { useRouter } from 'vue-router';
|
||||||
|
|
||||||
const { items, completeCount, pendingCount, startQueue, removeItem, cancelItem } = useUploadQueue();
|
const router = useRouter();
|
||||||
|
const { items, completeCount, pendingCount, startQueue, removeItem, cancelItem, removeAll } = useUploadQueue();
|
||||||
const uiState = useUIState();
|
const uiState = useUIState();
|
||||||
|
|
||||||
const isCollapsed = ref(false);
|
const isCollapsed = ref(false);
|
||||||
@@ -34,12 +36,27 @@ const statusText = computed(() => {
|
|||||||
if (pendingCount.value > 0) return `${pendingCount.value} file${pendingCount.value !== 1 ? 's' : ''} waiting`;
|
if (pendingCount.value > 0) return `${pendingCount.value} file${pendingCount.value !== 1 ? 's' : ''} waiting`;
|
||||||
return 'Processing...';
|
return 'Processing...';
|
||||||
});
|
});
|
||||||
|
const isDoneWithErrors = computed(() =>
|
||||||
|
isAllDone.value &&
|
||||||
|
items.value.some(i => i.status === 'error') && items.value.every(i => i.status === 'complete' || i.status === 'error')
|
||||||
|
);
|
||||||
|
const doneUpload = () => {
|
||||||
|
router.push({ name: 'videos', query: { uploaded: 'true' } });
|
||||||
|
removeAll();
|
||||||
|
}
|
||||||
|
watch(isAllDone, (newItems) => {
|
||||||
|
if (newItems && items.value.every(i => i.status === 'complete')) {
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
doneUpload();
|
||||||
|
clearTimeout(timeout);
|
||||||
|
}, 3000);
|
||||||
|
}
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Transition enter-active-class="transition-all duration-300 ease-out"
|
<Transition enter-active-class="transition-all duration-300 ease-out" enter-from-class="opacity-0 translate-y-4"
|
||||||
enter-from-class="opacity-0 translate-y-4" enter-to-class="opacity-100 translate-y-0"
|
enter-to-class="opacity-100 translate-y-0" leave-active-class="transition-all duration-200 ease-in"
|
||||||
leave-active-class="transition-all duration-200 ease-in"
|
|
||||||
leave-from-class="opacity-100 translate-y-0" leave-to-class="opacity-0 translate-y-4">
|
leave-from-class="opacity-100 translate-y-0" leave-to-class="opacity-0 translate-y-4">
|
||||||
|
|
||||||
<div v-if="isVisible"
|
<div v-if="isVisible"
|
||||||
@@ -54,15 +71,14 @@ const statusText = computed(() => {
|
|||||||
<div class="relative w-6 h-6 shrink-0">
|
<div class="relative w-6 h-6 shrink-0">
|
||||||
<svg v-if="isUploading" class="w-6 h-6 animate-spin text-accent" viewBox="0 0 24 24" fill="none">
|
<svg v-if="isUploading" class="w-6 h-6 animate-spin text-accent" viewBox="0 0 24 24" fill="none">
|
||||||
<circle class="opacity-20" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="3" />
|
<circle class="opacity-20" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="3" />
|
||||||
<path class="opacity-90" fill="currentColor"
|
<path class="opacity-90" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
|
||||||
</svg>
|
</svg>
|
||||||
<svg v-else-if="isAllDone" class="w-6 h-6 text-green-400" viewBox="0 0 24 24" fill="none"
|
<svg v-else-if="isAllDone" class="w-6 h-6 text-green-400" viewBox="0 0 24 24" fill="none"
|
||||||
stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
||||||
<path d="M20 6 9 17l-5-5" />
|
<path d="M20 6 9 17l-5-5" />
|
||||||
</svg>
|
</svg>
|
||||||
<svg v-else class="w-6 h-6 text-slate-400" viewBox="0 0 24 24" fill="none"
|
<svg v-else class="w-6 h-6 text-slate-400" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||||
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
<circle cx="12" cy="12" r="10" />
|
<circle cx="12" cy="12" r="10" />
|
||||||
<polyline points="12 6 12 12 16 14" />
|
<polyline points="12 6 12 12 16 14" />
|
||||||
</svg>
|
</svg>
|
||||||
@@ -86,7 +102,11 @@ const statusText = computed(() => {
|
|||||||
</svg>
|
</svg>
|
||||||
Start
|
Start
|
||||||
</button>
|
</button>
|
||||||
|
<button v-else-if="isDoneWithErrors" @click.stop="doneUpload"
|
||||||
|
class="flex items-center gap-1.5 text-xs font-semibold px-3 py-1.5 bg-green-500 hover:bg-green-500/80 text-white rounded-lg transition-all">
|
||||||
|
View Videos
|
||||||
|
</button>
|
||||||
|
<!-- Clear queue -->
|
||||||
<!-- Add more files -->
|
<!-- Add more files -->
|
||||||
<button @click.stop="uiState.uploadDialogVisible = true"
|
<button @click.stop="uiState.uploadDialogVisible = true"
|
||||||
class="w-7 h-7 flex items-center justify-center text-slate-400 hover:text-white hover:bg-white/10 rounded-lg transition-all"
|
class="w-7 h-7 flex items-center justify-center text-slate-400 hover:text-white hover:bg-white/10 rounded-lg transition-all"
|
||||||
@@ -102,9 +122,8 @@ const statusText = computed(() => {
|
|||||||
<button @click.stop="isCollapsed = !isCollapsed"
|
<button @click.stop="isCollapsed = !isCollapsed"
|
||||||
class="w-7 h-7 flex items-center justify-center text-slate-400 hover:text-white hover:bg-white/10 rounded-lg transition-all">
|
class="w-7 h-7 flex items-center justify-center text-slate-400 hover:text-white hover:bg-white/10 rounded-lg transition-all">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4 transition-transform duration-200"
|
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4 transition-transform duration-200"
|
||||||
:class="{ 'rotate-180': isCollapsed }"
|
:class="{ 'rotate-180': isCollapsed }" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||||
viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"
|
stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
||||||
stroke-linecap="round" stroke-linejoin="round">
|
|
||||||
<path d="m18 15-6-6-6 6" />
|
<path d="m18 15-6-6-6 6" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
@@ -113,19 +132,18 @@ const statusText = computed(() => {
|
|||||||
|
|
||||||
<!-- Overall progress bar -->
|
<!-- Overall progress bar -->
|
||||||
<div v-if="isUploading" class="h-0.5 w-full bg-slate-100 shrink-0">
|
<div v-if="isUploading" class="h-0.5 w-full bg-slate-100 shrink-0">
|
||||||
<div class="h-full bg-accent transition-all duration-500"
|
<div class="h-full bg-accent transition-all duration-500" :style="{ width: `${overallProgress}%` }">
|
||||||
:style="{ width: `${overallProgress}%` }"></div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- File list -->
|
<!-- File list -->
|
||||||
<Transition enter-active-class="transition-all duration-200 ease-out"
|
<Transition enter-active-class="transition-all duration-200 ease-out" enter-from-class="opacity-0"
|
||||||
enter-from-class="opacity-0" enter-to-class="opacity-100"
|
enter-to-class="opacity-100" leave-active-class="transition-all duration-150 ease-in"
|
||||||
leave-active-class="transition-all duration-150 ease-in"
|
|
||||||
leave-from-class="opacity-100" leave-to-class="opacity-0">
|
leave-from-class="opacity-100" leave-to-class="opacity-0">
|
||||||
<div v-if="!isCollapsed" class="flex-1 overflow-y-auto min-h-0">
|
<div v-if="!isCollapsed" class="flex-1 overflow-y-auto min-h-0">
|
||||||
<div class="p-3 flex flex-col gap-2">
|
<div class="p-3 flex flex-col gap-2">
|
||||||
<UploadQueueItem v-for="item in items" :key="item.id" :item="item"
|
<UploadQueueItem v-for="item in items" :key="item.id" :item="item" @remove="removeItem($event)"
|
||||||
@remove="removeItem($event)" @cancel="cancelItem($event)" />
|
@cancel="cancelItem($event)" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Transition>
|
</Transition>
|
||||||
|
|||||||
23
src/components/icons/FileUploadType.vue
Normal file
23
src/components/icons/FileUploadType.vue
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
<template>
|
||||||
|
<!-- Local file icon -->
|
||||||
|
<svg v-if="filled" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 404 532">
|
||||||
|
<path
|
||||||
|
d="M26 74v384c0 27 22 48 48 48h256c27 0 48-21 48-48V197c0-4 0-8-1-11H274c-31 0-56-25-56-56V27c-3-1-7-1-10-1H74c-26 0-48 22-48 48zm64 224c0-18 14-32 32-32h96c18 0 32 14 32 32v18l40-25c10-7 24 1 24 14v83c0 12-14 20-24 13l-40-25v18c0 18-14 32-32 32h-96c-18 0-32-14-32-32v-96z"
|
||||||
|
fill="#a6acb9" />
|
||||||
|
<path
|
||||||
|
d="M208 26c3 0 7 0 10 1v103c0 31 25 56 56 56h103c1 3 1 7 1 11v261c0 27-21 48-48 48H74c-26 0-48-21-48-48V74c0-26 22-48 48-48h134zm156 137c2 2 4 4 6 7h-96c-22 0-40-18-40-40V34c3 2 5 4 7 6l123 123zM74 10c-35 0-64 29-64 64v384c0 35 29 64 64 64h256c35 0 64-29 64-64V197c0-17-7-34-19-46L253 29c-12-12-28-19-45-19H74zm144 272c9 0 16 7 16 16v96c0 9-7 16-16 16h-96c-9 0-16-7-16-16v-96c0-9 7-16 16-16h96zm-96-16c-18 0-32 14-32 32v96c0 18 14 32 32 32h96c18 0 32-14 32-32v-18l40 25c10 7 24-1 24-13v-84c0-12-14-20-24-13l-40 25v-18c0-18-14-32-32-32h-96zm176 38v84l-48-30v-24l48-30z"
|
||||||
|
fill="#1e3050" />
|
||||||
|
</svg>
|
||||||
|
<!-- Remote link icon -->
|
||||||
|
<svg v-else xmlns="http://www.w3.org/2000/svg" viewBox="0 0 596 564">
|
||||||
|
<path
|
||||||
|
d="M90 258h104c2-1 5-2 8-2 3-117 56-193 99-228C185 42 94 139 90 258zm128-7c28-8 73-22 135-40-5 16-9 31-14 47h103c-3-132-72-209-112-231-39 22-107 96-112 224zm51 247c10 3 21 5 32 6-9-7-18-16-27-26-2 7-3 13-5 20zm11-38c17 22 36 37 50 45 40-22 109-99 112-231H334l-6 21c-16 55-32 110-48 164zm0 0zm79-432c44 35 97 112 99 230h112c-4-119-95-216-211-230zm0 476c116-14 207-111 211-230H458c-2 117-55 195-99 230z"
|
||||||
|
fill="#a6acb9" />
|
||||||
|
<path
|
||||||
|
d="M570 274H458c-2 118-55 195-99 230 116-14 207-111 211-230zM269 498c10 3 21 5 32 6-9-7-18-16-27-26l6-18c18 22 36 37 50 45 40-22 109-99 112-231H335l4-16h103c-3-132-72-209-112-231-39 22-107 96-112 224l-16 5c3-117 56-193 99-228C185 42 94 139 90 258h104l-55 16H90c0 5 1 10 1 14l-16 5c0-9-1-18-1-27C74 125 189 10 330 10s256 115 256 256-115 256-256 256c-23 0-45-3-66-9l5-15zm301-240c-4-119-95-216-211-230 44 35 97 112 99 230h112zM150 414l2 5 46 92 60-205-204 60 91 46 5 2zM31 373l-21-11 23-7 231-68 18-5-5 18-68 232-7 22-60-120-94 94-6 5-11-11 5-6 95-94-100-49z"
|
||||||
|
fill="#1e3050" />
|
||||||
|
</svg>
|
||||||
|
</template>
|
||||||
|
<script lang="ts" setup>
|
||||||
|
defineProps<{ filled?: boolean }>();
|
||||||
|
</script>
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<svg v-if="filled" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 596 404"><path d="M74 170v64c0 53 43 96 96 96h96v64h64v-64h96c53 0 96-43 96-96v-64c0-53-43-96-96-96h-96V10h-64v64h-96c-53 0-96 43-96 96zm96 0h256v64H170v-64z" fill="#a6acb9"/><path d="M170 10C82 10 10 82 10 170v64c0 88 72 160 160 160h96v-64h-96c-53 0-96-43-96-96v-64c0-53 43-96 96-96h96V10h-96zm256 384c88 0 160-72 160-160v-64c0-88-72-160-160-160h-96v64h96c53 0 96 43 96 96v64c0 53-43 96-96 96h-96v64h96zM202 170h-32v64h256v-64H202z" fill="#1e3050"/></svg>
|
<svg v-if="filled" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 596 404"><path d="M74 170v64c0 53 43 96 96 96h96v64h64v-64h96c53 0 96-43 96-96v-64c0-53-43-96-96-96h-96V10h-64v64h-96c-53 0-96 43-96 96zm96 0h256v64H170v-64z" fill="#a6acb9"/><path d="M170 10C82 10 10 82 10 170v64c0 88 72 160 160 160h96v-64h-96c-53 0-96-43-96-96v-64c0-53 43-96 96-96h96V10h-96zm256 384c88 0 160-72 160-160v-64c0-88-72-160-160-160h-96v64h96c53 0 96 43 96 96v64c0 53-43 96-96 96h-96v64h96zM202 170h-32v64h256v-64H202z" fill="#1e3050"/></svg>
|
||||||
<svg v-else xmlns="http://www.w3.org/2000/svg" viewBox="-10 -194 596 404"><path d="M160-184C72-184 0-112 0-24v64c0 88 72 160 160 160h96v-64h-96c-53 0-96-43-96-96v-64c0-53 43-96 96-96h96v-64h-96zm256 384c88 0 160-72 160-160v-64c0-88-72-160-160-160h-96v64h96c53 0 96 43 96 96v64c0 53-43 96-96 96h-96v64h96zM192-24h-32v64h256v-64H192z" fill="#1e3050"/></svg>
|
<svg v-else xmlns="http://www.w3.org/2000/svg" viewBox="-10 -194 596 404"><path d="M160-184C72-184 0-112 0-24v64c0 88 72 160 160 160h96v-64h-96c-53 0-96-43-96-96v-64c0-53 43-96 96-96h96v-64h-96zm256 384c88 0 160-72 160-160v-64c0-88-72-160-160-160h-96v64h96c53 0 96 43 96 96v64c0 53-43 96-96 96h-96v64h96zM192-24h-32v64h256v-64H192z" fill="currentColor"/></svg>
|
||||||
</template>
|
</template>
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
defineProps<{ filled?: boolean }>();
|
defineProps<{ filled?: boolean }>();
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { computed, ref } from 'vue';
|
import { computed, ref } from 'vue';
|
||||||
import { size } from 'zod';
|
|
||||||
|
|
||||||
export interface QueueItem {
|
export interface QueueItem {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -46,7 +45,18 @@ export function useUploadQueue() {
|
|||||||
|
|
||||||
const addFiles = (files: FileList) => {
|
const addFiles = (files: FileList) => {
|
||||||
const allowed = Array.from(files).slice(0, remainingSlots.value);
|
const allowed = Array.from(files).slice(0, remainingSlots.value);
|
||||||
const newItems: QueueItem[] = allowed.map((file) => ({
|
const duplicates: File[] = [];
|
||||||
|
const fresh: File[] = [];
|
||||||
|
|
||||||
|
for (const file of allowed) {
|
||||||
|
const isDupe = items.value.some(
|
||||||
|
item => item.type === 'local' && item.name === file.name && item.file?.size === file.size
|
||||||
|
);
|
||||||
|
if (isDupe) duplicates.push(file);
|
||||||
|
else fresh.push(file);
|
||||||
|
}
|
||||||
|
|
||||||
|
const newItems: QueueItem[] = fresh.map((file) => ({
|
||||||
id: Math.random().toString(36).substring(2, 9),
|
id: Math.random().toString(36).substring(2, 9),
|
||||||
name: file.name,
|
name: file.name,
|
||||||
type: 'local',
|
type: 'local',
|
||||||
@@ -63,12 +73,14 @@ export function useUploadQueue() {
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
items.value.push(...newItems);
|
items.value.push(...newItems);
|
||||||
return { added: newItems.length, skipped: files.length - newItems.length };
|
return { added: newItems.length, skipped: files.length - allowed.length, duplicates: duplicates.length };
|
||||||
};
|
};
|
||||||
|
|
||||||
const addRemoteUrls = (urls: string[]) => {
|
const addRemoteUrls = (urls: string[]) => {
|
||||||
const allowed = urls.slice(0, remainingSlots.value);
|
const allowed = urls.slice(0, remainingSlots.value);
|
||||||
const newItems: QueueItem[] = allowed.map((url) => ({
|
const fresh = allowed.filter(url => !items.value.some(item => item.type === 'remote' && item.url === url));
|
||||||
|
const duplicateCount = allowed.length - fresh.length;
|
||||||
|
const newItems: QueueItem[] = fresh.map((url) => ({
|
||||||
id: Math.random().toString(36).substring(2, 9),
|
id: Math.random().toString(36).substring(2, 9),
|
||||||
name: url.split('/').pop() || 'Remote File',
|
name: url.split('/').pop() || 'Remote File',
|
||||||
type: 'remote',
|
type: 'remote',
|
||||||
@@ -84,7 +96,7 @@ export function useUploadQueue() {
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
items.value.push(...newItems);
|
items.value.push(...newItems);
|
||||||
return { added: newItems.length, skipped: urls.length - newItems.length };
|
return { added: newItems.length, skipped: urls.length - allowed.length, duplicates: duplicateCount };
|
||||||
};
|
};
|
||||||
|
|
||||||
const removeItem = (id: string) => {
|
const removeItem = (id: string) => {
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import { Hono } from 'hono';
|
import { Hono } from 'hono';
|
||||||
|
|
||||||
import { setupMiddlewares } from './server/middlewares/setup';
|
|
||||||
import { apiProxyMiddleware } from './server/middlewares/apiProxy';
|
import { apiProxyMiddleware } from './server/middlewares/apiProxy';
|
||||||
import { registerWellKnownRoutes } from './server/routes/wellKnown';
|
import { setupMiddlewares } from './server/middlewares/setup';
|
||||||
import { registerMergeRoutes } from './server/routes/merge';
|
import { registerDisplayRoutes } from './server/routes/display';
|
||||||
import { registerManifestRoutes } from './server/routes/manifest';
|
import { registerManifestRoutes } from './server/routes/manifest';
|
||||||
|
import { registerMergeRoutes } from './server/routes/merge';
|
||||||
import { registerSSRRoutes } from './server/routes/ssr';
|
import { registerSSRRoutes } from './server/routes/ssr';
|
||||||
|
import { registerWellKnownRoutes } from './server/routes/wellKnown';
|
||||||
|
|
||||||
const app = new Hono();
|
const app = new Hono();
|
||||||
|
|
||||||
@@ -18,6 +19,7 @@ app.use(apiProxyMiddleware);
|
|||||||
// Routes
|
// Routes
|
||||||
registerWellKnownRoutes(app);
|
registerWellKnownRoutes(app);
|
||||||
registerMergeRoutes(app);
|
registerMergeRoutes(app);
|
||||||
|
registerDisplayRoutes(app);
|
||||||
registerManifestRoutes(app);
|
registerManifestRoutes(app);
|
||||||
registerSSRRoutes(app);
|
registerSSRRoutes(app);
|
||||||
|
|
||||||
|
|||||||
26
src/main.ts
26
src/main.ts
@@ -1,4 +1,5 @@
|
|||||||
import { PiniaColada, useQueryCache } from '@pinia/colada';
|
import { PiniaColada, useQueryCache } from '@pinia/colada';
|
||||||
|
import { definePreset } from '@primeuix/themes';
|
||||||
import Aura from '@primeuix/themes/aura';
|
import Aura from '@primeuix/themes/aura';
|
||||||
import { createHead as CSRHead } from "@unhead/vue/client";
|
import { createHead as CSRHead } from "@unhead/vue/client";
|
||||||
import { createHead as SSRHead } from "@unhead/vue/server";
|
import { createHead as SSRHead } from "@unhead/vue/server";
|
||||||
@@ -11,6 +12,25 @@ import { createSSRApp } from 'vue';
|
|||||||
import { RouterView } from 'vue-router';
|
import { RouterView } from 'vue-router';
|
||||||
import { withErrorBoundary } from './lib/hoc/withErrorBoundary';
|
import { withErrorBoundary } from './lib/hoc/withErrorBoundary';
|
||||||
import createAppRouter from './routes';
|
import createAppRouter from './routes';
|
||||||
|
|
||||||
|
const CompactAura = definePreset(Aura, {
|
||||||
|
semantic: {
|
||||||
|
formField: {
|
||||||
|
paddingX: '0.625rem',
|
||||||
|
paddingY: '0.375rem',
|
||||||
|
sm: {
|
||||||
|
fontSize: '0.75rem',
|
||||||
|
paddingX: '0.5rem',
|
||||||
|
paddingY: '0.25rem',
|
||||||
|
},
|
||||||
|
lg: {
|
||||||
|
fontSize: '1rem',
|
||||||
|
paddingX: '0.75rem',
|
||||||
|
paddingY: '0.5rem',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
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"
|
||||||
export function createApp() {
|
export function createApp() {
|
||||||
const pinia = createPinia();
|
const pinia = createPinia();
|
||||||
@@ -21,14 +41,10 @@ export function createApp() {
|
|||||||
app.use(PrimeVue, {
|
app.use(PrimeVue, {
|
||||||
// unstyled: true,
|
// unstyled: true,
|
||||||
theme: {
|
theme: {
|
||||||
preset: Aura,
|
preset: CompactAura,
|
||||||
options: {
|
options: {
|
||||||
darkModeSelector: '.my-app-dark',
|
darkModeSelector: '.my-app-dark',
|
||||||
cssLayer: false,
|
cssLayer: false,
|
||||||
// cssLayer: {
|
|
||||||
// name: 'primevue',
|
|
||||||
// order: 'theme, base, primevue'
|
|
||||||
// }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -9,12 +9,12 @@
|
|||||||
|
|
||||||
<div class="flex flex-col gap-1">
|
<div class="flex flex-col gap-1">
|
||||||
<label for="email" class="text-sm font-medium text-gray-700">Email address</label>
|
<label for="email" class="text-sm font-medium text-gray-700">Email address</label>
|
||||||
<InputText size="small" name="email" type="email" placeholder="you@example.com" fluid />
|
<InputText name="email" type="email" placeholder="you@example.com" fluid />
|
||||||
<Message v-if="$form.email?.invalid" severity="error" size="small" variant="simple">{{
|
<Message v-if="$form.email?.invalid" severity="error" variant="simple">{{
|
||||||
$form.email.error?.message }}</Message>
|
$form.email.error?.message }}</Message>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button type="submit" size="small" label="Send Reset Link" fluid />
|
<Button type="submit" label="Send Reset Link" fluid />
|
||||||
|
|
||||||
<div class="text-center mt-2">
|
<div class="text-center mt-2">
|
||||||
<router-link to="/login" replace
|
<router-link to="/login" replace
|
||||||
|
|||||||
@@ -5,23 +5,23 @@
|
|||||||
class="flex flex-col gap-4 w-full">
|
class="flex flex-col gap-4 w-full">
|
||||||
<div class="flex flex-col gap-1">
|
<div class="flex flex-col gap-1">
|
||||||
<label for="email" class="text-sm font-medium text-gray-700">Email</label>
|
<label for="email" class="text-sm font-medium text-gray-700">Email</label>
|
||||||
<InputText size="small" name="email" type="text" placeholder="Enter your email" fluid
|
<InputText name="email" type="text" placeholder="Enter your email" fluid
|
||||||
:disabled="auth.loading" />
|
:disabled="auth.loading" />
|
||||||
<Message v-if="$form.email?.invalid" severity="error" size="small" variant="simple">{{
|
<Message v-if="$form.email?.invalid" severity="error" variant="simple">{{
|
||||||
$form.email.error?.message }}</Message>
|
$form.email.error?.message }}</Message>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-col gap-1">
|
<div class="flex flex-col gap-1">
|
||||||
<label for="password" class="text-sm font-medium text-gray-700">Password</label>
|
<label for="password" class="text-sm font-medium text-gray-700">Password</label>
|
||||||
<Password name="password" size="small" placeholder="Enter your password" :feedback="false" toggleMask
|
<Password name="password" placeholder="Enter your password" :feedback="false" toggleMask
|
||||||
fluid :inputStyle="{ width: '100%' }" :disabled="auth.loading" />
|
fluid :inputStyle="{ width: '100%' }" :disabled="auth.loading" />
|
||||||
<Message v-if="$form.password?.invalid" severity="error" size="small" variant="simple">{{
|
<Message v-if="$form.password?.invalid" severity="error" variant="simple">{{
|
||||||
$form.password.error?.message }}</Message>
|
$form.password.error?.message }}</Message>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<Checkbox inputId="remember-me" size="small" name="rememberMe" binary :disabled="auth.loading" />
|
<Checkbox inputId="remember-me" name="rememberMe" binary :disabled="auth.loading" />
|
||||||
<label for="remember-me" class="text-sm text-gray-900">Remember me</label>
|
<label for="remember-me" class="text-sm text-gray-900">Remember me</label>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-sm">
|
<div class="text-sm">
|
||||||
@@ -31,7 +31,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button type="submit" size="small" :label="auth.loading ? 'Signing in...' : 'Sign in'" fluid
|
<Button type="submit" :label="auth.loading ? 'Signing in...' : 'Sign in'" fluid
|
||||||
:loading="auth.loading" />
|
:loading="auth.loading" />
|
||||||
|
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
@@ -43,7 +43,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button size="small" type="button" variant="outlined" severity="secondary"
|
<Button type="button" variant="outlined" severity="secondary"
|
||||||
class="w-full flex items-center justify-center gap-2" @click="loginWithGoogle" :disabled="auth.loading">
|
class="w-full flex items-center justify-center gap-2" @click="loginWithGoogle" :disabled="auth.loading">
|
||||||
<svg class="h-5 w-5" viewBox="0 0 24 24" fill="currentColor">
|
<svg class="h-5 w-5" viewBox="0 0 24 24" fill="currentColor">
|
||||||
<path
|
<path
|
||||||
|
|||||||
@@ -4,28 +4,28 @@
|
|||||||
class="flex flex-col gap-4 w-full">
|
class="flex flex-col gap-4 w-full">
|
||||||
<div class="flex flex-col gap-1">
|
<div class="flex flex-col gap-1">
|
||||||
<label for="name" class="text-sm font-medium text-gray-700">Full Name</label>
|
<label for="name" class="text-sm font-medium text-gray-700">Full Name</label>
|
||||||
<InputText size="small" name="name" placeholder="John Doe" fluid />
|
<InputText name="name" placeholder="John Doe" fluid />
|
||||||
<Message v-if="$form.name?.invalid" severity="error" size="small" variant="simple">{{
|
<Message v-if="$form.name?.invalid" severity="error" variant="simple">{{
|
||||||
$form.name.error?.message }}</Message>
|
$form.name.error?.message }}</Message>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-col gap-1">
|
<div class="flex flex-col gap-1">
|
||||||
<label for="email" class="text-sm font-medium text-gray-700">Email address</label>
|
<label for="email" class="text-sm font-medium text-gray-700">Email address</label>
|
||||||
<InputText size="small" name="email" type="email" placeholder="you@example.com" fluid />
|
<InputText name="email" type="email" placeholder="you@example.com" fluid />
|
||||||
<Message v-if="$form.email?.invalid" severity="error" size="small" variant="simple">{{
|
<Message v-if="$form.email?.invalid" severity="error" variant="simple">{{
|
||||||
$form.email.error?.message }}</Message>
|
$form.email.error?.message }}</Message>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-col gap-1">
|
<div class="flex flex-col gap-1">
|
||||||
<label for="password" class="text-sm font-medium text-gray-700">Password</label>
|
<label for="password" class="text-sm font-medium text-gray-700">Password</label>
|
||||||
<Password name="password" size="small" placeholder="Create a password" :feedback="true" toggleMask fluid
|
<Password name="password" placeholder="Create a password" :feedback="true" toggleMask fluid
|
||||||
:inputStyle="{ width: '100%' }" />
|
:inputStyle="{ width: '100%' }" />
|
||||||
<small class="text-gray-500">Must be at least 8 characters.</small>
|
<small class="text-gray-500">Must be at least 8 characters.</small>
|
||||||
<Message v-if="$form.password?.invalid" severity="error" size="small" variant="simple">{{
|
<Message v-if="$form.password?.invalid" severity="error" variant="simple">{{
|
||||||
$form.password.error?.message }}</Message>
|
$form.password.error?.message }}</Message>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button type="submit" size="small" label="Create Account" fluid />
|
<Button type="submit" label="Create Account" fluid />
|
||||||
|
|
||||||
<p class="mt-4 text-center text-sm text-gray-600">
|
<p class="mt-4 text-center text-sm text-gray-600">
|
||||||
Already have an account?
|
Already have an account?
|
||||||
|
|||||||
@@ -1,14 +1,12 @@
|
|||||||
import { type ReactiveHead, type ResolvableValue } from "@unhead/vue";
|
import { useAuthStore } from "@/stores/auth";
|
||||||
import { headSymbol } from "@unhead/vue";
|
import { headSymbol, type ReactiveHead, type ResolvableValue } from "@unhead/vue";
|
||||||
|
import { inject } from "vue";
|
||||||
import {
|
import {
|
||||||
createMemoryHistory,
|
createMemoryHistory,
|
||||||
createRouter,
|
createRouter,
|
||||||
createWebHistory,
|
createWebHistory,
|
||||||
type RouteRecordRaw,
|
type RouteRecordRaw,
|
||||||
} from "vue-router";
|
} from "vue-router";
|
||||||
import { useAuthStore } from "@/stores/auth";
|
|
||||||
import { inject } from "vue";
|
|
||||||
import { TinyMqttClient } from "@/lib/liteMqtt";
|
|
||||||
|
|
||||||
type RouteData = RouteRecordRaw & {
|
type RouteData = RouteRecordRaw & {
|
||||||
meta?: ResolvableValue<ReactiveHead> & { requiresAuth?: boolean };
|
meta?: ResolvableValue<ReactiveHead> & { requiresAuth?: boolean };
|
||||||
@@ -102,11 +100,11 @@ const routes: RouteData[] = [
|
|||||||
// },
|
// },
|
||||||
// },
|
// },
|
||||||
{
|
{
|
||||||
path: "video",
|
path: "videos",
|
||||||
children: [
|
children: [
|
||||||
{
|
{
|
||||||
path: "",
|
path: "",
|
||||||
name: "video",
|
name: "videos",
|
||||||
component: () => import("./video/Videos.vue"),
|
component: () => import("./video/Videos.vue"),
|
||||||
meta: {
|
meta: {
|
||||||
head: {
|
head: {
|
||||||
@@ -120,16 +118,16 @@ const routes: RouteData[] = [
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
// {
|
||||||
path: ":id",
|
// path: ":id",
|
||||||
name: "video-detail",
|
// name: "video-detail",
|
||||||
component: () => import("./video/DetailVideo.vue"),
|
// component: () => import("./video/DetailVideo.vue"),
|
||||||
meta: {
|
// meta: {
|
||||||
head: {
|
// head: {
|
||||||
title: "Edit Video - Holistream",
|
// title: "Edit Video - Holistream",
|
||||||
},
|
// },
|
||||||
},
|
// },
|
||||||
},
|
// },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -3,16 +3,17 @@ import Chart from '@/components/icons/Chart.vue';
|
|||||||
import Credit from '@/components/icons/Credit.vue';
|
import Credit from '@/components/icons/Credit.vue';
|
||||||
import Upload from '@/components/icons/Upload.vue';
|
import Upload from '@/components/icons/Upload.vue';
|
||||||
import Video from '@/components/icons/Video.vue';
|
import Video from '@/components/icons/Video.vue';
|
||||||
|
import { useUIState } from '@/stores/uiState';
|
||||||
import Skeleton from 'primevue/skeleton';
|
import Skeleton from 'primevue/skeleton';
|
||||||
import { useRouter } from 'vue-router';
|
import { useRouter } from 'vue-router';
|
||||||
import Referral from './Referral.vue';
|
import Referral from './Referral.vue';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
defineProps<Props>();
|
defineProps<Props>();
|
||||||
|
|
||||||
|
const uiState = useUIState();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const quickActions = [
|
const quickActions = [
|
||||||
@@ -20,7 +21,7 @@ const quickActions = [
|
|||||||
title: 'Upload Video',
|
title: 'Upload Video',
|
||||||
description: 'Upload a new video to your library',
|
description: 'Upload a new video to your library',
|
||||||
icon: Upload,
|
icon: Upload,
|
||||||
onClick: () => router.push('/upload')
|
onClick: () => uiState.toggleUploadDialog()
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Video Library',
|
title: 'Video Library',
|
||||||
|
|||||||
@@ -1,23 +1,41 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import UploadDropzone from './components/UploadDropzone.vue';
|
|
||||||
import RemoteUrlForm from './components/RemoteUrlForm.vue';
|
|
||||||
import { ref } from 'vue';
|
|
||||||
import { useUploadQueue } from '@/composables/useUploadQueue';
|
import { useUploadQueue } from '@/composables/useUploadQueue';
|
||||||
import { useUIState } from '@/stores/uiState';
|
import { useUIState } from '@/stores/uiState';
|
||||||
|
import { useToast } from 'primevue/usetoast';
|
||||||
|
import { ref } from 'vue';
|
||||||
|
import RemoteUrlForm from './components/RemoteUrlForm.vue';
|
||||||
|
import UploadDropzone from './components/UploadDropzone.vue';
|
||||||
|
|
||||||
const uiState = useUIState();
|
const uiState = useUIState();
|
||||||
|
const toast = useToast();
|
||||||
const mode = ref<'local' | 'remote'>('local');
|
const mode = ref<'local' | 'remote'>('local');
|
||||||
|
|
||||||
const { addFiles, addRemoteUrls, pendingCount, startQueue, remainingSlots, maxItems } = useUploadQueue();
|
const { addFiles, addRemoteUrls, pendingCount, startQueue, remainingSlots, maxItems } = useUploadQueue();
|
||||||
|
|
||||||
const handleFilesSelected = (files: FileList) => {
|
const handleFilesSelected = (files: FileList) => {
|
||||||
addFiles(files);
|
const result = addFiles(files);
|
||||||
uiState.uploadDialogVisible = false;
|
if (result.duplicates > 0) {
|
||||||
|
toast.add({
|
||||||
|
severity: 'warn',
|
||||||
|
summary: 'Duplicate files skipped',
|
||||||
|
detail: `${result.duplicates} file${result.duplicates > 1 ? 's are' : ' is'} already in the queue.`,
|
||||||
|
life: 4000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (result.added > 0) uiState.uploadDialogVisible = false;
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleRemoteUrls = (urls: string[]) => {
|
const handleRemoteUrls = (urls: string[]) => {
|
||||||
addRemoteUrls(urls);
|
const result = addRemoteUrls(urls);
|
||||||
uiState.uploadDialogVisible = false;
|
if (result.duplicates > 0) {
|
||||||
|
toast.add({
|
||||||
|
severity: 'warn',
|
||||||
|
summary: 'Duplicate URLs skipped',
|
||||||
|
detail: `${result.duplicates} URL${result.duplicates > 1 ? 's are' : ' is'} already in the queue.`,
|
||||||
|
life: 4000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (result.added > 0) uiState.uploadDialogVisible = false;
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleStartUpload = () => {
|
const handleStartUpload = () => {
|
||||||
|
|||||||
@@ -1,37 +1,81 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue';
|
||||||
|
|
||||||
const props = defineProps<{ maxFiles?: number }>();
|
const props = defineProps<{ maxFiles?: number }>();
|
||||||
const emit = defineEmits<{ filesSelected: [files: FileList] }>();
|
const emit = defineEmits<{ filesSelected: [files: FileList] }>();
|
||||||
|
|
||||||
|
const isDragOver = ref(false);
|
||||||
|
let dragCounter = 0;
|
||||||
|
|
||||||
|
const toVideoFiles = (files: FileList | null | undefined): FileList | null => {
|
||||||
|
if (!files?.length) return null;
|
||||||
|
const limit = props.maxFiles ?? 5;
|
||||||
|
const dt = new DataTransfer();
|
||||||
|
Array.from(files)
|
||||||
|
.filter(f => f.type.startsWith('video/'))
|
||||||
|
.slice(0, limit)
|
||||||
|
.forEach(f => dt.items.add(f));
|
||||||
|
return dt.files.length ? dt.files : null;
|
||||||
|
};
|
||||||
|
|
||||||
const handleFileChange = (event: Event) => {
|
const handleFileChange = (event: Event) => {
|
||||||
const input = event.target as HTMLInputElement;
|
const input = event.target as HTMLInputElement;
|
||||||
if (!input.files || input.files.length === 0) return;
|
const files = toVideoFiles(input.files);
|
||||||
const limit = props.maxFiles ?? 5;
|
if (files) emit('filesSelected', files);
|
||||||
if (input.files.length > limit) {
|
input.value = ''; // reset so same file can be re-selected
|
||||||
// Create a DataTransfer to slice to the limit
|
};
|
||||||
const dt = new DataTransfer();
|
|
||||||
Array.from(input.files).slice(0, limit).forEach(f => dt.items.add(f));
|
const onDragEnter = (e: DragEvent) => {
|
||||||
emit('filesSelected', dt.files);
|
e.preventDefault();
|
||||||
} else {
|
dragCounter++;
|
||||||
emit('filesSelected', input.files);
|
isDragOver.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const onDragLeave = (e: DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
dragCounter--;
|
||||||
|
if (dragCounter <= 0) {
|
||||||
|
dragCounter = 0;
|
||||||
|
isDragOver.value = false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const onDragOver = (e: DragEvent) => {
|
||||||
|
e.preventDefault(); // required to allow drop
|
||||||
|
e.stopPropagation();
|
||||||
|
};
|
||||||
|
|
||||||
|
const onDrop = (e: DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
dragCounter = 0;
|
||||||
|
isDragOver.value = false;
|
||||||
|
const files = toVideoFiles(e.dataTransfer?.files);
|
||||||
|
if (files) emit('filesSelected', files);
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="relative group cursor-pointer flex-1 flex flex-col h-full">
|
<div class="relative group cursor-pointer flex-1 flex flex-col h-full"
|
||||||
|
@dragenter="onDragEnter" @dragleave="onDragLeave" @dragover="onDragOver" @drop="onDrop">
|
||||||
<input type="file" multiple accept="video/*"
|
<input type="file" multiple accept="video/*"
|
||||||
class="absolute inset-0 w-full h-full opacity-0 z-20 cursor-pointer" @change="handleFileChange">
|
class="absolute inset-0 w-full h-full opacity-0 z-20 cursor-pointer" @change="handleFileChange">
|
||||||
|
|
||||||
<div class="flex-1 flex flex-col items-center justify-center gap-4 rounded-xl border-2 border-dashed
|
<div :class="[
|
||||||
border-slate-200 group-hover:border-accent/60 group-hover:bg-accent/[0.03]
|
'flex-1 flex flex-col items-center justify-center gap-4 rounded-xl border-2 border-dashed transition-all duration-200 py-6 px-4 h-full',
|
||||||
transition-all duration-300 py-6 px-4 h-full">
|
isDragOver
|
||||||
|
? 'border-accent bg-accent/5 scale-[0.99]'
|
||||||
|
: 'border-slate-200 group-hover:border-accent/60 group-hover:bg-accent/[0.03]'
|
||||||
|
]">
|
||||||
|
|
||||||
<!-- Animated icon -->
|
<!-- Animated icon -->
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<div class="w-20 h-20 rounded-2xl bg-slate-100 group-hover:bg-accent/10 flex items-center justify-center transition-all duration-300 group-hover:scale-105 group-hover:shadow-md">
|
<div :class="[
|
||||||
|
'w-20 h-20 rounded-2xl flex items-center justify-center transition-all duration-200',
|
||||||
|
isDragOver ? 'bg-accent/10 scale-110 shadow-md' : 'bg-slate-100 group-hover:bg-accent/10 group-hover:scale-105 group-hover:shadow-md'
|
||||||
|
]">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg"
|
<svg xmlns="http://www.w3.org/2000/svg"
|
||||||
class="w-10 h-10 text-slate-400 group-hover:text-accent transition-colors duration-300"
|
:class="['w-10 h-10 transition-colors duration-200', isDragOver ? 'text-accent' : 'text-slate-400 group-hover:text-accent']"
|
||||||
viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
|
viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
|
||||||
stroke-linecap="round" stroke-linejoin="round">
|
stroke-linecap="round" stroke-linejoin="round">
|
||||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
|
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
|
||||||
@@ -39,12 +83,15 @@ const handleFileChange = (event: Event) => {
|
|||||||
<line x1="12" x2="12" y1="3" y2="15" />
|
<line x1="12" x2="12" y1="3" y2="15" />
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<div class="absolute inset-0 rounded-2xl ring-4 ring-accent/0 group-hover:ring-accent/20 transition-all duration-300"></div>
|
<div :class="[
|
||||||
|
'absolute inset-0 rounded-2xl ring-4 transition-all duration-200',
|
||||||
|
isDragOver ? 'ring-accent/30' : 'ring-accent/0 group-hover:ring-accent/20'
|
||||||
|
]"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="text-center">
|
<div class="text-center">
|
||||||
<p class="text-base font-semibold text-slate-700 group-hover:text-slate-900 transition-colors">
|
<p :class="['text-base font-semibold transition-colors', isDragOver ? 'text-accent' : 'text-slate-700 group-hover:text-slate-900']">
|
||||||
Drop videos here
|
{{ isDragOver ? 'Release to add' : 'Drop videos here' }}
|
||||||
</p>
|
</p>
|
||||||
<p class="text-sm text-slate-400 mt-1.5">or click anywhere to browse</p>
|
<p class="text-sm text-slate-400 mt-1.5">or click anywhere to browse</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -52,7 +99,7 @@ const handleFileChange = (event: Event) => {
|
|||||||
<!-- Format badges -->
|
<!-- Format badges -->
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<span v-for="fmt in ['MP4', 'MOV', 'MKV']" :key="fmt"
|
<span v-for="fmt in ['MP4', 'MOV', 'MKV']" :key="fmt"
|
||||||
class="text-xs font-semibold px-3 py-1 bg-slate-100 text-slate-500 rounded-lg tracking-wide">
|
:class="['text-xs font-semibold px-3 py-1 rounded-lg tracking-wide transition-colors', isDragOver ? 'bg-accent/10 text-accent' : 'bg-slate-100 text-slate-500']">
|
||||||
{{ fmt }}
|
{{ fmt }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import FileUploadType from '@/components/icons/FileUploadType.vue';
|
||||||
import type { QueueItem } from '@/composables/useUploadQueue';
|
import type { QueueItem } from '@/composables/useUploadQueue';
|
||||||
import { computed } from 'vue';
|
import { computed } from 'vue';
|
||||||
|
|
||||||
@@ -37,7 +38,7 @@ const isActive = computed(() =>
|
|||||||
);
|
);
|
||||||
|
|
||||||
const canCancel = computed(() =>
|
const canCancel = computed(() =>
|
||||||
props.item.status === 'uploading' || props.item.status === 'pending'
|
props.item.status === 'uploading'
|
||||||
);
|
);
|
||||||
|
|
||||||
const progress = computed(() => props.item.progress || 0);
|
const progress = computed(() => props.item.progress || 0);
|
||||||
@@ -56,10 +57,7 @@ const progress = computed(() => props.item.progress || 0);
|
|||||||
<!-- File type icon -->
|
<!-- File type icon -->
|
||||||
<div class="w-8 h-8 rounded-lg flex items-center justify-center shrink-0 my-a"
|
<div class="w-8 h-8 rounded-lg flex items-center justify-center shrink-0 my-a"
|
||||||
:class="item.type === 'remote' ? 'bg-indigo-50 text-indigo-400' : 'bg-slate-100 text-slate-400'">
|
:class="item.type === 'remote' ? 'bg-indigo-50 text-indigo-400' : 'bg-slate-100 text-slate-400'">
|
||||||
<!-- Local file icon -->
|
<FileUploadType :filled="item.type === 'local'" />
|
||||||
<svg v-if="item.type === 'local'" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 404 532"><path d="M26 74v384c0 27 22 48 48 48h256c27 0 48-21 48-48V197c0-4 0-8-1-11H274c-31 0-56-25-56-56V27c-3-1-7-1-10-1H74c-26 0-48 22-48 48zm64 224c0-18 14-32 32-32h96c18 0 32 14 32 32v18l40-25c10-7 24 1 24 14v83c0 12-14 20-24 13l-40-25v18c0 18-14 32-32 32h-96c-18 0-32-14-32-32v-96z" fill="#a6acb9"/><path d="M208 26c3 0 7 0 10 1v103c0 31 25 56 56 56h103c1 3 1 7 1 11v261c0 27-21 48-48 48H74c-26 0-48-21-48-48V74c0-26 22-48 48-48h134zm156 137c2 2 4 4 6 7h-96c-22 0-40-18-40-40V34c3 2 5 4 7 6l123 123zM74 10c-35 0-64 29-64 64v384c0 35 29 64 64 64h256c35 0 64-29 64-64V197c0-17-7-34-19-46L253 29c-12-12-28-19-45-19H74zm144 272c9 0 16 7 16 16v96c0 9-7 16-16 16h-96c-9 0-16-7-16-16v-96c0-9 7-16 16-16h96zm-96-16c-18 0-32 14-32 32v96c0 18 14 32 32 32h96c18 0 32-14 32-32v-18l40 25c10 7 24-1 24-13v-84c0-12-14-20-24-13l-40 25v-18c0-18-14-32-32-32h-96zm176 38v84l-48-30v-24l48-30z" fill="#1e3050"/></svg>
|
|
||||||
<!-- Remote link icon -->
|
|
||||||
<svg v-else xmlns="http://www.w3.org/2000/svg" viewBox="0 0 596 564"><path d="M90 258h104c2-1 5-2 8-2 3-117 56-193 99-228C185 42 94 139 90 258zm128-7c28-8 73-22 135-40-5 16-9 31-14 47h103c-3-132-72-209-112-231-39 22-107 96-112 224zm51 247c10 3 21 5 32 6-9-7-18-16-27-26-2 7-3 13-5 20zm11-38c17 22 36 37 50 45 40-22 109-99 112-231H334l-6 21c-16 55-32 110-48 164zm0 0zm79-432c44 35 97 112 99 230h112c-4-119-95-216-211-230zm0 476c116-14 207-111 211-230H458c-2 117-55 195-99 230z" fill="#a6acb9"/><path d="M570 274H458c-2 118-55 195-99 230 116-14 207-111 211-230zM269 498c10 3 21 5 32 6-9-7-18-16-27-26l6-18c18 22 36 37 50 45 40-22 109-99 112-231H335l4-16h103c-3-132-72-209-112-231-39 22-107 96-112 224l-16 5c3-117 56-193 99-228C185 42 94 139 90 258h104l-55 16H90c0 5 1 10 1 14l-16 5c0-9-1-18-1-27C74 125 189 10 330 10s256 115 256 256-115 256-256 256c-23 0-45-3-66-9l5-15zm301-240c-4-119-95-216-211-230 44 35 97 112 99 230h112zM150 414l2 5 46 92 60-205-204 60 91 46 5 2zM31 373l-21-11 23-7 231-68 18-5-5 18-68 232-7 22-60-120-94 94-6 5-11-11 5-6 95-94-100-49z" fill="#1e3050"/></svg>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Content -->
|
<!-- Content -->
|
||||||
@@ -81,10 +79,16 @@ const progress = computed(() => props.item.progress || 0);
|
|||||||
<div class="mt-1.5">
|
<div class="mt-1.5">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<!-- Status badge -->
|
<!-- Status badge -->
|
||||||
<span class="flex items-center gap-1 text-[10px] font-medium" :class="statusVariant.text">
|
<div class="flex gap-2 text-[10px] font-medium" :class="statusVariant.text">
|
||||||
<span class="w-1.5 h-1.5 rounded-full shrink-0" :class="[statusVariant.dot, isActive ? 'animate-pulse' : '']"></span>
|
<!-- Size -->
|
||||||
{{ statusLabel }}
|
<span v-if="item.type === 'local'" >
|
||||||
</span>
|
{{ item.total }}
|
||||||
|
</span>
|
||||||
|
<span class="flex items-center gap-1" :class="statusVariant.text">
|
||||||
|
<span class="w-1.5 h-1.5 rounded-full shrink-0" :class="[statusVariant.dot, isActive ? 'animate-pulse' : '']"></span>
|
||||||
|
{{ statusLabel }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<!-- Progress % -->
|
<!-- Progress % -->
|
||||||
|
|||||||
174
src/routes/video/CopyVideoModal.vue
Normal file
174
src/routes/video/CopyVideoModal.vue
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { ModelVideo } from '@/api/client';
|
||||||
|
import { fetchMockVideoById } from '@/mocks/videos';
|
||||||
|
import Button from 'primevue/button';
|
||||||
|
import Dialog from 'primevue/dialog';
|
||||||
|
import InputText from 'primevue/inputtext';
|
||||||
|
import Skeleton from 'primevue/skeleton';
|
||||||
|
import { useToast } from 'primevue/usetoast';
|
||||||
|
import { computed, ref, watch } from 'vue';
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
videoId: string;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'close'): void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const toast = useToast();
|
||||||
|
const video = ref<ModelVideo | null>(null);
|
||||||
|
const loading = ref(true);
|
||||||
|
const copiedField = ref<string | null>(null);
|
||||||
|
|
||||||
|
const fetchVideo = async () => {
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
const videoData = await fetchMockVideoById(props.videoId);
|
||||||
|
if (videoData) {
|
||||||
|
video.value = videoData;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch video:', error);
|
||||||
|
toast.add({ severity: 'error', summary: 'Error', detail: 'Failed to load video details', life: 3000 });
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const baseUrl = computed(() => typeof window !== 'undefined' ? window.location.origin : '');
|
||||||
|
|
||||||
|
const shareLinks = computed(() => {
|
||||||
|
if (!video.value) return [];
|
||||||
|
const v = video.value;
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
key: 'embed',
|
||||||
|
label: 'Embed player (recommended)',
|
||||||
|
value: `${baseUrl.value}/play/index/${v.id}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'thumbnail',
|
||||||
|
label: 'Thumbnail URL',
|
||||||
|
value: v.thumbnail || '',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'hls',
|
||||||
|
label: 'HLS link (VIP only)',
|
||||||
|
value: v.hls_path ? `${baseUrl.value}/hls/getlink/${v.id}/${v.hls_token}/${v.hls_path}` : '',
|
||||||
|
placeholder: 'HLS link available for VIP with whitelisted domain',
|
||||||
|
hint: 'This link redirects to a signed HLS URL and only works on whitelisted domains.',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
|
const copyToClipboard = async (text: string, key: string) => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(text);
|
||||||
|
} catch {
|
||||||
|
const textArea = document.createElement('textarea');
|
||||||
|
textArea.value = text;
|
||||||
|
document.body.appendChild(textArea);
|
||||||
|
textArea.select();
|
||||||
|
document.execCommand('copy');
|
||||||
|
document.body.removeChild(textArea);
|
||||||
|
}
|
||||||
|
copiedField.value = key;
|
||||||
|
toast.add({ severity: 'success', summary: 'Copied', detail: 'Copied to clipboard', life: 2000 });
|
||||||
|
setTimeout(() => {
|
||||||
|
copiedField.value = null;
|
||||||
|
}, 2000);
|
||||||
|
};
|
||||||
|
|
||||||
|
watch(() => props.videoId, (newId) => {
|
||||||
|
if (newId) {
|
||||||
|
fetchVideo();
|
||||||
|
} else {
|
||||||
|
video.value = null;
|
||||||
|
}
|
||||||
|
}, { immediate: true });
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Dialog :visible="!!videoId" @update:visible="emit('close')" modal dismissableMask
|
||||||
|
:style="{ width: '600px', maxWidth: '90vw' }">
|
||||||
|
<!-- Header -->
|
||||||
|
<template #header>
|
||||||
|
<div v-if="loading" class="flex items-center gap-3">
|
||||||
|
<Skeleton width="12rem" height="1.25rem" />
|
||||||
|
</div>
|
||||||
|
<span v-else class="font-semibold text-lg">Get sharing address</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Loading Skeleton -->
|
||||||
|
<div v-if="loading" class="flex flex-col gap-5">
|
||||||
|
<div>
|
||||||
|
<Skeleton width="8rem" height="0.75rem" class="mb-3" />
|
||||||
|
<div v-for="i in 3" :key="i" class="flex flex-col gap-1.5 mb-4">
|
||||||
|
<Skeleton width="40%" height="0.75rem" />
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<Skeleton width="100%" height="2.25rem" borderRadius="6px" />
|
||||||
|
<Skeleton width="2.75rem" height="2.25rem" borderRadius="6px" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<Skeleton width="100%" height="4rem" borderRadius="6px" />
|
||||||
|
<Skeleton width="100%" height="4rem" borderRadius="6px" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Content -->
|
||||||
|
<div v-else class="flex flex-col gap-5">
|
||||||
|
<!-- Player addresses -->
|
||||||
|
<div>
|
||||||
|
<p class="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-3">Player address</p>
|
||||||
|
<div class="flex flex-col gap-4">
|
||||||
|
<div v-for="link in shareLinks" :key="link.key" class="flex flex-col gap-1.5">
|
||||||
|
<p class="text-sm font-medium text-muted-foreground">{{ link.label }}</p>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<InputText :value="link.value || ''" :placeholder="link.placeholder" readonly
|
||||||
|
class="flex-1 !font-mono !text-xs" @click="($event.target as HTMLInputElement)?.select()" />
|
||||||
|
<Button severity="secondary" outlined :disabled="!link.value || copiedField === link.key"
|
||||||
|
@click="copyToClipboard(link.value, link.key)" class="shrink-0">
|
||||||
|
<!-- Copy icon -->
|
||||||
|
<svg v-if="copiedField !== link.key" xmlns="http://www.w3.org/2000/svg" width="16"
|
||||||
|
height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
|
||||||
|
stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||||
|
<rect width="14" height="14" x="8" y="8" rx="2" ry="2" />
|
||||||
|
<path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2" />
|
||||||
|
</svg>
|
||||||
|
<!-- Check icon -->
|
||||||
|
<svg v-else xmlns="http://www.w3.org/2000/svg" width="16" height="16"
|
||||||
|
viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
|
||||||
|
stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||||
|
<path d="M20 6 9 17l-5-5" />
|
||||||
|
</svg>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<p v-if="link.hint" class="text-xs text-muted-foreground">{{ link.hint }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Notices -->
|
||||||
|
<div class="flex flex-col gap-2 text-sm">
|
||||||
|
<div class="rounded-xl border border-red-500/30 bg-red-500/10 p-3 flex items-start gap-3">
|
||||||
|
|
||||||
|
<div class="flex-1 text-sm">
|
||||||
|
<p class="font-medium text-red-900 dark:text-red-100 mb-1">Warning</p>
|
||||||
|
<p class="text-red-800 dark:text-red-200">Make sure shared files comply with <strong>local laws</strong> and confirm you understand the responsibilities involved when distributing content.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-xl border border-amber-500/30 bg-amber-500/10 p-3 flex items-start gap-3">
|
||||||
|
|
||||||
|
<div class="flex-1 text-sm">
|
||||||
|
<p class="font-medium text-amber-900 dark:text-amber-100 mb-1">Reminder</p>
|
||||||
|
<p class="text-amber-800 dark:text-amber-200">The embed player can auto switch fallback nodes and works well on mobile. Raw HLS links
|
||||||
|
rely on your own player and must be used only on whitelisted domains.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Dialog>
|
||||||
|
</template>
|
||||||
229
src/routes/video/DetailVideoModal.vue
Normal file
229
src/routes/video/DetailVideoModal.vue
Normal file
@@ -0,0 +1,229 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { ModelVideo } from '@/api/client';
|
||||||
|
import { fetchMockVideoById, updateMockVideo } from '@/mocks/videos';
|
||||||
|
import { Form, type FormSubmitEvent } from '@primevue/forms';
|
||||||
|
import { zodResolver } from '@primevue/forms/resolvers/zod';
|
||||||
|
import Button from 'primevue/button';
|
||||||
|
import Dialog from 'primevue/dialog';
|
||||||
|
import InputText from 'primevue/inputtext';
|
||||||
|
import Message from 'primevue/message';
|
||||||
|
import Skeleton from 'primevue/skeleton';
|
||||||
|
import Tag from 'primevue/tag';
|
||||||
|
import Textarea from 'primevue/textarea';
|
||||||
|
import { useToast } from 'primevue/usetoast';
|
||||||
|
import { ref, watch } from 'vue';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
videoId: string;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'close'): void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const toast = useToast();
|
||||||
|
const video = ref<ModelVideo | null>(null);
|
||||||
|
const loading = ref(true);
|
||||||
|
const saving = ref(false);
|
||||||
|
|
||||||
|
const initialValues = ref({
|
||||||
|
title: '',
|
||||||
|
description: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
const resolver = zodResolver(
|
||||||
|
z.object({
|
||||||
|
title: z.string().min(1, { message: 'Title is required.' }),
|
||||||
|
description: z.string().optional(),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const subtitleForm = ref({
|
||||||
|
file: null as File | null,
|
||||||
|
language: 'en',
|
||||||
|
displayName: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
const fetchVideo = async () => {
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
const videoData = await fetchMockVideoById(props.videoId);
|
||||||
|
if (videoData) {
|
||||||
|
video.value = videoData;
|
||||||
|
initialValues.value = {
|
||||||
|
title: videoData.title || '',
|
||||||
|
description: videoData.description || '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch video:', error);
|
||||||
|
toast.add({ severity: 'error', summary: 'Error', detail: 'Failed to load video details', life: 3000 });
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onFormSubmit = async ({ valid, values }: FormSubmitEvent) => {
|
||||||
|
if (!valid) return;
|
||||||
|
saving.value = true;
|
||||||
|
try {
|
||||||
|
const payload = { title: values.title as string, description: values.description as string };
|
||||||
|
await updateMockVideo(props.videoId, payload);
|
||||||
|
|
||||||
|
if (video.value) {
|
||||||
|
video.value.title = payload.title;
|
||||||
|
video.value.description = payload.description;
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.add({ severity: 'success', summary: 'Success', detail: 'Video updated successfully', life: 3000 });
|
||||||
|
close();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to save video:', error);
|
||||||
|
toast.add({ severity: 'error', summary: 'Error', detail: 'Failed to save changes', life: 3000 });
|
||||||
|
} finally {
|
||||||
|
saving.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onSubtitleFileChange = (event: Event) => {
|
||||||
|
const target = event.target as HTMLInputElement;
|
||||||
|
subtitleForm.value.file = target.files?.[0] || null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const canUploadSubtitle = computed(() => {
|
||||||
|
return subtitleForm.value.file && subtitleForm.value.language.trim();
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleUploadSubtitle = () => {
|
||||||
|
if (!canUploadSubtitle.value) return;
|
||||||
|
toast.add({ severity: 'info', summary: 'Info', detail: 'Subtitle upload not yet implemented', life: 3000 });
|
||||||
|
};
|
||||||
|
|
||||||
|
watch(() => props.videoId, (newId) => {
|
||||||
|
if (newId) {
|
||||||
|
fetchVideo();
|
||||||
|
}
|
||||||
|
}, { immediate: true });
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Dialog :visible="!!videoId" @update:visible="emit('close')" modal dismissableMask
|
||||||
|
:style="{ width: '600px', maxWidth: '90vw' }">
|
||||||
|
<!-- Header -->
|
||||||
|
<template #header>
|
||||||
|
<div v-if="loading" class="flex items-center gap-3">
|
||||||
|
<Skeleton width="8rem" height="1.25rem" />
|
||||||
|
</div>
|
||||||
|
<span v-else class="font-semibold text-lg">Edit video</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Loading Skeleton -->
|
||||||
|
<div v-if="loading" class="flex flex-col gap-4">
|
||||||
|
<!-- Title skeleton -->
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<Skeleton width="3rem" height="0.875rem" />
|
||||||
|
<Skeleton width="100%" height="2.5rem" borderRadius="6px" />
|
||||||
|
</div>
|
||||||
|
<!-- Description skeleton -->
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<Skeleton width="5rem" height="0.875rem" />
|
||||||
|
<Skeleton width="100%" height="6rem" borderRadius="6px" />
|
||||||
|
</div>
|
||||||
|
<!-- Subtitles section skeleton -->
|
||||||
|
<div class="flex flex-col gap-3 border-t border-surface pt-4">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<Skeleton width="4rem" height="0.875rem" />
|
||||||
|
<Skeleton width="4.5rem" height="1.5rem" borderRadius="16px" />
|
||||||
|
</div>
|
||||||
|
<Skeleton width="60%" height="0.875rem" />
|
||||||
|
<div class="flex flex-col gap-3 rounded-lg border border-surface p-3">
|
||||||
|
<Skeleton width="6rem" height="0.875rem" />
|
||||||
|
<Skeleton width="100%" height="2.25rem" borderRadius="6px" />
|
||||||
|
<div class="grid grid-cols-2 gap-3">
|
||||||
|
<Skeleton width="100%" height="2.25rem" borderRadius="6px" />
|
||||||
|
<Skeleton width="100%" height="2.25rem" borderRadius="6px" />
|
||||||
|
</div>
|
||||||
|
<Skeleton width="100%" height="2.5rem" borderRadius="6px" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Form Content -->
|
||||||
|
<Form v-else v-slot="$form" :resolver="resolver" :initialValues="initialValues" @submit="onFormSubmit"
|
||||||
|
class="flex flex-col gap-4">
|
||||||
|
<!-- Title -->
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<label for="edit-title" class="text-sm font-medium">Title</label>
|
||||||
|
<InputText id="edit-title" name="title" placeholder="Enter video title" fluid />
|
||||||
|
<Message v-if="$form.title?.invalid" severity="error" size="small" variant="simple">
|
||||||
|
{{ $form.title.error?.message }}
|
||||||
|
</Message>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Description -->
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<label for="edit-description" class="text-sm font-medium">Description</label>
|
||||||
|
<Textarea id="edit-description" name="description" placeholder="Enter video description"
|
||||||
|
:rows="4" autoResize fluid />
|
||||||
|
<Message v-if="$form.description?.invalid" severity="error" size="small" variant="simple">
|
||||||
|
{{ $form.description.error?.message }}
|
||||||
|
</Message>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Subtitles Section -->
|
||||||
|
<div class="flex flex-col gap-3 border-t-2 border-gray-200 pt-4">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<label class="text-sm font-medium">Subtitles</label>
|
||||||
|
<Tag value="0 tracks" severity="secondary" />
|
||||||
|
</div>
|
||||||
|
<p class="text-sm text-muted-foreground">No subtitles uploaded yet</p>
|
||||||
|
|
||||||
|
<!-- Upload Subtitle Form -->
|
||||||
|
<div class="flex flex-col gap-3 rounded-lg border border-gray-200 p-3">
|
||||||
|
<label class="text-sm font-medium">Upload Subtitle</label>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<label for="subtitle-file" class="text-xs font-medium">
|
||||||
|
Subtitle File (VTT, SRT, ASS, SSA)
|
||||||
|
</label>
|
||||||
|
<input id="subtitle-file" type="file"
|
||||||
|
accept=".vtt,.srt,.ass,.ssa,text/vtt,text/srt,application/x-subrip"
|
||||||
|
class="w-full text-sm file:mr-3 file:rounded-md file:border-0 file:bg-primary/10 file:px-3 file:py-1.5 file:text-sm file:font-medium file:text-primary hover:file:bg-primary/20 cursor-pointer"
|
||||||
|
@change="onSubtitleFileChange" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-2 gap-3">
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<label for="subtitle-language" class="text-xs font-medium">Language Code *</label>
|
||||||
|
<InputText id="subtitle-language" v-model="subtitleForm.language" placeholder="en, vi, etc."
|
||||||
|
:maxlength="10" size="small" />
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<label for="subtitle-name" class="text-xs font-medium">Display Name (Optional)</label>
|
||||||
|
<InputText id="subtitle-name" v-model="subtitleForm.displayName"
|
||||||
|
placeholder="English, Tiếng Việt, etc." size="small" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button label="Upload Subtitle" icon="i-carbon-upload" severity="secondary" outlined
|
||||||
|
class="w-full" :disabled="!canUploadSubtitle" @click="handleUploadSubtitle" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Footer inside Form so submit works -->
|
||||||
|
<div class="flex justify-end gap-2 border-t border-surface pt-4">
|
||||||
|
<Button label="Cancel" type="button" text severity="secondary" @click="emit('close')" />
|
||||||
|
<Button label="Save Changes" type="submit" icon="i-carbon-checkmark" :loading="saving" />
|
||||||
|
</div>
|
||||||
|
</Form>
|
||||||
|
|
||||||
|
<!-- Footer skeleton when loading -->
|
||||||
|
<template v-if="loading" #footer>
|
||||||
|
<div class="flex justify-end gap-2">
|
||||||
|
<Skeleton width="5rem" height="2.5rem" borderRadius="6px" />
|
||||||
|
<Skeleton width="8rem" height="2.5rem" borderRadius="6px" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Dialog>
|
||||||
|
</template>
|
||||||
@@ -1,25 +1,32 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted, createStaticVNode, watch, computed } from 'vue';
|
import { type ModelVideo } from '@/api/client';
|
||||||
import { useRouter } from 'vue-router';
|
|
||||||
import PageHeader from '@/components/dashboard/PageHeader.vue';
|
|
||||||
import EmptyState from '@/components/dashboard/EmptyState.vue';
|
import EmptyState from '@/components/dashboard/EmptyState.vue';
|
||||||
import { client, type ModelVideo } from '@/api/client';
|
import PageHeader from '@/components/dashboard/PageHeader.vue';
|
||||||
import { fetchMockVideos } from '@/mocks/videos';
|
import { fetchMockVideos } from '@/mocks/videos';
|
||||||
|
import { createStaticVNode, onMounted, onUnmounted, ref, watch } from 'vue';
|
||||||
|
import { useRouter } from 'vue-router';
|
||||||
|
|
||||||
import VideoFilters from './components/VideoFilters.vue';
|
import { useUploadQueue } from '@/composables/useUploadQueue';
|
||||||
import VideoGrid from './components/VideoGrid.vue';
|
|
||||||
import VideoTable from './components/VideoTable.vue';
|
|
||||||
import VideoBulkActions from './components/VideoBulkActions.vue';
|
|
||||||
import { useUIState } from '@/stores/uiState';
|
import { useUIState } from '@/stores/uiState';
|
||||||
|
import { useToast } from 'primevue/usetoast';
|
||||||
|
import VideoBulkActions from './components/VideoBulkActions.vue';
|
||||||
|
import VideoFilters from './components/VideoFilters.vue';
|
||||||
|
import VideoTable from './components/VideoTable.vue';
|
||||||
|
import CopyVideoModal from './CopyVideoModal.vue';
|
||||||
|
import DetailVideoModal from './DetailVideoModal.vue';
|
||||||
|
|
||||||
|
const detailVideoId = ref<string>("");
|
||||||
|
const copyVideoId = ref<string>("");
|
||||||
|
|
||||||
const uiState = useUIState();
|
const uiState = useUIState();
|
||||||
|
const { addFiles, startQueue } = useUploadQueue();
|
||||||
|
const toast = useToast();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const videos = ref<ModelVideo[]>([]);
|
const videos = ref<ModelVideo[]>([]);
|
||||||
const loading = ref(true);
|
const loading = ref(true);
|
||||||
const error = ref<string | null>(null);
|
const error = ref<string | null>(null);
|
||||||
const searchQuery = ref('');
|
const searchQuery = ref('');
|
||||||
const selectedStatus = ref<string>('all');
|
const selectedStatus = ref<string>('all');
|
||||||
const viewMode = ref<'grid' | 'table'>('table');
|
|
||||||
const iconHoist = createStaticVNode(`<svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 15a4 4 0 004 4h10a4 4 0 004-4v-1a4 4 0 00-4-4H7a4 4 0 00-4 4v1zM16 7l-4-4m0 0L8 7m4-4v12" /></svg>`, 1)
|
const iconHoist = createStaticVNode(`<svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 15a4 4 0 004 4h10a4 4 0 004-4v-1a4 4 0 00-4-4H7a4 4 0 00-4 4v1zM16 7l-4-4m0 0L8 7m4-4v12" /></svg>`, 1)
|
||||||
|
|
||||||
// Pagination
|
// Pagination
|
||||||
@@ -113,9 +120,103 @@ onMounted(() => {
|
|||||||
fetchVideos();
|
fetchVideos();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Reset drag state when upload dialog opens mid-drag
|
||||||
|
watch(() => uiState.uploadDialogVisible, (visible) => {
|
||||||
|
if (visible) {
|
||||||
|
dragCounter = 0;
|
||||||
|
isDraggingOver.value = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
watch([searchQuery, selectedStatus, limit, page], () => {
|
watch([searchQuery, selectedStatus, limit, page], () => {
|
||||||
fetchVideos();
|
fetchVideos();
|
||||||
});
|
});
|
||||||
|
const editVideo = (videoId?: string) => {
|
||||||
|
detailVideoId.value = videoId || "";
|
||||||
|
};
|
||||||
|
|
||||||
|
const copyVideo = (videoId?: string) => {
|
||||||
|
copyVideoId.value = videoId || "";
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Drag & drop upload ──────────────────────────────────────────────────
|
||||||
|
const isDraggingOver = ref(false);
|
||||||
|
let dragCounter = 0; // track nested dragenter/dragleave pairs
|
||||||
|
|
||||||
|
// Returns true for any OS file drag (used to keep counter balanced)
|
||||||
|
const isAnyFileDrag = (e: DragEvent) =>
|
||||||
|
Array.from(e.dataTransfer?.types ?? []).includes('Files');
|
||||||
|
|
||||||
|
// Returns true only when dragged items contain at least one video file
|
||||||
|
const isVideoDrag = (e: DragEvent): boolean => {
|
||||||
|
if (!isAnyFileDrag(e)) return false;
|
||||||
|
const items = e.dataTransfer?.items;
|
||||||
|
if (items?.length) {
|
||||||
|
return Array.from(items).some(item => item.kind === 'file' && item.type.startsWith('video/'));
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const onWindowDragEnter = (e: DragEvent) => {
|
||||||
|
if (uiState.uploadDialogVisible) return; // don't show overlay if dialog is open
|
||||||
|
if (!isAnyFileDrag(e)) return; // same guard as leave — keeps counter balanced
|
||||||
|
dragCounter++;
|
||||||
|
if (isVideoDrag(e)) isDraggingOver.value = true; // show overlay only for video
|
||||||
|
};
|
||||||
|
|
||||||
|
const onWindowDragLeave = (e: DragEvent) => {
|
||||||
|
if (!isAnyFileDrag(e)) return; // same guard as enter — keeps counter balanced
|
||||||
|
dragCounter--;
|
||||||
|
if (dragCounter <= 0) {
|
||||||
|
dragCounter = 0;
|
||||||
|
isDraggingOver.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onWindowDragOver = (e: DragEvent) => {
|
||||||
|
// When upload dialog is open, let the dropzone handle its own dragover/drop.
|
||||||
|
// Still preventDefault to block browser navigation, but stopPropagation
|
||||||
|
// is NOT set so the dropzone's own handler can also fire.
|
||||||
|
if (uiState.uploadDialogVisible) return;
|
||||||
|
if (isAnyFileDrag(e)) e.preventDefault();
|
||||||
|
};
|
||||||
|
|
||||||
|
const onWindowDrop = (e: DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
dragCounter = 0;
|
||||||
|
isDraggingOver.value = false;
|
||||||
|
if (uiState.uploadDialogVisible) return; // let the dialog handle it
|
||||||
|
const allFiles = e.dataTransfer?.files;
|
||||||
|
if (!allFiles?.length) return;
|
||||||
|
// Only pass video files
|
||||||
|
const dt = new DataTransfer();
|
||||||
|
Array.from(allFiles).filter(f => f.type.startsWith('video/')).forEach(f => dt.items.add(f));
|
||||||
|
if (!dt.files.length) return;
|
||||||
|
const result = addFiles(dt.files);
|
||||||
|
if (result.duplicates > 0) {
|
||||||
|
toast.add({
|
||||||
|
severity: 'warn',
|
||||||
|
summary: 'Duplicate files skipped',
|
||||||
|
detail: `${result.duplicates} file${result.duplicates > 1 ? 's are' : ' is'} already in the queue.`,
|
||||||
|
life: 4000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (result.added > 0) startQueue();
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
window.addEventListener('dragenter', onWindowDragEnter);
|
||||||
|
window.addEventListener('dragleave', onWindowDragLeave);
|
||||||
|
window.addEventListener('dragover', onWindowDragOver);
|
||||||
|
window.addEventListener('drop', onWindowDrop);
|
||||||
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
window.removeEventListener('dragenter', onWindowDragEnter);
|
||||||
|
window.removeEventListener('dragleave', onWindowDragLeave);
|
||||||
|
window.removeEventListener('dragover', onWindowDragOver);
|
||||||
|
window.removeEventListener('drop', onWindowDrop);
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -133,7 +234,7 @@ watch([searchQuery, selectedStatus, limit, page], () => {
|
|||||||
]" />
|
]" />
|
||||||
|
|
||||||
<VideoBulkActions :selectedVideos="selectedVideos" @delete="deleteSelectedVideos" @clear="selectedVideos = []" />
|
<VideoBulkActions :selectedVideos="selectedVideos" @delete="deleteSelectedVideos" @clear="selectedVideos = []" />
|
||||||
<VideoFilters :loading="loading" v-model:searchQuery="searchQuery" :selectedStatus="selectedStatus" v-model:viewMode="viewMode"
|
<VideoFilters :loading="loading" v-model:searchQuery="searchQuery" :selectedStatus="selectedStatus"
|
||||||
v-model:page="page" v-model:limit="limit" :total="total" ref="videoFilters" :statusOptions="statusOptions"
|
v-model:page="page" v-model:limit="limit" :total="total" ref="videoFilters" :statusOptions="statusOptions"
|
||||||
@search="handleSearch" @filter="handleFilter" />
|
@search="handleSearch" @filter="handleFilter" />
|
||||||
|
|
||||||
@@ -154,10 +255,38 @@ watch([searchQuery, selectedStatus, limit, page], () => {
|
|||||||
imageUrl="https://cdn-icons-png.flaticon.com/512/7486/7486747.png" actionLabel="Upload Video"
|
imageUrl="https://cdn-icons-png.flaticon.com/512/7486/7486747.png" actionLabel="Upload Video"
|
||||||
:onAction="() => router.push('/upload')" />
|
:onAction="() => router.push('/upload')" />
|
||||||
<!-- Grid View -->
|
<!-- Grid View -->
|
||||||
<VideoGrid :videos="videos" :loading="loading" v-model:selectedVideos="selectedVideos" @delete="deleteVideo" v-else-if="viewMode === 'grid'" />
|
<!-- <VideoGrid :videos="videos" :loading="loading" v-model:selectedVideos="selectedVideos" @delete="deleteVideo" v-else-if="viewMode === 'grid'" /> -->
|
||||||
|
|
||||||
<!-- Table View -->
|
<!-- Table View -->
|
||||||
<VideoTable v-else :videos="videos" :loading="loading" v-model:selectedVideos="selectedVideos" @delete="deleteVideo" />
|
<VideoTable v-else :videos="videos" :loading="loading" v-model:selectedVideos="selectedVideos" @delete="deleteVideo" @edit="editVideo" @copy="copyVideo" />
|
||||||
</Transition>
|
</Transition>
|
||||||
|
<DetailVideoModal :videoId="detailVideoId" @close="detailVideoId = ''"/>
|
||||||
|
<CopyVideoModal :videoId="copyVideoId" @close="copyVideoId = ''"/>
|
||||||
|
|
||||||
|
<!-- Global drag & drop overlay -->
|
||||||
|
<ClientOnly>
|
||||||
|
<Teleport to="body">
|
||||||
|
<div v-if="isDraggingOver"
|
||||||
|
class="fixed inset-0 z-[9999] flex flex-col items-center justify-center pointer-events-none"
|
||||||
|
aria-hidden="true">
|
||||||
|
<!-- Backdrop -->
|
||||||
|
<div class="absolute inset-0 bg-primary/10 backdrop-blur-[2px]" />
|
||||||
|
<!-- Card -->
|
||||||
|
<div class="animate-spring-card relative flex flex-col items-center gap-3 select-none">
|
||||||
|
<div class="w-16 h-16 rounded-2xl bg-white shadow-lg flex items-center justify-center">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="w-8 h-8 text-primary" fill="none" viewBox="0 0 24 24"
|
||||||
|
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>
|
||||||
|
</div>
|
||||||
|
<p class="text-lg font-semibold text-primary">Drop to upload</p>
|
||||||
|
<p class="text-sm text-primary/70">Files will be added to the upload queue</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
</ClientOnly>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { defineProps, defineEmits } from 'vue';
|
|
||||||
import type { ModelVideo } from '@/api/client';
|
import type { ModelVideo } from '@/api/client';
|
||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
|
|||||||
@@ -1,8 +1,13 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { getStatusSeverity } from '@/lib/utils';
|
||||||
|
import IconField from 'primevue/iconfield';
|
||||||
|
import InputIcon from 'primevue/inputicon';
|
||||||
|
import InputText from 'primevue/inputtext';
|
||||||
|
import Select from 'primevue/select';
|
||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
searchQuery: string;
|
searchQuery: string;
|
||||||
selectedStatus: string;
|
selectedStatus: string;
|
||||||
viewMode: 'grid' | 'table';
|
|
||||||
statusOptions: { label: string; value: string }[];
|
statusOptions: { label: string; value: string }[];
|
||||||
total: number;
|
total: number;
|
||||||
page: number; // 1-based index
|
page: number; // 1-based index
|
||||||
@@ -13,7 +18,6 @@ defineProps<{
|
|||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
(e: 'update:searchQuery', value: string): void;
|
(e: 'update:searchQuery', value: string): void;
|
||||||
(e: 'update:selectedStatus', value: string): void;
|
(e: 'update:selectedStatus', value: string): void;
|
||||||
(e: 'update:viewMode', value: 'grid' | 'table'): void;
|
|
||||||
(e: 'update:page', value: number): void;
|
(e: 'update:page', value: number): void;
|
||||||
(e: 'update:limit', value: number): void;
|
(e: 'update:limit', value: number): void;
|
||||||
(e: 'search'): void;
|
(e: 'search'): void;
|
||||||
@@ -22,84 +26,45 @@ const emit = defineEmits<{
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="border-b border-gray-200 mb-6">
|
<div class="border-b border-gray-200 mb-6">
|
||||||
<div class="flex flex-col md:flex-row gap-4">
|
<div class="flex flex-col md:flex-row gap-3 items-stretch md:items-center">
|
||||||
<!-- Search -->
|
<!-- Search -->
|
||||||
<div class="flex-1 bg-white rounded-lg">
|
<IconField class="flex-1">
|
||||||
<div class="relative">
|
<InputIcon>
|
||||||
<svg xmlns="http://www.w3.org/2000/svg"
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/></svg>
|
||||||
class="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400"
|
</InputIcon>
|
||||||
viewBox="-10 -258 534 534">
|
<InputText :modelValue="searchQuery"
|
||||||
<path
|
@update:modelValue="emit('update:searchQuery', $event as string)"
|
||||||
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"
|
@keyup.enter="emit('search')" placeholder="Search videos..." fluid />
|
||||||
fill="#1e3050" />
|
</IconField>
|
||||||
</svg>
|
|
||||||
<input :value="searchQuery"
|
|
||||||
@input="emit('update:searchQuery', ($event.target as HTMLInputElement).value)"
|
|
||||||
@keyup.enter="emit('search')" type="text" placeholder="Search videos by title or description..."
|
|
||||||
class="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Status Filter -->
|
<!-- Status Filter -->
|
||||||
<FloatLabel class="w-full md:w-56" variant="on">
|
<Select :modelValue="selectedStatus" @update:modelValue="emit('update:selectedStatus', $event)"
|
||||||
<Select :modelValue="selectedStatus" @update:modelValue="emit('update:selectedStatus', $event)"
|
:options="statusOptions" optionLabel="label" optionValue="value" placeholder="Status"
|
||||||
inputId="on_label" :options="statusOptions" optionLabel="label" optionValue="value"
|
class="w-full md:w-44">
|
||||||
class="w-full" />
|
<template #option="slotProps">
|
||||||
<label for="on_label">Status</label>
|
<Tag :value="slotProps.option.label" :severity="getStatusSeverity(slotProps.option.value)"
|
||||||
</FloatLabel>
|
class="capitalize" />
|
||||||
|
</template>
|
||||||
<!-- View Mode Toggle -->
|
</Select>
|
||||||
<div class="flex items-center gap-2 bg-slate-200 rounded-lg p-1">
|
|
||||||
<button @click="emit('update:viewMode', 'table')" :class="[
|
|
||||||
'px-3 py-1.5 rounded transition-colors',
|
|
||||||
viewMode === 'table' ? 'bg-white shadow-sm' : 'hover:bg-gray-200'
|
|
||||||
]" title="Table view">
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5"
|
|
||||||
:class="viewMode === 'table' ? 'text-primary' : 'text-gray-600'" fill="none" viewBox="0 0 24 24"
|
|
||||||
stroke="currentColor">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
|
||||||
d="M4 6h16M4 10h16M4 14h16M4 18h16" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
<button @click="emit('update:viewMode', 'grid')" :class="[
|
|
||||||
'px-3 py-1.5 rounded transition-colors',
|
|
||||||
viewMode === 'grid' ? 'bg-white shadow-sm' : 'hover:bg-gray-200'
|
|
||||||
]" title="Grid view">
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5"
|
|
||||||
:class="viewMode === 'grid' ? 'text-primary' : 'text-gray-600'" fill="none" viewBox="0 0 24 24"
|
|
||||||
stroke="currentColor">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
|
||||||
d="M4 4h6v6H4V4zm0 10h6v6H4v-6zm10-10h6v6h-6V4zm0 10h6v6h-6v-6z" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<Paginator :pt="{
|
|
||||||
root: '!bg-transparent !p-0 !justify-end !mt-2'
|
<!-- Paginator -->
|
||||||
}" :rows="limit" :totalRecords="total" :first="(page - 1) * limit" :rowsPerPageOptions="[10, 20, 30]"
|
<Paginator :pt="{ root: '!bg-transparent !p-0 !justify-end !mt-3 !mb-2' }" :rows="limit" :totalRecords="total"
|
||||||
|
:first="(page - 1) * limit" :rowsPerPageOptions="[10, 20, 30]"
|
||||||
@page="(e) => { emit('update:page', e.page + 1); emit('update:limit', e.rows); }">
|
@page="(e) => { emit('update:page', e.page + 1); emit('update:limit', e.rows); }">
|
||||||
<template #container="{ first, last, page, pageCount, prevPageCallback, nextPageCallback, totalRecords }">
|
<template #container="{ first, last, page, pageCount, prevPageCallback, nextPageCallback, totalRecords }">
|
||||||
<div class="flex items-center gap-2 bg-transparent px-2 justify-between sm:w-auto">
|
<div class="flex justify-end w-full gap-2">
|
||||||
<div class="text-sm text-gray-500">
|
<Tag severity="secondary" size="small" rounded>
|
||||||
<span class="hidden sm:block">{{ first }} - {{ last }} of {{ totalRecords }} results</span>
|
{{ first }}–{{ last }} of {{ totalRecords }}
|
||||||
<span class="block sm:hidden">Page {{ page + 1 }} of {{ pageCount }}</span>
|
</Tag>
|
||||||
</div>
|
|
||||||
<div class="flex items-center gap-1">
|
<div class="flex items-center gap-1">
|
||||||
<Button rounded variant="text" @click="prevPageCallback" :disabled="page === 0"
|
<Button rounded variant="text" size="small"
|
||||||
title="previous">
|
@click="prevPageCallback" :disabled="page === 0" aria-label="Previous page">
|
||||||
<!-- <span class="i-heroicons-chevron-left w-5 h-5" /> -->
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m15 18-6-6 6-6"/></svg>
|
||||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
|
||||||
d="M15 19l-7-7 7-7" />
|
|
||||||
</svg>
|
|
||||||
</Button>
|
</Button>
|
||||||
<Button rounded variant="text" @click="nextPageCallback" :disabled="page === pageCount! - 1"
|
<Button rounded variant="text" size="small"
|
||||||
title="next">
|
@click="nextPageCallback" :disabled="page === pageCount! - 1" aria-label="Next page">
|
||||||
<!-- <span class="i-heroicons-chevron-right w-5 h-5" /> -->
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m9 18 6-6-6-6"/></svg>
|
||||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
|
||||||
d="M9 5l7 7-7 7" />
|
|
||||||
</svg>
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { ModelVideo } from '@/api/client';
|
import type { ModelVideo } from '@/api/client';
|
||||||
import { formatBytes, formatDate, formatDuration, getStatusSeverity } from '@/lib/utils';
|
|
||||||
import ArrowDownTray from '@/components/icons/ArrowDownTray.vue';
|
|
||||||
import LinkIcon from '@/components/icons/LinkIcon.vue';
|
import LinkIcon from '@/components/icons/LinkIcon.vue';
|
||||||
import PencilIcon from '@/components/icons/PencilIcon.vue';
|
import PencilIcon from '@/components/icons/PencilIcon.vue';
|
||||||
import TrashIcon from '@/components/icons/TrashIcon.vue';
|
import TrashIcon from '@/components/icons/TrashIcon.vue';
|
||||||
import VideoIcon from '@/components/icons/VideoIcon.vue';
|
import VideoIcon from '@/components/icons/VideoIcon.vue';
|
||||||
|
import { formatBytes, formatDate, getStatusSeverity } from '@/lib/utils';
|
||||||
|
import Button from 'primevue/button';
|
||||||
import Column from 'primevue/column';
|
import Column from 'primevue/column';
|
||||||
import DataTable from 'primevue/datatable';
|
import DataTable from 'primevue/datatable';
|
||||||
|
|
||||||
@@ -18,25 +18,28 @@ defineProps<{
|
|||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
(e: 'update:selectedVideos', value: ModelVideo[]): void;
|
(e: 'update:selectedVideos', value: ModelVideo[]): void;
|
||||||
(e: 'delete', videoId: string): void;
|
(e: 'delete', videoId: string): void;
|
||||||
|
(e: 'edit', videoId: string): void;
|
||||||
|
(e: 'copy', videoId: string): void;
|
||||||
}>();
|
}>();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="bg-white rounded-xl border border-gray-200 overflow-hidden">
|
<div class="rounded-xl border border-gray-200 overflow-hidden">
|
||||||
<div v-if="loading">
|
<div v-if="loading">
|
||||||
<div class="p-4 border-b border-gray-200" v-for="i in 10" :key="i">
|
<div class="p-4 border-b border-gray-200 last:border-b-0" v-for="i in 10" :key="i">
|
||||||
<div class="flex gap-4 items-center">
|
<div class="flex gap-4 items-center">
|
||||||
<Skeleton width="5rem" height="3rem" class="rounded"></Skeleton>
|
<Skeleton width="5rem" height="3rem" borderRadius="6px" />
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<Skeleton width="40%" height="1.2rem" class="mb-2"></Skeleton>
|
<Skeleton width="40%" height="1rem" class="mb-2" />
|
||||||
<Skeleton width="30%" height="1rem"></Skeleton>
|
<Skeleton width="25%" height="0.75rem" />
|
||||||
</div>
|
</div>
|
||||||
<Skeleton width="10%" height="1rem"></Skeleton>
|
<Skeleton width="8%" height="0.75rem" />
|
||||||
<Skeleton width="10%" height="1rem"></Skeleton>
|
<Skeleton width="8%" height="0.75rem" />
|
||||||
<Skeleton width="5rem" height="2rem" borderRadius="16px"></Skeleton>
|
<Skeleton width="4rem" height="1.5rem" borderRadius="16px" />
|
||||||
|
<Skeleton width="5.5rem" height="1.75rem" borderRadius="6px" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<DataTable v-else :value="videos" dataKey="id" tableStyle="min-width: 50rem" :selection="selectedVideos"
|
<DataTable v-else :value="videos" dataKey="id" tableStyle="min-width: 50rem" :selection="selectedVideos"
|
||||||
@update:selection="emit('update:selectedVideos', $event)">
|
@update:selection="emit('update:selectedVideos', $event)">
|
||||||
<Column selectionMode="multiple" headerStyle="width: 3rem"></Column>
|
<Column selectionMode="multiple" headerStyle="width: 3rem"></Column>
|
||||||
@@ -86,28 +89,19 @@ const emit = defineEmits<{
|
|||||||
|
|
||||||
<Column header="Actions">
|
<Column header="Actions">
|
||||||
<template #body="{ data }">
|
<template #body="{ data }">
|
||||||
<div class="flex items-center gap-1">
|
<div class="flex items-center gap-0.5">
|
||||||
<button
|
<Button text rounded size="small" severity="secondary" title="Copy link"
|
||||||
class="p-1.5 text-gray-400 hover:text-primary hover:bg-primary/5 rounded transition-colors"
|
@click="emit('copy', data.id)">
|
||||||
title="Download">
|
|
||||||
<ArrowDownTray class="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class="p-1.5 text-gray-400 hover:text-primary hover:bg-primary/5 rounded transition-colors"
|
|
||||||
title="Copy Link">
|
|
||||||
<LinkIcon class="w-4 h-4" />
|
<LinkIcon class="w-4 h-4" />
|
||||||
</button>
|
</Button>
|
||||||
<div class="w-px h-3 bg-gray-200 mx-1"></div>
|
<Button text rounded size="small" title="Edit"
|
||||||
<router-link :to="{ name: 'video-detail', params: { id: data.id } }"
|
@click="emit('edit', data.id)">
|
||||||
class="p-1.5 text-gray-400 hover:text-blue-600 hover:bg-blue-50 rounded transition-colors inline-block"
|
|
||||||
title="Edit">
|
|
||||||
<PencilIcon class="w-4 h-4" />
|
<PencilIcon class="w-4 h-4" />
|
||||||
</router-link>
|
</Button>
|
||||||
<button @click="emit('delete', data.id)"
|
<Button text rounded size="small" severity="danger" title="Delete"
|
||||||
class="p-1.5 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded transition-colors"
|
@click="emit('delete', data.id)">
|
||||||
title="Delete">
|
|
||||||
<TrashIcon class="w-4 h-4" />
|
<TrashIcon class="w-4 h-4" />
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</Column>
|
</Column>
|
||||||
|
|||||||
@@ -214,3 +214,19 @@ export function streamManifest(manifest: Manifest): ReadableStream<Uint8Array> {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
export async function saveImageFromStream(stream: ArrayBuffer, filename: string): Promise<void> {
|
||||||
|
// Implement this function to save the thumbnail image stream to storage and update the database with the thumbnail URL
|
||||||
|
const url = `${S3_ENDPOINT}/${BUCKET_NAME}/${filename}.jpg`;
|
||||||
|
|
||||||
|
const response = await aws.fetch(url, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'image/jpeg',
|
||||||
|
},
|
||||||
|
body: stream,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to save thumbnail: ${response.status} ${await response.text()}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
23
src/server/routes/display.ts
Normal file
23
src/server/routes/display.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import type { Hono } from 'hono';
|
||||||
|
import { saveImageFromStream } from '../modules/merge';
|
||||||
|
|
||||||
|
export function registerDisplayRoutes(app: Hono) {
|
||||||
|
// app.get('/manifest/:id', async (c) => {
|
||||||
|
// const manifest = await getListFiles();
|
||||||
|
// if (!manifest) {
|
||||||
|
// return c.json({ error: "Manifest not found" }, 404);
|
||||||
|
// }
|
||||||
|
// return c.json(manifest);
|
||||||
|
// });
|
||||||
|
app.put('/display/:id/thumbnail', async (c) => {
|
||||||
|
const arrayBuffer = await c.req.arrayBuffer();
|
||||||
|
await saveImageFromStream(arrayBuffer, crypto.randomUUID());
|
||||||
|
return c.body('ok');
|
||||||
|
// nhận rawData, lưu vào storage, cập nhật url thumbnail vào database
|
||||||
|
|
||||||
|
});
|
||||||
|
app.put('/display/:id/metadata', async (c) => {
|
||||||
|
|
||||||
|
});
|
||||||
|
app.post('/display/:id/subs', async (c) => {});
|
||||||
|
}
|
||||||
@@ -216,6 +216,9 @@ export default defineConfig({
|
|||||||
],
|
],
|
||||||
["animate-loadingBar", ["animation", "loadingBar 1.5s linear infinite"]],
|
["animate-loadingBar", ["animation", "loadingBar 1.5s linear infinite"]],
|
||||||
],
|
],
|
||||||
|
rules: [
|
||||||
|
['animate-spring-card', { animation: 'card-spring-in 0.32s cubic-bezier(0.34, 1.56, 0.64, 1) both' }],
|
||||||
|
],
|
||||||
transformers: [transformerVariantGroup(), transformerCompileClass({
|
transformers: [transformerVariantGroup(), transformerCompileClass({
|
||||||
classPrefix: "_",
|
classPrefix: "_",
|
||||||
})],
|
})],
|
||||||
@@ -265,6 +268,12 @@ export default defineConfig({
|
|||||||
.fade-leave-to {
|
.fade-leave-to {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
}
|
}
|
||||||
|
@keyframes card-spring-in {
|
||||||
|
0% { transform: scale(0.82) translateY(12px); opacity: 0; }
|
||||||
|
60% { transform: scale(1.04) translateY(-2px); opacity: 1; }
|
||||||
|
80% { transform: scale(0.98) translateY(1px); }
|
||||||
|
100% { transform: scale(1) translateY(0); }
|
||||||
|
}
|
||||||
@keyframes glow-enter-blur {
|
@keyframes glow-enter-blur {
|
||||||
0% {
|
0% {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
|
|||||||
Reference in New Issue
Block a user