feat: Implement TinyMqttClient for MQTT communication and enhance video components with loading states

This commit is contained in:
2026-02-07 21:56:05 +07:00
parent 4d41d6540a
commit 66028d934a
11 changed files with 217 additions and 104 deletions

View File

@@ -2,6 +2,7 @@ import { hydrateQueryCache } from '@pinia/colada';
import 'uno.css'; import 'uno.css';
import PiniaSharedState from './lib/PiniaSharedState'; import PiniaSharedState from './lib/PiniaSharedState';
import { createApp } from './main'; import { createApp } from './main';
async function render() { async function render() {
const { app, router, queryCache, pinia } = createApp(); const { app, router, queryCache, pinia } = createApp();
pinia.use(PiniaSharedState({enable: true, initialize: true})); pinia.use(PiniaSharedState({enable: true, initialize: true}));

118
src/lib/liteMqtt.ts Normal file
View File

@@ -0,0 +1,118 @@
export type MessageCallback = (topic: string, payload: string) => void;
export class TinyMqttClient {
private ws: WebSocket | null = null;
private encoder = new TextEncoder();
private decoder = new TextDecoder();
private worker: Worker | null = null;
constructor(
private url: string,
private topics: string[],
private onMessage: MessageCallback
) {}
public connect(): void {
this.ws = new WebSocket(this.url, 'mqtt');
this.ws.binaryType = 'arraybuffer';
this.ws.onopen = () => {
this.sendConnect();
};
this.ws.onmessage = (e) => this.handlePacket(new Uint8Array(e.data));
this.ws.onclose = () => this.stopHeartbeatWorker();
}
public disconnect(): void {
this.ws?.close();
this.stopHeartbeatWorker();
}
private sendConnect(): void {
const clientId = `ws_worker_${Math.random().toString(16).slice(2, 8)}`;
const idBytes = this.encoder.encode(clientId);
// Keep-alive 60s
const packet = new Uint8Array([
0x10, 12 + idBytes.length,
0x00, 0x04, 0x4d, 0x51, 0x54, 0x54, 0x04, 0x02, 0x00, 0x3c,
0x00, idBytes.length, ...idBytes
]);
this.ws?.send(packet);
}
private startHeartbeatWorker(): void {
if (this.worker) return;
// Tạo nội dung Worker dưới dạng chuỗi
const workerCode = `
let timer = null;
self.onmessage = (e) => {
if (e.data === 'START') {
timer = setInterval(() => self.postMessage('TICK'), 30000);
} else if (e.data === 'STOP') {
clearInterval(timer);
}
};
`;
const blob = new Blob([workerCode], { type: 'application/javascript' });
this.worker = new Worker(URL.createObjectURL(blob));
this.worker.onmessage = (e) => {
if (e.data === 'TICK' && this.ws?.readyState === WebSocket.OPEN) {
this.ws.send(new Uint8Array([0xC0, 0x00])); // Gửi PINGREQ
}
};
this.worker.postMessage('START');
}
private stopHeartbeatWorker(): void {
if (this.worker) {
this.worker.postMessage('STOP');
this.worker.terminate();
this.worker = null;
console.log('🛑 Worker stopped');
}
}
private handlePacket(data: Uint8Array): void {
const type = data[0] & 0xF0;
switch (type) {
case 0x20: // CONNACK
this.startHeartbeatWorker();
this.subscribe();
break;
case 0xD0: // PINGRESP
break;
case 0x30: // PUBLISH
this.parsePublish(data);
break;
}
}
private subscribe(): void {
let payload: number[] = [];
this.topics.forEach(t => {
const b = this.encoder.encode(t);
payload.push(0x00, b.length, ...Array.from(b), 0x00);
});
const packet = new Uint8Array([0x82, 2 + payload.length, 0x00, 0x01, ...payload]);
this.ws?.send(packet);
}
private parsePublish(data: Uint8Array): void {
const tLen = (data[2] << 8) | data[3];
const topic = this.decoder.decode(data.slice(4, 4 + tLen));
const payload = this.decoder.decode(data.slice(4 + tLen));
this.onMessage(topic, payload);
}
}
// --- Cách dùng ---
// const client = new TinyMqttClient(
// 'ws://your-emqx:8083',
// ['sensor/temp', 'sensor/humi', 'system/ping'],
// (topic, msg) => {
// console.log(`[${topic}]: ${msg}`);
// }
// );
// client.connect();

View File

@@ -45,7 +45,7 @@ export function createApp() {
pinia, pinia,
plugins: [ plugins: [
(context) => { (context) => {
console.log("PiniaColada plugin initialized for store:", context); // console.log("PiniaColada plugin initialized for store:", context);
} }
], ],
queryOptions: { queryOptions: {

View File

@@ -8,6 +8,7 @@ import {
} from "vue-router"; } from "vue-router";
import { useAuthStore } from "@/stores/auth"; import { useAuthStore } from "@/stores/auth";
import { inject } from "vue"; 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 };
@@ -194,11 +195,20 @@ const createAppRouter = () => {
router.beforeEach((to, from, next) => { router.beforeEach((to, from, next) => {
const auth = useAuthStore(); const auth = useAuthStore();
const head = inject(headSymbol); const head = inject(headSymbol);
let client: any;
(head as any).push(to.meta.head || {}); (head as any).push(to.meta.head || {});
if (to.matched.some((record) => record.meta.requiresAuth)) { if (to.matched.some((record) => record.meta.requiresAuth)) {
if (!auth.user) { if (!auth.user) {
if(client?.disconnect) (client as any)?.disconnect();
next({ name: "login" }); next({ name: "login" });
} else { } else {
client = new TinyMqttClient(
'wss://broker.emqx.io:8084/mqtt',
[['ecos1231231'+auth.user.id+'#'].join("/")],
(topic, msg) => console.log(`Tín hiệu nhận được [${topic}]:`, msg)
);
client.connect();
next(); next();
} }
} else { } else {

View File

@@ -144,17 +144,16 @@ const videoInfos = computed(() => {
<VideoSkeleton v-if="loading" /> <VideoSkeleton v-if="loading" />
<!-- Content --> <!-- Content -->
<div v-else-if="video" class="flex flex-col md:flex-row gap-4"> <div v-else-if="video" class="flex flex-col lg:flex-row gap-4">
<VideoPlayer :video="video" class="md:flex-1" /> <VideoPlayer :video="video" class="lg:flex-1" />
<div class="bg-white rounded-lg border border-gray-200 max-w-full md:max-w-md w-full flex flex-col"> <div class="bg-white rounded-lg border border-gray-200 max-w-full lg:max-w-md w-full flex flex-col">
<div class="px-6 py-4"> <div class="px-6 py-4">
<VideoHeader :video="video" :is-editing="isEditing" :saving="saving" @reload="handleReload"
@toggle-edit="toggleEdit" @delete="handleDelete" @save="handleSave" />
<VideoEditForm v-if="isEditing" v-model:title="form.title" <VideoEditForm v-if="isEditing" v-model:title="form.title"
v-model:description="form.description" /> v-model:description="form.description" @save="handleSave" @toggle-edit="toggleEdit" :saving="saving" />
<div class=""> <div v-else>
<VideoHeader :video="video" @reload="handleReload"
@toggle-edit="toggleEdit" @delete="handleDelete" />
<div class="mb-4"> <div class="mb-4">
<h3 class="text-lg font-medium text-gray-900 mb-4">Video Details</h3> <h3 class="text-lg font-medium text-gray-900 mb-4">Video Details</h3>
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">

View File

@@ -136,42 +136,8 @@ watch([searchQuery, selectedStatus, limit, page], () => {
@search="handleSearch" @filter="handleFilter" /> @search="handleSearch" @filter="handleFilter" />
<Transition name="fade" mode="out-in"> <Transition name="fade" mode="out-in">
<!-- Loading State -->
<div v-if="loading" class="animate-pulse">
<!-- Grid Skeleton -->
<div v-if="viewMode === 'grid'" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
<div v-for="i in 8" :key="i" class="bg-white border border-gray-200 rounded-xl overflow-hidden">
<Skeleton height="150px" width="100%"></Skeleton>
<div class="p-4">
<Skeleton width="80%" height="1.5rem" class="mb-2"></Skeleton>
<Skeleton width="60%" height="1rem" class="mb-4"></Skeleton>
<div class="flex justify-between">
<Skeleton width="3rem" height="1rem"></Skeleton>
<Skeleton width="3rem" height="1rem"></Skeleton>
</div>
</div>
</div>
</div>
<!-- Table Skeleton -->
<div v-else class="bg-white rounded-xl border border-gray-200 overflow-hidden">
<div class="p-4 border-b border-gray-200" v-for="i in 5" :key="i">
<div class="flex gap-4 items-center">
<Skeleton width="5rem" height="3rem" class="rounded"></Skeleton>
<div class="flex-1">
<Skeleton width="40%" height="1.2rem" class="mb-2"></Skeleton>
<Skeleton width="30%" height="1rem"></Skeleton>
</div>
<Skeleton width="10%" height="1rem"></Skeleton>
<Skeleton width="10%" height="1rem"></Skeleton>
<Skeleton width="5rem" height="2rem" borderRadius="16px"></Skeleton>
</div>
</div>
</div>
</div>
<!-- Error State --> <!-- Error State -->
<div v-else-if="error" class="bg-red-50 border border-red-200 rounded-xl p-6 text-center"> <div v-if="error" class="bg-red-50 border border-red-200 rounded-xl p-6 text-center">
<span class="i-heroicons-exclamation-circle text-red-500 text-4xl mb-3 inline-block" /> <span class="i-heroicons-exclamation-circle text-red-500 text-4xl mb-3 inline-block" />
<p class="text-red-700 font-medium">{{ error }}</p> <p class="text-red-700 font-medium">{{ error }}</p>
<button @click="fetchVideos" <button @click="fetchVideos"
@@ -181,16 +147,15 @@ watch([searchQuery, selectedStatus, limit, page], () => {
</div> </div>
<!-- Empty State --> <!-- Empty State -->
<EmptyState v-else-if="videos.length === 0" title="No videos found" <EmptyState v-else-if="videos.length === 0 && !loading" title="No videos found"
description="You haven't uploaded any videos yet. Start by uploading your first video!" description="You haven't uploaded any videos yet. Start by uploading your first video!"
imageUrl="https://cdn-icons-png.flaticon.com/512/7486/7486747.png" 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" v-model:selectedVideos="selectedVideos" @delete="deleteVideo" <VideoGrid :videos="videos" :loading="loading" v-model:selectedVideos="selectedVideos" @delete="deleteVideo" v-else-if="viewMode === 'grid'" />
v-else-if="viewMode === 'grid'" />
<!-- Table View --> <!-- Table View -->
<VideoTable v-else :videos="videos" v-model:selectedVideos="selectedVideos" @delete="deleteVideo" /> <VideoTable v-else :videos="videos" :loading="loading" v-model:selectedVideos="selectedVideos" @delete="deleteVideo" />
</Transition> </Transition>
</div> </div>
</template> </template>

View File

@@ -2,11 +2,14 @@
defineProps<{ defineProps<{
title: string; title: string;
description: string; description: string;
saving: boolean;
}>(); }>();
const emit = defineEmits<{ const emit = defineEmits<{
'update:title': [value: string]; 'update:title': [value: string];
'update:description': [value: string]; 'update:description': [value: string];
save: [];
toggleEdit: [];
}>(); }>();
</script> </script>
@@ -14,21 +17,38 @@ const emit = defineEmits<{
<div class="mb-4 space-y-3"> <div class="mb-4 space-y-3">
<div> <div>
<label class="block text-sm font-medium text-gray-700 mb-1">Title</label> <label class="block text-sm font-medium text-gray-700 mb-1">Title</label>
<input <input :value="title" type="text"
:value="title"
type="text"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent" class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent"
placeholder="Enter video title" placeholder="Enter video title"
@input="$emit('update:title', ($event.target as HTMLInputElement).value)"> @input="$emit('update:title', ($event.target as HTMLInputElement).value)">
</div> </div>
<div> <div>
<label class="block text-sm font-medium text-gray-700 mb-1">Description</label> <label class="block text-sm font-medium text-gray-700 mb-1">Description</label>
<textarea <textarea :value="description" rows="3"
:value="description"
rows="3"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent" class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent"
placeholder="Enter video description" placeholder="Enter video description"
@input="$emit('update:description', ($event.target as HTMLTextAreaElement).value)"></textarea> @input="$emit('update:description', ($event.target as HTMLTextAreaElement).value)"></textarea>
</div> </div>
<div class="float-right flex gap-2">
<Button size="small"
title="Save changes" :disabled="saving" @click="$emit('save')">
<svg v-if="!saving" class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path>
</svg>
<span v-if="saving"
class="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin"></span>
<span class="hidden sm:inline">{{ saving ? 'Saving...' : 'Save' }}</span>
</Button>
<!-- Cancel Button (Edit Mode) -->
<Button severity="danger" size="small" title="Cancel editing"
@click="$emit('toggleEdit')">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12">
</path>
</svg>
<span class="hidden sm:inline">Cancel</span>
</Button>
</div>
</div> </div>
</template> </template>

View File

@@ -5,15 +5,12 @@ import Tag from 'primevue/tag';
const props = defineProps<{ const props = defineProps<{
video: ModelVideo; video: ModelVideo;
isEditing: boolean;
saving: boolean;
}>(); }>();
const emit = defineEmits<{ const emit = defineEmits<{
reload: []; reload: [];
toggleEdit: []; toggleEdit: [];
delete: []; delete: [];
save: [];
}>(); }>();
const formatFileSize = (bytes?: number): string => { const formatFileSize = (bytes?: number): string => {
@@ -52,7 +49,7 @@ const formatDate = (dateStr?: string): string => {
<div class="flex-1"> <div class="flex-1">
<!-- View Mode: Title --> <!-- View Mode: Title -->
<div class="mb-2"> <div class="mb-2">
<h1 v-if="!isEditing" class="text-2xl font-bold text-gray-900 mb-1"> <h1 class="text-2xl font-bold text-gray-900 mb-1">
{{ video.title }} {{ video.title }}
</h1> </h1>
<p v-if="video.description" class="text-sm text-gray-600 whitespace-pre-wrap">{{ video.description }} <p v-if="video.description" class="text-sm text-gray-600 whitespace-pre-wrap">{{ video.description }}
@@ -71,31 +68,9 @@ const formatDate = (dateStr?: string): string => {
<!-- Action Buttons --> <!-- Action Buttons -->
<div class="flex items-center space-x-2"> <div class="flex items-center space-x-2">
<!-- Save Button (Edit Mode) --> <!-- Save Button (Edit Mode) -->
<button v-if="isEditing"
class="btn-primary btn-sm flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed"
title="Save changes" :disabled="saving" @click="$emit('save')">
<svg v-if="!saving" class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path>
</svg>
<span v-if="saving"
class="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin"></span>
<span class="hidden sm:inline">{{ saving ? 'Saving...' : 'Save' }}</span>
</button>
<!-- Cancel Button (Edit Mode) -->
<button v-if="isEditing" class="btn-outline btn-sm flex items-center gap-2" title="Cancel editing"
@click="$emit('toggleEdit')">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12">
</path>
</svg>
<span class="hidden sm:inline">Cancel</span>
</button>
<!-- View Mode Buttons --> <!-- View Mode Buttons -->
<template v-if="!isEditing"> <Button size="small"
<button severity="secondary"
class="btn-outline btn-sm flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed"
title="Reload video" @click="$emit('reload')"> title="Reload video" @click="$emit('reload')">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
@@ -103,24 +78,23 @@ const formatDate = (dateStr?: string): string => {
</path> </path>
</svg> </svg>
<span class="hidden sm:inline">Reload</span> <span class="hidden sm:inline">Reload</span>
</button> </Button>
<button class="btn-outline btn-sm flex items-center gap-2" title="Edit" @click="$emit('toggleEdit')"> <Button size="small" title="Edit" variant="outlined" @click="$emit('toggleEdit')">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"> d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z">
</path> </path>
</svg> </svg>
<span class="hidden sm:inline">Edit</span> <span class="hidden sm:inline">Edit</span>
</button> </Button>
<button class="btn-danger btn-sm flex items-center gap-2" title="Delete" @click="$emit('delete')"> <Button severity="danger" size="small" title="Delete" @click="$emit('delete')">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"> d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16">
</path> </path>
</svg> </svg>
<span class="hidden sm:inline">Delete</span> <span class="hidden sm:inline">Delete</span>
</button> </Button>
</template>
</div> </div>
</div> </div>
</template> </template>

View File

@@ -3,9 +3,9 @@ import Skeleton from 'primevue/skeleton';
</script> </script>
<template> <template>
<div class="flex flex-col gap-4"> <div class="flex flex-col lg:flex-row gap-4">
<!-- Video Player Skeleton --> <!-- Video Player Skeleton -->
<div class="aspect-video rounded-xl bg-gray-200 animate-pulse" /> <div class="md:flex-1 aspect-video rounded-xl bg-gray-200 animate-pulse" />
<!-- Info Card Skeleton --> <!-- Info Card Skeleton -->
<div class="bg-white rounded-lg border border-gray-200 p-6 space-y-6"> <div class="bg-white rounded-lg border border-gray-200 p-6 space-y-6">
@@ -19,22 +19,22 @@ import Skeleton from 'primevue/skeleton';
<Skeleton width="4rem" height="1rem" /> <Skeleton width="4rem" height="1rem" />
<Skeleton width="4rem" height="1.5rem" /> <Skeleton width="4rem" height="1.5rem" />
</div> </div>
</div> <div class="flex items-center gap-2">
<div class="flex items-center gap-2"> <Skeleton width="5rem" height="2rem" />
<Skeleton width="5rem" height="2rem" /> <Skeleton width="4rem" height="2rem" />
<Skeleton width="4rem" height="2rem" /> <Skeleton width="4.5rem" height="2rem" />
<Skeleton width="4.5rem" height="2rem" /> </div>
</div> </div>
</div> </div>
<!-- Content Grid Skeleton --> <!-- Content Grid Skeleton -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-6"> <div class="grid grid-cols-1">
<!-- Left Column --> <!-- Left Column -->
<div class="space-y-4"> <div class="space-y-4">
<Skeleton width="8rem" height="1.5rem" /> <Skeleton width="100%" height="1.5rem" />
<div class="space-y-3"> <div class="space-y-3">
<div v-for="i in 6" :key="i" class="space-y-1"> <div v-for="i in 6" :key="i" class="space-y-1">
<Skeleton width="30%" height="0.875rem" /> <Skeleton width="100%" height="0.875rem" />
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<Skeleton width="100%" height="1.75rem" /> <Skeleton width="100%" height="1.75rem" />
<Skeleton width="2rem" height="1.75rem" /> <Skeleton width="2rem" height="1.75rem" />
@@ -44,7 +44,7 @@ import Skeleton from 'primevue/skeleton';
</div> </div>
<!-- Right Column --> <!-- Right Column -->
<div class="space-y-4"> <!-- <div class="space-y-4">
<Skeleton width="8rem" height="1.5rem" /> <Skeleton width="8rem" height="1.5rem" />
<div class="space-y-3"> <div class="space-y-3">
<div v-for="i in 3" :key="i" class="space-y-1"> <div v-for="i in 3" :key="i" class="space-y-1">
@@ -54,7 +54,7 @@ import Skeleton from 'primevue/skeleton';
</div> </div>
<Skeleton width="6rem" height="1.5rem" class="mt-6" /> <Skeleton width="6rem" height="1.5rem" class="mt-6" />
<Skeleton width="100%" height="4rem" /> <Skeleton width="100%" height="4rem" />
</div> </div> -->
</div> </div>
</div> </div>
</div> </div>

View File

@@ -3,11 +3,11 @@ import type { ModelVideo } from '@/api/client';
import { formatDate, formatDuration, getStatusSeverity } from '@/lib/utils'; import { formatDate, formatDuration, getStatusSeverity } from '@/lib/utils';
import Card from 'primevue/card'; import Card from 'primevue/card';
import Checkbox from 'primevue/checkbox'; import Checkbox from 'primevue/checkbox';
import { defineEmits, defineProps } from 'vue';
defineProps<{ defineProps<{
videos: ModelVideo[]; videos: ModelVideo[];
selectedVideos: ModelVideo[]; selectedVideos: ModelVideo[];
loading: boolean;
}>(); }>();
const emit = defineEmits<{ const emit = defineEmits<{
@@ -17,8 +17,19 @@ const emit = defineEmits<{
</script> </script>
<template> <template>
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 xl:grid-cols-5 2xl:grid-cols-6 gap-4"> <div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 xl:grid-cols-5 gap-4">
<Card v-for="video in videos" :key="video.id" <div v-if="loading" v-for="i in 10" :key="i" class="bg-white border border-gray-200 rounded-xl overflow-hidden">
<Skeleton height="150px" width="100%"></Skeleton>
<div class="p-4">
<Skeleton width="80%" height="1.5rem" class="mb-2"></Skeleton>
<Skeleton width="60%" height="1rem" class="mb-4"></Skeleton>
<div class="flex justify-between">
<Skeleton width="3rem" height="1rem"></Skeleton>
<Skeleton width="3rem" height="1rem"></Skeleton>
</div>
</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 shadow-sm hover:shadow-md transition-shadow group relative border border-gray-200"
:class="{ '!border-primary ring-2 ring-primary': selectedVideos.some(v => v.id === video.id) }"> :class="{ '!border-primary ring-2 ring-primary': selectedVideos.some(v => v.id === video.id) }">

View File

@@ -12,6 +12,7 @@ import DataTable from 'primevue/datatable';
defineProps<{ defineProps<{
videos: ModelVideo[]; videos: ModelVideo[];
selectedVideos: ModelVideo[]; selectedVideos: ModelVideo[];
loading: boolean;
}>(); }>();
const emit = defineEmits<{ const emit = defineEmits<{
@@ -22,7 +23,21 @@ const emit = defineEmits<{
<template> <template>
<div class="bg-white rounded-xl border border-gray-200 overflow-hidden"> <div class="bg-white rounded-xl border border-gray-200 overflow-hidden">
<DataTable :value="videos" dataKey="id" tableStyle="min-width: 50rem" :selection="selectedVideos" <div v-if="loading">
<div class="p-4 border-b border-gray-200" v-for="i in 10" :key="i">
<div class="flex gap-4 items-center">
<Skeleton width="5rem" height="3rem" class="rounded"></Skeleton>
<div class="flex-1">
<Skeleton width="40%" height="1.2rem" class="mb-2"></Skeleton>
<Skeleton width="30%" height="1rem"></Skeleton>
</div>
<Skeleton width="10%" height="1rem"></Skeleton>
<Skeleton width="10%" height="1rem"></Skeleton>
<Skeleton width="5rem" height="2rem" borderRadius="16px"></Skeleton>
</div>
</div>
</div>
<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>