first commit
This commit is contained in:
22
resources/js/pages/admin/dashboard.tsx
Normal file
22
resources/js/pages/admin/dashboard.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import AppLayout from '@/layouts/app-layout';
|
||||
import { type BreadcrumbItem } from '@/types';
|
||||
import { Head } from '@inertiajs/react';
|
||||
import VideoTimelineViewer from './partials/VideoTimelineViewer';
|
||||
|
||||
const breadcrumbs: BreadcrumbItem[] = [
|
||||
{
|
||||
title: 'Admin Dashboard',
|
||||
href: route('admin.dashboard'),
|
||||
},
|
||||
];
|
||||
|
||||
export default function Dashboard() {
|
||||
return (
|
||||
<AppLayout breadcrumbs={breadcrumbs}>
|
||||
<Head title="Dashboard" />
|
||||
<div className="flex h-full flex-1 flex-col gap-4 rounded-xl p-4">
|
||||
<VideoTimelineViewer></VideoTimelineViewer>
|
||||
</div>
|
||||
</AppLayout>
|
||||
);
|
||||
}
|
||||
482
resources/js/pages/admin/partials/VideoTimelineViewer.jsx
Normal file
482
resources/js/pages/admin/partials/VideoTimelineViewer.jsx
Normal file
@@ -0,0 +1,482 @@
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import useAdminVideoTimelineStore from '@/stores/AdminVideoTimelineStore';
|
||||
import { ZoomInIcon, ZoomOutIcon } from 'lucide-react';
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
|
||||
const VideoTimelineViewer = () => {
|
||||
const [videoUuid, setVideoUuid] = useState('c4fd0601-cac7-483b-907e-0ae4c108d3b1');
|
||||
const [zoomLevel, setZoomLevel] = useState(100); // Default zoom level (100%)
|
||||
const { getVideoTimelines, videoTimelines, isLoadingVideoTimelines } = useAdminVideoTimelineStore();
|
||||
const timelineContainerRef = useRef(null);
|
||||
|
||||
// Define a dark theme for tooltips
|
||||
const tooltipStyle = {
|
||||
content: 'p-2 bg-gray-800 border border-gray-700 rounded shadow-md z-50 text-white',
|
||||
text: 'text-white',
|
||||
highlight: 'text-white font-semibold',
|
||||
};
|
||||
|
||||
// Define an array of 20 distinct Tailwind background colors
|
||||
const colorPresets = [
|
||||
'bg-blue-500', // Blue
|
||||
'bg-green-500', // Green
|
||||
'bg-purple-500', // Purple
|
||||
'bg-red-500', // Red
|
||||
'bg-yellow-500', // Yellow
|
||||
'bg-pink-500', // Pink
|
||||
'bg-indigo-500', // Indigo
|
||||
'bg-teal-500', // Teal
|
||||
'bg-orange-500', // Orange
|
||||
'bg-cyan-500', // Cyan
|
||||
'bg-lime-500', // Lime
|
||||
'bg-emerald-500', // Emerald
|
||||
'bg-sky-500', // Sky blue
|
||||
'bg-violet-500', // Violet
|
||||
'bg-fuchsia-500', // Fuchsia
|
||||
'bg-rose-500', // Rose
|
||||
'bg-amber-500', // Amber
|
||||
'bg-blue-600', // Darker blue
|
||||
'bg-green-600', // Darker green
|
||||
'bg-purple-600', // Darker purple
|
||||
];
|
||||
|
||||
// Use a ref to persist the color map between renders
|
||||
const colorMapRef = useRef(new Map());
|
||||
|
||||
// Get color based on element type
|
||||
const getElementColor = (type) => {
|
||||
// If we've already assigned a color to this type, use it
|
||||
if (colorMapRef.current.has(type)) {
|
||||
return colorMapRef.current.get(type);
|
||||
}
|
||||
|
||||
// Otherwise, assign the next color in the sequence
|
||||
const currentCount = colorMapRef.current.size;
|
||||
const colorIndex = currentCount % colorPresets.length;
|
||||
const newColor = colorPresets[colorIndex];
|
||||
|
||||
// Store the mapping for future use
|
||||
colorMapRef.current.set(type, newColor);
|
||||
|
||||
return newColor;
|
||||
};
|
||||
|
||||
const handleSubmit = (e) => {
|
||||
e.preventDefault();
|
||||
if (videoUuid.trim()) {
|
||||
getVideoTimelines(videoUuid);
|
||||
}
|
||||
};
|
||||
|
||||
const handleZoomIn = () => {
|
||||
// Use different increments based on current zoom level
|
||||
if (zoomLevel < 50) {
|
||||
setZoomLevel((prev) => Math.min(prev + 5, 300)); // Smaller steps at low zoom
|
||||
} else {
|
||||
setZoomLevel((prev) => Math.min(prev + 25, 300)); // Larger steps at normal zoom
|
||||
}
|
||||
};
|
||||
|
||||
const handleZoomOut = () => {
|
||||
// Use different decrements based on current zoom level
|
||||
if (zoomLevel <= 50) {
|
||||
setZoomLevel((prev) => Math.max(prev - 5, 5)); // Smaller steps at low zoom, min 5%
|
||||
} else {
|
||||
setZoomLevel((prev) => Math.max(prev - 25, 5)); // Larger steps at normal zoom, min 5%
|
||||
}
|
||||
};
|
||||
|
||||
// Calculate the maximum duration for scaling
|
||||
const getMaxDuration = () => {
|
||||
if (!videoTimelines.length) return 7; // Default value
|
||||
|
||||
let maxEnd = 0;
|
||||
videoTimelines.forEach((element) => {
|
||||
const elementEnd = element.time + element.duration;
|
||||
if (elementEnd > maxEnd) {
|
||||
maxEnd = elementEnd;
|
||||
}
|
||||
});
|
||||
|
||||
return maxEnd;
|
||||
};
|
||||
|
||||
// Get unique tracks for vertical positioning
|
||||
const getTracks = () => {
|
||||
if (!videoTimelines.length) return [];
|
||||
|
||||
const tracks = [...new Set(videoTimelines.map((element) => element.track))];
|
||||
return tracks.sort((a, b) => b - a); // Sort in descending order
|
||||
};
|
||||
|
||||
// Get track information for tooltips
|
||||
const getTrackInfo = (trackNum) => {
|
||||
const trackElements = videoTimelines.filter((element) => element.track === trackNum);
|
||||
|
||||
if (trackElements.length === 0) {
|
||||
return { count: 0, duration: 0, startTime: 0, endTime: 0 };
|
||||
}
|
||||
|
||||
let totalDuration = 0;
|
||||
let minStartTime = Infinity;
|
||||
let maxEndTime = 0;
|
||||
|
||||
trackElements.forEach((element) => {
|
||||
totalDuration += element.duration;
|
||||
if (element.time < minStartTime) minStartTime = element.time;
|
||||
const endTime = element.time + element.duration;
|
||||
if (endTime > maxEndTime) maxEndTime = endTime;
|
||||
});
|
||||
|
||||
return {
|
||||
count: trackElements.length,
|
||||
duration: totalDuration,
|
||||
startTime: minStartTime,
|
||||
endTime: maxEndTime,
|
||||
elements: trackElements,
|
||||
};
|
||||
};
|
||||
|
||||
// Keep track of previous zoom level to calculate proper scrolling
|
||||
const prevZoomRef = useRef(zoomLevel);
|
||||
|
||||
// Scroll timeline to position when zoom changes
|
||||
useEffect(() => {
|
||||
if (timelineContainerRef.current && videoTimelines.length > 0) {
|
||||
const container = timelineContainerRef.current;
|
||||
const prevZoom = prevZoomRef.current;
|
||||
|
||||
// Calculate the center point of the current view
|
||||
const viewportWidth = container.clientWidth;
|
||||
const scrollLeft = container.scrollLeft;
|
||||
const centerPoint = scrollLeft + viewportWidth / 2;
|
||||
|
||||
// Calculate what percentage of the timeline this center point represents
|
||||
const timelineWidth = container.firstChild.offsetWidth;
|
||||
const centerPercentage = centerPoint / ((timelineWidth * prevZoom) / zoomLevel);
|
||||
|
||||
// Calculate the new scroll position to keep that same percentage in the center
|
||||
const newTimelineWidth = timelineWidth;
|
||||
const newCenterPoint = centerPercentage * newTimelineWidth;
|
||||
const newScrollPosition = newCenterPoint - viewportWidth / 2;
|
||||
|
||||
// Update the scroll position
|
||||
requestAnimationFrame(() => {
|
||||
container.scrollLeft = Math.max(0, newScrollPosition);
|
||||
});
|
||||
|
||||
// Save current zoom for next comparison
|
||||
prevZoomRef.current = zoomLevel;
|
||||
}
|
||||
}, [zoomLevel, videoTimelines.length]);
|
||||
|
||||
// Get label based on external reference
|
||||
const getElementLabel = (element) => {
|
||||
if (element.external_reference) {
|
||||
return element.external_reference.replace(/_/g, ' ');
|
||||
}
|
||||
return element.type;
|
||||
};
|
||||
|
||||
// Calculate time scale factor based on zoom level
|
||||
const getTimeScaleFactor = () => {
|
||||
// At 100%, scale factor is 1
|
||||
// Below 100%, scale shrinks proportionally
|
||||
// Above 100%, scale grows proportionally
|
||||
return zoomLevel / 100;
|
||||
};
|
||||
|
||||
const maxDuration = getMaxDuration();
|
||||
const tracks = getTracks();
|
||||
const trackHeight = 40; // Height of each track row
|
||||
const timelineHeight = tracks.length * trackHeight;
|
||||
|
||||
return (
|
||||
<Card className="mx-auto w-full shadow-lg">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-xl font-semibold">Video UUID</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit} className="flex flex-col space-y-2 sm:flex-row sm:space-y-0 sm:space-x-2">
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Enter video UUID..."
|
||||
value={videoUuid}
|
||||
onChange={(e) => setVideoUuid(e.target.value)}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button type="submit" disabled={isLoadingVideoTimelines} className="w-full sm:w-auto">
|
||||
{isLoadingVideoTimelines ? 'Loading...' : 'Submit'}
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
|
||||
<Separator className="my-2" />
|
||||
|
||||
<CardContent className="pb-6">
|
||||
<div className="mt-4">
|
||||
<h3 className="mb-2 font-medium">Timeline Preview</h3>
|
||||
|
||||
{/* Zoom controls */}
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-8 w-8 p-0"
|
||||
onClick={handleZoomOut}
|
||||
disabled={zoomLevel <= 5}
|
||||
title="Zoom out"
|
||||
>
|
||||
<ZoomOutIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
<span className="text-xs">{zoomLevel}%</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-8 w-8 p-0"
|
||||
onClick={handleZoomIn}
|
||||
disabled={zoomLevel >= 300}
|
||||
title="Zoom in"
|
||||
>
|
||||
<ZoomInIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
<span>Adjust zoom to change timeline scale</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Timeline container with horizontal scroll for small screens */}
|
||||
<div className="overflow-x-auto" ref={timelineContainerRef}>
|
||||
<div
|
||||
className="min-w-[640px]"
|
||||
style={
|
||||
{
|
||||
// We don't scale the container width with zoom anymore
|
||||
// Instead we scale the individual elements inside
|
||||
}
|
||||
}
|
||||
>
|
||||
{/* Timeline header - time markers with tick marks */}
|
||||
<div className="flex h-8 border-b border-gray-200 text-xs text-gray-500">
|
||||
<div className="w-16 flex-shrink-0 pr-2 font-medium sm:w-20">Track</div>
|
||||
<div className="relative flex-1">
|
||||
{/* Vertical guideline marks at each second */}
|
||||
{Array.from({ length: Math.ceil(maxDuration) + 1 }).map((_, i) => {
|
||||
const scaleFactor = zoomLevel / 100;
|
||||
const baseWidth = 100; // Base width in pixels at 100% zoom
|
||||
const position = i * baseWidth * scaleFactor;
|
||||
|
||||
return (
|
||||
<React.Fragment key={i}>
|
||||
<div
|
||||
className="absolute h-full border-l border-gray-200"
|
||||
style={{
|
||||
left: `${position}px`,
|
||||
height: `${timelineHeight + 20}px`,
|
||||
top: '16px',
|
||||
zIndex: 0,
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="absolute -mx-1 text-start"
|
||||
style={{
|
||||
left: `${position}px`,
|
||||
width: `${baseWidth * scaleFactor}px`,
|
||||
}}
|
||||
>
|
||||
{i}s
|
||||
</div>
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="relative"
|
||||
style={{
|
||||
height: `${timelineHeight}px`,
|
||||
paddingLeft: '0px', // Remove any padding that might affect alignment
|
||||
}}
|
||||
>
|
||||
{/* Track rows */}
|
||||
{tracks.map((trackNum, index) => {
|
||||
const trackInfo = getTrackInfo(trackNum);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={trackNum}
|
||||
className="absolute flex w-full items-center border-b border-gray-100"
|
||||
style={{
|
||||
top: `${index * trackHeight}px`,
|
||||
height: `${trackHeight}px`,
|
||||
zIndex: 1, // Ensure track labels are above elements
|
||||
}}
|
||||
>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button className="w-16 flex-shrink-0 cursor-help bg-white pr-2 text-left text-xs font-medium transition-colors hover:bg-gray-50 sm:w-20">
|
||||
Track {trackNum}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side="right"
|
||||
sideOffset={5}
|
||||
className="z-50 rounded border border-gray-700 bg-gray-800 p-3 text-white shadow-md"
|
||||
>
|
||||
<div className="space-y-1 text-sm">
|
||||
<p className="font-semibold">Track {trackNum}</p>
|
||||
<p>Elements: {trackInfo.count}</p>
|
||||
<p>Total duration: {trackInfo.duration.toFixed(2)}s</p>
|
||||
{trackInfo.count > 0 && (
|
||||
<>
|
||||
<p>Start time: {trackInfo.startTime.toFixed(2)}s</p>
|
||||
<p>End time: {trackInfo.endTime.toFixed(2)}s</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Timeline elements */}
|
||||
{videoTimelines.map((element) => {
|
||||
const trackIndex = tracks.indexOf(element.track);
|
||||
const scaleFactor = zoomLevel / 100;
|
||||
const baseWidth = 100; // Base width of 1 second at 100% zoom
|
||||
|
||||
// Position from the exact timeline start (after the track label)
|
||||
const trackLabelWidth = 64; // 16rem = 64px on small screens, 80px on larger
|
||||
const exactStartPosition = element.time * baseWidth * scaleFactor;
|
||||
const startPosition = trackLabelWidth + exactStartPosition;
|
||||
const elementWidth = element.duration * baseWidth * scaleFactor;
|
||||
|
||||
return (
|
||||
<TooltipProvider key={element.id}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div
|
||||
className={`absolute ml-4 cursor-pointer rounded px-1 text-xs text-white ${getElementColor(element.external_reference)} flex items-center hover:opacity-90`}
|
||||
style={{
|
||||
top: `${trackIndex * trackHeight + 5}px`,
|
||||
left: `${startPosition}px`,
|
||||
width: `${Math.max(2, elementWidth - 1)}px`, // Subtract 1px to create spacing, min 2px
|
||||
height: `${trackHeight - 10}px`,
|
||||
overflow: 'hidden',
|
||||
whiteSpace: 'nowrap',
|
||||
textOverflow: 'ellipsis',
|
||||
minWidth: zoomLevel < 20 ? '2px' : zoomLevel < 50 ? '10px' : '30px', // Adaptive minimum width
|
||||
marginRight: '1px', // Add 1px space between elements
|
||||
zIndex: 2, // Ensure elements are above track rows
|
||||
}}
|
||||
>
|
||||
{getElementLabel(element)}
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side="top"
|
||||
sideOffset={5}
|
||||
className="z-50 rounded border border-gray-700 bg-gray-800 p-2 text-white shadow-md"
|
||||
>
|
||||
<div className="space-y-1 text-sm">
|
||||
<p className="font-semibold">{getElementLabel(element)}</p>
|
||||
<p>Type: {element.type}</p>
|
||||
<p>Track: {element.track}</p>
|
||||
<p>Start: {element.time.toFixed(2)}s</p>
|
||||
<p>Duration: {element.duration.toFixed(2)}s</p>
|
||||
<p>End: {(element.time + element.duration).toFixed(2)}s</p>
|
||||
{element.id && <p>ID: {element.id}</p>}
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Element details section */}
|
||||
<div className="mt-4">
|
||||
<h3 className="mb-2 font-medium">Element Details</h3>
|
||||
<div className="overflow-auto rounded border border-gray-200" style={{ maxHeight: '40vh' }}>
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="sticky top-0 bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-2 py-2 text-left text-xs font-medium text-gray-500 sm:px-4">ID</th>
|
||||
<th className="px-2 py-2 text-left text-xs font-medium text-gray-500 sm:px-4">Type</th>
|
||||
<th className="px-2 py-2 text-left text-xs font-medium text-gray-500 sm:px-4">Reference</th>
|
||||
<th className="px-2 py-2 text-left text-xs font-medium text-gray-500 sm:px-4">Time</th>
|
||||
<th className="px-2 py-2 text-left text-xs font-medium text-gray-500 sm:px-4">Duration</th>
|
||||
<th className="px-2 py-2 text-left text-xs font-medium text-gray-500 sm:px-4">Track</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200 bg-white">
|
||||
{videoTimelines.map((element) => (
|
||||
<tr key={element.id} className="hover:bg-gray-50">
|
||||
<td className="px-2 py-2 text-xs whitespace-nowrap sm:px-4">{element.id}</td>
|
||||
<td className="px-2 py-2 text-xs whitespace-nowrap sm:px-4">{element.type}</td>
|
||||
<td className="px-2 py-2 text-xs whitespace-nowrap sm:px-4">
|
||||
<span
|
||||
className={`inline-block h-3 w-3 rounded-full ${getElementColor(element.external_reference)}`}
|
||||
></span>{' '}
|
||||
{element.external_reference}
|
||||
</td>
|
||||
<td className="px-2 py-2 text-xs whitespace-nowrap sm:px-4">{element.time}s</td>
|
||||
<td className="px-2 py-2 text-xs whitespace-nowrap sm:px-4">{element.duration}s</td>
|
||||
<td className="px-2 py-2 text-xs whitespace-nowrap sm:px-4">{element.track}</td>
|
||||
</tr>
|
||||
))}
|
||||
{videoTimelines.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan="6" className="py-4 text-center text-sm text-gray-500">
|
||||
No timeline data available
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
<CardFooter className="flex flex-col gap-2 py-2 sm:flex-row sm:justify-between">
|
||||
<div className="flex flex-wrap gap-2 text-xs text-gray-500">
|
||||
{/* Dynamically generate color legend based on types that appear in the data */}
|
||||
{Array.from(colorMapRef.current.entries()).map(([type, color]) => (
|
||||
<div key={type} className="flex items-center">
|
||||
<span className={`mr-1 inline-block h-3 w-3 rounded-full ${color}`}></span>
|
||||
{type}
|
||||
</div>
|
||||
))}
|
||||
{colorMapRef.current.size === 0 && (
|
||||
<>
|
||||
<div className="flex items-center">
|
||||
<span className="mr-1 inline-block h-3 w-3 rounded-full bg-blue-500"></span>
|
||||
No elements
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-xs text-gray-400">Total elements: {videoTimelines.length}</p>
|
||||
<div className="text-xs text-gray-400">|</div>
|
||||
<p className="text-xs text-gray-400">Zoom: {zoomLevel}%</p>
|
||||
</div>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default VideoTimelineViewer;
|
||||
Reference in New Issue
Block a user