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
117 lines
3.3 KiB
TypeScript
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>
|
|
);
|
|
}
|