UI: dashboards split
This commit is contained in:
@@ -1,7 +1,8 @@
|
||||
import { useState } from "react";
|
||||
import { useState, useEffect } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger, DialogDescription, DialogFooter } from "@/components/ui/dialog";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { Code, Table as TableIcon, BarChart as ChartIcon, LayoutDashboard, Copy, Check } from "lucide-react";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
@@ -31,11 +32,24 @@ export function InlineVisualizationCard({ viz }: InlineVisualizationCardProps) {
|
||||
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [pendingChart, setPendingChart] = useState<Omit<ChartConfig, 'layout'> | null>(null);
|
||||
const { addChart } = useDashboardStore();
|
||||
const [selectedDashboardId, setSelectedDashboardId] = useState<string>('');
|
||||
const { dashboards, addChart, loadDashboards } = useDashboardStore();
|
||||
const { currentProject } = useProjectStore();
|
||||
const objectRows = viz.rows.filter((row) => row && typeof row === "object" && !Array.isArray(row)) as Record<string, unknown>[];
|
||||
const columns = objectRows.length > 0 ? Object.keys(objectRows[0]) : [];
|
||||
|
||||
useEffect(() => {
|
||||
if (currentProject) {
|
||||
loadDashboards(currentProject.id);
|
||||
}
|
||||
}, [currentProject, loadDashboards]);
|
||||
|
||||
useEffect(() => {
|
||||
if (dashboards.length > 0 && !selectedDashboardId) {
|
||||
setSelectedDashboardId(dashboards[0].id);
|
||||
}
|
||||
}, [dashboards, selectedDashboardId]);
|
||||
|
||||
const buildPendingChart = (): Omit<ChartConfig, 'layout'> => {
|
||||
if (view === "table") {
|
||||
return {
|
||||
@@ -68,8 +82,8 @@ export function InlineVisualizationCard({ viz }: InlineVisualizationCardProps) {
|
||||
};
|
||||
|
||||
const handleConfirmAdd = () => {
|
||||
if (!pendingChart || !currentProject) return;
|
||||
addChart(pendingChart, currentProject.id);
|
||||
if (!pendingChart || !currentProject || !selectedDashboardId) return;
|
||||
addChart(pendingChart, selectedDashboardId, currentProject.id);
|
||||
setConfirmOpen(false);
|
||||
setPendingChart(null);
|
||||
};
|
||||
@@ -165,7 +179,7 @@ export function InlineVisualizationCard({ viz }: InlineVisualizationCardProps) {
|
||||
</Dialog>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" className="h-7 text-xs" onClick={handleAddToDashboard} disabled={objectRows.length === 0}>
|
||||
<Button variant="outline" size="sm" className="h-7 text-xs" onClick={handleAddToDashboard} disabled={objectRows.length === 0 || dashboards.length === 0}>
|
||||
<LayoutDashboard className="h-3.5 w-3.5 mr-1.5" />
|
||||
Add to Dashboard
|
||||
</Button>
|
||||
@@ -206,11 +220,26 @@ export function InlineVisualizationCard({ viz }: InlineVisualizationCardProps) {
|
||||
<Dialog open={confirmOpen} onOpenChange={setConfirmOpen}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('confirmAddToDashboard')}</DialogTitle>
|
||||
<DialogTitle>{t('pinChartToDashboard')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t('confirmAddChartToDashboardDesc')}
|
||||
{t('selectDashboardToPin')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="py-4">
|
||||
<label className="text-sm font-medium mb-2 block">{t('dashboardMenu')}</label>
|
||||
<Select value={selectedDashboardId} onValueChange={(val) => { if (val) setSelectedDashboardId(val); }}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={t('selectDashboard')}>
|
||||
{dashboards.find(d => d.id === selectedDashboardId)?.name || t('selectDashboard')}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{dashboards.map(d => (
|
||||
<SelectItem key={d.id} value={d.id}>{d.name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -221,8 +250,8 @@ export function InlineVisualizationCard({ viz }: InlineVisualizationCardProps) {
|
||||
>
|
||||
{t('cancel')}
|
||||
</Button>
|
||||
<Button onClick={handleConfirmAdd}>
|
||||
{t('confirmAdd')}
|
||||
<Button onClick={handleConfirmAdd} disabled={!selectedDashboardId}>
|
||||
{t('submit')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -6,6 +6,8 @@ import { useState, useRef, useEffect } from "react";
|
||||
import { Link, useNavigate, useLocation } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useAuthStore } from "@/store/authStore";
|
||||
import { useProjectStore } from "@/store/projectStore";
|
||||
import { useDashboardStore } from "@/store/dashboardStore";
|
||||
import { api } from "@/lib/api";
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
|
||||
@@ -23,39 +25,26 @@ interface SessionInfo {
|
||||
};
|
||||
}
|
||||
|
||||
function Section({
|
||||
function SectionHeader({
|
||||
title,
|
||||
count,
|
||||
isSelectionMode,
|
||||
setIsSelectionMode,
|
||||
selectedKeys,
|
||||
setSelectedKeys,
|
||||
items,
|
||||
onSelect,
|
||||
onDelete,
|
||||
onRename,
|
||||
onTogglePinned,
|
||||
onToggleArchived,
|
||||
onBatchDelete,
|
||||
activeKey
|
||||
onBatchDelete
|
||||
}: {
|
||||
title: string;
|
||||
count: number;
|
||||
isSelectionMode: boolean;
|
||||
setIsSelectionMode: (val: boolean) => void;
|
||||
selectedKeys: string[];
|
||||
setSelectedKeys: (val: string[] | ((prev: string[]) => string[])) => void;
|
||||
items: SessionInfo[];
|
||||
onSelect: (key: string) => void;
|
||||
onDelete: (key: string) => void;
|
||||
onRename: (key: string, currentTitle: string) => void;
|
||||
onTogglePinned: (key: string, pinned: boolean) => void;
|
||||
onToggleArchived: (key: string, archived: boolean) => void;
|
||||
onBatchDelete: (keys: string[]) => void;
|
||||
activeKey: string | null;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [selectedKeys, setSelectedKeys] = useState<string[]>([]);
|
||||
const [isSelectionMode, setIsSelectionMode] = useState(false);
|
||||
|
||||
const toggleSelect = (key: string, e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
setSelectedKeys(prev =>
|
||||
prev.includes(key) ? prev.filter(k => k !== key) : [...prev, key]
|
||||
);
|
||||
};
|
||||
|
||||
const handleSelectAll = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
@@ -84,12 +73,12 @@ function Section({
|
||||
if (!isSelectionMode) {
|
||||
setSelectedKeys([]);
|
||||
}
|
||||
}, [isSelectionMode]);
|
||||
}, [isSelectionMode, setSelectedKeys]);
|
||||
|
||||
return (
|
||||
<div className="px-3 pt-6">
|
||||
<div className="flex items-center justify-between mb-2 px-1 group">
|
||||
<div className="text-xs font-semibold text-zinc-500 flex items-center gap-1 uppercase tracking-wider">
|
||||
<div className="px-3 pt-4 pb-1">
|
||||
<div className="flex items-center justify-between px-1 group">
|
||||
<div className="text-[14px] font-semibold text-zinc-500 flex items-center gap-1">
|
||||
{title}
|
||||
<span>({count})</span>
|
||||
</div>
|
||||
@@ -140,7 +129,49 @@ function Section({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-0.5 mt-2">
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Section({
|
||||
items,
|
||||
onSelect,
|
||||
onDelete,
|
||||
onRename,
|
||||
onTogglePinned,
|
||||
onToggleArchived,
|
||||
onBatchDelete,
|
||||
activeKey,
|
||||
isSelectionMode,
|
||||
setIsSelectionMode,
|
||||
selectedKeys,
|
||||
setSelectedKeys
|
||||
}: {
|
||||
items: SessionInfo[];
|
||||
onSelect: (key: string) => void;
|
||||
onDelete: (key: string) => void;
|
||||
onRename: (key: string, currentTitle: string) => void;
|
||||
onTogglePinned: (key: string, pinned: boolean) => void;
|
||||
onToggleArchived: (key: string, archived: boolean) => void;
|
||||
onBatchDelete: (keys: string[]) => void;
|
||||
activeKey: string | null;
|
||||
isSelectionMode: boolean;
|
||||
setIsSelectionMode: (val: boolean) => void;
|
||||
selectedKeys: string[];
|
||||
setSelectedKeys: (val: string[] | ((prev: string[]) => string[])) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const toggleSelect = (key: string, e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
setSelectedKeys(prev =>
|
||||
prev.includes(key) ? prev.filter(k => k !== key) : [...prev, key]
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="px-3 pb-2">
|
||||
<div className="space-y-0 mt-1">
|
||||
{items.map((item) => {
|
||||
const displayTitle = item.metadata?.title || item.key.replace("api:", "");
|
||||
const isActive = activeKey === item.key;
|
||||
@@ -149,11 +180,11 @@ function Section({
|
||||
return (
|
||||
<div
|
||||
key={item.key}
|
||||
className={`w-full h-9 px-2 text-left rounded-md text-[14px] flex items-center justify-between group transition-colors cursor-pointer ${
|
||||
className={`w-full h-8 px-2 text-left rounded-md text-[14px] flex items-center justify-between group transition-colors cursor-pointer ${
|
||||
isActive && !isSelectionMode ? 'bg-zinc-100 text-zinc-900 font-medium' : 'text-zinc-600 hover:bg-zinc-50 hover:text-zinc-900'
|
||||
} ${isSelected ? 'bg-indigo-50/50 text-indigo-700' : ''}`}
|
||||
onClick={(e) => isSelectionMode ? toggleSelect(item.key, e) : onSelect(item.key)}
|
||||
>
|
||||
onClick={(e) => isSelectionMode ? toggleSelect(item.key, e) : onSelect(item.key)}
|
||||
>
|
||||
<div className="truncate pr-2 flex-1 flex items-center gap-1.5 min-w-0">
|
||||
{isSelectionMode ? (
|
||||
<span
|
||||
@@ -242,7 +273,7 @@ function Section({
|
||||
<span>{t('deleteSession')}</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
@@ -252,10 +283,100 @@ function Section({
|
||||
);
|
||||
}
|
||||
|
||||
function DashboardSection({
|
||||
title,
|
||||
count,
|
||||
items,
|
||||
onSelect,
|
||||
onDelete,
|
||||
onRename,
|
||||
onCreate,
|
||||
activeId
|
||||
}: {
|
||||
title: string;
|
||||
count: number;
|
||||
items: {id: string, name: string}[];
|
||||
onSelect: (id: string) => void;
|
||||
onDelete: (id: string) => void;
|
||||
onRename: (id: string, currentName: string) => void;
|
||||
onCreate: () => void;
|
||||
activeId: string | null;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="px-3 pt-4">
|
||||
<div className="flex items-center justify-between mb-1.5 px-1 group">
|
||||
<div className="text-[14px] font-semibold text-zinc-500 flex items-center gap-1">
|
||||
{title}
|
||||
<span>({count})</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={onCreate}
|
||||
className="text-[10px] font-medium px-1.5 py-0.5 hover:bg-zinc-200 rounded text-zinc-500 transition-colors opacity-0 group-hover:opacity-100 flex items-center gap-0.5"
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
{t('new')}
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-0 mt-1">
|
||||
{items.map((item) => {
|
||||
const isActive = activeId === item.id;
|
||||
return (
|
||||
<div
|
||||
key={item.id}
|
||||
className={`w-full h-8 px-2 text-left rounded-md text-[14px] flex items-center justify-between group transition-colors cursor-pointer ${
|
||||
isActive ? 'bg-zinc-100 text-zinc-900 font-medium' : 'text-zinc-600 hover:bg-zinc-50 hover:text-zinc-900'
|
||||
}`}
|
||||
onClick={() => onSelect(item.id)}
|
||||
>
|
||||
<div className="truncate pr-2 flex-1 flex items-center gap-1.5 min-w-0">
|
||||
<span className="w-4 shrink-0 flex items-center justify-center">
|
||||
<LayoutDashboard className="h-3.5 w-3.5 text-zinc-400" />
|
||||
</span>
|
||||
<span className="truncate">{item.name}</span>
|
||||
</div>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger onClick={(e) => e.stopPropagation()} className="h-6 w-6 flex items-center justify-center rounded hover:bg-zinc-200 text-zinc-400 opacity-0 group-hover:opacity-100 transition-opacity outline-none">
|
||||
<MoreVertical className="h-4 w-4" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-32">
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onRename(item.id, item.name);
|
||||
}}
|
||||
>
|
||||
<Pencil className="mr-2 h-4 w-4" />
|
||||
<span>{t('rename')}</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onDelete(item.id);
|
||||
}}
|
||||
className="text-red-600 focus:text-red-600 focus:bg-red-50"
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
<span>{t('deleteSession')}</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarBody() {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { user, logout } = useAuthStore();
|
||||
const { currentProject } = useProjectStore();
|
||||
const { dashboards, activeDashboardId, loadDashboards, createDashboard, deleteDashboard, renameDashboard, setActiveDashboard } = useDashboardStore();
|
||||
const { t, i18n } = useTranslation();
|
||||
const [showUserMenu, setShowUserMenu] = useState(false);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
@@ -266,11 +387,26 @@ function SidebarBody() {
|
||||
const [renameDialogOpen, setRenameDialogOpen] = useState(false);
|
||||
const [sessionToRename, setSessionToRename] = useState<{key: string, title: string} | null>(null);
|
||||
const [newTitle, setNewTitle] = useState("");
|
||||
|
||||
|
||||
const [activeSelectionMode, setActiveSelectionMode] = useState(false);
|
||||
const [activeSelectedKeys, setActiveSelectedKeys] = useState<string[]>([]);
|
||||
const [archivedSelectionMode, setArchivedSelectionMode] = useState(false);
|
||||
const [archivedSelectedKeys, setArchivedSelectedKeys] = useState<string[]>([]);
|
||||
|
||||
const [dashboardRenameDialogOpen, setDashboardRenameDialogOpen] = useState(false);
|
||||
const [dashboardToRename, setDashboardToRename] = useState<{id: string, name: string} | null>(null);
|
||||
const [newDashboardName, setNewDashboardName] = useState("");
|
||||
|
||||
// Try to parse active session from URL query
|
||||
const queryParams = new URLSearchParams(location.search);
|
||||
const activeSessionKey = queryParams.get("session") || "api:default";
|
||||
|
||||
useEffect(() => {
|
||||
if (currentProject) {
|
||||
loadDashboards(currentProject.id);
|
||||
}
|
||||
}, [currentProject, loadDashboards]);
|
||||
|
||||
const fetchSessions = async () => {
|
||||
try {
|
||||
const data = await api.get<SessionInfo[]>("/nanobot/sessions");
|
||||
@@ -440,6 +576,44 @@ function SidebarBody() {
|
||||
return title.includes(normalizedFilter);
|
||||
});
|
||||
|
||||
const handleCreateDashboard = () => {
|
||||
if (!currentProject) return;
|
||||
if (dashboards.length >= 3) {
|
||||
alert(t('dashboardLimitReached') || "You can only create up to 3 dashboards.");
|
||||
return;
|
||||
}
|
||||
createDashboard(t('newDashboardNameDefault'), currentProject.id);
|
||||
navigate(`/dashboard`);
|
||||
};
|
||||
|
||||
const handleSelectDashboard = (id: string) => {
|
||||
setActiveDashboard(id);
|
||||
navigate(`/dashboard`);
|
||||
};
|
||||
|
||||
const openDashboardRenameDialog = (id: string, name: string) => {
|
||||
setDashboardToRename({ id, name });
|
||||
setNewDashboardName(name);
|
||||
setDashboardRenameDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleDashboardRename = () => {
|
||||
if (!currentProject || !dashboardToRename || !newDashboardName.trim()) return;
|
||||
renameDashboard(dashboardToRename.id, newDashboardName.trim(), currentProject.id);
|
||||
setDashboardRenameDialogOpen(false);
|
||||
};
|
||||
|
||||
const handleDashboardDelete = (id: string) => {
|
||||
if (!currentProject) return;
|
||||
if (!window.confirm(t('confirmDeleteDashboard'))) return;
|
||||
deleteDashboard(id, currentProject.id);
|
||||
};
|
||||
|
||||
const filteredDashboards = dashboards.filter((d) => {
|
||||
if (!normalizedFilter) return true;
|
||||
return d.name.toLowerCase().includes(normalizedFilter);
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="h-full min-h-0 flex flex-col bg-zinc-50/30 border-r border-zinc-200 relative">
|
||||
{/* Header */}
|
||||
@@ -453,63 +627,103 @@ function SidebarBody() {
|
||||
<div className="w-8" />
|
||||
</div>
|
||||
|
||||
<div className="px-3 pt-4 space-y-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="w-full justify-start h-10 px-3 rounded-lg bg-zinc-200/50 hover:bg-zinc-200 text-zinc-900 font-medium"
|
||||
onClick={() => navigate("/dashboard")}
|
||||
>
|
||||
<LayoutDashboard className="h-4.5 w-4.5 mr-2 text-zinc-600" />
|
||||
{t('dashboardMenu')}
|
||||
</Button>
|
||||
<div className="flex-none">
|
||||
<DashboardSection
|
||||
title={t('dashboards') || 'Dashboards'}
|
||||
count={filteredDashboards.length}
|
||||
items={filteredDashboards.map(d => ({ id: d.id, name: d.name }))}
|
||||
onSelect={handleSelectDashboard}
|
||||
onDelete={handleDashboardDelete}
|
||||
onRename={openDashboardRenameDialog}
|
||||
onCreate={handleCreateDashboard}
|
||||
activeId={location.pathname === "/dashboard" ? activeDashboardId : null}
|
||||
/>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full justify-start h-10 px-3 rounded-lg border-zinc-200 bg-white hover:bg-zinc-50 text-zinc-600 font-medium"
|
||||
onClick={handleNewThread}
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
{t('newThread')}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="px-3 pt-4 mb-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full justify-start h-10 px-3 rounded-lg border-zinc-200 bg-white hover:bg-zinc-50 text-zinc-600 font-medium text-[14px]"
|
||||
onClick={handleNewThread}
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
{t('newThread')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<ScrollArea className="flex-1 min-h-0">
|
||||
<div className="px-3 pt-4">
|
||||
<div className="px-3 pt-2">
|
||||
<div className="relative">
|
||||
<Search className="h-4 w-4 text-zinc-400 absolute left-3 top-1/2 -translate-y-1/2" />
|
||||
<Input
|
||||
value={sessionFilter}
|
||||
onChange={(e) => setSessionFilter(e.target.value)}
|
||||
placeholder={t('filterSessionName')}
|
||||
className="pl-9 h-9 border-zinc-200 bg-white"
|
||||
className="pl-9 h-9 border-zinc-200 bg-white text-[14px]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Section
|
||||
title={t('threads')}
|
||||
count={activeSessions.length}
|
||||
items={activeSessions}
|
||||
onSelect={handleSelectSession}
|
||||
onDelete={handleDeleteSession}
|
||||
onBatchDelete={handleBatchDelete}
|
||||
onRename={openRenameDialog}
|
||||
onTogglePinned={handleTogglePinned}
|
||||
onToggleArchived={handleToggleArchived}
|
||||
activeKey={activeSessionKey}
|
||||
/>
|
||||
<Section
|
||||
title={t('archivedThreads')}
|
||||
count={archivedSessions.length}
|
||||
items={archivedSessions}
|
||||
onSelect={handleSelectSession}
|
||||
onDelete={handleDeleteSession}
|
||||
onBatchDelete={handleBatchDelete}
|
||||
onRename={openRenameDialog}
|
||||
onTogglePinned={handleTogglePinned}
|
||||
onToggleArchived={handleToggleArchived}
|
||||
activeKey={activeSessionKey}
|
||||
/>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-h-0 flex flex-col mt-2">
|
||||
<div className="flex-1 min-h-0 flex flex-col">
|
||||
<SectionHeader
|
||||
title={t('threads')}
|
||||
count={activeSessions.length}
|
||||
isSelectionMode={activeSelectionMode}
|
||||
setIsSelectionMode={setActiveSelectionMode}
|
||||
selectedKeys={activeSelectedKeys}
|
||||
setSelectedKeys={setActiveSelectedKeys}
|
||||
items={activeSessions}
|
||||
onBatchDelete={handleBatchDelete}
|
||||
/>
|
||||
<ScrollArea className="flex-1 min-h-0">
|
||||
<Section
|
||||
items={activeSessions}
|
||||
onSelect={handleSelectSession}
|
||||
onDelete={handleDeleteSession}
|
||||
onBatchDelete={handleBatchDelete}
|
||||
onRename={openRenameDialog}
|
||||
onTogglePinned={handleTogglePinned}
|
||||
onToggleArchived={handleToggleArchived}
|
||||
activeKey={activeSessionKey}
|
||||
isSelectionMode={activeSelectionMode}
|
||||
setIsSelectionMode={setActiveSelectionMode}
|
||||
selectedKeys={activeSelectedKeys}
|
||||
setSelectedKeys={setActiveSelectedKeys}
|
||||
/>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
|
||||
{archivedSessions.length > 0 && (
|
||||
<div className="h-[35%] min-h-[150px] shrink-0 border-t border-zinc-200 bg-zinc-50/50 flex flex-col">
|
||||
<SectionHeader
|
||||
title={t('archivedThreads')}
|
||||
count={archivedSessions.length}
|
||||
isSelectionMode={archivedSelectionMode}
|
||||
setIsSelectionMode={setArchivedSelectionMode}
|
||||
selectedKeys={archivedSelectedKeys}
|
||||
setSelectedKeys={setArchivedSelectedKeys}
|
||||
items={archivedSessions}
|
||||
onBatchDelete={handleBatchDelete}
|
||||
/>
|
||||
<ScrollArea className="flex-1 min-h-0">
|
||||
<Section
|
||||
items={archivedSessions}
|
||||
onSelect={handleSelectSession}
|
||||
onDelete={handleDeleteSession}
|
||||
onBatchDelete={handleBatchDelete}
|
||||
onRename={openRenameDialog}
|
||||
onTogglePinned={handleTogglePinned}
|
||||
onToggleArchived={handleToggleArchived}
|
||||
activeKey={activeSessionKey}
|
||||
isSelectionMode={archivedSelectionMode}
|
||||
setIsSelectionMode={setArchivedSelectionMode}
|
||||
selectedKeys={archivedSelectedKeys}
|
||||
setSelectedKeys={setArchivedSelectedKeys}
|
||||
/>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Dialog open={renameDialogOpen} onOpenChange={setRenameDialogOpen}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
@@ -536,6 +750,31 @@ function SidebarBody() {
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={dashboardRenameDialogOpen} onOpenChange={setDashboardRenameDialogOpen}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('renameDashboard')}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="py-4">
|
||||
<Input
|
||||
value={newDashboardName}
|
||||
onChange={(e) => setNewDashboardName(e.target.value)}
|
||||
placeholder={t('enterNewDashboardName')}
|
||||
autoFocus
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
handleDashboardRename();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setDashboardRenameDialogOpen(false)}>{t('cancel')}</Button>
|
||||
<Button className="bg-indigo-600 hover:bg-indigo-700 text-white" onClick={handleDashboardRename}>{t('save')}</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<div className="p-4 border-t border-zinc-200 mt-auto relative" ref={menuRef}>
|
||||
<div className="flex items-center justify-between text-zinc-600">
|
||||
<button
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useState } from "react";
|
||||
import { useState, useEffect } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger, DialogDescription, DialogFooter } from "@/components/ui/dialog";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { Code, Table as TableIcon, BarChart as ChartIcon, Download, LayoutDashboard, Loader2 } from "lucide-react";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
@@ -16,10 +17,23 @@ export function VisualizationPanel() {
|
||||
const [view, setView] = useState<'table' | 'chart'>('chart');
|
||||
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||
const [pendingChart, setPendingChart] = useState<Omit<ChartConfig, 'layout'> | null>(null);
|
||||
const { addChart } = useDashboardStore();
|
||||
const [selectedDashboardId, setSelectedDashboardId] = useState<string>('');
|
||||
const { dashboards, addChart, loadDashboards } = useDashboardStore();
|
||||
const { currentProject } = useProjectStore();
|
||||
const { currentData, currentSQL, currentChartSpec, currentChartInfo, isLoading, error } = useVisualizationStore();
|
||||
|
||||
useEffect(() => {
|
||||
if (currentProject) {
|
||||
loadDashboards(currentProject.id);
|
||||
}
|
||||
}, [currentProject, loadDashboards]);
|
||||
|
||||
useEffect(() => {
|
||||
if (dashboards.length > 0 && !selectedDashboardId) {
|
||||
setSelectedDashboardId(dashboards[0].id);
|
||||
}
|
||||
}, [dashboards, selectedDashboardId]);
|
||||
|
||||
const buildPendingChart = (): Omit<ChartConfig, 'layout'> | null => {
|
||||
if (!currentData || !currentSQL) return null;
|
||||
if (view === "table") {
|
||||
@@ -54,8 +68,8 @@ export function VisualizationPanel() {
|
||||
};
|
||||
|
||||
const handleConfirmAdd = () => {
|
||||
if (!pendingChart || !currentProject) return;
|
||||
addChart(pendingChart, currentProject.id);
|
||||
if (!pendingChart || !currentProject || !selectedDashboardId) return;
|
||||
addChart(pendingChart, selectedDashboardId, currentProject.id);
|
||||
setConfirmOpen(false);
|
||||
setPendingChart(null);
|
||||
};
|
||||
@@ -127,7 +141,7 @@ export function VisualizationPanel() {
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Button variant="outline" size="sm" className="h-7 text-xs" onClick={handleAddToDashboard}>
|
||||
<Button variant="outline" size="sm" className="h-7 text-xs" onClick={handleAddToDashboard} disabled={dashboards.length === 0}>
|
||||
<LayoutDashboard className="h-3.5 w-3.5 mr-1.5" />
|
||||
Add to Dashboard
|
||||
</Button>
|
||||
@@ -208,11 +222,26 @@ export function VisualizationPanel() {
|
||||
<Dialog open={confirmOpen} onOpenChange={setConfirmOpen}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('confirmAddToDashboard')}</DialogTitle>
|
||||
<DialogTitle>{t('pinChartToDashboard')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t('confirmAddChartToDashboardDesc')}
|
||||
{t('selectDashboardToPin')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="py-4">
|
||||
<label className="text-sm font-medium mb-2 block">{t('dashboardMenu')}</label>
|
||||
<Select value={selectedDashboardId} onValueChange={(val) => { if (val) setSelectedDashboardId(val); }}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={t('selectDashboard')}>
|
||||
{dashboards.find(d => d.id === selectedDashboardId)?.name || t('selectDashboard')}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{dashboards.map(d => (
|
||||
<SelectItem key={d.id} value={d.id}>{d.name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -223,7 +252,7 @@ export function VisualizationPanel() {
|
||||
>
|
||||
{t('cancel')}
|
||||
</Button>
|
||||
<Button onClick={handleConfirmAdd}>{t('confirmAdd')}</Button>
|
||||
<Button onClick={handleConfirmAdd} disabled={!selectedDashboardId}>{t('submit')}</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@@ -10,18 +10,18 @@
|
||||
"unarchive": "Unarchive",
|
||||
"deleteSession": "Delete Session",
|
||||
"confirmDeleteSession": "Are you sure you want to delete this session?",
|
||||
"confirmBatchDeleteSessions": "Are you sure you want to delete the selected {{count}} sessions?",
|
||||
"filterSessionName": "Filter session name",
|
||||
"renameSession": "Rename Session",
|
||||
"enterNewSessionTitle": "Enter new session title",
|
||||
"confirmBatchDeleteSessions": "Are you sure you want to delete the selected {{count}} threads?",
|
||||
"filterSessionName": "Filter thread name",
|
||||
"renameSession": "Rename thread",
|
||||
"enterNewSessionTitle": "Enter new thread title",
|
||||
"save": "Save",
|
||||
"lobsterDataQA": "DataClaw",
|
||||
"skillCenter": "Skill Center",
|
||||
"projectManagement": "Project Management",
|
||||
"dataSourceManagement": "Data Sources",
|
||||
"personalSettings": "Settings",
|
||||
"modelConfig": "Model Config",
|
||||
"userManagement": "Users",
|
||||
"dataSourceManagement": "Data Source Management",
|
||||
"personalSettings": "Personal Settings",
|
||||
"modelConfig": "Model Configuration",
|
||||
"userManagement": "User Management",
|
||||
"logout": "Logout",
|
||||
"searchModel": "Search model...",
|
||||
"modelNotFound": "Model not found",
|
||||
@@ -204,5 +204,19 @@
|
||||
"newThread": "New Thread",
|
||||
"threads": "THREADS",
|
||||
"archivedThreads": "ARCHIVED THREADS",
|
||||
"defaultUser": "User"
|
||||
"defaultUser": "Default User",
|
||||
"searchSkills": "Search skills...",
|
||||
"selectDashboard": "Select a dashboard",
|
||||
"submit": "Submit",
|
||||
"noDashboardsInCurrentProject": "No dashboards in current project",
|
||||
"createDashboardToGetStarted": "Create a new dashboard from the sidebar to get started",
|
||||
"confirmDeleteDashboard": "Are you sure you want to delete this dashboard?",
|
||||
"renameDashboard": "Rename Dashboard",
|
||||
"enterNewDashboardName": "Enter new dashboard name",
|
||||
"newDashboardNameDefault": "New Dashboard",
|
||||
"dashboardLimitReached": "You can only create up to 3 dashboards.",
|
||||
"dashboards": "Dashboards",
|
||||
"new": "New",
|
||||
"pinChartToDashboard": "Pin chart to dashboard",
|
||||
"selectDashboardToPin": "Select a dashboard to pin this chart to."
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"selectAllOrCancel": "全选/取消全选",
|
||||
"selectAllOrCancel": "全选 / 取消全选",
|
||||
"invertSelection": "反选",
|
||||
"batchDelete": "批量删除",
|
||||
"cancel": "取消",
|
||||
@@ -11,6 +11,19 @@
|
||||
"deleteSession": "删除会话",
|
||||
"confirmDeleteSession": "确定要删除这个会话吗?",
|
||||
"confirmBatchDeleteSessions": "确定要删除选中的 {{count}} 个会话吗?",
|
||||
"confirmDeleteDashboard": "确定要删除此仪表盘吗?",
|
||||
"renameDashboard": "重命名仪表盘",
|
||||
"enterNewDashboardName": "输入新的仪表盘名称",
|
||||
"newDashboardNameDefault": "新仪表盘",
|
||||
"dashboardLimitReached": "最多只能创建 3 个仪表盘。",
|
||||
"dashboards": "仪表盘",
|
||||
"new": "新建",
|
||||
"pinChartToDashboard": "固定图表到仪表盘",
|
||||
"selectDashboardToPin": "选择一个仪表盘以固定此图表。",
|
||||
"selectDashboard": "选择一个仪表盘",
|
||||
"submit": "提交",
|
||||
"noDashboardsInCurrentProject": "当前项目下没有仪表盘",
|
||||
"createDashboardToGetStarted": "从侧边栏创建一个新仪表盘以开始",
|
||||
"filterSessionName": "过滤会话名称",
|
||||
"renameSession": "重命名会话",
|
||||
"enterNewSessionTitle": "输入新的会话标题",
|
||||
@@ -204,5 +217,6 @@
|
||||
"newThread": "新会话",
|
||||
"threads": "会话",
|
||||
"archivedThreads": "已归档会话",
|
||||
"defaultUser": "用户"
|
||||
"defaultUser": "默认用户",
|
||||
"searchSkills": "搜索技能..."
|
||||
}
|
||||
|
||||
@@ -45,14 +45,20 @@ function inferChartKeys(data: Record<string, unknown>[]) {
|
||||
|
||||
export function Dashboard() {
|
||||
const { t } = useTranslation();
|
||||
const { charts, removeChart, updateLayout, loadCharts } = useDashboardStore();
|
||||
const { dashboards, activeDashboardId, removeChart, updateLayout, loadDashboards } = useDashboardStore();
|
||||
const { currentProject } = useProjectStore();
|
||||
|
||||
useEffect(() => {
|
||||
if (currentProject) {
|
||||
loadCharts(currentProject.id);
|
||||
loadDashboards(currentProject.id);
|
||||
}
|
||||
}, [currentProject, loadCharts]);
|
||||
}, [currentProject, loadDashboards]);
|
||||
|
||||
const activeDashboard = useMemo(() => {
|
||||
return dashboards.find((d) => d.id === activeDashboardId) || dashboards[0] || null;
|
||||
}, [dashboards, activeDashboardId]);
|
||||
|
||||
const charts = activeDashboard?.charts || [];
|
||||
|
||||
const ResponsiveGridLayout = useMemo(
|
||||
() => WidthProvider(Responsive as any) as any,
|
||||
@@ -64,7 +70,7 @@ export function Dashboard() {
|
||||
}), [charts]);
|
||||
|
||||
const onLayoutChange = (currentLayout: any[]) => {
|
||||
if (currentProject) {
|
||||
if (currentProject && activeDashboard) {
|
||||
updateLayout(
|
||||
currentLayout.map((item) => ({
|
||||
i: item.i,
|
||||
@@ -73,6 +79,7 @@ export function Dashboard() {
|
||||
w: item.w,
|
||||
h: item.h,
|
||||
})),
|
||||
activeDashboard.id,
|
||||
currentProject.id
|
||||
);
|
||||
}
|
||||
@@ -86,7 +93,16 @@ export function Dashboard() {
|
||||
);
|
||||
}
|
||||
|
||||
if (charts.length === 0) {
|
||||
if (dashboards.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full text-muted-foreground">
|
||||
<p>{t('noDashboardsInCurrentProject')}</p>
|
||||
<p className="text-sm">{t('createDashboardToGetStarted')}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!activeDashboard || charts.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full text-muted-foreground">
|
||||
<p>{t('noChartsInCurrentProject')}</p>
|
||||
@@ -97,7 +113,7 @@ export function Dashboard() {
|
||||
|
||||
return (
|
||||
<div className="p-4 h-full overflow-y-auto">
|
||||
<h1 className="text-2xl font-bold mb-4">Dashboard</h1>
|
||||
<h1 className="text-2xl font-bold mb-4">{activeDashboard.name || t('dashboardMenu')}</h1>
|
||||
<ResponsiveGridLayout
|
||||
className="layout"
|
||||
layouts={layouts}
|
||||
@@ -129,7 +145,7 @@ export function Dashboard() {
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
onClick={() => removeChart(chart.id, currentProject.id)}
|
||||
onClick={() => removeChart(chart.id, activeDashboard.id, currentProject.id)}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
@@ -14,80 +14,164 @@ export interface ChartConfig {
|
||||
layout: GridLayout;
|
||||
}
|
||||
|
||||
interface DashboardState {
|
||||
export interface DashboardConfig {
|
||||
id: string;
|
||||
name: string;
|
||||
createdAt: number;
|
||||
charts: ChartConfig[];
|
||||
addChart: (chart: Omit<ChartConfig, 'layout'>, projectId: number) => void;
|
||||
removeChart: (id: string, projectId: number) => void;
|
||||
updateLayout: (layouts: GridLayout[], projectId: number) => void;
|
||||
loadCharts: (projectId: number) => void;
|
||||
}
|
||||
|
||||
const DASHBOARD_STORAGE_KEY_PREFIX = 'dashboard_charts_v1_project_';
|
||||
interface DashboardState {
|
||||
dashboards: DashboardConfig[];
|
||||
activeDashboardId: string | null;
|
||||
loadDashboards: (projectId: number) => void;
|
||||
createDashboard: (name: string, projectId: number) => string;
|
||||
deleteDashboard: (id: string, projectId: number) => void;
|
||||
renameDashboard: (id: string, newName: string, projectId: number) => void;
|
||||
setActiveDashboard: (id: string | null) => void;
|
||||
addChart: (chart: Omit<ChartConfig, 'layout'>, dashboardId: string, projectId: number) => void;
|
||||
removeChart: (chartId: string, dashboardId: string, projectId: number) => void;
|
||||
updateLayout: (layouts: GridLayout[], dashboardId: string, projectId: number) => void;
|
||||
}
|
||||
|
||||
const DASHBOARD_STORAGE_KEY_PREFIX = 'dashboards_v2_project_';
|
||||
|
||||
function getStorageKey(projectId: number) {
|
||||
return `${DASHBOARD_STORAGE_KEY_PREFIX}${projectId}`;
|
||||
}
|
||||
|
||||
function loadChartsFromStorage(projectId: number): ChartConfig[] {
|
||||
function loadDashboardsFromStorage(projectId: number): DashboardConfig[] {
|
||||
if (typeof window === 'undefined') return [];
|
||||
try {
|
||||
const raw = window.localStorage.getItem(getStorageKey(projectId));
|
||||
if (!raw) return [];
|
||||
const parsed = JSON.parse(raw);
|
||||
if (!Array.isArray(parsed)) return [];
|
||||
return parsed
|
||||
.filter((item): item is ChartConfig => Boolean(item?.id && item?.layout))
|
||||
.map((item) => ({
|
||||
...item,
|
||||
layout: {
|
||||
i: item.layout.i,
|
||||
x: Number.isFinite(item.layout.x) ? item.layout.x : 0,
|
||||
y: Number.isFinite(item.layout.y) ? item.layout.y : 0,
|
||||
w: Number.isFinite(item.layout.w) ? item.layout.w : 4,
|
||||
h: Number.isFinite(item.layout.h) ? item.layout.h : 4,
|
||||
},
|
||||
}));
|
||||
if (raw) {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (Array.isArray(parsed)) {
|
||||
return parsed.map((d: any) => ({
|
||||
...d,
|
||||
charts: Array.isArray(d.charts) ? d.charts.map((item: any) => ({
|
||||
...item,
|
||||
layout: {
|
||||
i: item.layout?.i || item.id,
|
||||
x: Number.isFinite(item.layout?.x) ? item.layout.x : 0,
|
||||
y: Number.isFinite(item.layout?.y) ? item.layout.y : 0,
|
||||
w: Number.isFinite(item.layout?.w) ? item.layout.w : 4,
|
||||
h: Number.isFinite(item.layout?.h) ? item.layout.h : 4,
|
||||
},
|
||||
})) : []
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
// Migration from v1
|
||||
const oldRaw = window.localStorage.getItem(`dashboard_charts_v1_project_${projectId}`);
|
||||
if (oldRaw) {
|
||||
const parsed = JSON.parse(oldRaw);
|
||||
if (Array.isArray(parsed) && parsed.length > 0) {
|
||||
const defaultDashboard: DashboardConfig = {
|
||||
id: 'default',
|
||||
name: 'Default Dashboard',
|
||||
createdAt: Date.now(),
|
||||
charts: parsed.map((item: any) => ({
|
||||
...item,
|
||||
layout: {
|
||||
i: item.layout?.i || item.id,
|
||||
x: Number.isFinite(item.layout?.x) ? item.layout.x : 0,
|
||||
y: Number.isFinite(item.layout?.y) ? item.layout.y : 0,
|
||||
w: Number.isFinite(item.layout?.w) ? item.layout.w : 4,
|
||||
h: Number.isFinite(item.layout?.h) ? item.layout.h : 4,
|
||||
},
|
||||
})),
|
||||
};
|
||||
saveDashboardsToStorage([defaultDashboard], projectId);
|
||||
return [defaultDashboard];
|
||||
}
|
||||
}
|
||||
return [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function saveChartsToStorage(charts: ChartConfig[], projectId: number) {
|
||||
function saveDashboardsToStorage(dashboards: DashboardConfig[], projectId: number) {
|
||||
if (typeof window === 'undefined') return;
|
||||
window.localStorage.setItem(getStorageKey(projectId), JSON.stringify(charts));
|
||||
window.localStorage.setItem(getStorageKey(projectId), JSON.stringify(dashboards));
|
||||
}
|
||||
|
||||
export const useDashboardStore = create<DashboardState>((set) => ({
|
||||
charts: [],
|
||||
loadCharts: (projectId) => {
|
||||
set({ charts: loadChartsFromStorage(projectId) });
|
||||
dashboards: [],
|
||||
activeDashboardId: null,
|
||||
loadDashboards: (projectId) => {
|
||||
const dashboards = loadDashboardsFromStorage(projectId);
|
||||
set({ dashboards, activeDashboardId: dashboards.length > 0 ? dashboards[0].id : null });
|
||||
},
|
||||
addChart: (chart, projectId) => set((state) => {
|
||||
const colSize = 4;
|
||||
const cols = 12 / colSize;
|
||||
const index = state.charts.length;
|
||||
const newLayout: GridLayout = {
|
||||
i: chart.id,
|
||||
x: (index % cols) * colSize,
|
||||
y: Math.floor(index / cols) * 4,
|
||||
w: colSize,
|
||||
h: 4,
|
||||
};
|
||||
const nextCharts = [...state.charts, { ...chart, layout: newLayout }];
|
||||
saveChartsToStorage(nextCharts, projectId);
|
||||
return { charts: nextCharts };
|
||||
}),
|
||||
removeChart: (id, projectId) => set((state) => {
|
||||
const nextCharts = state.charts.filter((c) => c.id !== id);
|
||||
saveChartsToStorage(nextCharts, projectId);
|
||||
return { charts: nextCharts };
|
||||
}),
|
||||
updateLayout: (layouts, projectId) => set((state) => {
|
||||
const nextCharts = state.charts.map((chart) => {
|
||||
const layout = layouts.find((l) => l.i === chart.id);
|
||||
return layout ? { ...chart, layout } : chart;
|
||||
createDashboard: (name, projectId) => {
|
||||
const newId = Date.now().toString();
|
||||
set((state) => {
|
||||
const newDashboard: DashboardConfig = {
|
||||
id: newId,
|
||||
name,
|
||||
createdAt: Date.now(),
|
||||
charts: [],
|
||||
};
|
||||
const nextDashboards = [...state.dashboards, newDashboard];
|
||||
saveDashboardsToStorage(nextDashboards, projectId);
|
||||
return { dashboards: nextDashboards, activeDashboardId: newId };
|
||||
});
|
||||
saveChartsToStorage(nextCharts, projectId);
|
||||
return { charts: nextCharts };
|
||||
return newId;
|
||||
},
|
||||
deleteDashboard: (id, projectId) => set((state) => {
|
||||
const nextDashboards = state.dashboards.filter((d) => d.id !== id);
|
||||
saveDashboardsToStorage(nextDashboards, projectId);
|
||||
return {
|
||||
dashboards: nextDashboards,
|
||||
activeDashboardId: state.activeDashboardId === id ? (nextDashboards.length > 0 ? nextDashboards[0].id : null) : state.activeDashboardId,
|
||||
};
|
||||
}),
|
||||
renameDashboard: (id, newName, projectId) => set((state) => {
|
||||
const nextDashboards = state.dashboards.map((d) => d.id === id ? { ...d, name: newName } : d);
|
||||
saveDashboardsToStorage(nextDashboards, projectId);
|
||||
return { dashboards: nextDashboards };
|
||||
}),
|
||||
setActiveDashboard: (id) => set({ activeDashboardId: id }),
|
||||
addChart: (chart, dashboardId, projectId) => set((state) => {
|
||||
const nextDashboards = state.dashboards.map((d) => {
|
||||
if (d.id !== dashboardId) return d;
|
||||
const colSize = 4;
|
||||
const cols = 12 / colSize;
|
||||
const index = d.charts.length;
|
||||
const newLayout: GridLayout = {
|
||||
i: chart.id,
|
||||
x: (index % cols) * colSize,
|
||||
y: Math.floor(index / cols) * 4,
|
||||
w: colSize,
|
||||
h: 4,
|
||||
};
|
||||
return { ...d, charts: [...d.charts, { ...chart, layout: newLayout }] };
|
||||
});
|
||||
saveDashboardsToStorage(nextDashboards, projectId);
|
||||
return { dashboards: nextDashboards };
|
||||
}),
|
||||
removeChart: (chartId, dashboardId, projectId) => set((state) => {
|
||||
const nextDashboards = state.dashboards.map((d) => {
|
||||
if (d.id !== dashboardId) return d;
|
||||
return { ...d, charts: d.charts.filter((c) => c.id !== chartId) };
|
||||
});
|
||||
saveDashboardsToStorage(nextDashboards, projectId);
|
||||
return { dashboards: nextDashboards };
|
||||
}),
|
||||
updateLayout: (layouts, dashboardId, projectId) => set((state) => {
|
||||
const nextDashboards = state.dashboards.map((d) => {
|
||||
if (d.id !== dashboardId) return d;
|
||||
return {
|
||||
...d,
|
||||
charts: d.charts.map((chart) => {
|
||||
const layout = layouts.find((l) => l.i === chart.id);
|
||||
return layout ? { ...chart, layout } : chart;
|
||||
})
|
||||
};
|
||||
});
|
||||
saveDashboardsToStorage(nextDashboards, projectId);
|
||||
return { dashboards: nextDashboards };
|
||||
}),
|
||||
}));
|
||||
|
||||
Reference in New Issue
Block a user