feat: Add video detail management components and enhance video state sharing
This commit is contained in:
10
components.d.ts
vendored
10
components.d.ts
vendored
@@ -58,7 +58,12 @@ declare module 'vue' {
|
|||||||
TrashIcon: typeof import('./src/components/icons/TrashIcon.vue')['default']
|
TrashIcon: typeof import('./src/components/icons/TrashIcon.vue')['default']
|
||||||
Upload: typeof import('./src/components/icons/Upload.vue')['default']
|
Upload: typeof import('./src/components/icons/Upload.vue')['default']
|
||||||
Video: typeof import('./src/components/icons/Video.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']
|
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']
|
VueHead: typeof import('./src/components/VueHead.tsx')['default']
|
||||||
XCircleIcon: typeof import('./src/components/icons/XCircleIcon.vue')['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 TrashIcon: typeof import('./src/components/icons/TrashIcon.vue')['default']
|
||||||
const Upload: typeof import('./src/components/icons/Upload.vue')['default']
|
const Upload: typeof import('./src/components/icons/Upload.vue')['default']
|
||||||
const Video: typeof import('./src/components/icons/Video.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 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 VueHead: typeof import('./src/components/VueHead.tsx')['default']
|
||||||
const XCircleIcon: typeof import('./src/components/icons/XCircleIcon.vue')['default']
|
const XCircleIcon: typeof import('./src/components/icons/XCircleIcon.vue')['default']
|
||||||
}
|
}
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
import { hydrateQueryCache } from '@pinia/colada';
|
import { hydrateQueryCache } from '@pinia/colada';
|
||||||
import 'uno.css';
|
import 'uno.css';
|
||||||
|
import PiniaSharedState from './lib/PiniaSharedState';
|
||||||
import { createApp } from './main';
|
import { createApp } from './main';
|
||||||
async function render() {
|
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 || {});
|
hydrateQueryCache(queryCache, (window as any).$colada || {});
|
||||||
router.isReady().then(() => {
|
router.isReady().then(() => {
|
||||||
app.mount('body', true)
|
app.mount('body', true)
|
||||||
|
|||||||
91
src/lib/PiniaSharedState.ts
Normal file
91
src/lib/PiniaSharedState.ts
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
import type { DefineStoreOptions, PiniaPluginContext, StateTree } from "pinia";
|
||||||
|
|
||||||
|
type Serializer<T extends StateTree> = {
|
||||||
|
serialize: (value: T) => string;
|
||||||
|
deserialize: (value: string) => T;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface BroadcastMessage {
|
||||||
|
type: "STATE_UPDATE" | "SYNC_REQUEST";
|
||||||
|
timestamp?: number;
|
||||||
|
state?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type PluginOptions<T extends StateTree> = {
|
||||||
|
enable?: boolean;
|
||||||
|
initialize?: boolean;
|
||||||
|
serializer?: Serializer<T>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface StoreOptions<
|
||||||
|
S extends StateTree = StateTree,
|
||||||
|
G = object,
|
||||||
|
A = object,
|
||||||
|
> extends DefineStoreOptions<string, S, G, A> {
|
||||||
|
share?: PluginOptions<S>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add type extension for Pinia
|
||||||
|
declare module "pinia" {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
|
export interface DefineStoreOptionsBase<S, Store> {
|
||||||
|
share?: PluginOptions<S>;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PiniaSharedState<T extends StateTree>({
|
||||||
|
enable = false,
|
||||||
|
initialize = false,
|
||||||
|
serializer = {
|
||||||
|
serialize: JSON.stringify,
|
||||||
|
deserialize: JSON.parse,
|
||||||
|
},
|
||||||
|
}: PluginOptions<T> = {}) {
|
||||||
|
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<BroadcastMessage>) => {
|
||||||
|
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),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import { createHead as CSRHead } from "@unhead/vue/client";
|
|||||||
import { createHead as SSRHead } from "@unhead/vue/server";
|
import { createHead as SSRHead } from "@unhead/vue/server";
|
||||||
import { createPinia } from "pinia";
|
import { createPinia } from "pinia";
|
||||||
import PrimeVue from 'primevue/config';
|
import PrimeVue from 'primevue/config';
|
||||||
|
import ConfirmationService from 'primevue/confirmationservice';
|
||||||
import ToastService from 'primevue/toastservice';
|
import ToastService from 'primevue/toastservice';
|
||||||
import Tooltip from 'primevue/tooltip';
|
import Tooltip from 'primevue/tooltip';
|
||||||
import { createSSRApp } from 'vue';
|
import { createSSRApp } from 'vue';
|
||||||
@@ -32,6 +33,7 @@ export function createApp() {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
app.use(ToastService);
|
app.use(ToastService);
|
||||||
|
app.use(ConfirmationService);
|
||||||
app.directive('nh', {
|
app.directive('nh', {
|
||||||
created(el) {
|
created(el) {
|
||||||
el.__v_skip = true;
|
el.__v_skip = true;
|
||||||
@@ -40,6 +42,12 @@ export function createApp() {
|
|||||||
app.directive("tooltip", Tooltip)
|
app.directive("tooltip", Tooltip)
|
||||||
app.use(pinia);
|
app.use(pinia);
|
||||||
app.use(PiniaColada, {
|
app.use(PiniaColada, {
|
||||||
|
pinia,
|
||||||
|
plugins: [
|
||||||
|
(context) => {
|
||||||
|
console.log("PiniaColada plugin initialized for store:", context);
|
||||||
|
}
|
||||||
|
],
|
||||||
queryOptions: {
|
queryOptions: {
|
||||||
refetchOnMount: false,
|
refetchOnMount: false,
|
||||||
refetchOnWindowFocus: false,
|
refetchOnWindowFocus: false,
|
||||||
|
|||||||
@@ -328,3 +328,30 @@ export const fetchMockVideoById = async (id: string) => {
|
|||||||
}
|
}
|
||||||
return video;
|
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 };
|
||||||
|
};
|
||||||
@@ -1,23 +1,27 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted } from 'vue';
|
import type { ModelVideo } from '@/api/client';
|
||||||
import { useRoute, useRouter } from 'vue-router';
|
|
||||||
import { client, type ModelVideo } from '@/api/client';
|
|
||||||
import PageHeader from '@/components/dashboard/PageHeader.vue';
|
import PageHeader from '@/components/dashboard/PageHeader.vue';
|
||||||
|
import { deleteMockVideo, fetchMockVideoById, updateMockVideo } from '@/mocks/videos';
|
||||||
|
import ConfirmDialog from 'primevue/confirmdialog';
|
||||||
|
import { useConfirm } from 'primevue/useconfirm';
|
||||||
import { useToast } from 'primevue/usetoast';
|
import { useToast } from 'primevue/usetoast';
|
||||||
import InputText from 'primevue/inputtext';
|
import { onMounted, ref } from 'vue';
|
||||||
import Textarea from 'primevue/textarea';
|
import { useRoute, useRouter } from 'vue-router';
|
||||||
import Button from 'primevue/button';
|
import VideoEditForm from './components/Detail/VideoEditForm.vue';
|
||||||
import Skeleton from 'primevue/skeleton';
|
import VideoHeader from './components/Detail/VideoInfoHeader.vue';
|
||||||
import { fetchMockVideoById } from '@/mocks/videos';
|
import VideoPlayer from './components/Detail/VideoPlayer.vue';
|
||||||
|
import VideoSkeleton from './components/Detail/VideoSkeleton.vue';
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
|
const confirm = useConfirm();
|
||||||
|
|
||||||
const videoId = route.params.id as string;
|
const videoId = route.params.id as string;
|
||||||
const video = ref<ModelVideo | null>(null);
|
const video = ref<ModelVideo | null>(null);
|
||||||
const loading = ref(true);
|
const loading = ref(true);
|
||||||
const saving = ref(false);
|
const saving = ref(false);
|
||||||
|
const isEditing = ref(false);
|
||||||
|
|
||||||
const form = ref({
|
const form = ref({
|
||||||
title: '',
|
title: '',
|
||||||
@@ -28,8 +32,6 @@ const fetchVideo = async () => {
|
|||||||
loading.value = true;
|
loading.value = true;
|
||||||
try {
|
try {
|
||||||
const videoData = await fetchMockVideoById(videoId);
|
const videoData = await fetchMockVideoById(videoId);
|
||||||
// response is HttpResponse, response.data is the body, response.data.data is the ModelVideo
|
|
||||||
// const videoData = response.data.data;
|
|
||||||
if (videoData) {
|
if (videoData) {
|
||||||
video.value = videoData;
|
video.value = videoData;
|
||||||
form.value.title = videoData.title || '';
|
form.value.title = videoData.title || '';
|
||||||
@@ -44,15 +46,31 @@ const fetchVideo = async () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleReload = async () => {
|
||||||
|
toast.add({ severity: 'info', summary: 'Info', detail: 'Reloading video...', life: 2000 });
|
||||||
|
await fetchVideo();
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleEdit = () => {
|
||||||
|
isEditing.value = !isEditing.value;
|
||||||
|
if (!isEditing.value && video.value) {
|
||||||
|
form.value.title = video.value.title || '';
|
||||||
|
form.value.description = video.value.description || '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
saving.value = true;
|
saving.value = true;
|
||||||
try {
|
try {
|
||||||
// Mock update - API doesn't support update yet
|
await updateMockVideo(videoId, form.value);
|
||||||
console.log('Saving video:', videoId, form.value);
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 1000)); // Simulate delay
|
if (video.value) {
|
||||||
|
video.value.title = form.value.title;
|
||||||
|
video.value.description = form.value.description;
|
||||||
|
}
|
||||||
|
|
||||||
toast.add({ severity: 'success', summary: 'Success', detail: 'Video updated successfully', life: 3000 });
|
toast.add({ severity: 'success', summary: 'Success', detail: 'Video updated successfully', life: 3000 });
|
||||||
router.push('/video');
|
isEditing.value = false;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to save video:', error);
|
console.error('Failed to save video:', error);
|
||||||
toast.add({ severity: 'error', summary: 'Error', detail: 'Failed to save changes', life: 3000 });
|
toast.add({ severity: 'error', summary: 'Error', detail: 'Failed to save changes', life: 3000 });
|
||||||
@@ -61,65 +79,112 @@ const handleSave = async () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCancel = () => {
|
const handleDelete = () => {
|
||||||
router.back();
|
confirm.require({
|
||||||
|
message: 'Are you sure you want to delete this video? This action cannot be undone.',
|
||||||
|
header: 'Confirm Delete',
|
||||||
|
icon: 'pi pi-exclamation-triangle',
|
||||||
|
acceptClass: 'p-button-danger',
|
||||||
|
accept: async () => {
|
||||||
|
try {
|
||||||
|
await deleteMockVideo(videoId);
|
||||||
|
toast.add({ severity: 'success', summary: 'Success', detail: 'Video deleted successfully', life: 3000 });
|
||||||
|
router.push('/video');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to delete video:', error);
|
||||||
|
toast.add({ severity: 'error', summary: 'Error', detail: 'Failed to delete video', life: 3000 });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
reject: () => { }
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const copyToClipboard = async (text: string, label: string) => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(text);
|
||||||
|
toast.add({ severity: 'success', summary: 'Copied', detail: `${label} copied to clipboard`, life: 2000 });
|
||||||
|
} catch {
|
||||||
|
const textArea = document.createElement('textarea');
|
||||||
|
textArea.value = text;
|
||||||
|
document.body.appendChild(textArea);
|
||||||
|
textArea.select();
|
||||||
|
document.execCommand('copy');
|
||||||
|
document.body.removeChild(textArea);
|
||||||
|
toast.add({ severity: 'success', summary: 'Copied', detail: `${label} copied to clipboard`, life: 2000 });
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
fetchVideo();
|
fetchVideo();
|
||||||
});
|
});
|
||||||
|
const videoInfos = computed(() => {
|
||||||
|
if (!video) return [];
|
||||||
|
const embedUrl = video ? `${window.location.origin}/embed/${video.value?.id}` : '';
|
||||||
|
return [
|
||||||
|
{ label: 'Video ID', value: video.value?.id ?? '' },
|
||||||
|
{ label: 'Thumbnail URL', value: video.value?.thumbnail ?? '' },
|
||||||
|
{ label: 'Embed URL', value: embedUrl },
|
||||||
|
{ label: 'Iframe Code', value: embedUrl ? `<iframe src="${embedUrl}" title="${video.value?.title}" width="100%" height="400" frameborder="0" allowfullscreen></iframe>` : '' },
|
||||||
|
{ label: 'Share Link', value: video ? `${window.location.origin}/view/${video.value?.id}` : '' },
|
||||||
|
];
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<PageHeader title="Edit Video" :breadcrumbs="[
|
<ConfirmDialog />
|
||||||
|
<PageHeader title="Video Detail" description="View and manage video details" :breadcrumbs="[
|
||||||
{ label: 'Dashboard', to: '/' },
|
{ label: 'Dashboard', to: '/' },
|
||||||
{ label: 'Videos', to: '/video' },
|
{ label: 'Videos', to: '/video' },
|
||||||
{ label: 'Edit' }
|
{ label: video?.title || 'Loading...' }
|
||||||
]" />
|
]" />
|
||||||
<div class="max-w-6xl mx-auto mt-6">
|
|
||||||
<div v-if="loading" class="bg-white rounded-xl border border-gray-200 p-6 space-y-4">
|
|
||||||
<Skeleton width="100%" height="2rem" />
|
|
||||||
<Skeleton width="100%" height="10rem" />
|
|
||||||
<div class="flex gap-2">
|
|
||||||
<Skeleton width="6rem" height="2.5rem" />
|
|
||||||
<Skeleton width="6rem" height="2.5rem" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-else-if="video" class="bg-white rounded-xl border border-gray-200 p-6">
|
<div class="mx-auto p-4 w-full">
|
||||||
<div class="space-y-6">
|
<!-- Loading State -->
|
||||||
<!-- Preview / Info -->
|
<VideoSkeleton v-if="loading" />
|
||||||
<div class="flex items-start gap-4 p-4 bg-gray-50 rounded-lg">
|
|
||||||
<div class="w-32 h-20 bg-gray-200 rounded overflow-hidden flex-shrink-0">
|
<!-- Content -->
|
||||||
<img v-if="video.thumbnail" :src="video.thumbnail" :alt="video.title"
|
<div v-else-if="video" class="flex flex-col md:flex-row gap-4">
|
||||||
class="w-full h-full object-cover" />
|
<VideoPlayer :video="video" class="md:flex-1" />
|
||||||
<div v-else class="w-full h-full flex items-center justify-center">
|
|
||||||
<span class="i-heroicons-film text-gray-400 text-2xl" />
|
<div class="bg-white rounded-lg border border-gray-200 max-w-full md:max-w-md w-full flex flex-col">
|
||||||
</div>
|
<div class="px-6 py-4">
|
||||||
</div>
|
<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="">
|
||||||
|
<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">
|
||||||
|
<dl v-for="info in videoInfos" :key="info.label" class="space-y-2">
|
||||||
<div>
|
<div>
|
||||||
<h3 class="font-medium text-gray-900">{{ video.title }}</h3>
|
<dt class="text-sm font-medium text-gray-500">{{ info.label }}</dt>
|
||||||
<p class="text-sm text-gray-500 mt-1">ID: {{ video.id }}</p>
|
<dd class="text-sm text-gray-900">
|
||||||
<p class="text-sm text-gray-500">Status: {{ video.status }}</p>
|
<div class="flex items-center space-x-2">
|
||||||
|
<input readonly
|
||||||
|
class="flex-1 px-2 py-1 text-xs border border-gray-300 rounded bg-gray-50 font-mono"
|
||||||
|
:value="info.value || '-'">
|
||||||
|
<button v-if="info.value"
|
||||||
|
@click="copyToClipboard(info.value, info.label)"
|
||||||
|
class="px-2 py-1 text-xs bg-gray-100 hover:bg-gray-200 border border-gray-300 rounded transition-colors text-gray-700"
|
||||||
|
title="Copy value">
|
||||||
|
<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="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z">
|
||||||
|
</path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Form -->
|
|
||||||
<div class="field">
|
|
||||||
<label for="title" class="block text-sm font-medium text-gray-700 mb-1">Title</label>
|
|
||||||
<InputText id="title" v-model="form.title" class="w-full" />
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="field">
|
|
||||||
<label for="description" class="block text-sm font-medium text-gray-700 mb-1">Description</label>
|
|
||||||
<Textarea id="description" v-model="form.description" rows="5" class="w-full" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Actions -->
|
|
||||||
<div class="flex justify-end gap-3 pt-4 border-t border-gray-100">
|
|
||||||
<Button label="Cancel" severity="secondary" @click="handleCancel" text />
|
|
||||||
<Button label="Save Changes" @click="handleSave" :loading="saving" />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
34
src/routes/video/components/Detail/VideoEditForm.vue
Normal file
34
src/routes/video/components/Detail/VideoEditForm.vue
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
defineProps<{
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:title': [value: string];
|
||||||
|
'update:description': [value: string];
|
||||||
|
}>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<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"
|
||||||
|
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"
|
||||||
|
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>
|
||||||
|
</template>
|
||||||
126
src/routes/video/components/Detail/VideoInfoHeader.vue
Normal file
126
src/routes/video/components/Detail/VideoInfoHeader.vue
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { ModelVideo } from '@/api/client';
|
||||||
|
import { getStatusSeverity } from '@/lib/utils';
|
||||||
|
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 => {
|
||||||
|
if (!bytes) return '-';
|
||||||
|
const mb = bytes / (1024 * 1024);
|
||||||
|
if (mb < 1) return `${(bytes / 1024).toFixed(2)} KB`;
|
||||||
|
return `${mb.toFixed(2)} MB`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDuration = (seconds?: number): string => {
|
||||||
|
if (!seconds) return '-';
|
||||||
|
const hours = Math.floor(seconds / 3600);
|
||||||
|
const mins = Math.floor((seconds % 3600) / 60);
|
||||||
|
const secs = seconds % 60;
|
||||||
|
if (hours > 0) {
|
||||||
|
return `${hours}:${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDate = (dateStr?: string): string => {
|
||||||
|
if (!dateStr) return '-';
|
||||||
|
const date = new Date(dateStr);
|
||||||
|
return date.toLocaleString('en-US', {
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
year: 'numeric',
|
||||||
|
hour: 'numeric',
|
||||||
|
minute: '2-digit'
|
||||||
|
});
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex flex-col items-start justify-between mb-4 gap-4">
|
||||||
|
<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">
|
||||||
|
{{ video.title }}
|
||||||
|
</h1>
|
||||||
|
<p v-if="video.description" class="text-sm text-gray-600 whitespace-pre-wrap">{{ video.description }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<!-- Metadata -->
|
||||||
|
<div class="flex items-center space-x-4 text-sm text-gray-500">
|
||||||
|
<span>{{ formatDate(video.created_at) }}</span>
|
||||||
|
<span>{{ formatFileSize(video.size) }}</span>
|
||||||
|
<span>{{ formatDuration(video.duration) }}</span>
|
||||||
|
<Tag :value="video.status" :severity="getStatusSeverity(video.status)"
|
||||||
|
class="capitalize px-2 py-0.5 text-xs" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 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"
|
||||||
|
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"
|
||||||
|
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15">
|
||||||
|
</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')">
|
||||||
|
<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')">
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
58
src/routes/video/components/Detail/VideoInfoPanel.vue
Normal file
58
src/routes/video/components/Detail/VideoInfoPanel.vue
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { ModelVideo } from '@/api/client';
|
||||||
|
import { computed } from 'vue';
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
video: ModelVideo;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
copy: [text: string, label: string];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const handleCopy = (text: string, label: string) => {
|
||||||
|
emit('copy', text, label);
|
||||||
|
};
|
||||||
|
const videoInfos = computed(() => {
|
||||||
|
if (!props.video) return [];
|
||||||
|
const embedUrl = props.video ? `${window.location.origin}/embed/${props.video.id}` : '';
|
||||||
|
return [
|
||||||
|
{ label: 'Video ID', value: props.video.id },
|
||||||
|
{ label: 'Thumbnail URL', value: props.video.thumbnail },
|
||||||
|
{ label: 'Embed URL', value: embedUrl },
|
||||||
|
{ label: 'Iframe Code', value: embedUrl ? `<iframe src="${embedUrl}" title="${props.video.title}" width="100%" height="400" frameborder="0" allowfullscreen></iframe>` : '' },
|
||||||
|
{ label: 'Share Link', value: props.video ? `${window.location.origin}/view/${props.video.id}` : '' },
|
||||||
|
];
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="">
|
||||||
|
<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">
|
||||||
|
<dl v-for="info in videoInfos" :key="info.label" class="space-y-2">
|
||||||
|
<div>
|
||||||
|
<dt class="text-sm font-medium text-gray-500">{{ info.label }}</dt>
|
||||||
|
<dd class="text-sm text-gray-900">
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<input readonly
|
||||||
|
class="flex-1 px-2 py-1 text-xs border border-gray-300 rounded bg-gray-50 font-mono"
|
||||||
|
:value="info.value || '-'">
|
||||||
|
<button v-if="info.value" @click="handleCopy(info.value, info.label)"
|
||||||
|
class="px-2 py-1 text-xs bg-gray-100 hover:bg-gray-200 border border-gray-300 rounded transition-colors text-gray-700"
|
||||||
|
title="Copy value">
|
||||||
|
<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="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z">
|
||||||
|
</path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
31
src/routes/video/components/Detail/VideoPlayer.vue
Normal file
31
src/routes/video/components/Detail/VideoPlayer.vue
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { ModelVideo } from '@/api/client';
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
video: ModelVideo;
|
||||||
|
}>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="rounded-xl">
|
||||||
|
<div v-if="video.url" class="aspect-video rounded-xl bg-black overflow-hidden">
|
||||||
|
<video
|
||||||
|
:src="video.url"
|
||||||
|
controls
|
||||||
|
class="w-full h-full object-contain"
|
||||||
|
:poster="video.thumbnail">
|
||||||
|
Your browser does not support the video tag.
|
||||||
|
</video>
|
||||||
|
</div>
|
||||||
|
<div v-else class="w-full h-48 bg-gray-200 overflow-hidden flex-shrink-0">
|
||||||
|
<img
|
||||||
|
v-if="video.thumbnail"
|
||||||
|
:src="video.thumbnail"
|
||||||
|
:alt="video.title"
|
||||||
|
class="w-full h-full object-cover" />
|
||||||
|
<div v-else class="w-full h-full flex items-center justify-center">
|
||||||
|
<span class="i-heroicons-film text-gray-400 text-2xl" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
61
src/routes/video/components/Detail/VideoSkeleton.vue
Normal file
61
src/routes/video/components/Detail/VideoSkeleton.vue
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import Skeleton from 'primevue/skeleton';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex flex-col gap-4">
|
||||||
|
<!-- Video Player Skeleton -->
|
||||||
|
<div class="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">
|
||||||
|
<!-- Header Skeleton -->
|
||||||
|
<div class="flex items-start justify-between mb-4">
|
||||||
|
<div class="flex-1 space-y-3">
|
||||||
|
<Skeleton width="60%" height="2rem" />
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<Skeleton width="8rem" height="1rem" />
|
||||||
|
<Skeleton width="5rem" height="1rem" />
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Content Grid Skeleton -->
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
<!-- Left Column -->
|
||||||
|
<div class="space-y-4">
|
||||||
|
<Skeleton width="8rem" 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" />
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<Skeleton width="100%" height="1.75rem" />
|
||||||
|
<Skeleton width="2rem" height="1.75rem" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Right Column -->
|
||||||
|
<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">
|
||||||
|
<Skeleton width="25%" height="0.875rem" />
|
||||||
|
<Skeleton width="50%" height="1.25rem" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Skeleton width="6rem" height="1.5rem" class="mt-6" />
|
||||||
|
<Skeleton width="100%" height="4rem" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
Reference in New Issue
Block a user