Add routing and talk detail view; refactor search view and results table

master
Lasse Server 2 months ago
parent 39792b5dbe
commit bd922c498f
  1. 98
      frontend/src/App.tsx
  2. 16
      frontend/src/api.ts
  3. 58
      frontend/src/components/ResultsTable.tsx
  4. 212
      frontend/src/components/TalkView.tsx
  5. 223
      frontend/src/styles.css
  6. 2
      frontend/src/types.ts

@ -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<string[]>([]); // Store selected speaker IDs as a list
const [results, setResults] = useState<TalkHit[]>([]); // 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<boolean>(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 (
<div className="app">
{/* Page header */}
<>
<header className="page-header">
<h1>Vad säger de i Riksdagen?</h1>
</header>
{/* Main content: search form + results */}
<main className="content">
<SearchPanel
meta={meta.data}
@ -266,8 +338,6 @@ function App() {
onSubmit={() => {
// 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() {
/>
)}
</main>
</>
);
}
function App() {
return (
<BrowserRouter>
<div className="app">
<Routes>
<Route path="/" element={<SearchView />} />
<Route path="/talk/:id" element={<TalkView />} />
</Routes>
</div>
</BrowserRouter>
);
}

@ -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<any> {
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();
}

@ -1,3 +1,4 @@
import { Link, useNavigate } from "react-router-dom";
import type { TalkHit } from "../types";
type Props = {
@ -8,6 +9,8 @@ type Props = {
};
export function ResultsTable({ results, onLoadMore, nextBatchSize, compact }: Props) {
const navigate = useNavigate();
return (
<section className={`results-table${compact ? " results-table--compact" : ""}`}>
<table>
@ -22,8 +25,38 @@ export function ResultsTable({ results, onLoadMore, nextBatchSize, compact }: Pr
</tr>
</thead>
<tbody>
{results.map((hit) => (
<tr key={hit._id}>
{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 (
<tr
key={hit._id}
className="results-table__row"
onClick={handleRowClick}
role="button"
tabIndex={0}
onKeyDown={(e) => {
// Allow keyboard navigation with Enter or Space
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
handleRowClick();
}
}}
>
<td>{hit.date}</td>
<td>{hit.speaker}</td>
<td>
@ -35,7 +68,8 @@ export function ResultsTable({ results, onLoadMore, nextBatchSize, compact }: Pr
<td>
<div className="snippet">
<p>{hit.snippet}</p>
<details>
{/* Stop propagation so details toggle doesn't trigger row click */}
<details onClick={stopPropagation}>
<summary>Längre utdrag</summary>
<pre className="snippet-long">
{hit.snippet_long
@ -46,19 +80,31 @@ export function ResultsTable({ results, onLoadMore, nextBatchSize, compact }: Pr
</div>
</td>
<td>
{/* Stop propagation so external links work independently */}
{hit.url_session && (
<a href={hit.url_session} target="_blank" rel="noreferrer">
<a
href={hit.url_session}
target="_blank"
rel="noreferrer"
onClick={stopPropagation}
>
Webb-TV
</a>
)}
{hit.url_audio && (
<a href={hit.url_audio} target="_blank" rel="noreferrer">
<a
href={hit.url_audio}
target="_blank"
rel="noreferrer"
onClick={stopPropagation}
>
Ljud
</a>
)}
</td>
</tr>
))}
);
})}
</tbody>
</table>
{onLoadMore && nextBatchSize && nextBatchSize > 0 && (

@ -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 (
<div className="talk-view">
<div className="panel">
<p>Laddar anförande...</p>
</div>
</div>
);
}
if (error) {
return (
<div className="talk-view">
<div className="panel error-banner">
Kunde inte ladda anförande: {error.message}
</div>
</div>
);
}
if (!talk) {
return (
<div className="talk-view">
<div className="panel">
<p>Anförande hittades inte.</p>
</div>
</div>
);
}
// 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 (
<div className="talk-view">
{/* Navigation row: previous, back, next */}
<div className="talk-view__navRow">
{/* Left: Föregående tal */}
<div className="talk-view__navCell talk-view__navCell--left talk-view__navCell--minwidth">
{previousId && previousTalk ? (
<Link to={`/talk/${previousId}`} className="secondary-button talk-view__navButton">
{/* Use fontSize 1em for arrow so button height matches others */}
<span aria-hidden="true" style={{ fontSize: "1em", marginRight: "0.3em" }}></span>
Föregående i protokollet
</Link>
) : (
<span className="secondary-button talk-view__navButton" style={{ visibility: "hidden" }}>&nbsp;</span>
)}
</div>
{/* Center: Tillbaka till sökresultat */}
<div className="talk-view__navCell talk-view__navCell--center talk-view__navCell--minwidth">
<button
type="button"
className="secondary-button talk-view__navButton"
onClick={handleBackClick}
>
Tillbaka till sökresultat
</button>
</div>
{/* Right: Nästa tal */}
<div className="talk-view__navCell talk-view__navCell--right talk-view__navCell--minwidth">
{nextId && nextTalk ? (
<Link to={`/talk/${nextId}`} className="secondary-button talk-view__navButton">
Nästa i protokollet
{/* Use fontSize 1em for arrow so button height matches others */}
<span aria-hidden="true" style={{ fontSize: "1em", marginLeft: "0.3em" }}></span>
</Link>
) : (
<span className="secondary-button talk-view__navButton" style={{ visibility: "hidden" }}>&nbsp;</span>
)}
</div>
</div>
{/* Speaker card */}
<div className="panel talk-view__speaker">
{imageUrl && (
<img
src={imageUrl}
alt={talk.talare}
className="talk-view__speaker-photo"
/>
)}
<div className="talk-view__speaker-info">
<div className="talk-view__speaker-row">
<h1>{talk.talare}</h1>
<span className="party-chip" data-party={talk.parti ?? ""}>
{talk.parti}
</span>
</div>
<div className="talk-view__speaker-meta">
{/* Show valkrets if available */}
{talk.person?.valkrets && (
<span className="talk-view__speaker-detail">
{talk.person.valkrets}
</span>
)}
{/* Show status if available */}
{talk.person?.status && (
<span className="talk-view__speaker-detail">
{talk.person.status}
</span>
)}
</div>
</div>
</div>
{/* Talk metadata */}
<div className="panel talk-view__metadata">
<dl className="talk-view__meta-grid">
<dt>Datum</dt>
<dd>{talk.datum}</dd>
<dt>Debattyp</dt>
<dd>{talk.kammaraktivitet}</dd>
<dt>Rubrik</dt>
<dd>{talk.avsnittsrubrik}</dd>
{talk.titel && (
<>
<dt>Protokoll</dt>
<dd>{talk.titel}</dd>
</>
)}
</dl>
</div>
{/* Talk text */}
<div className="panel talk-view__text">
<div className="talk-view__content">
{talk.anforandetext}
</div>
</div>
{/* Links */}
{(talk.url_session || talk.url_audio) && (
<div className="panel talk-view__links">
<h3>Länkar</h3>
<div className="talk-view__link-group">
{talk.url_session && (
<a
href={talk.url_session}
target="_blank"
rel="noreferrer"
className="primary"
>
Webb-TV
</a>
)}
{talk.url_audio && (
<a
href={talk.url_audio}
target="_blank"
rel="noreferrer"
className="primary"
>
Ljud
</a>
)}
</div>
</div>
)}
</div>
);
}

@ -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;
}
}

@ -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;

Loading…
Cancel
Save