SearchResults.jsx 7.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306
  1. "use client";
  2. import React from "react";
  3. import Link from "next/link";
  4. import { Eye, FolderOpen, Loader2, SlidersHorizontal } from "lucide-react";
  5. import { SEARCH_SCOPE } from "@/lib/frontend/search/urlState";
  6. import { dayPath } from "@/lib/frontend/routes";
  7. import { buildPdfUrl } from "@/lib/frontend/explorer/pdfUrl";
  8. import ExplorerLoading from "@/components/explorer/states/ExplorerLoading";
  9. import ExplorerEmpty from "@/components/explorer/states/ExplorerEmpty";
  10. import ExplorerError from "@/components/explorer/states/ExplorerError";
  11. import { Alert, AlertTitle, AlertDescription } from "@/components/ui/alert";
  12. import { Button } from "@/components/ui/button";
  13. import {
  14. DropdownMenu,
  15. DropdownMenuContent,
  16. DropdownMenuLabel,
  17. DropdownMenuRadioGroup,
  18. DropdownMenuRadioItem,
  19. DropdownMenuSeparator,
  20. DropdownMenuTrigger,
  21. } from "@/components/ui/dropdown-menu";
  22. import {
  23. Table,
  24. TableBody,
  25. TableCaption,
  26. TableCell,
  27. TableHead,
  28. TableHeader,
  29. TableRow,
  30. } from "@/components/ui/table";
  31. const SORT = Object.freeze({
  32. RELEVANCE: "relevance",
  33. DATE_DESC: "date_desc",
  34. FILENAME_ASC: "filename_asc",
  35. });
  36. function toDateKey(it) {
  37. const y = String(it?.year || "");
  38. const m = String(it?.month || "").padStart(2, "0");
  39. const d = String(it?.day || "").padStart(2, "0");
  40. return `${y}-${m}-${d}`;
  41. }
  42. function formatDateDe(it) {
  43. const y = String(it?.year || "");
  44. const m = String(it?.month || "").padStart(2, "0");
  45. const d = String(it?.day || "").padStart(2, "0");
  46. return `${d}.${m}.${y}`;
  47. }
  48. export default function SearchResults({
  49. branch,
  50. scope,
  51. status,
  52. items,
  53. error,
  54. onRetry,
  55. nextCursor,
  56. onLoadMore,
  57. isLoadingMore,
  58. loadMoreError,
  59. }) {
  60. const showBranchColumn =
  61. scope === SEARCH_SCOPE.ALL || scope === SEARCH_SCOPE.MULTI;
  62. const [sortMode, setSortMode] = React.useState(SORT.RELEVANCE);
  63. const sortedItems = React.useMemo(() => {
  64. const arr = Array.isArray(items) ? [...items] : [];
  65. if (sortMode === SORT.RELEVANCE) return arr;
  66. if (sortMode === SORT.DATE_DESC) {
  67. return arr.sort((a, b) => {
  68. const da = toDateKey(a);
  69. const db = toDateKey(b);
  70. if (da !== db) return da < db ? 1 : -1;
  71. const fa = String(a?.filename || "");
  72. const fb = String(b?.filename || "");
  73. return fa.localeCompare(fb, "de");
  74. });
  75. }
  76. if (sortMode === SORT.FILENAME_ASC) {
  77. return arr.sort((a, b) =>
  78. String(a?.filename || "").localeCompare(String(b?.filename || ""), "de")
  79. );
  80. }
  81. return arr;
  82. }, [items, sortMode]);
  83. if (status === "idle") {
  84. return (
  85. <ExplorerEmpty
  86. title="Suche starten"
  87. description="Bitte geben Sie einen Suchbegriff ein und klicken Sie auf „Suchen“."
  88. upHref={null}
  89. />
  90. );
  91. }
  92. if (status === "loading") {
  93. return <ExplorerLoading variant="table" count={8} />;
  94. }
  95. if (status === "error" && error) {
  96. return (
  97. <ExplorerError
  98. title={error.title}
  99. description={error.description}
  100. onRetry={onRetry}
  101. />
  102. );
  103. }
  104. const list = Array.isArray(sortedItems) ? sortedItems : [];
  105. if (list.length === 0) {
  106. return (
  107. <ExplorerEmpty
  108. title="Keine Treffer"
  109. description="Für Ihre Suche wurden keine Treffer gefunden."
  110. upHref={null}
  111. />
  112. );
  113. }
  114. return (
  115. <div className="space-y-4">
  116. <div className="flex flex-wrap items-center justify-between gap-2">
  117. <div className="text-xs text-muted-foreground">
  118. {list.length} Treffer (aktuell geladen)
  119. </div>
  120. <DropdownMenu>
  121. <DropdownMenuTrigger asChild>
  122. <Button variant="outline" size="sm" type="button">
  123. <SlidersHorizontal className="h-4 w-4" />
  124. Sortierung
  125. </Button>
  126. </DropdownMenuTrigger>
  127. <DropdownMenuContent align="end" className="min-w-[16rem]">
  128. <DropdownMenuLabel>Sortierung</DropdownMenuLabel>
  129. <DropdownMenuSeparator />
  130. <DropdownMenuRadioGroup
  131. value={sortMode}
  132. onValueChange={(value) => setSortMode(value)}
  133. >
  134. <DropdownMenuRadioItem value={SORT.RELEVANCE}>
  135. Relevanz
  136. </DropdownMenuRadioItem>
  137. <DropdownMenuRadioItem value={SORT.DATE_DESC}>
  138. Datum (neueste zuerst)
  139. </DropdownMenuRadioItem>
  140. <DropdownMenuRadioItem value={SORT.FILENAME_ASC}>
  141. Dateiname (A–Z)
  142. </DropdownMenuRadioItem>
  143. </DropdownMenuRadioGroup>
  144. </DropdownMenuContent>
  145. </DropdownMenu>
  146. </div>
  147. <Table>
  148. <TableCaption>
  149. Hinweis: PDFs werden in einem neuen Tab geöffnet.
  150. </TableCaption>
  151. <TableHeader>
  152. <TableRow>
  153. {showBranchColumn ? <TableHead>Niederlassung</TableHead> : null}
  154. <TableHead>Datum</TableHead>
  155. <TableHead>Datei</TableHead>
  156. <TableHead className="hidden md:table-cell">Pfad</TableHead>
  157. <TableHead className="text-right">Aktion</TableHead>
  158. </TableRow>
  159. </TableHeader>
  160. <TableBody>
  161. {list.map((it) => {
  162. const itemBranch = String(it?.branch || branch);
  163. const year = String(it?.year || "");
  164. const month = String(it?.month || "");
  165. const day = String(it?.day || "");
  166. const filename = String(it?.filename || "");
  167. const relativePath = String(it?.relativePath || "");
  168. const snippet =
  169. typeof it?.snippet === "string" && it.snippet.trim()
  170. ? it.snippet.trim()
  171. : null;
  172. const pdfUrl = buildPdfUrl({
  173. branch: itemBranch,
  174. year,
  175. month,
  176. day,
  177. filename,
  178. });
  179. const dayHref = dayPath(itemBranch, year, month, day);
  180. return (
  181. <TableRow
  182. key={
  183. relativePath ||
  184. `${itemBranch}/${year}/${month}/${day}/${filename}`
  185. }
  186. >
  187. {showBranchColumn ? (
  188. <TableCell>
  189. <span className="text-sm">{itemBranch}</span>
  190. </TableCell>
  191. ) : null}
  192. <TableCell>
  193. <span className="text-sm">{formatDateDe(it)}</span>
  194. </TableCell>
  195. <TableCell className="min-w-0">
  196. <div className="min-w-0">
  197. <p className="truncate font-medium">{filename}</p>
  198. {snippet ? (
  199. <p className="mt-0.5 line-clamp-2 text-xs text-muted-foreground">
  200. {snippet}
  201. </p>
  202. ) : null}
  203. <p className="truncate text-xs text-muted-foreground md:hidden">
  204. {relativePath}
  205. </p>
  206. </div>
  207. </TableCell>
  208. <TableCell className="hidden md:table-cell">
  209. <span className="text-xs text-muted-foreground">
  210. {relativePath}
  211. </span>
  212. </TableCell>
  213. <TableCell className="text-right">
  214. <div className="flex justify-end gap-2">
  215. <Button variant="outline" size="sm" asChild>
  216. <a
  217. href={pdfUrl}
  218. target="_blank"
  219. rel="noopener noreferrer"
  220. aria-label={`PDF öffnen: ${filename}`}
  221. title={`PDF öffnen: ${filename}`}
  222. >
  223. <Eye className="h-4 w-4" />
  224. Öffnen
  225. </a>
  226. </Button>
  227. <Button variant="outline" size="sm" asChild>
  228. <Link href={dayHref} title="Zum Tag">
  229. <FolderOpen className="h-4 w-4" />
  230. Zum Tag
  231. </Link>
  232. </Button>
  233. </div>
  234. </TableCell>
  235. </TableRow>
  236. );
  237. })}
  238. </TableBody>
  239. </Table>
  240. {loadMoreError ? (
  241. <Alert variant="destructive">
  242. <AlertTitle>{loadMoreError.title}</AlertTitle>
  243. <AlertDescription>{loadMoreError.description}</AlertDescription>
  244. </Alert>
  245. ) : null}
  246. {nextCursor ? (
  247. <div className="flex justify-center">
  248. <Button
  249. type="button"
  250. variant="outline"
  251. onClick={onLoadMore}
  252. disabled={isLoadingMore}
  253. title="Weitere Ergebnisse laden"
  254. >
  255. {isLoadingMore ? (
  256. <>
  257. <Loader2 className="h-4 w-4 animate-spin" />
  258. Lädt…
  259. </>
  260. ) : (
  261. "Mehr laden"
  262. )}
  263. </Button>
  264. </div>
  265. ) : null}
  266. </div>
  267. );
  268. }