This commit is contained in:
root
2025-10-28 14:09:42 +00:00
parent d65585b821
commit e96cd63f33
20 changed files with 1255 additions and 404 deletions
+2
View File
@@ -32,6 +32,8 @@
<!-- Nepcha Analytics (nepcha.com) -->
<!-- Nepcha is a easy-to-use web analytics. No cookies and fully compliant with GDPR, CCPA and PECR. -->
<script defer data-site="YOUR_DOMAIN_HERE" src="https://api.nepcha.com/js/nepcha-analytics.js"></script>
<link rel="stylesheet" href="/src/tailwind.css" />
</head>
<body>
<div id="root"></div>
Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

+2
View File
@@ -9,6 +9,7 @@ import {
Footer,
} from "@/widgets/layout";
import routes from "@/routes";
import { UserDetails } from "@/pages/dashboard/userDetails.jsx";
import { useMaterialTailwindController, setOpenConfigurator } from "@/components";
export function Dashboard() {
@@ -154,6 +155,7 @@ export function Dashboard() {
<Route exact path={path} element={element} />
))
)}
<Route path="users/:userId" element={<UserDetails />} />
</Routes>
<div className="text-blue-gray-600">
<Footer />
+2 -1
View File
@@ -6,6 +6,7 @@ import {
Alert,
} from "@material-tailwind/react";
import { useNavigate } from "react-router-dom";
import pattern from "@/assets/images/pattern.png";
export function SignIn() {
const navigate = useNavigate();
@@ -195,7 +196,7 @@ export function SignIn() {
</form>
</div>
<div className="w-2/5 h-full hidden lg:block">
<img src="/img/pattern.png" className="h-full w-full object-cover rounded-3xl" />
<img src={pattern} className="h-full w-full object-cover rounded-3xl" />
</div>
</section>
);
+2 -1
View File
@@ -2,4 +2,5 @@ export * from "@/pages/dashboard/stacks";
export * from "@/pages/dashboard/maintenance";
export * from "@/pages/dashboard/logs";
export * from "@/pages/dashboard/users";
export * from "@/pages/dashboard/usergroups";
export * from "@/pages/dashboard/usergroups";
export * from "@/pages/dashboard/userDetails";
+116 -128
View File
@@ -148,20 +148,20 @@ export function Logs() {
const [optionsInitialized, setOptionsInitialized] = useState(false);
const [refreshSignal, setRefreshSignal] = useState(0);
const { showToast } = useToast();
const {
maintenance: maintenanceMeta,
update: updateState,
script: scriptConfig,
ssh: sshConfig,
} = useMaintenance();
const maintenanceActive = Boolean(maintenanceMeta?.active);
const maintenanceMessage = maintenanceMeta?.message;
const updateRunning = Boolean(updateState?.running);
const maintenanceLocked = maintenanceActive || updateRunning;
const updateStageLabel = updateState?.stage ? (UPDATE_STAGE_LABELS[updateState.stage] ?? updateState.stage) : "";
const { showToast } = useToast();
const {
maintenance: maintenanceMeta,
update: updateState,
script: scriptConfig,
ssh: sshConfig,
} = useMaintenance();
const maintenanceActive = Boolean(maintenanceMeta?.active);
const maintenanceMessage = maintenanceMeta?.message;
const updateRunning = Boolean(updateState?.running);
const maintenanceLocked = maintenanceActive || updateRunning;
const updateStageLabel = updateState?.stage ? (UPDATE_STAGE_LABELS[updateState.stage] ?? updateState.stage) : "";
const {
page,
@@ -620,8 +620,8 @@ export function Logs() {
const [open, setOpen] = React.useState(false);
const toggleOpen = () => setOpen((cur) => !cur);
return (
<div className="mt-12 mb-8 flex flex-col gap-12">
{(maintenanceActive || updateRunning) && (<div className="rounded-lg border border-cyan-500/60 bg-cyan-900/30 px-4 py-3 text-sm text-bluegray-100">
<div className="mt-12 mb-8">
{(maintenanceActive || updateRunning) && (<div className="rounded-lg border border-cyan-500/60 bg-cyan-900/30 px-4 py-3 text-sm text-bluegray-100">
<div className="flex flex-col gap-1">
<span>
Wartungsmodus aktiv{maintenanceMessage ? ` ${maintenanceMessage}` : updateRunning ? " Portainer-Update läuft" : ""}.
@@ -635,14 +635,14 @@ export function Logs() {
</div>
)}
<Card>
<CardHeader variant="gradient" color="gray" className="p-4 pt-2 pb-2">
<CardHeader variant="gradient" color="gray" className="mb-5 p-4">
<Typography variant="h6" color="white">
<button
onClick={handleToggleFilters}
className="flex w-full items-center justify-between"
>
<span className="flex items-center gap-2">
<span>Filter und Optionen</span>
<span>Logs</span>
{activeFilterCount > 0 && (
<span className="rounded-full bg-blue-gray-500/80 px-2 py-0.5 text-xs text-white">
@@ -651,14 +651,14 @@ export function Logs() {
)}
</span>
<span className="text-xs uppercase tracking-wide text-gray-400">
{filtersOpen ? "Ausblenden" : "Anzeigen"}
{filtersOpen ? "Filter ausblenden" : "Filter anzeigen"}
</span>
</button>
</Typography>
</CardHeader>
<CardBody>
<CardBody className="pt-0">
{filtersOpen && (
<div>
<div className="mb-8">
<div className="flex flex-wrap gap-2">
<Button
@@ -945,118 +945,106 @@ export function Logs() {
</div>
)}
</CardBody>
</Card>
<Card>
<CardHeader variant="gradient" color="gray" className="mb-5 p-4">
<Typography
variant="h6"
color="white"
className="flex items-center justify-between"
>
<span>Logs</span>
</Typography>
</CardHeader>
<CardBody className="overflow-x-scroll px-0 pt-0 pb-2">
<table className="w-full min-w-[640px] table-auto">
<thead>
<tr>
{["Zeitpunkt", "Stack", "Art", "Status", "Nachricht", "Endpoint", "Aktionen"].map((el) => (
<th
key={el}
className="border-b border-stormGrey-50 py-3 px-5 text-left"
>
<Typography
variant="small"
className="text-[11px] font-bold uppercase text-stormGrey-400"
>
<div className="overflow-x-auto rounded-lg border border-blue-gray-50">
<table className="w-full min-w-[720px] table-auto text-left">
<thead>
<tr className="bg-blue-gray-50/50 text-xs uppercase tracking-wide text-stormGrey-400">
{["Zeitpunkt", "Stack", "Art", "Status", "Nachricht", "Endpoint", "Aktionen"].map((el) => (
<th key={el} className="px-6 py-4 font-semibold">
{el}
</Typography>
</th>
))}
</tr>
</thead>
<tbody>
{logs.map((log) => {
const statusClass = STATUS_COLORS[log.status] || "text-blue-300";
const className = "py-3 px-5";
const stackDisplayName = log.stackName || "Unbekannt";
const showStackId = stackDisplayName !== '---' && log.stackId !== undefined && log.stackId !== null;
const redeployTypeLabel = log.redeployType
? (REDEPLOY_TYPE_LABELS[log.redeployType] ?? log.redeployType)
: '---';
return (
<tr key={log.id}>
<td className={className}>
<Typography
variant="small"
className="mb-1 block text-xs font-medium text-stormGrey-600">
{formatTimestamp(log.timestamp)}
</Typography>
</td>
<td className={className}>
<div className="flex flex-col">
<span className="font-medium">{stackDisplayName}</span>
<Typography
variant="small"
>
{showStackId && (
<span className="text-xs text-stormGrey-400">ID: {log.stackId}</span>
)}
</Typography>
</div>
</td>
<td className={className}>
<Typography
variant="small"
>
{redeployTypeLabel}
</Typography>
</td>
<td className={className}>
<Typography
variant="small"
className={`font-semibold ${statusClass}`}
>
{log.status}
</Typography>
</td>
<td className={className}>
<Typography
variant="small"
>
{log.message || "-"}
</Typography>
</td>
<td className={className}>
<Typography
variant="small"
>
{log.endpoint ?? "-"}
</Typography>
</td>
<td className={className}>
<Typography
variant="small"
>
<button
onClick={() => handleDeleteLog(log.id)}
disabled={actionLoading}
className="rounded-md border border-sunsetCoral-600 px-3 py-1 text-xs text-sunsetCoral-800 transition hover:bg-sunsetCoral-600/20 disabled:opacity-60">
Löschen
</button>
</Typography>
</th>
))}
</tr>
</thead>
<tbody>
{loading ? (
<tr>
<td colSpan={7} className="px-6 py-8 text-center text-stormGrey-400">
Logs werden geladen ...
</td>
</tr>
);
}
)}
</tbody>
</table>
) : logs.length === 0 ? (
<tr>
<td colSpan={7} className="px-6 py-8 text-center text-stormGrey-400">
Keine Logs gefunden.
</td>
</tr>
) : (
logs.map((log, index) => {
const statusClass = STATUS_COLORS[log.status] || "text-blue-300";
const stackDisplayName = log.stackName || "Unbekannt";
const showStackId = stackDisplayName !== '---' && log.stackId !== undefined && log.stackId !== null;
const redeployTypeLabel = log.redeployType
? (REDEPLOY_TYPE_LABELS[log.redeployType] ?? log.redeployType)
: '---';
const rowClass = index === logs.length - 1 ? "" : "border-b border-blue-gray-50";
return (
<tr key={log.id} className={`text-sm text-stormGrey-700 ${rowClass}`}>
<td className="px-6 py-4">
<Typography
variant="small"
className="mb-1 block text-xs font-medium text-stormGrey-600">
{formatTimestamp(log.timestamp)}
</Typography>
</td>
<td className="px-6 py-4">
<div className="flex flex-col">
<span className="font-medium">{stackDisplayName}</span>
<Typography variant="small">
{showStackId && (
<span className="text-xs text-stormGrey-400">ID: {log.stackId}</span>
)}
</Typography>
</div>
</td>
<td className="px-6 py-4">
<Typography variant="small">
{redeployTypeLabel}
</Typography>
</td>
<td className="px-6 py-4">
<Typography
variant="small"
className={`font-semibold ${statusClass}`}
>
{log.status}
</Typography>
</td>
<td className="px-6 py-4">
<Typography variant="small">
{log.message || "-"}
</Typography>
</td>
<td className="px-6 py-4">
<Typography variant="small">
{log.endpoint ?? "-"}
</Typography>
</td>
<td className="px-6 py-4">
<Typography variant="small">
<button
onClick={() => handleDeleteLog(log.id)}
disabled={actionLoading}
className="rounded-md border border-sunsetCoral-600 px-3 py-1 text-xs text-sunsetCoral-800 transition hover:bg-sunsetCoral-600/20 disabled:opacity-60">
Löschen
</button>
</Typography>
</td>
</tr>
);
})
)}
</tbody>
</table>
</div>
<div className="mt-6 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<Typography variant="small" color="gray">
{""}
</Typography>
<PaginationControls disabled={loading && logs.length === 0} />
</div>
</CardBody>
</Card>
<PaginationControls disabled={actionLoading} />
</div>
@@ -0,0 +1,570 @@
import React, { useCallback, useEffect, useMemo, useState } from "react";
import axios from "axios";
import { useParams } from "react-router-dom";
import {
Card,
CardHeader,
CardBody,
Typography,
Button,
Spinner,
CardFooter,
Tabs,
TabsHeader,
Tab,
Switch,
Tooltip,
Avatar,
Checkbox
} from "@material-tailwind/react";
import {
HomeIcon,
ChatBubbleLeftEllipsisIcon,
Cog6ToothIcon,
PencilIcon,
} from "@heroicons/react/24/outline";
import { Link } from "react-router-dom";
import { useToast } from "@/components/ToastProvider.jsx";
import { ProfileInfoCard } from "@/widgets/cards";
import { platformSettingsData, projectsData } from "@/data";
import { useMaintenance } from "@/components/MaintenanceProvider.jsx";
const DEFAULT_AVATAR = { background: "bg-blue-gray-500", text: "text-white" };
const AVATAR_COLORS = [
'bg-arcticBlue-600',
'bg-copperRust-500',
'bg-sunsetCoral-600',
'bg-mintTea-400',
'bg-lavenderSmoke-500',
'bg-emeraldMist-500',
'bg-roseQuartz-500',
'bg-auroraTeal-500',
'bg-citrusPunch-500',
'bg-mossGreen-400'
];
const _ = AVATAR_COLORS.join(" ");
const normalizeUserGroups = (rawGroups) => {
if (!Array.isArray(rawGroups)) {
return [];
}
return rawGroups
.map((group) => {
if (group && typeof group === "object") {
const id = Number(group.id);
const name = typeof group.name === "string" ? group.name : "";
if (!name) {
return null;
}
return {
id: Number.isFinite(id) ? id : null,
name
};
}
if (typeof group === "string") {
const name = group.trim();
return name ? { id: null, name } : null;
}
return null;
})
.filter(Boolean);
};
const mapUser = (item) => ({
id: item?.id ?? null,
username: item?.username || "",
email: item?.email || "",
isActive: Boolean(item?.isActive),
avatarColor: item?.avatarColor || null,
lastLogin: item?.lastLogin || null,
createdAt: item?.createdAt || null,
updatedAt: item?.updatedAt || null,
groups: normalizeUserGroups(item?.groups)
});
export function UserDetails() {
const { userId } = useParams();
const { showToast } = useToast();
const { maintenance } = useMaintenance();
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const [hasLoaded, setHasLoaded] = useState(false);
const [availableGroups, setAvailableGroups] = useState([]);
const [groupsLoading, setGroupsLoading] = useState(false);
const [groupsError, setGroupsError] = useState("");
const [selectedGroupIds, setSelectedGroupIds] = useState([]);
const [savingGroups, setSavingGroups] = useState(false);
const maintenanceActive = Boolean(maintenance?.active);
const numericUserId = useMemo(() => {
const asNumber = Number(userId);
return Number.isFinite(asNumber) ? asNumber : null;
}, [userId]);
const formatTimestamp = useCallback((value) => {
if (!value) {
return "";
}
const normalized = typeof value === "string" ? value.replace(" ", "T") : value;
const parsed = new Date(normalized);
if (Number.isNaN(parsed.getTime())) {
return value;
}
return new Intl.DateTimeFormat("de-DE", {
dateStyle: "short",
timeStyle: "short"
}).format(parsed);
}, []);
const fetchUserDetails = useCallback(async () => {
if (!numericUserId) {
setError("Ungültige Benutzer-ID.");
setUser(null);
setHasLoaded(true);
return;
}
setLoading(true);
setError("");
try {
const response = await axios.get(`/api/users/${numericUserId}`);
const item = mapUser(response.data?.item);
if (!item.id) {
throw new Error("USER_NOT_FOUND");
}
setUser(item);
setError("");
} catch (err) {
const serverError = err.response?.data?.error;
let message = "Benutzerdetails konnten nicht geladen werden.";
if (serverError === "USER_NOT_FOUND") {
message = "Der angeforderte Benutzer wurde nicht gefunden.";
} else if (serverError === "INVALID_USER_ID") {
message = "Die angegebene Benutzer-ID ist ungültig.";
} else if (err.response?.status === 404) {
message = "Der angeforderte Benutzer existiert nicht.";
}
setUser(null);
setError(message);
showToast({
variant: "error",
title: "Fehler beim Laden",
description: message
});
} finally {
setLoading(false);
setHasLoaded(true);
}
}, [numericUserId, showToast]);
const fetchAvailableGroups = useCallback(async () => {
setGroupsLoading(true);
setGroupsError("");
try {
const response = await axios.get("/api/groups");
const items = Array.isArray(response.data?.items) ? response.data.items : [];
const normalized = items
.map((item) => ({
id: Number(item.id),
name: item.name || "",
description: item.description || "",
memberCount: Number.isFinite(Number(item.memberCount)) ? Number(item.memberCount) : 0
}))
.filter((group) => Number.isFinite(group.id) && group.id > 0 && group.name)
.sort((a, b) => a.name.localeCompare(b.name, "de-DE"));
setAvailableGroups(normalized);
} catch (err) {
const message = err.response?.data?.error || err.message || "Benutzergruppen konnten nicht geladen werden.";
setGroupsError(message);
showToast({
variant: "error",
title: "Benutzergruppen",
description: message
});
} finally {
setGroupsLoading(false);
}
}, [showToast]);
useEffect(() => {
fetchUserDetails();
}, [fetchUserDetails]);
useEffect(() => {
fetchAvailableGroups();
}, [fetchAvailableGroups]);
const originalGroupIds = useMemo(() => {
if (!user || !Array.isArray(user.groups)) {
return [];
}
return user.groups
.map((group) => Number(group.id))
.filter((id) => Number.isFinite(id) && id > 0);
}, [user]);
useEffect(() => {
setSelectedGroupIds([...originalGroupIds]);
}, [originalGroupIds]);
const hasGroupChanges = useMemo(() => {
const normalize = (ids) =>
Array.from(
new Set(
ids
.map((id) => Number(id))
.filter((id) => Number.isFinite(id) && id > 0)
)
).sort((a, b) => a - b);
const next = normalize(selectedGroupIds);
const baseline = normalize(originalGroupIds);
if (next.length !== baseline.length) {
return true;
}
return next.some((value, index) => value !== baseline[index]);
}, [selectedGroupIds, originalGroupIds]);
const handleToggleGroup = useCallback((groupId) => {
const numeric = Number(groupId);
if (!Number.isFinite(numeric) || numeric <= 0) {
return;
}
setSelectedGroupIds((prev) => {
if (prev.includes(numeric)) {
return prev.filter((id) => id !== numeric);
}
return [...prev, numeric];
});
}, []);
const handleResetGroups = useCallback(() => {
setSelectedGroupIds([...originalGroupIds]);
}, [originalGroupIds]);
const handleSaveGroups = useCallback(async () => {
if (!user) {
return;
}
const payloadGroups = Array.from(
new Set(
selectedGroupIds
.map((id) => Number(id))
.filter((id) => Number.isFinite(id) && id > 0)
)
);
setSavingGroups(true);
setGroupsError("");
try {
const response = await axios.put(`/api/users/${user.id}/groups`, { groupIds: payloadGroups });
const updatedUser = mapUser(response.data?.item || response.data?.user);
setUser(updatedUser);
setSelectedGroupIds(
Array.isArray(updatedUser.groups)
? updatedUser.groups
.map((group) => Number(group.id))
.filter((id) => Number.isFinite(id) && id > 0)
: []
);
showToast({
variant: "success",
title: "Benutzergruppen aktualisiert",
description: "Die Gruppenzuordnung wurde gespeichert."
});
} catch (err) {
const serverError = err.response?.data?.error;
let message = err.message || "Benutzergruppen konnten nicht aktualisiert werden.";
if (serverError === "INVALID_USER_ID") {
message = "Die Benutzer-ID ist ungültig.";
} else if (serverError === "USER_NOT_FOUND") {
message = "Der Benutzer konnte nicht gefunden werden.";
} else if (serverError === "GROUP_NOT_FOUND") {
message = "Mindestens eine ausgewählte Gruppe existiert nicht mehr.";
} else if (typeof serverError === "string" && serverError.length > 0) {
message = serverError;
}
setGroupsError(message);
showToast({
variant: "error",
title: "Benutzergruppen",
description: message
});
} finally {
setSavingGroups(false);
}
}, [user, selectedGroupIds, showToast]);
const avatarLabel = useMemo(() => {
const source = (user?.username || user?.email || "").trim();
if (!source) {
return "?";
}
return source.charAt(0).toUpperCase();
}, [user]);
return (
<>
<div className="relative mt-8 h-72 w-full overflow-hidden rounded-xl bg-[url('/img/background-image.png')] bg-cover bg-center">
<div className="absolute inset-0 h-full w-full bg-gray-900/75" />
</div>
<Card className="mx-3 -mt-16 mb-6 lg:mx-4 border border-blue-gray-100">
<CardBody className="p-4">
<div className="mb-10 flex items-center justify-between flex-wrap gap-6">
<div className="flex items-center gap-6">
<div
className={`text-black flex h-[74px] w-[74px] items-center justify-center rounded-xl text-3xl font-semibold uppercase shadow-lg shadow-blue-gray-500/40 ${user?.avatarColor}`}
aria-label={user?.username || "Benutzeravatar"}
>
{avatarLabel}
</div>
<div>
<Typography variant="h5" color="blue-gray" className="mb-1">
{user?.username}
</Typography>
<Typography className="text-xs font-semibold uppercase tracking-wide text-stormGrey-400">
User-ID: {user?.id || ""}
</Typography>
</div>
</div>
</div>
{loading && !user && (
<div className="mb-6 flex items-center gap-3 rounded-lg border border-blue-gray-50 bg-blue-gray-50/50 px-4 py-3 text-sm text-blue-gray-500">
<Spinner className="h-4 w-4" />
<span>Benutzerdaten werden geladen ...</span>
</div>
)}
{error && !loading && (
<div className="mb-6 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-800">
{error}
</div>
)}
<div className="gird-cols-1 mb-12 grid gap-12 px-4 lg:grid-cols-2 xl:grid-cols-3">
<div>
<Typography variant="h6" color="blue-gray" className="mb-3">
Platform Settings
</Typography>
<div className="flex flex-col gap-12">
{platformSettingsData.map(({ title, options }) => (
<div key={title}>
<Typography className="mb-4 block text-xs font-semibold uppercase text-blue-gray-500">
{title}
</Typography>
<div className="flex flex-col gap-6">
{options.map(({ checked, label }) => (
<Switch
key={label}
id={label}
label={label}
defaultChecked={checked}
labelProps={{
className: "text-sm font-normal text-blue-gray-500",
}}
/>
))}
</div>
</div>
))}
</div>
</div>
<ProfileInfoCard
title="Profile Information"
description="Hi, I'm Alec Thompson, Decisions: If you can't decide, the answer is no. If two equally difficult paths, choose the one more painful in the short term (pain avoidance is creating an illusion of equality)."
details={{
"first name": "Alec M. Thompson",
mobile: "(44) 123 1234 123",
email: "alecthompson@mail.com",
location: "USA",
social: (
<div className="flex items-center gap-4">
<i className="fa-brands fa-facebook text-blue-700" />
<i className="fa-brands fa-twitter text-blue-400" />
<i className="fa-brands fa-instagram text-purple-500" />
</div>
),
}}
action={
<Tooltip content="Edit Profile">
<PencilIcon className="h-4 w-4 cursor-pointer text-blue-gray-500" />
</Tooltip>
}
/>
<div id="platform" className="flex flex-col gap-4">
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<Typography variant="h6" color="blue-gray">
Benutzergruppen
</Typography>
<div className="flex items-center gap-2">
<Button
variant="text"
size="sm"
color="blue-gray"
className="normal-case"
onClick={handleResetGroups}
disabled={!hasGroupChanges || savingGroups || !user}
>
Änderungen verwerfen
</Button>
<Button
size="sm"
color="green"
className="normal-case"
onClick={handleSaveGroups}
disabled={maintenanceActive || savingGroups || !hasGroupChanges || !user}
>
{savingGroups ? "Speichert ..." : "Speichern"}
</Button>
</div>
</div>
{maintenanceActive && (
<div className="rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800">
Wartungsmodus aktiv Änderungen sind deaktiviert.
</div>
)}
{groupsError && (
<div className="rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-800">
{groupsError}
</div>
)}
{groupsLoading && availableGroups.length === 0 ? (
<div className="flex items-center gap-3 rounded-lg border border-blue-gray-50 bg-blue-gray-50/50 px-4 py-3 text-sm text-blue-gray-500">
<Spinner className="h-4 w-4" />
<span>Benutzergruppen werden geladen ...</span>
</div>
) : availableGroups.length === 0 ? (
<Typography variant="small" className="text-sm text-stormGrey-500">
Es sind noch keine Benutzergruppen vorhanden.
</Typography>
) : (
<div className="flex flex-col gap-3">
{availableGroups.map((group) => {
const checked = selectedGroupIds.includes(group.id);
return (
<label
key={group.id}
className={`flex items-start justify-between gap-3 rounded-lg border px-4 py-3 transition ${
checked ? "border-auroraTeal-500 bg-auroraTeal-500/10" : "border-blue-gray-100 bg-white"
} ${maintenanceActive ? "opacity-70" : ""}`}
>
<div className="flex items-start gap-3">
<Checkbox
ripple={false}
className="mt-1"
checked={checked}
onChange={() => handleToggleGroup(group.id)}
disabled={maintenanceActive || savingGroups || !user}
/>
<div>
<Typography variant="small" className="font-semibold text-blue-gray-900">
{group.name}
</Typography>
{group.description && (
<Typography variant="small" className="text-xs text-stormGrey-500">
{group.description}
</Typography>
)}
</div>
</div>
<Typography variant="small" className="text-xs font-medium text-stormGrey-500">
Mitglieder: {group.memberCount}
</Typography>
</label>
);
})}
</div>
)}
</div>
</div>
<div className="px-4 pb-4">
<Typography variant="h6" color="blue-gray" className="mb-2">
Projects
</Typography>
<Typography
variant="small"
className="font-normal text-blue-gray-500"
>
Architects design houses
</Typography>
<div className="mt-6 grid grid-cols-1 gap-12 md:grid-cols-2 xl:grid-cols-4">
{projectsData.map(
({ img, title, description, tag, route, members }) => (
<Card key={title} color="transparent" shadow={false}>
<CardHeader
floated={false}
color="gray"
className="mx-0 mt-0 mb-4 h-64 xl:h-40"
>
<img
src={img}
alt={title}
className="h-full w-full object-cover"
/>
</CardHeader>
<CardBody className="py-0 px-1">
<Typography
variant="small"
className="font-normal text-blue-gray-500"
>
{tag}
</Typography>
<Typography
variant="h5"
color="blue-gray"
className="mt-1 mb-2"
>
{title}
</Typography>
<Typography
variant="small"
className="font-normal text-blue-gray-500"
>
{description}
</Typography>
</CardBody>
<CardFooter className="mt-6 flex items-center justify-between py-0 px-1">
<Link to={route}>
<Button variant="outlined" size="sm">
view project
</Button>
</Link>
<div>
{members.map(({ img, name }, key) => (
<Tooltip key={name} content={name}>
<Avatar
src={img}
alt={name}
size="xs"
variant="circular"
className={`cursor-pointer border-2 border-white ${key === 0 ? "" : "-ml-2.5"
}`}
/>
</Tooltip>
))}
</div>
</CardFooter>
</Card>
)
)}
</div>
</div>
</CardBody>
</Card>
</>
);
}
export default UserDetails;
+80 -78
View File
@@ -7,7 +7,10 @@ import {
Typography,
Chip,
Button,
Input
Select,
Option,
Input,
useSelect
} from "@material-tailwind/react";
import { PaginationControls, usePage } from "@/components/PageProvider.jsx";
import { useMaintenance } from "@/components/MaintenanceProvider.jsx";
@@ -35,6 +38,7 @@ export function Usergroups() {
} = usePage();
useEffect(() => () => resetPagination(), [resetPagination]);
const noop = useCallback(() => { }, []);
const fetchGroups = useCallback(async () => {
setLoading(true);
@@ -50,9 +54,9 @@ export function Usergroups() {
memberCount: Number(item.memberCount) || 0,
members: Array.isArray(item.members)
? item.members.map((member) => ({
id: member.id,
username: member.username || ""
})).filter((member) => member.username)
id: member.id,
username: member.username || ""
})).filter((member) => member.username)
: [],
createdAt: item.createdAt || null,
updatedAt: item.updatedAt || null
@@ -170,77 +174,54 @@ export function Usergroups() {
return (
<div className="mt-12">
<Card className="border border-blue-gray-100 shadow-sm">
<CardHeader
floated={false}
shadow={false}
color="transparent"
className="m-0 flex flex-col gap-4 bg-transparent p-6 md:flex-row md:items-center md:justify-between"
>
<div>
<Typography variant="h4" color="blue-gray">
Benutzergruppen
</Typography>
<Typography variant="small" color="gray" className="font-normal">
Übersicht aller Gruppen inklusive Mitgliederliste.
</Typography>
</div>
<div className="flex flex-col items-stretch gap-3 md:flex-row md:items-center">
<div>
<label className="mb-1 block text-xs font-semibold uppercase tracking-wide text-blue-gray-400">
Einträge pro Seite
</label>
<select
value={perPage}
onChange={handlePerPageChange}
className="w-full rounded-lg border border-blue-gray-100 px-3 py-2 text-sm text-blue-gray-700 shadow-sm focus:border-blue-400 focus:outline-none focus:ring-2 focus:ring-blue-200 md:w-40"
>
{perPageOptions.map(({ value, label }) => (
<option key={value} value={value}>
{label}
</option>
))}
</select>
</div>
<Button
variant="outlined"
color="blue"
onClick={handleRefresh}
disabled={loading}
className="whitespace-nowrap"
>
{loading ? "Lädt ..." : "Aktualisieren"}
</Button>
</div>
<CardHeader variant="gradient" color="gray" className="mb-5 p-4">
<Typography
variant="h6"
color="white"
>
<span>Benutzergruppen</span>
</Typography>
</CardHeader>
<CardBody className="pt-0">
{maintenanceActive && (
<div className="mb-4 rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800">
Wartungsmodus aktiv Änderungen sind deaktiviert. Die Liste kann dennoch angezeigt werden.
</div>
)}
<div className="mb-6 flex flex-col gap-3 md:flex-row md:items-end md:justify-between">
<div className="w-full md:max-w-md">
<Input
label="Suchen nach Gruppenname, Beschreibung oder Mitglied"
value={searchQuery}
onChange={handleSearchChange}
disabled={loading && !groups.length}
crossOrigin=""
/>
<div className="mb-8">
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div className="md:flex-1">
<Input
label="Suchen nach Name, E-Mail oder Gruppe"
value={searchQuery}
onChange={handleSearchChange}
disabled={loading && !groups.length}
crossOrigin=""
/>
</div>
<div className="md:mt-0 mt-8 md:flex-1">
<Select
variant="static"
label="Einträge pro Seite"
onChange={noop}
value={perPage}
>
{perPageOptions.map(({ value, label }) => (
<Option key={value} value={value}>
{label}
</Option>
))}
</Select>
</div>
</div>
{searchQuery && (
<Button
variant="text"
color="blue-gray"
onClick={handleClearSearch}
className="w-full md:w-auto"
>
Suche zurücksetzen
</Button>
)}
</div>
{error && (
<div className="mb-4 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-800">
{error}
</div>
)}
{error && (
<div className="mb-4 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-800">
{error}
@@ -250,25 +231,27 @@ export function Usergroups() {
<div className="overflow-x-auto rounded-lg border border-blue-gray-50">
<table className="w-full min-w-[720px] table-auto text-left">
<thead>
<tr className="bg-blue-gray-50/50 text-xs uppercase tracking-wide text-blue-gray-400">
<tr className="bg-blue-gray-50/50 text-xs uppercase tracking-wide text-stormGrey-400">
<th className="px-6 py-4 font-semibold">Gruppenname</th>
<th className="px-6 py-4 font-semibold">Beschreibung</th>
<th className="px-6 py-4 font-semibold">Mitglieder</th>
<th className="px-6 py-4 font-semibold">Anzahl</th>
<th className="px-6 py-4 font-semibold">Erstellt am</th>
<th className="px-6 py-4 font-semibold">Zuletzt aktualisiert</th>
<th className="px-6 py-4 font-semibold">Aktionen</th>
</tr>
</thead>
<tbody>
{loading && groups.length === 0 ? (
<tr>
<td colSpan={6} className="px-6 py-8 text-center text-blue-gray-400">
<td colSpan={6} className="px-6 py-8 text-center text-stormGrey-400">
Gruppen werden geladen ...
</td>
</tr>
) : paginatedGroups.length === 0 ? (
<tr>
<td colSpan={6} className="px-6 py-8 text-center text-blue-gray-400">
<td colSpan={6} className="px-6 py-8 text-center text-stormGrey-400">
Keine Gruppen gefunden.
</td>
</tr>
@@ -276,14 +259,20 @@ export function Usergroups() {
paginatedGroups.map((group, index) => {
const rowClass = index === paginatedGroups.length - 1 ? "" : "border-b border-blue-gray-50";
return (
<tr key={group.id} className={`text-sm text-blue-gray-700 ${rowClass}`}>
<td className="px-6 py-4 font-medium text-blue-gray-900">{group.name || ""}</td>
<tr key={group.id} className={`text-sm text-stormGrey-700 ${rowClass}`}>
<td className="px-6 py-4">
{group.description ? (
group.description
) : (
<span className="text-blue-gray-400"></span>
)}
<Typography variant="small" className="font-medium text-stormGrey-900">
{group.name || ""}
</Typography>
</td>
<td className="px-6 py-4">
<Typography variant="small">
{group.description ? (
group.description
) : (
<span className="text-stormGrey-400"></span>
)}
</Typography>
</td>
<td className="px-6 py-4">
{group.members.length > 0 ? (
@@ -299,7 +288,7 @@ export function Usergroups() {
))}
</div>
) : (
<span className="text-blue-gray-400">Keine Mitglieder</span>
<span className="text-stormGrey-400">Keine Mitglieder</span>
)}
</td>
<td className="px-6 py-4">
@@ -310,8 +299,21 @@ export function Usergroups() {
variant="ghost"
/>
</td>
<td className="px-6 py-4">{formatTimestamp(group.createdAt)}</td>
<td className="px-6 py-4">{formatTimestamp(group.updatedAt)}</td>
<td className="px-6 py-4">
<Typography variant="small" className="antialiased font-sans mb-1 block text-xs font-medium text-stormGrey-600">
{formatTimestamp(group.createdAt)}
</Typography>
</td>
<td className="px-6 py-4">
<Typography variant="small" className="antialiased font-sans mb-1 block text-xs font-medium text-stormGrey-600">
{formatTimestamp(group.updatedAt)}
</Typography>
</td>
<td className="px-6 py-4">
<Typography variant="small" className="antialiased font-sans mb-1 block text-xs font-medium text-stormGrey-600">
Aktionen
</Typography>
</td>
</tr>
);
})
+148 -63
View File
@@ -7,12 +7,41 @@ import {
Typography,
Chip,
Button,
Select,
Option,
Input
} from "@material-tailwind/react";
import { useNavigate } from "react-router-dom";
import { PaginationControls, usePage } from "@/components/PageProvider.jsx";
import { useMaintenance } from "@/components/MaintenanceProvider.jsx";
import { useToast } from "@/components/ToastProvider.jsx";
const normalizeUserGroups = (rawGroups) => {
if (!Array.isArray(rawGroups)) {
return [];
}
return rawGroups
.map((group) => {
if (group && typeof group === "object") {
const id = Number(group.id);
const name = typeof group.name === "string" ? group.name : "";
if (!name) {
return null;
}
return {
id: Number.isFinite(id) ? id : null,
name
};
}
if (typeof group === "string") {
const name = group.trim();
return name ? { id: null, name } : null;
}
return null;
})
.filter(Boolean);
};
export function Users() {
const [users, setUsers] = useState([]);
const [loading, setLoading] = useState(false);
@@ -22,6 +51,8 @@ export function Users() {
const { showToast } = useToast();
const { maintenance } = useMaintenance();
const maintenanceActive = Boolean(maintenance?.active);
const navigate = useNavigate();
const noop = useCallback(() => { }, []);
const {
page,
@@ -48,10 +79,11 @@ export function Users() {
username: item.username || "",
email: item.email || "",
isActive: Boolean(item.isActive),
avatarColor: typeof item.avatarColor === "string" ? item.avatarColor.trim() : null,
lastLogin: item.lastLogin || null,
createdAt: item.createdAt || null,
updatedAt: item.updatedAt || null,
groups: Array.isArray(item.groups) ? item.groups : []
groups: normalizeUserGroups(item.groups)
}))
.sort((a, b) => a.username.localeCompare(b.username, "de-DE"));
setUsers(sorted);
@@ -99,7 +131,7 @@ export function Users() {
const haystacks = [
user.username,
user.email,
...user.groups
...user.groups.map((group) => group.name)
].map((value) => String(value || "").toLowerCase());
return haystacks.some((value) => value.includes(query));
});
@@ -165,48 +197,14 @@ export function Users() {
return (
<div className="mt-12">
<Card className="border border-blue-gray-100 shadow-sm">
<CardHeader
floated={false}
shadow={false}
color="transparent"
className="m-0 flex flex-col gap-4 bg-transparent p-6 md:flex-row md:items-center md:justify-between"
>
<div>
<Typography variant="h4" color="blue-gray">
Benutzerverwaltung
</Typography>
<Typography variant="small" color="gray" className="font-normal">
Übersicht aller im System angelegten Benutzer.
</Typography>
</div>
<div className="flex flex-col items-stretch gap-3 md:flex-row md:items-center">
<div>
<label className="mb-1 block text-xs font-semibold uppercase tracking-wide text-blue-gray-400">
Einträge pro Seite
</label>
<select
value={perPage}
onChange={handlePerPageChange}
className="w-full rounded-lg border border-blue-gray-100 px-3 py-2 text-sm text-blue-gray-700 shadow-sm focus:border-blue-400 focus:outline-none focus:ring-2 focus:ring-blue-200 md:w-40"
>
{perPageOptions.map(({ value, label }) => (
<option key={value} value={value}>
{label}
</option>
))}
</select>
</div>
<Button
variant="outlined"
color="blue"
onClick={handleRefresh}
disabled={loading}
className="whitespace-nowrap"
>
{loading ? "Lädt ..." : "Aktualisieren"}
</Button>
</div>
<Card>
<CardHeader variant="gradient" color="gray" className="mb-5 p-4">
<Typography
variant="h6"
color="white"
>
<span>Benutzerverwaltung</span>
</Typography>
</CardHeader>
<CardBody className="pt-0">
{maintenanceActive && (
@@ -215,8 +213,36 @@ export function Users() {
</div>
)}
<div className="mb-6 flex flex-col gap-3 md:flex-row md:items-end md:justify-between">
<div className="w-full md:max-w-md">
<div className="mb-8">
<div className="flex flex-wrap gap-2"> {/* <div className="flex flex-col items-stretch gap-3 md:flex-row md:items-center">
<div>
<label className="mb-1 block text-xs font-semibold uppercase tracking-wide text-blue-gray-400">
Einträge pro Seite
</label>
<select
value={perPage}
onChange={handlePerPageChange}
className="w-full rounded-lg border border-blue-gray-100 px-3 py-2 text-sm text-blue-gray-700 shadow-sm focus:border-blue-400 focus:outline-none focus:ring-2 focus:ring-blue-200 md:w-40"
>
{perPageOptions.map(({ value, label }) => (
<option key={value} value={value}>
{label}
</option>
))}
</select>
</div>
<Button
variant="outlined"
color="blue"
onClick={handleRefresh}
disabled={loading}
className="whitespace-nowrap"
>
{loading ? "Lädt ..." : "Aktualisieren"}
</Button>
</div> */}
{/* <div className="w-full md:max-w-md">
<Input
label="Suchen nach Name, E-Mail oder Gruppe"
value={searchQuery}
@@ -224,8 +250,8 @@ export function Users() {
disabled={loading && !users.length}
crossOrigin=""
/>
</div>
{searchQuery && (
</div> */}
{/* {searchQuery && (
<Button
variant="text"
color="blue-gray"
@@ -234,9 +260,36 @@ export function Users() {
>
Suche zurücksetzen
</Button>
)}
</div>
)} */}
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between mt-8">
<div className="md:flex-1">
<Input
label="Suchen nach Name, E-Mail oder Gruppe"
value={searchQuery}
onChange={handleSearchChange}
disabled={loading && !users.length}
crossOrigin=""
/>
</div>
<div className="md:mt-0 mt-8 md:flex-1">
<Select
variant="static"
label="Einträge pro Seite"
onChange={noop}
value={perPage}
>
{perPageOptions.map(({ value, label }) => (
<Option key={value} value={value}>
{label}
</Option>
))}
</Select>
</div>
</div>
</div>
</div>
{error && (
<div className="mb-4 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-800">
{error}
@@ -246,25 +299,27 @@ export function Users() {
<div className="overflow-x-auto rounded-lg border border-blue-gray-50">
<table className="w-full min-w-[720px] table-auto text-left">
<thead>
<tr className="bg-blue-gray-50/50 text-xs uppercase tracking-wide text-blue-gray-400">
<tr className="bg-blue-gray-50/50 text-xs uppercase tracking-wide text-stormGrey-400">
<th className="px-6 py-4 font-semibold">Benutzername</th>
<th className="px-6 py-4 font-semibold">E-Mail</th>
<th className="px-6 py-4 font-semibold">Gruppen</th>
<th className="px-6 py-4 font-semibold">Status</th>
<th className="px-6 py-4 font-semibold">Letzte Anmeldung</th>
<th className="px-6 py-4 font-semibold">Erstellt am</th>
<th className="px-6 py-4 font-semibold">Aktionen</th>
</tr>
</thead>
<tbody>
{loading && users.length === 0 ? (
<tr>
<td colSpan={6} className="px-6 py-8 text-center text-blue-gray-400">
<td colSpan={7} className="px-6 py-8 text-center text-blue-gray-400">
Benutzerdaten werden geladen ...
</td>
</tr>
) : paginatedUsers.length === 0 ? (
<tr>
<td colSpan={6} className="px-6 py-8 text-center text-blue-gray-400">
<td colSpan={7} className="px-6 py-8 text-center text-blue-gray-400">
Keine Benutzer gefunden.
</td>
</tr>
@@ -272,18 +327,32 @@ export function Users() {
paginatedUsers.map((user, index) => {
const rowClass = index === paginatedUsers.length - 1 ? "" : "border-b border-blue-gray-50";
return (
<tr key={user.id} className={`text-sm text-blue-gray-700 ${rowClass}`}>
<td className="px-6 py-4 font-medium text-blue-gray-900">{user.username || ""}</td>
<td className="px-6 py-4">{user.email || ""}</td>
<tr key={user.id} className={`text-sm text-stormGrey-700 ${rowClass}`}>
<td className="px-6 py-4">
<Typography variant="small" className="font-medium text-stormGrey-900">
{user.username || ""}
</Typography>
</td>
<td className="px-6 py-4">
<Typography variant="small">
{user.email || ""}
</Typography>
</td>
<td className="px-6 py-4">
{user.groups.length > 0 ? (
<div className="flex flex-wrap gap-2">
{user.groups.map((group) => (
<Chip key={group} value={group} size="sm" color="blue-gray" variant="ghost" />
<Chip
key={group.id ?? group.name}
value={group.name}
size="sm"
color="blue-gray"
variant="ghost"
/>
))}
</div>
) : (
<span className="text-blue-gray-400"></span>
<span className="text-stormGrey-400"></span>
)}
</td>
<td className="px-6 py-4">
@@ -294,8 +363,26 @@ export function Users() {
variant="ghost"
/>
</td>
<td className="px-6 py-4">{formatTimestamp(user.lastLogin)}</td>
<td className="px-6 py-4">{formatTimestamp(user.createdAt)}</td>
<td className="px-6 py-4">
<Typography variant="small" className="antialiased font-sans mb-1 block text-xs font-medium text-stormGrey-600">
{formatTimestamp(user.lastLogin)}
</Typography>
</td>
<td className="px-6 py-4">
<Typography variant="small" className="antialiased font-sans mb-1 block text-xs font-medium text-stormGrey-600">
{formatTimestamp(user.createdAt)}
</Typography>
</td>
<td className="px-6 py-4">
<Button
size="sm"
variant="outlined"
color="blue-gray"
onClick={() => navigate(`/dashboard/users/${user.id}`)}
>
Details
</Button>
</td>
</tr>
);
})
@@ -306,9 +393,7 @@ export function Users() {
<div className="mt-6 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<Typography variant="small" color="gray">
{!loading && users.length === 0
? "Noch keine Benutzer angelegt."
: `${filteredCount.toLocaleString("de-DE")} Benutzer gefunden`}
{""}
</Typography>
<PaginationControls disabled={loading && users.length === 0} />
</div>
+21 -21
View File
@@ -10,29 +10,29 @@ module.exports = withMT({
green: colors.green,
slate: colors.slate,
orange: colors.orange, // falls du z.B. text-orange-300 brauchst
auroraTeal: {50:'#f0fafb',100:'#e0f5f7',200:'#c3ecef',300:'#9de0e4',400:'#6ad3d8',500:'#38c6cc',600:'#2aa9b0',700:'#1f8d94',800:'#166e76',900:'#0c4f58'},
sunsetCoral: {50:'#fff6f5',100:'#ffece9',200:'#ffd7d3',300:'#ffb8b5',400:'#ff9189',500:'#ff6a5d',600:'#e65048',700:'#b93d38',800:'#8a2c28',900:'#5c1c1a'},
lavenderSmoke: {50:'#faf8fc',100:'#f5f1f9',200:'#ece2f3',300:'#dac4e6',400:'#b88acf',500:'#9670b8',600:'#7a5894',700:'#5d4373',800:'#412f52',900:'#291e38'},
mossGreen: {50:'#f5faf2',100:'#ebf5e5',200:'#d9ebca',300:'#b8d99e',400:'#8cc56f',500:'#60b240',600:'#4d8f33',700:'#3b6b27',800:'#2a491d',900:'#1c3012'},
deepOceanBlue: {50:'#f4faff',100:'#e9f5ff',200:'#cfe6ff',300:'#add1ff',400:'#7ab9ff',500:'#479fff',600:'#3c86e6',700:'#305eaf',800:'#23437f',900:'#162a50'},
warmAmberGlow: {50:'#fff9f2',100:'#fff3e5',200:'#ffe6cc',300:'#ffcc99',400:'#ffad4d',500:'#ff8f00',600:'#e67600',700:'#b35a00',800:'#804000',900:'#4d2600'},
peachMist: {50:'#fff8f7',100:'#fff0ed',200:'#ffe1db',300:'#ffc2b7',400:'#ff9a87',500:'#ff7257',600:'#e65f4b',700:'#b34938',800:'#803622',900:'#4d230f'},
slatePurple: {50:'#f7f5fa',100:'#efe9f5',200:'#ded1ea',300:'#bdb3d5',400:'#9584b5',500:'#6d5795',600:'#5c4a7c',700:'#473861',800:'#322648',900:'#1f1530'},
mintTea: {50:'#f2fcfa',100:'#e5f9f6',200:'#ccefee',300:'#99dfdc',400:'#66cfc9',500:'#33bfb6',600:'#2aa395',700:'#228174',800:'#195953',900:'#103330'},
copperRust: {50:'#faf7f6',100:'#f5efee',200:'#ebe0dc',300:'#d6c1b8',400:'#b28a86',500:'#8d4d55',600:'#7a444b',700:'#5f3338',800:'#442426',900:'#2d1718'},
auroraTeal: { 50: '#f0fafb', 100: '#e0f5f7', 200: '#c3ecef', 300: '#9de0e4', 400: '#6ad3d8', 500: '#38c6cc', 600: '#2aa9b0', 700: '#1f8d94', 800: '#166e76', 900: '#0c4f58' },
sunsetCoral: { 50: '#fff6f5', 100: '#ffece9', 200: '#ffd7d3', 300: '#ffb8b5', 400: '#ff9189', 500: '#ff6a5d', 600: '#e65048', 700: '#b93d38', 800: '#8a2c28', 900: '#5c1c1a' },
lavenderSmoke: { 50: '#faf8fc', 100: '#f5f1f9', 200: '#ece2f3', 300: '#dac4e6', 400: '#b88acf', 500: '#9670b8', 600: '#7a5894', 700: '#5d4373', 800: '#412f52', 900: '#291e38' },
mossGreen: { 50: '#f5faf2', 100: '#ebf5e5', 200: '#d9ebca', 300: '#b8d99e', 400: '#8cc56f', 500: '#60b240', 600: '#4d8f33', 700: '#3b6b27', 800: '#2a491d', 900: '#1c3012' },
deepOceanBlue: { 50: '#f4faff', 100: '#e9f5ff', 200: '#cfe6ff', 300: '#add1ff', 400: '#7ab9ff', 500: '#479fff', 600: '#3c86e6', 700: '#305eaf', 800: '#23437f', 900: '#162a50' },
warmAmberGlow: { 50: '#fff9f2', 100: '#fff3e5', 200: '#ffe6cc', 300: '#ffcc99', 400: '#ffad4d', 500: '#ff8f00', 600: '#e67600', 700: '#b35a00', 800: '#804000', 900: '#4d2600' },
peachMist: { 50: '#fff8f7', 100: '#fff0ed', 200: '#ffe1db', 300: '#ffc2b7', 400: '#ff9a87', 500: '#ff7257', 600: '#e65f4b', 700: '#b34938', 800: '#803622', 900: '#4d230f' },
slatePurple: { 50: '#f7f5fa', 100: '#efe9f5', 200: '#ded1ea', 300: '#bdb3d5', 400: '#9584b5', 500: '#6d5795', 600: '#5c4a7c', 700: '#473861', 800: '#322648', 900: '#1f1530' },
mintTea: { 50: '#f2fcfa', 100: '#e5f9f6', 200: '#ccefee', 300: '#99dfdc', 400: '#66cfc9', 500: '#33bfb6', 600: '#2aa395', 700: '#228174', 800: '#195953', 900: '#103330' },
copperRust: { 50: '#faf7f6', 100: '#f5efee', 200: '#ebe0dc', 300: '#d6c1b8', 400: '#b28a86', 500: '#8d4d55', 600: '#7a444b', 700: '#5f3338', 800: '#442426', 900: '#2d1718' },
// Neue 10
arcticBlue: {50:'#f2f9fd',100:'#e6f3fb',200:'#cce6f7',300:'#99ceef',400:'#66b6e7',500:'#339ede',600:'#1f86c3',700:'#16699b',800:'#0f4b6f',900:'#082d43'},
roseQuartz: {50:'#fdf7f9',100:'#faeff3',200:'#f5dfe6',300:'#ecbfd0',400:'#e08dad',500:'#d45b89',600:'#b94771',700:'#8e3556',800:'#63233b',900:'#391324'},
citrusPunch: {50:'#fffdf6',100:'#fffbe9',200:'#fff5cc',300:'#ffeb99',400:'#ffe066',500:'#ffd633',600:'#e6bd00',700:'#b39300',800:'#806900',900:'#4d3f00'},
midnightPlum: {50:'#faf7fb',100:'#f4ecf6',200:'#e4cfee',300:'#cba0de',400:'#a869cc',500:'#8431b9',600:'#6d299b',700:'#521f74',800:'#38154d',900:'#1f0b28'},
sandstone: {50:'#fbf9f7',100:'#f6f2ee',200:'#ece3da',300:'#d8c6b3',400:'#c0a27f',500:'#a87e4b',600:'#906a3e',700:'#6b4f2f',800:'#47341f',900:'#241a10'},
emeraldMist: {50:'#f3faf7',100:'#e7f6ef',200:'#ccecdc',300:'#9ddabf',400:'#6ec8a3',500:'#3fb586',600:'#359771',700:'#2a765a',800:'#1f5442',900:'#13332a'},
velvetRed: {50:'#fdf7f7',100:'#faeeee',200:'#f4d6d6',300:'#e7a8a8',400:'#d56666',500:'#c32424',600:'#a61e1e',700:'#801717',800:'#590f0f',900:'#330808'},
stormGrey: {50:'#f7f8fa',100:'#eef0f3',200:'#d9dce2',300:'#b6bbc8',400:'#8b91a4',500:'#606780',600:'#4f556b',700:'#3e4456',800:'#2c313f',900:'#1b1e29'},
neonLime: {50:'#f7ffe6',100:'#eeffc9',200:'#dbff96',300:'#c2ff52',400:'#a8ff0e',500:'#8de600',600:'#74bf00',700:'#5b9900',800:'#426f00',900:'#294600'},
skyMauve: {50:'#faf8fc',100:'#f4f0f9',200:'#e5dbf2',300:'#c9b2e6',400:'#a27dd4',500:'#7b48c2',600:'#633aa0',700:'#4b2b7c',800:'#341d58',900:'#1e1035'},
arcticBlue: { 50: '#f2f9fd', 100: '#e6f3fb', 200: '#cce6f7', 300: '#99ceef', 400: '#66b6e7', 500: '#339ede', 600: '#1f86c3', 700: '#16699b', 800: '#0f4b6f', 900: '#082d43' },
roseQuartz: { 50: '#fdf7f9', 100: '#faeff3', 200: '#f5dfe6', 300: '#ecbfd0', 400: '#e08dad', 500: '#d45b89', 600: '#b94771', 700: '#8e3556', 800: '#63233b', 900: '#391324' },
citrusPunch: { 50: '#fffdf6', 100: '#fffbe9', 200: '#fff5cc', 300: '#ffeb99', 400: '#ffe066', 500: '#ffd633', 600: '#e6bd00', 700: '#b39300', 800: '#806900', 900: '#4d3f00' },
midnightPlum: { 50: '#faf7fb', 100: '#f4ecf6', 200: '#e4cfee', 300: '#cba0de', 400: '#a869cc', 500: '#8431b9', 600: '#6d299b', 700: '#521f74', 800: '#38154d', 900: '#1f0b28' },
sandstone: { 50: '#fbf9f7', 100: '#f6f2ee', 200: '#ece3da', 300: '#d8c6b3', 400: '#c0a27f', 500: '#a87e4b', 600: '#906a3e', 700: '#6b4f2f', 800: '#47341f', 900: '#241a10' },
emeraldMist: { 50: '#f3faf7', 100: '#e7f6ef', 200: '#ccecdc', 300: '#9ddabf', 400: '#6ec8a3', 500: '#3fb586', 600: '#359771', 700: '#2a765a', 800: '#1f5442', 900: '#13332a' },
velvetRed: { 50: '#fdf7f7', 100: '#faeeee', 200: '#f4d6d6', 300: '#e7a8a8', 400: '#d56666', 500: '#c32424', 600: '#a61e1e', 700: '#801717', 800: '#590f0f', 900: '#330808' },
stormGrey: { 50: '#f7f8fa', 100: '#eef0f3', 200: '#d9dce2', 300: '#b6bbc8', 400: '#8b91a4', 500: '#606780', 600: '#4f556b', 700: '#3e4456', 800: '#2c313f', 900: '#1b1e29' },
neonLime: { 50: '#f7ffe6', 100: '#eeffc9', 200: '#dbff96', 300: '#c2ff52', 400: '#a8ff0e', 500: '#8de600', 600: '#74bf00', 700: '#5b9900', 800: '#426f00', 900: '#294600' },
skyMauve: { 50: '#faf8fc', 100: '#f4f0f9', 200: '#e5dbf2', 300: '#c9b2e6', 400: '#a27dd4', 500: '#7b48c2', 600: '#633aa0', 700: '#4b2b7c', 800: '#341d58', 900: '#1e1035' },
},
},
},
plugins: [],
});
});