feat(video): enhance video management UI and functionality

- Refactor VideoBulkActions.vue to remove unused imports.
- Update VideoFilters.vue to improve search and status filtering with new UI components from PrimeVue.
- Modify VideoTable.vue to enhance action buttons for editing, copying, and deleting videos, using PrimeVue Button components.
- Implement saveImageFromStream function in merge.ts to handle thumbnail image uploads.
- Add new animation rule for card spring effect in uno.config.ts.
- Create FileUploadType.vue icon component for local and remote file uploads.
- Introduce CopyVideoModal.vue for sharing video links with enhanced user experience.
- Add DetailVideoModal.vue for editing video details with form validation using Zod.
- Establish new display routes in display.ts for handling thumbnail and metadata updates.
This commit is contained in:
2026-02-27 18:07:43 +07:00
parent a5b4028bc8
commit c6924afe5b
25 changed files with 906 additions and 231 deletions

6
components.d.ts vendored
View File

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

View File

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

View File

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

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

View File

@@ -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 }>();

View File

@@ -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) => {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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",
}, // },
}, // },
}, // },
], ],
}, },
{ {

View File

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

View File

@@ -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 = () => {

View File

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

View File

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

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

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

View File

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

View File

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

View File

@@ -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 }}&ndash;{{ 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>

View File

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

View File

@@ -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()}`)
}
}

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

View File

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