first commit
This commit is contained in:
1 parent
abb09fbea8
commit
72f6d49818
355 files changed
+121239
-3
No files matched your search
@@ -0,0 +1,182 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { AlertTriangle, X } from "lucide-react";
|
||||
|
||||
type ConfirmActionDialogProps = {
|
||||
open: boolean;
|
||||
title: string;
|
||||
description: string;
|
||||
confirmLabel: string;
|
||||
cancelLabel?: string;
|
||||
tone?: "danger" | "warning" | "info";
|
||||
note?: string;
|
||||
icon?: ReactNode;
|
||||
onClose: () => void;
|
||||
onConfirm: () => void;
|
||||
};
|
||||
|
||||
const toneStyles = {
|
||||
danger: {
|
||||
color: "#ef4444",
|
||||
softBackground: "rgba(239,68,68,0.08)",
|
||||
strongBackground: "rgba(239,68,68,0.12)",
|
||||
border: "rgba(239,68,68,0.22)",
|
||||
lightBorder: "rgba(239,68,68,0.18)",
|
||||
},
|
||||
warning: {
|
||||
color: "#f59e0b",
|
||||
softBackground: "rgba(245,158,11,0.08)",
|
||||
strongBackground: "rgba(245,158,11,0.12)",
|
||||
border: "rgba(245,158,11,0.22)",
|
||||
lightBorder: "rgba(245,158,11,0.18)",
|
||||
},
|
||||
info: {
|
||||
color: "#3b82f6",
|
||||
softBackground: "rgba(59,130,246,0.08)",
|
||||
strongBackground: "rgba(59,130,246,0.12)",
|
||||
border: "rgba(59,130,246,0.22)",
|
||||
lightBorder: "rgba(59,130,246,0.18)",
|
||||
},
|
||||
} as const;
|
||||
|
||||
export default function ConfirmActionDialog({
|
||||
open,
|
||||
title,
|
||||
description,
|
||||
confirmLabel,
|
||||
cancelLabel = "Cancel",
|
||||
tone = "danger",
|
||||
note,
|
||||
icon,
|
||||
onClose,
|
||||
onConfirm,
|
||||
}: ConfirmActionDialogProps) {
|
||||
if (!open) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const palette = toneStyles[tone];
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: "fixed",
|
||||
inset: 0,
|
||||
background: "rgba(2, 6, 23, 0.68)",
|
||||
backdropFilter: "blur(6px)",
|
||||
zIndex: 2200,
|
||||
display: "grid",
|
||||
placeItems: "center",
|
||||
padding: 20,
|
||||
}}
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
className="card"
|
||||
style={{
|
||||
width: "min(100%, 520px)",
|
||||
padding: 0,
|
||||
overflow: "hidden",
|
||||
boxShadow: "0 28px 70px rgba(2, 6, 23, 0.46)",
|
||||
}}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div style={{ padding: 28, display: "grid", gap: 18 }}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
gap: 12,
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "grid", gap: 8 }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
|
||||
<div
|
||||
style={{
|
||||
width: 34,
|
||||
height: 34,
|
||||
borderRadius: 10,
|
||||
border: `1px solid ${palette.lightBorder}`,
|
||||
background: palette.softBackground,
|
||||
display: "grid",
|
||||
placeItems: "center",
|
||||
color: palette.color,
|
||||
}}
|
||||
>
|
||||
{icon ?? <AlertTriangle size={16} />}
|
||||
</div>
|
||||
<strong style={{ color: "var(--text-primary)", fontSize: 18 }}>
|
||||
{title}
|
||||
</strong>
|
||||
</div>
|
||||
<p
|
||||
style={{
|
||||
color: "var(--text-muted)",
|
||||
fontSize: 14,
|
||||
lineHeight: 1.7,
|
||||
maxWidth: 430,
|
||||
}}
|
||||
>
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
style={{
|
||||
background: "none",
|
||||
border: "none",
|
||||
cursor: "pointer",
|
||||
color: "var(--text-muted)",
|
||||
alignSelf: "flex-start",
|
||||
}}
|
||||
aria-label="Close confirmation dialog"
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{note ? (
|
||||
<div
|
||||
style={{
|
||||
borderRadius: 12,
|
||||
border: `1px solid ${palette.lightBorder}`,
|
||||
background: palette.softBackground,
|
||||
padding: "14px 16px",
|
||||
color: palette.color,
|
||||
fontSize: 13,
|
||||
lineHeight: 1.7,
|
||||
}}
|
||||
>
|
||||
{note}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
gap: 10,
|
||||
justifyContent: "flex-end",
|
||||
}}
|
||||
>
|
||||
<button className="btn" onClick={onClose}>
|
||||
{cancelLabel}
|
||||
</button>
|
||||
<button
|
||||
className="btn"
|
||||
onClick={onConfirm}
|
||||
style={{
|
||||
background: palette.strongBackground,
|
||||
color: palette.color,
|
||||
border: `1px solid ${palette.border}`,
|
||||
}}
|
||||
>
|
||||
{icon ?? <AlertTriangle size={14} />}
|
||||
{confirmLabel}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import Sidebar from "@/components/Sidebar";
|
||||
import Topbar from "@/components/Topbar";
|
||||
import ReadOnlyAccessNotice from "@/components/ReadOnlyAccessNotice";
|
||||
import ReadOnlyActionScope from "@/components/ReadOnlyActionScope";
|
||||
import { useCurrentUser, useRequireAuth } from "@/lib/auth-state";
|
||||
import { addPreferencesListener, getStoredPanelName } from "@/lib/preferences";
|
||||
import { getRoleCapabilities } from "@/lib/rbac";
|
||||
|
||||
interface DashboardLayoutProps {
|
||||
children: React.ReactNode;
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
}
|
||||
|
||||
export default function DashboardLayout({
|
||||
children,
|
||||
title,
|
||||
subtitle,
|
||||
}: DashboardLayoutProps) {
|
||||
const isAuthenticated = useRequireAuth();
|
||||
const currentUser = useCurrentUser();
|
||||
const roleCapabilities = getRoleCapabilities(currentUser?.role);
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const [isMobile, setIsMobile] = useState(false);
|
||||
const [mobileOpen, setMobileOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const checkMobile = () => {
|
||||
const mobile = window.innerWidth < 768;
|
||||
setIsMobile(mobile);
|
||||
if (mobile) setCollapsed(true);
|
||||
};
|
||||
checkMobile();
|
||||
window.addEventListener("resize", checkMobile);
|
||||
return () => window.removeEventListener("resize", checkMobile);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const syncTitle = () => {
|
||||
document.title = `${title} | ${getStoredPanelName()}`;
|
||||
};
|
||||
|
||||
syncTitle();
|
||||
return addPreferencesListener(syncTitle);
|
||||
}, [title]);
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", height: "100vh", overflow: "hidden" }}>
|
||||
{/* Floating particles */}
|
||||
<div className="particles-bg">
|
||||
{[...Array(8)].map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="particle"
|
||||
style={{
|
||||
left: `${10 + i * 12}%`,
|
||||
top: `${20 + (i % 3) * 25}%`,
|
||||
animationDelay: `${i * 0.5}s`,
|
||||
opacity: 0.15 + (i % 3) * 0.1,
|
||||
width: 3 + (i % 2) * 2,
|
||||
height: 3 + (i % 2) * 2,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Sidebar — handles its own backdrop overlay for mobile */}
|
||||
<Sidebar
|
||||
collapsed={collapsed}
|
||||
onToggle={() => setCollapsed(!collapsed)}
|
||||
isMobile={isMobile}
|
||||
mobileOpen={mobileOpen}
|
||||
onMobileClose={() => setMobileOpen(false)}
|
||||
/>
|
||||
|
||||
{/* Main content area */}
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
overflow: "hidden",
|
||||
minWidth: 0,
|
||||
}}
|
||||
>
|
||||
<Topbar
|
||||
title={title}
|
||||
subtitle={subtitle}
|
||||
onMobileMenuToggle={
|
||||
isMobile ? () => setMobileOpen(!mobileOpen) : undefined
|
||||
}
|
||||
/>
|
||||
<main
|
||||
className="p-4 md:p-6"
|
||||
style={{
|
||||
flex: 1,
|
||||
overflowY: "auto",
|
||||
overflowX: "hidden",
|
||||
padding: 15,
|
||||
}}
|
||||
>
|
||||
<ReadOnlyActionScope enabled={roleCapabilities.isReadOnly}>
|
||||
{roleCapabilities.isReadOnly ? <ReadOnlyAccessNotice /> : null}
|
||||
{children}
|
||||
</ReadOnlyActionScope>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, type ReactNode } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import type { UserInfo } from "@/lib/api";
|
||||
import DashboardLayout from "@/components/DashboardLayout";
|
||||
import { canAccessRoute } from "@/lib/permissions";
|
||||
import { useCurrentUser } from "@/lib/auth-state";
|
||||
|
||||
interface GuardedPageProps {
|
||||
route: string;
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
redirectSubtitle?: string;
|
||||
redirectMessage?: string;
|
||||
currentUser?: UserInfo | null;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export default function GuardedPage({
|
||||
route,
|
||||
title,
|
||||
subtitle,
|
||||
redirectSubtitle = "Redirecting to a page you can access",
|
||||
redirectMessage = "You do not have access to this page. Redirecting...",
|
||||
currentUser: currentUserProp,
|
||||
children,
|
||||
}: GuardedPageProps) {
|
||||
const router = useRouter();
|
||||
const hookUser = useCurrentUser();
|
||||
const currentUser = currentUserProp ?? hookUser;
|
||||
const canViewPage = canAccessRoute(route, currentUser?.role);
|
||||
|
||||
useEffect(() => {
|
||||
if (currentUser && !canViewPage) {
|
||||
router.replace("/");
|
||||
}
|
||||
}, [canViewPage, currentUser, router]);
|
||||
|
||||
if (currentUser && !canViewPage) {
|
||||
return (
|
||||
<DashboardLayout title={title} subtitle={redirectSubtitle}>
|
||||
<div className="card" style={{ padding: 24, textAlign: "center" }}>
|
||||
<p style={{ fontSize: 14, color: "var(--text-secondary)" }}>
|
||||
{redirectMessage}
|
||||
</p>
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<DashboardLayout title={title} subtitle={subtitle}>
|
||||
{children}
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,947 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
organizationsApi,
|
||||
type CreateOrganizationBody,
|
||||
type OrganizationRecord,
|
||||
} from "@/lib/api";
|
||||
import {
|
||||
addOrganizationStateListener,
|
||||
getStoredOrganizationId,
|
||||
setStoredOrganizationId,
|
||||
} from "@/lib/organization-state";
|
||||
import {
|
||||
Building2,
|
||||
ChevronDown,
|
||||
ChevronsUpDown,
|
||||
Pencil,
|
||||
Plus,
|
||||
Star,
|
||||
Trash2,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
interface OrganizationSwitcherProps {
|
||||
collapsed: boolean;
|
||||
canManage: boolean;
|
||||
}
|
||||
|
||||
const emptyForm: CreateOrganizationBody = {
|
||||
name: "",
|
||||
logoUrl: "",
|
||||
};
|
||||
|
||||
let organizationsCache: OrganizationRecord[] | null = null;
|
||||
let organizationsCacheUpdatedAt = 0;
|
||||
let organizationsRequest: Promise<OrganizationRecord[]> | null = null;
|
||||
|
||||
const ORGANIZATIONS_CACHE_TTL_MS = 30_000;
|
||||
|
||||
async function fetchOrganizations() {
|
||||
if (organizationsRequest) {
|
||||
return organizationsRequest;
|
||||
}
|
||||
|
||||
organizationsRequest = organizationsApi
|
||||
.list()
|
||||
.then((response) => {
|
||||
const items = response.data ?? [];
|
||||
organizationsCache = items;
|
||||
organizationsCacheUpdatedAt = Date.now();
|
||||
return items;
|
||||
})
|
||||
.finally(() => {
|
||||
organizationsRequest = null;
|
||||
});
|
||||
|
||||
return organizationsRequest;
|
||||
}
|
||||
|
||||
export default function OrganizationSwitcher({
|
||||
collapsed,
|
||||
canManage,
|
||||
}: OrganizationSwitcherProps) {
|
||||
const [isNarrowViewport, setIsNarrowViewport] = useState(false);
|
||||
const [organizations, setOrganizations] = useState<OrganizationRecord[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
const [open, setOpen] = useState(false);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [deleteTarget, setDeleteTarget] = useState<OrganizationRecord | null>(
|
||||
null,
|
||||
);
|
||||
const [deleteConfirmation, setDeleteConfirmation] = useState("");
|
||||
const [confirmDeleteStep, setConfirmDeleteStep] = useState(false);
|
||||
const [form, setForm] = useState<CreateOrganizationBody>(emptyForm);
|
||||
const rootRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const loadOrganizations = async (options?: { force?: boolean }) => {
|
||||
const cacheFresh =
|
||||
organizationsCache &&
|
||||
Date.now() - organizationsCacheUpdatedAt < ORGANIZATIONS_CACHE_TTL_MS;
|
||||
|
||||
if (organizationsCache) {
|
||||
setOrganizations(organizationsCache);
|
||||
}
|
||||
|
||||
if (!organizationsCache || options?.force) {
|
||||
setLoading(true);
|
||||
} else {
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
if (cacheFresh && !options?.force) {
|
||||
return;
|
||||
}
|
||||
|
||||
setError("");
|
||||
|
||||
try {
|
||||
const items = await fetchOrganizations();
|
||||
setOrganizations(items);
|
||||
|
||||
const storedOrganizationId = getStoredOrganizationId();
|
||||
const fallbackOrganization =
|
||||
items.find(
|
||||
(organization) => organization.id === storedOrganizationId,
|
||||
) ??
|
||||
items.find((organization) => organization.isActive) ??
|
||||
items.find((organization) => organization.isDefault) ??
|
||||
items[0];
|
||||
|
||||
if (
|
||||
fallbackOrganization?.id &&
|
||||
fallbackOrganization.id !== storedOrganizationId
|
||||
) {
|
||||
setStoredOrganizationId(fallbackOrganization.id);
|
||||
}
|
||||
} catch (err) {
|
||||
setError(
|
||||
err instanceof Error ? err.message : "Failed to load organizations",
|
||||
);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const syncViewport = () => {
|
||||
setIsNarrowViewport(window.innerWidth < 1180);
|
||||
};
|
||||
|
||||
const initialLoadTimer = window.setTimeout(() => {
|
||||
void loadOrganizations();
|
||||
syncViewport();
|
||||
}, 0);
|
||||
|
||||
const handlePointerDown = (event: MouseEvent) => {
|
||||
if (!rootRef.current?.contains(event.target as Node)) {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
const removeOrganizationListener = addOrganizationStateListener(() => {
|
||||
void loadOrganizations({ force: true });
|
||||
});
|
||||
|
||||
document.addEventListener("pointerdown", handlePointerDown);
|
||||
window.addEventListener("resize", syncViewport);
|
||||
|
||||
return () => {
|
||||
window.clearTimeout(initialLoadTimer);
|
||||
removeOrganizationListener();
|
||||
document.removeEventListener("pointerdown", handlePointerDown);
|
||||
window.removeEventListener("resize", syncViewport);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const activeOrganization = useMemo(() => {
|
||||
const storedOrganizationId = getStoredOrganizationId();
|
||||
return (
|
||||
organizations.find(
|
||||
(organization) => organization.id === storedOrganizationId,
|
||||
) ??
|
||||
organizations.find((organization) => organization.isActive) ??
|
||||
organizations[0] ??
|
||||
null
|
||||
);
|
||||
}, [organizations]);
|
||||
|
||||
const openCreateModal = () => {
|
||||
setEditingId(null);
|
||||
setForm(emptyForm);
|
||||
setModalOpen(true);
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
const openEditModal = (organization: OrganizationRecord) => {
|
||||
setEditingId(organization.id);
|
||||
setForm({ name: organization.name, logoUrl: organization.logoUrl ?? "" });
|
||||
setModalOpen(true);
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
const resetDeleteModal = () => {
|
||||
setDeleteTarget(null);
|
||||
setDeleteConfirmation("");
|
||||
setConfirmDeleteStep(false);
|
||||
};
|
||||
|
||||
const closeDeleteModal = () => {
|
||||
if (saving) {
|
||||
return;
|
||||
}
|
||||
|
||||
resetDeleteModal();
|
||||
};
|
||||
|
||||
const openDeleteModal = (organization: OrganizationRecord) => {
|
||||
setDeleteTarget(organization);
|
||||
setDeleteConfirmation("");
|
||||
setConfirmDeleteStep(false);
|
||||
setError("");
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
const handleSelect = async (organization: OrganizationRecord) => {
|
||||
if (organization.id === activeOrganization?.id) {
|
||||
setOpen(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
setError("");
|
||||
const previousOrganizationId = activeOrganization?.id;
|
||||
setStoredOrganizationId(organization.id);
|
||||
|
||||
try {
|
||||
await organizationsApi.activate(organization.id);
|
||||
setOpen(false);
|
||||
window.location.reload();
|
||||
} catch (err) {
|
||||
if (previousOrganizationId) {
|
||||
setStoredOrganizationId(previousOrganizationId);
|
||||
}
|
||||
setError(
|
||||
err instanceof Error ? err.message : "Failed to switch organization",
|
||||
);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSetDefault = async (organization: OrganizationRecord) => {
|
||||
setSaving(true);
|
||||
setError("");
|
||||
|
||||
try {
|
||||
await organizationsApi.setDefault(organization.id);
|
||||
if (organization.id === activeOrganization?.id) {
|
||||
setStoredOrganizationId(organization.id);
|
||||
}
|
||||
await loadOrganizations({ force: true });
|
||||
} catch (err) {
|
||||
setError(
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: "Failed to set default organization",
|
||||
);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (organization: OrganizationRecord) => {
|
||||
setSaving(true);
|
||||
setError("");
|
||||
const wasActive = organization.id === activeOrganization?.id;
|
||||
|
||||
try {
|
||||
await organizationsApi.remove(organization.id, {
|
||||
confirmation: "DELETE",
|
||||
});
|
||||
await loadOrganizations({ force: true });
|
||||
resetDeleteModal();
|
||||
|
||||
if (wasActive) {
|
||||
window.location.reload();
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
setError(
|
||||
err instanceof Error ? err.message : "Failed to delete organization",
|
||||
);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
setSaving(true);
|
||||
setError("");
|
||||
|
||||
try {
|
||||
const payload = {
|
||||
name: form.name.trim(),
|
||||
logoUrl: form.logoUrl?.trim() || undefined,
|
||||
};
|
||||
|
||||
if (editingId) {
|
||||
await organizationsApi.update(editingId, payload);
|
||||
} else {
|
||||
await organizationsApi.create(payload);
|
||||
}
|
||||
|
||||
setModalOpen(false);
|
||||
setEditingId(null);
|
||||
setForm(emptyForm);
|
||||
await loadOrganizations({ force: true });
|
||||
} catch (err) {
|
||||
setError(
|
||||
err instanceof Error ? err.message : "Failed to save organization",
|
||||
);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (collapsed) return null;
|
||||
|
||||
return (
|
||||
<div ref={rootRef} style={{ position: "relative", margin: "12px 8px 8px" }}>
|
||||
<button
|
||||
onClick={() => setOpen((value) => !value)}
|
||||
style={{
|
||||
width: "100%",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 10,
|
||||
padding: "10px 12px",
|
||||
borderRadius: 8,
|
||||
border: "1px solid var(--border)",
|
||||
// background: "rgba(255,255,255,0.04)",
|
||||
background: activeOrganization
|
||||
? "transparent"
|
||||
: "var(--bg-secondary)",
|
||||
color: "var(--text-primary)",
|
||||
cursor: "pointer",
|
||||
textAlign: "left",
|
||||
}}
|
||||
>
|
||||
{activeOrganization?.logoUrl ? (
|
||||
<img
|
||||
src={activeOrganization.logoUrl}
|
||||
alt={activeOrganization.name}
|
||||
style={{
|
||||
width: 20,
|
||||
height: 20,
|
||||
borderRadius: 999,
|
||||
objectFit: "cover",
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
width: 20,
|
||||
height: 20,
|
||||
borderRadius: 999,
|
||||
border: "1px solid var(--border)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
color: "var(--text-muted)",
|
||||
}}
|
||||
>
|
||||
<Building2 size={12} />
|
||||
</div>
|
||||
)}
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 12,
|
||||
color: "var(--text-muted)",
|
||||
marginBottom: 2,
|
||||
}}
|
||||
>
|
||||
Organization
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 14,
|
||||
fontWeight: 600,
|
||||
whiteSpace: "nowrap",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
}}
|
||||
>
|
||||
{activeOrganization?.name ??
|
||||
(loading ? "Loading..." : "No organization")}
|
||||
</div>
|
||||
</div>
|
||||
<ChevronsUpDown size={16} color="var(--text-muted)" />
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: isNarrowViewport ? "calc(100% + 8px)" : 0,
|
||||
left: isNarrowViewport ? 0 : "calc(100% + 14px)",
|
||||
minHeight: "250px",
|
||||
maxHeight: "350px",
|
||||
width: 300,
|
||||
background: "var(--bg-secondary)",
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: 8,
|
||||
boxShadow: "0 16px 40px rgba(0,0,0,0.22)",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
overflow: "hidden",
|
||||
padding: 12,
|
||||
zIndex: 60,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 13,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 2,
|
||||
color: "var(--text-muted)",
|
||||
padding: "2px 6px 10px",
|
||||
}}
|
||||
>
|
||||
<Building2 size={12} style={{ marginRight: 4 }} />
|
||||
Organizations
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 4,
|
||||
flex: "1 1 auto",
|
||||
minHeight: 0,
|
||||
overflowY: "auto",
|
||||
paddingRight: 4,
|
||||
}}
|
||||
>
|
||||
{organizations.map((organization) => {
|
||||
const selected = organization.id === activeOrganization?.id;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={organization.id}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => void handleSelect(organization)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault();
|
||||
void handleSelect(organization);
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
width: "100%",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 10,
|
||||
padding: "12px 10px",
|
||||
borderRadius: 12,
|
||||
background: selected
|
||||
? "rgba(59, 130, 246, 0.12)"
|
||||
: "transparent",
|
||||
color: "var(--text-primary)",
|
||||
cursor: "pointer",
|
||||
textAlign: "left",
|
||||
outline: "none",
|
||||
}}
|
||||
>
|
||||
{organization.logoUrl ? (
|
||||
<img
|
||||
src={organization.logoUrl}
|
||||
alt={organization.name}
|
||||
style={{
|
||||
width: 18,
|
||||
height: 18,
|
||||
borderRadius: 999,
|
||||
objectFit: "cover",
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
width: 18,
|
||||
height: 18,
|
||||
borderRadius: 999,
|
||||
border: "1px solid var(--border)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
color: "var(--text-muted)",
|
||||
}}
|
||||
>
|
||||
<Building2 size={11} />
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
whiteSpace: "nowrap",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
fontSize: 15,
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
{organization.name}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
title="Set default organization"
|
||||
disabled={saving}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
void handleSetDefault(organization);
|
||||
}}
|
||||
style={{
|
||||
border: "none",
|
||||
background: "transparent",
|
||||
color: organization.isDefault
|
||||
? "#f5c451"
|
||||
: "var(--text-muted)",
|
||||
cursor: "pointer",
|
||||
padding: 2,
|
||||
}}
|
||||
>
|
||||
<Star
|
||||
size={15}
|
||||
fill={organization.isDefault ? "currentColor" : "none"}
|
||||
/>
|
||||
</button>
|
||||
{canManage && (
|
||||
<button
|
||||
type="button"
|
||||
title="Edit organization"
|
||||
disabled={saving}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
openEditModal(organization);
|
||||
}}
|
||||
style={{
|
||||
border: "none",
|
||||
background: "transparent",
|
||||
color: "var(--text-muted)",
|
||||
cursor: "pointer",
|
||||
padding: 2,
|
||||
}}
|
||||
>
|
||||
<Pencil size={15} />
|
||||
</button>
|
||||
)}
|
||||
{canManage && organizations.length > 1 && (
|
||||
<button
|
||||
type="button"
|
||||
title={
|
||||
selected
|
||||
? "Current organization cannot be deleted"
|
||||
: "Delete organization"
|
||||
}
|
||||
disabled={saving || selected}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
if (selected) {
|
||||
return;
|
||||
}
|
||||
openDeleteModal(organization);
|
||||
}}
|
||||
style={{
|
||||
border: "none",
|
||||
background: "transparent",
|
||||
color: "var(--text-muted)",
|
||||
cursor: saving || selected ? "not-allowed" : "pointer",
|
||||
opacity: selected ? 0.45 : 1,
|
||||
padding: 2,
|
||||
}}
|
||||
>
|
||||
<Trash2 size={15} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{canManage && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={openCreateModal}
|
||||
disabled={saving}
|
||||
style={{
|
||||
marginTop: 10,
|
||||
width: "100%",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
padding: "8px 6px",
|
||||
borderRadius: 7,
|
||||
border: "1px solid var(--border)",
|
||||
background: "transparent",
|
||||
color: "var(--text-secondary)",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
<Plus size={14} /> Add organization
|
||||
</button>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div style={{ marginTop: 8, fontSize: 12, color: "#ef4444" }}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{deleteTarget && (
|
||||
<div
|
||||
style={{
|
||||
position: "fixed",
|
||||
inset: 0,
|
||||
background: "rgba(2, 6, 23, 0.68)",
|
||||
display: "grid",
|
||||
placeItems: "center",
|
||||
padding: 20,
|
||||
zIndex: 90,
|
||||
}}
|
||||
onClick={closeDeleteModal}
|
||||
>
|
||||
<div
|
||||
className="card"
|
||||
style={{
|
||||
width: "min(100%, 520px)",
|
||||
padding: 0,
|
||||
overflow: "hidden",
|
||||
boxShadow: "0 28px 70px rgba(2, 6, 23, 0.46)",
|
||||
}}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
{!confirmDeleteStep ? (
|
||||
<div style={{ padding: 28, display: "grid", gap: 18 }}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
gap: 12,
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "grid", gap: 8 }}>
|
||||
<div
|
||||
style={{ display: "flex", alignItems: "center", gap: 10 }}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: 34,
|
||||
height: 34,
|
||||
borderRadius: 10,
|
||||
border: "1px solid rgba(239,68,68,0.2)",
|
||||
background: "rgba(239,68,68,0.08)",
|
||||
display: "grid",
|
||||
placeItems: "center",
|
||||
color: "#ef4444",
|
||||
}}
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</div>
|
||||
<strong
|
||||
style={{ color: "var(--text-primary)", fontSize: 18 }}
|
||||
>
|
||||
Delete Organization
|
||||
</strong>
|
||||
</div>
|
||||
<p
|
||||
style={{
|
||||
color: "var(--text-muted)",
|
||||
fontSize: 14,
|
||||
lineHeight: 1.7,
|
||||
maxWidth: 430,
|
||||
}}
|
||||
>
|
||||
Type DELETE to unlock the final removal confirmation. This
|
||||
will delete organization data and related records in the
|
||||
database, including servers, containers, domains, SSL,
|
||||
networks, backups, security presets, installed apps,
|
||||
invitations, API keys, and linked access records.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={closeDeleteModal}
|
||||
disabled={saving}
|
||||
style={{
|
||||
background: "none",
|
||||
border: "none",
|
||||
cursor: saving ? "not-allowed" : "pointer",
|
||||
color: "var(--text-muted)",
|
||||
alignSelf: "flex-start",
|
||||
}}
|
||||
aria-label="Close delete organization dialog"
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
borderRadius: 12,
|
||||
border: "1px solid rgba(239,68,68,0.18)",
|
||||
background: "rgba(239,68,68,0.08)",
|
||||
padding: "14px 16px",
|
||||
color: "#f87171",
|
||||
fontSize: 13,
|
||||
lineHeight: 1.7,
|
||||
}}
|
||||
>
|
||||
Target: {deleteTarget.name}. If some related records do not
|
||||
exist in the database, they will be skipped automatically.
|
||||
</div>
|
||||
|
||||
<input
|
||||
className="input"
|
||||
placeholder="Type DELETE to confirm"
|
||||
value={deleteConfirmation}
|
||||
onChange={(event) => {
|
||||
const nextValue = event.target.value;
|
||||
setDeleteConfirmation(nextValue);
|
||||
if (nextValue.trim() !== "DELETE") {
|
||||
setConfirmDeleteStep(false);
|
||||
}
|
||||
}}
|
||||
autoFocus
|
||||
/>
|
||||
|
||||
<div style={{ display: "flex", justifyContent: "flex-start" }}>
|
||||
<button
|
||||
className="btn btn-sm"
|
||||
onClick={() => setConfirmDeleteStep(true)}
|
||||
disabled={deleteConfirmation.trim() !== "DELETE"}
|
||||
style={{
|
||||
background: "rgba(239,68,68,0.12)",
|
||||
color: "#ef4444",
|
||||
border: "1px solid rgba(239,68,68,0.22)",
|
||||
}}
|
||||
>
|
||||
<Trash2 size={14} /> Continue
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ padding: 28, display: "grid", gap: 18 }}>
|
||||
<div style={{ display: "grid", gap: 8 }}>
|
||||
<strong
|
||||
style={{ color: "var(--text-primary)", fontSize: 18 }}
|
||||
>
|
||||
Delete Organization
|
||||
</strong>
|
||||
<p
|
||||
style={{
|
||||
color: "var(--text-muted)",
|
||||
fontSize: 14,
|
||||
lineHeight: 1.7,
|
||||
}}
|
||||
>
|
||||
This will permanently remove the organization record and all
|
||||
related database data tied to {deleteTarget.name}. This
|
||||
action cannot be undone.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
borderRadius: 12,
|
||||
padding: "14px 16px",
|
||||
fontSize: 13,
|
||||
color: "#ef4444",
|
||||
background: "rgba(239,68,68,0.08)",
|
||||
border: "1px solid rgba(239,68,68,0.24)",
|
||||
lineHeight: 1.7,
|
||||
}}
|
||||
>
|
||||
Confirm this action only if you expect permanent database
|
||||
cleanup for this organization and its related records.
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
gap: 10,
|
||||
justifyContent: "flex-end",
|
||||
}}
|
||||
>
|
||||
<button
|
||||
className="btn"
|
||||
onClick={() => setConfirmDeleteStep(false)}
|
||||
disabled={saving}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
className="btn"
|
||||
onClick={() => void handleDelete(deleteTarget)}
|
||||
disabled={saving}
|
||||
style={{
|
||||
background: "rgba(239,68,68,0.12)",
|
||||
color: "#ef4444",
|
||||
border: "1px solid rgba(239,68,68,0.22)",
|
||||
}}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
{saving ? "Deleting..." : "Delete Organization"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{modalOpen && (
|
||||
<div
|
||||
style={{
|
||||
position: "fixed",
|
||||
inset: 0,
|
||||
background: "rgba(10, 14, 23, 0.7)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
padding: 20,
|
||||
zIndex: 80,
|
||||
}}
|
||||
>
|
||||
<form
|
||||
onSubmit={(event) => void handleSubmit(event)}
|
||||
style={{
|
||||
width: "100%",
|
||||
maxWidth: 520,
|
||||
background: "var(--bg-secondary)",
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: 18,
|
||||
padding: 24,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 18,
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 28,
|
||||
fontWeight: 700,
|
||||
color: "var(--text-primary)",
|
||||
}}
|
||||
>
|
||||
{editingId ? "Edit organization" : "Add organization"}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
marginTop: 6,
|
||||
fontSize: 14,
|
||||
color: "var(--text-muted)",
|
||||
}}
|
||||
>
|
||||
Create a separate workspace for projects, domains, apps,
|
||||
security, invitations, and API keys.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 14,
|
||||
fontWeight: 600,
|
||||
color: "var(--text-primary)",
|
||||
}}
|
||||
>
|
||||
Name *
|
||||
</span>
|
||||
<input
|
||||
className="input"
|
||||
value={form.name}
|
||||
onChange={(event) =>
|
||||
setForm((current) => ({
|
||||
...current,
|
||||
name: event.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder="Organization name"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 14,
|
||||
fontWeight: 600,
|
||||
color: "var(--text-primary)",
|
||||
}}
|
||||
>
|
||||
Logo URL
|
||||
</span>
|
||||
<input
|
||||
className="input"
|
||||
value={form.logoUrl ?? ""}
|
||||
onChange={(event) =>
|
||||
setForm((current) => ({
|
||||
...current,
|
||||
logoUrl: event.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder="https://example.com/logo.png"
|
||||
/>
|
||||
</label>
|
||||
|
||||
{error && (
|
||||
<div style={{ fontSize: 13, color: "#ef4444" }}>{error}</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
style={{ display: "flex", justifyContent: "flex-end", gap: 10 }}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost"
|
||||
onClick={() => {
|
||||
setModalOpen(false);
|
||||
setEditingId(null);
|
||||
setForm(emptyForm);
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="btn btn-primary"
|
||||
disabled={saving}
|
||||
>
|
||||
{saving
|
||||
? "Saving..."
|
||||
: editingId
|
||||
? "Save changes"
|
||||
: "Create organization"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,518 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Check,
|
||||
Circle,
|
||||
ImageIcon,
|
||||
Loader2,
|
||||
ScrollText,
|
||||
Terminal,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
type ReactNode,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import Image from "next/image";
|
||||
import { sanitizeLogText } from "@/lib/terminal-output";
|
||||
|
||||
export type ProcessLogStepStatus =
|
||||
| "pending"
|
||||
| "running"
|
||||
| "success"
|
||||
| "error"
|
||||
| "skipped";
|
||||
|
||||
export type ProcessLogStep = {
|
||||
id: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
progress?: number | string;
|
||||
status?: ProcessLogStepStatus;
|
||||
};
|
||||
|
||||
export type ProcessLogsModalState = {
|
||||
title: string;
|
||||
description?: string;
|
||||
timelineLogs?: ProcessLogStep[];
|
||||
terminalLogs?: string | string[];
|
||||
imageAlt?: string;
|
||||
imageUrl?: string;
|
||||
imageSlot?: ReactNode;
|
||||
initialTab?: ProcessLogsModalTab;
|
||||
statusLabel?: string;
|
||||
};
|
||||
|
||||
export type ProcessLogsModalTab = "timeline" | "terminal";
|
||||
|
||||
type ProcessLogsModalProps = ProcessLogsModalState & {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
closeOnOverlayClick?: boolean;
|
||||
};
|
||||
|
||||
const defaultTimelineLogs: ProcessLogStep[] = [
|
||||
{
|
||||
id: "queued",
|
||||
label: "Waiting for process data",
|
||||
progress: 0,
|
||||
status: "running",
|
||||
},
|
||||
];
|
||||
|
||||
const statusTone: Record<
|
||||
ProcessLogStepStatus,
|
||||
{
|
||||
color: string;
|
||||
border: string;
|
||||
background: string;
|
||||
icon: ReactNode;
|
||||
}
|
||||
> = {
|
||||
success: {
|
||||
color: "#16a34a",
|
||||
border: "rgba(22, 163, 74, 0.55)",
|
||||
background: "rgba(34, 197, 94, 0.2)",
|
||||
icon: <Check size={13} strokeWidth={3} />,
|
||||
},
|
||||
running: {
|
||||
color: "#16a34a",
|
||||
border: "rgba(22, 163, 74, 0.55)",
|
||||
background: "rgba(34, 197, 94, 0.18)",
|
||||
icon: <Loader2 size={13} className="animate-spin" />,
|
||||
},
|
||||
pending: {
|
||||
color: "#8b949e",
|
||||
border: "rgba(148, 163, 184, 0.48)",
|
||||
background: "rgba(148, 163, 184, 0.12)",
|
||||
icon: <Circle size={10} />,
|
||||
},
|
||||
skipped: {
|
||||
color: "#8b949e",
|
||||
border: "rgba(148, 163, 184, 0.42)",
|
||||
background: "rgba(148, 163, 184, 0.1)",
|
||||
icon: <Circle size={10} />,
|
||||
},
|
||||
error: {
|
||||
color: "#ef4444",
|
||||
border: "rgba(239, 68, 68, 0.58)",
|
||||
background: "rgba(239, 68, 68, 0.16)",
|
||||
icon: <X size={13} strokeWidth={3} />,
|
||||
},
|
||||
};
|
||||
|
||||
function formatProgress(progress: ProcessLogStep["progress"]) {
|
||||
if (progress === undefined || progress === null || progress === "") {
|
||||
return "";
|
||||
}
|
||||
|
||||
return typeof progress === "number" ? `${progress}%` : progress;
|
||||
}
|
||||
|
||||
export function useProcessLogsModal() {
|
||||
const [modalState, setModalState] = useState<ProcessLogsModalState | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const openProcessLogs = useCallback((state: ProcessLogsModalState) => {
|
||||
setModalState(state);
|
||||
}, []);
|
||||
|
||||
const updateProcessLogs = useCallback(
|
||||
(nextState: Partial<ProcessLogsModalState>) => {
|
||||
setModalState((current) =>
|
||||
current
|
||||
? { ...current, ...nextState }
|
||||
: (nextState as ProcessLogsModalState),
|
||||
);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const closeProcessLogs = useCallback(() => {
|
||||
setModalState(null);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
modalState,
|
||||
isProcessLogsOpen: modalState !== null,
|
||||
openProcessLogs,
|
||||
updateProcessLogs,
|
||||
closeProcessLogs,
|
||||
};
|
||||
}
|
||||
|
||||
export default function ProcessLogsModal({
|
||||
open,
|
||||
title,
|
||||
description = "Detail of the request log entry",
|
||||
timelineLogs = defaultTimelineLogs,
|
||||
terminalLogs = "",
|
||||
imageAlt = "Process preview",
|
||||
imageUrl,
|
||||
imageSlot,
|
||||
initialTab = "timeline",
|
||||
statusLabel,
|
||||
closeOnOverlayClick = true,
|
||||
onClose,
|
||||
}: ProcessLogsModalProps) {
|
||||
const [selectedTab, setSelectedTab] = useState<ProcessLogsModalTab | null>(
|
||||
null,
|
||||
);
|
||||
const terminalViewportRef = useRef<HTMLPreElement>(null);
|
||||
const activeTab = selectedTab ?? initialTab;
|
||||
|
||||
const sanitizedTerminalLogs = useMemo(() => {
|
||||
const rawLogs = Array.isArray(terminalLogs)
|
||||
? terminalLogs.join("\n")
|
||||
: terminalLogs;
|
||||
|
||||
return sanitizeLogText(rawLogs || "No terminal logs available yet.");
|
||||
}, [terminalLogs]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || activeTab !== "terminal") return;
|
||||
|
||||
const viewport = terminalViewportRef.current;
|
||||
if (!viewport) return;
|
||||
|
||||
viewport.scrollTop = viewport.scrollHeight;
|
||||
}, [activeTab, open, sanitizedTerminalLogs]);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
setSelectedTab(null);
|
||||
onClose();
|
||||
}, [onClose]);
|
||||
|
||||
if (!open) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const latestActiveStep =
|
||||
[...timelineLogs]
|
||||
.reverse()
|
||||
.find((step) => step.status === "running" || step.status === "success") ??
|
||||
timelineLogs[0];
|
||||
const progressLabel =
|
||||
statusLabel ?? formatProgress(latestActiveStep?.progress);
|
||||
const progressBarWidth =
|
||||
typeof latestActiveStep?.progress === "number"
|
||||
? `${Math.min(Math.max(latestActiveStep.progress, 0), 100)}%`
|
||||
: latestActiveStep?.status === "running"
|
||||
? "50%"
|
||||
: "100%";
|
||||
|
||||
return (
|
||||
<div
|
||||
className="modal-overlay"
|
||||
onClick={closeOnOverlayClick ? handleClose : undefined}
|
||||
>
|
||||
<div
|
||||
className="modal animate-slide-in"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="process-logs-modal-title"
|
||||
style={{
|
||||
width: "min(100%, 620px)",
|
||||
maxWidth: 620,
|
||||
padding: 0,
|
||||
overflow: "hidden",
|
||||
maxHeight: "92vh",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
borderRadius: 18,
|
||||
}}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "flex-start",
|
||||
justifyContent: "space-between",
|
||||
gap: 14,
|
||||
padding: "22px 26px 14px",
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "flex", gap: 12, minWidth: 0 }}>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
width: 26,
|
||||
height: 26,
|
||||
borderRadius: 999,
|
||||
flex: "0 0 auto",
|
||||
border: "1px solid rgba(22,163,74,0.65)",
|
||||
background: "rgba(34,197,94,0.24)",
|
||||
}}
|
||||
/>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<h3
|
||||
id="process-logs-modal-title"
|
||||
style={{
|
||||
color: "var(--text-primary)",
|
||||
fontSize: 16,
|
||||
fontWeight: 750,
|
||||
lineHeight: 1.2,
|
||||
overflowWrap: "anywhere",
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</h3>
|
||||
<p
|
||||
style={{
|
||||
color: "var(--text-muted)",
|
||||
fontSize: 12,
|
||||
lineHeight: 1.55,
|
||||
marginTop: 4,
|
||||
overflowWrap: "anywhere",
|
||||
}}
|
||||
>
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost"
|
||||
onClick={handleClose}
|
||||
aria-label="Close process logs modal"
|
||||
style={{
|
||||
width: 25,
|
||||
height: 25,
|
||||
padding: 0,
|
||||
flex: "0 0 auto",
|
||||
justifyContent: "center",
|
||||
color: "var(--text-muted)",
|
||||
}}
|
||||
>
|
||||
<X size={21} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(2, minmax(0, 1fr))",
|
||||
gap: 12,
|
||||
padding: "8px 25px 18px",
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost"
|
||||
onClick={() => setSelectedTab("timeline")}
|
||||
style={{
|
||||
minHeight: 34,
|
||||
justifyContent: "center",
|
||||
border:
|
||||
activeTab === "timeline"
|
||||
? "1px solid rgba(59,130,246,0.38)"
|
||||
: "1px solid var(--border)",
|
||||
background:
|
||||
activeTab === "timeline"
|
||||
? "rgba(59,130,246,0.1)"
|
||||
: "var(--bg-card)",
|
||||
color: activeTab === "timeline" ? "#3b82f6" : "var(--text-muted)",
|
||||
fontWeight: 700,
|
||||
}}
|
||||
>
|
||||
<ScrollText size={14} /> Timeline
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost"
|
||||
onClick={() => setSelectedTab("terminal")}
|
||||
style={{
|
||||
minHeight: 34,
|
||||
justifyContent: "center",
|
||||
border:
|
||||
activeTab === "terminal"
|
||||
? "1px solid rgba(59,130,246,0.38)"
|
||||
: "1px solid var(--border)",
|
||||
background:
|
||||
activeTab === "terminal"
|
||||
? "rgba(59,130,246,0.1)"
|
||||
: "var(--bg-card)",
|
||||
color: activeTab === "terminal" ? "#3b82f6" : "var(--text-muted)",
|
||||
fontWeight: 700,
|
||||
}}
|
||||
>
|
||||
<Terminal size={14} /> Terminal Logs
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1, overflow: "auto", padding: "0 28px 26px" }}>
|
||||
{activeTab === "timeline" ? (
|
||||
<div style={{ display: "grid", gap: 22 }}>
|
||||
<div
|
||||
style={{
|
||||
width: 144,
|
||||
height: 115,
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: 18,
|
||||
margin: "0 auto",
|
||||
display: "grid",
|
||||
placeItems: "center",
|
||||
color: "var(--text-muted)",
|
||||
background: "var(--bg-input)",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
{imageSlot ??
|
||||
(imageUrl ? (
|
||||
<Image
|
||||
src={imageUrl}
|
||||
alt={imageAlt}
|
||||
width={144}
|
||||
height={115}
|
||||
unoptimized
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
objectFit: "cover",
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<ImageIcon size={30} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div style={{ display: "grid", gap: 14 }}>
|
||||
{timelineLogs.map((step) => {
|
||||
const status = step.status ?? "pending";
|
||||
const tone = statusTone[status];
|
||||
const stepProgress = formatProgress(step.progress);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={step.id}
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "minmax(0, 1fr) auto",
|
||||
alignItems: "center",
|
||||
gap: 14,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 10,
|
||||
minWidth: 0,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
width: 26,
|
||||
height: 26,
|
||||
borderRadius: 999,
|
||||
display: "grid",
|
||||
placeItems: "center",
|
||||
flex: "0 0 auto",
|
||||
color: tone.color,
|
||||
border: `1px solid ${tone.border}`,
|
||||
background: tone.background,
|
||||
}}
|
||||
>
|
||||
{tone.icon}
|
||||
</span>
|
||||
<span style={{ minWidth: 0 }}>
|
||||
<strong
|
||||
style={{
|
||||
display: "block",
|
||||
color: tone.color,
|
||||
fontSize: 15,
|
||||
fontWeight: 750,
|
||||
lineHeight: 1.35,
|
||||
overflowWrap: "anywhere",
|
||||
}}
|
||||
>
|
||||
{step.label}
|
||||
</strong>
|
||||
{step.description ? (
|
||||
<span
|
||||
style={{
|
||||
display: "block",
|
||||
color: "var(--text-muted)",
|
||||
fontSize: 12,
|
||||
lineHeight: 1.45,
|
||||
marginTop: 2,
|
||||
overflowWrap: "anywhere",
|
||||
}}
|
||||
>
|
||||
{step.description}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
</div>
|
||||
{stepProgress ? (
|
||||
<strong
|
||||
style={{
|
||||
color: tone.color,
|
||||
fontSize: 14,
|
||||
fontWeight: 750,
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{stepProgress}
|
||||
</strong>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{progressLabel ? (
|
||||
<div
|
||||
aria-label={`Current progress ${progressLabel}`}
|
||||
style={{
|
||||
height: 8,
|
||||
borderRadius: 999,
|
||||
background: "var(--bg-input)",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: progressBarWidth,
|
||||
height: "100%",
|
||||
borderRadius: 999,
|
||||
background: "#16a34a",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<pre
|
||||
ref={terminalViewportRef}
|
||||
className="terminal-text"
|
||||
style={{
|
||||
minHeight: 360,
|
||||
maxHeight: "56vh",
|
||||
overflow: "auto",
|
||||
margin: 0,
|
||||
padding: 18,
|
||||
borderRadius: 12,
|
||||
border: "1px solid rgba(148,163,184,0.2)",
|
||||
background: "#0d1117",
|
||||
color: "#e6edf3",
|
||||
lineHeight: 1.65,
|
||||
whiteSpace: "pre-wrap",
|
||||
wordBreak: "break-word",
|
||||
}}
|
||||
>
|
||||
{sanitizedTerminalLogs}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
interface ReadOnlyAccessNoticeProps {
|
||||
title?: string;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export default function ReadOnlyAccessNotice({
|
||||
title = "Viewer Mode Enabled",
|
||||
message = "Your role is read-only. Buttons and forms that change data are disabled on this page.",
|
||||
}: ReadOnlyAccessNoticeProps) {
|
||||
return (
|
||||
<div
|
||||
className="card"
|
||||
style={{
|
||||
padding: "12px 16px",
|
||||
marginBottom: 16,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 10,
|
||||
color: "#f59e0b",
|
||||
border: "1px solid rgba(245,158,11,0.28)",
|
||||
background: "rgba(245,158,11,0.08)",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: "50%",
|
||||
background: "currentColor",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
<div>
|
||||
<p style={{ fontSize: 12, fontWeight: 700, color: "#fbbf24" }}>
|
||||
{title}
|
||||
</p>
|
||||
<p style={{ fontSize: 12, color: "var(--text-secondary)" }}>
|
||||
{message}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, type ReactNode } from "react";
|
||||
|
||||
const WRITE_ACTION_PATTERN =
|
||||
/(add|create|deploy|save|delete|remove|edit|issue|renew|revoke|restart|stop|start|install|uninstall|reinstall|rebuild|reset|prune|sync|check all|toggle|generate|verify|test|restore|backup|reboot|terminal|choose domain)/i;
|
||||
|
||||
function markButtonLikeElement(element: HTMLElement) {
|
||||
if (element.dataset.rbacReadonlyDisabled === "true") {
|
||||
return;
|
||||
}
|
||||
|
||||
element.dataset.rbacReadonlyDisabled = "true";
|
||||
|
||||
if (
|
||||
element instanceof HTMLButtonElement ||
|
||||
element instanceof HTMLInputElement ||
|
||||
element instanceof HTMLSelectElement ||
|
||||
element instanceof HTMLTextAreaElement
|
||||
) {
|
||||
element.dataset.rbacReadonlyOriginalDisabled = String(element.disabled);
|
||||
element.disabled = true;
|
||||
return;
|
||||
}
|
||||
|
||||
element.dataset.rbacReadonlyOriginalAriaDisabled =
|
||||
element.getAttribute("aria-disabled") ?? "";
|
||||
element.dataset.rbacReadonlyOriginalTabIndex = String(element.tabIndex);
|
||||
element.dataset.rbacReadonlyOriginalPointerEvents =
|
||||
element.style.pointerEvents;
|
||||
element.dataset.rbacReadonlyOriginalOpacity = element.style.opacity;
|
||||
element.setAttribute("aria-disabled", "true");
|
||||
element.tabIndex = -1;
|
||||
element.style.pointerEvents = "none";
|
||||
element.style.opacity = "0.55";
|
||||
}
|
||||
|
||||
function restoreMarkedElement(element: HTMLElement) {
|
||||
if (element.dataset.rbacReadonlyDisabled !== "true") {
|
||||
return;
|
||||
}
|
||||
|
||||
delete element.dataset.rbacReadonlyDisabled;
|
||||
|
||||
if (
|
||||
element instanceof HTMLButtonElement ||
|
||||
element instanceof HTMLInputElement ||
|
||||
element instanceof HTMLSelectElement ||
|
||||
element instanceof HTMLTextAreaElement
|
||||
) {
|
||||
element.disabled = element.dataset.rbacReadonlyOriginalDisabled === "true";
|
||||
delete element.dataset.rbacReadonlyOriginalDisabled;
|
||||
return;
|
||||
}
|
||||
|
||||
const previousAriaDisabled = element.dataset.rbacReadonlyOriginalAriaDisabled;
|
||||
if (previousAriaDisabled) {
|
||||
element.setAttribute("aria-disabled", previousAriaDisabled);
|
||||
} else {
|
||||
element.removeAttribute("aria-disabled");
|
||||
}
|
||||
|
||||
const previousTabIndex = Number(element.dataset.rbacReadonlyOriginalTabIndex);
|
||||
if (!Number.isNaN(previousTabIndex)) {
|
||||
element.tabIndex = previousTabIndex;
|
||||
}
|
||||
|
||||
element.style.pointerEvents =
|
||||
element.dataset.rbacReadonlyOriginalPointerEvents ?? "";
|
||||
element.style.opacity = element.dataset.rbacReadonlyOriginalOpacity ?? "";
|
||||
delete element.dataset.rbacReadonlyOriginalAriaDisabled;
|
||||
delete element.dataset.rbacReadonlyOriginalTabIndex;
|
||||
delete element.dataset.rbacReadonlyOriginalPointerEvents;
|
||||
delete element.dataset.rbacReadonlyOriginalOpacity;
|
||||
}
|
||||
|
||||
function getElementActionText(element: HTMLElement) {
|
||||
return [
|
||||
element.getAttribute("title") ?? "",
|
||||
element.getAttribute("aria-label") ?? "",
|
||||
element instanceof HTMLInputElement ? element.value : "",
|
||||
element.textContent ?? "",
|
||||
]
|
||||
.join(" ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function shouldDisableElement(element: HTMLElement) {
|
||||
if (element.dataset.rbacAllowReadonly === "true") {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (element instanceof HTMLButtonElement) {
|
||||
if (element.type === "submit") {
|
||||
return true;
|
||||
}
|
||||
|
||||
const classNames = element.className;
|
||||
if (
|
||||
typeof classNames === "string" &&
|
||||
/(btn-primary|btn-danger|btn-success)/.test(classNames)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return WRITE_ACTION_PATTERN.test(getElementActionText(element));
|
||||
}
|
||||
|
||||
function disableWriteControls(scope: HTMLElement) {
|
||||
const writeControls = scope.querySelectorAll<HTMLElement>(
|
||||
"button, a.btn, input[type='submit'], input[type='button']",
|
||||
);
|
||||
|
||||
const formsToLock = new Set<HTMLFormElement>();
|
||||
|
||||
for (const control of writeControls) {
|
||||
if (!shouldDisableElement(control)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
markButtonLikeElement(control);
|
||||
|
||||
const parentForm = control.closest("form");
|
||||
if (parentForm) {
|
||||
formsToLock.add(parentForm);
|
||||
}
|
||||
}
|
||||
|
||||
for (const form of formsToLock) {
|
||||
const editableFields = form.querySelectorAll<HTMLElement>(
|
||||
"input:not([type='hidden']):not([type='button']):not([type='submit']), select, textarea",
|
||||
);
|
||||
editableFields.forEach(markButtonLikeElement);
|
||||
}
|
||||
}
|
||||
|
||||
interface ReadOnlyActionScopeProps {
|
||||
enabled: boolean;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export default function ReadOnlyActionScope({
|
||||
enabled,
|
||||
children,
|
||||
}: ReadOnlyActionScopeProps) {
|
||||
const scopeRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const scope = scopeRef.current;
|
||||
if (!scope) {
|
||||
return;
|
||||
}
|
||||
|
||||
const clearScope = () => {
|
||||
scope
|
||||
.querySelectorAll<HTMLElement>("[data-rbac-readonly-disabled='true']")
|
||||
.forEach(restoreMarkedElement);
|
||||
};
|
||||
|
||||
if (!enabled) {
|
||||
clearScope();
|
||||
return;
|
||||
}
|
||||
|
||||
disableWriteControls(scope);
|
||||
|
||||
const observer = new MutationObserver(() => {
|
||||
disableWriteControls(scope);
|
||||
});
|
||||
|
||||
observer.observe(scope, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
attributes: true,
|
||||
attributeFilter: ["class", "title", "aria-label", "disabled"],
|
||||
});
|
||||
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
clearScope();
|
||||
};
|
||||
}, [enabled]);
|
||||
|
||||
return <div ref={scopeRef}>{children}</div>;
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { Search } from "lucide-react";
|
||||
import type { ChangeEventHandler, CSSProperties } from "react";
|
||||
|
||||
type SearchFieldProps = {
|
||||
placeholder: string;
|
||||
value: string;
|
||||
onChange: ChangeEventHandler<HTMLInputElement>;
|
||||
containerStyle?: CSSProperties;
|
||||
inputStyle?: CSSProperties;
|
||||
autoComplete?: string;
|
||||
};
|
||||
|
||||
export default function SearchField({
|
||||
placeholder,
|
||||
value,
|
||||
onChange,
|
||||
containerStyle,
|
||||
inputStyle,
|
||||
autoComplete = "off",
|
||||
}: SearchFieldProps) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: "relative",
|
||||
flex: "1 1 320px",
|
||||
minWidth: 220,
|
||||
...containerStyle,
|
||||
}}
|
||||
>
|
||||
<Search
|
||||
size={13}
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: 10,
|
||||
top: "50%",
|
||||
transform: "translateY(-50%)",
|
||||
color: "var(--text-muted)",
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
/>
|
||||
<input
|
||||
className="input"
|
||||
type="text"
|
||||
name="panel-search-field"
|
||||
autoComplete={autoComplete}
|
||||
autoCorrect="off"
|
||||
autoCapitalize="none"
|
||||
spellCheck={false}
|
||||
placeholder={placeholder}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
style={{
|
||||
paddingLeft: 30,
|
||||
width: "100%",
|
||||
...inputStyle,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,329 @@
|
||||
"use client";
|
||||
|
||||
import { Check, ChevronDown, Search } from "lucide-react";
|
||||
import {
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type CSSProperties,
|
||||
} from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
|
||||
export type SearchableSelectOption = {
|
||||
value: string;
|
||||
label: string;
|
||||
keywords?: string;
|
||||
description?: string;
|
||||
};
|
||||
|
||||
type SearchableSelectProps = {
|
||||
value: string;
|
||||
options: SearchableSelectOption[];
|
||||
onChange: (value: string) => void;
|
||||
placeholder: string;
|
||||
searchPlaceholder: string;
|
||||
emptyText: string;
|
||||
disabled?: boolean;
|
||||
style?: CSSProperties;
|
||||
showSearch?: boolean;
|
||||
};
|
||||
|
||||
// Constants for menu sizing and option row height to ensure consistent layout
|
||||
const VISIBLE_OPTION_COUNT = 5;
|
||||
const OPTION_ROW_HEIGHT = 38;
|
||||
|
||||
export default function SearchableSelect({
|
||||
value,
|
||||
options,
|
||||
onChange,
|
||||
placeholder,
|
||||
searchPlaceholder,
|
||||
emptyText,
|
||||
disabled = false,
|
||||
style,
|
||||
showSearch = true,
|
||||
}: SearchableSelectProps) {
|
||||
const wrapperRef = useRef<HTMLDivElement | null>(null);
|
||||
const triggerRef = useRef<HTMLButtonElement | null>(null);
|
||||
const menuRef = useRef<HTMLDivElement | null>(null);
|
||||
const searchInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [query, setQuery] = useState("");
|
||||
const [menuStyle, setMenuStyle] = useState<CSSProperties | null>(null);
|
||||
|
||||
const selected = useMemo(
|
||||
() => options.find((option) => option.value === value) ?? null,
|
||||
[options, value],
|
||||
);
|
||||
|
||||
const filteredOptions = useMemo(() => {
|
||||
if (!showSearch) return options;
|
||||
|
||||
const normalizedQuery = query.trim().toLowerCase();
|
||||
if (!normalizedQuery) return options;
|
||||
|
||||
return options.filter((option) => {
|
||||
const haystack =
|
||||
`${option.label} ${option.keywords ?? ""} ${option.description ?? ""}`
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
return haystack.includes(normalizedQuery);
|
||||
});
|
||||
}, [options, query, showSearch]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setQuery("");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!showSearch) return;
|
||||
|
||||
const frame = window.requestAnimationFrame(() => {
|
||||
searchInputRef.current?.focus();
|
||||
});
|
||||
|
||||
return () => window.cancelAnimationFrame(frame);
|
||||
}, [open, showSearch]);
|
||||
|
||||
useEffect(() => {
|
||||
const handlePointerDown = (event: MouseEvent) => {
|
||||
const target = event.target as Node;
|
||||
if (
|
||||
!wrapperRef.current?.contains(target) &&
|
||||
!menuRef.current?.contains(target)
|
||||
) {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("mousedown", handlePointerDown);
|
||||
return () => document.removeEventListener("mousedown", handlePointerDown);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setMenuStyle(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const syncMenuPosition = () => {
|
||||
const rect = triggerRef.current?.getBoundingClientRect();
|
||||
if (!rect) return;
|
||||
|
||||
setMenuStyle({
|
||||
position: "fixed",
|
||||
top: rect.bottom + 6,
|
||||
left: rect.left,
|
||||
width: rect.width,
|
||||
zIndex: 2100,
|
||||
});
|
||||
};
|
||||
|
||||
syncMenuPosition();
|
||||
window.addEventListener("resize", syncMenuPosition);
|
||||
window.addEventListener("scroll", syncMenuPosition, true);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("resize", syncMenuPosition);
|
||||
window.removeEventListener("scroll", syncMenuPosition, true);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<div ref={wrapperRef} style={{ position: "relative", ...style }}>
|
||||
<button
|
||||
ref={triggerRef}
|
||||
type="button"
|
||||
className="input"
|
||||
disabled={disabled}
|
||||
onClick={() => {
|
||||
if (disabled) return;
|
||||
setOpen((current) => !current);
|
||||
}}
|
||||
style={{
|
||||
width: "100%",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
gap: 10,
|
||||
cursor: disabled ? "not-allowed" : "pointer",
|
||||
textAlign: "left",
|
||||
opacity: disabled ? 0.7 : 1,
|
||||
fontWeight: 400,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
color: selected ? "var(--text-primary)" : "var(--text-muted)",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{selected?.label ?? placeholder}
|
||||
</span>
|
||||
<ChevronDown size={14} style={{ color: "var(--text-muted)" }} />
|
||||
</button>
|
||||
|
||||
{open && menuStyle
|
||||
? createPortal(
|
||||
<div
|
||||
ref={menuRef}
|
||||
style={{
|
||||
...menuStyle,
|
||||
borderRadius: 6,
|
||||
border: "1px solid var(--border)",
|
||||
background: "var(--bg-card)",
|
||||
boxShadow: "0 18px 32px rgba(15, 23, 42, 0.18)",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
{showSearch ? (
|
||||
<div
|
||||
style={{
|
||||
padding: 6,
|
||||
borderBottom: "1px solid var(--border)",
|
||||
background: "var(--bg-card)",
|
||||
}}
|
||||
>
|
||||
<div style={{ position: "relative" }}>
|
||||
<Search
|
||||
size={13}
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: 10,
|
||||
top: "50%",
|
||||
transform: "translateY(-50%)",
|
||||
color: "var(--text-muted)",
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
/>
|
||||
<input
|
||||
ref={searchInputRef}
|
||||
className="input"
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
placeholder={searchPlaceholder}
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
autoCapitalize="none"
|
||||
spellCheck={false}
|
||||
style={{
|
||||
width: "100%",
|
||||
paddingLeft: 30,
|
||||
minHeight: 34,
|
||||
background: "var(--bg-input)",
|
||||
borderColor: "var(--border)",
|
||||
borderRadius: 4,
|
||||
boxShadow: "none",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div
|
||||
style={{
|
||||
maxHeight: OPTION_ROW_HEIGHT * VISIBLE_OPTION_COUNT,
|
||||
overflowY: "auto",
|
||||
padding: 0,
|
||||
background: "var(--bg-card)",
|
||||
}}
|
||||
>
|
||||
{filteredOptions.length === 0 ? (
|
||||
<div
|
||||
style={{
|
||||
padding: "10px 12px",
|
||||
minHeight: OPTION_ROW_HEIGHT,
|
||||
fontSize: 12,
|
||||
color: "var(--text-muted)",
|
||||
}}
|
||||
>
|
||||
{emptyText}
|
||||
</div>
|
||||
) : (
|
||||
filteredOptions.map((option) => {
|
||||
const active = option.value === value;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onChange(option.value);
|
||||
setOpen(false);
|
||||
}}
|
||||
style={{
|
||||
width: "100%",
|
||||
border: "none",
|
||||
borderBottom: "1px solid var(--border)",
|
||||
background: active
|
||||
? "var(--bg-card-hover)"
|
||||
: "transparent",
|
||||
color: "var(--text-primary)",
|
||||
minHeight: OPTION_ROW_HEIGHT,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
gap: 10,
|
||||
textAlign: "left",
|
||||
borderRadius: 0,
|
||||
padding: "9px 12px",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
<span style={{ minWidth: 0 }}>
|
||||
<span
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
fontSize: 13,
|
||||
lineHeight: 1.35,
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
{option.label}
|
||||
</span>
|
||||
{showSearch && option.description ? (
|
||||
<span
|
||||
style={{
|
||||
flexShrink: 0,
|
||||
color: "var(--text-secondary)",
|
||||
fontSize: 11,
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
{option.description}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
</span>
|
||||
{active ? (
|
||||
<Check
|
||||
size={14}
|
||||
style={{ color: "var(--text-primary)" }}
|
||||
/>
|
||||
) : null}
|
||||
</button>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
)
|
||||
: null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,464 @@
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import { useEffect, useState, type MouseEvent } from "react";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { auth, clearToken, redirectToLogin } from "@/lib/api";
|
||||
import { useCurrentUser } from "@/lib/auth-state";
|
||||
import OrganizationSwitcher from "@/components/OrganizationSwitcher";
|
||||
import { navigation } from "@/lib/navigation";
|
||||
import { addPreferencesListener, getStoredPanelName } from "@/lib/preferences";
|
||||
import { formatRoleLabel, hasMinimumRole } from "@/lib/permissions";
|
||||
import { LogOut, ChevronLeft, ChevronRight, X } from "lucide-react";
|
||||
|
||||
interface SidebarProps {
|
||||
/** Desktop: whether sidebar is in collapsed (icon-only) state */
|
||||
collapsed: boolean;
|
||||
/** Desktop: called when user clicks the collapse toggle */
|
||||
onToggle: () => void;
|
||||
/** Mobile: whether screen is narrow */
|
||||
isMobile: boolean;
|
||||
/** Mobile: whether the drawer is open */
|
||||
mobileOpen: boolean;
|
||||
/** Mobile: called when user closes the drawer */
|
||||
onMobileClose: () => void;
|
||||
}
|
||||
|
||||
export default function Sidebar({
|
||||
collapsed,
|
||||
onToggle,
|
||||
isMobile,
|
||||
mobileOpen,
|
||||
onMobileClose,
|
||||
}: SidebarProps) {
|
||||
const pathname = usePathname();
|
||||
const currentUser = useCurrentUser();
|
||||
const [loggingOut, setLoggingOut] = useState(false);
|
||||
const [pendingHref, setPendingHref] = useState<string | null>(null);
|
||||
const [panelName, setPanelName] = useState("DOKTAINER");
|
||||
const visibleNavigation = navigation
|
||||
.map((section) => ({
|
||||
...section,
|
||||
items: section.items.filter((item) =>
|
||||
hasMinimumRole(currentUser?.role, item.minRole),
|
||||
),
|
||||
}))
|
||||
.filter((section) => section.items.length > 0);
|
||||
|
||||
const handleLogout = async () => {
|
||||
if (loggingOut) return;
|
||||
|
||||
setLoggingOut(true);
|
||||
|
||||
try {
|
||||
await auth.logout();
|
||||
} catch {
|
||||
// Best effort server-side logout; local session is cleared regardless.
|
||||
} finally {
|
||||
clearToken();
|
||||
onMobileClose();
|
||||
redirectToLogin();
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const syncPanelName = () => {
|
||||
setPanelName(getStoredPanelName().toUpperCase());
|
||||
};
|
||||
|
||||
syncPanelName();
|
||||
return addPreferencesListener(syncPanelName);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
setPendingHref(null);
|
||||
}, 0);
|
||||
|
||||
return () => window.clearTimeout(timeoutId);
|
||||
}, [pathname]);
|
||||
|
||||
const handleNavigate = (
|
||||
event: MouseEvent<HTMLAnchorElement>,
|
||||
href: string,
|
||||
) => {
|
||||
if (
|
||||
event.defaultPrevented ||
|
||||
event.button !== 0 ||
|
||||
event.metaKey ||
|
||||
event.ctrlKey ||
|
||||
event.shiftKey ||
|
||||
event.altKey
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (href === pathname) {
|
||||
onMobileClose();
|
||||
return;
|
||||
}
|
||||
|
||||
setPendingHref(href);
|
||||
onMobileClose();
|
||||
};
|
||||
|
||||
// On mobile the sidebar is always full-width (never icon-only)
|
||||
const isCollapsed = !isMobile && collapsed;
|
||||
|
||||
const sidebar = (
|
||||
<aside
|
||||
style={{
|
||||
position: "relative",
|
||||
width: isMobile ? "240px" : isCollapsed ? "60px" : "220px",
|
||||
height: "100vh",
|
||||
background: "var(--bg-sidebar)",
|
||||
borderRight: "1px solid var(--border)",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
transition: isMobile ? "none" : "width 0.25s ease",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{/* ── Logo + collapse/close button ──────────────────────── */}
|
||||
<div
|
||||
style={{
|
||||
padding: isCollapsed ? "0 12px" : "0 12px 0 16px",
|
||||
borderBottom: "1px solid var(--border)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
height: 64,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{/* App icon */}
|
||||
<div
|
||||
style={{
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: 8,
|
||||
background: "linear-gradient(135deg, #3b82f6, #8b5cf6)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
flexShrink: 0,
|
||||
boxShadow: "0 0 12px rgba(59, 130, 246, 0.4)",
|
||||
}}
|
||||
>
|
||||
{/* <Cpu size={16} color="white" /> */}
|
||||
<Image
|
||||
src="/assets/images/favicon.png"
|
||||
alt="Logo"
|
||||
width={30}
|
||||
height={30}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Title — hidden when collapsed on desktop */}
|
||||
{!isCollapsed && (
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 13,
|
||||
fontWeight: 700,
|
||||
color: "var(--text-primary)",
|
||||
}}
|
||||
>
|
||||
{panelName}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 10,
|
||||
color: "var(--text-muted)",
|
||||
letterSpacing: "0.05em",
|
||||
}}
|
||||
>
|
||||
{`${process.env.NEXT_PUBLIC_VERSION || "unknown"} | ${process.env.NEXT_PUBLIC_BATCH || "dev"}`}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Collapse toggle (desktop) or close button (mobile) */}
|
||||
<button
|
||||
onClick={isMobile ? onMobileClose : onToggle}
|
||||
title={
|
||||
isMobile
|
||||
? "Close menu"
|
||||
: isCollapsed
|
||||
? "Expand sidebar"
|
||||
: "Collapse sidebar"
|
||||
}
|
||||
style={{
|
||||
position: isMobile ? "static" : "absolute",
|
||||
right: isMobile ? undefined : -14,
|
||||
top: isMobile ? undefined : 18,
|
||||
zIndex: 50,
|
||||
flexShrink: 0,
|
||||
width: 28,
|
||||
height: 28,
|
||||
borderRadius: "50%",
|
||||
background: "var(--bg-sidebar)",
|
||||
border: "1px solid var(--border)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
cursor: "pointer",
|
||||
color: "var(--text-muted)",
|
||||
transition: "background 0.15s, color 0.15s, border-color 0.15s",
|
||||
marginLeft: isMobile ? "auto" : undefined,
|
||||
boxShadow: isMobile ? "none" : "0 2px 8px rgba(0,0,0,0.2)",
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
const btn = e.currentTarget;
|
||||
btn.style.background = "rgba(59,130,246,0.12)";
|
||||
btn.style.borderColor = "rgba(59,130,246,0.4)";
|
||||
btn.style.color = "#3b82f6";
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
const btn = e.currentTarget;
|
||||
btn.style.background = "var(--bg-sidebar)";
|
||||
btn.style.borderColor = "var(--border)";
|
||||
btn.style.color = "var(--text-muted)";
|
||||
}}
|
||||
>
|
||||
{isMobile ? (
|
||||
<X size={13} />
|
||||
) : isCollapsed ? (
|
||||
<ChevronRight size={13} />
|
||||
) : (
|
||||
<ChevronLeft size={13} />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<OrganizationSwitcher
|
||||
collapsed={isCollapsed}
|
||||
canManage={currentUser?.role === "SUPER_ADMIN"}
|
||||
/>
|
||||
|
||||
{/* ── Navigation ──────────────────────────────────────────── */}
|
||||
<nav
|
||||
style={{
|
||||
flex: 1,
|
||||
overflowY: "auto",
|
||||
overflowX: "hidden",
|
||||
padding: "12px 8px",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
{visibleNavigation.map((section) => (
|
||||
<div key={section.label} style={{ marginBottom: 4 }}>
|
||||
{!isCollapsed && (
|
||||
<div
|
||||
style={{
|
||||
fontSize: 10,
|
||||
fontWeight: 600,
|
||||
letterSpacing: "0.08em",
|
||||
color: "var(--text-muted)",
|
||||
padding: "8px 12px 4px",
|
||||
textTransform: "uppercase",
|
||||
}}
|
||||
>
|
||||
{section.label}
|
||||
</div>
|
||||
)}
|
||||
{isCollapsed && <div style={{ height: 6 }} />}
|
||||
|
||||
{section.items.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const isActive =
|
||||
pathname === item.href || pendingHref === item.href;
|
||||
const isNavigatingToItem =
|
||||
pendingHref === item.href && pathname !== item.href;
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className="nav-item"
|
||||
data-active={isActive}
|
||||
aria-current={pathname === item.href ? "page" : undefined}
|
||||
aria-busy={isNavigatingToItem || undefined}
|
||||
onClick={(event) => handleNavigate(event, item.href)}
|
||||
style={{
|
||||
background: isActive
|
||||
? "rgba(59, 130, 246, 0.12)"
|
||||
: "transparent",
|
||||
color: isActive ? "#3b82f6" : "var(--text-secondary)",
|
||||
borderLeft: isActive
|
||||
? "2px solid #3b82f6"
|
||||
: "2px solid transparent",
|
||||
justifyContent: isCollapsed ? "center" : "flex-start",
|
||||
padding: isCollapsed
|
||||
? "9px 0"
|
||||
: isActive
|
||||
? "8px 12px 8px 10px"
|
||||
: "8px 12px",
|
||||
}}
|
||||
title={isCollapsed ? item.label : undefined}
|
||||
>
|
||||
<Icon
|
||||
size={15}
|
||||
style={{
|
||||
flexShrink: 0,
|
||||
color: isActive ? "#3b82f6" : "var(--text-muted)",
|
||||
}}
|
||||
/>
|
||||
{!isCollapsed && (
|
||||
<span style={{ fontSize: 13, fontWeight: 500 }}>
|
||||
{item.label}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Navigating indicator */}
|
||||
{isNavigatingToItem && (
|
||||
<span
|
||||
className="animate-pulse-dot"
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
width: 6,
|
||||
height: 6,
|
||||
marginLeft: isCollapsed ? 0 : "auto",
|
||||
borderRadius: "50%",
|
||||
background: "#3b82f6",
|
||||
boxShadow: "0 0 10px rgba(59, 130, 246, 0.55)",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{!isCollapsed && item.badge && (
|
||||
<span
|
||||
style={{
|
||||
marginLeft: isNavigatingToItem ? 0 : "auto",
|
||||
background: "rgba(59, 130, 246, 0.2)",
|
||||
color: "#3b82f6",
|
||||
fontSize: 10,
|
||||
fontWeight: 600,
|
||||
padding: "1px 6px",
|
||||
borderRadius: 10,
|
||||
}}
|
||||
>
|
||||
{item.badge}
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
{/* ── User footer ─────────────────────────────────────────── */}
|
||||
<div
|
||||
style={{
|
||||
padding: isCollapsed ? "12px 8px" : "12px 16px",
|
||||
borderTop: "1px solid var(--border)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 10,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: 8,
|
||||
background: "linear-gradient(135deg, #3b82f6, #8b5cf6)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
flexShrink: 0,
|
||||
fontSize: 12,
|
||||
fontWeight: 700,
|
||||
color: "white",
|
||||
}}
|
||||
>
|
||||
{(
|
||||
currentUser?.name?.charAt(0) ||
|
||||
currentUser?.email?.charAt(0) ||
|
||||
"U"
|
||||
).toUpperCase()}
|
||||
</div>
|
||||
{!isCollapsed && (
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
color: "var(--text-primary)",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{currentUser?.name || currentUser?.email || "Authenticated User"}
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: "var(--text-muted)" }}>
|
||||
{formatRoleLabel(currentUser?.role)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
onClick={() => void handleLogout()}
|
||||
disabled={loggingOut}
|
||||
style={{
|
||||
marginLeft: isCollapsed ? "auto" : 0,
|
||||
background: "transparent",
|
||||
border: "none",
|
||||
color: loggingOut ? "#3b82f6" : "var(--text-muted)",
|
||||
cursor: loggingOut ? "default" : "pointer",
|
||||
padding: 4,
|
||||
borderRadius: 6,
|
||||
opacity: loggingOut ? 0.8 : 1,
|
||||
}}
|
||||
title={loggingOut ? "Logging out..." : "Logout"}
|
||||
>
|
||||
<LogOut size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
|
||||
// ── Mobile: full-screen overlay with slide-in drawer ──────────
|
||||
if (isMobile) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: "fixed",
|
||||
inset: 0,
|
||||
zIndex: 40,
|
||||
pointerEvents: mobileOpen ? "auto" : "none",
|
||||
}}
|
||||
>
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
onClick={onMobileClose}
|
||||
style={{
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
background: "rgba(0,0,0,0.55)",
|
||||
opacity: mobileOpen ? 1 : 0,
|
||||
transition: "opacity 0.25s ease",
|
||||
}}
|
||||
/>
|
||||
{/* Drawer panel */}
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
height: "100%",
|
||||
transform: mobileOpen ? "translateX(0)" : "translateX(-100%)",
|
||||
transition: "transform 0.25s ease",
|
||||
}}
|
||||
>
|
||||
{sidebar}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Desktop: plain inline flex item ───────────────────────────
|
||||
return sidebar;
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
"use client";
|
||||
|
||||
interface TablePaginationProps {
|
||||
currentPage: number;
|
||||
totalPages: number;
|
||||
totalItems: number;
|
||||
startItem: number;
|
||||
endItem: number;
|
||||
itemLabel?: string;
|
||||
onPageChange: (page: number) => void;
|
||||
}
|
||||
|
||||
export default function TablePagination({
|
||||
currentPage,
|
||||
totalPages,
|
||||
totalItems,
|
||||
startItem,
|
||||
endItem,
|
||||
itemLabel = "items",
|
||||
onPageChange,
|
||||
}: TablePaginationProps) {
|
||||
if (totalItems === 0) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
padding: "10px 16px",
|
||||
borderTop: "1px solid var(--border)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
gap: 12,
|
||||
flexWrap: "wrap",
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: 12, color: "var(--text-muted)" }}>
|
||||
Showing {startItem}-{endItem} of {totalItems} {itemLabel}
|
||||
</span>
|
||||
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
style={{ fontSize: 12, padding: "5px 10px" }}
|
||||
onClick={() => onPageChange(currentPage - 1)}
|
||||
disabled={currentPage <= 1}
|
||||
>
|
||||
Prev
|
||||
</button>
|
||||
<span style={{ fontSize: 12, color: "var(--text-secondary)" }}>
|
||||
Page {currentPage} / {totalPages}
|
||||
</span>
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
style={{ fontSize: 12, padding: "5px 10px" }}
|
||||
onClick={() => onPageChange(currentPage + 1)}
|
||||
disabled={currentPage >= totalPages}
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import {
|
||||
addPreferencesListener,
|
||||
applyTheme,
|
||||
getStoredTheme,
|
||||
initializeSessionTimeoutWatcher,
|
||||
} from "@/lib/preferences";
|
||||
|
||||
export default function ThemeController() {
|
||||
useEffect(() => {
|
||||
const syncTheme = () => applyTheme(getStoredTheme());
|
||||
|
||||
syncTheme();
|
||||
|
||||
const media = window.matchMedia("(prefers-color-scheme: light)");
|
||||
const onThemeMediaChange = () => {
|
||||
if (getStoredTheme() === "system") {
|
||||
syncTheme();
|
||||
}
|
||||
};
|
||||
|
||||
media.addEventListener("change", onThemeMediaChange);
|
||||
window.addEventListener("pageshow", syncTheme);
|
||||
document.addEventListener("visibilitychange", syncTheme);
|
||||
const removePreferencesListener = addPreferencesListener(syncTheme);
|
||||
const stopSessionWatcher = initializeSessionTimeoutWatcher();
|
||||
|
||||
return () => {
|
||||
media.removeEventListener("change", onThemeMediaChange);
|
||||
window.removeEventListener("pageshow", syncTheme);
|
||||
document.removeEventListener("visibilitychange", syncTheme);
|
||||
removePreferencesListener();
|
||||
stopSessionWatcher();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
"use client";
|
||||
|
||||
import { AlertTriangle, CheckCircle, Info, X, XCircle } from "lucide-react";
|
||||
|
||||
export type ToastTone = "success" | "error" | "warning" | "info";
|
||||
|
||||
interface ToastProps {
|
||||
tone: ToastTone;
|
||||
title?: string;
|
||||
message: string;
|
||||
onClose?: () => void;
|
||||
duration?: number;
|
||||
showProgress?: boolean;
|
||||
}
|
||||
|
||||
const toneConfig: Record<
|
||||
ToastTone,
|
||||
{
|
||||
title: string;
|
||||
icon: typeof CheckCircle;
|
||||
border: string;
|
||||
background: string;
|
||||
titleColor: string;
|
||||
messageColor: string;
|
||||
closeColor: string;
|
||||
iconColor: string;
|
||||
}
|
||||
> = {
|
||||
success: {
|
||||
title: "Success",
|
||||
icon: CheckCircle,
|
||||
border: "1px solid rgba(16,185,129,0.35)",
|
||||
background:
|
||||
"linear-gradient(135deg, rgba(16,185,129,0.16), rgba(15,23,42,0.94))",
|
||||
titleColor: "#d1fae5",
|
||||
messageColor: "rgba(209,250,229,0.9)",
|
||||
closeColor: "rgba(209,250,229,0.75)",
|
||||
iconColor: "#10b981",
|
||||
},
|
||||
error: {
|
||||
title: "Error",
|
||||
icon: XCircle,
|
||||
border: "1px solid rgba(239,68,68,0.35)",
|
||||
background:
|
||||
"linear-gradient(135deg, rgba(239,68,68,0.18), rgba(15,23,42,0.94))",
|
||||
titleColor: "#fee2e2",
|
||||
messageColor: "rgba(254,226,226,0.9)",
|
||||
closeColor: "rgba(254,226,226,0.75)",
|
||||
iconColor: "#ef4444",
|
||||
},
|
||||
warning: {
|
||||
title: "Warning",
|
||||
icon: AlertTriangle,
|
||||
border: "1px solid rgba(245,158,11,0.35)",
|
||||
background:
|
||||
"linear-gradient(135deg, rgba(245,158,11,0.18), rgba(15,23,42,0.94))",
|
||||
titleColor: "#fef3c7",
|
||||
messageColor: "rgba(254,243,199,0.9)",
|
||||
closeColor: "rgba(254,243,199,0.75)",
|
||||
iconColor: "#f59e0b",
|
||||
},
|
||||
info: {
|
||||
title: "Info",
|
||||
icon: Info,
|
||||
border: "1px solid rgba(59,130,246,0.35)",
|
||||
background:
|
||||
"linear-gradient(135deg, rgba(59,130,246,0.18), rgba(15,23,42,0.94))",
|
||||
titleColor: "#dbeafe",
|
||||
messageColor: "rgba(219,234,254,0.9)",
|
||||
closeColor: "rgba(219,234,254,0.75)",
|
||||
iconColor: "#3b82f6",
|
||||
},
|
||||
};
|
||||
|
||||
export default function Toast({
|
||||
tone,
|
||||
title,
|
||||
message,
|
||||
onClose,
|
||||
duration,
|
||||
showProgress = false,
|
||||
}: ToastProps) {
|
||||
const config = toneConfig[tone];
|
||||
const Icon = config.icon;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className="card animate-slide-in"
|
||||
style={{
|
||||
padding: "12px 14px",
|
||||
display: "flex",
|
||||
alignItems: "flex-start",
|
||||
gap: 10,
|
||||
border: config.border,
|
||||
background: config.background,
|
||||
boxShadow: "0 18px 40px rgba(2,6,23,0.35)",
|
||||
overflow: "hidden",
|
||||
position: "relative",
|
||||
}}
|
||||
>
|
||||
<Icon size={18} style={{ color: config.iconColor, flexShrink: 0 }} />
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<p
|
||||
style={{
|
||||
color: config.titleColor,
|
||||
fontSize: 13,
|
||||
fontWeight: 700,
|
||||
marginBottom: 2,
|
||||
}}
|
||||
>
|
||||
{title ?? config.title}
|
||||
</p>
|
||||
<p
|
||||
style={{
|
||||
color: config.messageColor,
|
||||
fontSize: 12,
|
||||
lineHeight: 1.45,
|
||||
}}
|
||||
>
|
||||
{message}
|
||||
</p>
|
||||
</div>
|
||||
{onClose && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
style={{
|
||||
background: "none",
|
||||
border: "none",
|
||||
cursor: "pointer",
|
||||
color: config.closeColor,
|
||||
padding: 0,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
)}
|
||||
{showProgress && duration && (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
height: 3,
|
||||
background: "rgba(255,255,255,0.08)",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
height: "100%",
|
||||
width: "100%",
|
||||
background: config.iconColor,
|
||||
transformOrigin: "left center",
|
||||
animation: `toast-progress-shrink ${duration}ms linear forwards`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<style jsx>{`
|
||||
@keyframes toast-progress-shrink {
|
||||
from {
|
||||
transform: scaleX(1);
|
||||
}
|
||||
|
||||
to {
|
||||
transform: scaleX(0);
|
||||
}
|
||||
}
|
||||
`}</style>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
"use client";
|
||||
|
||||
import Toast from "@/components/Toast";
|
||||
import type { ToastItem } from "@/lib/use-toast-manager";
|
||||
|
||||
export type ToastViewportPosition =
|
||||
| "top-right"
|
||||
| "top-left"
|
||||
| "bottom-right"
|
||||
| "bottom-left";
|
||||
|
||||
interface ToastViewportProps {
|
||||
toasts: ToastItem[];
|
||||
onClose: (id: string) => void;
|
||||
position?: ToastViewportPosition;
|
||||
}
|
||||
|
||||
const viewportPositionStyle: Record<
|
||||
ToastViewportPosition,
|
||||
React.CSSProperties
|
||||
> = {
|
||||
"top-right": {
|
||||
top: 80,
|
||||
right: 20,
|
||||
},
|
||||
"top-left": {
|
||||
top: 80,
|
||||
left: 20,
|
||||
},
|
||||
"bottom-right": {
|
||||
bottom: 20,
|
||||
right: 20,
|
||||
},
|
||||
"bottom-left": {
|
||||
bottom: 20,
|
||||
left: 20,
|
||||
},
|
||||
};
|
||||
|
||||
export default function ToastViewport({
|
||||
toasts,
|
||||
onClose,
|
||||
position = "top-right",
|
||||
}: ToastViewportProps) {
|
||||
if (toasts.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: "fixed",
|
||||
zIndex: 1100,
|
||||
width: "calc(100vw - 32px)",
|
||||
maxWidth: 360,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 10,
|
||||
pointerEvents: "none",
|
||||
...viewportPositionStyle[position],
|
||||
}}
|
||||
>
|
||||
{toasts.map((toast) => (
|
||||
<div key={toast.id} style={{ pointerEvents: "auto" }}>
|
||||
<Toast
|
||||
tone={toast.tone}
|
||||
title={toast.title}
|
||||
message={toast.message}
|
||||
duration={toast.duration}
|
||||
showProgress={toast.showProgress}
|
||||
onClose={() => onClose(toast.id)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1 @@
|
||||
for Shared UI components
|
||||
Reference in new issue
Block a user