voilaj commited on
Commit
efd1827
·
verified ·
1 Parent(s): b7849c6

Deploy from GitHub: ac823d8

Browse files
README.md CHANGED
@@ -7,7 +7,7 @@ sdk: docker
7
  app_port: 7860
8
  pinned: true
9
  license: mit
10
- short_description: Search 300K+ Swiss court decisions with AI
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
- - **300,000+ Decisions**: From all Swiss federal courts (BGer, BVGer, BStGer, BPatGer) and 26 cantons
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, body {
10
  height: 100%;
 
11
  }
12
 
13
  body {
14
- @apply bg-white text-fg font-mono antialiased;
15
- font-size: 13px;
16
- line-height: 1.6;
 
 
 
17
  }
18
 
 
 
 
 
 
 
 
 
 
 
19
  a {
20
- @apply text-accent no-underline hover:underline;
 
 
 
 
21
  }
22
 
 
23
  input, select, textarea {
24
- @apply border border-line bg-white px-3 py-2 text-sm font-mono;
25
- @apply focus:outline-none focus:border-dim;
 
 
 
 
 
 
 
 
 
 
 
 
26
  }
27
 
28
  input[type="checkbox"] {
29
- @apply h-3.5 w-3.5 border border-line bg-white accent-accent;
30
- @apply cursor-pointer;
 
 
 
 
 
 
 
 
 
 
 
 
31
  }
32
 
33
  select {
34
- @apply appearance-none cursor-pointer pr-7;
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='1.5' d='M19 9l-7 7-7-7'/%3E%3C/svg%3E");
36
- background-position: right 6px center;
37
  background-repeat: no-repeat;
38
- background-size: 14px;
39
  }
40
 
41
  button {
42
- @apply font-mono;
 
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/10 text-fg;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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: "Hybrid search + AI answers over Swiss case law"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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, useCallback } from "react";
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 = 100;
308
 
309
- // AI
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 savedKey = localStorage.getItem("openai_api_key");
327
- if (savedKey) setApiKey(savedKey);
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 docLanguages = useMemo(() => {
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
- // AI answer (only on first page, if enabled)
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, limit: 20 }),
454
  });
455
  const data: AnswerResponse = await res.json();
456
  setAnswer(data);
 
 
457
  } catch {
458
- setAnswer(null);
 
 
 
 
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
- function handleKeyDown(e: React.KeyboardEvent) {
485
- if (e.key === "Enter") runSearch();
486
- }
487
-
488
- const activeFiltersCount = [level, canton, language, dateFrom, dateTo].filter(Boolean).length;
 
 
 
489
 
490
  return (
491
- <div className="min-h-screen bg-white text-neutral-900">
492
- {/* Header */}
493
- <header className="sticky top-0 z-40 bg-white/95 backdrop-blur border-b border-neutral-100">
494
- <div className="mx-auto max-w-5xl px-4 sm:px-6">
495
- {/* Top bar */}
496
- <div className="flex items-center justify-between py-4">
 
497
  <div>
498
- <h1 className="text-lg font-semibold tracking-tight">{t("title")}</h1>
499
- <p className="text-xs text-neutral-500 mt-0.5">
500
- {stats ? `${stats.total.toLocaleString()} ${t("decisions")}` : t("subtitle")}
501
- </p>
502
  </div>
503
 
504
- <div className="flex items-center gap-1">
505
- {/* Language switcher */}
506
- <div className="flex border border-neutral-200 rounded-md overflow-hidden mr-4">
507
- {(Object.keys(languageNames) as Language[]).map((l) => (
508
- <button
509
- key={l}
510
- onClick={() => saveLanguage(l)}
511
- className={`px-2 py-1 text-xs font-medium transition-colors ${
512
- lang === l
513
- ? "bg-neutral-900 text-white"
514
- : "text-neutral-500 hover:text-neutral-900 hover:bg-neutral-50"
515
- }`}
516
- >
517
- {languageNames[l]}
518
- </button>
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
- {/* Search bar */}
546
- <div className="pb-4">
547
- <div className="flex gap-2">
548
- <div className="flex-1 relative">
549
- <input
550
- type="text"
551
- value={query}
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
- {t("filters")}
567
- {activeFiltersCount > 0 && (
568
- <span className="ml-1.5 inline-flex items-center justify-center w-5 h-5 text-xs bg-neutral-900 text-white rounded-full">
569
- {activeFiltersCount}
570
- </span>
571
- )}
572
- </button>
573
- <button
574
- onClick={() => runSearch()}
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
- {loading ? "..." : t("search")}
579
- </button>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- {/* Main content */}
655
- <main className="mx-auto max-w-5xl px-4 sm:px-6 py-6">
656
- {/* Results header */}
657
- {hits.length > 0 && (
658
- <div className="flex items-center justify-between mb-4">
659
- <p className="text-sm text-neutral-500">
660
- {totalResults.toLocaleString()} {t("results")}
661
- </p>
662
- <div className="flex items-center gap-2">
663
- <label className="flex items-center gap-2 text-sm text-neutral-500">
 
 
 
 
 
 
 
 
664
  <input
665
  type="checkbox"
666
  checked={aiEnabled}
667
  onChange={(e) => setAiEnabled(e.target.checked)}
668
- className="rounded"
669
  />
670
- {t("aiAnswer")}
671
  </label>
672
- <select
673
- value={sortBy}
674
- onChange={(e) => setSortBy(e.target.value as SortOption)}
675
- className="h-9 px-3 text-sm border border-neutral-200 rounded-md bg-white focus:outline-none focus:ring-2 focus:ring-neutral-900"
 
 
 
 
 
 
 
 
 
 
 
676
  >
677
- <option value="relevance">{t("relevance")}</option>
678
- <option value="date_desc">{t("dateDesc")}</option>
679
- <option value="date_asc">{t("dateAsc")}</option>
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
- </div>
695
- <span className="text-sm font-medium">{t("aiAnswer")}</span>
696
- </div>
697
-
698
- {answerLoading ? (
699
- <p className="text-sm text-neutral-500">{t("generating")}</p>
700
- ) : answer ? (
701
- <>
702
- <p className="text-sm leading-relaxed whitespace-pre-wrap">{answer.answer}</p>
703
- {answer.citations?.length > 0 && (
704
- <div className="mt-4 pt-3 border-t border-neutral-100">
705
- <p className="text-xs font-medium text-neutral-500 mb-2">{t("sources")}</p>
706
- <div className="flex flex-wrap gap-2">
707
- {answer.citations.map((c) => (
708
- <span
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
- {/* Results */}
725
- {loading && hits.length === 0 && (
726
- <div className="py-20 text-center">
727
- <div className="inline-block w-6 h-6 border-2 border-neutral-300 border-t-neutral-900 rounded-full animate-spin" />
728
- <p className="mt-3 text-sm text-neutral-500">{t("loading")}</p>
729
- </div>
730
- )}
 
 
 
 
 
 
 
 
 
731
 
732
- {!loading && hits.length === 0 && (
733
- <div className="py-20 text-center">
734
- <div className="w-16 h-16 mx-auto mb-4 rounded-full bg-neutral-100 flex items-center justify-center">
735
- <svg className="w-8 h-8 text-neutral-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
736
- <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
737
- </svg>
738
- </div>
739
- <p className="text-neutral-500">{t("searchHint")}</p>
740
- </div>
741
- )}
742
 
743
- {sortedHits.length > 0 && (
744
- <div className="space-y-1">
745
- {sortedHits.map((hit, idx) => (
746
- <button
747
- key={`${hit.decision.id}-${idx}`}
748
- onClick={() => openDecision(hit.decision.id)}
749
- className="w-full text-left p-4 rounded-lg hover:bg-neutral-50 transition-colors group"
750
- >
751
- <div className="flex items-start justify-between gap-4">
752
- <div className="flex-1 min-w-0">
753
- <div className="flex items-center gap-2 flex-wrap">
754
- <span className="font-medium text-neutral-900">
755
- {hit.decision.docket || hit.decision.source_name}
756
- </span>
757
- {hit.decision.canton && (
758
- <span className="px-1.5 py-0.5 text-xs font-medium bg-neutral-100 text-neutral-600 rounded">
759
- {hit.decision.canton}
760
- </span>
761
- )}
762
- {hit.decision.language && (
763
- <span className="px-1.5 py-0.5 text-xs bg-neutral-50 text-neutral-500 rounded">
764
- {hit.decision.language.toUpperCase()}
765
- </span>
766
- )}
767
- </div>
768
- <div className="mt-1 text-xs text-neutral-500">
769
- {hit.decision.decision_date || "—"}
770
- {hit.decision.court && ` · ${hit.decision.court}`}
771
- </div>
772
- <p className="mt-2 text-sm text-neutral-600 line-clamp-2">{hit.snippet}</p>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
773
  </div>
774
- <div className="flex-shrink-0 opacity-0 group-hover:opacity-100 transition-opacity">
775
- <svg className="w-5 h-5 text-neutral-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
776
- <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
777
  </svg>
 
778
  </div>
779
- </div>
780
- </button>
781
- ))}
782
-
783
- {hasMore && (
784
- <div className="pt-4">
785
- <button
786
- onClick={loadMore}
787
- disabled={loading}
788
- className="w-full py-3 text-sm font-medium text-neutral-600 hover:text-neutral-900 border border-neutral-200 rounded-lg hover:border-neutral-300 transition-colors disabled:opacity-50"
789
- >
790
- {loading ? t("loading") : t("loadMore")}
791
- </button>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
792
  </div>
793
  )}
794
- </div>
795
- )}
796
  </main>
797
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
798
  {/* Decision Modal */}
799
  {activeDecisionId && (
800
  <div
801
- className="fixed inset-0 z-50 bg-black/60 backdrop-blur-sm overflow-auto"
802
- onClick={() => {
803
- setActiveDecisionId(null);
804
- setActiveDecision(null);
805
- }}
806
  >
807
- <div className="min-h-full flex items-start justify-center p-4 sm:p-8">
808
- <div
809
- className="w-full max-w-3xl bg-white rounded-xl shadow-2xl overflow-hidden"
810
- onClick={(e) => e.stopPropagation()}
811
- >
812
- {/* Modal header */}
813
- <div className="sticky top-0 bg-white border-b border-neutral-100 px-6 py-4">
814
- <div className="flex items-start justify-between gap-4">
815
- <div>
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
- </div>
870
-
871
- {/* Modal content */}
872
- <div className="px-6 py-4 max-h-[70vh] overflow-auto">
873
- {decisionLoading && (
874
- <div className="py-8 text-center">
875
- <div className="inline-block w-6 h-6 border-2 border-neutral-300 border-t-neutral-900 rounded-full animate-spin" />
 
 
 
 
 
 
 
 
 
 
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/60 backdrop-blur-sm flex items-center justify-center p-4"
896
  onClick={() => setShowSettings(false)}
897
  >
898
  <div
899
- className="w-full max-w-md bg-white rounded-xl shadow-2xl overflow-hidden"
900
  onClick={(e) => e.stopPropagation()}
901
  >
902
- <div className="flex items-center justify-between border-b border-neutral-100 px-6 py-4">
903
- <h3 className="text-lg font-semibold">{t("settings")}</h3>
904
- <button
905
- onClick={() => setShowSettings(false)}
906
- className="p-2 text-neutral-400 hover:text-neutral-900 hover:bg-neutral-100 rounded-lg transition-colors"
907
- >
908
- <svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
909
  <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
910
  </svg>
911
  </button>
 
 
 
912
  </div>
913
- <div className="p-6 space-y-4">
914
  <div>
915
- <label className="block text-sm font-medium text-neutral-700 mb-1.5">
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 h-10 px-3 text-sm border border-neutral-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-neutral-900"
924
  />
925
- <div className="mt-2 flex items-center justify-between text-xs text-neutral-500">
926
- <span>{apiKey ? `Set: ${apiKey.slice(0, 7)}...` : "Not set — required for AI answers"}</span>
927
  {apiKey && (
928
- <button
929
- onClick={() => saveApiKey("")}
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
  )}