develop-updateui #1
@@ -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
118
src/lib/liteMqtt.ts
Normal 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();
|
||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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) }">
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user