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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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) }">

View File

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