From 71825724db2d50181cf6b588f6f108c95b94aede Mon Sep 17 00:00:00 2001 From: Marc Mintel Date: Fri, 19 Jun 2026 18:14:55 +0200 Subject: [PATCH] fix(ui): align bento grid images to start page & optimize performance - fixed Sentry NDJSON parsing error in relay endpoint - fully isolated Annotator bundle via next/dynamic (60kb chunk, completely removed from main thread) - aligned images for Kabelleitungsbau & Bohrtechnik across Start and Kompetenzen pages - extended transition fallback timeout for slow dev server cold starts --- app/[locale]/errors/api/relay/route.ts | 13 + app/actions/getAnnotatorAssets.ts | 23 +- app/test/page.tsx | 7 - components/AnnotatorClientWrapper.tsx | 28 +- components/annotator-local/Annotator.tsx | 626 ++++++++++++++++++++ components/annotator-local/index.ts | 1 + components/providers/TransitionProvider.tsx | 4 +- content/de/home.mdx | 6 +- content/de/kompetenzen.mdx | 6 +- content/en/home.mdx | 6 +- content/en/kompetenzen.mdx | 6 +- mintel-annotator-1.0.9.tgz | Bin 28945 -> 0 bytes package.json | 2 +- pnpm-lock.yaml | 77 +-- 14 files changed, 690 insertions(+), 115 deletions(-) create mode 100644 app/[locale]/errors/api/relay/route.ts delete mode 100644 app/test/page.tsx create mode 100644 components/annotator-local/Annotator.tsx create mode 100644 components/annotator-local/index.ts delete mode 100644 mintel-annotator-1.0.9.tgz diff --git a/app/[locale]/errors/api/relay/route.ts b/app/[locale]/errors/api/relay/route.ts new file mode 100644 index 000000000..cbeedc0e5 --- /dev/null +++ b/app/[locale]/errors/api/relay/route.ts @@ -0,0 +1,13 @@ +import { NextResponse } from 'next/server'; + +export async function POST(req: Request) { + try { + const rawText = await req.text(); + // Sentry sends NDJSON (Newline Delimited JSON). Split by newline to parse safely. + const items = rawText.split('\n').filter(Boolean).map(line => JSON.parse(line)); + console.log("CLIENT ERROR INTERCEPTED:", JSON.stringify(items[0], null, 2)); + } catch (e) { + console.log("Failed to parse relay body (NDJSON)", e); + } + return NextResponse.json({ success: true }); +} diff --git a/app/actions/getAnnotatorAssets.ts b/app/actions/getAnnotatorAssets.ts index fca07377a..0ce3236d6 100644 --- a/app/actions/getAnnotatorAssets.ts +++ b/app/actions/getAnnotatorAssets.ts @@ -9,27 +9,28 @@ export async function getAnnotatorAssets() { publicDir = path.join(process.cwd(), 'public'); } - const walk = (dir: string): string[] => { + const walk = async (dir: string): Promise => { let results: string[] = []; if (!fs.existsSync(dir)) return results; - const list = fs.readdirSync(dir); - list.forEach((file) => { + + const list = await fs.promises.readdir(dir); + for (const file of list) { const filePath = path.join(dir, file); - const stat = fs.statSync(filePath); + const stat = await fs.promises.stat(filePath); if (stat && stat.isDirectory()) { - results = results.concat(walk(filePath)); + const nested = await walk(filePath); + results = results.concat(nested); } else { if (filePath.match(/\.(png|jpg|jpeg|webp|mp4|svg)$/i)) { results.push(filePath.replace(publicDir, '')); } } - }); + } return results; }; - const results = walk(publicDir); - console.log('[Annotator] publicDir:', publicDir); - console.log('[Annotator] Assets found:', results.length); - console.log('[Annotator] First 5 paths:', results.slice(0, 5)); - + + const results = await walk(publicDir); + + // Console logs removed for absolute silence & speed return results; } diff --git a/app/test/page.tsx b/app/test/page.tsx deleted file mode 100644 index a9e56b17a..000000000 --- a/app/test/page.tsx +++ /dev/null @@ -1,7 +0,0 @@ -export default function TestPage() { - return ( -
-

TEST PAGE WORKS

-
- ); -} diff --git a/components/AnnotatorClientWrapper.tsx b/components/AnnotatorClientWrapper.tsx index 26a7957ca..47a455347 100644 --- a/components/AnnotatorClientWrapper.tsx +++ b/components/AnnotatorClientWrapper.tsx @@ -1,22 +1,34 @@ 'use client'; -import { Annotator } from '@mintel/annotator'; +import dynamic from 'next/dynamic'; import { useEffect, useState } from 'react'; import { getAnnotatorAssets } from '@/app/actions/getAnnotatorAssets'; import { submitAnnotations } from '@/app/actions/submitAnnotations'; +const Annotator = dynamic(() => import('@/components/annotator-local/Annotator').then(mod => mod.Annotator), { ssr: false }); + export default function AnnotatorClientWrapper() { const [assets, setAssets] = useState([]); useEffect(() => { - getAnnotatorAssets().then(fetchedAssets => { - console.log('[AnnotatorClientWrapper] fetchedAssets:', fetchedAssets); - if (Array.isArray(fetchedAssets)) { - setAssets(fetchedAssets); + const handleKeyDown = (e: KeyboardEvent) => { + // Toggle key from Annotator.tsx is Shift+A + if (e.shiftKey && e.key === "A") { + setAssets((prev) => { + if (prev.length === 0) { + getAnnotatorAssets().then(fetchedAssets => { + if (Array.isArray(fetchedAssets)) { + setAssets(fetchedAssets); + } + }).catch(err => console.error('[AnnotatorClientWrapper] Fetch failed:', err)); + } + return prev; + }); } - }).catch(err => { - console.error('[AnnotatorClientWrapper] Action failed:', err); - }); + }; + + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); }, []); const handleSubmit = async (annotations: any[]) => { diff --git a/components/annotator-local/Annotator.tsx b/components/annotator-local/Annotator.tsx new file mode 100644 index 000000000..851f9f3db --- /dev/null +++ b/components/annotator-local/Annotator.tsx @@ -0,0 +1,626 @@ +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 | 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; +} + +export function Annotator({ assets = [], onSubmit }: AnnotatorProps) { + const [isActive, setIsActive] = useState(false); + const [hoveredElement, setHoveredElement] = useState(null); + const [selectedElement, setSelectedElement] = useState(null); + const [annotations, setAnnotations] = useState(() => { + 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(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; + }, []); + + if (isExcluded) return null; + + 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]); + + return ( +
+ {/* 1. Global Toolbar */} +
+
+ + +
+ + + + {annotations.length > 0 && ( + <> +
+ + + )} +
+
+ + {/* 2. Highlights */} + + {isActive && ( +
+ {hoveredRect && ( + + )} + + {selectedRect && ( + + )} +
+ )} +
+ + {/* 3. Action Modal */} + + {selectedElement && ( +
+ +
+

Änderung erfassen

+ +
+ +
+ + + +
+ +
+ {currentType === "text" && ( +
+ +