feat(PROJ-44): Snippet + Quellen-Badge in Such-Trefferliste

- Zweite Zeile unter dem Betreff zeigt Manticore-Snippet
  mit <b>-Highlights, gerendert via dangerouslySetInnerHTML
  ueber sanitizeSnippet
- Quellen-Badge je match_field (Subject, Body, PDF-Anhang,
  Dateiname, Absender, Empfaenger) als kleines Tailwind-Pill
- Nur sichtbar wenn das Backend ein snippet zurueckliefert

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
sysops
2026-05-10 22:18:55 +02:00
parent f4403c8e6c
commit 7be73c1041
+41 -7
View File
@@ -3,7 +3,8 @@
import { useState, useCallback, useEffect, useRef } from "react";
import { useRouter } from "next/navigation";
import { useAuth } from "@/hooks/useAuth";
import { searchEmails, exportMailsZIP, exportEDiscovery, uploadMailFilesUser, getUploadProgressUser, listSavedSearches, createSavedSearch, deleteSavedSearch, type SearchHit, type UploadJob, type SavedSearch } from "@/lib/api";
import { searchEmails, exportMailsZIP, exportEDiscovery, uploadMailFilesUser, getUploadProgressUser, listSavedSearches, createSavedSearch, deleteSavedSearch, type SearchHit, type SearchMatchField, type UploadJob, type SavedSearch } from "@/lib/api";
import { sanitizeSnippet } from "@/lib/sanitize";
import { Navbar } from "@/components/navbar";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@@ -51,6 +52,36 @@ function formatBytes(bytes: number): string {
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
const MATCH_FIELD_LABEL: Record<SearchMatchField, string> = {
subject: "📨 Subject",
body: "✉️ Body",
attachment_text: "📄 PDF-Anhang",
attachment_names: "📎 Dateiname",
from_addr: "👤 Absender",
to_addr: "📧 Empfänger",
};
function MatchSourceBadge({ field }: { field: SearchMatchField }) {
return (
<span className="inline-flex items-center rounded-full bg-muted px-2 py-0.5 text-[10px] font-medium text-muted-foreground whitespace-nowrap">
{MATCH_FIELD_LABEL[field]}
</span>
);
}
function SnippetLine({ hit }: { hit: SearchHit }) {
if (!hit.snippet) return null;
return (
<div className="mt-1 flex items-start gap-2 text-xs text-muted-foreground font-normal">
{hit.match_field && <MatchSourceBadge field={hit.match_field} />}
<span
className="min-w-0 flex-1 truncate [&_b]:font-semibold [&_b]:text-foreground"
dangerouslySetInnerHTML={{ __html: sanitizeSnippet(hit.snippet) }}
/>
</div>
);
}
export default function SearchPage() {
const { user, loading: authLoading } = useAuth();
const router = useRouter();
@@ -621,12 +652,15 @@ export default function SearchPage() {
</TableCell>
<TableCell className="max-w-[14rem] truncate text-sm">{hit.from || "-"}</TableCell>
<TableCell className="font-medium">
<span>{hit.subject || "(kein Betreff)"}</span>
{hit.thread_size && hit.thread_size > 1 && (
<span className="ml-2 inline-flex items-center rounded-full bg-muted px-1.5 py-0.5 text-xs text-muted-foreground font-normal">
{hit.thread_size}
</span>
)}
<div className="flex items-center gap-2">
<span className="truncate">{hit.subject || "(kein Betreff)"}</span>
{hit.thread_size && hit.thread_size > 1 && (
<span className="inline-flex items-center rounded-full bg-muted px-1.5 py-0.5 text-xs text-muted-foreground font-normal">
{hit.thread_size}
</span>
)}
</div>
<SnippetLine hit={hit} />
</TableCell>
<TableCell className="max-w-[12rem] truncate text-sm text-muted-foreground">{hit.to || "-"}</TableCell>
<TableCell className="text-center text-sm">