mirror of
https://github.com/kjanat/livedash-node.git
synced 2026-02-13 22:35:47 +01:00
- Fix syntax errors in skills markdown files (.github/skills, .opencode/skills) - Change typescript to tsx for code blocks with JSX - Replace ellipsis (...) in array examples with valid syntax - Separate CSS from TypeScript into distinct code blocks - Convert JavaScript object examples to valid JSON in docs - Fix enum definitions with proper comma separation
678 lines
24 KiB
TypeScript
678 lines
24 KiB
TypeScript
"use client";
|
|
|
|
import {
|
|
ChevronDown,
|
|
ChevronLeft,
|
|
ChevronRight,
|
|
ChevronUp,
|
|
Clock,
|
|
Eye,
|
|
Filter,
|
|
Globe,
|
|
MessageSquare,
|
|
Search,
|
|
} from "lucide-react";
|
|
import Link from "next/link";
|
|
import { useCallback, useEffect, useId, useReducer } from "react";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Label } from "@/components/ui/label";
|
|
import { formatCategory } from "@/lib/format-enums";
|
|
import type { ChatSession } from "@/lib/types";
|
|
|
|
// State types
|
|
interface FilterState {
|
|
searchTerm: string;
|
|
debouncedSearchTerm: string;
|
|
category: string;
|
|
language: string;
|
|
startDate: string;
|
|
endDate: string;
|
|
sortKey: string;
|
|
sortOrder: "asc" | "desc";
|
|
options: { categories: string[]; languages: string[] };
|
|
}
|
|
|
|
interface PaginationState {
|
|
currentPage: number;
|
|
totalPages: number;
|
|
pageSize: number;
|
|
}
|
|
|
|
interface UIState {
|
|
loading: boolean;
|
|
error: string | null;
|
|
filtersExpanded: boolean;
|
|
}
|
|
|
|
interface SessionsState {
|
|
sessions: ChatSession[];
|
|
filters: FilterState;
|
|
pagination: PaginationState;
|
|
ui: UIState;
|
|
}
|
|
|
|
// Action types
|
|
type SessionsAction =
|
|
| { type: "SET_SESSIONS"; payload: ChatSession[] }
|
|
| {
|
|
type: "SET_FILTER";
|
|
field: keyof Omit<FilterState, "options" | "debouncedSearchTerm">;
|
|
value: string;
|
|
}
|
|
| { type: "SET_DEBOUNCED_SEARCH"; value: string }
|
|
| {
|
|
type: "SET_FILTER_OPTIONS";
|
|
payload: { categories: string[]; languages: string[] };
|
|
}
|
|
| { type: "SET_PAGE"; page: number }
|
|
| { type: "SET_TOTAL_PAGES"; total: number }
|
|
| { type: "SET_LOADING"; loading: boolean }
|
|
| { type: "SET_ERROR"; error: string | null }
|
|
| { type: "TOGGLE_FILTERS" };
|
|
|
|
const initialState: SessionsState = {
|
|
sessions: [],
|
|
filters: {
|
|
searchTerm: "",
|
|
debouncedSearchTerm: "",
|
|
category: "",
|
|
language: "",
|
|
startDate: "",
|
|
endDate: "",
|
|
sortKey: "startTime",
|
|
sortOrder: "desc",
|
|
options: { categories: [], languages: [] },
|
|
},
|
|
pagination: {
|
|
currentPage: 1,
|
|
totalPages: 0,
|
|
pageSize: 10,
|
|
},
|
|
ui: {
|
|
loading: true,
|
|
error: null,
|
|
filtersExpanded: false,
|
|
},
|
|
};
|
|
|
|
function sessionsReducer(
|
|
state: SessionsState,
|
|
action: SessionsAction
|
|
): SessionsState {
|
|
switch (action.type) {
|
|
case "SET_SESSIONS":
|
|
return { ...state, sessions: action.payload };
|
|
case "SET_FILTER":
|
|
return {
|
|
...state,
|
|
filters: { ...state.filters, [action.field]: action.value },
|
|
pagination: { ...state.pagination, currentPage: 1 }, // Reset page on filter change
|
|
};
|
|
case "SET_DEBOUNCED_SEARCH":
|
|
return {
|
|
...state,
|
|
filters: { ...state.filters, debouncedSearchTerm: action.value },
|
|
};
|
|
case "SET_FILTER_OPTIONS":
|
|
return {
|
|
...state,
|
|
filters: { ...state.filters, options: action.payload },
|
|
};
|
|
case "SET_PAGE":
|
|
return {
|
|
...state,
|
|
pagination: { ...state.pagination, currentPage: action.page },
|
|
};
|
|
case "SET_TOTAL_PAGES":
|
|
return {
|
|
...state,
|
|
pagination: { ...state.pagination, totalPages: action.total },
|
|
};
|
|
case "SET_LOADING":
|
|
return { ...state, ui: { ...state.ui, loading: action.loading } };
|
|
case "SET_ERROR":
|
|
return { ...state, ui: { ...state.ui, error: action.error } };
|
|
case "TOGGLE_FILTERS":
|
|
return {
|
|
...state,
|
|
ui: { ...state.ui, filtersExpanded: !state.ui.filtersExpanded },
|
|
};
|
|
default:
|
|
return state;
|
|
}
|
|
}
|
|
|
|
export default function SessionsPage() {
|
|
const [state, dispatch] = useReducer(sessionsReducer, initialState);
|
|
const { sessions, filters, pagination, ui } = state;
|
|
|
|
const searchHeadingId = useId();
|
|
const filtersHeadingId = useId();
|
|
const filterContentId = useId();
|
|
const categoryFilterId = useId();
|
|
const categoryHelpId = useId();
|
|
const languageFilterId = useId();
|
|
const languageHelpId = useId();
|
|
const sortOrderId = useId();
|
|
const sortOrderHelpId = useId();
|
|
const resultsHeadingId = useId();
|
|
const startDateFilterId = useId();
|
|
const startDateHelpId = useId();
|
|
const endDateFilterId = useId();
|
|
const endDateHelpId = useId();
|
|
const sortKeyId = useId();
|
|
const sortKeyHelpId = useId();
|
|
|
|
// Debounce search term
|
|
useEffect(() => {
|
|
const timerId = setTimeout(() => {
|
|
dispatch({ type: "SET_DEBOUNCED_SEARCH", value: filters.searchTerm });
|
|
}, 500);
|
|
return () => clearTimeout(timerId);
|
|
}, [filters.searchTerm]);
|
|
|
|
const fetchFilterOptions = useCallback(async () => {
|
|
try {
|
|
const response = await fetch("/api/dashboard/session-filter-options");
|
|
if (!response.ok) throw new Error("Failed to fetch filter options");
|
|
const data = await response.json();
|
|
dispatch({ type: "SET_FILTER_OPTIONS", payload: data });
|
|
} catch (err) {
|
|
dispatch({
|
|
type: "SET_ERROR",
|
|
error:
|
|
err instanceof Error ? err.message : "Failed to load filter options",
|
|
});
|
|
}
|
|
}, []);
|
|
|
|
const fetchSessions = useCallback(async () => {
|
|
dispatch({ type: "SET_LOADING", loading: true });
|
|
dispatch({ type: "SET_ERROR", error: null });
|
|
try {
|
|
const params = new URLSearchParams();
|
|
if (filters.debouncedSearchTerm)
|
|
params.append("searchTerm", filters.debouncedSearchTerm);
|
|
if (filters.category) params.append("category", filters.category);
|
|
if (filters.language) params.append("language", filters.language);
|
|
if (filters.startDate) params.append("startDate", filters.startDate);
|
|
if (filters.endDate) params.append("endDate", filters.endDate);
|
|
if (filters.sortKey) params.append("sortKey", filters.sortKey);
|
|
if (filters.sortOrder) params.append("sortOrder", filters.sortOrder);
|
|
params.append("page", pagination.currentPage.toString());
|
|
params.append("pageSize", pagination.pageSize.toString());
|
|
|
|
const response = await fetch(
|
|
`/api/dashboard/sessions?${params.toString()}`
|
|
);
|
|
if (!response.ok)
|
|
throw new Error(`Failed to fetch sessions: ${response.statusText}`);
|
|
const data = await response.json();
|
|
dispatch({ type: "SET_SESSIONS", payload: data.sessions || [] });
|
|
dispatch({
|
|
type: "SET_TOTAL_PAGES",
|
|
total: Math.ceil((data.totalSessions || 0) / pagination.pageSize),
|
|
});
|
|
} catch (err) {
|
|
dispatch({
|
|
type: "SET_ERROR",
|
|
error: err instanceof Error ? err.message : "An unknown error occurred",
|
|
});
|
|
dispatch({ type: "SET_SESSIONS", payload: [] });
|
|
} finally {
|
|
dispatch({ type: "SET_LOADING", loading: false });
|
|
}
|
|
}, [filters, pagination.currentPage, pagination.pageSize]);
|
|
|
|
useEffect(() => {
|
|
fetchSessions();
|
|
}, [fetchSessions]);
|
|
|
|
useEffect(() => {
|
|
fetchFilterOptions();
|
|
}, [fetchFilterOptions]);
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
{/* Page heading for screen readers */}
|
|
<h1 className="sr-only">Sessions Management</h1>
|
|
|
|
{/* Header */}
|
|
<Card>
|
|
<CardHeader>
|
|
<div className="flex items-center gap-3">
|
|
<MessageSquare className="h-6 w-6" />
|
|
<CardTitle>Chat Sessions</CardTitle>
|
|
</div>
|
|
</CardHeader>
|
|
</Card>
|
|
|
|
{/* Search Input */}
|
|
<section aria-labelledby={searchHeadingId}>
|
|
<h2 id={searchHeadingId} className="sr-only">
|
|
Search Sessions
|
|
</h2>
|
|
<Card>
|
|
<CardContent className="pt-6">
|
|
<div className="relative">
|
|
<Search
|
|
className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground"
|
|
aria-hidden="true"
|
|
/>
|
|
<Input
|
|
placeholder="Search sessions (ID, category, initial message...)"
|
|
value={filters.searchTerm}
|
|
onChange={(e) =>
|
|
dispatch({
|
|
type: "SET_FILTER",
|
|
field: "searchTerm",
|
|
value: e.target.value,
|
|
})
|
|
}
|
|
className="pl-10"
|
|
aria-label="Search sessions by ID, category, or message content"
|
|
/>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</section>
|
|
|
|
{/* Filter and Sort Controls */}
|
|
<section aria-labelledby={filtersHeadingId}>
|
|
<Card>
|
|
<CardHeader>
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-2">
|
|
<Filter className="h-5 w-5" aria-hidden="true" />
|
|
<CardTitle id={filtersHeadingId} className="text-lg">
|
|
Filters & Sorting
|
|
</CardTitle>
|
|
</div>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => dispatch({ type: "TOGGLE_FILTERS" })}
|
|
className="gap-2"
|
|
aria-expanded={ui.filtersExpanded}
|
|
aria-controls={filterContentId}
|
|
>
|
|
{ui.filtersExpanded ? (
|
|
<>
|
|
<ChevronUp className="h-4 w-4" />
|
|
Hide
|
|
</>
|
|
) : (
|
|
<>
|
|
<ChevronDown className="h-4 w-4" />
|
|
Show
|
|
</>
|
|
)}
|
|
</Button>
|
|
</div>
|
|
</CardHeader>
|
|
{ui.filtersExpanded && (
|
|
<CardContent id={filterContentId}>
|
|
<fieldset>
|
|
<legend className="sr-only">
|
|
Session Filters and Sorting Options
|
|
</legend>
|
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6 gap-4">
|
|
{/* Category Filter */}
|
|
<div className="space-y-2">
|
|
<Label htmlFor={categoryFilterId}>Category</Label>
|
|
<select
|
|
id={categoryFilterId}
|
|
className="w-full h-10 px-3 py-2 text-sm rounded-md border border-input bg-background ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
|
value={filters.category}
|
|
onChange={(e) =>
|
|
dispatch({
|
|
type: "SET_FILTER",
|
|
field: "category",
|
|
value: e.target.value,
|
|
})
|
|
}
|
|
aria-describedby={categoryHelpId}
|
|
>
|
|
<option value="">All Categories</option>
|
|
{filters.options.categories.map((cat) => (
|
|
<option key={cat} value={cat}>
|
|
{formatCategory(cat)}
|
|
</option>
|
|
))}
|
|
</select>
|
|
<div id={categoryHelpId} className="sr-only">
|
|
Filter sessions by category type
|
|
</div>
|
|
</div>
|
|
|
|
{/* Language Filter */}
|
|
<div className="space-y-2">
|
|
<Label htmlFor={languageFilterId}>Language</Label>
|
|
<select
|
|
id={languageFilterId}
|
|
className="w-full h-10 px-3 py-2 text-sm rounded-md border border-input bg-background ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
|
value={filters.language}
|
|
onChange={(e) =>
|
|
dispatch({
|
|
type: "SET_FILTER",
|
|
field: "language",
|
|
value: e.target.value,
|
|
})
|
|
}
|
|
aria-describedby={languageHelpId}
|
|
>
|
|
<option value="">All Languages</option>
|
|
{filters.options.languages.map((lang) => (
|
|
<option key={lang} value={lang}>
|
|
{lang.toUpperCase()}
|
|
</option>
|
|
))}
|
|
</select>
|
|
<div id={languageHelpId} className="sr-only">
|
|
Filter sessions by language
|
|
</div>
|
|
</div>
|
|
|
|
{/* Start Date Filter */}
|
|
<div className="space-y-2">
|
|
<Label htmlFor={startDateFilterId}>Start Date</Label>
|
|
<Input
|
|
type="date"
|
|
id={startDateFilterId}
|
|
value={filters.startDate}
|
|
onChange={(e) =>
|
|
dispatch({
|
|
type: "SET_FILTER",
|
|
field: "startDate",
|
|
value: e.target.value,
|
|
})
|
|
}
|
|
aria-describedby={startDateHelpId}
|
|
/>
|
|
<div id={startDateHelpId} className="sr-only">
|
|
Filter sessions from this date onwards
|
|
</div>
|
|
</div>
|
|
|
|
{/* End Date Filter */}
|
|
<div className="space-y-2">
|
|
<Label htmlFor={endDateFilterId}>End Date</Label>
|
|
<Input
|
|
type="date"
|
|
id={endDateFilterId}
|
|
value={filters.endDate}
|
|
onChange={(e) =>
|
|
dispatch({
|
|
type: "SET_FILTER",
|
|
field: "endDate",
|
|
value: e.target.value,
|
|
})
|
|
}
|
|
aria-describedby={endDateHelpId}
|
|
/>
|
|
<div id={endDateHelpId} className="sr-only">
|
|
Filter sessions up to this date
|
|
</div>
|
|
</div>
|
|
|
|
{/* Sort Key */}
|
|
<div className="space-y-2">
|
|
<Label htmlFor={sortKeyId}>Sort By</Label>
|
|
<select
|
|
id={sortKeyId}
|
|
className="w-full h-10 px-3 py-2 text-sm rounded-md border border-input bg-background ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
|
value={filters.sortKey}
|
|
onChange={(e) =>
|
|
dispatch({
|
|
type: "SET_FILTER",
|
|
field: "sortKey",
|
|
value: e.target.value,
|
|
})
|
|
}
|
|
aria-describedby={sortKeyHelpId}
|
|
>
|
|
<option value="startTime">Start Time</option>
|
|
<option value="category">Category</option>
|
|
<option value="language">Language</option>
|
|
<option value="sentiment">Sentiment</option>
|
|
<option value="messagesSent">Messages Sent</option>
|
|
<option value="avgResponseTime">
|
|
Avg. Response Time
|
|
</option>
|
|
</select>
|
|
<div id={sortKeyHelpId} className="sr-only">
|
|
Choose field to sort sessions by
|
|
</div>
|
|
</div>
|
|
|
|
{/* Sort Order */}
|
|
<div className="space-y-2">
|
|
<Label htmlFor={sortOrderId}>Order</Label>
|
|
<select
|
|
id={sortOrderId}
|
|
className="w-full h-10 px-3 py-2 text-sm rounded-md border border-input bg-background ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
|
value={filters.sortOrder}
|
|
onChange={(e) =>
|
|
dispatch({
|
|
type: "SET_FILTER",
|
|
field: "sortOrder",
|
|
value: e.target.value as "asc" | "desc",
|
|
})
|
|
}
|
|
aria-describedby={sortOrderHelpId}
|
|
>
|
|
<option value="desc">Descending</option>
|
|
<option value="asc">Ascending</option>
|
|
</select>
|
|
<div id={sortOrderHelpId} className="sr-only">
|
|
Choose ascending or descending order
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</fieldset>
|
|
</CardContent>
|
|
)}
|
|
</Card>
|
|
</section>
|
|
|
|
{/* Results section */}
|
|
<section aria-labelledby={resultsHeadingId}>
|
|
<h2 id={resultsHeadingId} className="sr-only">
|
|
Session Results
|
|
</h2>
|
|
|
|
{/* Live region for screen reader announcements */}
|
|
<output aria-live="polite" className="sr-only">
|
|
{ui.loading && "Loading sessions..."}
|
|
{ui.error && `Error loading sessions: ${ui.error}`}
|
|
{!ui.loading &&
|
|
!ui.error &&
|
|
sessions.length > 0 &&
|
|
`Found ${sessions.length} sessions`}
|
|
{!ui.loading &&
|
|
!ui.error &&
|
|
sessions.length === 0 &&
|
|
"No sessions found"}
|
|
</output>
|
|
|
|
{/* Loading State */}
|
|
{ui.loading && (
|
|
<Card>
|
|
<CardContent className="pt-6">
|
|
<div
|
|
className="text-center py-8 text-muted-foreground"
|
|
aria-hidden="true"
|
|
>
|
|
Loading sessions...
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
{/* Error State */}
|
|
{ui.error && (
|
|
<Card>
|
|
<CardContent className="pt-6">
|
|
<div
|
|
className="text-center py-8 text-destructive"
|
|
role="alert"
|
|
aria-hidden="true"
|
|
>
|
|
Error: {ui.error}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
{/* Empty State */}
|
|
{!ui.loading && !ui.error && sessions.length === 0 && (
|
|
<Card>
|
|
<CardContent className="pt-6">
|
|
<div className="text-center py-8 text-muted-foreground">
|
|
{filters.debouncedSearchTerm
|
|
? `No sessions found for "${filters.debouncedSearchTerm}".`
|
|
: "No sessions found."}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
{/* Sessions List */}
|
|
{!ui.loading && !ui.error && sessions.length > 0 && (
|
|
<ul aria-label="Chat sessions" className="grid gap-4">
|
|
{sessions.map((session) => (
|
|
<li key={session.id}>
|
|
<Card className="hover:shadow-md transition-shadow">
|
|
<CardContent className="pt-6">
|
|
<article aria-labelledby={`session-${session.id}-title`}>
|
|
<header className="flex justify-between items-start mb-4">
|
|
<div className="space-y-2 flex-1">
|
|
<h3
|
|
id={`session-${session.id}-title`}
|
|
className="sr-only"
|
|
>
|
|
Session {session.sessionId || session.id} from{" "}
|
|
{new Date(session.startTime).toLocaleDateString()}
|
|
</h3>
|
|
<div className="flex items-center gap-3">
|
|
<Badge
|
|
variant="outline"
|
|
className="font-mono text-xs"
|
|
>
|
|
ID
|
|
</Badge>
|
|
<code className="text-sm text-muted-foreground font-mono truncate max-w-24">
|
|
{session.sessionId || session.id}
|
|
</code>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<Badge variant="outline" className="text-xs">
|
|
<Clock
|
|
className="h-3 w-3 mr-1"
|
|
aria-hidden="true"
|
|
/>
|
|
{new Date(session.startTime).toLocaleDateString()}
|
|
</Badge>
|
|
<span className="text-xs text-muted-foreground">
|
|
{new Date(session.startTime).toLocaleTimeString()}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
<Link href={`/dashboard/sessions/${session.id}`}>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
className="gap-2"
|
|
aria-label={`View details for session ${session.sessionId || session.id}`}
|
|
>
|
|
<Eye className="h-4 w-4" aria-hidden="true" />
|
|
<span className="hidden sm:inline">
|
|
View Details
|
|
</span>
|
|
</Button>
|
|
</Link>
|
|
</header>
|
|
|
|
<div className="flex flex-wrap gap-2 mb-3">
|
|
{session.category && (
|
|
<Badge variant="secondary" className="gap-1">
|
|
<Filter className="h-3 w-3" aria-hidden="true" />
|
|
{formatCategory(session.category)}
|
|
</Badge>
|
|
)}
|
|
{session.language && (
|
|
<Badge variant="outline" className="gap-1">
|
|
<Globe className="h-3 w-3" aria-hidden="true" />
|
|
{session.language.toUpperCase()}
|
|
</Badge>
|
|
)}
|
|
</div>
|
|
|
|
{session.summary ? (
|
|
<p className="text-sm text-muted-foreground line-clamp-2">
|
|
{session.summary}
|
|
</p>
|
|
) : session.initialMsg ? (
|
|
<p className="text-sm text-muted-foreground line-clamp-2">
|
|
{session.initialMsg}
|
|
</p>
|
|
) : null}
|
|
</article>
|
|
</CardContent>
|
|
</Card>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
|
|
{/* Pagination */}
|
|
{pagination.totalPages > 0 && (
|
|
<Card>
|
|
<CardContent className="pt-6">
|
|
<div className="flex justify-center items-center gap-4">
|
|
<Button
|
|
variant="outline"
|
|
onClick={() =>
|
|
dispatch({
|
|
type: "SET_PAGE",
|
|
page: Math.max(pagination.currentPage - 1, 1),
|
|
})
|
|
}
|
|
disabled={pagination.currentPage === 1}
|
|
className="gap-2"
|
|
>
|
|
<ChevronLeft className="h-4 w-4" />
|
|
Previous
|
|
</Button>
|
|
<span className="text-sm text-muted-foreground">
|
|
Page {pagination.currentPage} of {pagination.totalPages}
|
|
</span>
|
|
<Button
|
|
variant="outline"
|
|
onClick={() =>
|
|
dispatch({
|
|
type: "SET_PAGE",
|
|
page: Math.min(
|
|
pagination.currentPage + 1,
|
|
pagination.totalPages
|
|
),
|
|
})
|
|
}
|
|
disabled={pagination.currentPage === pagination.totalPages}
|
|
className="gap-2"
|
|
>
|
|
Next
|
|
<ChevronRight className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
</section>
|
|
</div>
|
|
);
|
|
}
|