All checks were successful
Build & Deploy / 🔍 Prepare (push) Successful in 31s
Build & Deploy / 🧪 QA (push) Successful in 1m31s
Build & Deploy / 🏗️ Build (push) Successful in 2m54s
Build & Deploy / 🚀 Deploy (push) Successful in 36s
Build & Deploy / 🧪 Post-Deploy Verification (push) Successful in 1m0s
Build & Deploy / 🔔 Notify (push) Successful in 3s
628 lines
26 KiB
TypeScript
628 lines
26 KiB
TypeScript
import { useState, useEffect, useMemo } from "react";
|
|
import { motion, AnimatePresence } from "framer-motion";
|
|
import { MessageSquare, X, Check, Image as ImageIcon, Type, StickyNote, Download, Trash2, List } from "lucide-react";
|
|
import { clsx, type ClassValue } from "clsx";
|
|
import { twMerge } from "tailwind-merge";
|
|
import { finder } from "@medv/finder";
|
|
|
|
function cn(...inputs: ClassValue[]) {
|
|
return twMerge(clsx(inputs));
|
|
}
|
|
|
|
export interface Annotation {
|
|
id: string;
|
|
x: number;
|
|
y: number;
|
|
selector: string;
|
|
type: "text" | "image" | "note";
|
|
originalContent: string;
|
|
newContent: string;
|
|
url: string;
|
|
elementRect: Record<string, number> | null;
|
|
scrollX: number;
|
|
scrollY: number;
|
|
viewportWidth: number;
|
|
viewportHeight: number;
|
|
userAgent: string;
|
|
timestamp: string;
|
|
}
|
|
|
|
export interface AnnotatorProps {
|
|
assets?: string[]; // Array of image URLs for the media browser
|
|
onSubmit?: (annotations: Annotation[]) => Promise<void>;
|
|
}
|
|
|
|
export function Annotator({ assets = [], onSubmit }: AnnotatorProps) {
|
|
const [isActive, setIsActive] = useState(false);
|
|
const [hoveredElement, setHoveredElement] = useState<HTMLElement | null>(null);
|
|
const [selectedElement, setSelectedElement] = useState<HTMLElement | null>(null);
|
|
const [annotations, setAnnotations] = useState<Annotation[]>(() => {
|
|
if (typeof window !== "undefined") {
|
|
const saved = localStorage.getItem("mintel-annotations");
|
|
if (saved) {
|
|
try {
|
|
return JSON.parse(saved);
|
|
} catch (e) {
|
|
console.error("Failed to parse saved annotations", e);
|
|
}
|
|
}
|
|
}
|
|
return [];
|
|
});
|
|
|
|
useEffect(() => {
|
|
if (typeof window !== "undefined") {
|
|
localStorage.setItem("mintel-annotations", JSON.stringify(annotations));
|
|
}
|
|
}, [annotations]);
|
|
|
|
// Modal state
|
|
const [currentType, setCurrentType] = useState<"text" | "image" | "note">("text");
|
|
const [currentText, setCurrentText] = useState("");
|
|
const [selectedAsset, setSelectedAsset] = useState<string | null>(null);
|
|
const [showList, setShowList] = useState(false);
|
|
const [searchTerm, setSearchTerm] = useState("");
|
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
|
|
// Disable overlays if needed (e.g. inside iframes)
|
|
const isExcluded = useMemo(() => {
|
|
if (typeof window === "undefined") return false;
|
|
return window.self !== window.top;
|
|
}, []);
|
|
|
|
const getSelector = (el: HTMLElement): string => {
|
|
try {
|
|
return finder(el, {
|
|
root: document.body,
|
|
className: (name) =>
|
|
!name.startsWith("annotator-") &&
|
|
!name.includes("[") &&
|
|
!name.includes("/") &&
|
|
!name.match(/^[a-z]-[0-9]/),
|
|
idName: (name) => !name.startsWith("__next") && !name.includes(":"),
|
|
});
|
|
} catch {
|
|
return "unknown";
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
if (!isActive) {
|
|
setHoveredElement(null);
|
|
return;
|
|
}
|
|
|
|
const handleMouseMove = (e: MouseEvent) => {
|
|
if (selectedElement) return;
|
|
const target = e.target as HTMLElement;
|
|
if (target.closest(".annotator-ui-ignore")) {
|
|
setHoveredElement(null);
|
|
return;
|
|
}
|
|
setHoveredElement(target);
|
|
};
|
|
|
|
const handleClick = (e: MouseEvent) => {
|
|
if (selectedElement) return;
|
|
const target = e.target as HTMLElement;
|
|
if (target.closest(".annotator-ui-ignore")) return;
|
|
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
|
|
setSelectedElement(target);
|
|
setHoveredElement(null);
|
|
|
|
// Auto-detect type
|
|
if (target.tagName.toLowerCase() === "img") {
|
|
setCurrentType("image");
|
|
} else {
|
|
setCurrentType("text");
|
|
setCurrentText(target.innerText || "");
|
|
}
|
|
};
|
|
|
|
window.addEventListener("mousemove", handleMouseMove);
|
|
window.addEventListener("click", handleClick, true);
|
|
|
|
return () => {
|
|
window.removeEventListener("mousemove", handleMouseMove);
|
|
window.removeEventListener("click", handleClick, true);
|
|
};
|
|
}, [isActive, selectedElement]);
|
|
|
|
useEffect(() => {
|
|
const handleKeyDown = (e: KeyboardEvent) => {
|
|
if (e.key === "Escape") {
|
|
if (selectedElement) {
|
|
setSelectedElement(null);
|
|
} else if (isActive) {
|
|
setIsActive(false);
|
|
}
|
|
}
|
|
};
|
|
window.addEventListener("keydown", handleKeyDown);
|
|
return () => window.removeEventListener("keydown", handleKeyDown);
|
|
}, [isActive, selectedElement]);
|
|
|
|
const saveAnnotation = () => {
|
|
if (!selectedElement) return;
|
|
|
|
const rect = selectedElement.getBoundingClientRect();
|
|
const newContent = currentType === "image" ? selectedAsset || "" : currentText;
|
|
|
|
if (!newContent) return;
|
|
|
|
const annotation: Annotation = {
|
|
id: Math.random().toString(36).substring(2, 9),
|
|
x: rect.left + rect.width / 2 + window.scrollX,
|
|
y: rect.top + rect.height / 2 + window.scrollY,
|
|
selector: getSelector(selectedElement),
|
|
type: currentType,
|
|
originalContent: currentType === "image" ? (selectedElement as HTMLImageElement).src || "" : selectedElement.innerText,
|
|
newContent,
|
|
url: window.location.href,
|
|
elementRect: rect ? {
|
|
x: rect.x, y: rect.y, width: rect.width, height: rect.height,
|
|
top: rect.top, right: rect.right, bottom: rect.bottom, left: rect.left
|
|
} : null,
|
|
scrollX: window.scrollX,
|
|
scrollY: window.scrollY,
|
|
viewportWidth: window.innerWidth,
|
|
viewportHeight: window.innerHeight,
|
|
userAgent: navigator.userAgent,
|
|
timestamp: new Date().toISOString(),
|
|
};
|
|
|
|
setAnnotations([...annotations, annotation]);
|
|
setSelectedElement(null);
|
|
setCurrentText("");
|
|
setSelectedAsset(null);
|
|
};
|
|
|
|
const exportJSON = () => {
|
|
const dataStr = "data:text/json;charset=utf-8," + encodeURIComponent(JSON.stringify(annotations, null, 2));
|
|
const downloadAnchorNode = document.createElement("a");
|
|
downloadAnchorNode.setAttribute("href", dataStr);
|
|
downloadAnchorNode.setAttribute("download", "corrections.json");
|
|
document.body.appendChild(downloadAnchorNode);
|
|
downloadAnchorNode.click();
|
|
downloadAnchorNode.remove();
|
|
};
|
|
|
|
const handleSubmit = async () => {
|
|
if (!onSubmit || annotations.length === 0) return;
|
|
setIsSubmitting(true);
|
|
try {
|
|
await onSubmit(annotations);
|
|
setAnnotations([]);
|
|
setShowList(false);
|
|
} catch (error) {
|
|
console.error("Submission failed", error);
|
|
alert("Fehler beim Senden der Korrekturen. Bitte JSON Export nutzen.");
|
|
} finally {
|
|
setIsSubmitting(false);
|
|
}
|
|
};
|
|
|
|
const deleteAnnotation = (id: string) => {
|
|
setAnnotations(annotations.filter((a) => a.id !== id));
|
|
};
|
|
|
|
const [updateTick, setUpdateTick] = useState(0);
|
|
|
|
useEffect(() => {
|
|
if (!isActive && !selectedElement) return;
|
|
|
|
let rafId: number;
|
|
const handleScroll = () => {
|
|
cancelAnimationFrame(rafId);
|
|
rafId = requestAnimationFrame(() => setUpdateTick(t => t + 1));
|
|
};
|
|
|
|
// Use capture phase to catch scrolls on any nested container
|
|
window.addEventListener("scroll", handleScroll, true);
|
|
window.addEventListener("resize", handleScroll);
|
|
|
|
return () => {
|
|
window.removeEventListener("scroll", handleScroll, true);
|
|
window.removeEventListener("resize", handleScroll);
|
|
cancelAnimationFrame(rafId);
|
|
};
|
|
}, [isActive, selectedElement]);
|
|
|
|
const hoveredRect = useMemo(() => hoveredElement?.getBoundingClientRect(), [hoveredElement, updateTick]);
|
|
const selectedRect = useMemo(() => selectedElement?.getBoundingClientRect(), [selectedElement, updateTick]);
|
|
|
|
if (isExcluded) return null;
|
|
|
|
return (
|
|
<div className="annotator-ui-ignore">
|
|
{/* 1. Global Toolbar */}
|
|
<div className="fixed bottom-24 right-4 z-[9999] flex flex-col items-end gap-2">
|
|
<div className="bg-black/80 backdrop-blur-xl border border-white/10 p-1.5 rounded-2xl shadow-2xl flex flex-col items-center gap-1">
|
|
<button
|
|
onClick={() => setIsActive(!isActive)}
|
|
className={cn(
|
|
"p-2.5 rounded-xl transition-all",
|
|
isActive
|
|
? "bg-blue-500 text-white shadow-lg shadow-blue-500/20"
|
|
: "text-white/70 hover:text-white hover:bg-white/10"
|
|
)}
|
|
title={isActive ? "Modus beenden" : "Korrekturen erfassen"}
|
|
>
|
|
{isActive ? <X size={20} /> : <MessageSquare size={20} />}
|
|
</button>
|
|
|
|
<div className="w-6 h-px bg-white/10 my-1" />
|
|
|
|
<button
|
|
onClick={() => setShowList(!showList)}
|
|
className="p-2.5 text-white/70 hover:text-white hover:bg-white/10 rounded-xl relative"
|
|
title="Liste anzeigen"
|
|
>
|
|
<List size={20} />
|
|
{annotations.length > 0 && (
|
|
<span className="absolute -top-1 -right-1 w-4 h-4 bg-blue-500 text-[9px] flex items-center justify-center rounded-full text-white font-bold border border-[#1a1a1a]">
|
|
{annotations.length}
|
|
</span>
|
|
)}
|
|
</button>
|
|
|
|
{annotations.length > 0 && (
|
|
<>
|
|
<div className="w-6 h-px bg-white/10 my-1" />
|
|
<button
|
|
onClick={exportJSON}
|
|
className="p-2.5 rounded-xl text-white/70 hover:text-white hover:bg-white/10 transition-all"
|
|
title="JSON Export"
|
|
>
|
|
<Download size={20} />
|
|
</button>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* 2. Highlights */}
|
|
<AnimatePresence>
|
|
{isActive && (
|
|
<div className="fixed inset-0 pointer-events-none z-[9998]">
|
|
{hoveredRect && (
|
|
<motion.div
|
|
initial={{ opacity: 0 }}
|
|
animate={{ opacity: 1 }}
|
|
exit={{ opacity: 0 }}
|
|
className="absolute border-2 border-blue-400 bg-blue-400/10 rounded-sm transition-all duration-200"
|
|
style={{
|
|
top: hoveredRect.top,
|
|
left: hoveredRect.left,
|
|
width: hoveredRect.width,
|
|
height: hoveredRect.height,
|
|
}}
|
|
/>
|
|
)}
|
|
|
|
{selectedRect && (
|
|
<motion.div
|
|
initial={{ scale: 0.9, opacity: 0 }}
|
|
animate={{ scale: 1, opacity: 1 }}
|
|
className="absolute border-2 border-yellow-400 bg-yellow-400/20 rounded-sm"
|
|
style={{
|
|
top: selectedRect.top,
|
|
left: selectedRect.left,
|
|
width: selectedRect.width,
|
|
height: selectedRect.height,
|
|
}}
|
|
/>
|
|
)}
|
|
</div>
|
|
)}
|
|
</AnimatePresence>
|
|
|
|
{/* 3. Action Modal */}
|
|
<AnimatePresence>
|
|
{selectedElement && (
|
|
<div className="fixed inset-0 flex items-center justify-center z-[10000] bg-black/40 backdrop-blur-sm">
|
|
<motion.div
|
|
initial={{ opacity: 0, y: 20, scale: 0.95 }}
|
|
animate={{ opacity: 1, y: 0, scale: 1 }}
|
|
exit={{ opacity: 0, y: 20, scale: 0.95 }}
|
|
className="bg-[#1c1c1e] border border-white/10 rounded-3xl p-4 sm:p-6 w-[95vw] md:w-[800px] shadow-2xl flex flex-col max-h-[85vh]"
|
|
>
|
|
<div className="flex items-center justify-between mb-6">
|
|
<h3 className="text-white font-bold text-lg">Änderung erfassen</h3>
|
|
<button
|
|
onClick={() => setSelectedElement(null)}
|
|
className="text-white/40 hover:text-white"
|
|
>
|
|
<X size={20} />
|
|
</button>
|
|
</div>
|
|
|
|
<div className="flex gap-2 mb-6 shrink-0">
|
|
<button
|
|
onClick={() => setCurrentType("text")}
|
|
className={cn(
|
|
"flex-1 py-3 px-4 rounded-xl text-sm font-medium transition-all flex items-center justify-center gap-2",
|
|
currentType === "text"
|
|
? "bg-white text-black shadow-lg"
|
|
: "bg-white/5 text-white/40 hover:bg-white/10"
|
|
)}
|
|
>
|
|
<Type size={16} /> Text
|
|
</button>
|
|
<button
|
|
onClick={() => setCurrentType("image")}
|
|
className={cn(
|
|
"flex-1 py-3 px-4 rounded-xl text-sm font-medium transition-all flex items-center justify-center gap-2",
|
|
currentType === "image"
|
|
? "bg-white text-black shadow-lg"
|
|
: "bg-white/5 text-white/40 hover:bg-white/10"
|
|
)}
|
|
>
|
|
<ImageIcon size={16} /> Bild
|
|
</button>
|
|
<button
|
|
onClick={() => setCurrentType("note")}
|
|
className={cn(
|
|
"flex-1 py-3 px-4 rounded-xl text-sm font-medium transition-all flex items-center justify-center gap-2",
|
|
currentType === "note"
|
|
? "bg-white text-black shadow-lg"
|
|
: "bg-white/5 text-white/40 hover:bg-white/10"
|
|
)}
|
|
>
|
|
<StickyNote size={16} /> Notiz
|
|
</button>
|
|
</div>
|
|
|
|
<div className="flex-1 overflow-y-auto min-h-[200px] mb-6 hide-scrollbar" style={{ scrollbarWidth: 'none', msOverflowStyle: 'none' }}>
|
|
{currentType === "text" && (
|
|
<div className="space-y-2">
|
|
<label className="text-xs font-bold uppercase tracking-wider text-white/40">Neuer Text</label>
|
|
<textarea
|
|
autoFocus
|
|
value={currentText}
|
|
onChange={(e) => setCurrentText(e.target.value)}
|
|
className="w-full h-32 bg-black/40 border border-white/10 rounded-2xl p-4 text-white placeholder:text-white/20 focus:outline-none focus:border-blue-500/50 transition-colors resize-none"
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{currentType === "note" && (
|
|
<div className="space-y-2">
|
|
<label className="text-xs font-bold uppercase tracking-wider text-white/40">Anmerkung</label>
|
|
<textarea
|
|
autoFocus
|
|
value={currentText}
|
|
onChange={(e) => setCurrentText(e.target.value)}
|
|
placeholder="Was soll hier gemacht werden?"
|
|
className="w-full h-32 bg-black/40 border border-white/10 rounded-2xl p-4 text-white placeholder:text-white/20 focus:outline-none focus:border-blue-500/50 transition-colors resize-none"
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{currentType === "image" && (
|
|
<div className="space-y-2 h-full">
|
|
<label className="text-xs font-bold uppercase tracking-wider text-white/40">Neues Bild wählen</label>
|
|
{assets.length === 0 ? (
|
|
<div className="text-sm text-white/40 p-4 bg-white/5 rounded-xl border border-white/5 text-center">
|
|
Keine Assets übergeben. Bitte das prop <code>assets</code> befüllen.
|
|
</div>
|
|
) : (
|
|
<>
|
|
<style>{`.hide-scrollbar::-webkit-scrollbar { display: none; }`}</style>
|
|
<div className="mb-3">
|
|
<input
|
|
type="text"
|
|
placeholder="Suchen nach Dateinamen..."
|
|
value={searchTerm}
|
|
onChange={(e) => setSearchTerm(e.target.value)}
|
|
className="w-full bg-black/40 border border-white/10 rounded-xl px-4 py-3 text-sm text-white placeholder:text-white/40 focus:outline-none focus:border-blue-500/50 transition-colors"
|
|
/>
|
|
</div>
|
|
<div
|
|
className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-3 h-[400px] overflow-y-auto hide-scrollbar"
|
|
style={{ scrollbarWidth: 'none', msOverflowStyle: 'none' }}
|
|
>
|
|
{assets.filter(asset => asset.toLowerCase().includes(searchTerm.toLowerCase())).map((asset) => {
|
|
const filename = asset.split('/').pop() || asset;
|
|
return (
|
|
<button
|
|
key={asset}
|
|
title={asset}
|
|
onClick={() => setSelectedAsset(asset)}
|
|
className={cn(
|
|
"relative flex flex-col items-center bg-black/20 rounded-xl overflow-hidden border-2 transition-all p-2 gap-2 w-full",
|
|
selectedAsset === asset
|
|
? "border-blue-500 shadow-[0_0_0_4px_rgba(59,130,246,0.3)] bg-blue-500/10"
|
|
: "border-transparent hover:border-white/20 hover:bg-white/5"
|
|
)}
|
|
style={{ height: '130px' }}
|
|
>
|
|
<div className="w-full flex-1 rounded-lg overflow-hidden bg-black/40 flex items-center justify-center relative">
|
|
{asset.match(/\.(mp4|webm)$/i) ? (
|
|
<video src={asset} className="w-full h-full object-cover opacity-80" />
|
|
) : (
|
|
<img src={asset} loading="lazy" alt={filename} className="w-full h-full object-contain" />
|
|
)}
|
|
{selectedAsset === asset && (
|
|
<div className="absolute inset-0 flex items-center justify-center bg-blue-500/20 backdrop-blur-[2px]">
|
|
<div className="bg-blue-500 text-white rounded-full p-1.5 shadow-lg">
|
|
<Check size={18} strokeWidth={3} />
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
<span className="text-[11px] font-medium text-white/70 truncate w-full text-center shrink-0" title={filename}>
|
|
{filename}
|
|
</span>
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<button
|
|
disabled={(!currentText && currentType !== "image") || (currentType === "image" && !selectedAsset)}
|
|
onClick={saveAnnotation}
|
|
className="w-full bg-blue-500 hover:bg-blue-400 disabled:opacity-50 disabled:cursor-not-allowed text-white font-bold py-4 rounded-2xl flex items-center justify-center gap-2 transition-all shadow-lg shadow-blue-500/20 shrink-0"
|
|
>
|
|
<Check size={20} />
|
|
Speichern
|
|
</button>
|
|
</motion.div>
|
|
</div>
|
|
)}
|
|
</AnimatePresence>
|
|
|
|
{/* 4. Annotations List Sidebar */}
|
|
<AnimatePresence>
|
|
{showList && (
|
|
<>
|
|
<motion.div
|
|
initial={{ opacity: 0 }}
|
|
animate={{ opacity: 1 }}
|
|
exit={{ opacity: 0 }}
|
|
onClick={() => setShowList(false)}
|
|
className="fixed inset-0 bg-black/60 backdrop-blur-sm z-[10001]"
|
|
/>
|
|
<motion.div
|
|
initial={{ x: "100%" }}
|
|
animate={{ x: 0 }}
|
|
exit={{ x: "100%" }}
|
|
transition={{ type: "spring", damping: 25, stiffness: 200 }}
|
|
className="fixed top-0 right-0 h-full w-full sm:w-[400px] bg-[#1c1c1e] border-l border-white/10 z-[10002] shadow-2xl flex flex-col"
|
|
>
|
|
<div className="p-8 border-b border-white/10 flex items-center justify-between">
|
|
<div>
|
|
<h2 className="text-2xl font-bold text-white mb-1">Korrekturen</h2>
|
|
<p className="text-white/40 text-sm">{annotations.length} erfasst</p>
|
|
</div>
|
|
<button
|
|
onClick={() => setShowList(false)}
|
|
className="p-2 text-white/40 hover:text-white bg-white/5 rounded-xl transition-colors"
|
|
>
|
|
<X size={20} />
|
|
</button>
|
|
</div>
|
|
|
|
<div className="flex-1 overflow-y-auto p-6 space-y-4">
|
|
{annotations.length === 0 ? (
|
|
<div className="h-full flex flex-col items-center justify-center text-center px-8 opacity-40">
|
|
<List size={48} className="mb-4" />
|
|
<p>Noch keine Korrekturen vorhanden.</p>
|
|
</div>
|
|
) : (
|
|
annotations.map((ann) => (
|
|
<div
|
|
key={ann.id}
|
|
className="bg-white/5 border border-white/5 rounded-2xl p-4 flex flex-col gap-3 group relative"
|
|
>
|
|
<button
|
|
onClick={() => deleteAnnotation(ann.id)}
|
|
className="absolute top-4 right-4 text-white/20 hover:text-red-400 opacity-0 group-hover:opacity-100 transition-opacity"
|
|
>
|
|
<Trash2 size={16} />
|
|
</button>
|
|
|
|
<div className="flex items-center gap-2">
|
|
<span className={cn(
|
|
"px-2 py-1 rounded-md text-[10px] font-bold uppercase tracking-wider flex items-center gap-1",
|
|
ann.type === "text" ? "bg-blue-500/20 text-blue-400" :
|
|
ann.type === "image" ? "bg-purple-500/20 text-purple-400" :
|
|
"bg-orange-500/20 text-orange-400"
|
|
)}>
|
|
{ann.type === "text" && <Type size={12} />}
|
|
{ann.type === "image" && <ImageIcon size={12} />}
|
|
{ann.type === "note" && <StickyNote size={12} />}
|
|
{ann.type}
|
|
</span>
|
|
</div>
|
|
|
|
{ann.type === "image" ? (
|
|
<div className="flex items-center gap-2">
|
|
<img src={ann.newContent} alt="New" className="w-16 h-16 object-cover rounded-lg bg-white/10" />
|
|
<div className="text-xs text-white/60 flex-1 truncate">{ann.newContent}</div>
|
|
</div>
|
|
) : (
|
|
<p className="text-white text-sm whitespace-pre-wrap">{ann.newContent}</p>
|
|
)}
|
|
|
|
<div className="text-[10px] text-white/30 truncate mt-2 font-mono flex items-center gap-2">
|
|
<span>{ann.selector}</span>
|
|
{ann.url && <span className="truncate max-w-[100px] text-blue-400" title={ann.url}>{new URL(ann.url).pathname}</span>}
|
|
</div>
|
|
<div className="flex flex-wrap items-center gap-2 mt-2 pt-2 border-t border-white/5">
|
|
{ann.timestamp && (
|
|
<span className="text-[9px] px-1.5 py-0.5 rounded bg-white/5 text-white/40">
|
|
{new Date(ann.timestamp).toLocaleString()}
|
|
</span>
|
|
)}
|
|
{ann.viewportWidth && (
|
|
<span className="text-[9px] px-1.5 py-0.5 rounded bg-white/5 text-white/40">
|
|
{ann.viewportWidth}x{ann.viewportHeight}
|
|
</span>
|
|
)}
|
|
{ann.scrollX !== undefined && (
|
|
<span className="text-[9px] px-1.5 py-0.5 rounded bg-white/5 text-white/40">
|
|
Scroll: {Math.round(ann.scrollX)},{Math.round(ann.scrollY)}
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
))
|
|
)}
|
|
</div>
|
|
|
|
{annotations.length > 0 && (
|
|
<div className="p-6 border-t border-white/10 flex flex-col gap-3">
|
|
{onSubmit && (
|
|
<button
|
|
onClick={handleSubmit}
|
|
disabled={isSubmitting}
|
|
className="w-full bg-blue-500 hover:bg-blue-400 text-white font-bold py-4 rounded-2xl flex items-center justify-center gap-2 transition-all shadow-lg shadow-blue-500/20 disabled:opacity-50 disabled:cursor-not-allowed"
|
|
>
|
|
{isSubmitting ? (
|
|
<div className="w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin" />
|
|
) : (
|
|
<Check size={20} />
|
|
)}
|
|
{isSubmitting ? "Wird gesendet..." : "Korrekturen an Entwickler senden"}
|
|
</button>
|
|
)}
|
|
<button
|
|
onClick={exportJSON}
|
|
className={cn(
|
|
"w-full font-bold py-3 rounded-2xl flex items-center justify-center gap-2 transition-all",
|
|
onSubmit ? "bg-white/5 text-white/70 hover:bg-white/10 hover:text-white" : "bg-blue-500 hover:bg-blue-400 text-white shadow-lg shadow-blue-500/20 py-4"
|
|
)}
|
|
>
|
|
<Download size={onSubmit ? 18 : 20} />
|
|
JSON {onSubmit && "lokal "}Exportieren
|
|
</button>
|
|
<button
|
|
onClick={() => {
|
|
if (confirm("Möchtest du wirklich alle erfassten Korrekturen löschen?")) {
|
|
setAnnotations([]);
|
|
}
|
|
}}
|
|
className="w-full bg-red-500/10 text-red-500 hover:bg-red-500/20 font-bold py-3 rounded-xl flex items-center justify-center gap-2 transition-all mt-2"
|
|
>
|
|
<Trash2 size={18} />
|
|
Alle löschen
|
|
</button>
|
|
</div>
|
|
)}
|
|
</motion.div>
|
|
</>
|
|
)}
|
|
</AnimatePresence>
|
|
</div>
|
|
);
|
|
}
|