Files
nexarch/web/mail-search/app/page.tsx
T
sysopsandClaude Sonnet 5 d23438d3d0 SRC-04: such-oberflaeche-mit-hervorhebung
Such-Oberfläche (Next.js/React/TS) mit Live-Trefferliste und Hervorhebung
der Suchbegriffe im Kontext (Snippet), auf web/shl (SHL-01) aufbauend.

- app/api/search/route.ts: schlanke Backend-for-Frontend-Route gegen
  dieselbe Manticore-Instanz wie mail/internal/search (SRC-01/SRC-03),
  fordert Highlights mit eigenen Markern statt HTML an.
- lib/highlight.ts: zerlegt markierten Snippet-Text in reine
  Textsegmente, kein dangerouslySetInnerHTML — Mailinhalte werden nie
  als HTML interpretiert.
- app/page.tsx: Sucheingabe, Trefferliste mit <mark>-Hervorhebung,
  verständlicher Hinweis bei leerem Ergebnis.
- app/mail/[messageId]/page.tsx: öffnet mit Anker #fundstelle und
  hervorgehobenem Snippet (voller Mail-Inhaltsabruf folgt mit INT-01).
- lib/contrast.ts: reale WCAG-2.1-Kontrastberechnung.

Prüfungen (alle real durchgeführt, siehe mail/docs/SRC-04-PRUEFPROTOKOLL.md):
1. Manueller Test gegen echten next start + live Manticore auf
   192.168.1.131: Hervorhebung real bestätigt.
2. lib/highlightColors.test.ts: echte WCAG-Berechnung, Hell 14,29:1,
   Dunkel 6,43:1 (>= 4.5:1 AA).
3. Sonderzeichen-Anfrage real gegen laufenden Server: 200 OK, kein
   Absturz; zusätzlich automatisiert gegen Skript-Tags/Unicode.

Kein Umbau: mail/internal/*, web/shl, web/retention-admin unverändert.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HhgFcLS8tYMhDJpP74C6AQ
2026-08-31 10:26:02 +02:00

117 lines
3.3 KiB
TypeScript

"use client";
import { useState, type FormEvent } from "react";
import Link from "next/link";
import { TextField } from "@nexarch/shl";
import { search, ApiError, type SearchHit } from "../lib/api";
import { splitHighlighted } from "../lib/highlight";
import { HIGHLIGHT_BG_LIGHT, HIGHLIGHT_FG_LIGHT } from "../lib/highlightColors";
function tenantSlug(): string {
return process.env.NEXT_PUBLIC_MAIL_TENANT_SLUG ?? "";
}
function Snippet({ text }: { text: string }) {
if (!text) {
return null;
}
return (
<span>
{splitHighlighted(text).map((segment, idx) =>
segment.matched ? (
<mark
key={idx}
style={{ background: HIGHLIGHT_BG_LIGHT, color: HIGHLIGHT_FG_LIGHT, padding: "0 2px" }}
>
{segment.text}
</mark>
) : (
<span key={idx}>{segment.text}</span>
)
)}
</span>
);
}
function resultHref(hit: SearchHit): string {
const params = new URLSearchParams({
subject: hit.subjectSnippet,
snippet: hit.bodySnippet,
});
return `/mail/${encodeURIComponent(hit.messageId)}?${params.toString()}#fundstelle`;
}
export default function SearchPage() {
const [query, setQuery] = useState("");
const [hits, setHits] = useState<SearchHit[] | null>(null);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
async function runSearch(e?: FormEvent) {
e?.preventDefault();
if (!query.trim()) {
setHits(null);
setError(null);
return;
}
setLoading(true);
setError(null);
try {
const result = await search(tenantSlug(), query);
setHits(result.hits);
} catch (err) {
// Akzeptanzkriterium 3 (sinngemäß auf Fehlerfall übertragen): auch
// ein Suchfehler zeigt einen verständlichen Hinweis statt einer
// leeren Fläche.
setHits([]);
setError(err instanceof ApiError ? err.message : "Suche konnte nicht ausgeführt werden.");
} finally {
setLoading(false);
}
}
return (
<main style={{ maxWidth: 720, margin: "40px auto", padding: "0 16px" }}>
<h1>Mail-Suche</h1>
<form onSubmit={runSearch}>
<TextField
label="Suchbegriff"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder='z. B. "Quartalsbericht" oder Umsatz -Verlust'
/>
<button type="submit" disabled={loading} style={{ marginTop: 8 }}>
Suchen
</button>
</form>
{error && (
<p role="alert" style={{ marginTop: 16 }}>
{error}
</p>
)}
{hits !== null && hits.length === 0 && !error && (
<p role="status" style={{ marginTop: 16 }}>
Keine Treffer für diese Suchanfrage.
</p>
)}
{hits !== null && hits.length > 0 && (
<ul style={{ listStyle: "none", padding: 0, marginTop: 16 }}>
{hits.map((hit) => (
<li key={hit.messageId} style={{ padding: "8px 0", borderBottom: "1px solid var(--shl-color-border, #d7dbe0)" }}>
<Link href={resultHref(hit)}>
<Snippet text={hit.subjectSnippet} />
<div>
<Snippet text={hit.bodySnippet} />
</div>
</Link>
</li>
))}
</ul>
)}
</main>
);
}