feat: Implement TinyMqttClient for MQTT communication and enhance video components with loading states
This commit is contained in:
@@ -8,6 +8,7 @@ import {
|
||||
} from "vue-router";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
import { inject } from "vue";
|
||||
import { TinyMqttClient } from "@/lib/liteMqtt";
|
||||
|
||||
type RouteData = RouteRecordRaw & {
|
||||
meta?: ResolvableValue<ReactiveHead> & { requiresAuth?: boolean };
|
||||
@@ -194,11 +195,20 @@ const createAppRouter = () => {
|
||||
router.beforeEach((to, from, next) => {
|
||||
const auth = useAuthStore();
|
||||
const head = inject(headSymbol);
|
||||
let client: any;
|
||||
(head as any).push(to.meta.head || {});
|
||||
if (to.matched.some((record) => record.meta.requiresAuth)) {
|
||||
if (!auth.user) {
|
||||
if(client?.disconnect) (client as any)?.disconnect();
|
||||
next({ name: "login" });
|
||||
} 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();
|
||||
}
|
||||
} else {
|
||||
|
||||
@@ -144,17 +144,16 @@ const videoInfos = computed(() => {
|
||||
<VideoSkeleton v-if="loading" />
|
||||
|
||||
<!-- Content -->
|
||||
<div v-else-if="video" class="flex flex-col md:flex-row gap-4">
|
||||
<VideoPlayer :video="video" class="md:flex-1" />
|
||||
<div v-else-if="video" class="flex flex-col lg:flex-row gap-4">
|
||||
<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">
|
||||
<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"
|
||||
v-model:description="form.description" />
|
||||
<div class="">
|
||||
v-model:description="form.description" @save="handleSave" @toggle-edit="toggleEdit" :saving="saving" />
|
||||
<div v-else>
|
||||
<VideoHeader :video="video" @reload="handleReload"
|
||||
@toggle-edit="toggleEdit" @delete="handleDelete" />
|
||||
<div class="mb-4">
|
||||
<h3 class="text-lg font-medium text-gray-900 mb-4">Video Details</h3>
|
||||
<div class="flex flex-col gap-2">
|
||||
|
||||
@@ -136,42 +136,8 @@ watch([searchQuery, selectedStatus, limit, page], () => {
|
||||
@search="handleSearch" @filter="handleFilter" />
|
||||
|
||||
<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 -->
|
||||
<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" />
|
||||
<p class="text-red-700 font-medium">{{ error }}</p>
|
||||
<button @click="fetchVideos"
|
||||
@@ -181,16 +147,15 @@ watch([searchQuery, selectedStatus, limit, page], () => {
|
||||
</div>
|
||||
|
||||
<!-- 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!"
|
||||
imageUrl="https://cdn-icons-png.flaticon.com/512/7486/7486747.png" actionLabel="Upload Video"
|
||||
:onAction="() => router.push('/upload')" />
|
||||
<!-- Grid View -->
|
||||
<VideoGrid :videos="videos" 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 -->
|
||||
<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>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -2,11 +2,14 @@
|
||||
defineProps<{
|
||||
title: string;
|
||||
description: string;
|
||||
saving: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:title': [value: string];
|
||||
'update:description': [value: string];
|
||||
save: [];
|
||||
toggleEdit: [];
|
||||
}>();
|
||||
</script>
|
||||
|
||||
@@ -14,21 +17,38 @@ const emit = defineEmits<{
|
||||
<div class="mb-4 space-y-3">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Title</label>
|
||||
<input
|
||||
:value="title"
|
||||
type="text"
|
||||
<input :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"
|
||||
placeholder="Enter video title"
|
||||
@input="$emit('update:title', ($event.target as HTMLInputElement).value)">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Description</label>
|
||||
<textarea
|
||||
:value="description"
|
||||
rows="3"
|
||||
<textarea :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"
|
||||
placeholder="Enter video description"
|
||||
@input="$emit('update:description', ($event.target as HTMLTextAreaElement).value)"></textarea>
|
||||
</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>
|
||||
</template>
|
||||
|
||||
@@ -5,15 +5,12 @@ import Tag from 'primevue/tag';
|
||||
|
||||
const props = defineProps<{
|
||||
video: ModelVideo;
|
||||
isEditing: boolean;
|
||||
saving: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
reload: [];
|
||||
toggleEdit: [];
|
||||
delete: [];
|
||||
save: [];
|
||||
}>();
|
||||
|
||||
const formatFileSize = (bytes?: number): string => {
|
||||
@@ -52,7 +49,7 @@ const formatDate = (dateStr?: string): string => {
|
||||
<div class="flex-1">
|
||||
<!-- View Mode: Title -->
|
||||
<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 }}
|
||||
</h1>
|
||||
<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 -->
|
||||
<div class="flex items-center space-x-2">
|
||||
<!-- 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 -->
|
||||
<template v-if="!isEditing">
|
||||
<button
|
||||
class="btn-outline btn-sm flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
<Button size="small"
|
||||
severity="secondary"
|
||||
title="Reload video" @click="$emit('reload')">
|
||||
<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"
|
||||
@@ -103,24 +78,23 @@ const formatDate = (dateStr?: string): string => {
|
||||
</path>
|
||||
</svg>
|
||||
<span class="hidden sm:inline">Reload</span>
|
||||
</button>
|
||||
<button class="btn-outline btn-sm flex items-center gap-2" title="Edit" @click="$emit('toggleEdit')">
|
||||
</Button>
|
||||
<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">
|
||||
<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">
|
||||
</path>
|
||||
</svg>
|
||||
<span class="hidden sm:inline">Edit</span>
|
||||
</button>
|
||||
<button class="btn-danger btn-sm flex items-center gap-2" title="Delete" @click="$emit('delete')">
|
||||
</Button>
|
||||
<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">
|
||||
<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">
|
||||
</path>
|
||||
</svg>
|
||||
<span class="hidden sm:inline">Delete</span>
|
||||
</button>
|
||||
</template>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -3,9 +3,9 @@ import Skeleton from 'primevue/skeleton';
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex flex-col lg:flex-row gap-4">
|
||||
<!-- 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 -->
|
||||
<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="1.5rem" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Skeleton width="5rem" height="2rem" />
|
||||
<Skeleton width="4rem" height="2rem" />
|
||||
<Skeleton width="4.5rem" height="2rem" />
|
||||
<div class="flex items-center gap-2">
|
||||
<Skeleton width="5rem" height="2rem" />
|
||||
<Skeleton width="4rem" height="2rem" />
|
||||
<Skeleton width="4.5rem" height="2rem" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Content Grid Skeleton -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div class="grid grid-cols-1">
|
||||
<!-- Left Column -->
|
||||
<div class="space-y-4">
|
||||
<Skeleton width="8rem" height="1.5rem" />
|
||||
<Skeleton width="100%" height="1.5rem" />
|
||||
<div class="space-y-3">
|
||||
<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">
|
||||
<Skeleton width="100%" height="1.75rem" />
|
||||
<Skeleton width="2rem" height="1.75rem" />
|
||||
@@ -44,7 +44,7 @@ import Skeleton from 'primevue/skeleton';
|
||||
</div>
|
||||
|
||||
<!-- Right Column -->
|
||||
<div class="space-y-4">
|
||||
<!-- <div class="space-y-4">
|
||||
<Skeleton width="8rem" height="1.5rem" />
|
||||
<div class="space-y-3">
|
||||
<div v-for="i in 3" :key="i" class="space-y-1">
|
||||
@@ -54,7 +54,7 @@ import Skeleton from 'primevue/skeleton';
|
||||
</div>
|
||||
<Skeleton width="6rem" height="1.5rem" class="mt-6" />
|
||||
<Skeleton width="100%" height="4rem" />
|
||||
</div>
|
||||
</div> -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3,11 +3,11 @@ import type { ModelVideo } from '@/api/client';
|
||||
import { formatDate, formatDuration, getStatusSeverity } from '@/lib/utils';
|
||||
import Card from 'primevue/card';
|
||||
import Checkbox from 'primevue/checkbox';
|
||||
import { defineEmits, defineProps } from 'vue';
|
||||
|
||||
defineProps<{
|
||||
videos: ModelVideo[];
|
||||
selectedVideos: ModelVideo[];
|
||||
loading: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -17,8 +17,19 @@ const emit = defineEmits<{
|
||||
</script>
|
||||
|
||||
<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">
|
||||
<Card v-for="video in videos" :key="video.id"
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 xl:grid-cols-5 gap-4">
|
||||
<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="{ '!border-primary ring-2 ring-primary': selectedVideos.some(v => v.id === video.id) }">
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import DataTable from 'primevue/datatable';
|
||||
defineProps<{
|
||||
videos: ModelVideo[];
|
||||
selectedVideos: ModelVideo[];
|
||||
loading: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -22,7 +23,21 @@ const emit = defineEmits<{
|
||||
|
||||
<template>
|
||||
<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)">
|
||||
<Column selectionMode="multiple" headerStyle="width: 3rem"></Column>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user