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

This commit is contained in:
2026-02-09 23:23:31 +01:00
parent 6451a9e28e
commit eb388610de
85 changed files with 14182 additions and 30922 deletions

23
components/ContactMap.tsx Normal file
View 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} />;
}

View File

@@ -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>
);
}