From 4d41d6540a86c32b1250b5f9a026e8f911b53ede Mon Sep 17 00:00:00 2001 From: "Mr.Dat" Date: Fri, 6 Feb 2026 18:39:38 +0700 Subject: [PATCH] feat: Add video detail management components and enhance video state sharing --- components.d.ts | 10 + src/client.ts | 4 +- src/lib/PiniaSharedState.ts | 91 +++++++++ src/main.ts | 8 + src/mocks/videos.ts | 27 +++ src/routes/video/DetailVideo.vue | 183 ++++++++++++------ .../video/components/Detail/VideoEditForm.vue | 34 ++++ .../components/Detail/VideoInfoHeader.vue | 126 ++++++++++++ .../components/Detail/VideoInfoPanel.vue | 58 ++++++ .../video/components/Detail/VideoPlayer.vue | 31 +++ .../video/components/Detail/VideoSkeleton.vue | 61 ++++++ 11 files changed, 573 insertions(+), 60 deletions(-) create mode 100644 src/lib/PiniaSharedState.ts create mode 100644 src/routes/video/components/Detail/VideoEditForm.vue create mode 100644 src/routes/video/components/Detail/VideoInfoHeader.vue create mode 100644 src/routes/video/components/Detail/VideoInfoPanel.vue create mode 100644 src/routes/video/components/Detail/VideoPlayer.vue create mode 100644 src/routes/video/components/Detail/VideoSkeleton.vue diff --git a/components.d.ts b/components.d.ts index 5839358..cde6a65 100644 --- a/components.d.ts +++ b/components.d.ts @@ -58,7 +58,12 @@ declare module 'vue' { TrashIcon: typeof import('./src/components/icons/TrashIcon.vue')['default'] Upload: typeof import('./src/components/icons/Upload.vue')['default'] Video: typeof import('./src/components/icons/Video.vue')['default'] + VideoEditForm: typeof import('./src/components/video/VideoEditForm.vue')['default'] + VideoHeader: typeof import('./src/components/video/VideoHeader.vue')['default'] VideoIcon: typeof import('./src/components/icons/VideoIcon.vue')['default'] + VideoInfoPanel: typeof import('./src/components/video/VideoInfoPanel.vue')['default'] + VideoPlayer: typeof import('./src/components/video/VideoPlayer.vue')['default'] + VideoSkeleton: typeof import('./src/components/video/VideoSkeleton.vue')['default'] VueHead: typeof import('./src/components/VueHead.tsx')['default'] XCircleIcon: typeof import('./src/components/icons/XCircleIcon.vue')['default'] } @@ -112,7 +117,12 @@ declare global { const TrashIcon: typeof import('./src/components/icons/TrashIcon.vue')['default'] const Upload: typeof import('./src/components/icons/Upload.vue')['default'] const Video: typeof import('./src/components/icons/Video.vue')['default'] + const VideoEditForm: typeof import('./src/components/video/VideoEditForm.vue')['default'] + const VideoHeader: typeof import('./src/components/video/VideoHeader.vue')['default'] const VideoIcon: typeof import('./src/components/icons/VideoIcon.vue')['default'] + const VideoInfoPanel: typeof import('./src/components/video/VideoInfoPanel.vue')['default'] + const VideoPlayer: typeof import('./src/components/video/VideoPlayer.vue')['default'] + const VideoSkeleton: typeof import('./src/components/video/VideoSkeleton.vue')['default'] const VueHead: typeof import('./src/components/VueHead.tsx')['default'] const XCircleIcon: typeof import('./src/components/icons/XCircleIcon.vue')['default'] } \ No newline at end of file diff --git a/src/client.ts b/src/client.ts index a749f16..e3f6d69 100644 --- a/src/client.ts +++ b/src/client.ts @@ -1,8 +1,10 @@ import { hydrateQueryCache } from '@pinia/colada'; import 'uno.css'; +import PiniaSharedState from './lib/PiniaSharedState'; import { createApp } from './main'; async function render() { - const { app, router, queryCache } = createApp(); + const { app, router, queryCache, pinia } = createApp(); + pinia.use(PiniaSharedState({enable: true, initialize: true})); hydrateQueryCache(queryCache, (window as any).$colada || {}); router.isReady().then(() => { app.mount('body', true) diff --git a/src/lib/PiniaSharedState.ts b/src/lib/PiniaSharedState.ts new file mode 100644 index 0000000..7faf90b --- /dev/null +++ b/src/lib/PiniaSharedState.ts @@ -0,0 +1,91 @@ +import type { DefineStoreOptions, PiniaPluginContext, StateTree } from "pinia"; + +type Serializer = { + serialize: (value: T) => string; + deserialize: (value: string) => T; +}; + +interface BroadcastMessage { + type: "STATE_UPDATE" | "SYNC_REQUEST"; + timestamp?: number; + state?: string; +} + +type PluginOptions = { + enable?: boolean; + initialize?: boolean; + serializer?: Serializer; +}; + +export interface StoreOptions< + S extends StateTree = StateTree, + G = object, + A = object, +> extends DefineStoreOptions { + share?: PluginOptions; +} + +// Add type extension for Pinia +declare module "pinia" { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + export interface DefineStoreOptionsBase { + share?: PluginOptions; + } +} + +export default function PiniaSharedState({ + enable = false, + initialize = false, + serializer = { + serialize: JSON.stringify, + deserialize: JSON.parse, + }, +}: PluginOptions = {}) { + return ({ store, options }: PiniaPluginContext) => { + if (!(options.share?.enable ?? enable)) return; + const channel = new BroadcastChannel(store.$id); + let timestamp = 0; + let externalUpdate = false; + + // Initial state sync + if (options.share?.initialize ?? initialize) { + channel.postMessage({ type: "SYNC_REQUEST" }); + } + + // State change listener + store.$subscribe((_mutation, state) => { + if (externalUpdate) return; + + timestamp = Date.now(); + channel.postMessage({ + type: "STATE_UPDATE", + timestamp, + state: serializer.serialize(state as T), + }); + }); + + // Message handler + channel.onmessage = (event: MessageEvent) => { + const data = event.data; + if ( + data.type === "STATE_UPDATE" && + data.timestamp && + data.timestamp > timestamp && + data.state + ) { + externalUpdate = true; + timestamp = data.timestamp; + store.$patch(serializer.deserialize(data.state)); + externalUpdate = false; + } + + if (data.type === "SYNC_REQUEST") { + channel.postMessage({ + type: "STATE_UPDATE", + timestamp, + state: serializer.serialize(store.$state as T), + }); + } + }; + }; +} \ No newline at end of file diff --git a/src/main.ts b/src/main.ts index 1538a75..ec2bee2 100644 --- a/src/main.ts +++ b/src/main.ts @@ -4,6 +4,7 @@ import { createHead as CSRHead } from "@unhead/vue/client"; import { createHead as SSRHead } from "@unhead/vue/server"; import { createPinia } from "pinia"; import PrimeVue from 'primevue/config'; +import ConfirmationService from 'primevue/confirmationservice'; import ToastService from 'primevue/toastservice'; import Tooltip from 'primevue/tooltip'; import { createSSRApp } from 'vue'; @@ -32,6 +33,7 @@ export function createApp() { } }); app.use(ToastService); + app.use(ConfirmationService); app.directive('nh', { created(el) { el.__v_skip = true; @@ -40,6 +42,12 @@ export function createApp() { app.directive("tooltip", Tooltip) app.use(pinia); app.use(PiniaColada, { + pinia, + plugins: [ + (context) => { + console.log("PiniaColada plugin initialized for store:", context); + } + ], queryOptions: { refetchOnMount: false, refetchOnWindowFocus: false, diff --git a/src/mocks/videos.ts b/src/mocks/videos.ts index 06405c7..64c7d38 100644 --- a/src/mocks/videos.ts +++ b/src/mocks/videos.ts @@ -327,4 +327,31 @@ export const fetchMockVideoById = async (id: string) => { throw new Error('Video not found'); } return video; +}; + +export const updateMockVideo = async (id: string, updates: { title: string; description?: string }) => { + // Simulate API delay + await new Promise(resolve => setTimeout(resolve, 800)); + const videoIndex = mockVideos.findIndex(v => v.id === id); + if (videoIndex === -1) { + throw new Error('Video not found'); + } + mockVideos[videoIndex] = { + ...mockVideos[videoIndex], + title: updates.title, + description: updates.description, + updated_at: new Date().toISOString() + }; + return mockVideos[videoIndex]; +}; + +export const deleteMockVideo = async (id: string) => { + // Simulate API delay + await new Promise(resolve => setTimeout(resolve, 600)); + const videoIndex = mockVideos.findIndex(v => v.id === id); + if (videoIndex === -1) { + throw new Error('Video not found'); + } + mockVideos.splice(videoIndex, 1); + return { success: true }; }; \ No newline at end of file diff --git a/src/routes/video/DetailVideo.vue b/src/routes/video/DetailVideo.vue index 6c8b8ab..3d3981b 100644 --- a/src/routes/video/DetailVideo.vue +++ b/src/routes/video/DetailVideo.vue @@ -1,23 +1,27 @@ diff --git a/src/routes/video/components/Detail/VideoInfoHeader.vue b/src/routes/video/components/Detail/VideoInfoHeader.vue new file mode 100644 index 0000000..fc12756 --- /dev/null +++ b/src/routes/video/components/Detail/VideoInfoHeader.vue @@ -0,0 +1,126 @@ + + + diff --git a/src/routes/video/components/Detail/VideoInfoPanel.vue b/src/routes/video/components/Detail/VideoInfoPanel.vue new file mode 100644 index 0000000..c0a647d --- /dev/null +++ b/src/routes/video/components/Detail/VideoInfoPanel.vue @@ -0,0 +1,58 @@ + + + diff --git a/src/routes/video/components/Detail/VideoPlayer.vue b/src/routes/video/components/Detail/VideoPlayer.vue new file mode 100644 index 0000000..81dd7a5 --- /dev/null +++ b/src/routes/video/components/Detail/VideoPlayer.vue @@ -0,0 +1,31 @@ + + + diff --git a/src/routes/video/components/Detail/VideoSkeleton.vue b/src/routes/video/components/Detail/VideoSkeleton.vue new file mode 100644 index 0000000..4206aa2 --- /dev/null +++ b/src/routes/video/components/Detail/VideoSkeleton.vue @@ -0,0 +1,61 @@ + + +