diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index c37270b..c5ec4d8 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,4 +1,5 @@ import { useEffect, useMemo, useRef, useState } from "react"; +import { BrowserRouter, Routes, Route } from "react-router-dom"; import { useQuery, useMutation } from "@tanstack/react-query"; // Hooks for data fetching & mutations import { fetchMeta, searchTalks, sendFeedback, setSessionId } from "./api"; // API functions import type { SearchFilters, SearchRequest, TalkHit } from "./types"; @@ -8,11 +9,13 @@ import { ResultsTable } from "./components/ResultsTable"; // Table of result import { ChatPanel, type ChatPanelHandle, INITIAL_ASSISTANT_MESSAGE } from "./components/ChatPanel"; import type { ChatMessage, ChatTurn } from "./types"; import MentionTextarea from "./components/MentionTextarea"; // Ensure import for ref type +import { TalkView } from "./components/TalkView"; const DEFAULT_FILTERS: SearchFilters = { parties: [], people: [], debates: [], from_year: undefined, to_year: undefined, }; const PAGE_SIZE = 25; // Number of results to show per page +const SEARCH_STATE_KEY = "riksdagen-search-state"; /** * Remove all @mentions from a query string. @@ -43,7 +46,7 @@ function stripMentions(query: string): string { return result; } -function App() { +function SearchView() { // --- UI state --- const [mode, setMode] = useState<"search" | "chat">("search"); const [query, setQuery] = useState(""); // Current search query text @@ -52,6 +55,9 @@ function App() { // Change from single speakerId to array of speakerIds const [speakerIds, setSpeakerIds] = useState([]); // Store selected speaker IDs as a list const [results, setResults] = useState([]); // Holds search hits + const [hasHydratedSearch, setHasHydratedSearch] = useState(false); + // Tracks whether we are currently restoring persisted search state so we can skip side effects. + const isRestoringStateRef = useRef(true); // --- Pagination and sorting --- const [sortMode, setSortMode] = useState<"relevance" | "date">("relevance"); @@ -234,7 +240,75 @@ function App() { }; useEffect(() => { - // Any filter change should restart pagination so the user always sees page 1 of the refined results. + // Restore the latest search state (query, filters, results, etc.) when the list view mounts again. + if (typeof window === "undefined") { + isRestoringStateRef.current = false; + setHasHydratedSearch(true); + return; + } + const stored = window.sessionStorage.getItem(SEARCH_STATE_KEY); + if (!stored) { + isRestoringStateRef.current = false; + setHasHydratedSearch(true); + return; + } + try { + const parsed = JSON.parse(stored) as { + query?: string; + filters?: SearchFilters; + results?: TalkHit[]; + sortMode?: "relevance" | "date"; + visibleCount?: number; + hasSearched?: boolean; + lastError?: string | null; + speaker?: string | null; + speakerIds?: string[]; + }; + if (typeof parsed.query === "string") setQuery(parsed.query); + if (parsed.filters) { + setFilters({ + parties: parsed.filters.parties ?? [], + people: parsed.filters.people ?? [], + debates: parsed.filters.debates ?? [], + from_year: parsed.filters.from_year ?? undefined, + to_year: parsed.filters.to_year ?? undefined, + }); + } + if (Array.isArray(parsed.results)) setResults(parsed.results); + if (parsed.sortMode === "date" || parsed.sortMode === "relevance") setSortMode(parsed.sortMode); + if (typeof parsed.visibleCount === "number") setVisibleCount(parsed.visibleCount); + if ("hasSearched" in parsed) setHasSearched(Boolean(parsed.hasSearched)); + if ("lastError" in parsed) setLastError(parsed.lastError ?? null); + setSpeaker(parsed.speaker ?? null); + setSpeakerIds(Array.isArray(parsed.speakerIds) ? parsed.speakerIds : []); + } catch (error) { + console.error("Failed to restore search state from session storage:", error); + window.sessionStorage.removeItem(SEARCH_STATE_KEY); + } finally { + isRestoringStateRef.current = false; + setHasHydratedSearch(true); + } + }, []); + useEffect(() => { + // Persist the current search state so it is available when returning from the detail view. + if (!hasHydratedSearch || isRestoringStateRef.current || typeof window === "undefined") return; + const persistableState = { + query, + filters, + results, + sortMode, + visibleCount, + hasSearched, + lastError, + speaker, + speakerIds, + }; + window.sessionStorage.setItem(SEARCH_STATE_KEY, JSON.stringify(persistableState)); + }, [query, filters, results, sortMode, visibleCount, hasSearched, lastError, speaker, speakerIds, hasHydratedSearch]); + + useEffect(() => { + // Any filter change while the user is interacting should reset pagination back to the first page. + if (isRestoringStateRef.current) return; setVisibleCount(PAGE_SIZE); }, [filters, speaker, speakerIds]); // ← ADD speakerIds to dependencies @@ -249,13 +323,11 @@ function App() { }, []); return ( -
- {/* Page header */} + <>

Vad säger de i Riksdagen?

- {/* Main content: search form + results */}
{ // Strip @mentions from query before sending to backend const cleanQuery = stripMentions(query); - console.log('Original query:', query); - console.log('Cleaned query:', cleanQuery); searchMutation.mutate({ q: cleanQuery, ...filters, @@ -279,7 +349,6 @@ function App() { speakerSuggestions={speakerSuggestions} onSelectSpeaker={(name, selectedSpeakerId) => { // Update speaker name and speakerIds array - console.log('Selected speaker:', name, 'with ID:', selectedSpeakerId); setSpeaker(name); if (selectedSpeakerId) { setSpeakerIds([selectedSpeakerId]); @@ -363,7 +432,20 @@ function App() { /> )}
-
+ + ); +} + +function App() { + return ( + +
+ + } /> + } /> + +
+
); } diff --git a/frontend/src/api.ts b/frontend/src/api.ts index e5f5977..8ebf5e7 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -78,3 +78,19 @@ export async function sendChat( focus_ids: data.focus_ids ?? [], }; } + +/** + * Fetches a single talk by its ID, including person information. + * + * @param id - The talk ID (e.g., "H40911") + * @returns Promise containing the talk data with person info + */ +export async function fetchTalk(id: string): Promise { + const response = await fetch(`/api/talk/${encodeURIComponent(id)}`, { + headers: getSessionHeaders(), + }); + if (!response.ok) { + throw new Error(`Failed to fetch talk: ${response.statusText}`); + } + return response.json(); +} diff --git a/frontend/src/components/ResultsTable.tsx b/frontend/src/components/ResultsTable.tsx index 997ae31..86bc98c 100644 --- a/frontend/src/components/ResultsTable.tsx +++ b/frontend/src/components/ResultsTable.tsx @@ -1,3 +1,4 @@ +import { Link, useNavigate } from "react-router-dom"; import type { TalkHit } from "../types"; type Props = { @@ -8,22 +9,54 @@ type Props = { }; export function ResultsTable({ results, onLoadMore, nextBatchSize, compact }: Props) { - return ( -
- - - - - - - - - - - - - {results.map((hit) => ( - + const navigate = useNavigate(); + + return ( +
+
DatumTalarePartiDebattypUtdragLänkar
+ + + + + + + + + + + + {results.map((hit) => { + // Always use _id, and strip "talks/" prefix for routing + const talkKey = hit._id?.startsWith("talks/") ? hit._id.slice(6) : hit._id; + if (!talkKey) { + console.warn("Result hit missing _id:", hit); + } + + // Handler for row click - navigates to talk page + const handleRowClick = () => { + navigate(`/talk/${talkKey}`); + }; + + // Handler to stop propagation for nested interactive elements + const stopPropagation = (e: React.MouseEvent) => { + e.stopPropagation(); + }; + + return ( + { + // Allow keyboard navigation with Enter or Space + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + handleRowClick(); + } + }} + > - ))} - -
DatumTalarePartiDebattypUtdragLänkar
{hit.date} {hit.speaker} @@ -35,7 +68,8 @@ export function ResultsTable({ results, onLoadMore, nextBatchSize, compact }: Pr

{hit.snippet}

-
+ {/* Stop propagation so details toggle doesn't trigger row click */} +
Längre utdrag
 												{hit.snippet_long
@@ -46,28 +80,40 @@ export function ResultsTable({ results, onLoadMore, nextBatchSize, compact }: Pr
 									
+ {/* Stop propagation so external links work independently */} {hit.url_session && ( - + Webb-TV )} {hit.url_audio && ( - + Ljud )}
- {onLoadMore && nextBatchSize && nextBatchSize > 0 && ( -
- -
- )} -
- ); + ); + })} + + + {onLoadMore && nextBatchSize && nextBatchSize > 0 && ( +
+ +
+ )} + + ); } diff --git a/frontend/src/components/TalkView.tsx b/frontend/src/components/TalkView.tsx new file mode 100644 index 0000000..fb0f10c --- /dev/null +++ b/frontend/src/components/TalkView.tsx @@ -0,0 +1,212 @@ +import { useQuery } from "@tanstack/react-query"; +import { useParams, Link, useNavigate } from "react-router-dom"; +import { fetchTalk } from "../api"; + +/** + * TalkView component displays a single talk with full details. + * + * It shows: + * - Speaker information with photo + * - Talk metadata (date, debate type, etc.) + * - Full text of the speech + * - Links to related resources + */ +export function TalkView() { + const { id } = useParams<{ id: string }>(); + const navigate = useNavigate(); + + const { data: talk, isLoading, error } = useQuery({ + queryKey: ["talk", id], + queryFn: () => fetchTalk(id!), + enabled: !!id, + }); + + if (isLoading) { + return ( +
+
+

Laddar anförande...

+
+
+ ); + } + + if (error) { + return ( +
+
+ Kunde inte ladda anförande: {error.message} +
+
+ ); + } + + if (!talk) { + return ( +
+
+

Anförande hittades inte.

+
+
+ ); + } + + // Fix image URLs from http to https + const imageUrl = talk.person?.bild_url_192?.replace('http://', 'https://'); + + const previousTalk = talk.navigation?.previous ?? null; + const nextTalk = talk.navigation?.next ?? null; + + + // Normalise database IDs so router links stay consistent. + const normalizeTalkId = (rawId?: string | null): string | null => { + if (!rawId) { + return null; + } + // Handle both object with _id property and plain string + const idString = typeof rawId === 'object' && rawId !== null && '_id' in rawId + ? (rawId as any)._id + : String(rawId); + const result = idString.startsWith("talks/") ? idString.slice(6) : idString; + return result; + }; + const previousId = normalizeTalkId(previousTalk); + const nextId = normalizeTalkId(nextTalk); + + // Use browser history when possible so the search page restores prior state. + const handleBackClick = () => { + navigate("/"); + }; + + return ( +
+ {/* Navigation row: previous, back, next */} +
+ {/* Left: Föregående tal */} +
+ {previousId && previousTalk ? ( + + {/* Use fontSize 1em for arrow so button height matches others */} + + Föregående i protokollet + + ) : ( +   + )} +
+ {/* Center: Tillbaka till sökresultat */} +
+ +
+ {/* Right: Nästa tal */} +
+ {nextId && nextTalk ? ( + + Nästa i protokollet + {/* Use fontSize 1em for arrow so button height matches others */} + + + ) : ( +   + )} +
+
+ + {/* Speaker card */} +
+ {imageUrl && ( + {talk.talare} + )} +
+
+

{talk.talare}

+ + {talk.parti} + +
+
+ {/* Show valkrets if available */} + {talk.person?.valkrets && ( + + {talk.person.valkrets} + + )} + {/* Show status if available */} + {talk.person?.status && ( + + {talk.person.status} + + )} +
+
+
+ + {/* Talk metadata */} +
+
+
Datum
+
{talk.datum}
+ +
Debattyp
+
{talk.kammaraktivitet}
+ +
Rubrik
+
{talk.avsnittsrubrik}
+ + {talk.titel && ( + <> +
Protokoll
+
{talk.titel}
+ + )} +
+
+ + {/* Talk text */} +
+
+ {talk.anforandetext} +
+
+ + {/* Links */} + {(talk.url_session || talk.url_audio) && ( +
+

Länkar

+
+ {talk.url_session && ( + + Webb-TV → + + )} + {talk.url_audio && ( + + Ljud → + + )} +
+
+ )} +
+ ); +} diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 76921b7..6ed445c 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -1062,6 +1062,11 @@ textarea { opacity: 0.7; /* slightly faded for clarity */ } +/* Ensure search input text is always dark and readable */ +.search-panel__input { + color: #222 !important; /* Use !important to override unwanted styles */ +} + /* Display area for MentionInput (shows plain text or placeholder) */ .mention-input-display { width: 100%; @@ -1084,8 +1089,8 @@ textarea { top: 0; left: 0; background: transparent; - /* Show text as transparent only when there is a value, otherwise show normal color for placeholder */ - color: transparent; + + color: #222; caret-color: black; padding: 8px 12px; font-size: 16px; @@ -1100,3 +1105,217 @@ textarea { font-weight: 400; opacity: 0.7; } + +/* Talk View Styles */ +.talk-view { + max-width: 900px; + margin: 0 auto; + padding: 2rem 1.5rem; + display: flex; + flex-direction: column; + gap: 1.5rem; +} + +.talk-view__nav { + margin-bottom: 0.5rem; + display: flex; + align-items: center; + gap: 0.75rem; + flex-wrap: wrap; +} + +.talk-view__nav--vertical { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.5rem; +} + +.talk-view__navBackRow { + width: 100%; + display: flex; + justify-content: center; +} + +.talk-view__navActions { + margin-left: auto; + display: flex; + align-items: center; + gap: 0.5rem; +} + +.talk-view__navActionsSplit { + display: flex; + justify-content: space-between; + width: 100%; + gap: 0.5rem; +} + +.talk-view__navButton { + display: inline-flex; + align-items: center; + gap: 0.4rem; + color: var(--text); + font-weight: 600; + text-decoration: none; + font-size: 0.95rem; /* Explicit font size for consistency across button/link */ +} + +.talk-view__navButton:hover { + color: var(--primary); + text-decoration: none; +} + +.talk-view__speaker { + display: flex; + align-items: center; + gap: 1.5rem; +} + +.talk-view__speaker-photo { + width: 120px; + height: 120px; + border-radius: 50%; + object-fit: cover; + border: 3px solid var(--panel-border); + box-shadow: 0 4px 12px rgba(15, 23, 42, 0.1); +} + +.talk-view__speaker-info { + width: 100%; /* Make the info container fill the panel width */ + display: flex; + flex-direction: column; + gap: 0.75rem; + /* Remove flex: 1 so it doesn't just fill remaining space */ +} + +.talk-view__speaker-row { + display: flex; + justify-content: center; /* Center speaker name and party-chip horizontally */ + align-items: center; + gap: 1rem; +} + +.talk-view__speaker-meta { + display: flex; + justify-content: center; /* Center meta info horizontally */ + align-items: center; + gap: 1rem; + flex-wrap: wrap; +} + +.talk-view__metadata h2, +.talk-view__text h2, +.talk-view__links h3 { + margin: 0 0 1rem 0; + font-size: 1.4rem; + color: var(--text); +} + +.talk-view__meta-grid { + display: grid; + grid-template-columns: auto 1fr; + gap: 0.75rem 1.5rem; + margin: 0; +} + +.talk-view__meta-grid dt { + font-weight: 600; + color: var(--muted); +} + +.talk-view__meta-grid dd { + margin: 0; + color: var(--text); +} + +.talk-view__content { + line-height: 1.7; + color: var(--text); + font-size: 1.05rem; +} + +.talk-view__link-group { + display: flex; + gap: 1rem; + flex-wrap: wrap; +} + +/* Make table rows look clickable */ +.results-table__row { + cursor: pointer; + transition: transform 0.2s ease, box-shadow 0.2s ease; +} + +.results-table__row:hover { + transform: translateY(-2px); +} + +.results-table__row:hover td { + box-shadow: 0 6px 20px rgba(15, 23, 42, 0.12); +} + +.results-table__row a { + cursor: pointer; /* Ensure links still show pointer cursor */ +} + +.talk-view__navRow { + display: flex; + width: 100%; + justify-content: space-between; + align-items: center; + gap: 0.5rem; + margin-bottom: 0.5rem; +} + +.talk-view__navCell { + flex: 1 1 0; + display: flex; + justify-content: center; +} + +.talk-view__navCell--left { + justify-content: flex-start; +} + +.talk-view__navCell--center { + justify-content: center; +} + +.talk-view__navCell--right { + justify-content: flex-end; +} + +.talk-view__navCell--minwidth { + min-width: 120px; /* Ensures each cell has enough space for a button */ +} + +@media (max-width: 768px) { + .talk-view { + padding: 1.5rem 1rem; + } + + .talk-view__speaker { + flex-direction: column; + align-items: flex-start; + } + + .talk-view__speaker-photo { + width: 100px; + height: 100px; + } + + .talk-view__speaker-info h1 { + font-size: 1.6rem; + } + + .talk-view__meta-grid { + grid-template-columns: 1fr; + gap: 0.5rem; + } + + .talk-view__meta-grid dt { + font-size: 0.85rem; + margin-bottom: -0.25rem; + } +} diff --git a/frontend/src/types.ts b/frontend/src/types.ts index fb5add4..13d5d98 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -1,5 +1,5 @@ export type TalkHit = { - _id: string; // Changed from number to string - ArangoDB _id is like "talks/12345" + id: string; // ArangoDB _id, but named 'id' in TypeScript dok_id?: string; // Made optional since it might not always be present text: string; snippet?: string | null;