This is an automated email from the ASF dual-hosted git repository. hulk pushed a commit to branch unstable in repository https://gitbox.apache.org/repos/asf/kvrocks-controller.git
The following commit(s) were added to refs/heads/unstable by this push:
new 26567c2 feat(webui/namespace): redesign namespace and sidebar (#319)
26567c2 is described below
commit 26567c21e8382c5f50872ef5e8e04967554faa56
Author: Agnik Misra <[email protected]>
AuthorDate: Thu Jun 19 20:30:26 2025 +0530
feat(webui/namespace): redesign namespace and sidebar (#319)
---
webui/src/app/globals.css | 135 +++++
webui/src/app/namespaces/page.tsx | 1048 +++++++++++++++++++++++++++++++++++--
webui/src/app/ui/emptyState.tsx | 20 +-
webui/src/app/ui/formCreation.tsx | 3 +-
webui/src/app/ui/formDialog.tsx | 191 ++++++-
webui/src/app/ui/sidebar.tsx | 341 +++++++-----
webui/src/app/ui/sidebarItem.tsx | 62 ++-
webui/tailwind.config.ts | 11 +-
8 files changed, 1596 insertions(+), 215 deletions(-)
diff --git a/webui/src/app/globals.css b/webui/src/app/globals.css
index 14ddd07..43e5dd4 100644
--- a/webui/src/app/globals.css
+++ b/webui/src/app/globals.css
@@ -349,3 +349,138 @@ img[data-loaded="false"] {
perspective: 1000;
will-change: transform, opacity;
}
+
+.custom-scrollbar {
+ scrollbar-width: thin;
+}
+
+.custom-scrollbar::-webkit-scrollbar {
+ width: 4px;
+ height: 4px;
+}
+
+.custom-scrollbar::-webkit-scrollbar-track {
+ background: transparent;
+}
+
+.custom-scrollbar::-webkit-scrollbar-thumb {
+ background: rgba(0, 0, 0, 0.15);
+ border-radius: 10px;
+}
+
+.dark .custom-scrollbar::-webkit-scrollbar-thumb {
+ background: rgba(255, 255, 255, 0.15);
+}
+
+.custom-scrollbar::-webkit-scrollbar-thumb:hover {
+ background: rgba(0, 0, 0, 0.25);
+}
+
+.dark .custom-scrollbar::-webkit-scrollbar-thumb:hover {
+ background: rgba(255, 255, 255, 0.25);
+}
+
+.no-scrollbar {
+ scrollbar-width: none;
+ -ms-overflow-style: none;
+}
+
+.no-scrollbar::-webkit-scrollbar {
+ display: none;
+}
+
+.search-container {
+ max-width: 100%;
+ transition: all 0.3s cubic-bezier(0.165, 0.84, 0.44, 1);
+}
+
+.search-container:focus-within {
+ max-width: 100%;
+ transform: translateY(-2px);
+}
+
+.search-inner {
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
+}
+
+.search-inner:focus-within {
+ box-shadow: 0 8px 20px rgba(0, 0, 0, 0.08);
+}
+
+@media (max-width: 640px) {
+ .search-container {
+ width: 100%;
+ }
+
+ .search-inner input {
+ font-size: 16px;
+ }
+}
+
+.sidebar-container {
+ position: relative;
+ transform-origin: left center;
+ will-change: width, transform;
+ box-shadow: 0 0 20px rgba(0, 0, 0, 0.04);
+ z-index: 40;
+}
+
+.sidebar-inner {
+ min-width: 260px;
+ overflow-x: hidden;
+}
+
+.sidebar-toggle-btn {
+ opacity: 0.9;
+ transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
+}
+
+.sidebar-toggle-btn:hover {
+ opacity: 1;
+ transform: scale(1.1);
+}
+
+.sidebar-scrollbar {
+ scrollbar-width: thin;
+ scrollbar-color: rgba(0, 0, 0, 0.2) transparent;
+}
+
+.sidebar-scrollbar::-webkit-scrollbar {
+ width: 4px;
+}
+
+.sidebar-scrollbar::-webkit-scrollbar-track {
+ background: transparent;
+}
+
+.sidebar-scrollbar::-webkit-scrollbar-thumb {
+ background-color: rgba(0, 0, 0, 0.15);
+ border-radius: 20px;
+}
+
+.dark .sidebar-scrollbar::-webkit-scrollbar-thumb {
+ background-color: rgba(255, 255, 255, 0.15);
+}
+
+.sidebar-scrollbar::-webkit-scrollbar-thumb:hover {
+ background-color: rgba(0, 0, 0, 0.25);
+}
+
+.dark .sidebar-scrollbar::-webkit-scrollbar-thumb:hover {
+ background-color: rgba(255, 255, 255, 0.25);
+}
+
+@media (max-width: 767px) {
+ .sidebar-container {
+ position: fixed;
+ height: 100vh;
+ top: 0;
+ left: 0;
+ z-index: 100;
+ }
+
+ .sidebar-container[style*="width: 0px"] {
+ border-right: none;
+ box-shadow: none;
+ }
+}
diff --git a/webui/src/app/namespaces/page.tsx
b/webui/src/app/namespaces/page.tsx
index 15a861a..da98120 100644
--- a/webui/src/app/namespaces/page.tsx
+++ b/webui/src/app/namespaces/page.tsx
@@ -19,29 +19,141 @@
"use client";
-import { Container, Typography, Paper, Box } from "@mui/material";
+import {
+ Container,
+ Typography,
+ Paper,
+ Box,
+ Tooltip,
+ Chip,
+ Divider,
+ Grid,
+ Button,
+ Card,
+ CardContent,
+ IconButton,
+} from "@mui/material";
import { NamespaceSidebar } from "../ui/sidebar";
import { useRouter } from "next/navigation";
-import { useState, useEffect } from "react";
-import { fetchNamespaces } from "../lib/api";
+import { useState, useEffect, useRef } from "react";
+import { deleteNamespace, fetchClusters, fetchNamespaces, listShards,
listNodes } from "../lib/api";
import { LoadingSpinner } from "../ui/loadingSpinner";
-import { CreateCard, ResourceCard } from "../ui/createCard";
+import { NamespaceCreation } from "../ui/formCreation";
import Link from "next/link";
import FolderIcon from "@mui/icons-material/Folder";
+import FolderOpenIcon from "@mui/icons-material/FolderOpen";
import EmptyState from "../ui/emptyState";
+import StorageIcon from "@mui/icons-material/Storage";
+import ChevronRightIcon from "@mui/icons-material/ChevronRight";
+import AccessTimeIcon from "@mui/icons-material/AccessTime";
+import DeleteIcon from "@mui/icons-material/Delete";
+import InfoIcon from "@mui/icons-material/Info";
+import DnsIcon from "@mui/icons-material/Dns";
+import DeviceHubIcon from "@mui/icons-material/DeviceHub";
+import EqualizerIcon from "@mui/icons-material/Equalizer";
+import AddIcon from "@mui/icons-material/Add";
+import SearchIcon from "@mui/icons-material/Search";
+import SortIcon from "@mui/icons-material/Sort";
+import FilterListIcon from "@mui/icons-material/FilterList";
+import MoreVertIcon from "@mui/icons-material/MoreVert";
+import ArrowUpwardIcon from "@mui/icons-material/ArrowUpward";
+import ArrowDownwardIcon from "@mui/icons-material/ArrowDownward";
+import CheckIcon from "@mui/icons-material/Check";
+import Breadcrumb from "../ui/breadcrumb";
+import { Popover, RadioGroup, FormControlLabel, Radio, Fade } from
"@mui/material";
+
+interface ResourceCounts {
+ namespaces: number;
+ clusters: number;
+ shards: number;
+ nodes: number;
+}
+
+interface NamespaceData {
+ name: string;
+ clusterCount: number;
+ shardCount: number;
+ nodeCount: number;
+}
+
+type FilterOption = "all" | "with-clusters" | "no-clusters";
+type SortOption =
+ | "name-asc"
+ | "name-desc"
+ | "clusters-desc"
+ | "clusters-asc"
+ | "nodes-desc"
+ | "nodes-asc";
export default function Namespace() {
- const [namespaces, setNamespaces] = useState<string[]>([]);
+ const [namespacesData, setNamespacesData] = useState<NamespaceData[]>([]);
+ const [resourceCounts, setResourceCounts] = useState<ResourceCounts>({
+ namespaces: 0,
+ clusters: 0,
+ shards: 0,
+ nodes: 0,
+ });
const [loading, setLoading] = useState<boolean>(true);
+ const [deletingNamespace, setDeletingNamespace] = useState<string |
null>(null);
+ const [searchTerm, setSearchTerm] = useState<string>("");
+
+ const [filterAnchorEl, setFilterAnchorEl] = useState<null |
HTMLElement>(null);
+ const [sortAnchorEl, setSortAnchorEl] = useState<null | HTMLElement>(null);
+ const [filterOption, setFilterOption] = useState<FilterOption>("all");
+ const [sortOption, setSortOption] = useState<SortOption>("name-asc");
+
const router = useRouter();
useEffect(() => {
const fetchData = async () => {
try {
const fetchedNamespaces = await fetchNamespaces();
- setNamespaces(fetchedNamespaces);
+
+ let totalClusters = 0;
+ let totalShards = 0;
+ let totalNodes = 0;
+ const nsData: NamespaceData[] = [];
+
+ for (const namespace of fetchedNamespaces) {
+ const clusters = await fetchClusters(namespace);
+ totalClusters += clusters.length;
+
+ let namespaceShardCount = 0;
+ let namespaceNodeCount = 0;
+
+ for (const cluster of clusters) {
+ const shards = await listShards(namespace, cluster);
+ if (Array.isArray(shards)) {
+ namespaceShardCount += shards.length;
+ totalShards += shards.length;
+
+ for (let i = 0; i < shards.length; i++) {
+ const nodes = await listNodes(namespace,
cluster, i.toString());
+ if (Array.isArray(nodes)) {
+ namespaceNodeCount += nodes.length;
+ totalNodes += nodes.length;
+ }
+ }
+ }
+ }
+
+ nsData.push({
+ name: namespace,
+ clusterCount: clusters.length,
+ shardCount: namespaceShardCount,
+ nodeCount: namespaceNodeCount,
+ });
+ }
+
+ setNamespacesData(nsData);
+ setResourceCounts({
+ namespaces: fetchedNamespaces.length,
+ clusters: totalClusters,
+ shards: totalShards,
+ nodes: totalNodes,
+ });
} catch (error) {
- console.error("Error fetching namespaces:", error);
+ console.error("Error fetching namespaces data:", error);
} finally {
setLoading(false);
}
@@ -50,50 +162,902 @@ export default function Namespace() {
fetchData();
}, [router]);
+ const handleDeleteNamespace = async (namespace: string) => {
+ if (confirm(`Are you sure you want to delete namespace
"${namespace}"?`)) {
+ try {
+ setDeletingNamespace(namespace);
+ await deleteNamespace(namespace);
+ setNamespacesData(namespacesData.filter((ns) => ns.name !==
namespace));
+ setResourceCounts((prev) => ({
+ ...prev,
+ namespaces: prev.namespaces - 1,
+ }));
+ } catch (error) {
+ console.error(`Error deleting namespace ${namespace}:`, error);
+ alert(`Failed to delete namespace: ${error}`);
+ } finally {
+ setDeletingNamespace(null);
+ }
+ }
+ };
+
+ const handleFilterClick = (event: React.MouseEvent<HTMLElement>) => {
+ setFilterAnchorEl(event.currentTarget);
+ };
+
+ const handleSortClick = (event: React.MouseEvent<HTMLElement>) => {
+ setSortAnchorEl(event.currentTarget);
+ };
+
+ const handleFilterClose = () => {
+ setFilterAnchorEl(null);
+ };
+
+ const handleSortClose = () => {
+ setSortAnchorEl(null);
+ };
+
+ const filteredAndSortedNamespaces = namespacesData
+ .filter((ns) => {
+ if (!ns.name.toLowerCase().includes(searchTerm.toLowerCase())) {
+ return false;
+ }
+
+ switch (filterOption) {
+ case "with-clusters":
+ return ns.clusterCount > 0;
+ case "no-clusters":
+ return ns.clusterCount === 0;
+ default:
+ return true;
+ }
+ })
+ .sort((a, b) => {
+ switch (sortOption) {
+ case "name-asc":
+ return a.name.localeCompare(b.name);
+ case "name-desc":
+ return b.name.localeCompare(a.name);
+ case "clusters-desc":
+ return b.clusterCount - a.clusterCount;
+ case "clusters-asc":
+ return a.clusterCount - b.clusterCount;
+ case "nodes-desc":
+ return b.nodeCount - a.nodeCount;
+ case "nodes-asc":
+ return a.nodeCount - b.nodeCount;
+ default:
+ return 0;
+ }
+ });
+
+ const isFilterOpen = Boolean(filterAnchorEl);
+ const isSortOpen = Boolean(sortAnchorEl);
+ const filterId = isFilterOpen ? "filter-popover" : undefined;
+ const sortId = isSortOpen ? "sort-popover" : undefined;
+
if (loading) {
return <LoadingSpinner />;
}
return (
<div className="flex h-full">
- <NamespaceSidebar />
- <div className="flex-1 overflow-auto">
- <Box className="container-inner">
- <Box className="mb-6 flex items-center justify-between">
- <Typography
- variant="h5"
- className="font-medium text-gray-800
dark:text-gray-100"
- >
- Namespaces
- </Typography>
- </Box>
-
- {namespaces.length > 0 ? (
- <div className="grid grid-cols-1 gap-4 sm:grid-cols-2
lg:grid-cols-3 xl:grid-cols-4">
- {namespaces.map((namespace) => (
- <Link key={namespace}
href={`/namespaces/${namespace}`} passHref>
- <ResourceCard
- title={namespace}
- description="Namespace"
- tags={[{ label: "namespace", color:
"primary" }]}
+ <div className="relative h-full">
+ <NamespaceSidebar />
+ </div>
+ <div className="no-scrollbar flex-1 overflow-y-auto bg-white pb-8
dark:bg-dark">
+ <Box className="px-6 py-4 sm:px-8 sm:py-6">
+ <div className="mb-4 flex flex-col gap-3 sm:mb-5
lg:flex-row lg:items-center lg:justify-between">
+ <div>
+ <Typography
+ variant="h4"
+ className="font-medium text-gray-900
dark:text-white"
+ >
+ Namespaces
+ </Typography>
+ <Typography
+ variant="body1"
+ className="mt-0.5 text-gray-500
dark:text-gray-400"
+ >
+ Manage your Kvrocks database namespaces
+ </Typography>
+ </div>
+
+ <div className="flex w-full flex-row items-center
gap-2 lg:w-auto">
+ <div className="search-container relative max-w-md
flex-grow transition-all duration-300 lg:min-w-[280px]">
+ <div className="search-inner relative w-full
rounded-lg bg-gray-50 transition-all duration-300 focus-within:bg-white
focus-within:shadow-md dark:bg-dark-paper/90 dark:focus-within:bg-dark-paper">
+ <div className="pointer-events-none
absolute inset-y-0 left-3 flex items-center">
+ <SearchIcon
+ className="text-gray-400"
+ sx={{ fontSize: 18 }}
+ />
+ </div>
+ <input
+ type="text"
+ placeholder="Search namespaces..."
+ className="w-full rounded-lg border-0
bg-transparent py-2.5 pl-9 pr-4 text-sm text-gray-800 outline-none ring-1
ring-gray-200 transition-all focus:ring-2 focus:ring-primary dark:text-gray-200
dark:ring-gray-700 dark:focus:ring-primary-light"
+ value={searchTerm}
+ onChange={(e) =>
setSearchTerm(e.target.value)}
+ />
+ {searchTerm && (
+ <button
+ className="absolute inset-y-0
right-3 flex items-center text-gray-400 transition-colors hover:text-gray-600
dark:hover:text-gray-300"
+ onClick={() => setSearchTerm("")}
+ >
+ <span className="text-xs">✕</span>
+ </button>
+ )}
+ </div>
+ </div>
+
+ <div className="flex-shrink-0">
+ <NamespaceCreation position="page">
+ <Button
+ variant="contained"
+ color="primary"
+ className="whitespace-nowrap
rounded-lg px-5 py-2.5 font-medium shadow-md transition-all hover:shadow-lg"
+ startIcon={<AddIcon />}
+ disableElevation
+ size="medium"
>
- <div className="mt-4 flex h-20
items-center justify-center">
- <FolderIcon
- sx={{ fontSize: 60 }}
- className="text-primary/20
dark:text-primary-light/30"
- />
+ Create Namespace
+ </Button>
+ </NamespaceCreation>
+ </div>
+ </div>
+ </div>
+
+ <div className="mb-4 sm:mb-5">
+ <Grid container spacing={2}>
+ <Grid item xs={12} sm={6} lg={3}>
+ <Paper
+ elevation={0}
+ className="relative h-full overflow-hidden
rounded-2xl border border-gray-100 p-4 transition-all hover:-translate-y-1
hover:shadow-md dark:border-gray-800 dark:bg-dark-paper"
+ >
+ <div className="flex items-center
justify-between">
+ <div className="flex h-12 w-12
items-center justify-center rounded-xl bg-blue-50 text-blue-500
dark:bg-blue-900/30 dark:text-blue-400">
+ <FolderIcon sx={{ fontSize: 24 }}
/>
+ </div>
+ <div className="flex flex-col
items-end">
+ <Typography
+ variant="h4"
+ className="font-semibold
text-gray-900 dark:text-white"
+ >
+ {resourceCounts.namespaces}
+ </Typography>
+ <Typography
+ variant="body2"
+ className="text-gray-500
dark:text-gray-400"
+ >
+ Namespaces
+ </Typography>
+ </div>
+ </div>
+ <div className="absolute -bottom-4
-right-4 h-24 w-24 rounded-full bg-blue-500/5 blur-xl"></div>
+ </Paper>
+ </Grid>
+
+ <Grid item xs={12} sm={6} lg={3}>
+ <Paper
+ elevation={0}
+ className="relative h-full overflow-hidden
rounded-2xl border border-gray-100 p-4 transition-all hover:-translate-y-1
hover:shadow-md dark:border-gray-800 dark:bg-dark-paper"
+ >
+ <div className="flex items-center
justify-between">
+ <div className="flex h-12 w-12
items-center justify-center rounded-xl bg-purple-50 text-purple-500
dark:bg-purple-900/30 dark:text-purple-400">
+ <StorageIcon sx={{ fontSize: 24 }}
/>
+ </div>
+ <div className="flex flex-col
items-end">
+ <Typography
+ variant="h4"
+ className="font-semibold
text-gray-900 dark:text-white"
+ >
+ {resourceCounts.clusters}
+ </Typography>
+ <Typography
+ variant="body2"
+ className="text-gray-500
dark:text-gray-400"
+ >
+ Clusters
+ </Typography>
+ </div>
+ </div>
+ <div className="absolute -bottom-4
-right-4 h-24 w-24 rounded-full bg-purple-500/5 blur-xl"></div>
+ </Paper>
+ </Grid>
+
+ <Grid item xs={12} sm={6} lg={3}>
+ <Paper
+ elevation={0}
+ className="relative h-full overflow-hidden
rounded-2xl border border-gray-100 p-4 transition-all hover:-translate-y-1
hover:shadow-md dark:border-gray-800 dark:bg-dark-paper"
+ >
+ <div className="flex items-center
justify-between">
+ <div className="flex h-12 w-12
items-center justify-center rounded-xl bg-green-50 text-green-500
dark:bg-green-900/30 dark:text-green-400">
+ <DnsIcon sx={{ fontSize: 24 }} />
</div>
- </ResourceCard>
- </Link>
- ))}
+ <div className="flex flex-col
items-end">
+ <Typography
+ variant="h4"
+ className="font-semibold
text-gray-900 dark:text-white"
+ >
+ {resourceCounts.shards}
+ </Typography>
+ <Typography
+ variant="body2"
+ className="text-gray-500
dark:text-gray-400"
+ >
+ Shards
+ </Typography>
+ </div>
+ </div>
+ <div className="absolute -bottom-4
-right-4 h-24 w-24 rounded-full bg-green-500/5 blur-xl"></div>
+ </Paper>
+ </Grid>
+
+ <Grid item xs={12} sm={6} lg={3}>
+ <Paper
+ elevation={0}
+ className="relative h-full overflow-hidden
rounded-2xl border border-gray-100 p-4 transition-all hover:-translate-y-1
hover:shadow-md dark:border-gray-800 dark:bg-dark-paper"
+ >
+ <div className="flex items-center
justify-between">
+ <div className="flex h-12 w-12
items-center justify-center rounded-xl bg-amber-50 text-amber-500
dark:bg-amber-900/30 dark:text-amber-400">
+ <DeviceHubIcon sx={{ fontSize: 24
}} />
+ </div>
+ <div className="flex flex-col
items-end">
+ <Typography
+ variant="h4"
+ className="font-semibold
text-gray-900 dark:text-white"
+ >
+ {resourceCounts.nodes}
+ </Typography>
+ <Typography
+ variant="body2"
+ className="text-gray-500
dark:text-gray-400"
+ >
+ Nodes
+ </Typography>
+ </div>
+ </div>
+ <div className="absolute -bottom-4
-right-4 h-24 w-24 rounded-full bg-amber-500/5 blur-xl"></div>
+ </Paper>
+ </Grid>
+ </Grid>
+ </div>
+
+ <Paper
+ elevation={0}
+ className="overflow-hidden rounded-2xl border
border-gray-100 transition-all hover:shadow-md dark:border-gray-800
dark:bg-dark-paper"
+ >
+ <div className="border-b border-gray-100 px-6 py-3
dark:border-gray-800 sm:px-8">
+ <div className="flex items-center justify-between">
+ <Typography
+ variant="h6"
+ className="font-medium text-gray-800
dark:text-gray-100"
+ >
+ All Namespaces
+ </Typography>
+ <div className="flex items-center gap-2">
+ <Tooltip title="Filter">
+ <IconButton
+ size="small"
+ onClick={handleFilterClick}
+ aria-describedby={filterId}
+ className="rounded-full bg-gray-50
text-gray-500 hover:bg-gray-100 dark:bg-gray-800 dark:text-gray-400
dark:hover:bg-gray-700"
+ >
+ <FilterListIcon fontSize="small" />
+ </IconButton>
+ </Tooltip>
+ <Tooltip title="Sort">
+ <IconButton
+ size="small"
+ onClick={handleSortClick}
+ aria-describedby={sortId}
+ className="rounded-full bg-gray-50
text-gray-500 hover:bg-gray-100 dark:bg-gray-800 dark:text-gray-400
dark:hover:bg-gray-700"
+ >
+ <SortIcon fontSize="small" />
+ </IconButton>
+ </Tooltip>
+ </div>
+ </div>
</div>
- ) : (
- <EmptyState
- title="No namespaces found"
- description="Create a namespace to get started"
- icon={<FolderIcon sx={{ fontSize: 60 }} />}
- />
- )}
+
+ <Popover
+ id={filterId}
+ open={isFilterOpen}
+ anchorEl={filterAnchorEl}
+ onClose={handleFilterClose}
+ anchorOrigin={{
+ vertical: "bottom",
+ horizontal: "right",
+ }}
+ transformOrigin={{
+ vertical: "top",
+ horizontal: "right",
+ }}
+ TransitionComponent={Fade}
+ PaperProps={{
+ className:
+ "rounded-xl shadow-xl border
border-gray-100 dark:border-gray-700",
+ elevation: 3,
+ sx: { width: 280 },
+ }}
+ >
+ <div className="p-4">
+ <div className="mb-3 flex items-center
justify-between border-b border-gray-100 pb-2 dark:border-gray-700">
+ <Typography variant="subtitle1"
className="font-medium">
+ Filter Namespaces
+ </Typography>
+ </div>
+
+ <RadioGroup
+ value={filterOption}
+ onChange={(e) =>
+ setFilterOption(e.target.value as
FilterOption)
+ }
+ >
+ <div className="space-y-2">
+ <div className="rounded-lg bg-gray-50
p-2 dark:bg-gray-800">
+ <FormControlLabel
+ value="all"
+ control={
+ <Radio
+ size="small"
+
className="text-primary"
+ checkedIcon={
+ <div
className="flex h-5 w-5 items-center justify-center rounded-full border-2
border-primary bg-primary text-white">
+ <CheckIcon
+ style={{
fontSize: 12 }}
+ />
+ </div>
+ }
+ />
+ }
+ label={
+ <div className="flex
items-center">
+ <span
className="text-sm font-medium">
+ All namespaces
+ </span>
+ <Chip
+ size="small"
+
label={namespacesData.length}
+ className="ml-2"
+ sx={{ height: 20,
fontSize: "0.7rem" }}
+ />
+ </div>
+ }
+ className="m-0 w-full"
+ />
+ </div>
+
+ <div className="rounded-lg bg-gray-50
p-2 dark:bg-gray-800">
+ <FormControlLabel
+ value="with-clusters"
+ control={
+ <Radio
+ size="small"
+
className="text-primary"
+ checkedIcon={
+ <div
className="flex h-5 w-5 items-center justify-center rounded-full border-2
border-primary bg-primary text-white">
+ <CheckIcon
+ style={{
fontSize: 12 }}
+ />
+ </div>
+ }
+ />
+ }
+ label={
+ <div className="flex
items-center">
+ <span
className="text-sm font-medium">
+ With clusters
+ </span>
+ <Chip
+ size="small"
+ label={
+
namespacesData.filter(
+ (ns) =>
ns.clusterCount > 0
+ ).length
+ }
+ className="ml-2"
+ sx={{ height: 20,
fontSize: "0.7rem" }}
+ />
+ </div>
+ }
+ className="m-0 w-full"
+ />
+ </div>
+
+ <div className="rounded-lg bg-gray-50
p-2 dark:bg-gray-800">
+ <FormControlLabel
+ value="no-clusters"
+ control={
+ <Radio
+ size="small"
+
className="text-primary"
+ checkedIcon={
+ <div
className="flex h-5 w-5 items-center justify-center rounded-full border-2
border-primary bg-primary text-white">
+ <CheckIcon
+ style={{
fontSize: 12 }}
+ />
+ </div>
+ }
+ />
+ }
+ label={
+ <div className="flex
items-center">
+ <span
className="text-sm font-medium">
+ No clusters
+ </span>
+ <Chip
+ size="small"
+ label={
+
namespacesData.filter(
+ (ns) =>
ns.clusterCount === 0
+ ).length
+ }
+ className="ml-2"
+ sx={{ height: 20,
fontSize: "0.7rem" }}
+ />
+ </div>
+ }
+ className="m-0 w-full"
+ />
+ </div>
+ </div>
+ </RadioGroup>
+
+ <div className="mt-4 flex justify-end">
+ <Button
+ variant="text"
+ size="small"
+ onClick={handleFilterClose}
+ className="rounded-lg px-3 py-1
text-xs"
+ >
+ Close
+ </Button>
+ </div>
+ </div>
+ </Popover>
+
+ <Popover
+ id={sortId}
+ open={isSortOpen}
+ anchorEl={sortAnchorEl}
+ onClose={handleSortClose}
+ anchorOrigin={{
+ vertical: "bottom",
+ horizontal: "right",
+ }}
+ transformOrigin={{
+ vertical: "top",
+ horizontal: "right",
+ }}
+ TransitionComponent={Fade}
+ PaperProps={{
+ className:
+ "rounded-xl shadow-xl border
border-gray-100 dark:border-gray-700",
+ elevation: 3,
+ sx: { width: 280 },
+ }}
+ >
+ <div className="p-4">
+ <div className="mb-3 flex items-center
justify-between border-b border-gray-100 pb-2 dark:border-gray-700">
+ <Typography variant="subtitle1"
className="font-medium">
+ Sort By
+ </Typography>
+ </div>
+
+ <RadioGroup
+ value={sortOption}
+ onChange={(e) =>
setSortOption(e.target.value as SortOption)}
+ >
+ <div className="space-y-2">
+ <div className="mb-2 text-xs
font-medium uppercase text-gray-500 dark:text-gray-400">
+ Name
+ </div>
+ <div className="space-y-1">
+ <div className="rounded-lg
bg-gray-50 p-2 dark:bg-gray-800">
+ <FormControlLabel
+ value="name-asc"
+ control={
+ <Radio
+ size="small"
+
className="text-primary"
+ checkedIcon={
+ <div
className="flex h-5 w-5 items-center justify-center rounded-full border-2
border-primary bg-primary text-white">
+ <CheckIcon
+
style={{ fontSize: 12 }}
+ />
+ </div>
+ }
+ />
+ }
+ label={
+ <div className="flex
items-center">
+ <ArrowUpwardIcon
+ style={{
fontSize: 16 }}
+
className="mr-1 text-gray-500"
+ />
+ <span
className="text-sm font-medium">
+ A to Z
+ </span>
+ </div>
+ }
+ className="m-0 w-full"
+ />
+ </div>
+ <div className="rounded-lg
bg-gray-50 p-2 dark:bg-gray-800">
+ <FormControlLabel
+ value="name-desc"
+ control={
+ <Radio
+ size="small"
+
className="text-primary"
+ checkedIcon={
+ <div
className="flex h-5 w-5 items-center justify-center rounded-full border-2
border-primary bg-primary text-white">
+ <CheckIcon
+
style={{ fontSize: 12 }}
+ />
+ </div>
+ }
+ />
+ }
+ label={
+ <div className="flex
items-center">
+ <ArrowDownwardIcon
+ style={{
fontSize: 16 }}
+
className="mr-1 text-gray-500"
+ />
+ <span
className="text-sm font-medium">
+ Z to A
+ </span>
+ </div>
+ }
+ className="m-0 w-full"
+ />
+ </div>
+ </div>
+
+ <div className="mb-2 mt-4 text-xs
font-medium uppercase text-gray-500 dark:text-gray-400">
+ Clusters
+ </div>
+ <div className="space-y-1">
+ <div className="rounded-lg
bg-gray-50 p-2 dark:bg-gray-800">
+ <FormControlLabel
+ value="clusters-desc"
+ control={
+ <Radio
+ size="small"
+
className="text-primary"
+ checkedIcon={
+ <div
className="flex h-5 w-5 items-center justify-center rounded-full border-2
border-primary bg-primary text-white">
+ <CheckIcon
+
style={{ fontSize: 12 }}
+ />
+ </div>
+ }
+ />
+ }
+ label={
+ <div className="flex
items-center">
+ <ArrowDownwardIcon
+ style={{
fontSize: 16 }}
+
className="mr-1 text-gray-500"
+ />
+ <span
className="text-sm font-medium">
+ Most clusters
+ </span>
+ </div>
+ }
+ className="m-0 w-full"
+ />
+ </div>
+ <div className="rounded-lg
bg-gray-50 p-2 dark:bg-gray-800">
+ <FormControlLabel
+ value="clusters-asc"
+ control={
+ <Radio
+ size="small"
+
className="text-primary"
+ checkedIcon={
+ <div
className="flex h-5 w-5 items-center justify-center rounded-full border-2
border-primary bg-primary text-white">
+ <CheckIcon
+
style={{ fontSize: 12 }}
+ />
+ </div>
+ }
+ />
+ }
+ label={
+ <div className="flex
items-center">
+ <ArrowUpwardIcon
+ style={{
fontSize: 16 }}
+
className="mr-1 text-gray-500"
+ />
+ <span
className="text-sm font-medium">
+ Fewest clusters
+ </span>
+ </div>
+ }
+ className="m-0 w-full"
+ />
+ </div>
+ </div>
+
+ <div className="mb-2 mt-4 text-xs
font-medium uppercase text-gray-500 dark:text-gray-400">
+ Nodes
+ </div>
+ <div className="space-y-1">
+ <div className="rounded-lg
bg-gray-50 p-2 dark:bg-gray-800">
+ <FormControlLabel
+ value="nodes-desc"
+ control={
+ <Radio
+ size="small"
+
className="text-primary"
+ checkedIcon={
+ <div
className="flex h-5 w-5 items-center justify-center rounded-full border-2
border-primary bg-primary text-white">
+ <CheckIcon
+
style={{ fontSize: 12 }}
+ />
+ </div>
+ }
+ />
+ }
+ label={
+ <div className="flex
items-center">
+ <ArrowDownwardIcon
+ style={{
fontSize: 16 }}
+
className="mr-1 text-gray-500"
+ />
+ <span
className="text-sm font-medium">
+ Most nodes
+ </span>
+ </div>
+ }
+ className="m-0 w-full"
+ />
+ </div>
+ <div className="rounded-lg
bg-gray-50 p-2 dark:bg-gray-800">
+ <FormControlLabel
+ value="nodes-asc"
+ control={
+ <Radio
+ size="small"
+
className="text-primary"
+ checkedIcon={
+ <div
className="flex h-5 w-5 items-center justify-center rounded-full border-2
border-primary bg-primary text-white">
+ <CheckIcon
+
style={{ fontSize: 12 }}
+ />
+ </div>
+ }
+ />
+ }
+ label={
+ <div className="flex
items-center">
+ <ArrowUpwardIcon
+ style={{
fontSize: 16 }}
+
className="mr-1 text-gray-500"
+ />
+ <span
className="text-sm font-medium">
+ Fewest nodes
+ </span>
+ </div>
+ }
+ className="m-0 w-full"
+ />
+ </div>
+ </div>
+ </div>
+ </RadioGroup>
+
+ <div className="mt-4 flex justify-end">
+ <Button
+ variant="text"
+ size="small"
+ onClick={handleSortClose}
+ className="rounded-lg px-3 py-1
text-xs"
+ >
+ Close
+ </Button>
+ </div>
+ </div>
+ </Popover>
+
+ {filteredAndSortedNamespaces.length > 0 ? (
+ <div className="divide-y divide-gray-100
dark:divide-gray-800">
+ {filteredAndSortedNamespaces.map((nsData) => (
+ <div
+ key={nsData.name}
+ className="group p-2 transition-colors
hover:bg-gray-50 dark:hover:bg-gray-800/30"
+ >
+ <Paper
+ elevation={0}
+ className="overflow-hidden
rounded-xl border border-transparent bg-white p-4 transition-all
group-hover:border-primary/10 group-hover:shadow-sm dark:bg-dark-paper
dark:group-hover:border-primary-dark/20"
+ >
+ <div className="flex flex-col
items-start sm:flex-row sm:items-center">
+ <div className="mb-3 flex h-14
w-14 flex-shrink-0 items-center justify-center rounded-xl bg-blue-50
text-blue-500 dark:bg-blue-900/30 dark:text-blue-400 sm:mb-0">
+ <FolderIcon sx={{
fontSize: 28 }} />
+ </div>
+
+ <div className="flex flex-1
flex-col sm:ml-5 sm:flex-row sm:items-center sm:overflow-hidden">
+ <div className="flex-1
overflow-hidden">
+ <Link
+
href={`/namespaces/${nsData.name}`}
+ className="block"
+ >
+ <Typography
+ variant="h6"
+
className="truncate font-medium text-gray-900 transition-colors
hover:text-primary dark:text-gray-100 dark:hover:text-primary-light"
+ >
+ {nsData.name}
+ </Typography>
+ <Typography
+ variant="body2"
+
className="flex items-center text-gray-500 dark:text-gray-400"
+ >
+ <AccessTimeIcon
+ sx={{
fontSize: 14 }}
+
className="mr-1"
+ />
+ Created
recently
+ </Typography>
+ </Link>
+ </div>
+
+ <div className="mt-3 flex
space-x-2 overflow-x-auto sm:ml-6 sm:mt-0 md:hidden lg:flex xl:hidden 2xl:flex">
+ <Chip
+ icon={<StorageIcon
fontSize="small" />}
+
label={`${nsData.clusterCount} clusters`}
+ size="small"
+ color="primary"
+ variant="outlined"
+
className="whitespace-nowrap"
+ />
+
+ <Chip
+ icon={<DnsIcon
fontSize="small" />}
+
label={`${nsData.shardCount} shards`}
+ size="small"
+ color="secondary"
+ variant="outlined"
+
className="whitespace-nowrap"
+ />
+
+ <Chip
+ icon={
+ <DeviceHubIcon
fontSize="small" />
+ }
+
label={`${nsData.nodeCount} nodes`}
+ size="small"
+ color="default"
+ variant="outlined"
+
className="whitespace-nowrap"
+ />
+ </div>
+
+ <div className="hidden
space-x-3 sm:ml-8 md:flex lg:hidden xl:flex 2xl:hidden">
+ <div className="flex
flex-col items-center rounded-lg border border-gray-100 bg-gray-50 px-4 py-2
dark:border-gray-800 dark:bg-gray-800/50">
+ <Typography
+
variant="caption"
+
className="text-gray-500 dark:text-gray-400"
+ >
+ Clusters
+ </Typography>
+ <Typography
+
variant="subtitle1"
+
className="font-semibold text-gray-900 dark:text-white"
+ >
+
{nsData.clusterCount}
+ </Typography>
+ </div>
+
+ <div className="flex
flex-col items-center rounded-lg border border-gray-100 bg-gray-50 px-4 py-2
dark:border-gray-800 dark:bg-gray-800/50">
+ <Typography
+
variant="caption"
+
className="text-gray-500 dark:text-gray-400"
+ >
+ Shards
+ </Typography>
+ <Typography
+
variant="subtitle1"
+
className="font-semibold text-gray-900 dark:text-white"
+ >
+
{nsData.shardCount}
+ </Typography>
+ </div>
+
+ <div className="flex
flex-col items-center rounded-lg border border-gray-100 bg-gray-50 px-4 py-2
dark:border-gray-800 dark:bg-gray-800/50">
+ <Typography
+
variant="caption"
+
className="text-gray-500 dark:text-gray-400"
+ >
+ Nodes
+ </Typography>
+ <Typography
+
variant="subtitle1"
+
className="font-semibold text-gray-900 dark:text-white"
+ >
+
{nsData.nodeCount}
+ </Typography>
+ </div>
+ </div>
+
+ <div className="ml-2 mt-3
flex items-center space-x-2 sm:mt-0">
+ <button
+ onClick={() =>
+
handleDeleteNamespace(nsData.name)
+ }
+ disabled={
+
deletingNamespace === nsData.name
+ }
+
className="rounded-full bg-gray-100 p-2 text-gray-600 transition-colors
hover:bg-red-100 hover:text-red-600 disabled:cursor-not-allowed
disabled:opacity-50 dark:bg-gray-800 dark:text-gray-300
dark:hover:bg-red-900/30 dark:hover:text-red-400"
+ >
+ <DeleteIcon />
+ </button>
+
+ <Link
+
href={`/namespaces/${nsData.name}`}
+
className="rounded-full bg-primary/10 p-2 text-primary transition-colors
hover:bg-primary/20 dark:bg-primary-dark/20 dark:text-primary-light
dark:hover:bg-primary-dark/30"
+ >
+ <ChevronRightIcon
/>
+ </Link>
+ </div>
+ </div>
+ </div>
+ </Paper>
+ </div>
+ ))}
+ </div>
+ ) : (
+ <div className="p-12">
+ <EmptyState
+ title={
+ filterOption !== "all"
+ ? "No matching namespaces"
+ : "No namespaces found"
+ }
+ description={
+ filterOption !== "all"
+ ? "Try changing your filter
settings"
+ : searchTerm
+ ? "Try adjusting your search
term"
+ : "Create a namespace to get
started"
+ }
+ icon={
+ <FolderOpenIcon
+ sx={{ fontSize: 64 }}
+ className="text-gray-300
dark:text-gray-600"
+ />
+ }
+ action={{
+ label: "Create Namespace",
+ onClick: () =>
+ document
+
.getElementById("create-namespace-btn")
+ ?.click(),
+ }}
+ />
+ <div className="hidden">
+ <NamespaceCreation position="page">
+ <Button
id="create-namespace-btn">Create</Button>
+ </NamespaceCreation>
+ </div>
+ </div>
+ )}
+
+ {filteredAndSortedNamespaces.length > 0 && (
+ <div className="bg-gray-50 px-6 py-4
dark:bg-gray-800/30 sm:px-8">
+ <Typography
+ variant="body2"
+ className="text-gray-500
dark:text-gray-400"
+ >
+ Showing
{filteredAndSortedNamespaces.length} of{" "}
+ {namespacesData.length} namespaces
+ {filterOption !== "all" && " (filtered)"}
+ </Typography>
+ </div>
+ )}
+ </Paper>
</Box>
</div>
</div>
diff --git a/webui/src/app/ui/emptyState.tsx b/webui/src/app/ui/emptyState.tsx
index 62e866a..795172a 100644
--- a/webui/src/app/ui/emptyState.tsx
+++ b/webui/src/app/ui/emptyState.tsx
@@ -34,21 +34,23 @@ const EmptyState: React.FC<EmptyStateProps> = ({ title,
description, icon, actio
return (
<Paper
elevation={0}
- className="mx-auto max-w-md rounded-lg border border-light-border
bg-white p-10 text-center dark:border-dark-border dark:bg-dark-paper"
+ className="mx-auto max-w-md rounded-2xl border border-gray-100
bg-white p-12 text-center shadow-sm dark:border-gray-800 dark:bg-dark-paper"
>
- {icon && (
- <Box className="mb-4 flex justify-center text-gray-400
dark:text-gray-500">
- {icon}
- </Box>
- )}
- <Typography variant="h6" className="mb-2 font-medium text-gray-800
dark:text-gray-100">
+ {icon && <Box className="mb-6 flex justify-center">{icon}</Box>}
+ <Typography variant="h5" className="mb-3 font-medium text-gray-800
dark:text-gray-100">
{title}
</Typography>
- <Typography variant="body2" className="mb-6 text-gray-500
dark:text-gray-400">
+ <Typography variant="body1" className="mb-8 text-gray-500
dark:text-gray-400">
{description}
</Typography>
{action && (
- <Button variant="contained" className="btn btn-primary"
onClick={action.onClick}>
+ <Button
+ variant="contained"
+ color="primary"
+ className="rounded-full px-6 py-2.5 font-medium shadow-md
transition-all hover:shadow-lg"
+ onClick={action.onClick}
+ disableElevation
+ >
{action.label}
</Button>
)}
diff --git a/webui/src/app/ui/formCreation.tsx
b/webui/src/app/ui/formCreation.tsx
index 54d36e6..c7ab479 100644
--- a/webui/src/app/ui/formCreation.tsx
+++ b/webui/src/app/ui/formCreation.tsx
@@ -32,6 +32,7 @@ import { useRouter } from "next/navigation";
type NamespaceFormProps = {
position: string;
+ children?: React.ReactNode;
};
type ClusterFormProps = {
@@ -66,7 +67,7 @@ const validateFormData = (formData: FormData, fields:
string[]): string | null =
return null;
};
-export const NamespaceCreation: React.FC<NamespaceFormProps> = ({ position })
=> {
+export const NamespaceCreation: React.FC<NamespaceFormProps> = ({ position,
children }) => {
const router = useRouter();
const handleSubmit = async (formData: FormData) => {
const fieldsToValidate = ["name"];
diff --git a/webui/src/app/ui/formDialog.tsx b/webui/src/app/ui/formDialog.tsx
index da6a107..798ac97 100644
--- a/webui/src/app/ui/formDialog.tsx
+++ b/webui/src/app/ui/formDialog.tsx
@@ -37,6 +37,8 @@ import {
FormControl,
Paper,
CircularProgress,
+ alpha,
+ useTheme,
} from "@mui/material";
import React, { useCallback, useState, FormEvent } from "react";
import AddIcon from "@mui/icons-material/Add";
@@ -68,6 +70,7 @@ const FormDialog: React.FC<FormDialogProps> = ({
const [errorMessage, setErrorMessage] = useState("");
const [arrayValues, setArrayValues] = useState<{ [key: string]: string[]
}>({});
const [submitting, setSubmitting] = useState(false);
+ const theme = useTheme();
const handleArrayChange = (name: string, value: string[]) => {
setArrayValues({ ...arrayValues, [name]: value });
@@ -102,9 +105,15 @@ const FormDialog: React.FC<FormDialogProps> = ({
<Button
variant="contained"
onClick={openDialog}
- className="btn btn-primary px-3 py-1 text-xs"
+ className="rounded-full px-4 py-1.5 text-xs font-medium
shadow-sm transition-all duration-200 hover:shadow-md"
startIcon={<AddIcon sx={{ fontSize: 16 }} />}
size="small"
+ sx={{
+ textTransform: "none",
+ "&:hover": {
+ transform: "translateY(-1px)",
+ },
+ }}
>
{title}
</Button>
@@ -112,8 +121,15 @@ const FormDialog: React.FC<FormDialogProps> = ({
<Button
variant="outlined"
onClick={openDialog}
- className="btn btn-outline w-full"
+ className="w-full rounded-xl border-2 py-2.5 font-medium
shadow-sm transition-all duration-200 hover:bg-primary/5 hover:shadow-md
dark:border-primary-dark/60 dark:hover:bg-primary-dark/10"
startIcon={<AddIcon />}
+ sx={{
+ textTransform: "none",
+ borderWidth: "1.5px",
+ "&:hover": {
+ borderWidth: "1.5px",
+ },
+ }}
>
{title}
</Button>
@@ -123,22 +139,55 @@ const FormDialog: React.FC<FormDialogProps> = ({
open={showDialog}
onClose={closeDialog}
PaperProps={{
- className: "rounded-lg shadow-xl",
+ className: "overflow-hidden",
+ sx: {
+ borderRadius: "16px",
+ boxShadow: "0 25px 50px -12px rgba(0, 0, 0, 0.25)",
+ maxWidth: "500px",
+ width: "100%",
+ backgroundImage:
+ theme.palette.mode === "dark"
+ ? "linear-gradient(to bottom, rgba(66, 66, 66,
0.8), rgba(33, 33, 33, 0.9))"
+ : "linear-gradient(to bottom, #ffffff,
#f9fafb)",
+ backdropFilter: "blur(20px)",
+ overflow: "hidden",
+ },
}}
- maxWidth="sm"
- fullWidth
+ TransitionProps={{}}
>
<form onSubmit={handleSubmit}>
- <DialogTitle className="border-b border-light-border
bg-gray-50 px-6 py-4 dark:border-dark-border dark:bg-dark-paper">
- <Typography variant="h6" className="font-medium">
+ <DialogTitle
+ className="border-b border-light-border px-6 py-5
dark:border-dark-border"
+ sx={{
+ background:
+ theme.palette.mode === "dark"
+ ? alpha(theme.palette.background.paper,
0.5)
+ : alpha(theme.palette.primary.light, 0.05),
+ }}
+ >
+ <Typography
+ variant="h6"
+ className="font-semibold text-gray-800
dark:text-gray-100"
+ >
{title}
</Typography>
</DialogTitle>
- <DialogContent className="p-6">
+
+ <DialogContent
+ className="p-6"
+ sx={{
+ "&:first-of-type": {
+ paddingTop: "24px",
+ },
+ }}
+ >
{formFields.map((field, index) =>
field.type === "array" ? (
- <Box key={index} mb={3} mt={index === 0 ? 3 :
2}>
- <Typography variant="subtitle2"
className="mb-2 font-medium">
+ <Box key={index} mb={3} mt={index === 0 ? 0 :
2}>
+ <Typography
+ variant="subtitle2"
+ className="mb-2 font-medium
text-gray-700 dark:text-gray-300"
+ >
{field.label}
</Typography>
<Autocomplete
@@ -156,7 +205,24 @@ const FormDialog: React.FC<FormDialogProps> = ({
key={index}
label={option}
size="small"
-
className="bg-primary-light/20 dark:bg-primary-dark/20"
+ className="rounded-full
bg-primary-light/20 dark:bg-primary-dark/20"
+ sx={{
+ fontWeight: 500,
+ "&
.MuiChip-deleteIcon": {
+ color:
+
theme.palette.mode === "dark"
+ ? alpha(
+
theme.palette.primary
+
.light,
+ 0.7
+ )
+ : alpha(
+
theme.palette.primary
+
.main,
+ 0.7
+ ),
+ },
+ }}
/>
))
}
@@ -167,7 +233,16 @@ const FormDialog: React.FC<FormDialogProps> = ({
label={`Add ${field.label}*`}
placeholder="Type and press
enter"
size="small"
- className="rounded-md bg-white
dark:bg-dark-paper"
+ className="rounded-xl bg-white
dark:bg-dark-paper/70"
+ sx={{
+ "&
.MuiOutlinedInput-root": {
+ borderRadius: "12px",
+ transition: "all 0.2s
ease",
+ "&.Mui-focused": {
+ boxShadow: `0 0 0
2px ${alpha(theme.palette.primary.main, 0.2)}`,
+ },
+ },
+ }}
/>
)}
/>
@@ -176,9 +251,9 @@ const FormDialog: React.FC<FormDialogProps> = ({
<FormControl
key={index}
fullWidth
- sx={{ mt: index === 0 ? 3 : 3, mb: 2 }}
+ sx={{ mt: index === 0 ? 0 : 3, mb: 2 }}
>
- <InputLabel id={`${field.name}-label`}>
+ <InputLabel id={`${field.name}-label`}
className="font-medium">
{field.label}
</InputLabel>
<Select
@@ -188,10 +263,42 @@ const FormDialog: React.FC<FormDialogProps> = ({
required={field.required}
defaultValue=""
size="small"
- className="rounded-md bg-white
dark:bg-dark-paper"
+ className="rounded-xl bg-white
dark:bg-dark-paper/70"
+ sx={{
+ borderRadius: "12px",
+ "&
.MuiOutlinedInput-notchedOutline": {
+ borderColor:
alpha(theme.palette.divider, 0.8),
+ },
+ "&.Mui-focused
.MuiOutlinedInput-notchedOutline": {
+ boxShadow: `0 0 0 2px
${alpha(theme.palette.primary.main, 0.2)}`,
+ },
+ "& .MuiSelect-select": {
+ display: "flex",
+ alignItems: "center",
+ minHeight: "32px",
+ },
+ }}
+ MenuProps={{
+ PaperProps: {
+ sx: {
+ borderRadius: "12px",
+ boxShadow:
+ "0 10px 25px -5px
rgba(0, 0, 0, 0.1)",
+ mt: 1,
+ },
+ },
+ }}
>
{field.values?.map((value, index) => (
- <MenuItem key={index}
value={value}>
+ <MenuItem
+ key={index}
+ value={value}
+ sx={{
+ minHeight: "32px",
+ display: "flex",
+ alignItems: "center",
+ }}
+ >
{value}
</MenuItem>
))}
@@ -209,20 +316,46 @@ const FormDialog: React.FC<FormDialogProps> = ({
variant="outlined"
margin="normal"
size="small"
- className="rounded-md bg-white
dark:bg-dark-paper"
+ className="bg-white dark:bg-dark-paper/70"
sx={{
- mt: index === 0 ? 3 : 3,
+ mt: index === 0 ? 0 : 3,
mb: 1.5,
+ "& .MuiOutlinedInput-root": {
+ borderRadius: "12px",
+ transition: "all 0.2s ease",
+ "&.Mui-focused": {
+ boxShadow: `0 0 0 2px
${alpha(theme.palette.primary.main, 0.2)}`,
+ },
+ },
+ "& .MuiInputLabel-root": {
+ fontWeight: 500,
+ },
}}
/>
)
)}
</DialogContent>
- <DialogActions className="border-t border-light-border
bg-gray-50 p-4 dark:border-dark-border dark:bg-dark-paper">
+
+ <DialogActions
+ className="border-t border-light-border p-4
dark:border-dark-border"
+ sx={{
+ background:
+ theme.palette.mode === "dark"
+ ? alpha(theme.palette.background.paper,
0.5)
+ : alpha(theme.palette.primary.light, 0.05),
+ padding: "16px 24px",
+ justifyContent: "space-between",
+ }}
+ >
<Button
onClick={closeDialog}
disabled={submitting}
- className="text-gray-600 hover:bg-gray-100
dark:text-gray-300 dark:hover:bg-dark-border"
+ className="rounded-xl px-4 py-1.5 text-gray-600
hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-dark-border"
+ sx={{
+ textTransform: "none",
+ fontWeight: 500,
+ transition: "all 0.2s ease",
+ }}
>
Cancel
</Button>
@@ -230,7 +363,16 @@ const FormDialog: React.FC<FormDialogProps> = ({
type="submit"
variant="contained"
disabled={submitting}
- className="btn-primary"
+ className="rounded-xl px-5 py-1.5 shadow-sm
transition-all duration-200 hover:shadow-md"
+ sx={{
+ textTransform: "none",
+ fontWeight: 600,
+ transition: "all 0.2s ease",
+ "&:hover": {
+ transform: "translateY(-1px)",
+ boxShadow: "0 6px 15px rgba(0, 0, 0, 0.1)",
+ },
+ }}
startIcon={
submitting ? <CircularProgress size={16}
color="inherit" /> : null
}
@@ -240,6 +382,7 @@ const FormDialog: React.FC<FormDialogProps> = ({
</DialogActions>
</form>
</Dialog>
+
<Snackbar
open={!!errorMessage}
autoHideDuration={5000}
@@ -251,7 +394,11 @@ const FormDialog: React.FC<FormDialogProps> = ({
onClose={() => setErrorMessage("")}
severity="error"
variant="filled"
- className="shadow-lg"
+ className="rounded-xl shadow-lg"
+ sx={{
+ borderRadius: "12px",
+ boxShadow: "0 10px 30px rgba(0, 0, 0, 0.15)",
+ }}
>
{errorMessage}
</Alert>
diff --git a/webui/src/app/ui/sidebar.tsx b/webui/src/app/ui/sidebar.tsx
index 46018b7..8fcedc4 100644
--- a/webui/src/app/ui/sidebar.tsx
+++ b/webui/src/app/ui/sidebar.tsx
@@ -47,21 +47,32 @@ const SidebarHeader = ({
icon: React.ReactNode;
}) => (
<div
- className="mb-2 flex cursor-pointer items-center justify-between
rounded-md bg-gray-50 px-4 py-3 transition-colors hover:bg-gray-100
dark:bg-dark-paper dark:hover:bg-dark-border"
+ className="mb-0 flex cursor-pointer items-center justify-between
rounded-xl bg-gray-50/80 px-4 py-2 shadow-sm transition-all duration-200
hover:bg-gray-100 hover:shadow-md dark:bg-dark-paper/80
dark:hover:bg-dark-border"
onClick={toggleOpen}
>
<div className="flex items-center space-x-2">
- {icon}
- <Typography variant="subtitle1" className="font-medium">
+ <div className="flex h-8 w-8 items-center justify-center
rounded-full bg-primary/10 text-primary dark:bg-primary-dark/20
dark:text-primary-light">
+ {icon}
+ </div>
+ <Typography
+ variant="subtitle1"
+ className="font-medium text-gray-800 dark:text-gray-200"
+ >
{title}
</Typography>
{count > 0 && (
- <span className="rounded-full bg-primary px-2 py-0.5 text-xs
text-white dark:bg-primary-dark">
+ <span className="flex h-5 min-w-5 items-center justify-center
rounded-full bg-primary/90 px-1.5 text-xs font-medium text-white shadow-sm
dark:bg-primary">
{count}
</span>
)}
</div>
- {isOpen ? <ExpandMoreIcon fontSize="small" /> : <ChevronRightIcon
fontSize="small" />}
+ <div className="flex h-7 w-7 items-center justify-center rounded-full
bg-white/80 text-gray-500 transition-transform dark:bg-dark-border/60
dark:text-gray-400">
+ {isOpen ? (
+ <ExpandMoreIcon fontSize="small" className="transform
transition-transform" />
+ ) : (
+ <ChevronRightIcon fontSize="small" className="transform
transition-transform" />
+ )}
+ </div>
</div>
);
@@ -69,6 +80,18 @@ export function NamespaceSidebar() {
const [namespaces, setNamespaces] = useState<string[]>([]);
const [error, setError] = useState<string | null>(null);
const [isOpen, setIsOpen] = useState(true);
+ const [sidebarWidth, setSidebarWidth] = useState(260);
+ const [isMobile, setIsMobile] = useState(false);
+
+ useEffect(() => {
+ const checkMobile = () => {
+ setIsMobile(window.innerWidth < 768);
+ };
+
+ checkMobile();
+ window.addEventListener("resize", checkMobile);
+ return () => window.removeEventListener("resize", checkMobile);
+ }, []);
useEffect(() => {
const fetchData = async () => {
@@ -82,38 +105,78 @@ export function NamespaceSidebar() {
fetchData();
}, []);
+ const toggleSidebar = () => {
+ if (isMobile) {
+ setSidebarWidth(isOpen ? 0 : 260);
+ }
+ setIsOpen(!isOpen);
+ };
+
return (
<Paper
- className="flex h-full w-64 flex-col overflow-hidden border-r
border-light-border dark:border-dark-border"
+ className="sidebar-container flex h-full flex-col overflow-hidden
border-r border-light-border/50 bg-white/90 backdrop-blur-sm transition-all
duration-300 dark:border-dark-border/50 dark:bg-dark-paper/90"
elevation={0}
- square
+ sx={{
+ width: `${sidebarWidth}px`,
+ minWidth: isMobile ? 0 : "260px",
+ maxWidth: "260px",
+ borderTopRightRadius: "16px",
+ borderBottomRightRadius: "16px",
+ boxShadow: "4px 0 15px rgba(0, 0, 0, 0.03)",
+ transition: "width 0.3s cubic-bezier(0.4, 0, 0.2, 1)",
+ }}
>
- <Box className="p-4">
- <NamespaceCreation position="sidebar" />
- </Box>
+ {isMobile && (
+ <button
+ onClick={toggleSidebar}
+ className="sidebar-toggle-btn absolute -right-10 top-4
z-50 flex h-9 w-9 items-center justify-center rounded-full bg-white
text-gray-600 shadow-lg transition-all hover:bg-gray-50 dark:bg-dark-paper
dark:text-gray-300 dark:hover:bg-dark-border"
+ >
+ {isOpen ? (
+ <ChevronRightIcon />
+ ) : (
+ <ChevronRightIcon sx={{ transform: "rotate(180deg)" }}
/>
+ )}
+ </button>
+ )}
- <SidebarHeader
- title="Namespaces"
- count={namespaces.length}
- isOpen={isOpen}
- toggleOpen={() => setIsOpen(!isOpen)}
- icon={<FolderIcon className="text-primary
dark:text-primary-light" />}
- />
+ <div className="sidebar-inner w-[260px]">
+ <Box className="p-4 pb-2">
+ <NamespaceCreation position="sidebar" />
+ </Box>
- <Collapse in={isOpen}>
- <List className="max-h-[calc(100vh-180px)] overflow-y-auto
px-2">
- {error && (
- <Typography color="error" align="center"
className="py-2 text-sm">
- {error}
- </Typography>
- )}
- {namespaces.map((namespace) => (
- <Link href={`/namespaces/${namespace}`} passHref
key={namespace}>
- <Item type="namespace" item={namespace} />
- </Link>
- ))}
- </List>
- </Collapse>
+ <Box className="px-4 py-2">
+ <SidebarHeader
+ title="Namespaces"
+ count={namespaces.length}
+ isOpen={isOpen}
+ toggleOpen={toggleSidebar}
+ icon={<FolderIcon fontSize="small" />}
+ />
+ </Box>
+
+ <Collapse in={isOpen} className="flex-1 overflow-hidden">
+ <div className="h-full overflow-hidden px-4">
+ <div className="sidebar-scrollbar
max-h-[calc(100vh-200px)] overflow-y-auto rounded-xl bg-gray-50/50 p-2
dark:bg-dark-border/20">
+ {error && (
+ <div className="my-2 rounded-lg bg-red-50 p-2
text-center text-sm text-red-600 dark:bg-red-900/20 dark:text-red-400">
+ {error}
+ </div>
+ )}
+ <List className="p-0">
+ {namespaces.map((namespace) => (
+ <Link
+ href={`/namespaces/${namespace}`}
+ passHref
+ key={namespace}
+ >
+ <Item type="namespace"
item={namespace} />
+ </Link>
+ ))}
+ </List>
+ </div>
+ </div>
+ </Collapse>
+ </div>
</Paper>
);
}
@@ -137,39 +200,49 @@ export function ClusterSidebar({ namespace }: {
namespace: string }) {
return (
<Paper
- className="flex h-full w-64 flex-col overflow-hidden border-r
border-light-border dark:border-dark-border"
+ className="flex h-full w-64 flex-col overflow-hidden border-r
border-light-border/50 bg-white/90 backdrop-blur-sm dark:border-dark-border/50
dark:bg-dark-paper/90"
elevation={0}
- square
+ sx={{
+ borderTopRightRadius: "16px",
+ borderBottomRightRadius: "16px",
+ boxShadow: "4px 0 15px rgba(0, 0, 0, 0.03)",
+ }}
>
- <Box className="p-4">
+ <Box className="p-4 pb-2">
<ClusterCreation namespace={namespace} position="sidebar" />
</Box>
- <SidebarHeader
- title="Clusters"
- count={clusters.length}
- isOpen={isOpen}
- toggleOpen={() => setIsOpen(!isOpen)}
- icon={<StorageIcon className="text-primary
dark:text-primary-light" />}
- />
+ <Box className="px-4 py-2">
+ <SidebarHeader
+ title="Clusters"
+ count={clusters.length}
+ isOpen={isOpen}
+ toggleOpen={() => setIsOpen(!isOpen)}
+ icon={<StorageIcon fontSize="small" />}
+ />
+ </Box>
- <Collapse in={isOpen}>
- <List className="max-h-[calc(100vh-180px)] overflow-y-auto
px-2">
- {error && (
- <Typography color="error" align="center"
className="py-2 text-sm">
- {error}
- </Typography>
- )}
- {clusters.map((cluster) => (
- <Link
-
href={`/namespaces/${namespace}/clusters/${cluster}`}
- passHref
- key={cluster}
- >
- <Item type="cluster" item={cluster}
namespace={namespace} />
- </Link>
- ))}
- </List>
+ <Collapse in={isOpen} className="flex-1 overflow-hidden">
+ <div className="h-full overflow-hidden px-4">
+ <div className="custom-scrollbar max-h-[calc(100vh-180px)]
overflow-y-auto rounded-xl bg-gray-50/50 p-2 dark:bg-dark-border/20">
+ {error && (
+ <div className="my-2 rounded-lg bg-red-50 p-2
text-center text-sm text-red-600 dark:bg-red-900/20 dark:text-red-400">
+ {error}
+ </div>
+ )}
+ <List className="p-0">
+ {clusters.map((cluster) => (
+ <Link
+
href={`/namespaces/${namespace}/clusters/${cluster}`}
+ passHref
+ key={cluster}
+ >
+ <Item type="cluster" item={cluster}
namespace={namespace} />
+ </Link>
+ ))}
+ </List>
+ </div>
+ </div>
</Collapse>
</Paper>
);
@@ -197,44 +270,54 @@ export function ShardSidebar({ namespace, cluster }: {
namespace: string; cluste
return (
<Paper
- className="flex h-full w-64 flex-col overflow-hidden border-r
border-light-border dark:border-dark-border"
+ className="flex h-full w-64 flex-col overflow-hidden border-r
border-light-border/50 bg-white/90 backdrop-blur-sm dark:border-dark-border/50
dark:bg-dark-paper/90"
elevation={0}
- square
+ sx={{
+ borderTopRightRadius: "16px",
+ borderBottomRightRadius: "16px",
+ boxShadow: "4px 0 15px rgba(0, 0, 0, 0.03)",
+ }}
>
- <Box className="p-4">
+ <Box className="p-4 pb-2">
<ShardCreation namespace={namespace} cluster={cluster}
position="sidebar" />
</Box>
- <SidebarHeader
- title="Shards"
- count={shards.length}
- isOpen={isOpen}
- toggleOpen={() => setIsOpen(!isOpen)}
- icon={<DnsIcon className="text-primary
dark:text-primary-light" />}
- />
+ <Box className="px-4 py-2">
+ <SidebarHeader
+ title="Shards"
+ count={shards.length}
+ isOpen={isOpen}
+ toggleOpen={() => setIsOpen(!isOpen)}
+ icon={<DnsIcon fontSize="small" />}
+ />
+ </Box>
- <Collapse in={isOpen}>
- <List className="max-h-[calc(100vh-180px)] overflow-y-auto
px-2">
- {error && (
- <Typography color="error" align="center"
className="py-2 text-sm">
- {error}
- </Typography>
- )}
- {shards.map((shard, index) => (
- <Link
-
href={`/namespaces/${namespace}/clusters/${cluster}/shards/${index}`}
- passHref
- key={index}
- >
- <Item
- type="shard"
- item={shard}
- namespace={namespace}
- cluster={cluster}
- />
- </Link>
- ))}
- </List>
+ <Collapse in={isOpen} className="flex-1 overflow-hidden">
+ <div className="h-full overflow-hidden px-4">
+ <div className="custom-scrollbar max-h-[calc(100vh-180px)]
overflow-y-auto rounded-xl bg-gray-50/50 p-2 dark:bg-dark-border/20">
+ {error && (
+ <div className="my-2 rounded-lg bg-red-50 p-2
text-center text-sm text-red-600 dark:bg-red-900/20 dark:text-red-400">
+ {error}
+ </div>
+ )}
+ <List className="p-0">
+ {shards.map((shard, index) => (
+ <Link
+
href={`/namespaces/${namespace}/clusters/${cluster}/shards/${index}`}
+ passHref
+ key={index}
+ >
+ <Item
+ type="shard"
+ item={shard}
+ namespace={namespace}
+ cluster={cluster}
+ />
+ </Link>
+ ))}
+ </List>
+ </div>
+ </div>
</Collapse>
</Paper>
);
@@ -275,11 +358,15 @@ export function NodeSidebar({
return (
<Paper
- className="flex h-full w-64 flex-col overflow-hidden border-r
border-light-border dark:border-dark-border"
+ className="flex h-full w-64 flex-col overflow-hidden border-r
border-light-border/50 bg-white/90 backdrop-blur-sm dark:border-dark-border/50
dark:bg-dark-paper/90"
elevation={0}
- square
+ sx={{
+ borderTopRightRadius: "16px",
+ borderBottomRightRadius: "16px",
+ boxShadow: "4px 0 15px rgba(0, 0, 0, 0.03)",
+ }}
>
- <Box className="p-4">
+ <Box className="p-4 pb-2">
<NodeCreation
namespace={namespace}
cluster={cluster}
@@ -288,38 +375,44 @@ export function NodeSidebar({
/>
</Box>
- <SidebarHeader
- title="Nodes"
- count={nodes.length}
- isOpen={isOpen}
- toggleOpen={() => setIsOpen(!isOpen)}
- icon={<DeviceHubIcon className="text-primary
dark:text-primary-light" />}
- />
+ <Box className="px-4 py-2">
+ <SidebarHeader
+ title="Nodes"
+ count={nodes.length}
+ isOpen={isOpen}
+ toggleOpen={() => setIsOpen(!isOpen)}
+ icon={<DeviceHubIcon fontSize="small" />}
+ />
+ </Box>
- <Collapse in={isOpen}>
- <List className="max-h-[calc(100vh-180px)] overflow-y-auto
px-2">
- {error && (
- <Typography color="error" align="center"
className="py-2 text-sm">
- {error}
- </Typography>
- )}
- {nodes.map((node, index) => (
- <Link
-
href={`/namespaces/${namespace}/clusters/${cluster}/shards/${shard}/nodes/${index}`}
- passHref
- key={index}
- >
- <Item
- type="node"
- item={`Node\t${index + 1}`}
- id={node.id}
- namespace={namespace}
- cluster={cluster}
- shard={shard}
- />
- </Link>
- ))}
- </List>
+ <Collapse in={isOpen} className="flex-1 overflow-hidden">
+ <div className="h-full overflow-hidden px-4">
+ <div className="custom-scrollbar max-h-[calc(100vh-180px)]
overflow-y-auto rounded-xl bg-gray-50/50 p-2 dark:bg-dark-border/20">
+ {error && (
+ <div className="my-2 rounded-lg bg-red-50 p-2
text-center text-sm text-red-600 dark:bg-red-900/20 dark:text-red-400">
+ {error}
+ </div>
+ )}
+ <List className="p-0">
+ {nodes.map((node, index) => (
+ <Link
+
href={`/namespaces/${namespace}/clusters/${cluster}/shards/${shard}/nodes/${index}`}
+ passHref
+ key={index}
+ >
+ <Item
+ type="node"
+ item={`Node\t${index + 1}`}
+ id={node.id}
+ namespace={namespace}
+ cluster={cluster}
+ shard={shard}
+ />
+ </Link>
+ ))}
+ </List>
+ </div>
+ </div>
</Collapse>
</Paper>
);
diff --git a/webui/src/app/ui/sidebarItem.tsx b/webui/src/app/ui/sidebarItem.tsx
index f065f9a..de46a5f 100644
--- a/webui/src/app/ui/sidebarItem.tsx
+++ b/webui/src/app/ui/sidebarItem.tsx
@@ -186,19 +186,29 @@ export default function Item(props: ItemProps) {
onMouseLeave={() => !showMenu && setHover(false)}
>
<ListItemButton
- className={`group rounded-md transition-colors ${
+ className={`group rounded-lg transition-all duration-200 ${
isActive
- ? "bg-primary-light/10 text-primary
dark:text-primary-light"
- : "hover:bg-gray-100 dark:hover:bg-dark-border"
+ ? "bg-primary/10 text-primary shadow-sm
dark:bg-primary-dark/20 dark:text-primary-light"
+ : "hover:bg-gray-100/80 hover:shadow-sm
dark:hover:bg-dark-border/60"
}`}
dense
+ sx={{
+ padding: "6px 10px",
+ borderRadius: "8px",
+ }}
>
- <ListItemIcon sx={{ minWidth: 36
}}>{getItemIcon()}</ListItemIcon>
+ <ListItemIcon sx={{ minWidth: 32 }}>
+ <div
+ className={`flex h-6 w-6 items-center justify-center
rounded-full ${isActive ? "bg-white/80 dark:bg-dark-paper/60" : "bg-gray-100
dark:bg-dark-border"}`}
+ >
+ {getItemIcon()}
+ </div>
+ </ListItemIcon>
<ListItemText
primary={displayName}
className="overflow-hidden text-ellipsis"
primaryTypographyProps={{
- className: "text-sm font-medium",
+ className: `text-sm font-medium ${isActive ?
"text-primary dark:text-primary-light" : "text-gray-700 dark:text-gray-300"}`,
noWrap: true,
}}
/>
@@ -207,9 +217,23 @@ export default function Item(props: ItemProps) {
size="small"
edge="end"
onClick={openMenu}
- className="opacity-0 transition-opacity
group-hover:opacity-100"
+ className="ml-1 opacity-0 transition-all duration-200
group-hover:opacity-100"
+ sx={{
+ width: 26,
+ height: 26,
+ backgroundColor: "rgba(0, 0, 0, 0.04)",
+ "&:hover": {
+ backgroundColor: "rgba(0, 0, 0, 0.08)",
+ },
+ ".dark &": {
+ backgroundColor: "rgba(255, 255, 255, 0.1)",
+ },
+ ".dark &:hover": {
+ backgroundColor: "rgba(255, 255, 255, 0.15)",
+ },
+ }}
>
- <MoreVertIcon fontSize="small" />
+ <MoreVertIcon sx={{ fontSize: 16 }} />
</IconButton>
)}
</ListItemButton>
@@ -228,7 +252,8 @@ export default function Item(props: ItemProps) {
horizontal: "right",
}}
PaperProps={{
- className: "shadow-lg",
+ className: "shadow-lg rounded-lg",
+ elevation: 3,
}}
>
<MenuItem
@@ -245,11 +270,14 @@ export default function Item(props: ItemProps) {
onClose={closeDeleteConfirmDialog}
className="backdrop-blur-sm"
PaperProps={{
- className: "rounded-lg shadow-xl",
+ className: "rounded-xl shadow-xl",
+ sx: { minWidth: 320 },
}}
>
- <DialogTitle className="font-medium">Confirm
Delete</DialogTitle>
- <DialogContent>
+ <DialogTitle className="border-b border-gray-100 pb-3
font-semibold dark:border-gray-800">
+ Confirm Delete
+ </DialogTitle>
+ <DialogContent className="mt-4">
{type === "node" || type === "shard" ? (
<DialogContentText>
Are you sure you want to delete {displayName}?
@@ -261,15 +289,19 @@ export default function Item(props: ItemProps) {
</DialogContentText>
)}
</DialogContent>
- <DialogActions className="p-4">
- <Button onClick={closeDeleteConfirmDialog}
variant="outlined">
+ <DialogActions className="border-t border-gray-100 p-4
dark:border-gray-800">
+ <Button
+ onClick={closeDeleteConfirmDialog}
+ variant="outlined"
+ className="rounded-lg px-4"
+ >
Cancel
</Button>
<Button
onClick={confirmDelete}
variant="contained"
color="error"
- className="bg-error hover:bg-error-dark"
+ className="rounded-lg bg-error px-4
hover:bg-error-dark"
>
Delete
</Button>
@@ -286,7 +318,7 @@ export default function Item(props: ItemProps) {
onClose={() => setErrorMessage("")}
severity="error"
variant="filled"
- sx={{ width: "100%" }}
+ sx={{ width: "100%", borderRadius: "8px" }}
>
{errorMessage}
</Alert>
diff --git a/webui/tailwind.config.ts b/webui/tailwind.config.ts
index a1e2761..387c2dd 100644
--- a/webui/tailwind.config.ts
+++ b/webui/tailwind.config.ts
@@ -78,10 +78,12 @@ const config: Config = {
"conic-gradient(from 180deg at 50% 50%,
var(--tw-gradient-stops))",
},
boxShadow: {
- card: "0 2px 8px rgba(0, 0, 0, 0.08)",
- "card-hover": "0 4px 12px rgba(0, 0, 0, 0.15)",
+ card: "0 2px 8px rgba(0, 0, 0, 0.06)",
+ "card-hover": "0 4px 12px rgba(0, 0, 0, 0.1)",
sidebar: "2px 0 5px rgba(0, 0, 0, 0.05)",
"footer-glow": "0 8px 25px rgba(0, 0, 0, 0.15)",
+ subtle: "0 2px 5px rgba(0, 0, 0, 0.05)",
+ elevated: "0 10px 30px rgba(0, 0, 0, 0.08)",
},
transitionProperty: {
height: "height",
@@ -102,6 +104,11 @@ const config: Config = {
"100%": { opacity: "1", transform: "scale(1)" },
},
},
+ borderRadius: {
+ "2xl": "16px",
+ "3xl": "24px",
+ "4xl": "32px",
+ },
},
},
plugins: [],
