Files
e-tib.com/components/annotator-local/Annotator.tsx
Marc Mintel 5027bea666
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 1m16s
Build & Deploy / 🏗️ Build (push) Has been cancelled
Build & Deploy / 🧪 QA (push) Has been cancelled
Build & Deploy / 🚀 Deploy (push) Has been cancelled
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been cancelled
Build & Deploy / 🔔 Notify (push) Has been cancelled
fix(ui): resolve annotator overlap, slider images, subcompany rounding and copy updates
2026-06-19 23:25:29 +02:00

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-36 md:bottom-24 right-4 z-[10000] 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>
);
}