Spaces:
Building
Building
Deploy from GitHub: ac823d8
Browse files- README.md +2 -4
- frontend/app/globals.css +88 -15
- frontend/app/layout.tsx +21 -2
- frontend/app/page.tsx +465 -666
README.md
CHANGED
|
@@ -7,7 +7,7 @@ sdk: docker
|
|
| 7 |
app_port: 7860
|
| 8 |
pinned: true
|
| 9 |
license: mit
|
| 10 |
-
short_description: Search
|
| 11 |
---
|
| 12 |
|
| 13 |
# Swiss Caselaw
|
|
@@ -16,9 +16,7 @@ Search Swiss federal and cantonal court decisions with hybrid retrieval (full-te
|
|
| 16 |
|
| 17 |
## Features
|
| 18 |
|
| 19 |
-
- **
|
| 20 |
-
- **Multilingual UI**: German, French, Italian, Romansh, English
|
| 21 |
-
- **Advanced Sorting**: By relevance, date, or court
|
| 22 |
- **Hybrid Search**: Combines full-text search with semantic embeddings for better results
|
| 23 |
- **AI Answers**: Get AI-generated answers with pinpoint citations to source decisions
|
| 24 |
- **Multi-language**: German, French, Italian decisions
|
|
|
|
| 7 |
app_port: 7860
|
| 8 |
pinned: true
|
| 9 |
license: mit
|
| 10 |
+
short_description: Search 800K+ Swiss court decisions with AI
|
| 11 |
---
|
| 12 |
|
| 13 |
# Swiss Caselaw
|
|
|
|
| 16 |
|
| 17 |
## Features
|
| 18 |
|
| 19 |
+
- **800,000+ Decisions**: From all Swiss federal courts (BGer, BVGer, BStGer, BPatGer) and 26 cantons
|
|
|
|
|
|
|
| 20 |
- **Hybrid Search**: Combines full-text search with semantic embeddings for better results
|
| 21 |
- **AI Answers**: Get AI-generated answers with pinpoint citations to source decisions
|
| 22 |
- **Multi-language**: German, French, Italian decisions
|
frontend/app/globals.css
CHANGED
|
@@ -4,44 +4,91 @@
|
|
| 4 |
|
| 5 |
:root {
|
| 6 |
color-scheme: light;
|
|
|
|
|
|
|
| 7 |
}
|
| 8 |
|
| 9 |
-
html
|
| 10 |
height: 100%;
|
|
|
|
| 11 |
}
|
| 12 |
|
| 13 |
body {
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
|
|
|
|
|
|
|
|
|
| 17 |
}
|
| 18 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
a {
|
| 20 |
-
@apply text-accent no-underline
|
|
|
|
|
|
|
|
|
|
|
|
|
| 21 |
}
|
| 22 |
|
|
|
|
| 23 |
input, select, textarea {
|
| 24 |
-
@apply border border-line bg-white px-
|
| 25 |
-
@apply focus:outline-none focus:border-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
}
|
| 27 |
|
| 28 |
input[type="checkbox"] {
|
| 29 |
-
@apply h-
|
| 30 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 31 |
}
|
| 32 |
|
| 33 |
select {
|
| 34 |
-
@apply
|
| 35 |
-
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24' stroke='%23737373'%3E%3Cpath stroke-linecap='round' stroke-width='
|
| 36 |
-
background-position: right
|
| 37 |
background-repeat: no-repeat;
|
| 38 |
-
background-size:
|
| 39 |
}
|
| 40 |
|
| 41 |
button {
|
| 42 |
-
|
|
|
|
| 43 |
}
|
| 44 |
|
|
|
|
| 45 |
::-webkit-scrollbar {
|
| 46 |
width: 6px;
|
| 47 |
height: 6px;
|
|
@@ -60,6 +107,32 @@ button {
|
|
| 60 |
background: #a3a3a3;
|
| 61 |
}
|
| 62 |
|
|
|
|
| 63 |
::selection {
|
| 64 |
-
@apply bg-accent/
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 65 |
}
|
|
|
|
| 4 |
|
| 5 |
:root {
|
| 6 |
color-scheme: light;
|
| 7 |
+
--safe-top: env(safe-area-inset-top);
|
| 8 |
+
--safe-bottom: env(safe-area-inset-bottom);
|
| 9 |
}
|
| 10 |
|
| 11 |
+
html {
|
| 12 |
height: 100%;
|
| 13 |
+
-webkit-text-size-adjust: 100%;
|
| 14 |
}
|
| 15 |
|
| 16 |
body {
|
| 17 |
+
min-height: 100%;
|
| 18 |
+
@apply bg-bg text-fg antialiased;
|
| 19 |
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
| 20 |
+
font-size: 16px;
|
| 21 |
+
line-height: 1.5;
|
| 22 |
+
-webkit-tap-highlight-color: transparent;
|
| 23 |
}
|
| 24 |
|
| 25 |
+
/* Safe area helpers */
|
| 26 |
+
.safe-area-top {
|
| 27 |
+
height: env(safe-area-inset-top);
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
.safe-area-bottom {
|
| 31 |
+
height: env(safe-area-inset-bottom);
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
/* Links */
|
| 35 |
a {
|
| 36 |
+
@apply text-accent no-underline;
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
a:hover {
|
| 40 |
+
@apply underline;
|
| 41 |
}
|
| 42 |
|
| 43 |
+
/* Form inputs */
|
| 44 |
input, select, textarea {
|
| 45 |
+
@apply border border-line bg-white px-4 py-3 text-base;
|
| 46 |
+
@apply focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent;
|
| 47 |
+
border-radius: 8px;
|
| 48 |
+
/* Prevent iOS zoom */
|
| 49 |
+
font-size: 16px;
|
| 50 |
+
-webkit-appearance: none;
|
| 51 |
+
appearance: none;
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
@media (min-width: 1024px) {
|
| 55 |
+
input, select, textarea {
|
| 56 |
+
@apply px-3 py-2 text-sm;
|
| 57 |
+
font-size: 14px;
|
| 58 |
+
}
|
| 59 |
}
|
| 60 |
|
| 61 |
input[type="checkbox"] {
|
| 62 |
+
@apply h-5 w-5 rounded border-2 border-line bg-white cursor-pointer;
|
| 63 |
+
min-width: 20px;
|
| 64 |
+
min-height: 20px;
|
| 65 |
+
accent-color: #2563eb;
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
input[type="date"] {
|
| 69 |
+
min-height: 48px;
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
@media (min-width: 1024px) {
|
| 73 |
+
input[type="date"] {
|
| 74 |
+
min-height: auto;
|
| 75 |
+
}
|
| 76 |
}
|
| 77 |
|
| 78 |
select {
|
| 79 |
+
@apply cursor-pointer pr-10;
|
| 80 |
+
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24' stroke='%23737373'%3E%3Cpath stroke-linecap='round' stroke-width='2' d='M19 9l-7 7-7-7'/%3E%3C/svg%3E");
|
| 81 |
+
background-position: right 12px center;
|
| 82 |
background-repeat: no-repeat;
|
| 83 |
+
background-size: 16px;
|
| 84 |
}
|
| 85 |
|
| 86 |
button {
|
| 87 |
+
font-family: inherit;
|
| 88 |
+
-webkit-tap-highlight-color: transparent;
|
| 89 |
}
|
| 90 |
|
| 91 |
+
/* Scrollbar */
|
| 92 |
::-webkit-scrollbar {
|
| 93 |
width: 6px;
|
| 94 |
height: 6px;
|
|
|
|
| 107 |
background: #a3a3a3;
|
| 108 |
}
|
| 109 |
|
| 110 |
+
/* Selection */
|
| 111 |
::selection {
|
| 112 |
+
@apply bg-accent/20 text-fg;
|
| 113 |
+
}
|
| 114 |
+
|
| 115 |
+
/* Line clamp */
|
| 116 |
+
.line-clamp-2 {
|
| 117 |
+
display: -webkit-box;
|
| 118 |
+
-webkit-line-clamp: 2;
|
| 119 |
+
-webkit-box-orient: vertical;
|
| 120 |
+
overflow: hidden;
|
| 121 |
+
}
|
| 122 |
+
|
| 123 |
+
/* Animations */
|
| 124 |
+
@keyframes spin {
|
| 125 |
+
from { transform: rotate(0deg); }
|
| 126 |
+
to { transform: rotate(360deg); }
|
| 127 |
+
}
|
| 128 |
+
|
| 129 |
+
.animate-spin {
|
| 130 |
+
animation: spin 1s linear infinite;
|
| 131 |
+
}
|
| 132 |
+
|
| 133 |
+
/* Prevent overscroll bounce on iOS */
|
| 134 |
+
@supports (-webkit-touch-callout: none) {
|
| 135 |
+
body {
|
| 136 |
+
overscroll-behavior-y: none;
|
| 137 |
+
}
|
| 138 |
}
|
frontend/app/layout.tsx
CHANGED
|
@@ -1,14 +1,33 @@
|
|
| 1 |
import "./globals.css";
|
| 2 |
import type { ReactNode } from "react";
|
|
|
|
| 3 |
|
| 4 |
-
export const metadata = {
|
| 5 |
title: "Swiss Case Law AI",
|
| 6 |
-
description: "
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7 |
};
|
| 8 |
|
| 9 |
export default function RootLayout({ children }: { children: ReactNode }) {
|
| 10 |
return (
|
| 11 |
<html lang="en">
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
<body>
|
| 13 |
{children}
|
| 14 |
</body>
|
|
|
|
| 1 |
import "./globals.css";
|
| 2 |
import type { ReactNode } from "react";
|
| 3 |
+
import type { Viewport, Metadata } from "next";
|
| 4 |
|
| 5 |
+
export const metadata: Metadata = {
|
| 6 |
title: "Swiss Case Law AI",
|
| 7 |
+
description: "Search 800k+ Swiss court decisions with AI-powered answers",
|
| 8 |
+
appleWebApp: {
|
| 9 |
+
capable: true,
|
| 10 |
+
statusBarStyle: "default",
|
| 11 |
+
title: "Swiss Case Law"
|
| 12 |
+
}
|
| 13 |
+
};
|
| 14 |
+
|
| 15 |
+
export const viewport: Viewport = {
|
| 16 |
+
width: "device-width",
|
| 17 |
+
initialScale: 1,
|
| 18 |
+
maximumScale: 1,
|
| 19 |
+
userScalable: false,
|
| 20 |
+
viewportFit: "cover",
|
| 21 |
+
themeColor: "#ffffff"
|
| 22 |
};
|
| 23 |
|
| 24 |
export default function RootLayout({ children }: { children: ReactNode }) {
|
| 25 |
return (
|
| 26 |
<html lang="en">
|
| 27 |
+
<head>
|
| 28 |
+
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
|
| 29 |
+
<meta name="apple-mobile-web-app-capable" content="yes" />
|
| 30 |
+
</head>
|
| 31 |
<body>
|
| 32 |
{children}
|
| 33 |
</body>
|
frontend/app/page.tsx
CHANGED
|
@@ -1,12 +1,8 @@
|
|
| 1 |
"use client";
|
| 2 |
|
| 3 |
-
import { useEffect, useMemo, useState
|
| 4 |
import Link from "next/link";
|
| 5 |
|
| 6 |
-
// ============================================================================
|
| 7 |
-
// Types
|
| 8 |
-
// ============================================================================
|
| 9 |
-
|
| 10 |
type Source = {
|
| 11 |
id: string;
|
| 12 |
name: string;
|
|
@@ -65,269 +61,42 @@ type AnswerResponse = {
|
|
| 65 |
hits_count: number;
|
| 66 |
};
|
| 67 |
|
| 68 |
-
type Language = "de" | "fr" | "it" | "rm" | "en";
|
| 69 |
-
|
| 70 |
-
type SortOption = "relevance" | "date_desc" | "date_asc" | "court";
|
| 71 |
-
|
| 72 |
-
// ============================================================================
|
| 73 |
-
// Translations
|
| 74 |
-
// ============================================================================
|
| 75 |
-
|
| 76 |
-
const translations: Record<Language, Record<string, string>> = {
|
| 77 |
-
de: {
|
| 78 |
-
title: "Schweizer Rechtsprechung",
|
| 79 |
-
subtitle: "Durchsuchen Sie Entscheide von Bundes- und Kantonsgerichten",
|
| 80 |
-
searchPlaceholder: "Suchen Sie nach Entscheiden...",
|
| 81 |
-
search: "Suchen",
|
| 82 |
-
filters: "Filter",
|
| 83 |
-
allLevels: "Alle Ebenen",
|
| 84 |
-
federal: "Bund",
|
| 85 |
-
cantonal: "Kantonal",
|
| 86 |
-
allCantons: "Alle Kantone",
|
| 87 |
-
anyLanguage: "Alle Sprachen",
|
| 88 |
-
from: "Von",
|
| 89 |
-
to: "Bis",
|
| 90 |
-
results: "Ergebnisse",
|
| 91 |
-
noResults: "Keine Ergebnisse gefunden",
|
| 92 |
-
loading: "Laden...",
|
| 93 |
-
loadMore: "Mehr laden",
|
| 94 |
-
aiAnswer: "KI-Antwort",
|
| 95 |
-
generating: "Generieren...",
|
| 96 |
-
aiHint: "Suchen Sie, um eine KI-generierte Antwort mit Zitaten zu erhalten.",
|
| 97 |
-
sources: "Quellen",
|
| 98 |
-
settings: "Einstellungen",
|
| 99 |
-
apiKey: "OpenAI API-Schlüssel",
|
| 100 |
-
close: "Schliessen",
|
| 101 |
-
clear: "Löschen",
|
| 102 |
-
source: "Quelle",
|
| 103 |
-
pdf: "PDF",
|
| 104 |
-
stats: "Statistiken",
|
| 105 |
-
api: "API",
|
| 106 |
-
sortBy: "Sortieren nach",
|
| 107 |
-
relevance: "Relevanz",
|
| 108 |
-
dateDesc: "Datum (neueste)",
|
| 109 |
-
dateAsc: "Datum (älteste)",
|
| 110 |
-
court: "Gericht",
|
| 111 |
-
decisions: "Entscheide",
|
| 112 |
-
searchHint: "Geben Sie eine Suchanfrage ein, um Entscheide zu finden.",
|
| 113 |
-
},
|
| 114 |
-
fr: {
|
| 115 |
-
title: "Jurisprudence Suisse",
|
| 116 |
-
subtitle: "Recherchez les décisions des tribunaux fédéraux et cantonaux",
|
| 117 |
-
searchPlaceholder: "Rechercher des décisions...",
|
| 118 |
-
search: "Rechercher",
|
| 119 |
-
filters: "Filtres",
|
| 120 |
-
allLevels: "Tous les niveaux",
|
| 121 |
-
federal: "Fédéral",
|
| 122 |
-
cantonal: "Cantonal",
|
| 123 |
-
allCantons: "Tous les cantons",
|
| 124 |
-
anyLanguage: "Toutes les langues",
|
| 125 |
-
from: "Du",
|
| 126 |
-
to: "Au",
|
| 127 |
-
results: "résultats",
|
| 128 |
-
noResults: "Aucun résultat trouvé",
|
| 129 |
-
loading: "Chargement...",
|
| 130 |
-
loadMore: "Charger plus",
|
| 131 |
-
aiAnswer: "Réponse IA",
|
| 132 |
-
generating: "Génération...",
|
| 133 |
-
aiHint: "Recherchez pour obtenir une réponse générée par IA avec citations.",
|
| 134 |
-
sources: "Sources",
|
| 135 |
-
settings: "Paramètres",
|
| 136 |
-
apiKey: "Clé API OpenAI",
|
| 137 |
-
close: "Fermer",
|
| 138 |
-
clear: "Effacer",
|
| 139 |
-
source: "Source",
|
| 140 |
-
pdf: "PDF",
|
| 141 |
-
stats: "Statistiques",
|
| 142 |
-
api: "API",
|
| 143 |
-
sortBy: "Trier par",
|
| 144 |
-
relevance: "Pertinence",
|
| 145 |
-
dateDesc: "Date (récent)",
|
| 146 |
-
dateAsc: "Date (ancien)",
|
| 147 |
-
court: "Tribunal",
|
| 148 |
-
decisions: "décisions",
|
| 149 |
-
searchHint: "Entrez une requête pour rechercher des décisions.",
|
| 150 |
-
},
|
| 151 |
-
it: {
|
| 152 |
-
title: "Giurisprudenza Svizzera",
|
| 153 |
-
subtitle: "Cerca le decisioni dei tribunali federali e cantonali",
|
| 154 |
-
searchPlaceholder: "Cerca decisioni...",
|
| 155 |
-
search: "Cerca",
|
| 156 |
-
filters: "Filtri",
|
| 157 |
-
allLevels: "Tutti i livelli",
|
| 158 |
-
federal: "Federale",
|
| 159 |
-
cantonal: "Cantonale",
|
| 160 |
-
allCantons: "Tutti i cantoni",
|
| 161 |
-
anyLanguage: "Tutte le lingue",
|
| 162 |
-
from: "Dal",
|
| 163 |
-
to: "Al",
|
| 164 |
-
results: "risultati",
|
| 165 |
-
noResults: "Nessun risultato trovato",
|
| 166 |
-
loading: "Caricamento...",
|
| 167 |
-
loadMore: "Carica altro",
|
| 168 |
-
aiAnswer: "Risposta IA",
|
| 169 |
-
generating: "Generazione...",
|
| 170 |
-
aiHint: "Cerca per ottenere una risposta generata dall'IA con citazioni.",
|
| 171 |
-
sources: "Fonti",
|
| 172 |
-
settings: "Impostazioni",
|
| 173 |
-
apiKey: "Chiave API OpenAI",
|
| 174 |
-
close: "Chiudi",
|
| 175 |
-
clear: "Cancella",
|
| 176 |
-
source: "Fonte",
|
| 177 |
-
pdf: "PDF",
|
| 178 |
-
stats: "Statistiche",
|
| 179 |
-
api: "API",
|
| 180 |
-
sortBy: "Ordina per",
|
| 181 |
-
relevance: "Rilevanza",
|
| 182 |
-
dateDesc: "Data (recente)",
|
| 183 |
-
dateAsc: "Data (vecchio)",
|
| 184 |
-
court: "Tribunale",
|
| 185 |
-
decisions: "decisioni",
|
| 186 |
-
searchHint: "Inserisci una query per cercare decisioni.",
|
| 187 |
-
},
|
| 188 |
-
rm: {
|
| 189 |
-
title: "Giurisprudenza Svizra",
|
| 190 |
-
subtitle: "Tschertgai decisiuns dals tribunals federals e chantunals",
|
| 191 |
-
searchPlaceholder: "Tschertgar decisiuns...",
|
| 192 |
-
search: "Tschertgar",
|
| 193 |
-
filters: "Filters",
|
| 194 |
-
allLevels: "Tuts nivels",
|
| 195 |
-
federal: "Federal",
|
| 196 |
-
cantonal: "Chantunal",
|
| 197 |
-
allCantons: "Tuts chantuns",
|
| 198 |
-
anyLanguage: "Tut las linguas",
|
| 199 |
-
from: "Da",
|
| 200 |
-
to: "Enfin",
|
| 201 |
-
results: "resultats",
|
| 202 |
-
noResults: "Nagins resultats chattads",
|
| 203 |
-
loading: "Chargiar...",
|
| 204 |
-
loadMore: "Chargiar dapli",
|
| 205 |
-
aiAnswer: "Resposta IA",
|
| 206 |
-
generating: "Generar...",
|
| 207 |
-
aiHint: "Tschertgai per obtegnair ina resposta generada da l'IA.",
|
| 208 |
-
sources: "Funtaunas",
|
| 209 |
-
settings: "Parameters",
|
| 210 |
-
apiKey: "Clav API OpenAI",
|
| 211 |
-
close: "Serrar",
|
| 212 |
-
clear: "Stizzar",
|
| 213 |
-
source: "Funtauna",
|
| 214 |
-
pdf: "PDF",
|
| 215 |
-
stats: "Statisticas",
|
| 216 |
-
api: "API",
|
| 217 |
-
sortBy: "Ordinar tenor",
|
| 218 |
-
relevance: "Relevanza",
|
| 219 |
-
dateDesc: "Data (nov)",
|
| 220 |
-
dateAsc: "Data (vegl)",
|
| 221 |
-
court: "Tribunal",
|
| 222 |
-
decisions: "decisiuns",
|
| 223 |
-
searchHint: "Endatai ina dumonda per tschertgar decisiuns.",
|
| 224 |
-
},
|
| 225 |
-
en: {
|
| 226 |
-
title: "Swiss Case Law",
|
| 227 |
-
subtitle: "Search federal and cantonal court decisions",
|
| 228 |
-
searchPlaceholder: "Search decisions...",
|
| 229 |
-
search: "Search",
|
| 230 |
-
filters: "Filters",
|
| 231 |
-
allLevels: "All levels",
|
| 232 |
-
federal: "Federal",
|
| 233 |
-
cantonal: "Cantonal",
|
| 234 |
-
allCantons: "All cantons",
|
| 235 |
-
anyLanguage: "Any language",
|
| 236 |
-
from: "From",
|
| 237 |
-
to: "To",
|
| 238 |
-
results: "results",
|
| 239 |
-
noResults: "No results found",
|
| 240 |
-
loading: "Loading...",
|
| 241 |
-
loadMore: "Load more",
|
| 242 |
-
aiAnswer: "AI Answer",
|
| 243 |
-
generating: "Generating...",
|
| 244 |
-
aiHint: "Search to get an AI-generated answer with citations.",
|
| 245 |
-
sources: "Sources",
|
| 246 |
-
settings: "Settings",
|
| 247 |
-
apiKey: "OpenAI API Key",
|
| 248 |
-
close: "Close",
|
| 249 |
-
clear: "Clear",
|
| 250 |
-
source: "Source",
|
| 251 |
-
pdf: "PDF",
|
| 252 |
-
stats: "Stats",
|
| 253 |
-
api: "API",
|
| 254 |
-
sortBy: "Sort by",
|
| 255 |
-
relevance: "Relevance",
|
| 256 |
-
dateDesc: "Date (newest)",
|
| 257 |
-
dateAsc: "Date (oldest)",
|
| 258 |
-
court: "Court",
|
| 259 |
-
decisions: "decisions",
|
| 260 |
-
searchHint: "Enter a query to search decisions.",
|
| 261 |
-
},
|
| 262 |
-
};
|
| 263 |
-
|
| 264 |
-
const languageNames: Record<Language, string> = {
|
| 265 |
-
de: "DE",
|
| 266 |
-
fr: "FR",
|
| 267 |
-
it: "IT",
|
| 268 |
-
rm: "RM",
|
| 269 |
-
en: "EN",
|
| 270 |
-
};
|
| 271 |
-
|
| 272 |
-
// ============================================================================
|
| 273 |
-
// API
|
| 274 |
-
// ============================================================================
|
| 275 |
-
|
| 276 |
const API_BASE =
|
| 277 |
process.env.NEXT_PUBLIC_API_BASE_URL?.replace(/\/+$/, "") ?? "http://localhost:8000";
|
| 278 |
|
| 279 |
-
// ============================================================================
|
| 280 |
-
// Component
|
| 281 |
-
// ============================================================================
|
| 282 |
-
|
| 283 |
export default function HomePage() {
|
| 284 |
-
// Language
|
| 285 |
-
const [lang, setLang] = useState<Language>("de");
|
| 286 |
-
const t = useCallback((key: string) => translations[lang][key] || key, [lang]);
|
| 287 |
-
|
| 288 |
-
// Data
|
| 289 |
const [sources, setSources] = useState<Source[]>([]);
|
| 290 |
-
const [stats, setStats] = useState<{ total: number } | null>(null);
|
| 291 |
-
|
| 292 |
-
// Search state
|
| 293 |
const [query, setQuery] = useState("");
|
|
|
|
| 294 |
const [level, setLevel] = useState<string>("");
|
| 295 |
const [canton, setCanton] = useState<string>("");
|
| 296 |
const [language, setLanguage] = useState<string>("");
|
| 297 |
const [dateFrom, setDateFrom] = useState<string>("");
|
| 298 |
const [dateTo, setDateTo] = useState<string>("");
|
| 299 |
-
const [sortBy, setSortBy] = useState<SortOption>("relevance");
|
| 300 |
|
| 301 |
-
// Results
|
| 302 |
const [hits, setHits] = useState<SearchHit[]>([]);
|
| 303 |
-
const [totalResults, setTotalResults] = useState<number>(0);
|
| 304 |
const [loading, setLoading] = useState(false);
|
| 305 |
const [page, setPage] = useState(0);
|
| 306 |
const [hasMore, setHasMore] = useState(false);
|
| 307 |
-
const PAGE_SIZE =
|
| 308 |
|
| 309 |
-
|
| 310 |
-
const [aiEnabled, setAiEnabled] = useState(false);
|
| 311 |
const [answer, setAnswer] = useState<AnswerResponse | null>(null);
|
| 312 |
const [answerLoading, setAnswerLoading] = useState(false);
|
|
|
|
| 313 |
|
| 314 |
-
// Decision detail
|
| 315 |
const [activeDecisionId, setActiveDecisionId] = useState<string | null>(null);
|
| 316 |
const [activeDecision, setActiveDecision] = useState<any | null>(null);
|
| 317 |
const [decisionLoading, setDecisionLoading] = useState(false);
|
| 318 |
|
| 319 |
-
// Settings
|
| 320 |
const [apiKey, setApiKey] = useState<string>("");
|
| 321 |
const [showSettings, setShowSettings] = useState(false);
|
| 322 |
const [showFilters, setShowFilters] = useState(false);
|
|
|
|
| 323 |
|
| 324 |
-
// Load saved preferences
|
| 325 |
useEffect(() => {
|
| 326 |
-
const
|
| 327 |
-
if (
|
| 328 |
-
|
| 329 |
-
const savedLang = localStorage.getItem("ui_language") as Language;
|
| 330 |
-
if (savedLang && translations[savedLang]) setLang(savedLang);
|
| 331 |
}, []);
|
| 332 |
|
| 333 |
function saveApiKey(key: string) {
|
|
@@ -339,11 +108,6 @@ export default function HomePage() {
|
|
| 339 |
}
|
| 340 |
}
|
| 341 |
|
| 342 |
-
function saveLanguage(l: Language) {
|
| 343 |
-
setLang(l);
|
| 344 |
-
localStorage.setItem("ui_language", l);
|
| 345 |
-
}
|
| 346 |
-
|
| 347 |
function getHeaders(): HeadersInit {
|
| 348 |
const headers: HeadersInit = { "content-type": "application/json" };
|
| 349 |
if (apiKey) {
|
|
@@ -352,17 +116,11 @@ export default function HomePage() {
|
|
| 352 |
return headers;
|
| 353 |
}
|
| 354 |
|
| 355 |
-
// Load sources and stats
|
| 356 |
useEffect(() => {
|
| 357 |
fetch(`${API_BASE}/api/sources`)
|
| 358 |
.then((r) => r.json())
|
| 359 |
.then((data) => setSources(Array.isArray(data) ? data : []))
|
| 360 |
.catch(() => setSources([]));
|
| 361 |
-
|
| 362 |
-
fetch(`${API_BASE}/api/stats`)
|
| 363 |
-
.then((r) => r.json())
|
| 364 |
-
.then((data) => setStats(data))
|
| 365 |
-
.catch(() => setStats(null));
|
| 366 |
}, []);
|
| 367 |
|
| 368 |
const cantons = useMemo(() => {
|
|
@@ -371,36 +129,12 @@ export default function HomePage() {
|
|
| 371 |
return Array.from(set).sort();
|
| 372 |
}, [sources]);
|
| 373 |
|
| 374 |
-
const
|
| 375 |
const set = new Set<string>();
|
| 376 |
for (const s of sources) for (const l of s.languages || []) set.add(l);
|
| 377 |
return Array.from(set).sort();
|
| 378 |
}, [sources]);
|
| 379 |
|
| 380 |
-
// Sort hits client-side
|
| 381 |
-
const sortedHits = useMemo(() => {
|
| 382 |
-
if (sortBy === "relevance") return hits;
|
| 383 |
-
|
| 384 |
-
return [...hits].sort((a, b) => {
|
| 385 |
-
if (sortBy === "date_desc") {
|
| 386 |
-
const dateA = a.decision.decision_date || "";
|
| 387 |
-
const dateB = b.decision.decision_date || "";
|
| 388 |
-
return dateB.localeCompare(dateA);
|
| 389 |
-
}
|
| 390 |
-
if (sortBy === "date_asc") {
|
| 391 |
-
const dateA = a.decision.decision_date || "";
|
| 392 |
-
const dateB = b.decision.decision_date || "";
|
| 393 |
-
return dateA.localeCompare(dateB);
|
| 394 |
-
}
|
| 395 |
-
if (sortBy === "court") {
|
| 396 |
-
const courtA = a.decision.court || a.decision.source_name || "";
|
| 397 |
-
const courtB = b.decision.court || b.decision.source_name || "";
|
| 398 |
-
return courtA.localeCompare(courtB);
|
| 399 |
-
}
|
| 400 |
-
return 0;
|
| 401 |
-
});
|
| 402 |
-
}, [hits, sortBy]);
|
| 403 |
-
|
| 404 |
async function runSearch(newPage: number = 0) {
|
| 405 |
const q = query.trim();
|
| 406 |
if (!q) return;
|
|
@@ -409,11 +143,13 @@ export default function HomePage() {
|
|
| 409 |
if (newPage === 0) {
|
| 410 |
setAnswer(null);
|
| 411 |
setHits([]);
|
| 412 |
-
setTotalResults(0);
|
| 413 |
}
|
|
|
|
|
|
|
| 414 |
setPage(newPage);
|
| 415 |
|
| 416 |
const body: any = { query: q, limit: PAGE_SIZE, offset: newPage * PAGE_SIZE };
|
|
|
|
| 417 |
if (level) body.level = level;
|
| 418 |
if (canton) body.canton = canton;
|
| 419 |
if (language) body.language = language;
|
|
@@ -424,14 +160,12 @@ export default function HomePage() {
|
|
| 424 |
const res = await fetch(`${API_BASE}/api/search`, {
|
| 425 |
method: "POST",
|
| 426 |
headers: getHeaders(),
|
| 427 |
-
body: JSON.stringify(body)
|
| 428 |
});
|
| 429 |
const data: SearchResponse = await res.json();
|
| 430 |
const newHits = Array.isArray(data?.hits) ? data.hits : [];
|
| 431 |
-
|
| 432 |
if (newPage === 0) {
|
| 433 |
setHits(newHits);
|
| 434 |
-
setTotalResults(data.total || newHits.length);
|
| 435 |
} else {
|
| 436 |
setHits((prev) => [...prev, ...newHits]);
|
| 437 |
}
|
|
@@ -443,19 +177,24 @@ export default function HomePage() {
|
|
| 443 |
setLoading(false);
|
| 444 |
}
|
| 445 |
|
| 446 |
-
|
| 447 |
-
if (aiEnabled && newPage === 0 && apiKey) {
|
| 448 |
setAnswerLoading(true);
|
| 449 |
try {
|
| 450 |
const res = await fetch(`${API_BASE}/api/answer`, {
|
| 451 |
method: "POST",
|
| 452 |
headers: getHeaders(),
|
| 453 |
-
body: JSON.stringify({ ...body, offset: 0
|
| 454 |
});
|
| 455 |
const data: AnswerResponse = await res.json();
|
| 456 |
setAnswer(data);
|
|
|
|
|
|
|
| 457 |
} catch {
|
| 458 |
-
setAnswer(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 459 |
} finally {
|
| 460 |
setAnswerLoading(false);
|
| 461 |
}
|
|
@@ -470,6 +209,7 @@ export default function HomePage() {
|
|
| 470 |
setActiveDecisionId(decisionId);
|
| 471 |
setDecisionLoading(true);
|
| 472 |
setActiveDecision(null);
|
|
|
|
| 473 |
try {
|
| 474 |
const res = await fetch(`${API_BASE}/api/decisions/${decisionId}`);
|
| 475 |
const data = await res.json();
|
|
@@ -481,410 +221,467 @@ export default function HomePage() {
|
|
| 481 |
}
|
| 482 |
}
|
| 483 |
|
| 484 |
-
|
| 485 |
-
|
| 486 |
-
|
| 487 |
-
|
| 488 |
-
|
|
|
|
|
|
|
|
|
|
| 489 |
|
| 490 |
return (
|
| 491 |
-
<div className="min-h-screen bg-
|
| 492 |
-
{/* Header */}
|
| 493 |
-
<header className="sticky top-0 z-40 bg-white
|
| 494 |
-
<div className="
|
| 495 |
-
|
| 496 |
-
|
|
|
|
| 497 |
<div>
|
| 498 |
-
<h1 className="text-lg font-semibold
|
| 499 |
-
<p className="text-xs text-
|
| 500 |
-
{stats ? `${stats.total.toLocaleString()} ${t("decisions")}` : t("subtitle")}
|
| 501 |
-
</p>
|
| 502 |
</div>
|
| 503 |
|
| 504 |
-
|
| 505 |
-
|
| 506 |
-
<
|
| 507 |
-
|
| 508 |
-
|
| 509 |
-
|
| 510 |
-
|
| 511 |
-
|
| 512 |
-
|
| 513 |
-
|
| 514 |
-
|
| 515 |
-
|
| 516 |
-
|
| 517 |
-
|
| 518 |
-
|
| 519 |
-
))}
|
| 520 |
-
</div>
|
| 521 |
-
|
| 522 |
-
<Link
|
| 523 |
-
href="/stats"
|
| 524 |
-
className="px-3 py-1.5 text-xs text-neutral-500 hover:text-neutral-900 transition-colors"
|
| 525 |
-
>
|
| 526 |
-
{t("stats")}
|
| 527 |
-
</Link>
|
| 528 |
-
<a
|
| 529 |
-
href={`${API_BASE}/docs`}
|
| 530 |
-
target="_blank"
|
| 531 |
-
rel="noreferrer"
|
| 532 |
-
className="px-3 py-1.5 text-xs text-neutral-500 hover:text-neutral-900 transition-colors"
|
| 533 |
-
>
|
| 534 |
-
{t("api")}
|
| 535 |
-
</a>
|
| 536 |
-
<button
|
| 537 |
-
onClick={() => setShowSettings(true)}
|
| 538 |
-
className="px-3 py-1.5 text-xs text-neutral-500 hover:text-neutral-900 transition-colors"
|
| 539 |
-
>
|
| 540 |
-
{t("settings")}
|
| 541 |
</button>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 542 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 543 |
</div>
|
| 544 |
-
|
| 545 |
-
{/*
|
| 546 |
-
|
| 547 |
-
<div className="
|
| 548 |
-
<
|
| 549 |
-
|
| 550 |
-
|
| 551 |
-
|
| 552 |
-
onChange={(e) => setQuery(e.target.value)}
|
| 553 |
-
onKeyDown={handleKeyDown}
|
| 554 |
-
placeholder={t("searchPlaceholder")}
|
| 555 |
-
className="w-full h-12 px-4 text-base border border-neutral-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-neutral-900 focus:border-transparent transition-shadow"
|
| 556 |
-
/>
|
| 557 |
-
</div>
|
| 558 |
-
<button
|
| 559 |
-
onClick={() => setShowFilters(!showFilters)}
|
| 560 |
-
className={`h-12 px-4 text-sm font-medium border rounded-lg transition-colors ${
|
| 561 |
-
activeFiltersCount > 0
|
| 562 |
-
? "border-neutral-900 text-neutral-900 bg-neutral-50"
|
| 563 |
-
: "border-neutral-200 text-neutral-500 hover:border-neutral-300 hover:text-neutral-700"
|
| 564 |
-
}`}
|
| 565 |
>
|
| 566 |
-
|
| 567 |
-
|
| 568 |
-
|
| 569 |
-
|
| 570 |
-
|
| 571 |
-
|
| 572 |
-
|
| 573 |
-
|
| 574 |
-
|
| 575 |
-
disabled={!query.trim() || loading}
|
| 576 |
-
className="h-12 px-6 text-sm font-medium bg-neutral-900 text-white rounded-lg hover:bg-neutral-800 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
|
| 577 |
>
|
| 578 |
-
|
| 579 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 580 |
</div>
|
| 581 |
-
|
| 582 |
-
{/* Filters */}
|
| 583 |
-
{showFilters && (
|
| 584 |
-
<div className="mt-3 p-4 border border-neutral-200 rounded-lg bg-neutral-50/50">
|
| 585 |
-
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-3">
|
| 586 |
-
<select
|
| 587 |
-
value={level}
|
| 588 |
-
onChange={(e) => setLevel(e.target.value)}
|
| 589 |
-
className="h-10 px-3 text-sm border border-neutral-200 rounded-md bg-white focus:outline-none focus:ring-2 focus:ring-neutral-900"
|
| 590 |
-
>
|
| 591 |
-
<option value="">{t("allLevels")}</option>
|
| 592 |
-
<option value="federal">{t("federal")}</option>
|
| 593 |
-
<option value="cantonal">{t("cantonal")}</option>
|
| 594 |
-
</select>
|
| 595 |
-
|
| 596 |
-
<select
|
| 597 |
-
value={canton}
|
| 598 |
-
onChange={(e) => setCanton(e.target.value)}
|
| 599 |
-
disabled={level === "federal"}
|
| 600 |
-
className="h-10 px-3 text-sm border border-neutral-200 rounded-md bg-white focus:outline-none focus:ring-2 focus:ring-neutral-900 disabled:opacity-50"
|
| 601 |
-
>
|
| 602 |
-
<option value="">{t("allCantons")}</option>
|
| 603 |
-
{cantons.map((c) => (
|
| 604 |
-
<option key={c} value={c}>{c}</option>
|
| 605 |
-
))}
|
| 606 |
-
</select>
|
| 607 |
-
|
| 608 |
-
<select
|
| 609 |
-
value={language}
|
| 610 |
-
onChange={(e) => setLanguage(e.target.value)}
|
| 611 |
-
className="h-10 px-3 text-sm border border-neutral-200 rounded-md bg-white focus:outline-none focus:ring-2 focus:ring-neutral-900"
|
| 612 |
-
>
|
| 613 |
-
<option value="">{t("anyLanguage")}</option>
|
| 614 |
-
{docLanguages.map((l) => (
|
| 615 |
-
<option key={l} value={l}>{l.toUpperCase()}</option>
|
| 616 |
-
))}
|
| 617 |
-
</select>
|
| 618 |
-
|
| 619 |
-
<input
|
| 620 |
-
type="date"
|
| 621 |
-
value={dateFrom}
|
| 622 |
-
onChange={(e) => setDateFrom(e.target.value)}
|
| 623 |
-
className="h-10 px-3 text-sm border border-neutral-200 rounded-md bg-white focus:outline-none focus:ring-2 focus:ring-neutral-900"
|
| 624 |
-
placeholder={t("from")}
|
| 625 |
-
/>
|
| 626 |
-
|
| 627 |
-
<input
|
| 628 |
-
type="date"
|
| 629 |
-
value={dateTo}
|
| 630 |
-
onChange={(e) => setDateTo(e.target.value)}
|
| 631 |
-
className="h-10 px-3 text-sm border border-neutral-200 rounded-md bg-white focus:outline-none focus:ring-2 focus:ring-neutral-900"
|
| 632 |
-
placeholder={t("to")}
|
| 633 |
-
/>
|
| 634 |
-
|
| 635 |
-
<button
|
| 636 |
-
onClick={() => {
|
| 637 |
-
setLevel("");
|
| 638 |
-
setCanton("");
|
| 639 |
-
setLanguage("");
|
| 640 |
-
setDateFrom("");
|
| 641 |
-
setDateTo("");
|
| 642 |
-
}}
|
| 643 |
-
className="h-10 px-3 text-sm text-neutral-500 hover:text-neutral-900 border border-neutral-200 rounded-md bg-white transition-colors"
|
| 644 |
-
>
|
| 645 |
-
{t("clear")}
|
| 646 |
-
</button>
|
| 647 |
-
</div>
|
| 648 |
-
</div>
|
| 649 |
-
)}
|
| 650 |
-
</div>
|
| 651 |
</div>
|
| 652 |
</header>
|
| 653 |
|
| 654 |
-
{/*
|
| 655 |
-
|
| 656 |
-
|
| 657 |
-
|
| 658 |
-
|
| 659 |
-
|
| 660 |
-
|
| 661 |
-
</
|
| 662 |
-
<div className="flex items-center
|
| 663 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 664 |
<input
|
| 665 |
type="checkbox"
|
| 666 |
checked={aiEnabled}
|
| 667 |
onChange={(e) => setAiEnabled(e.target.checked)}
|
| 668 |
-
className="
|
| 669 |
/>
|
| 670 |
-
|
| 671 |
</label>
|
| 672 |
-
<
|
| 673 |
-
|
| 674 |
-
|
| 675 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 676 |
>
|
| 677 |
-
<
|
| 678 |
-
|
| 679 |
-
|
| 680 |
-
<option value="court">{t("court")}</option>
|
| 681 |
-
</select>
|
| 682 |
-
</div>
|
| 683 |
-
</div>
|
| 684 |
-
)}
|
| 685 |
-
|
| 686 |
-
{/* AI Answer */}
|
| 687 |
-
{aiEnabled && (answer || answerLoading) && (
|
| 688 |
-
<div className="mb-6 p-5 border border-neutral-200 rounded-xl bg-gradient-to-br from-neutral-50 to-white">
|
| 689 |
-
<div className="flex items-center gap-2 mb-3">
|
| 690 |
-
<div className="w-6 h-6 rounded-full bg-neutral-900 flex items-center justify-center">
|
| 691 |
-
<svg className="w-3.5 h-3.5 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
| 692 |
-
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
|
| 693 |
</svg>
|
| 694 |
-
|
| 695 |
-
|
| 696 |
-
|
| 697 |
-
|
| 698 |
-
|
| 699 |
-
|
| 700 |
-
|
| 701 |
-
|
| 702 |
-
|
| 703 |
-
|
| 704 |
-
<
|
| 705 |
-
|
| 706 |
-
|
| 707 |
-
|
| 708 |
-
|
| 709 |
-
key={c.marker}
|
| 710 |
-
className="inline-flex items-center gap-1.5 px-2 py-1 text-xs bg-neutral-100 rounded"
|
| 711 |
-
>
|
| 712 |
-
<span className="font-medium">[{c.marker}]</span>
|
| 713 |
-
<span className="text-neutral-600">{c.docket || c.source_name}</span>
|
| 714 |
-
</span>
|
| 715 |
-
))}
|
| 716 |
-
</div>
|
| 717 |
-
</div>
|
| 718 |
-
)}
|
| 719 |
-
</>
|
| 720 |
-
) : null}
|
| 721 |
</div>
|
| 722 |
-
|
|
|
|
| 723 |
|
| 724 |
-
|
| 725 |
-
|
| 726 |
-
|
| 727 |
-
|
| 728 |
-
|
| 729 |
-
|
| 730 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 731 |
|
| 732 |
-
|
| 733 |
-
|
| 734 |
-
|
| 735 |
-
|
| 736 |
-
|
| 737 |
-
|
| 738 |
-
|
| 739 |
-
|
| 740 |
-
|
| 741 |
-
|
| 742 |
|
| 743 |
-
|
| 744 |
-
|
| 745 |
-
|
| 746 |
-
|
| 747 |
-
|
| 748 |
-
|
| 749 |
-
|
| 750 |
-
|
| 751 |
-
|
| 752 |
-
|
| 753 |
-
|
| 754 |
-
<
|
| 755 |
-
|
| 756 |
-
|
| 757 |
-
|
| 758 |
-
|
| 759 |
-
|
| 760 |
-
|
| 761 |
-
|
| 762 |
-
|
| 763 |
-
|
| 764 |
-
|
| 765 |
-
|
| 766 |
-
|
| 767 |
-
|
| 768 |
-
|
| 769 |
-
|
| 770 |
-
|
| 771 |
-
|
| 772 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 773 |
</div>
|
| 774 |
-
|
| 775 |
-
|
| 776 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 777 |
</svg>
|
|
|
|
| 778 |
</div>
|
| 779 |
-
|
| 780 |
-
|
| 781 |
-
|
| 782 |
-
|
| 783 |
-
|
| 784 |
-
|
| 785 |
-
|
| 786 |
-
|
| 787 |
-
|
| 788 |
-
|
| 789 |
-
|
| 790 |
-
|
| 791 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 792 |
</div>
|
| 793 |
)}
|
| 794 |
-
</
|
| 795 |
-
|
| 796 |
</main>
|
| 797 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 798 |
{/* Decision Modal */}
|
| 799 |
{activeDecisionId && (
|
| 800 |
<div
|
| 801 |
-
className="fixed inset-0 z-50 bg-black/
|
| 802 |
-
onClick={() => {
|
| 803 |
-
setActiveDecisionId(null);
|
| 804 |
-
setActiveDecision(null);
|
| 805 |
-
}}
|
| 806 |
>
|
| 807 |
-
<div
|
| 808 |
-
|
| 809 |
-
|
| 810 |
-
|
| 811 |
-
>
|
| 812 |
-
|
| 813 |
-
<div className="
|
| 814 |
-
<div className="
|
| 815 |
-
|
| 816 |
-
<h2 className="text-lg font-semibold text-neutral-900">
|
| 817 |
-
{activeDecision?.docket || activeDecision?.source_name || "Decision"}
|
| 818 |
-
</h2>
|
| 819 |
-
<div className="mt-1 flex items-center gap-2 text-sm text-neutral-500">
|
| 820 |
-
{activeDecision?.decision_date}
|
| 821 |
-
{activeDecision?.canton && (
|
| 822 |
-
<span className="px-1.5 py-0.5 text-xs font-medium bg-neutral-100 rounded">
|
| 823 |
-
{activeDecision.canton}
|
| 824 |
-
</span>
|
| 825 |
-
)}
|
| 826 |
-
{activeDecision?.language && (
|
| 827 |
-
<span className="px-1.5 py-0.5 text-xs bg-neutral-50 rounded">
|
| 828 |
-
{activeDecision.language.toUpperCase()}
|
| 829 |
-
</span>
|
| 830 |
-
)}
|
| 831 |
-
</div>
|
| 832 |
-
{(activeDecision?.url || activeDecision?.pdf_url) && (
|
| 833 |
-
<div className="mt-2 flex gap-3">
|
| 834 |
-
{activeDecision?.url && (
|
| 835 |
-
<a
|
| 836 |
-
href={activeDecision.url}
|
| 837 |
-
target="_blank"
|
| 838 |
-
rel="noreferrer"
|
| 839 |
-
className="text-sm text-neutral-600 hover:text-neutral-900 underline"
|
| 840 |
-
>
|
| 841 |
-
{t("source")} ↗
|
| 842 |
-
</a>
|
| 843 |
-
)}
|
| 844 |
-
{activeDecision?.pdf_url && (
|
| 845 |
-
<a
|
| 846 |
-
href={activeDecision.pdf_url}
|
| 847 |
-
target="_blank"
|
| 848 |
-
rel="noreferrer"
|
| 849 |
-
className="text-sm text-neutral-600 hover:text-neutral-900 underline"
|
| 850 |
-
>
|
| 851 |
-
{t("pdf")} ↗
|
| 852 |
-
</a>
|
| 853 |
-
)}
|
| 854 |
-
</div>
|
| 855 |
-
)}
|
| 856 |
-
</div>
|
| 857 |
-
<button
|
| 858 |
-
onClick={() => {
|
| 859 |
-
setActiveDecisionId(null);
|
| 860 |
-
setActiveDecision(null);
|
| 861 |
-
}}
|
| 862 |
-
className="p-2 text-neutral-400 hover:text-neutral-900 hover:bg-neutral-100 rounded-lg transition-colors"
|
| 863 |
-
>
|
| 864 |
-
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
| 865 |
-
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
| 866 |
-
</svg>
|
| 867 |
-
</button>
|
| 868 |
</div>
|
| 869 |
-
|
| 870 |
-
|
| 871 |
-
|
| 872 |
-
|
| 873 |
-
|
| 874 |
-
|
| 875 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 876 |
</div>
|
| 877 |
)}
|
| 878 |
-
{!decisionLoading && activeDecision?.content_text && (
|
| 879 |
-
<pre className="whitespace-pre-wrap text-sm leading-relaxed text-neutral-700 font-sans">
|
| 880 |
-
{activeDecision.content_text}
|
| 881 |
-
</pre>
|
| 882 |
-
)}
|
| 883 |
-
{!decisionLoading && activeDecision?.error && (
|
| 884 |
-
<p className="py-8 text-center text-neutral-500">Failed to load decision.</p>
|
| 885 |
-
)}
|
| 886 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 887 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 888 |
</div>
|
| 889 |
</div>
|
| 890 |
)}
|
|
@@ -892,49 +689,51 @@ export default function HomePage() {
|
|
| 892 |
{/* Settings Modal */}
|
| 893 |
{showSettings && (
|
| 894 |
<div
|
| 895 |
-
className="fixed inset-0 z-50 bg-black/
|
| 896 |
onClick={() => setShowSettings(false)}
|
| 897 |
>
|
| 898 |
<div
|
| 899 |
-
className="w-full max-w-md bg-white rounded-
|
| 900 |
onClick={(e) => e.stopPropagation()}
|
| 901 |
>
|
| 902 |
-
<div className="flex
|
| 903 |
-
<
|
| 904 |
-
|
| 905 |
-
|
| 906 |
-
|
| 907 |
-
>
|
| 908 |
-
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24"
|
| 909 |
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
| 910 |
</svg>
|
| 911 |
</button>
|
|
|
|
|
|
|
|
|
|
| 912 |
</div>
|
| 913 |
-
<div className="p-
|
| 914 |
<div>
|
| 915 |
-
<label className="
|
| 916 |
-
{t("apiKey")}
|
| 917 |
-
</label>
|
| 918 |
<input
|
| 919 |
type="password"
|
| 920 |
value={apiKey}
|
| 921 |
onChange={(e) => saveApiKey(e.target.value)}
|
| 922 |
placeholder="sk-..."
|
| 923 |
-
className="w-full
|
| 924 |
/>
|
| 925 |
-
<div className="mt-2 flex items-center justify-between text-
|
| 926 |
-
<span>{apiKey ? `
|
| 927 |
{apiKey && (
|
| 928 |
-
<button
|
| 929 |
-
|
| 930 |
-
className="text-red-600 hover:underline"
|
| 931 |
-
>
|
| 932 |
-
{t("clear")}
|
| 933 |
</button>
|
| 934 |
)}
|
| 935 |
</div>
|
|
|
|
|
|
|
|
|
|
| 936 |
</div>
|
| 937 |
</div>
|
|
|
|
| 938 |
</div>
|
| 939 |
</div>
|
| 940 |
)}
|
|
|
|
| 1 |
"use client";
|
| 2 |
|
| 3 |
+
import { useEffect, useMemo, useState } from "react";
|
| 4 |
import Link from "next/link";
|
| 5 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6 |
type Source = {
|
| 7 |
id: string;
|
| 8 |
name: string;
|
|
|
|
| 61 |
hits_count: number;
|
| 62 |
};
|
| 63 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 64 |
const API_BASE =
|
| 65 |
process.env.NEXT_PUBLIC_API_BASE_URL?.replace(/\/+$/, "") ?? "http://localhost:8000";
|
| 66 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 67 |
export default function HomePage() {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 68 |
const [sources, setSources] = useState<Source[]>([]);
|
|
|
|
|
|
|
|
|
|
| 69 |
const [query, setQuery] = useState("");
|
| 70 |
+
const [selectedSourceIds, setSelectedSourceIds] = useState<string[]>([]);
|
| 71 |
const [level, setLevel] = useState<string>("");
|
| 72 |
const [canton, setCanton] = useState<string>("");
|
| 73 |
const [language, setLanguage] = useState<string>("");
|
| 74 |
const [dateFrom, setDateFrom] = useState<string>("");
|
| 75 |
const [dateTo, setDateTo] = useState<string>("");
|
|
|
|
| 76 |
|
|
|
|
| 77 |
const [hits, setHits] = useState<SearchHit[]>([]);
|
|
|
|
| 78 |
const [loading, setLoading] = useState(false);
|
| 79 |
const [page, setPage] = useState(0);
|
| 80 |
const [hasMore, setHasMore] = useState(false);
|
| 81 |
+
const PAGE_SIZE = 20;
|
| 82 |
|
| 83 |
+
const [aiEnabled, setAiEnabled] = useState(true);
|
|
|
|
| 84 |
const [answer, setAnswer] = useState<AnswerResponse | null>(null);
|
| 85 |
const [answerLoading, setAnswerLoading] = useState(false);
|
| 86 |
+
const [showAiPanel, setShowAiPanel] = useState(false);
|
| 87 |
|
|
|
|
| 88 |
const [activeDecisionId, setActiveDecisionId] = useState<string | null>(null);
|
| 89 |
const [activeDecision, setActiveDecision] = useState<any | null>(null);
|
| 90 |
const [decisionLoading, setDecisionLoading] = useState(false);
|
| 91 |
|
|
|
|
| 92 |
const [apiKey, setApiKey] = useState<string>("");
|
| 93 |
const [showSettings, setShowSettings] = useState(false);
|
| 94 |
const [showFilters, setShowFilters] = useState(false);
|
| 95 |
+
const [showMenu, setShowMenu] = useState(false);
|
| 96 |
|
|
|
|
| 97 |
useEffect(() => {
|
| 98 |
+
const saved = localStorage.getItem("openai_api_key");
|
| 99 |
+
if (saved) setApiKey(saved);
|
|
|
|
|
|
|
|
|
|
| 100 |
}, []);
|
| 101 |
|
| 102 |
function saveApiKey(key: string) {
|
|
|
|
| 108 |
}
|
| 109 |
}
|
| 110 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 111 |
function getHeaders(): HeadersInit {
|
| 112 |
const headers: HeadersInit = { "content-type": "application/json" };
|
| 113 |
if (apiKey) {
|
|
|
|
| 116 |
return headers;
|
| 117 |
}
|
| 118 |
|
|
|
|
| 119 |
useEffect(() => {
|
| 120 |
fetch(`${API_BASE}/api/sources`)
|
| 121 |
.then((r) => r.json())
|
| 122 |
.then((data) => setSources(Array.isArray(data) ? data : []))
|
| 123 |
.catch(() => setSources([]));
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 124 |
}, []);
|
| 125 |
|
| 126 |
const cantons = useMemo(() => {
|
|
|
|
| 129 |
return Array.from(set).sort();
|
| 130 |
}, [sources]);
|
| 131 |
|
| 132 |
+
const languages = useMemo(() => {
|
| 133 |
const set = new Set<string>();
|
| 134 |
for (const s of sources) for (const l of s.languages || []) set.add(l);
|
| 135 |
return Array.from(set).sort();
|
| 136 |
}, [sources]);
|
| 137 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 138 |
async function runSearch(newPage: number = 0) {
|
| 139 |
const q = query.trim();
|
| 140 |
if (!q) return;
|
|
|
|
| 143 |
if (newPage === 0) {
|
| 144 |
setAnswer(null);
|
| 145 |
setHits([]);
|
|
|
|
| 146 |
}
|
| 147 |
+
setShowFilters(false);
|
| 148 |
+
setShowMenu(false);
|
| 149 |
setPage(newPage);
|
| 150 |
|
| 151 |
const body: any = { query: q, limit: PAGE_SIZE, offset: newPage * PAGE_SIZE };
|
| 152 |
+
if (selectedSourceIds.length) body.source_ids = selectedSourceIds;
|
| 153 |
if (level) body.level = level;
|
| 154 |
if (canton) body.canton = canton;
|
| 155 |
if (language) body.language = language;
|
|
|
|
| 160 |
const res = await fetch(`${API_BASE}/api/search`, {
|
| 161 |
method: "POST",
|
| 162 |
headers: getHeaders(),
|
| 163 |
+
body: JSON.stringify(body)
|
| 164 |
});
|
| 165 |
const data: SearchResponse = await res.json();
|
| 166 |
const newHits = Array.isArray(data?.hits) ? data.hits : [];
|
|
|
|
| 167 |
if (newPage === 0) {
|
| 168 |
setHits(newHits);
|
|
|
|
| 169 |
} else {
|
| 170 |
setHits((prev) => [...prev, ...newHits]);
|
| 171 |
}
|
|
|
|
| 177 |
setLoading(false);
|
| 178 |
}
|
| 179 |
|
| 180 |
+
if (aiEnabled && newPage === 0) {
|
|
|
|
| 181 |
setAnswerLoading(true);
|
| 182 |
try {
|
| 183 |
const res = await fetch(`${API_BASE}/api/answer`, {
|
| 184 |
method: "POST",
|
| 185 |
headers: getHeaders(),
|
| 186 |
+
body: JSON.stringify({ ...body, offset: 0 })
|
| 187 |
});
|
| 188 |
const data: AnswerResponse = await res.json();
|
| 189 |
setAnswer(data);
|
| 190 |
+
// Auto-show AI panel on mobile when answer arrives
|
| 191 |
+
if (data.answer) setShowAiPanel(true);
|
| 192 |
} catch {
|
| 193 |
+
setAnswer({
|
| 194 |
+
answer: "AI answer failed. Add your OpenAI API key in Settings.",
|
| 195 |
+
citations: [],
|
| 196 |
+
hits_count: 0
|
| 197 |
+
});
|
| 198 |
} finally {
|
| 199 |
setAnswerLoading(false);
|
| 200 |
}
|
|
|
|
| 209 |
setActiveDecisionId(decisionId);
|
| 210 |
setDecisionLoading(true);
|
| 211 |
setActiveDecision(null);
|
| 212 |
+
setShowAiPanel(false);
|
| 213 |
try {
|
| 214 |
const res = await fetch(`${API_BASE}/api/decisions/${decisionId}`);
|
| 215 |
const data = await res.json();
|
|
|
|
| 221 |
}
|
| 222 |
}
|
| 223 |
|
| 224 |
+
const activeFiltersCount = [
|
| 225 |
+
level,
|
| 226 |
+
canton,
|
| 227 |
+
language,
|
| 228 |
+
dateFrom,
|
| 229 |
+
dateTo,
|
| 230 |
+
...selectedSourceIds
|
| 231 |
+
].filter(Boolean).length;
|
| 232 |
|
| 233 |
return (
|
| 234 |
+
<div className="min-h-screen bg-bg flex flex-col">
|
| 235 |
+
{/* Sticky Header */}
|
| 236 |
+
<header className="sticky top-0 z-40 bg-white border-b border-line shadow-sm">
|
| 237 |
+
<div className="safe-area-top" />
|
| 238 |
+
<div className="px-4 py-3 lg:max-w-6xl lg:mx-auto lg:px-6">
|
| 239 |
+
{/* Top row: Logo + Menu */}
|
| 240 |
+
<div className="flex items-center justify-between mb-3">
|
| 241 |
<div>
|
| 242 |
+
<h1 className="text-lg lg:text-base font-semibold text-fg">Swiss Case Law</h1>
|
| 243 |
+
<p className="text-xs text-dim hidden lg:block">800k+ decisions</p>
|
|
|
|
|
|
|
| 244 |
</div>
|
| 245 |
|
| 246 |
+
{/* Desktop nav */}
|
| 247 |
+
<nav className="hidden lg:flex items-center gap-4 text-xs">
|
| 248 |
+
<label className="flex items-center gap-1.5 cursor-pointer text-dim hover:text-fg">
|
| 249 |
+
<input
|
| 250 |
+
type="checkbox"
|
| 251 |
+
checked={aiEnabled}
|
| 252 |
+
onChange={(e) => setAiEnabled(e.target.checked)}
|
| 253 |
+
className="!h-3.5 !w-3.5 !min-h-0 !min-w-0"
|
| 254 |
+
/>
|
| 255 |
+
<span>AI</span>
|
| 256 |
+
</label>
|
| 257 |
+
<Link href="/stats" className="text-dim hover:text-fg">Stats</Link>
|
| 258 |
+
<Link href="/dashboard" className="text-dim hover:text-fg">Sources</Link>
|
| 259 |
+
<button onClick={() => setShowSettings(true)} className="text-dim hover:text-fg">
|
| 260 |
+
{apiKey ? "API ✓" : "Settings"}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 261 |
</button>
|
| 262 |
+
<a href={`${API_BASE}/docs`} target="_blank" rel="noreferrer" className="text-dim hover:text-fg">
|
| 263 |
+
API
|
| 264 |
+
</a>
|
| 265 |
+
</nav>
|
| 266 |
+
|
| 267 |
+
{/* Mobile menu button */}
|
| 268 |
+
<button
|
| 269 |
+
onClick={() => setShowMenu(!showMenu)}
|
| 270 |
+
className="lg:hidden p-2 -mr-2 text-dim hover:text-fg"
|
| 271 |
+
>
|
| 272 |
+
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
| 273 |
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
|
| 274 |
+
</svg>
|
| 275 |
+
</button>
|
| 276 |
+
</div>
|
| 277 |
+
|
| 278 |
+
{/* Search row */}
|
| 279 |
+
<div className="flex gap-2">
|
| 280 |
+
<div className="flex-1 relative">
|
| 281 |
+
<input
|
| 282 |
+
value={query}
|
| 283 |
+
onChange={(e) => setQuery(e.target.value)}
|
| 284 |
+
onKeyDown={(e) => { if (e.key === "Enter") runSearch(); }}
|
| 285 |
+
placeholder="Search Swiss court decisions..."
|
| 286 |
+
className="w-full pl-4 pr-4 py-3 lg:py-2 rounded-lg border border-line bg-white text-base lg:text-sm focus:border-accent focus:ring-1 focus:ring-accent"
|
| 287 |
+
/>
|
| 288 |
</div>
|
| 289 |
+
<button
|
| 290 |
+
onClick={() => setShowFilters(!showFilters)}
|
| 291 |
+
className={`px-3 py-2 rounded-lg border text-sm font-medium transition-colors ${
|
| 292 |
+
activeFiltersCount > 0
|
| 293 |
+
? "border-accent bg-accent/10 text-accent"
|
| 294 |
+
: "border-line text-dim hover:text-fg hover:border-dim"
|
| 295 |
+
}`}
|
| 296 |
+
>
|
| 297 |
+
<svg className="w-5 h-5 lg:hidden" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
| 298 |
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707V17l-4 4v-6.586a1 1 0 00-.293-.707L3.293 7.293A1 1 0 013 6.586V4z" />
|
| 299 |
+
</svg>
|
| 300 |
+
<span className="hidden lg:inline">Filters{activeFiltersCount > 0 ? ` (${activeFiltersCount})` : ""}</span>
|
| 301 |
+
</button>
|
| 302 |
+
<button
|
| 303 |
+
onClick={() => runSearch()}
|
| 304 |
+
disabled={!query.trim() || loading}
|
| 305 |
+
className="px-5 py-2 rounded-lg bg-accent text-white text-sm font-medium hover:bg-accent/90 disabled:opacity-40 disabled:cursor-not-allowed"
|
| 306 |
+
>
|
| 307 |
+
{loading ? (
|
| 308 |
+
<svg className="w-5 h-5 animate-spin" fill="none" viewBox="0 0 24 24">
|
| 309 |
+
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
| 310 |
+
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
|
| 311 |
+
</svg>
|
| 312 |
+
) : "Search"}
|
| 313 |
+
</button>
|
| 314 |
</div>
|
| 315 |
+
|
| 316 |
+
{/* Filters - slide down */}
|
| 317 |
+
{showFilters && (
|
| 318 |
+
<div className="mt-3 pt-3 border-t border-line grid grid-cols-1 gap-2 sm:grid-cols-2 lg:grid-cols-5">
|
| 319 |
+
<select
|
| 320 |
+
className="rounded-lg text-sm"
|
| 321 |
+
value={level}
|
| 322 |
+
onChange={(e) => setLevel(e.target.value)}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 323 |
>
|
| 324 |
+
<option value="">All levels</option>
|
| 325 |
+
<option value="federal">Federal</option>
|
| 326 |
+
<option value="cantonal">Cantonal</option>
|
| 327 |
+
</select>
|
| 328 |
+
<select
|
| 329 |
+
className="rounded-lg text-sm"
|
| 330 |
+
value={canton}
|
| 331 |
+
onChange={(e) => setCanton(e.target.value)}
|
| 332 |
+
disabled={level === "federal"}
|
|
|
|
|
|
|
| 333 |
>
|
| 334 |
+
<option value="">All cantons</option>
|
| 335 |
+
{cantons.map((c) => (<option key={c} value={c}>{c}</option>))}
|
| 336 |
+
</select>
|
| 337 |
+
<select
|
| 338 |
+
className="rounded-lg text-sm"
|
| 339 |
+
value={language}
|
| 340 |
+
onChange={(e) => setLanguage(e.target.value)}
|
| 341 |
+
>
|
| 342 |
+
<option value="">Any language</option>
|
| 343 |
+
{languages.map((l) => (<option key={l} value={l}>{l}</option>))}
|
| 344 |
+
</select>
|
| 345 |
+
<input
|
| 346 |
+
type="date"
|
| 347 |
+
className="rounded-lg text-sm"
|
| 348 |
+
value={dateFrom}
|
| 349 |
+
onChange={(e) => setDateFrom(e.target.value)}
|
| 350 |
+
/>
|
| 351 |
+
<input
|
| 352 |
+
type="date"
|
| 353 |
+
className="rounded-lg text-sm"
|
| 354 |
+
value={dateTo}
|
| 355 |
+
onChange={(e) => setDateTo(e.target.value)}
|
| 356 |
+
/>
|
| 357 |
</div>
|
| 358 |
+
)}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 359 |
</div>
|
| 360 |
</header>
|
| 361 |
|
| 362 |
+
{/* Mobile Menu Dropdown */}
|
| 363 |
+
{showMenu && (
|
| 364 |
+
<div className="lg:hidden fixed inset-0 z-50 bg-black/50" onClick={() => setShowMenu(false)}>
|
| 365 |
+
<div
|
| 366 |
+
className="absolute top-0 right-0 w-64 bg-white h-full shadow-xl"
|
| 367 |
+
onClick={(e) => e.stopPropagation()}
|
| 368 |
+
>
|
| 369 |
+
<div className="safe-area-top" />
|
| 370 |
+
<div className="p-4 border-b border-line flex items-center justify-between">
|
| 371 |
+
<span className="font-medium">Menu</span>
|
| 372 |
+
<button onClick={() => setShowMenu(false)} className="p-2 -mr-2">
|
| 373 |
+
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
| 374 |
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
| 375 |
+
</svg>
|
| 376 |
+
</button>
|
| 377 |
+
</div>
|
| 378 |
+
<nav className="p-4 space-y-1">
|
| 379 |
+
<label className="flex items-center gap-3 p-3 rounded-lg hover:bg-bg cursor-pointer">
|
| 380 |
<input
|
| 381 |
type="checkbox"
|
| 382 |
checked={aiEnabled}
|
| 383 |
onChange={(e) => setAiEnabled(e.target.checked)}
|
| 384 |
+
className="!h-5 !w-5"
|
| 385 |
/>
|
| 386 |
+
<span>AI Answers</span>
|
| 387 |
</label>
|
| 388 |
+
<Link href="/stats" className="flex items-center gap-3 p-3 rounded-lg hover:bg-bg">
|
| 389 |
+
<svg className="w-5 h-5 text-dim" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
| 390 |
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
|
| 391 |
+
</svg>
|
| 392 |
+
<span>Statistics</span>
|
| 393 |
+
</Link>
|
| 394 |
+
<Link href="/dashboard" className="flex items-center gap-3 p-3 rounded-lg hover:bg-bg">
|
| 395 |
+
<svg className="w-5 h-5 text-dim" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
| 396 |
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
|
| 397 |
+
</svg>
|
| 398 |
+
<span>Data Sources</span>
|
| 399 |
+
</Link>
|
| 400 |
+
<button
|
| 401 |
+
onClick={() => { setShowMenu(false); setShowSettings(true); }}
|
| 402 |
+
className="flex items-center gap-3 p-3 rounded-lg hover:bg-bg w-full text-left"
|
| 403 |
>
|
| 404 |
+
<svg className="w-5 h-5 text-dim" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
| 405 |
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
| 406 |
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 407 |
</svg>
|
| 408 |
+
<span>Settings</span>
|
| 409 |
+
{apiKey && <span className="ml-auto text-xs text-accent">✓</span>}
|
| 410 |
+
</button>
|
| 411 |
+
<a
|
| 412 |
+
href={`${API_BASE}/docs`}
|
| 413 |
+
target="_blank"
|
| 414 |
+
rel="noreferrer"
|
| 415 |
+
className="flex items-center gap-3 p-3 rounded-lg hover:bg-bg"
|
| 416 |
+
>
|
| 417 |
+
<svg className="w-5 h-5 text-dim" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
| 418 |
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4" />
|
| 419 |
+
</svg>
|
| 420 |
+
<span>API Docs</span>
|
| 421 |
+
</a>
|
| 422 |
+
</nav>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 423 |
</div>
|
| 424 |
+
</div>
|
| 425 |
+
)}
|
| 426 |
|
| 427 |
+
{/* Main Content */}
|
| 428 |
+
<main className="flex-1 lg:max-w-6xl lg:mx-auto lg:px-6 w-full">
|
| 429 |
+
<div className="lg:grid lg:grid-cols-3 lg:gap-6 lg:py-6">
|
| 430 |
+
{/* Results */}
|
| 431 |
+
<section className="lg:col-span-2">
|
| 432 |
+
{loading && hits.length === 0 && (
|
| 433 |
+
<div className="flex items-center justify-center py-20">
|
| 434 |
+
<div className="text-center">
|
| 435 |
+
<svg className="w-8 h-8 animate-spin text-accent mx-auto mb-3" fill="none" viewBox="0 0 24 24">
|
| 436 |
+
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
| 437 |
+
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
|
| 438 |
+
</svg>
|
| 439 |
+
<p className="text-sm text-dim">Searching...</p>
|
| 440 |
+
</div>
|
| 441 |
+
</div>
|
| 442 |
+
)}
|
| 443 |
|
| 444 |
+
{!loading && hits.length === 0 && (
|
| 445 |
+
<div className="flex items-center justify-center py-20 px-4">
|
| 446 |
+
<div className="text-center max-w-sm">
|
| 447 |
+
<svg className="w-12 h-12 text-line mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
| 448 |
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
| 449 |
+
</svg>
|
| 450 |
+
<p className="text-dim">Search across 800k+ Swiss court decisions from federal and cantonal courts.</p>
|
| 451 |
+
</div>
|
| 452 |
+
</div>
|
| 453 |
+
)}
|
| 454 |
|
| 455 |
+
{hits.length > 0 && (
|
| 456 |
+
<div>
|
| 457 |
+
<div className="px-4 py-3 lg:px-0 lg:pb-3 flex items-center justify-between border-b border-line lg:border-0">
|
| 458 |
+
<span className="text-sm text-dim">{hits.length} results</span>
|
| 459 |
+
{aiEnabled && answer && (
|
| 460 |
+
<button
|
| 461 |
+
onClick={() => setShowAiPanel(true)}
|
| 462 |
+
className="lg:hidden flex items-center gap-1.5 px-3 py-1.5 rounded-full bg-accent/10 text-accent text-sm font-medium"
|
| 463 |
+
>
|
| 464 |
+
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
|
| 465 |
+
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z"/>
|
| 466 |
+
</svg>
|
| 467 |
+
AI Answer
|
| 468 |
+
</button>
|
| 469 |
+
)}
|
| 470 |
+
</div>
|
| 471 |
+
|
| 472 |
+
<div className="divide-y divide-line">
|
| 473 |
+
{hits.map((h, idx) => (
|
| 474 |
+
<button
|
| 475 |
+
key={`${h.decision.id}-${idx}`}
|
| 476 |
+
onClick={() => openDecision(h.decision.id)}
|
| 477 |
+
className="w-full text-left px-4 py-4 lg:px-0 hover:bg-white lg:hover:bg-bg/50 transition-colors active:bg-bg"
|
| 478 |
+
>
|
| 479 |
+
<div className="flex items-start justify-between gap-3">
|
| 480 |
+
<div className="min-w-0 flex-1">
|
| 481 |
+
<div className="font-medium text-fg">
|
| 482 |
+
{h.decision.docket || h.decision.source_name}
|
| 483 |
+
{h.decision.canton && (
|
| 484 |
+
<span className="font-normal text-dim"> · {h.decision.canton}</span>
|
| 485 |
+
)}
|
| 486 |
+
</div>
|
| 487 |
+
<div className="mt-1 text-sm text-dim">
|
| 488 |
+
{h.decision.decision_date || "—"}
|
| 489 |
+
{h.decision.court && ` · ${h.decision.court}`}
|
| 490 |
+
</div>
|
| 491 |
+
</div>
|
| 492 |
+
<div className="flex items-center gap-2">
|
| 493 |
+
<span className="text-xs text-faint tabular-nums bg-bg px-2 py-0.5 rounded">
|
| 494 |
+
{Math.round(h.score * 100)}%
|
| 495 |
+
</span>
|
| 496 |
+
<svg className="w-4 h-4 text-dim lg:hidden" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
| 497 |
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
| 498 |
+
</svg>
|
| 499 |
+
</div>
|
| 500 |
+
</div>
|
| 501 |
+
<p className="mt-2 text-sm text-dim line-clamp-2">{h.snippet}</p>
|
| 502 |
+
</button>
|
| 503 |
+
))}
|
| 504 |
+
</div>
|
| 505 |
+
|
| 506 |
+
{hasMore && (
|
| 507 |
+
<div className="px-4 py-4 lg:px-0">
|
| 508 |
+
<button
|
| 509 |
+
onClick={loadMore}
|
| 510 |
+
disabled={loading}
|
| 511 |
+
className="w-full py-3 rounded-lg border border-line text-accent font-medium hover:bg-bg disabled:opacity-50"
|
| 512 |
+
>
|
| 513 |
+
{loading ? "Loading..." : "Load more results"}
|
| 514 |
+
</button>
|
| 515 |
</div>
|
| 516 |
+
)}
|
| 517 |
+
</div>
|
| 518 |
+
)}
|
| 519 |
+
</section>
|
| 520 |
+
|
| 521 |
+
{/* AI Answer - Desktop sidebar */}
|
| 522 |
+
<aside className="hidden lg:block lg:col-span-1">
|
| 523 |
+
{aiEnabled && (
|
| 524 |
+
<div className="sticky top-24 border border-line bg-white p-4 rounded-lg">
|
| 525 |
+
<div className="flex items-center justify-between mb-3">
|
| 526 |
+
<span className="text-sm font-medium text-fg">AI Answer</span>
|
| 527 |
+
{answer?.hits_count ? (
|
| 528 |
+
<span className="text-xs text-dim">{answer.hits_count} sources</span>
|
| 529 |
+
) : null}
|
| 530 |
+
</div>
|
| 531 |
+
|
| 532 |
+
{answerLoading && (
|
| 533 |
+
<div className="flex items-center gap-2 text-sm text-dim">
|
| 534 |
+
<svg className="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
|
| 535 |
+
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
| 536 |
+
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
| 537 |
</svg>
|
| 538 |
+
Generating...
|
| 539 |
</div>
|
| 540 |
+
)}
|
| 541 |
+
|
| 542 |
+
{!answerLoading && !answer && (
|
| 543 |
+
<p className="text-sm text-dim">Search to get an AI-generated answer with citations.</p>
|
| 544 |
+
)}
|
| 545 |
+
|
| 546 |
+
{answer && (
|
| 547 |
+
<>
|
| 548 |
+
<div className="text-sm text-fg leading-relaxed whitespace-pre-wrap">{answer.answer}</div>
|
| 549 |
+
{answer.citations?.length > 0 && (
|
| 550 |
+
<div className="mt-4 pt-3 border-t border-line">
|
| 551 |
+
<div className="text-xs font-medium text-dim mb-2">Sources</div>
|
| 552 |
+
<div className="space-y-1">
|
| 553 |
+
{answer.citations.map((c) => (
|
| 554 |
+
<button
|
| 555 |
+
key={c.marker}
|
| 556 |
+
onClick={() => openDecision(c.decision_id)}
|
| 557 |
+
className="text-xs text-left w-full p-2 rounded hover:bg-bg"
|
| 558 |
+
>
|
| 559 |
+
<span className="text-accent font-medium">[{c.marker}]</span>{" "}
|
| 560 |
+
<span className="text-dim">{c.docket || c.source_name}</span>
|
| 561 |
+
</button>
|
| 562 |
+
))}
|
| 563 |
+
</div>
|
| 564 |
+
</div>
|
| 565 |
+
)}
|
| 566 |
+
</>
|
| 567 |
+
)}
|
| 568 |
</div>
|
| 569 |
)}
|
| 570 |
+
</aside>
|
| 571 |
+
</div>
|
| 572 |
</main>
|
| 573 |
|
| 574 |
+
{/* Mobile AI Answer Bottom Sheet */}
|
| 575 |
+
{showAiPanel && aiEnabled && answer && (
|
| 576 |
+
<div
|
| 577 |
+
className="lg:hidden fixed inset-0 z-50 bg-black/50"
|
| 578 |
+
onClick={() => setShowAiPanel(false)}
|
| 579 |
+
>
|
| 580 |
+
<div
|
| 581 |
+
className="absolute bottom-0 left-0 right-0 bg-white rounded-t-2xl max-h-[80vh] flex flex-col"
|
| 582 |
+
onClick={(e) => e.stopPropagation()}
|
| 583 |
+
>
|
| 584 |
+
<div className="safe-area-bottom" />
|
| 585 |
+
<div className="flex justify-center pt-3 pb-2">
|
| 586 |
+
<div className="w-10 h-1 bg-line rounded-full" />
|
| 587 |
+
</div>
|
| 588 |
+
<div className="px-4 pb-2 flex items-center justify-between border-b border-line">
|
| 589 |
+
<span className="font-medium">AI Answer</span>
|
| 590 |
+
<button onClick={() => setShowAiPanel(false)} className="p-2 -mr-2 text-dim">
|
| 591 |
+
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
| 592 |
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
| 593 |
+
</svg>
|
| 594 |
+
</button>
|
| 595 |
+
</div>
|
| 596 |
+
<div className="flex-1 overflow-auto p-4">
|
| 597 |
+
<div className="text-sm text-fg leading-relaxed whitespace-pre-wrap">{answer.answer}</div>
|
| 598 |
+
{answer.citations?.length > 0 && (
|
| 599 |
+
<div className="mt-4 pt-3 border-t border-line">
|
| 600 |
+
<div className="text-xs font-medium text-dim mb-2">Sources</div>
|
| 601 |
+
<div className="space-y-1">
|
| 602 |
+
{answer.citations.map((c) => (
|
| 603 |
+
<button
|
| 604 |
+
key={c.marker}
|
| 605 |
+
onClick={() => openDecision(c.decision_id)}
|
| 606 |
+
className="text-sm text-left w-full p-3 rounded-lg bg-bg active:bg-line"
|
| 607 |
+
>
|
| 608 |
+
<span className="text-accent font-medium">[{c.marker}]</span>{" "}
|
| 609 |
+
<span className="text-dim">{c.docket || c.source_name}</span>
|
| 610 |
+
</button>
|
| 611 |
+
))}
|
| 612 |
+
</div>
|
| 613 |
+
</div>
|
| 614 |
+
)}
|
| 615 |
+
</div>
|
| 616 |
+
</div>
|
| 617 |
+
</div>
|
| 618 |
+
)}
|
| 619 |
+
|
| 620 |
{/* Decision Modal */}
|
| 621 |
{activeDecisionId && (
|
| 622 |
<div
|
| 623 |
+
className="fixed inset-0 z-50 bg-black/50 lg:p-8 overflow-auto"
|
| 624 |
+
onClick={() => { setActiveDecisionId(null); setActiveDecision(null); }}
|
|
|
|
|
|
|
|
|
|
| 625 |
>
|
| 626 |
+
<div
|
| 627 |
+
className="min-h-full lg:min-h-0 lg:mx-auto lg:max-w-3xl bg-white lg:rounded-lg flex flex-col"
|
| 628 |
+
onClick={(e) => e.stopPropagation()}
|
| 629 |
+
>
|
| 630 |
+
<div className="safe-area-top lg:hidden" />
|
| 631 |
+
<div className="sticky top-0 bg-white border-b border-line px-4 py-3 flex items-start justify-between gap-4 z-10">
|
| 632 |
+
<div className="min-w-0 flex-1">
|
| 633 |
+
<div className="font-medium text-fg">
|
| 634 |
+
{activeDecision?.docket || activeDecision?.source_name || "Decision"}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 635 |
</div>
|
| 636 |
+
<div className="mt-1 text-sm text-dim">
|
| 637 |
+
{activeDecision?.decision_date}
|
| 638 |
+
{activeDecision?.canton && ` · ${activeDecision.canton}`}
|
| 639 |
+
{activeDecision?.language && ` · ${activeDecision.language}`}
|
| 640 |
+
</div>
|
| 641 |
+
{(activeDecision?.url || activeDecision?.pdf_url) && (
|
| 642 |
+
<div className="mt-2 flex gap-4">
|
| 643 |
+
{activeDecision?.url && (
|
| 644 |
+
<a href={activeDecision.url} target="_blank" rel="noreferrer" className="text-sm text-accent">
|
| 645 |
+
Source ↗
|
| 646 |
+
</a>
|
| 647 |
+
)}
|
| 648 |
+
{activeDecision?.pdf_url && (
|
| 649 |
+
<a href={activeDecision.pdf_url} target="_blank" rel="noreferrer" className="text-sm text-accent">
|
| 650 |
+
PDF ↗
|
| 651 |
+
</a>
|
| 652 |
+
)}
|
| 653 |
</div>
|
| 654 |
)}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 655 |
</div>
|
| 656 |
+
<button
|
| 657 |
+
onClick={() => { setActiveDecisionId(null); setActiveDecision(null); }}
|
| 658 |
+
className="shrink-0 w-10 h-10 lg:w-auto lg:h-auto flex items-center justify-center rounded-full bg-bg lg:bg-transparent text-dim hover:text-fg"
|
| 659 |
+
>
|
| 660 |
+
<svg className="w-5 h-5 lg:hidden" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
| 661 |
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
| 662 |
+
</svg>
|
| 663 |
+
<span className="hidden lg:inline text-sm">Close</span>
|
| 664 |
+
</button>
|
| 665 |
</div>
|
| 666 |
+
<div className="flex-1 p-4 lg:max-h-[70vh] lg:overflow-auto">
|
| 667 |
+
{decisionLoading && (
|
| 668 |
+
<div className="flex items-center justify-center py-12">
|
| 669 |
+
<svg className="w-6 h-6 animate-spin text-accent" fill="none" viewBox="0 0 24 24">
|
| 670 |
+
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
| 671 |
+
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
| 672 |
+
</svg>
|
| 673 |
+
</div>
|
| 674 |
+
)}
|
| 675 |
+
{!decisionLoading && activeDecision?.content_text && (
|
| 676 |
+
<pre className="whitespace-pre-wrap text-sm leading-relaxed text-fg font-mono">
|
| 677 |
+
{activeDecision.content_text}
|
| 678 |
+
</pre>
|
| 679 |
+
)}
|
| 680 |
+
{!decisionLoading && activeDecision?.error && (
|
| 681 |
+
<p className="text-sm text-dim text-center py-12">Failed to load decision.</p>
|
| 682 |
+
)}
|
| 683 |
+
</div>
|
| 684 |
+
<div className="safe-area-bottom lg:hidden" />
|
| 685 |
</div>
|
| 686 |
</div>
|
| 687 |
)}
|
|
|
|
| 689 |
{/* Settings Modal */}
|
| 690 |
{showSettings && (
|
| 691 |
<div
|
| 692 |
+
className="fixed inset-0 z-50 bg-black/50 flex items-end lg:items-center justify-center"
|
| 693 |
onClick={() => setShowSettings(false)}
|
| 694 |
>
|
| 695 |
<div
|
| 696 |
+
className="w-full lg:max-w-md bg-white rounded-t-2xl lg:rounded-lg"
|
| 697 |
onClick={(e) => e.stopPropagation()}
|
| 698 |
>
|
| 699 |
+
<div className="flex justify-center pt-3 pb-2 lg:hidden">
|
| 700 |
+
<div className="w-10 h-1 bg-line rounded-full" />
|
| 701 |
+
</div>
|
| 702 |
+
<div className="px-4 py-3 border-b border-line flex items-center justify-between">
|
| 703 |
+
<span className="font-medium">Settings</span>
|
| 704 |
+
<button onClick={() => setShowSettings(false)} className="p-2 -mr-2 text-dim lg:hidden">
|
| 705 |
+
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
| 706 |
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
| 707 |
</svg>
|
| 708 |
</button>
|
| 709 |
+
<button onClick={() => setShowSettings(false)} className="hidden lg:block text-sm text-dim hover:text-fg">
|
| 710 |
+
Close
|
| 711 |
+
</button>
|
| 712 |
</div>
|
| 713 |
+
<div className="p-4 space-y-4">
|
| 714 |
<div>
|
| 715 |
+
<label className="text-sm text-dim block mb-2">OpenAI API Key</label>
|
|
|
|
|
|
|
| 716 |
<input
|
| 717 |
type="password"
|
| 718 |
value={apiKey}
|
| 719 |
onChange={(e) => saveApiKey(e.target.value)}
|
| 720 |
placeholder="sk-..."
|
| 721 |
+
className="w-full rounded-lg"
|
| 722 |
/>
|
| 723 |
+
<div className="mt-2 flex items-center justify-between text-sm text-dim">
|
| 724 |
+
<span>{apiKey ? `Key set: ${apiKey.slice(0, 7)}...` : "No key set"}</span>
|
| 725 |
{apiKey && (
|
| 726 |
+
<button onClick={() => saveApiKey("")} className="text-accent">
|
| 727 |
+
Clear
|
|
|
|
|
|
|
|
|
|
| 728 |
</button>
|
| 729 |
)}
|
| 730 |
</div>
|
| 731 |
+
<p className="mt-3 text-xs text-dim">
|
| 732 |
+
Your API key is stored locally in your browser and never sent to our servers.
|
| 733 |
+
</p>
|
| 734 |
</div>
|
| 735 |
</div>
|
| 736 |
+
<div className="safe-area-bottom" />
|
| 737 |
</div>
|
| 738 |
</div>
|
| 739 |
)}
|