540 lines
27 KiB
TypeScript
540 lines
27 KiB
TypeScript
'use client';
|
|
|
|
import React, { useState, useEffect, useRef, useMemo } from 'react';
|
|
import { motion, AnimatePresence } from 'framer-motion';
|
|
import { MessageSquare, X, Check, MousePointer2, Plus, List, Send, User } from 'lucide-react';
|
|
import { clsx } from 'clsx';
|
|
import { twMerge } from 'tailwind-merge';
|
|
|
|
function cn(...inputs: any[]) {
|
|
return twMerge(clsx(inputs));
|
|
}
|
|
|
|
interface FeedbackComment {
|
|
id: string;
|
|
userName: string;
|
|
text: string;
|
|
createdAt: string;
|
|
}
|
|
|
|
interface Feedback {
|
|
id: string;
|
|
x: number;
|
|
y: number;
|
|
selector: string;
|
|
text: string;
|
|
type: 'design' | 'content';
|
|
elementRect: DOMRect | null;
|
|
userName: string;
|
|
comments: FeedbackComment[];
|
|
}
|
|
|
|
export function FeedbackOverlay() {
|
|
const [isActive, setIsActive] = useState(false);
|
|
const [hoveredElement, setHoveredElement] = useState<HTMLElement | null>(null);
|
|
const [selectedElement, setSelectedElement] = useState<HTMLElement | null>(null);
|
|
const [feedbacks, setFeedbacks] = useState<Feedback[]>([]);
|
|
const [currentComment, setCurrentComment] = useState('');
|
|
const [currentType, setCurrentType] = useState<'design' | 'content'>('design');
|
|
const [showList, setShowList] = useState(false);
|
|
const [currentUser, setCurrentUser] = useState<{ identity: string, isDevFallback?: boolean } | null>(null);
|
|
const [newCommentTexts, setNewCommentTexts] = useState<{ [feedbackId: string]: string }>({});
|
|
|
|
// 1. Fetch Identity and Existing Feedback
|
|
useEffect(() => {
|
|
const checkAuth = async () => {
|
|
try {
|
|
// Determine if we have a bypass parameter in the URL
|
|
const urlParams = new URLSearchParams(window.location.search);
|
|
const bypass = urlParams.get('gatekeeper_bypass');
|
|
const apiUrl = bypass ? `/api/whoami?gatekeeper_bypass=${bypass}` : '/api/whoami';
|
|
|
|
const res = await fetch(apiUrl);
|
|
if (res.ok) {
|
|
const data = await res.json();
|
|
setCurrentUser(data);
|
|
} else {
|
|
setCurrentUser({ identity: "Guest" });
|
|
}
|
|
} catch (e) {
|
|
setCurrentUser({ identity: "Guest" });
|
|
}
|
|
};
|
|
|
|
const fetchFeedback = async () => {
|
|
try {
|
|
const res = await fetch('/api/feedback');
|
|
if (res.ok) {
|
|
const data = await res.json();
|
|
// Map Directus fields back to our interface if necessary
|
|
const mapped = data.map((fb: any) => ({
|
|
id: fb.id,
|
|
x: fb.x,
|
|
y: fb.y,
|
|
selector: fb.selector,
|
|
text: fb.text,
|
|
type: fb.type,
|
|
userName: fb.user_name,
|
|
comments: (fb.comments || []).map((c: any) => ({
|
|
id: c.id,
|
|
userName: c.user_name,
|
|
text: c.text,
|
|
createdAt: c.date_created
|
|
}))
|
|
}));
|
|
setFeedbacks(mapped);
|
|
}
|
|
} catch (e) {
|
|
console.error("Failed to fetch feedbacks", e);
|
|
}
|
|
};
|
|
|
|
checkAuth();
|
|
fetchFeedback();
|
|
}, []);
|
|
|
|
// Helper to get unique selector
|
|
const getSelector = (el: HTMLElement): string => {
|
|
if (el.id) return `#${el.id}`;
|
|
let path = [];
|
|
while (el.parentElement) {
|
|
let index = Array.from(el.parentElement.children).indexOf(el) + 1;
|
|
path.unshift(`${el.tagName.toLowerCase()}:nth-child(${index})`);
|
|
el = el.parentElement;
|
|
}
|
|
return path.join(' > ');
|
|
};
|
|
|
|
useEffect(() => {
|
|
if (!isActive) {
|
|
setHoveredElement(null);
|
|
return;
|
|
}
|
|
|
|
const handleMouseMove = (e: MouseEvent) => {
|
|
if (selectedElement) return;
|
|
|
|
const target = e.target as HTMLElement;
|
|
if (target.closest('.feedback-ui-ignore')) {
|
|
setHoveredElement(null);
|
|
return;
|
|
}
|
|
setHoveredElement(target);
|
|
};
|
|
|
|
const handleClick = (e: MouseEvent) => {
|
|
if (selectedElement) return;
|
|
|
|
const target = e.target as HTMLElement;
|
|
if (target.closest('.feedback-ui-ignore')) return;
|
|
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
|
|
setSelectedElement(target);
|
|
setHoveredElement(null);
|
|
};
|
|
|
|
window.addEventListener('mousemove', handleMouseMove);
|
|
window.addEventListener('click', handleClick, true);
|
|
|
|
return () => {
|
|
window.removeEventListener('mousemove', handleMouseMove);
|
|
window.removeEventListener('click', handleClick, true);
|
|
};
|
|
}, [isActive, selectedElement]);
|
|
|
|
const saveFeedback = async () => {
|
|
if (!selectedElement || !currentComment) return;
|
|
|
|
const rect = selectedElement.getBoundingClientRect();
|
|
const feedbackData = {
|
|
url: window.location.href,
|
|
x: rect.left + rect.width / 2 + window.scrollX,
|
|
y: rect.top + rect.height / 2 + window.scrollY,
|
|
selector: getSelector(selectedElement),
|
|
text: currentComment,
|
|
type: currentType,
|
|
userName: currentUser?.identity || "Unknown",
|
|
userIdentity: currentUser?.identity === 'Admin' ? 'admin' : 'user'
|
|
};
|
|
|
|
try {
|
|
const res = await fetch('/api/feedback', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(feedbackData)
|
|
});
|
|
|
|
if (res.ok) {
|
|
const savedFb = await res.json();
|
|
const newFeedback: Feedback = {
|
|
id: savedFb.id,
|
|
x: savedFb.x,
|
|
y: savedFb.y,
|
|
selector: savedFb.selector,
|
|
text: savedFb.text,
|
|
type: savedFb.type,
|
|
elementRect: rect,
|
|
userName: savedFb.user_name,
|
|
comments: [],
|
|
};
|
|
setFeedbacks([...feedbacks, newFeedback]);
|
|
setSelectedElement(null);
|
|
setCurrentComment('');
|
|
}
|
|
} catch (e) {
|
|
console.error("Failed to save feedback", e);
|
|
}
|
|
};
|
|
|
|
const addReply = async (feedbackId: string) => {
|
|
const text = newCommentTexts[feedbackId];
|
|
if (!text) return;
|
|
|
|
if (!currentUser?.identity || currentUser.identity === 'Guest') {
|
|
alert("Nur angemeldete Benutzer können antworten.");
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const res = await fetch('/api/feedback', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
action: 'reply',
|
|
feedbackId,
|
|
userName: currentUser?.identity || "Unknown",
|
|
text
|
|
})
|
|
});
|
|
|
|
if (res.ok) {
|
|
const savedReply = await res.json();
|
|
setFeedbacks(feedbacks.map(f => {
|
|
if (f.id === feedbackId) {
|
|
return {
|
|
...f,
|
|
comments: [...f.comments, {
|
|
id: savedReply.id,
|
|
userName: savedReply.user_name,
|
|
text: savedReply.text,
|
|
createdAt: savedReply.date_created
|
|
}]
|
|
};
|
|
}
|
|
return f;
|
|
}));
|
|
setNewCommentTexts({ ...newCommentTexts, [feedbackId]: '' });
|
|
}
|
|
} catch (e) {
|
|
console.error("Failed to save reply", e);
|
|
}
|
|
};
|
|
|
|
const hoveredRect = useMemo(() => hoveredElement?.getBoundingClientRect(), [hoveredElement]);
|
|
const selectedRect = useMemo(() => selectedElement?.getBoundingClientRect(), [selectedElement]);
|
|
|
|
return (
|
|
<div className="feedback-ui-ignore">
|
|
{/* 1. Global Toolbar */}
|
|
<div className="fixed bottom-6 left-1/2 -translate-x-1/2 z-[9999]">
|
|
<div className="bg-black/80 backdrop-blur-xl border border-white/10 p-2 rounded-2xl shadow-2xl flex items-center gap-2">
|
|
<div className={cn(
|
|
"flex items-center gap-2 px-3 py-2 rounded-xl transition-all",
|
|
currentUser?.isDevFallback ? "bg-orange-500/20 text-orange-400" : "bg-white/5 text-white/40"
|
|
)}>
|
|
<User size={14} />
|
|
<span className="text-[10px] font-bold uppercase tracking-wider">
|
|
{currentUser?.identity || "Loading..."}
|
|
{currentUser?.isDevFallback && " (Local Dev Bypass)"}
|
|
</span>
|
|
</div>
|
|
|
|
<div className="w-px h-6 bg-white/10 mx-1" />
|
|
|
|
<button
|
|
onClick={() => {
|
|
if (!currentUser?.identity || currentUser.identity === 'Guest') {
|
|
// Maybe show a toast or just stay disabled
|
|
alert("Bitte logge dich ein, um Feedback zu geben.");
|
|
return;
|
|
}
|
|
setIsActive(!isActive);
|
|
}}
|
|
disabled={!currentUser?.identity || currentUser.identity === 'Guest'}
|
|
className={cn(
|
|
"flex items-center gap-2 px-4 py-2 rounded-xl transition-all font-medium disabled:opacity-30 disabled:cursor-not-allowed",
|
|
isActive
|
|
? "bg-blue-500 text-white shadow-lg shadow-blue-500/20"
|
|
: "text-white/70 hover:text-white hover:bg-white/10"
|
|
)}
|
|
>
|
|
{isActive ? <X size={18} /> : <MessageSquare size={18} />}
|
|
{isActive ? "Modus beenden" : "Feedback geben"}
|
|
</button>
|
|
|
|
<div className="w-px h-6 bg-white/10 mx-1" />
|
|
|
|
<button
|
|
onClick={() => setShowList(!showList)}
|
|
className="p-2 text-white/70 hover:text-white hover:bg-white/10 rounded-xl relative"
|
|
>
|
|
<List size={20} />
|
|
{feedbacks.length > 0 && (
|
|
<span className="absolute -top-1 -right-1 w-5 h-5 bg-blue-500 text-[10px] flex items-center justify-center rounded-full text-white font-bold border-2 border-[#1a1a1a]">
|
|
{feedbacks.length}
|
|
</span>
|
|
)}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 2. Feedback Markers & Highlights */}
|
|
<AnimatePresence>
|
|
{isActive && (
|
|
<>
|
|
{/* Fixed Overlay for real-time highlights */}
|
|
<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>
|
|
|
|
{/* Absolute Overlay for persistent pins */}
|
|
<div className="absolute inset-0 pointer-events-none z-[9997]">
|
|
{feedbacks.map((fb) => (
|
|
<div
|
|
key={fb.id}
|
|
className="absolute"
|
|
style={{ top: fb.y, left: fb.x }}
|
|
>
|
|
<button
|
|
onClick={() => {
|
|
setShowList(true);
|
|
// TODO: Scroll to feedback in list
|
|
}}
|
|
className={cn(
|
|
"w-6 h-6 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-white shadow-lg flex items-center justify-center text-white cursor-pointer pointer-events-auto transition-transform hover:scale-110",
|
|
fb.type === 'design' ? 'bg-purple-500' : 'bg-orange-500'
|
|
)}
|
|
>
|
|
<Plus size={14} className="rotate-45" />
|
|
</button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</>
|
|
)}
|
|
</AnimatePresence>
|
|
|
|
{/* 3. Feedback 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-6 w-[400px] shadow-2xl"
|
|
>
|
|
<div className="flex items-center justify-between mb-6">
|
|
<h3 className="text-white font-bold text-lg">Feedback geben</h3>
|
|
<button
|
|
onClick={() => setSelectedElement(null)}
|
|
className="text-white/40 hover:text-white"
|
|
>
|
|
<X size={20} />
|
|
</button>
|
|
</div>
|
|
|
|
<div className="flex gap-2 mb-6">
|
|
{(['design', 'content'] as const).map((type) => (
|
|
<button
|
|
key={type}
|
|
onClick={() => setCurrentType(type)}
|
|
className={cn(
|
|
"flex-1 py-3 px-4 rounded-xl text-sm font-medium transition-all capitalize",
|
|
currentType === type
|
|
? "bg-white text-black shadow-lg"
|
|
: "bg-white/5 text-white/40 hover:bg-white/10"
|
|
)}
|
|
>
|
|
{type === 'design' ? '🎨 Design' : '✍️ Content'}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
<textarea
|
|
autoFocus
|
|
value={currentComment}
|
|
onChange={(e) => setCurrentComment(e.target.value)}
|
|
placeholder="Was möchtest du anmerken?"
|
|
className="w-full h-32 bg-white/5 border border-white/5 rounded-2xl p-4 text-white placeholder:text-white/20 focus:outline-none focus:border-blue-500/50 transition-colors resize-none mb-6"
|
|
/>
|
|
|
|
<button
|
|
disabled={!currentComment}
|
|
onClick={saveFeedback}
|
|
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"
|
|
>
|
|
<Check size={20} />
|
|
Feedback speichern
|
|
</button>
|
|
</motion.div>
|
|
</div>
|
|
)}
|
|
</AnimatePresence>
|
|
|
|
{/* 4. Feedback 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-[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">Feedback</h2>
|
|
<p className="text-white/40 text-sm">{feedbacks.length} Anmerkungen live</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-6">
|
|
{feedbacks.length === 0 ? (
|
|
<div className="h-full flex flex-col items-center justify-center text-center px-8 opacity-40">
|
|
<MessageSquare size={48} className="mb-4" />
|
|
<p>Noch kein Feedback vorhanden. Aktiviere den Modus um Stellen auf der Seite zu markieren.</p>
|
|
</div>
|
|
) : (
|
|
feedbacks.map((fb) => (
|
|
<div
|
|
key={fb.id}
|
|
className="bg-white/5 border border-white/5 rounded-3xl overflow-hidden hover:border-white/20 transition-all flex flex-col"
|
|
>
|
|
{/* Header */}
|
|
<div className="p-5 border-b border-white/5 bg-white/[0.02]">
|
|
<div className="flex items-center justify-between mb-4">
|
|
<div className="flex items-center gap-2">
|
|
<div className="w-8 h-8 rounded-full bg-blue-500/20 flex items-center justify-center text-blue-400">
|
|
<User size={14} />
|
|
</div>
|
|
<div>
|
|
<p className="text-white text-[11px] font-bold uppercase tracking-wider">{fb.userName}</p>
|
|
<p className="text-white/20 text-[9px] uppercase tracking-widest">Original Poster</p>
|
|
</div>
|
|
</div>
|
|
<span className={cn(
|
|
"px-3 py-1 rounded-full text-[9px] font-bold uppercase tracking-wider",
|
|
fb.type === 'design' ? 'bg-purple-500/20 text-purple-400' : 'bg-orange-500/20 text-orange-400'
|
|
)}>
|
|
{fb.type}
|
|
</span>
|
|
</div>
|
|
<p className="text-white/80 whitespace-pre-wrap text-sm leading-relaxed">{fb.text}</p>
|
|
<div className="mt-3 flex items-center gap-2">
|
|
<div className="w-1 h-1 bg-white/10 rounded-full" />
|
|
<span className="text-white/20 text-[9px] truncate tracking-wider italic">
|
|
{fb.selector}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Comments List */}
|
|
{fb.comments.length > 0 && (
|
|
<div className="bg-black/20 p-5 space-y-4">
|
|
{fb.comments.map(comment => (
|
|
<div key={comment.id} className="flex gap-3">
|
|
<div className="w-6 h-6 rounded-full bg-white/10 flex items-center justify-center text-white/40 shrink-0">
|
|
<User size={10} />
|
|
</div>
|
|
<div className="space-y-1">
|
|
<div className="flex items-center gap-2">
|
|
<p className="text-[10px] font-bold text-white/60 uppercase">{comment.userName}</p>
|
|
<p className="text-[10px] text-white/20">
|
|
{new Date(comment.createdAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
|
|
</p>
|
|
</div>
|
|
<p className="text-white/80 text-xs leading-snug">{comment.text}</p>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{/* Reply Input */}
|
|
<div className="p-4 bg-white/[0.01] mt-auto border-t border-white/5">
|
|
<div className="relative">
|
|
<input
|
|
type="text"
|
|
value={newCommentTexts[fb.id] || ''}
|
|
onChange={(e) => setNewCommentTexts({ ...newCommentTexts, [fb.id]: e.target.value })}
|
|
placeholder="Antworten..."
|
|
className="w-full bg-black/40 border border-white/5 rounded-2xl py-3 pl-4 pr-12 text-xs text-white placeholder:text-white/20 focus:outline-none focus:border-blue-500/50 transition-colors"
|
|
onKeyDown={(e) => {
|
|
if (e.key === 'Enter') addReply(fb.id);
|
|
}}
|
|
/>
|
|
<button
|
|
onClick={() => addReply(fb.id)}
|
|
className="absolute right-2 top-1/2 -translate-y-1/2 p-2 text-blue-500 hover:text-blue-400 transition-colors disabled:opacity-30"
|
|
disabled={!newCommentTexts[fb.id]}
|
|
>
|
|
<Send size={14} />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
))
|
|
)}
|
|
</div>
|
|
</motion.div>
|
|
</>
|
|
)}
|
|
</AnimatePresence>
|
|
</div>
|
|
);
|
|
}
|