feat(ux): Finalize advanced UX with plot zoom, export, and shadcn UI consistency.
This commit is contained in:
@@ -9,6 +9,7 @@ import { AuthService, type UserResponse } from "./services/auth"
|
|||||||
import { ChatService, type MessageResponse } from "./services/chat"
|
import { ChatService, type MessageResponse } from "./services/chat"
|
||||||
import { type Conversation } from "./components/layout/HistorySidebar"
|
import { type Conversation } from "./components/layout/HistorySidebar"
|
||||||
import { registerUnauthorizedCallback } from "./services/api"
|
import { registerUnauthorizedCallback } from "./services/api"
|
||||||
|
import { Button } from "./components/ui/button"
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const [isAuthenticated, setIsAuthenticated] = useState(false)
|
const [isAuthenticated, setIsAuthenticated] = useState(false)
|
||||||
@@ -179,12 +180,13 @@ function App() {
|
|||||||
Welcome, {user?.display_name || user?.email || "User"}!
|
Welcome, {user?.display_name || user?.email || "User"}!
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<Button
|
||||||
|
variant="link"
|
||||||
onClick={handleLogout}
|
onClick={handleLogout}
|
||||||
className="text-sm text-muted-foreground hover:text-primary underline"
|
className="text-sm text-muted-foreground hover:text-primary h-auto p-0 font-normal underline"
|
||||||
>
|
>
|
||||||
Logout
|
Logout
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 min-h-0">
|
<div className="flex-1 min-h-0">
|
||||||
@@ -218,12 +220,12 @@ function App() {
|
|||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
Create a new conversation in the sidebar to start asking questions.
|
Create a new conversation in the sidebar to start asking questions.
|
||||||
</p>
|
</p>
|
||||||
<button
|
<Button
|
||||||
onClick={handleCreateConversation}
|
onClick={handleCreateConversation}
|
||||||
className="mt-4 px-4 py-2 bg-primary text-primary-foreground rounded-md text-sm font-medium hover:bg-primary/90"
|
className="mt-4"
|
||||||
>
|
>
|
||||||
Start New Chat
|
Start New Chat
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ import { Input } from "@/components/ui/input"
|
|||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
import { useState } from "react"
|
import { useState } from "react"
|
||||||
import { Separator } from "@/components/ui/separator"
|
import { Separator } from "@/components/ui/separator"
|
||||||
|
import { Alert, AlertDescription } from "@/components/ui/alert"
|
||||||
|
import { AlertCircle } from "lucide-react"
|
||||||
import axios from "axios"
|
import axios from "axios"
|
||||||
|
|
||||||
interface LoginFormProps {
|
interface LoginFormProps {
|
||||||
@@ -70,9 +72,10 @@ export function LoginForm({ onSuccess, onToggleMode }: LoginFormProps) {
|
|||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||||
{error && (
|
{error && (
|
||||||
<div className="p-3 text-sm font-medium text-destructive bg-destructive/10 rounded-md">
|
<Alert variant="destructive">
|
||||||
{error}
|
<AlertCircle className="h-4 w-4" />
|
||||||
</div>
|
<AlertDescription>{error}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
)}
|
)}
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
@@ -121,13 +124,13 @@ export function LoginForm({ onSuccess, onToggleMode }: LoginFormProps) {
|
|||||||
|
|
||||||
<div className="mt-4 text-center text-sm">
|
<div className="mt-4 text-center text-sm">
|
||||||
Don't have an account?{" "}
|
Don't have an account?{" "}
|
||||||
<button
|
<Button
|
||||||
type="button"
|
variant="link"
|
||||||
className="underline underline-offset-4 hover:text-primary"
|
className="p-0 h-auto font-normal"
|
||||||
onClick={onToggleMode}
|
onClick={onToggleMode}
|
||||||
>
|
>
|
||||||
Register
|
Register
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ import {
|
|||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
import { useState } from "react"
|
import { useState } from "react"
|
||||||
|
import { Alert, AlertDescription } from "@/components/ui/alert"
|
||||||
|
import { AlertCircle } from "lucide-react"
|
||||||
import axios from "axios"
|
import axios from "axios"
|
||||||
|
|
||||||
interface RegisterFormProps {
|
interface RegisterFormProps {
|
||||||
@@ -62,9 +64,10 @@ export function RegisterForm({ onSuccess, onToggleMode }: RegisterFormProps) {
|
|||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||||
{error && (
|
{error && (
|
||||||
<div className="p-3 text-sm font-medium text-destructive bg-destructive/10 rounded-md">
|
<Alert variant="destructive">
|
||||||
{error}
|
<AlertCircle className="h-4 w-4" />
|
||||||
</div>
|
<AlertDescription>{error}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
)}
|
)}
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
@@ -112,13 +115,13 @@ export function RegisterForm({ onSuccess, onToggleMode }: RegisterFormProps) {
|
|||||||
</Form>
|
</Form>
|
||||||
<div className="mt-4 text-center text-sm">
|
<div className="mt-4 text-center text-sm">
|
||||||
Already have an account?{" "}
|
Already have an account?{" "}
|
||||||
<button
|
<Button
|
||||||
type="button"
|
variant="link"
|
||||||
className="underline underline-offset-4 hover:text-primary"
|
className="p-0 h-auto font-normal"
|
||||||
onClick={onToggleMode}
|
onClick={onToggleMode}
|
||||||
>
|
>
|
||||||
Login
|
Login
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
|
import * as React from "react"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { type MessageResponse } from "@/services/chat"
|
import { type MessageResponse } from "@/services/chat"
|
||||||
import { MarkdownContent } from "./MarkdownContent"
|
import { MarkdownContent } from "./MarkdownContent"
|
||||||
|
import { PlotModal } from "./PlotModal"
|
||||||
|
import { Maximize2Icon } from "lucide-react"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
|
||||||
interface MessageBubbleProps {
|
interface MessageBubbleProps {
|
||||||
message: MessageResponse
|
message: MessageResponse
|
||||||
@@ -8,6 +12,7 @@ interface MessageBubbleProps {
|
|||||||
|
|
||||||
export function MessageBubble({ message }: MessageBubbleProps) {
|
export function MessageBubble({ message }: MessageBubbleProps) {
|
||||||
const isAssistant = message.role === "assistant"
|
const isAssistant = message.role === "assistant"
|
||||||
|
const [selectedPlot, setSelectedPlot] = React.useState<string | null>(null)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -44,31 +49,57 @@ export function MessageBubble({ message }: MessageBubbleProps) {
|
|||||||
|
|
||||||
{(message.plots || message.plot_ids) && ((message.plots?.length || 0) > 0 || (message.plot_ids?.length || 0) > 0) && (
|
{(message.plots || message.plot_ids) && ((message.plots?.length || 0) > 0 || (message.plot_ids?.length || 0) > 0) && (
|
||||||
<div className="mt-4 grid grid-cols-1 gap-2">
|
<div className="mt-4 grid grid-cols-1 gap-2">
|
||||||
{message.plots?.map((plot, index) => (
|
{message.plots?.map((plot, index) => {
|
||||||
<img
|
const src = `data:image/png;base64,${plot}`
|
||||||
key={`stream-${index}`}
|
return (
|
||||||
src={`data:image/png;base64,${plot}`}
|
<Button
|
||||||
alt="Analysis Plot"
|
key={`stream-${index}`}
|
||||||
className="rounded-md border bg-white w-full h-auto cursor-pointer hover:opacity-90 transition-opacity"
|
variant="ghost"
|
||||||
onClick={() => {
|
className="relative group p-0 h-auto w-full overflow-hidden hover:bg-transparent"
|
||||||
// TODO: Open in modal (Phase 5)
|
onClick={() => setSelectedPlot(src)}
|
||||||
}}
|
>
|
||||||
/>
|
<img
|
||||||
))}
|
src={src}
|
||||||
{message.plot_ids?.map((plotId, index) => (
|
alt="Analysis Plot"
|
||||||
<img
|
className="rounded-md border bg-white w-full h-auto transition-all group-hover:brightness-95"
|
||||||
key={`history-${index}`}
|
/>
|
||||||
src={`${import.meta.env.VITE_API_URL || ""}/api/v1/artifacts/plots/${plotId}`}
|
<div className="absolute inset-0 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity bg-black/10">
|
||||||
alt="Historical Analysis Plot"
|
<Maximize2Icon className="h-6 w-6 text-white drop-shadow-md" />
|
||||||
className="rounded-md border bg-white w-full h-auto cursor-pointer hover:opacity-90 transition-opacity"
|
</div>
|
||||||
onClick={() => {
|
</Button>
|
||||||
// TODO: Open in modal (Phase 5)
|
)
|
||||||
}}
|
})}
|
||||||
/>
|
{message.plot_ids?.map((plotId, index) => {
|
||||||
))}
|
const src = `${import.meta.env.VITE_API_URL || ""}/api/v1/artifacts/plots/${plotId}`
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
key={`history-${index}`}
|
||||||
|
variant="ghost"
|
||||||
|
className="relative group p-0 h-auto w-full overflow-hidden hover:bg-transparent"
|
||||||
|
onClick={() => setSelectedPlot(src)}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={src}
|
||||||
|
alt="Historical Analysis Plot"
|
||||||
|
className="rounded-md border bg-white w-full h-auto transition-all group-hover:brightness-95"
|
||||||
|
/>
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity bg-black/10">
|
||||||
|
<Maximize2Icon className="h-6 w-6 text-white drop-shadow-md" />
|
||||||
|
</div>
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{selectedPlot && (
|
||||||
|
<PlotModal
|
||||||
|
src={selectedPlot}
|
||||||
|
isOpen={true}
|
||||||
|
onClose={() => setSelectedPlot(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
56
frontend/src/components/chat/PlotModal.tsx
Normal file
56
frontend/src/components/chat/PlotModal.tsx
Normal file
@@ -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 (
|
||||||
|
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
|
||||||
|
<DialogContent className="max-w-4xl w-[90vw] max-h-[90vh] flex flex-col p-0 gap-0 overflow-hidden">
|
||||||
|
<DialogHeader className="p-4 border-b bg-muted/30">
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<DialogTitle className="text-base">{title}</DialogTitle>
|
||||||
|
<DialogDescription className="text-xs">
|
||||||
|
Generated analysis visualization
|
||||||
|
</DialogDescription>
|
||||||
|
</div>
|
||||||
|
<Button size="sm" variant="outline" className="gap-2 shrink-0" onClick={handleDownload}>
|
||||||
|
<DownloadIcon className="h-4 w-4" />
|
||||||
|
Download PNG
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-auto bg-white flex items-center justify-center p-4">
|
||||||
|
<img
|
||||||
|
src={src}
|
||||||
|
alt={title}
|
||||||
|
className="max-w-full h-auto object-contain shadow-sm border rounded-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -27,6 +27,16 @@ import {
|
|||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
DropdownMenuTrigger
|
DropdownMenuTrigger
|
||||||
} from "@/components/ui/dropdown-menu"
|
} 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 { Button } from "@/components/ui/button"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
|
|
||||||
@@ -55,6 +65,7 @@ export function HistorySidebar({
|
|||||||
}: HistorySidebarProps) {
|
}: HistorySidebarProps) {
|
||||||
const [editingId, setEditingId] = React.useState<string | null>(null)
|
const [editingId, setEditingId] = React.useState<string | null>(null)
|
||||||
const [editValue, setEditValue] = React.useState("")
|
const [editValue, setEditValue] = React.useState("")
|
||||||
|
const [deleteId, setDeleteId] = React.useState<string | null>(null)
|
||||||
|
|
||||||
const handleStartRename = (conv: Conversation) => {
|
const handleStartRename = (conv: Conversation) => {
|
||||||
setEditingId(conv.id)
|
setEditingId(conv.id)
|
||||||
@@ -72,95 +83,115 @@ export function HistorySidebar({
|
|||||||
setEditingId(null)
|
setEditingId(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleDelete = (id: string) => {
|
const handleDelete = () => {
|
||||||
if (confirm("Are you sure you want to delete this conversation?")) {
|
if (deleteId) {
|
||||||
onDelete(id)
|
onDelete(deleteId)
|
||||||
|
setDeleteId(null)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Sidebar role="complementary">
|
<>
|
||||||
<SidebarHeader className="border-b p-4">
|
<Sidebar role="complementary">
|
||||||
<Button
|
<SidebarHeader className="border-b p-4">
|
||||||
onClick={onCreate}
|
<Button
|
||||||
className="w-full justify-start gap-2"
|
onClick={onCreate}
|
||||||
variant="outline"
|
className="w-full justify-start gap-2"
|
||||||
>
|
variant="outline"
|
||||||
<PlusIcon className="h-4 w-4" />
|
>
|
||||||
New Chat
|
<PlusIcon className="h-4 w-4" />
|
||||||
</Button>
|
New Chat
|
||||||
</SidebarHeader>
|
</Button>
|
||||||
|
</SidebarHeader>
|
||||||
<SidebarContent>
|
|
||||||
<SidebarGroup>
|
<SidebarContent>
|
||||||
<SidebarGroupLabel>Recent Conversations</SidebarGroupLabel>
|
<SidebarGroup>
|
||||||
<SidebarGroupContent>
|
<SidebarGroupLabel>Recent Conversations</SidebarGroupLabel>
|
||||||
<SidebarMenu>
|
<SidebarGroupContent>
|
||||||
{conversations.map((conv) => (
|
<SidebarMenu>
|
||||||
<SidebarMenuItem key={conv.id}>
|
{conversations.map((conv) => (
|
||||||
{editingId === conv.id ? (
|
<SidebarMenuItem key={conv.id}>
|
||||||
<div className="flex items-center gap-1 p-1 w-full">
|
{editingId === conv.id ? (
|
||||||
<Input
|
<div className="flex items-center gap-1 p-1 w-full">
|
||||||
value={editValue}
|
<Input
|
||||||
onChange={(e) => setEditValue(e.target.value)}
|
value={editValue}
|
||||||
className="h-7 text-sm"
|
onChange={(e) => setEditValue(e.target.value)}
|
||||||
autoFocus
|
className="h-7 text-sm"
|
||||||
onKeyDown={(e) => {
|
autoFocus
|
||||||
if (e.key === "Enter") handleConfirmRename()
|
onKeyDown={(e) => {
|
||||||
if (e.key === "Escape") handleCancelRename()
|
if (e.key === "Enter") handleConfirmRename()
|
||||||
}}
|
if (e.key === "Escape") handleCancelRename()
|
||||||
/>
|
}}
|
||||||
<Button size="icon-xs" variant="ghost" onClick={handleConfirmRename}>
|
/>
|
||||||
<CheckIcon className="h-3 w-3" />
|
<Button size="icon-xs" variant="ghost" onClick={handleConfirmRename}>
|
||||||
</Button>
|
<CheckIcon className="h-3 w-3" />
|
||||||
<Button size="icon-xs" variant="ghost" onClick={handleCancelRename}>
|
</Button>
|
||||||
<XIcon className="h-3 w-3" />
|
<Button size="icon-xs" variant="ghost" onClick={handleCancelRename}>
|
||||||
</Button>
|
<XIcon className="h-3 w-3" />
|
||||||
</div>
|
</Button>
|
||||||
) : (
|
</div>
|
||||||
<>
|
) : (
|
||||||
<SidebarMenuButton
|
<>
|
||||||
isActive={selectedId === conv.id}
|
<SidebarMenuButton
|
||||||
onClick={() => onSelect(conv.id)}
|
isActive={selectedId === conv.id}
|
||||||
tooltip={conv.name}
|
onClick={() => onSelect(conv.id)}
|
||||||
>
|
tooltip={conv.name}
|
||||||
<MessageSquareIcon className="h-4 w-4" />
|
>
|
||||||
<span>{conv.name}</span>
|
<MessageSquareIcon className="h-4 w-4" />
|
||||||
</SidebarMenuButton>
|
<span>{conv.name}</span>
|
||||||
|
</SidebarMenuButton>
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenu>
|
||||||
<SidebarMenuAction showOnHover>
|
<DropdownMenuTrigger asChild>
|
||||||
<MoreVerticalIcon className="h-4 w-4" />
|
<SidebarMenuAction showOnHover>
|
||||||
<span className="sr-only">Conversation actions</span>
|
<MoreVerticalIcon className="h-4 w-4" />
|
||||||
</SidebarMenuAction>
|
<span className="sr-only">Conversation actions</span>
|
||||||
</DropdownMenuTrigger>
|
</SidebarMenuAction>
|
||||||
<DropdownMenuContent align="end" className="w-40">
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuItem onClick={() => handleStartRename(conv)}>
|
<DropdownMenuContent align="end" className="w-40">
|
||||||
<PencilIcon className="mr-2 h-4 w-4" />
|
<DropdownMenuItem onClick={() => handleStartRename(conv)}>
|
||||||
Rename
|
<PencilIcon className="mr-2 h-4 w-4" />
|
||||||
</DropdownMenuItem>
|
Rename
|
||||||
<DropdownMenuItem
|
</DropdownMenuItem>
|
||||||
onClick={() => handleDelete(conv.id)}
|
<DropdownMenuItem
|
||||||
className="text-destructive focus:text-destructive"
|
onClick={() => setDeleteId(conv.id)}
|
||||||
>
|
className="text-destructive focus:text-destructive"
|
||||||
<TrashIcon className="mr-2 h-4 w-4" />
|
>
|
||||||
Delete
|
<TrashIcon className="mr-2 h-4 w-4" />
|
||||||
</DropdownMenuItem>
|
Delete
|
||||||
</DropdownMenuContent>
|
</DropdownMenuItem>
|
||||||
</DropdownMenu>
|
</DropdownMenuContent>
|
||||||
</>
|
</DropdownMenu>
|
||||||
)}
|
</>
|
||||||
</SidebarMenuItem>
|
)}
|
||||||
))}
|
</SidebarMenuItem>
|
||||||
</SidebarMenu>
|
))}
|
||||||
</SidebarGroupContent>
|
</SidebarMenu>
|
||||||
</SidebarGroup>
|
</SidebarGroupContent>
|
||||||
</SidebarContent>
|
</SidebarGroup>
|
||||||
|
</SidebarContent>
|
||||||
<SidebarFooter className="border-t p-4">
|
|
||||||
<div className="text-xs text-muted-foreground">© 2026 Election Analytics</div>
|
<SidebarFooter className="border-t p-4">
|
||||||
</SidebarFooter>
|
<div className="text-xs text-muted-foreground">© 2026 Election Analytics</div>
|
||||||
</Sidebar>
|
</SidebarFooter>
|
||||||
|
</Sidebar>
|
||||||
|
|
||||||
|
<AlertDialog open={!!deleteId} onOpenChange={(open) => !open && setDeleteId(null)}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
This action cannot be undone. This will permanently delete the conversation history.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||||
|
<AlertDialogAction onClick={handleDelete} className="bg-destructive text-destructive-foreground hover:bg-destructive/90">
|
||||||
|
Delete
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
194
frontend/src/components/ui/alert-dialog.tsx
Normal file
194
frontend/src/components/ui/alert-dialog.tsx
Normal file
@@ -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<typeof AlertDialogPrimitive.Root>) {
|
||||||
|
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogTrigger({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogPortal({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogOverlay({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Overlay>) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Overlay
|
||||||
|
data-slot="alert-dialog-overlay"
|
||||||
|
className={cn(
|
||||||
|
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogContent({
|
||||||
|
className,
|
||||||
|
size = "default",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Content> & {
|
||||||
|
size?: "default" | "sm"
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPortal>
|
||||||
|
<AlertDialogOverlay />
|
||||||
|
<AlertDialogPrimitive.Content
|
||||||
|
data-slot="alert-dialog-content"
|
||||||
|
data-size={size}
|
||||||
|
className={cn(
|
||||||
|
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 group/alert-dialog-content fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 data-[size=sm]:max-w-xs data-[size=default]:sm:max-w-lg",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</AlertDialogPortal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogHeader({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="alert-dialog-header"
|
||||||
|
className={cn(
|
||||||
|
"grid grid-rows-[auto_1fr] place-items-center gap-1.5 text-center has-data-[slot=alert-dialog-media]:grid-rows-[auto_auto_1fr] has-data-[slot=alert-dialog-media]:gap-x-6 sm:group-data-[size=default]/alert-dialog-content:place-items-start sm:group-data-[size=default]/alert-dialog-content:text-left sm:group-data-[size=default]/alert-dialog-content:has-data-[slot=alert-dialog-media]:grid-rows-[auto_1fr]",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogFooter({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="alert-dialog-footer"
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col-reverse gap-2 group-data-[size=sm]/alert-dialog-content:grid group-data-[size=sm]/alert-dialog-content:grid-cols-2 sm:flex-row sm:justify-end",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogTitle({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Title
|
||||||
|
data-slot="alert-dialog-title"
|
||||||
|
className={cn(
|
||||||
|
"text-lg font-semibold sm:group-data-[size=default]/alert-dialog-content:group-has-data-[slot=alert-dialog-media]/alert-dialog-content:col-start-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogDescription({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Description
|
||||||
|
data-slot="alert-dialog-description"
|
||||||
|
className={cn("text-muted-foreground text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogMedia({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="alert-dialog-media"
|
||||||
|
className={cn(
|
||||||
|
"bg-muted mb-2 inline-flex size-16 items-center justify-center rounded-md sm:group-data-[size=default]/alert-dialog-content:row-span-2 *:[svg:not([class*='size-'])]:size-8",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogAction({
|
||||||
|
className,
|
||||||
|
variant = "default",
|
||||||
|
size = "default",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Action> &
|
||||||
|
Pick<React.ComponentProps<typeof Button>, "variant" | "size">) {
|
||||||
|
return (
|
||||||
|
<Button variant={variant} size={size} asChild>
|
||||||
|
<AlertDialogPrimitive.Action
|
||||||
|
data-slot="alert-dialog-action"
|
||||||
|
className={cn(className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogCancel({
|
||||||
|
className,
|
||||||
|
variant = "outline",
|
||||||
|
size = "default",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Cancel> &
|
||||||
|
Pick<React.ComponentProps<typeof Button>, "variant" | "size">) {
|
||||||
|
return (
|
||||||
|
<Button variant={variant} size={size} asChild>
|
||||||
|
<AlertDialogPrimitive.Cancel
|
||||||
|
data-slot="alert-dialog-cancel"
|
||||||
|
className={cn(className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogMedia,
|
||||||
|
AlertDialogOverlay,
|
||||||
|
AlertDialogPortal,
|
||||||
|
AlertDialogTitle,
|
||||||
|
AlertDialogTrigger,
|
||||||
|
}
|
||||||
122
frontend/src/components/ui/dialog.tsx
Normal file
122
frontend/src/components/ui/dialog.tsx
Normal file
@@ -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<typeof DialogPrimitive.Overlay>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DialogPrimitive.Overlay
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/80",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
DialogOverlay.displayName = "DialogOverlay"
|
||||||
|
|
||||||
|
const DialogContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<DialogPortal>
|
||||||
|
<DialogOverlay />
|
||||||
|
<DialogPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 fixed top-[50%] left-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border p-6 shadow-lg transition-all duration-200 sm:rounded-lg md:w-full",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<DialogPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-sm opacity-70 transition-opacity hover:opacity-100 focus:outline-hidden focus:ring-2 focus:ring-offset-2 disabled:pointer-events-none">
|
||||||
|
<XIcon className="h-4 w-4" />
|
||||||
|
<span className="sr-only">Close</span>
|
||||||
|
</DialogPrimitive.Close>
|
||||||
|
</DialogPrimitive.Content>
|
||||||
|
</DialogPortal>
|
||||||
|
))
|
||||||
|
DialogContent.displayName = "DialogContent"
|
||||||
|
|
||||||
|
const DialogHeader = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col space-y-1.5 text-center sm:text-left",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
DialogHeader.displayName = "DialogHeader"
|
||||||
|
|
||||||
|
const DialogFooter = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
DialogFooter.displayName = "DialogFooter"
|
||||||
|
|
||||||
|
const DialogTitle = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DialogPrimitive.Title
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"text-lg font-semibold leading-none tracking-tight",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
DialogTitle.displayName = "DialogTitle"
|
||||||
|
|
||||||
|
const DialogDescription = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DialogPrimitive.Description>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DialogPrimitive.Description
|
||||||
|
ref={ref}
|
||||||
|
className={cn("text-muted-foreground text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
DialogDescription.displayName = "DialogDescription"
|
||||||
|
|
||||||
|
export {
|
||||||
|
Dialog,
|
||||||
|
DialogPortal,
|
||||||
|
DialogOverlay,
|
||||||
|
DialogTrigger,
|
||||||
|
DialogClose,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogFooter,
|
||||||
|
DialogTitle,
|
||||||
|
DialogDescription,
|
||||||
|
}
|
||||||
@@ -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 api from "./api"
|
||||||
import { ChatService } from "./chat"
|
import { ChatService } from "./chat"
|
||||||
import { AxiosResponse } from "axios"
|
import type { AxiosResponse } from "axios"
|
||||||
|
|
||||||
vi.mock("./api")
|
vi.mock("./api")
|
||||||
const mockedApi = vi.mocked(api)
|
const mockedApi = api as unknown as {
|
||||||
|
get: MockInstance<typeof api.get>
|
||||||
|
post: MockInstance<typeof api.post>
|
||||||
|
patch: MockInstance<typeof api.patch>
|
||||||
|
delete: MockInstance<typeof api.delete>
|
||||||
|
}
|
||||||
|
|
||||||
describe("ChatService History Management", () => {
|
describe("ChatService History Management", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
|||||||
Reference in New Issue
Block a user