feat: Add CardPopover component for video actions and integrate EllipsisVerticalIcon
This commit is contained in:
32
components.d.ts
vendored
32
components.d.ts
vendored
@@ -17,7 +17,9 @@ declare module 'vue' {
|
||||
ArrowDownTray: typeof import('./src/components/icons/ArrowDownTray.vue')['default']
|
||||
ArrowRightIcon: typeof import('./src/components/icons/ArrowRightIcon.vue')['default']
|
||||
Bell: typeof import('./src/components/icons/Bell.vue')['default']
|
||||
Button: typeof import('primevue/button')['default']
|
||||
Chart: typeof import('./src/components/icons/Chart.vue')['default']
|
||||
Checkbox: typeof import('primevue/checkbox')['default']
|
||||
CheckCircleIcon: typeof import('./src/components/icons/CheckCircleIcon.vue')['default']
|
||||
CheckIcon: typeof import('./src/components/icons/CheckIcon.vue')['default']
|
||||
CheckMarkIcon: typeof import('./src/components/icons/CheckMarkIcon.vue')['default']
|
||||
@@ -26,23 +28,37 @@ declare module 'vue' {
|
||||
CreditCardIcon: typeof import('./src/components/icons/CreditCardIcon.vue')['default']
|
||||
DashboardLayout: typeof import('./src/components/DashboardLayout.vue')['default']
|
||||
DashboardNav: typeof import('./src/components/DashboardNav.vue')['default']
|
||||
EllipsisVerticalIcon: typeof import('./src/components/icons/EllipsisVerticalIcon.vue')['default']
|
||||
EmptyState: typeof import('./src/components/dashboard/EmptyState.vue')['default']
|
||||
FloatLabel: typeof import('primevue/floatlabel')['default']
|
||||
GlobalUploadIndicator: typeof import('./src/components/GlobalUploadIndicator.vue')['default']
|
||||
HardDriveUpload: typeof import('./src/components/icons/HardDriveUpload.vue')['default']
|
||||
Home: typeof import('./src/components/icons/Home.vue')['default']
|
||||
IconField: typeof import('primevue/iconfield')['default']
|
||||
InfoIcon: typeof import('./src/components/icons/InfoIcon.vue')['default']
|
||||
InputGroup: typeof import('primevue/inputgroup')['default']
|
||||
InputGroupAddon: typeof import('primevue/inputgroupaddon')['default']
|
||||
InputIcon: typeof import('primevue/inputicon')['default']
|
||||
InputText: typeof import('primevue/inputtext')['default']
|
||||
Layout: typeof import('./src/components/icons/Layout.vue')['default']
|
||||
LinkIcon: typeof import('./src/components/icons/LinkIcon.vue')['default']
|
||||
Menu: typeof import('primevue/menu')['default']
|
||||
Message: typeof import('primevue/message')['default']
|
||||
NotificationDrawer: typeof import('./src/components/NotificationDrawer.vue')['default']
|
||||
PageHeader: typeof import('./src/components/dashboard/PageHeader.vue')['default']
|
||||
Paginator: typeof import('primevue/paginator')['default']
|
||||
PanelLeft: typeof import('./src/components/icons/PanelLeft.vue')['default']
|
||||
Password: typeof import('primevue/password')['default']
|
||||
PencilIcon: typeof import('./src/components/icons/PencilIcon.vue')['default']
|
||||
Popover: typeof import('primevue/popover')['default']
|
||||
RootLayout: typeof import('./src/components/RootLayout.vue')['default']
|
||||
RouterLink: typeof import('vue-router')['RouterLink']
|
||||
RouterView: typeof import('vue-router')['RouterView']
|
||||
Select: typeof import('primevue/select')['default']
|
||||
SettingsIcon: typeof import('./src/components/icons/SettingsIcon.vue')['default']
|
||||
Skeleton: typeof import('primevue/skeleton')['default']
|
||||
StatsCard: typeof import('./src/components/dashboard/StatsCard.vue')['default']
|
||||
Tag: typeof import('primevue/tag')['default']
|
||||
TestIcon: typeof import('./src/components/icons/TestIcon.vue')['default']
|
||||
TrashIcon: typeof import('./src/components/icons/TrashIcon.vue')['default']
|
||||
Upload: typeof import('./src/components/icons/Upload.vue')['default']
|
||||
@@ -60,7 +76,9 @@ declare global {
|
||||
const ArrowDownTray: typeof import('./src/components/icons/ArrowDownTray.vue')['default']
|
||||
const ArrowRightIcon: typeof import('./src/components/icons/ArrowRightIcon.vue')['default']
|
||||
const Bell: typeof import('./src/components/icons/Bell.vue')['default']
|
||||
const Button: typeof import('primevue/button')['default']
|
||||
const Chart: typeof import('./src/components/icons/Chart.vue')['default']
|
||||
const Checkbox: typeof import('primevue/checkbox')['default']
|
||||
const CheckCircleIcon: typeof import('./src/components/icons/CheckCircleIcon.vue')['default']
|
||||
const CheckIcon: typeof import('./src/components/icons/CheckIcon.vue')['default']
|
||||
const CheckMarkIcon: typeof import('./src/components/icons/CheckMarkIcon.vue')['default']
|
||||
@@ -69,23 +87,37 @@ declare global {
|
||||
const CreditCardIcon: typeof import('./src/components/icons/CreditCardIcon.vue')['default']
|
||||
const DashboardLayout: typeof import('./src/components/DashboardLayout.vue')['default']
|
||||
const DashboardNav: typeof import('./src/components/DashboardNav.vue')['default']
|
||||
const EllipsisVerticalIcon: typeof import('./src/components/icons/EllipsisVerticalIcon.vue')['default']
|
||||
const EmptyState: typeof import('./src/components/dashboard/EmptyState.vue')['default']
|
||||
const FloatLabel: typeof import('primevue/floatlabel')['default']
|
||||
const GlobalUploadIndicator: typeof import('./src/components/GlobalUploadIndicator.vue')['default']
|
||||
const HardDriveUpload: typeof import('./src/components/icons/HardDriveUpload.vue')['default']
|
||||
const Home: typeof import('./src/components/icons/Home.vue')['default']
|
||||
const IconField: typeof import('primevue/iconfield')['default']
|
||||
const InfoIcon: typeof import('./src/components/icons/InfoIcon.vue')['default']
|
||||
const InputGroup: typeof import('primevue/inputgroup')['default']
|
||||
const InputGroupAddon: typeof import('primevue/inputgroupaddon')['default']
|
||||
const InputIcon: typeof import('primevue/inputicon')['default']
|
||||
const InputText: typeof import('primevue/inputtext')['default']
|
||||
const Layout: typeof import('./src/components/icons/Layout.vue')['default']
|
||||
const LinkIcon: typeof import('./src/components/icons/LinkIcon.vue')['default']
|
||||
const Menu: typeof import('primevue/menu')['default']
|
||||
const Message: typeof import('primevue/message')['default']
|
||||
const NotificationDrawer: typeof import('./src/components/NotificationDrawer.vue')['default']
|
||||
const PageHeader: typeof import('./src/components/dashboard/PageHeader.vue')['default']
|
||||
const Paginator: typeof import('primevue/paginator')['default']
|
||||
const PanelLeft: typeof import('./src/components/icons/PanelLeft.vue')['default']
|
||||
const Password: typeof import('primevue/password')['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 RouterLink: typeof import('vue-router')['RouterLink']
|
||||
const RouterView: typeof import('vue-router')['RouterView']
|
||||
const Select: typeof import('primevue/select')['default']
|
||||
const SettingsIcon: typeof import('./src/components/icons/SettingsIcon.vue')['default']
|
||||
const Skeleton: typeof import('primevue/skeleton')['default']
|
||||
const StatsCard: typeof import('./src/components/dashboard/StatsCard.vue')['default']
|
||||
const Tag: typeof import('primevue/tag')['default']
|
||||
const TestIcon: typeof import('./src/components/icons/TestIcon.vue')['default']
|
||||
const TrashIcon: typeof import('./src/components/icons/TrashIcon.vue')['default']
|
||||
const Upload: typeof import('./src/components/icons/Upload.vue')['default']
|
||||
|
||||
5
src/components/icons/EllipsisVerticalIcon.vue
Normal file
5
src/components/icons/EllipsisVerticalIcon.vue
Normal file
@@ -0,0 +1,5 @@
|
||||
<template>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 512" fill="currentColor">
|
||||
<path d="M64 360a56 56 0 1 0 0 112 56 56 0 1 0 0-112zm0-160a56 56 0 1 0 0 112 56 56 0 1 0 0-112zM120 96A56 56 0 1 0 8 96a56 56 0 1 0 112 0z"/>
|
||||
</svg>
|
||||
</template>
|
||||
139
src/routes/video/components/CardPopover.vue
Normal file
139
src/routes/video/components/CardPopover.vue
Normal file
@@ -0,0 +1,139 @@
|
||||
|
||||
<template>
|
||||
<div class="card flex justify-center">
|
||||
<Button type="button" class="!border-none" @click="toggle" severity="secondary" variant="text" aria-haspopup="true" aria-controls="overlay_menu">
|
||||
<EllipsisVerticalIcon class="w-4 h-4 text-gray-500" />
|
||||
</Button>
|
||||
<Menu ref="menu" id="overlay_menu" :model="items as any" :popup="true" class="min-w-[160px]">
|
||||
<template #item="{ item, props }">
|
||||
<router-link v-if="(item as any).route" v-bind="props.action" :to="(item as any).route" class="flex items-center gap-2 px-3 py-2 hover:bg-gray-100 rounded cursor-pointer">
|
||||
<component :is="(item as any).icon" class="w-4 h-4" :class="(item as any).iconClass" />
|
||||
<span :class="(item as any).labelClass">{{ item.label }}</span>
|
||||
</router-link>
|
||||
<a v-else-if="!(item as any).separator" v-bind="props.action" @click="(item as any).command" class="flex items-center gap-2 px-3 py-2 hover:bg-gray-100 rounded cursor-pointer">
|
||||
<component :is="(item as any).icon" class="w-4 h-4" :class="(item as any).iconClass" />
|
||||
<span :class="(item as any).labelClass">{{ item.label }}</span>
|
||||
</a>
|
||||
</template>
|
||||
</Menu>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { DefineComponent } from "vue";
|
||||
import ArrowDownTray from "@/components/icons/ArrowDownTray.vue";
|
||||
import LinkIcon from "@/components/icons/LinkIcon.vue";
|
||||
import PencilIcon from "@/components/icons/PencilIcon.vue";
|
||||
import TrashIcon from "@/components/icons/TrashIcon.vue";
|
||||
import EllipsisVerticalIcon from "@/components/icons/EllipsisVerticalIcon.vue";
|
||||
import type { ModelVideo } from '@/api/client';
|
||||
import { useToast } from "primevue/usetoast";
|
||||
import Menu from "primevue/menu";
|
||||
import { computed, ref, shallowRef } from "vue";
|
||||
import type { RouteLocationRaw } from "vue-router";
|
||||
|
||||
const props = defineProps<{
|
||||
video: ModelVideo
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'delete'): void;
|
||||
}>();
|
||||
|
||||
const toast = useToast();
|
||||
const menu = ref<InstanceType<typeof Menu>>();
|
||||
|
||||
const videoUrl = computed(() => {
|
||||
return `${window.location.origin}/videos/${props.video.id}`;
|
||||
});
|
||||
|
||||
const handleCopyLink = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(videoUrl.value);
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: 'Thành công',
|
||||
detail: 'Đã sao chép link video',
|
||||
life: 3000
|
||||
});
|
||||
} catch {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: 'Lỗi',
|
||||
detail: 'Không thể sao chép link',
|
||||
life: 3000
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownload = () => {
|
||||
if (props.video.id) {
|
||||
const link = document.createElement('a');
|
||||
link.href = props.video.hls_path || videoUrl.value;
|
||||
link.download = props.video.title || 'video';
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: 'Thành công',
|
||||
detail: 'Đang tải xuống video...',
|
||||
life: 3000
|
||||
});
|
||||
} else {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: 'Lỗi',
|
||||
detail: 'Không tìm thấy file video',
|
||||
life: 3000
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = () => {
|
||||
emit('delete');
|
||||
};
|
||||
|
||||
interface CustomMenuItem {
|
||||
label?: string;
|
||||
icon?: DefineComponent<{}, {}, any>;
|
||||
iconClass?: string;
|
||||
labelClass?: string;
|
||||
separator?: boolean;
|
||||
route?: RouteLocationRaw;
|
||||
command?: () => void;
|
||||
}
|
||||
|
||||
const items = shallowRef<CustomMenuItem[]>([
|
||||
{
|
||||
label: 'Tải xuống',
|
||||
icon: ArrowDownTray,
|
||||
command: handleDownload
|
||||
},
|
||||
{
|
||||
label: 'Sao chép link',
|
||||
icon: LinkIcon,
|
||||
command: handleCopyLink
|
||||
},
|
||||
{
|
||||
separator: true
|
||||
},
|
||||
{
|
||||
label: 'Chỉnh sửa',
|
||||
icon: PencilIcon,
|
||||
route: { name: 'video-detail', params: { id: props.video.id } }
|
||||
},
|
||||
{
|
||||
label: 'Xóa',
|
||||
icon: TrashIcon,
|
||||
iconClass: 'text-red-500',
|
||||
labelClass: 'text-red-500',
|
||||
command: handleDelete
|
||||
}
|
||||
]);
|
||||
|
||||
const toggle = (event: Event) => {
|
||||
menu.value?.toggle(event);
|
||||
};
|
||||
</script>
|
||||
@@ -3,6 +3,7 @@ import type { ModelVideo } from '@/api/client';
|
||||
import { formatDate, formatDuration, getStatusSeverity } from '@/lib/utils';
|
||||
import Card from 'primevue/card';
|
||||
import Checkbox from 'primevue/checkbox';
|
||||
import CardPopover from './CardPopover.vue';
|
||||
|
||||
defineProps<{
|
||||
videos: ModelVideo[];
|
||||
@@ -30,7 +31,7 @@ const emit = defineEmits<{
|
||||
</div>
|
||||
</div>
|
||||
<Card v-for="video in videos" :key="video.id" v-else
|
||||
class="overflow-hidden shadow-sm hover:shadow-md transition-shadow group relative border border-gray-200"
|
||||
class="overflow-hidden transition group relative border-2 border-gray-200 !shadow-none"
|
||||
:class="{ '!border-primary ring-2 ring-primary': selectedVideos.some(v => v.id === video.id) }">
|
||||
|
||||
<template #header>
|
||||
@@ -74,14 +75,16 @@ const emit = defineEmits<{
|
||||
|
||||
<p class="text-xs text-gray-500 mb-3 line-clamp-1 h-4">{{ video.description || 'No description' }}
|
||||
</p>
|
||||
|
||||
<div class="mt-auto flex items-center justify-between">
|
||||
<Tag :value="video.status" :severity="getStatusSeverity(video.status)"
|
||||
class="capitalize px-2 py-0.5 text-xs" />
|
||||
<div class="text-[10px] text-gray-400">
|
||||
<div class="text-xs text-gray-400 mt-auto">
|
||||
{{ formatDate(video.created_at) }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #footer>
|
||||
<div class="mt-auto flex items-center justify-between">
|
||||
<Tag :value="video.status" :severity="getStatusSeverity(video.status)"
|
||||
class="capitalize px-2 py-0.5 text-xs" />
|
||||
<CardPopover :video="video" @delete="emit('delete', video.id || '')"/>
|
||||
</div>
|
||||
</template>
|
||||
</Card>
|
||||
|
||||
Reference in New Issue
Block a user