- Removed manual 'images' metadata overrides. - This allows Next.js to use built-in automatic discovery. - Ensures metadata uses the dynamic metadataBase from the environment. - Refined Traefik public router regex for sub-routes. - Restored and verified imports in modified page.tsx files.
353 lines
12 KiB
TypeScript
353 lines
12 KiB
TypeScript
'use client';
|
|
|
|
import React, { useState, useEffect } from 'react';
|
|
import { useRecordMode } from './RecordModeContext';
|
|
import { finder } from '@medv/finder';
|
|
import {
|
|
Play,
|
|
Square,
|
|
MousePointer2,
|
|
Scroll,
|
|
Plus,
|
|
Save,
|
|
Trash2,
|
|
Eye,
|
|
Edit2,
|
|
X,
|
|
Check,
|
|
} from 'lucide-react';
|
|
import { RecordEvent } from '@/types/record-mode';
|
|
|
|
export function RecordModeOverlay() {
|
|
const {
|
|
isActive,
|
|
setIsActive,
|
|
isRecording,
|
|
startRecording,
|
|
stopRecording,
|
|
events,
|
|
addEvent,
|
|
updateEvent,
|
|
removeEvent,
|
|
isPlaying,
|
|
playEvents,
|
|
saveSession,
|
|
clearEvents,
|
|
} = useRecordMode();
|
|
|
|
const [pickingMode, setPickingMode] = useState<'click' | 'scroll' | null>(null);
|
|
const [hoveredElement, setHoveredElement] = useState<HTMLElement | null>(null);
|
|
const [editingEventId, setEditingEventId] = useState<string | null>(null);
|
|
|
|
// Edit form state
|
|
const [editForm, setEditForm] = useState<Partial<RecordEvent>>({});
|
|
|
|
useEffect(() => {
|
|
if (!isActive) return;
|
|
|
|
const handleMouseOver = (e: MouseEvent) => {
|
|
if (pickingMode) {
|
|
e.stopPropagation();
|
|
e.preventDefault();
|
|
const target = e.target as HTMLElement;
|
|
if (target.closest('.record-mode-ui')) return;
|
|
setHoveredElement(target);
|
|
}
|
|
};
|
|
|
|
const handleClick = (e: MouseEvent) => {
|
|
if (pickingMode && hoveredElement) {
|
|
e.stopPropagation();
|
|
e.preventDefault();
|
|
|
|
const selector = finder(hoveredElement);
|
|
|
|
if (pickingMode === 'click') {
|
|
addEvent({
|
|
type: 'click',
|
|
selector,
|
|
duration: 1000,
|
|
zoom: 1,
|
|
description: `Click on ${hoveredElement.tagName.toLowerCase()}`,
|
|
motionBlur: false,
|
|
});
|
|
} else if (pickingMode === 'scroll') {
|
|
addEvent({
|
|
type: 'scroll',
|
|
selector,
|
|
duration: 1000,
|
|
zoom: 1,
|
|
description: `Scroll to ${hoveredElement.tagName.toLowerCase()}`,
|
|
motionBlur: false,
|
|
});
|
|
}
|
|
|
|
setPickingMode(null);
|
|
setHoveredElement(null);
|
|
}
|
|
};
|
|
|
|
if (pickingMode) {
|
|
window.addEventListener('mouseover', handleMouseOver);
|
|
window.addEventListener('click', handleClick, true);
|
|
}
|
|
|
|
return () => {
|
|
window.removeEventListener('mouseover', handleMouseOver);
|
|
window.removeEventListener('click', handleClick, true);
|
|
};
|
|
}, [isActive, pickingMode, hoveredElement, addEvent]);
|
|
|
|
const startEditing = (event: RecordEvent) => {
|
|
setEditingEventId(event.id);
|
|
setEditForm({ ...event });
|
|
};
|
|
|
|
const saveEdit = () => {
|
|
if (editingEventId && editForm) {
|
|
updateEvent(editingEventId, editForm);
|
|
setEditingEventId(null);
|
|
setEditForm({});
|
|
}
|
|
};
|
|
|
|
const cancelEdit = () => {
|
|
setEditingEventId(null);
|
|
setEditForm({});
|
|
};
|
|
|
|
if (!isActive) {
|
|
return (
|
|
<button
|
|
onClick={() => setIsActive(true)}
|
|
className="fixed bottom-4 right-4 z-[9999] bg-red-600 text-white p-3 rounded-full shadow-lg hover:scale-110 transition-transform record-mode-ui"
|
|
>
|
|
<div className="w-4 h-4 rounded-full bg-white" />
|
|
</button>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="fixed inset-0 z-[9999] pointer-events-none">
|
|
{/* Hover Highlighter */}
|
|
{pickingMode && hoveredElement && (
|
|
<div
|
|
className="fixed pointer-events-none border-2 border-red-500 bg-red-500/20 transition-all z-[9998]"
|
|
style={{
|
|
top: hoveredElement.getBoundingClientRect().top,
|
|
left: hoveredElement.getBoundingClientRect().left,
|
|
width: hoveredElement.getBoundingClientRect().width,
|
|
height: hoveredElement.getBoundingClientRect().height,
|
|
}}
|
|
/>
|
|
)}
|
|
|
|
{/* Control Panel */}
|
|
<div className="fixed bottom-8 left-1/2 -translate-x-1/2 bg-black/80 backdrop-blur-md text-white p-4 rounded-xl shadow-2xl pointer-events-auto record-mode-ui border border-white/10 w-[600px] max-h-[80vh] flex flex-col">
|
|
<div className="flex items-center justify-between mb-4">
|
|
<h3 className="font-bold flex items-center gap-2">
|
|
<div
|
|
className={`w-3 h-3 rounded-full ${isRecording ? 'bg-red-500 animate-pulse' : 'bg-gray-500'}`}
|
|
/>
|
|
Record Mode
|
|
</h3>
|
|
<button onClick={() => setIsActive(false)} className="text-white/50 hover:text-white">
|
|
Close
|
|
</button>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-2 mb-4 overflow-x-auto pb-2">
|
|
{!isRecording ? (
|
|
<button
|
|
onClick={startRecording}
|
|
className="flex items-center gap-2 bg-red-600 px-4 py-2 rounded-lg hover:bg-red-700 whitespace-nowrap"
|
|
>
|
|
<div className="w-3 h-3 rounded-full bg-white" />
|
|
Start Rec
|
|
</button>
|
|
) : (
|
|
<button
|
|
onClick={stopRecording}
|
|
className="flex items-center gap-2 bg-gray-700 px-4 py-2 rounded-lg hover:bg-gray-600 whitespace-nowrap"
|
|
>
|
|
<Square size={16} fill="currentColor" />
|
|
Stop Rec
|
|
</button>
|
|
)}
|
|
|
|
<div className="w-px h-8 bg-white/20 mx-2" />
|
|
|
|
<button
|
|
disabled={!isRecording}
|
|
onClick={() => setPickingMode('click')}
|
|
className={`flex items-center gap-2 px-3 py-2 rounded-lg transition-colors whitespace-nowrap ${pickingMode === 'click' ? 'bg-blue-600' : 'hover:bg-white/10 disabled:opacity-50'}`}
|
|
>
|
|
<MousePointer2 size={16} />
|
|
Add Click
|
|
</button>
|
|
|
|
<button
|
|
disabled={!isRecording}
|
|
onClick={() => setPickingMode('scroll')}
|
|
className={`flex items-center gap-2 px-3 py-2 rounded-lg transition-colors whitespace-nowrap ${pickingMode === 'scroll' ? 'bg-blue-600' : 'hover:bg-white/10 disabled:opacity-50'}`}
|
|
>
|
|
<Scroll size={16} />
|
|
Add Scroll
|
|
</button>
|
|
|
|
<div className="w-px h-8 bg-white/20 mx-2" />
|
|
|
|
<button
|
|
onClick={playEvents}
|
|
disabled={isRecording || events.length === 0}
|
|
className="p-2 hover:bg-white/10 rounded-lg disabled:opacity-50"
|
|
>
|
|
<Play size={20} />
|
|
</button>
|
|
|
|
<button
|
|
onClick={() => saveSession('Session 1')}
|
|
disabled={events.length === 0}
|
|
className="p-2 hover:bg-white/10 rounded-lg disabled:opacity-50"
|
|
>
|
|
<Save size={20} />
|
|
</button>
|
|
|
|
<button
|
|
onClick={clearEvents}
|
|
disabled={events.length === 0}
|
|
className="p-2 hover:bg-red-500/20 text-red-400 rounded-lg disabled:opacity-50"
|
|
>
|
|
<Trash2 size={20} />
|
|
</button>
|
|
</div>
|
|
|
|
{/* Edit Form */}
|
|
{editingEventId && (
|
|
<div className="bg-blue-900/40 p-3 rounded-lg mb-4 border border-blue-500/30">
|
|
<div className="flex justify-between items-center mb-2">
|
|
<span className="font-bold text-sm text-blue-300">Edit Event</span>
|
|
<div className="flex gap-1">
|
|
<button
|
|
onClick={saveEdit}
|
|
className="p-1 hover:bg-green-500/20 text-green-400 rounded"
|
|
>
|
|
<Check size={14} />
|
|
</button>
|
|
<button
|
|
onClick={cancelEdit}
|
|
className="p-1 hover:bg-red-500/20 text-red-400 rounded"
|
|
>
|
|
<X size={14} />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div className="grid grid-cols-2 gap-2 text-xs">
|
|
<div>
|
|
<label className="block text-white/50 mb-1">Type</label>
|
|
<select
|
|
value={editForm.type}
|
|
onChange={(e) => setEditForm({ ...editForm, type: e.target.value as any })}
|
|
className="w-full bg-black/40 border border-white/10 rounded p-1"
|
|
>
|
|
<option value="click">Click</option>
|
|
<option value="scroll">Scroll</option>
|
|
<option value="wait">Wait</option>
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label className="block text-white/50 mb-1">Duration (ms)</label>
|
|
<input
|
|
type="number"
|
|
value={editForm.duration}
|
|
onChange={(e) => setEditForm({ ...editForm, duration: parseInt(e.target.value) })}
|
|
className="w-full bg-black/40 border border-white/10 rounded p-1"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="block text-white/50 mb-1">Zoom (x)</label>
|
|
<input
|
|
type="number"
|
|
step="0.1"
|
|
value={editForm.zoom}
|
|
onChange={(e) => setEditForm({ ...editForm, zoom: parseFloat(e.target.value) })}
|
|
className="w-full bg-black/40 border border-white/10 rounded p-1"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="block text-white/50 mb-1">Motion Blur</label>
|
|
<button
|
|
onClick={() => setEditForm({ ...editForm, motionBlur: !editForm.motionBlur })}
|
|
className={`w-full p-1 rounded text-center border ${editForm.motionBlur ? 'bg-blue-500/20 border-blue-500 text-blue-300' : 'bg-black/40 border-white/10 text-white/50'}`}
|
|
>
|
|
{editForm.motionBlur ? 'Enabled' : 'Disabled'}
|
|
</button>
|
|
</div>
|
|
<div className="col-span-2">
|
|
<label className="block text-white/50 mb-1">Description</label>
|
|
<input
|
|
type="text"
|
|
value={editForm.description || ''}
|
|
onChange={(e) => setEditForm({ ...editForm, description: e.target.value })}
|
|
className="w-full bg-black/40 border border-white/10 rounded p-1"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Event Timeline */}
|
|
<div className="bg-white/5 rounded-lg p-2 flex-1 overflow-y-auto space-y-2 min-h-[200px]">
|
|
{events.length === 0 && (
|
|
<div className="text-center text-white/30 text-sm py-4">No events recorded yet.</div>
|
|
)}
|
|
{events.map((event, index) => (
|
|
<div
|
|
key={event.id}
|
|
className={`flex items-center gap-3 bg-white/5 p-2 rounded text-sm group cursor-pointer hover:bg-white/10 border border-transparent ${editingEventId === event.id ? 'border-blue-500 bg-blue-500/10' : ''}`}
|
|
onClick={() => startEditing(event)}
|
|
>
|
|
<span className="text-white/30 w-6 text-center">{index + 1}</span>
|
|
{event.type === 'click' && <MousePointer2 size={14} className="text-blue-400" />}
|
|
{event.type === 'scroll' && <Scroll size={14} className="text-green-400" />}
|
|
|
|
<div className="flex-1 truncate">
|
|
<span className="font-mono text-white/50 text-xs mr-2">{event.selector}</span>
|
|
{event.motionBlur && (
|
|
<span className="text-[10px] bg-purple-500/20 text-purple-300 px-1 rounded ml-1">
|
|
Blur
|
|
</span>
|
|
)}
|
|
{event.zoom && event.zoom !== 1 && (
|
|
<span className="text-[10px] bg-yellow-500/20 text-yellow-300 px-1 rounded ml-1">
|
|
x{event.zoom}
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
<span className="text-xs text-white/40">{(event.timestamp / 1000).toFixed(1)}s</span>
|
|
|
|
<button
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
removeEvent(event.id);
|
|
}}
|
|
className="opacity-0 group-hover:opacity-100 p-1 hover:bg-white/10 rounded text-red-400"
|
|
>
|
|
<Trash2 size={12} />
|
|
</button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Picking Instructions */}
|
|
{pickingMode && (
|
|
<div className="fixed top-8 left-1/2 -translate-x-1/2 bg-blue-600 text-white px-6 py-2 rounded-full shadow-xl z-[10000] animate-bounce">
|
|
Select element to {pickingMode}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|