539 lines
22 KiB
TypeScript
539 lines
22 KiB
TypeScript
import { useState, type ReactNode } from "react";
|
|
import { BarChart3, ChevronDown, Pencil, Play, Plus, Search, Trash2, X } from "lucide-react";
|
|
import type { ChartWorkspaceResponse } from "../api/chartWorkspaceApi";
|
|
|
|
const RADIUS = "rounded-[6px]";
|
|
|
|
export function LayoutButton({
|
|
theme,
|
|
active,
|
|
icon,
|
|
title,
|
|
onClick,
|
|
}: {
|
|
theme: "dark" | "light";
|
|
active: boolean;
|
|
icon: ReactNode;
|
|
title: string;
|
|
onClick: () => void;
|
|
}) {
|
|
const isDark = theme === "dark";
|
|
|
|
return (
|
|
<button
|
|
type="button"
|
|
title={title}
|
|
aria-label={title}
|
|
onClick={onClick}
|
|
className={
|
|
active
|
|
? isDark
|
|
? `${RADIUS} grid h-10 w-10 place-items-center bg-[#4FD1C5] text-[#07101B]`
|
|
: `${RADIUS} grid h-10 w-10 place-items-center bg-[#0F766E] text-white`
|
|
: isDark
|
|
? `${RADIUS} grid h-10 w-10 place-items-center border border-[#263247] bg-[#111A2B] text-[#A8B3C7] transition hover:border-[#36506D] hover:text-white`
|
|
: `${RADIUS} grid h-10 w-10 place-items-center border border-[#D7DEE8] bg-[#F8FAFC] text-slate-600 transition hover:bg-white hover:text-[#0F172A]`
|
|
}
|
|
>
|
|
{icon}
|
|
</button>
|
|
);
|
|
}
|
|
|
|
export function ModeButton({
|
|
theme,
|
|
active,
|
|
icon,
|
|
label,
|
|
onClick,
|
|
}: {
|
|
theme: "dark" | "light";
|
|
active: boolean;
|
|
icon?: ReactNode;
|
|
label: string;
|
|
onClick: () => void;
|
|
}) {
|
|
return (
|
|
<button
|
|
type="button"
|
|
onClick={onClick}
|
|
className={
|
|
active
|
|
? theme === "dark"
|
|
? `${RADIUS} flex items-center justify-center gap-2 bg-[#4FD1C5] px-3 py-2 text-xs font-black text-[#07101B]`
|
|
: `${RADIUS} flex items-center justify-center gap-2 bg-[#0F766E] px-3 py-2 text-xs font-black text-white`
|
|
: theme === "dark"
|
|
? `${RADIUS} flex items-center justify-center gap-2 border border-[#263247] bg-[#111A2B] px-3 py-2 text-xs font-bold text-[#A8B3C7]`
|
|
: `${RADIUS} flex items-center justify-center gap-2 border border-[#D7DEE8] bg-[#F8FAFC] px-3 py-2 text-xs font-bold text-slate-600`
|
|
}
|
|
>
|
|
{icon}
|
|
{label}
|
|
</button>
|
|
);
|
|
}
|
|
|
|
export function EmptyWorkspace({
|
|
theme,
|
|
canAddMoreCharts,
|
|
title = "Nenhum gráfico aberto",
|
|
description = "Abra um gráfico guardado ou crie um novo gráfico para continuar.",
|
|
addLabel = "Novo Gráfico",
|
|
savedLabel = "Abrir Guardado",
|
|
minHeightClass = "min-h-0 flex-1",
|
|
onAddChart,
|
|
onOpenSaved,
|
|
}: {
|
|
theme: "dark" | "light";
|
|
canAddMoreCharts: boolean;
|
|
title?: string;
|
|
description?: string;
|
|
addLabel?: string;
|
|
savedLabel?: string;
|
|
minHeightClass?: string;
|
|
onAddChart: () => void;
|
|
onOpenSaved: () => void;
|
|
}) {
|
|
const isDark = theme === "dark";
|
|
|
|
return (
|
|
<section
|
|
className={
|
|
isDark
|
|
? `${RADIUS} flex ${minHeightClass} items-center justify-center border border-dashed border-[#33445F] bg-[#0E1726]/70`
|
|
: `${RADIUS} flex ${minHeightClass} items-center justify-center border border-dashed border-[#CBD5E1] bg-white`
|
|
}
|
|
>
|
|
<div className="max-w-[420px] text-center">
|
|
<div
|
|
className={
|
|
isDark
|
|
? "mx-auto grid h-16 w-16 place-items-center rounded-2xl border border-[#263247] bg-[#111A2B] text-[#4FD1C5]"
|
|
: "mx-auto grid h-16 w-16 place-items-center rounded-2xl border border-[#D7DEE8] bg-[#F8FAFC] text-[#0F766E]"
|
|
}
|
|
>
|
|
<BarChart3 className="h-8 w-8" />
|
|
</div>
|
|
|
|
<h2
|
|
className={
|
|
isDark
|
|
? "mt-5 text-xl font-black text-white"
|
|
: "mt-5 text-xl font-black text-[#0F172A]"
|
|
}
|
|
>
|
|
{title}
|
|
</h2>
|
|
|
|
<p
|
|
className={
|
|
isDark
|
|
? "mt-3 text-sm leading-6 text-[#A8B3C7]"
|
|
: "mt-3 text-sm leading-6 text-slate-600"
|
|
}
|
|
>
|
|
{description}
|
|
</p>
|
|
|
|
<div className="mt-6 flex items-center justify-center gap-3">
|
|
<button
|
|
type="button"
|
|
disabled={!canAddMoreCharts}
|
|
onClick={onAddChart}
|
|
className={
|
|
canAddMoreCharts
|
|
? `inline-flex h-11 items-center gap-3 ${RADIUS} bg-[#18B8A6] px-5 text-sm font-black text-white transition hover:bg-[#21C7B5]`
|
|
: `inline-flex h-11 cursor-not-allowed items-center gap-3 ${RADIUS} bg-[#18B8A6]/40 px-5 text-sm font-black text-white`
|
|
}
|
|
>
|
|
<Plus className="h-5 w-5" />
|
|
{addLabel}
|
|
</button>
|
|
|
|
<button
|
|
type="button"
|
|
onClick={onOpenSaved}
|
|
className={
|
|
isDark
|
|
? `inline-flex h-11 items-center gap-3 ${RADIUS} border border-[#263247] bg-[#111A2B] px-5 text-sm font-black text-white`
|
|
: `inline-flex h-11 items-center gap-3 ${RADIUS} border border-[#D7DEE8] bg-[#F8FAFC] px-5 text-sm font-black text-[#0F172A]`
|
|
}
|
|
>
|
|
<Search className="h-5 w-5" />
|
|
{savedLabel}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
export function WorkspaceSelector({
|
|
theme,
|
|
workspaces,
|
|
activeWorkspaceId,
|
|
detachedWorkspaceIds,
|
|
loading,
|
|
creating,
|
|
canCreateWorkspace,
|
|
onSelectWorkspace,
|
|
onCreateWorkspace,
|
|
onRenameWorkspace,
|
|
onDeleteWorkspace,
|
|
}: {
|
|
theme: "dark" | "light";
|
|
workspaces: ChartWorkspaceResponse[];
|
|
activeWorkspaceId: number | null;
|
|
detachedWorkspaceIds: Set<number>;
|
|
loading: boolean;
|
|
creating: boolean;
|
|
canCreateWorkspace: boolean;
|
|
onSelectWorkspace: (workspaceId: number) => void;
|
|
onCreateWorkspace: (name: string) => void;
|
|
onRenameWorkspace: (workspaceId: number, name: string) => void | Promise<void>;
|
|
onDeleteWorkspace: (workspaceId: number) => void | Promise<void>;
|
|
}) {
|
|
const isDark = theme === "dark";
|
|
const [open, setOpen] = useState(false);
|
|
const [createOpen, setCreateOpen] = useState(false);
|
|
const [renameWorkspace, setRenameWorkspace] =
|
|
useState<ChartWorkspaceResponse | null>(null);
|
|
const [search, setSearch] = useState("");
|
|
const activeWorkspace =
|
|
workspaces.find((workspace) => workspace.id === activeWorkspaceId) ??
|
|
null;
|
|
|
|
const filteredWorkspaces = workspaces.filter((workspace) =>
|
|
workspace.name.toLowerCase().includes(search.toLowerCase()),
|
|
);
|
|
|
|
const createWorkspace = (name: string) => {
|
|
onCreateWorkspace(name);
|
|
setCreateOpen(false);
|
|
setOpen(false);
|
|
};
|
|
|
|
const renameSelectedWorkspace = (name: string) => {
|
|
if (!renameWorkspace) return;
|
|
|
|
void onRenameWorkspace(renameWorkspace.id, name);
|
|
setRenameWorkspace(null);
|
|
};
|
|
|
|
return (
|
|
<div className="relative">
|
|
<button
|
|
type="button"
|
|
disabled={loading}
|
|
onClick={() => setOpen((value) => !value)}
|
|
className={
|
|
isDark
|
|
? `flex h-10 min-w-[260px] items-center justify-between gap-3 ${RADIUS} border border-[#263247] bg-[#111A2B] px-4 text-sm font-black text-white transition hover:border-[#36506D] disabled:cursor-not-allowed disabled:text-[#7F8CA3]`
|
|
: `flex h-10 min-w-[260px] items-center justify-between gap-3 ${RADIUS} border border-[#D7DEE8] bg-[#F8FAFC] px-4 text-sm font-black text-[#0F172A] transition hover:bg-white disabled:cursor-not-allowed disabled:text-slate-400`
|
|
}
|
|
>
|
|
<span className="min-w-0 truncate">
|
|
{loading
|
|
? "A carregar workspaces..."
|
|
: activeWorkspace?.name ?? "Workspaces"}
|
|
</span>
|
|
|
|
<ChevronDown className="h-4 w-4 shrink-0" />
|
|
</button>
|
|
|
|
{open && (
|
|
<div
|
|
className={
|
|
isDark
|
|
? `${RADIUS} absolute left-0 top-[calc(100%+8px)] z-40 w-[460px] border border-[#263247] bg-[#0E1726] p-3 shadow-2xl`
|
|
: `${RADIUS} absolute left-0 top-[calc(100%+8px)] z-40 w-[460px] border border-[#D7DEE8] bg-white p-3 shadow-2xl`
|
|
}
|
|
>
|
|
<div className="mb-3 flex items-center justify-between">
|
|
<div>
|
|
<h2 className="text-sm font-black">Workspaces</h2>
|
|
<p className={isDark ? "mt-1 text-xs text-[#7F8CA3]" : "mt-1 text-xs text-slate-500"}>
|
|
{workspaces.length}/10 workspaces neste modulo.
|
|
</p>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-2">
|
|
<button
|
|
type="button"
|
|
disabled={!canCreateWorkspace || creating}
|
|
onClick={() => setCreateOpen(true)}
|
|
className={
|
|
canCreateWorkspace && !creating
|
|
? `inline-flex h-9 items-center gap-2 ${RADIUS} bg-[#18B8A6] px-3 text-xs font-black text-white transition hover:bg-[#21C7B5]`
|
|
: `inline-flex h-9 cursor-not-allowed items-center gap-2 ${RADIUS} bg-[#18B8A6]/40 px-3 text-xs font-black text-white`
|
|
}
|
|
>
|
|
<Plus className="h-4 w-4" />
|
|
Novo
|
|
</button>
|
|
|
|
<button type="button" onClick={() => setOpen(false)}>
|
|
<X className="h-4 w-4" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div
|
|
className={
|
|
isDark
|
|
? `${RADIUS} mb-3 flex h-10 items-center gap-2 border border-[#263247] bg-[#07101B] px-3 text-sm text-[#7F8CA3]`
|
|
: `${RADIUS} mb-3 flex h-10 items-center gap-2 border border-[#D7DEE8] bg-[#F8FAFC] px-3 text-sm text-slate-500`
|
|
}
|
|
>
|
|
<Search className="h-4 w-4" />
|
|
<input
|
|
value={search}
|
|
onChange={(event) => setSearch(event.target.value)}
|
|
placeholder="Pesquisar workspaces..."
|
|
className="w-full bg-transparent outline-none placeholder:text-inherit"
|
|
/>
|
|
</div>
|
|
|
|
<div className="max-h-[300px] space-y-2 overflow-y-auto pr-1">
|
|
{filteredWorkspaces.map((workspace) => {
|
|
const active = workspace.id === activeWorkspaceId;
|
|
const detached = detachedWorkspaceIds.has(workspace.id);
|
|
const chartCount = getWorkspaceChartCount(workspace);
|
|
|
|
return (
|
|
<div
|
|
key={workspace.id}
|
|
className={
|
|
isDark
|
|
? `${RADIUS} flex w-full items-center justify-between gap-3 border border-[#263247] bg-[#111A2B] px-3 py-3 transition hover:border-[#36506D]`
|
|
: `${RADIUS} flex w-full items-center justify-between gap-3 border border-[#D7DEE8] bg-[#F8FAFC] px-3 py-3 transition hover:bg-white`
|
|
}
|
|
>
|
|
<div className="min-w-0">
|
|
<p className="truncate text-sm font-black">
|
|
{workspace.name}
|
|
</p>
|
|
<p className={isDark ? "mt-1 truncate text-xs text-[#7F8CA3]" : "mt-1 truncate text-xs text-slate-500"}>
|
|
{chartCount}/10 graficos
|
|
{detached
|
|
? " · detached"
|
|
: workspace.defaultWorkspace
|
|
? " · ultimo aberto"
|
|
: ""}
|
|
</p>
|
|
</div>
|
|
|
|
<div className="flex shrink-0 items-center gap-3">
|
|
{active ? (
|
|
<span className="text-xs font-black text-[#4FD1C5]">
|
|
Aberto
|
|
</span>
|
|
) : detached ? (
|
|
<span className="text-xs font-black text-[#FACC15]">
|
|
Detached
|
|
</span>
|
|
) : (
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
onSelectWorkspace(workspace.id);
|
|
setOpen(false);
|
|
}}
|
|
title="Abrir workspace"
|
|
className="text-[#7F8CA3] transition hover:text-[#4FD1C5]"
|
|
>
|
|
<Play className="h-4 w-4" />
|
|
</button>
|
|
)}
|
|
|
|
<button
|
|
type="button"
|
|
onClick={() => setRenameWorkspace(workspace)}
|
|
title="Renomear workspace"
|
|
className="text-[#7F8CA3] transition hover:text-[#4FD1C5]"
|
|
>
|
|
<Pencil className="h-4 w-4" />
|
|
</button>
|
|
|
|
<button
|
|
type="button"
|
|
onClick={() => void onDeleteWorkspace(workspace.id)}
|
|
title="Eliminar workspace"
|
|
className="text-[#7F8CA3] transition hover:text-red-400"
|
|
>
|
|
<Trash2 className="h-4 w-4" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
|
|
{filteredWorkspaces.length === 0 && (
|
|
<div className={isDark ? `${RADIUS} border border-dashed border-[#263247] px-4 py-6 text-center text-sm font-bold text-[#7F8CA3]` : `${RADIUS} border border-dashed border-[#D7DEE8] px-4 py-6 text-center text-sm font-bold text-slate-500`}>
|
|
Nenhum workspace encontrado.
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{createOpen && (
|
|
<NewWorkspaceModal
|
|
theme={theme}
|
|
creating={creating}
|
|
onClose={() => setCreateOpen(false)}
|
|
onCreate={createWorkspace}
|
|
/>
|
|
)}
|
|
|
|
{renameWorkspace && (
|
|
<WorkspaceNameModal
|
|
theme={theme}
|
|
title="Renomear workspace"
|
|
actionLabel="Guardar"
|
|
initialName={renameWorkspace.name}
|
|
creating={false}
|
|
onClose={() => setRenameWorkspace(null)}
|
|
onSubmit={renameSelectedWorkspace}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function getWorkspaceChartCount(workspace: ChartWorkspaceResponse) {
|
|
try {
|
|
const charts = JSON.parse(workspace.chartsJson) as unknown[];
|
|
return Array.isArray(charts) ? charts.length : 0;
|
|
} catch {
|
|
return 0;
|
|
}
|
|
}
|
|
|
|
function WorkspaceNameModal({
|
|
theme,
|
|
title,
|
|
actionLabel,
|
|
initialName = "",
|
|
creating,
|
|
onClose,
|
|
onSubmit,
|
|
}: {
|
|
theme: "dark" | "light";
|
|
title: string;
|
|
actionLabel: string;
|
|
initialName?: string;
|
|
creating: boolean;
|
|
onClose: () => void;
|
|
onSubmit: (name: string) => void;
|
|
}) {
|
|
const isDark = theme === "dark";
|
|
const [name, setName] = useState(initialName);
|
|
const canCreate = name.trim().length > 0 && !creating;
|
|
|
|
return (
|
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/55 p-6">
|
|
<div
|
|
className={
|
|
isDark
|
|
? `${RADIUS} w-full max-w-[420px] border border-[#263247] bg-[#0E1726] shadow-2xl`
|
|
: `${RADIUS} w-full max-w-[420px] border border-[#D7DEE8] bg-white shadow-2xl`
|
|
}
|
|
>
|
|
<header
|
|
className={
|
|
isDark
|
|
? "flex items-center justify-between border-b border-[#263247] px-5 py-4"
|
|
: "flex items-center justify-between border-b border-[#D7DEE8] px-5 py-4"
|
|
}
|
|
>
|
|
<h2 className="text-sm font-black">{title}</h2>
|
|
|
|
<button type="button" onClick={onClose}>
|
|
<X className="h-4 w-4" />
|
|
</button>
|
|
</header>
|
|
|
|
<main className="p-5">
|
|
<label className="mb-2 block text-sm font-black">
|
|
Nome
|
|
</label>
|
|
|
|
<input
|
|
autoFocus
|
|
value={name}
|
|
onChange={(event) => setName(event.target.value)}
|
|
onKeyDown={(event) => {
|
|
if (event.key === "Enter" && canCreate) {
|
|
onSubmit(name);
|
|
}
|
|
}}
|
|
placeholder="Ex: Estufa norte"
|
|
className={
|
|
isDark
|
|
? `${RADIUS} h-11 w-full border border-[#263247] bg-[#07101B] px-3 text-sm font-bold text-white outline-none focus:border-[#4FD1C5]`
|
|
: `${RADIUS} h-11 w-full border border-[#D7DEE8] bg-[#F8FAFC] px-3 text-sm font-bold text-[#0F172A] outline-none focus:border-[#0F766E]`
|
|
}
|
|
/>
|
|
</main>
|
|
|
|
<footer
|
|
className={
|
|
isDark
|
|
? "flex justify-end gap-3 border-t border-[#263247] px-5 py-4"
|
|
: "flex justify-end gap-3 border-t border-[#D7DEE8] px-5 py-4"
|
|
}
|
|
>
|
|
<button
|
|
type="button"
|
|
onClick={onClose}
|
|
className={
|
|
isDark
|
|
? `${RADIUS} border border-[#263247] bg-[#111A2B] px-4 py-2 text-sm font-bold text-white`
|
|
: `${RADIUS} border border-[#D7DEE8] bg-[#F8FAFC] px-4 py-2 text-sm font-bold text-[#0F172A]`
|
|
}
|
|
>
|
|
Cancelar
|
|
</button>
|
|
|
|
<button
|
|
type="button"
|
|
disabled={!canCreate}
|
|
onClick={() => onSubmit(name)}
|
|
className={
|
|
canCreate
|
|
? `${RADIUS} bg-[#18B8A6] px-4 py-2 text-sm font-black text-white transition hover:bg-[#21C7B5]`
|
|
: `${RADIUS} cursor-not-allowed bg-[#18B8A6]/40 px-4 py-2 text-sm font-black text-white`
|
|
}
|
|
>
|
|
{actionLabel}
|
|
</button>
|
|
</footer>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function NewWorkspaceModal({
|
|
theme,
|
|
creating,
|
|
onClose,
|
|
onCreate,
|
|
}: {
|
|
theme: "dark" | "light";
|
|
creating: boolean;
|
|
onClose: () => void;
|
|
onCreate: (name: string) => void;
|
|
}) {
|
|
return (
|
|
<WorkspaceNameModal
|
|
theme={theme}
|
|
title="Novo workspace"
|
|
actionLabel="Criar"
|
|
creating={creating}
|
|
onClose={onClose}
|
|
onSubmit={onCreate}
|
|
/>
|
|
);
|
|
}
|