chore: align ecosystem to Next.js 16.1.6 and v1.6.0, migrate to ESLint 9 Flat Config
Some checks failed
CI - Lint, Typecheck & Test / quality-assurance (push) Failing after 30s
Some checks failed
CI - Lint, Typecheck & Test / quality-assurance (push) Failing after 30s
This commit is contained in:
23
components/ContactMap.tsx
Normal file
23
components/ContactMap.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import dynamic from 'next/dynamic';
|
||||
|
||||
const LeafletMap = dynamic(() => import('@/components/LeafletMap'), {
|
||||
ssr: false,
|
||||
loading: () => (
|
||||
<div className="h-full w-full bg-neutral-medium flex items-center justify-center">
|
||||
<div className="animate-pulse text-primary font-medium">Loading Map...</div>
|
||||
</div>
|
||||
),
|
||||
});
|
||||
|
||||
interface ContactMapProps {
|
||||
address: string;
|
||||
lat: number;
|
||||
lng: number;
|
||||
}
|
||||
|
||||
export default function ContactMap({ address, lat, lng }: ContactMapProps) {
|
||||
return <LeafletMap address={address} lat={lat} lng={lng} />;
|
||||
}
|
||||
@@ -1,539 +0,0 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user