From 69c75bd4fe9f97be03a239a644cdfdebf2b56e7e Mon Sep 17 00:00:00 2001 From: Yunxiao Xu Date: Fri, 13 Feb 2026 03:58:55 -0800 Subject: [PATCH] feat(ux): Finalize advanced UX with plot zoom, export, and shadcn UI consistency. --- frontend/src/App.tsx | 14 +- frontend/src/components/auth/LoginForm.tsx | 17 +- frontend/src/components/auth/RegisterForm.tsx | 17 +- .../src/components/chat/MessageBubble.tsx | 75 +++++-- frontend/src/components/chat/PlotModal.tsx | 56 +++++ .../src/components/layout/HistorySidebar.tsx | 203 ++++++++++-------- frontend/src/components/ui/alert-dialog.tsx | 194 +++++++++++++++++ frontend/src/components/ui/dialog.tsx | 122 +++++++++++ frontend/src/services/chat_history.test.ts | 11 +- 9 files changed, 578 insertions(+), 131 deletions(-) create mode 100644 frontend/src/components/chat/PlotModal.tsx create mode 100644 frontend/src/components/ui/alert-dialog.tsx create mode 100644 frontend/src/components/ui/dialog.tsx diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 8c64d43..09b7d61 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -9,6 +9,7 @@ import { AuthService, type UserResponse } from "./services/auth" import { ChatService, type MessageResponse } from "./services/chat" import { type Conversation } from "./components/layout/HistorySidebar" import { registerUnauthorizedCallback } from "./services/api" +import { Button } from "./components/ui/button" function App() { const [isAuthenticated, setIsAuthenticated] = useState(false) @@ -179,12 +180,13 @@ function App() { Welcome, {user?.display_name || user?.email || "User"}! - +
@@ -218,12 +220,12 @@ function App() {

Create a new conversation in the sidebar to start asking questions.

- +
)} diff --git a/frontend/src/components/auth/LoginForm.tsx b/frontend/src/components/auth/LoginForm.tsx index 9619a5a..4e602dd 100644 --- a/frontend/src/components/auth/LoginForm.tsx +++ b/frontend/src/components/auth/LoginForm.tsx @@ -15,6 +15,8 @@ import { Input } from "@/components/ui/input" import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" import { useState } from "react" import { Separator } from "@/components/ui/separator" +import { Alert, AlertDescription } from "@/components/ui/alert" +import { AlertCircle } from "lucide-react" import axios from "axios" interface LoginFormProps { @@ -70,9 +72,10 @@ export function LoginForm({ onSuccess, onToggleMode }: LoginFormProps) {
{error && ( -
- {error} -
+ + + {error} + )} Don't have an account?{" "} - + diff --git a/frontend/src/components/auth/RegisterForm.tsx b/frontend/src/components/auth/RegisterForm.tsx index 7c58654..a955e6d 100644 --- a/frontend/src/components/auth/RegisterForm.tsx +++ b/frontend/src/components/auth/RegisterForm.tsx @@ -14,6 +14,8 @@ import { import { Input } from "@/components/ui/input" import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" import { useState } from "react" +import { Alert, AlertDescription } from "@/components/ui/alert" +import { AlertCircle } from "lucide-react" import axios from "axios" interface RegisterFormProps { @@ -62,9 +64,10 @@ export function RegisterForm({ onSuccess, onToggleMode }: RegisterFormProps) { {error && ( -
- {error} -
+ + + {error} + )}
Already have an account?{" "} - +
diff --git a/frontend/src/components/chat/MessageBubble.tsx b/frontend/src/components/chat/MessageBubble.tsx index d63ff69..4bf7c05 100644 --- a/frontend/src/components/chat/MessageBubble.tsx +++ b/frontend/src/components/chat/MessageBubble.tsx @@ -1,6 +1,10 @@ +import * as React from "react" import { cn } from "@/lib/utils" import { type MessageResponse } from "@/services/chat" import { MarkdownContent } from "./MarkdownContent" +import { PlotModal } from "./PlotModal" +import { Maximize2Icon } from "lucide-react" +import { Button } from "@/components/ui/button" interface MessageBubbleProps { message: MessageResponse @@ -8,6 +12,7 @@ interface MessageBubbleProps { export function MessageBubble({ message }: MessageBubbleProps) { const isAssistant = message.role === "assistant" + const [selectedPlot, setSelectedPlot] = React.useState(null) return (
0 || (message.plot_ids?.length || 0) > 0) && (
- {message.plots?.map((plot, index) => ( - Analysis Plot { - // TODO: Open in modal (Phase 5) - }} - /> - ))} - {message.plot_ids?.map((plotId, index) => ( - Historical Analysis Plot { - // TODO: Open in modal (Phase 5) - }} - /> - ))} + {message.plots?.map((plot, index) => { + const src = `data:image/png;base64,${plot}` + return ( + + ) + })} + {message.plot_ids?.map((plotId, index) => { + const src = `${import.meta.env.VITE_API_URL || ""}/api/v1/artifacts/plots/${plotId}` + return ( + + ) + })}
)}
+ + {selectedPlot && ( + setSelectedPlot(null)} + /> + )} ) } diff --git a/frontend/src/components/chat/PlotModal.tsx b/frontend/src/components/chat/PlotModal.tsx new file mode 100644 index 0000000..a9ff5ea --- /dev/null +++ b/frontend/src/components/chat/PlotModal.tsx @@ -0,0 +1,56 @@ +import { DownloadIcon } from "lucide-react" +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription +} from "@/components/ui/dialog" +import { Button } from "@/components/ui/button" + +interface PlotModalProps { + src: string + isOpen: boolean + onClose: () => void + title?: string +} + +export function PlotModal({ src, isOpen, onClose, title = "Analysis Plot" }: PlotModalProps) { + const handleDownload = () => { + const link = document.createElement("a") + link.href = src + link.download = `${title.toLowerCase().replace(/\s+/g, "-")}-${Date.now()}.png` + document.body.appendChild(link) + link.click() + document.body.removeChild(link) + } + + return ( + !open && onClose()}> + + +
+
+ {title} + + Generated analysis visualization + +
+ +
+
+ +
+ {title} +
+
+
+ ) +} diff --git a/frontend/src/components/layout/HistorySidebar.tsx b/frontend/src/components/layout/HistorySidebar.tsx index 43d624d..dfba805 100644 --- a/frontend/src/components/layout/HistorySidebar.tsx +++ b/frontend/src/components/layout/HistorySidebar.tsx @@ -27,6 +27,16 @@ import { DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu" +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog" import { Button } from "@/components/ui/button" import { Input } from "@/components/ui/input" @@ -55,6 +65,7 @@ export function HistorySidebar({ }: HistorySidebarProps) { const [editingId, setEditingId] = React.useState(null) const [editValue, setEditValue] = React.useState("") + const [deleteId, setDeleteId] = React.useState(null) const handleStartRename = (conv: Conversation) => { setEditingId(conv.id) @@ -72,95 +83,115 @@ export function HistorySidebar({ setEditingId(null) } - const handleDelete = (id: string) => { - if (confirm("Are you sure you want to delete this conversation?")) { - onDelete(id) + const handleDelete = () => { + if (deleteId) { + onDelete(deleteId) + setDeleteId(null) } } return ( - - - - - - - - Recent Conversations - - - {conversations.map((conv) => ( - - {editingId === conv.id ? ( -
- setEditValue(e.target.value)} - className="h-7 text-sm" - autoFocus - onKeyDown={(e) => { - if (e.key === "Enter") handleConfirmRename() - if (e.key === "Escape") handleCancelRename() - }} - /> - - -
- ) : ( - <> - onSelect(conv.id)} - tooltip={conv.name} - > - - {conv.name} - - - - - - - Conversation actions - - - - handleStartRename(conv)}> - - Rename - - handleDelete(conv.id)} - className="text-destructive focus:text-destructive" - > - - Delete - - - - - )} -
- ))} -
-
-
-
- - -
© 2026 Election Analytics
-
-
+ <> + + + + + + + + Recent Conversations + + + {conversations.map((conv) => ( + + {editingId === conv.id ? ( +
+ setEditValue(e.target.value)} + className="h-7 text-sm" + autoFocus + onKeyDown={(e) => { + if (e.key === "Enter") handleConfirmRename() + if (e.key === "Escape") handleCancelRename() + }} + /> + + +
+ ) : ( + <> + onSelect(conv.id)} + tooltip={conv.name} + > + + {conv.name} + + + + + + + Conversation actions + + + + handleStartRename(conv)}> + + Rename + + setDeleteId(conv.id)} + className="text-destructive focus:text-destructive" + > + + Delete + + + + + )} +
+ ))} +
+
+
+
+ + +
© 2026 Election Analytics
+
+
+ + !open && setDeleteId(null)}> + + + Are you absolutely sure? + + This action cannot be undone. This will permanently delete the conversation history. + + + + Cancel + + Delete + + + + + ) } diff --git a/frontend/src/components/ui/alert-dialog.tsx b/frontend/src/components/ui/alert-dialog.tsx new file mode 100644 index 0000000..a9cda6a --- /dev/null +++ b/frontend/src/components/ui/alert-dialog.tsx @@ -0,0 +1,194 @@ +import * as React from "react" +import { AlertDialog as AlertDialogPrimitive } from "radix-ui" + +import { cn } from "@/lib/utils" +import { Button } from "@/components/ui/button" + +function AlertDialog({ + ...props +}: React.ComponentProps) { + return +} + +function AlertDialogTrigger({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogPortal({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogOverlay({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogContent({ + className, + size = "default", + ...props +}: React.ComponentProps & { + size?: "default" | "sm" +}) { + return ( + + + + + ) +} + +function AlertDialogHeader({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function AlertDialogFooter({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function AlertDialogTitle({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogDescription({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogMedia({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function AlertDialogAction({ + className, + variant = "default", + size = "default", + ...props +}: React.ComponentProps & + Pick, "variant" | "size">) { + return ( + + ) +} + +function AlertDialogCancel({ + className, + variant = "outline", + size = "default", + ...props +}: React.ComponentProps & + Pick, "variant" | "size">) { + return ( + + ) +} + +export { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogMedia, + AlertDialogOverlay, + AlertDialogPortal, + AlertDialogTitle, + AlertDialogTrigger, +} diff --git a/frontend/src/components/ui/dialog.tsx b/frontend/src/components/ui/dialog.tsx new file mode 100644 index 0000000..b81f15b --- /dev/null +++ b/frontend/src/components/ui/dialog.tsx @@ -0,0 +1,122 @@ +"use client" + +import * as React from "react" +import { Dialog as DialogPrimitive } from "radix-ui" +import { XIcon } from "lucide-react" + +import { cn } from "@/lib/utils" + +const Dialog = DialogPrimitive.Root + +const DialogTrigger = DialogPrimitive.Trigger + +const DialogPortal = DialogPrimitive.Portal + +const DialogClose = DialogPrimitive.Close + +const DialogOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DialogOverlay.displayName = "DialogOverlay" + +const DialogContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + {children} + + + Close + + + +)) +DialogContent.displayName = "DialogContent" + +const DialogHeader = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +DialogHeader.displayName = "DialogHeader" + +const DialogFooter = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +DialogFooter.displayName = "DialogFooter" + +const DialogTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DialogTitle.displayName = "DialogTitle" + +const DialogDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DialogDescription.displayName = "DialogDescription" + +export { + Dialog, + DialogPortal, + DialogOverlay, + DialogTrigger, + DialogClose, + DialogContent, + DialogHeader, + DialogFooter, + DialogTitle, + DialogDescription, +} diff --git a/frontend/src/services/chat_history.test.ts b/frontend/src/services/chat_history.test.ts index f49c32c..a628164 100644 --- a/frontend/src/services/chat_history.test.ts +++ b/frontend/src/services/chat_history.test.ts @@ -1,10 +1,15 @@ -import { describe, it, expect, vi, beforeEach } from "vitest" +import { describe, it, expect, vi, beforeEach, type MockInstance } from "vitest" import api from "./api" import { ChatService } from "./chat" -import { AxiosResponse } from "axios" +import type { AxiosResponse } from "axios" vi.mock("./api") -const mockedApi = vi.mocked(api) +const mockedApi = api as unknown as { + get: MockInstance + post: MockInstance + patch: MockInstance + delete: MockInstance +} describe("ChatService History Management", () => { beforeEach(() => {