mirror of
https://github.com/9technologygroup/patchmon.net.git
synced 2025-11-01 20:44:09 +00:00
Theme settings per user
This commit is contained in:
@@ -196,7 +196,6 @@ model settings {
|
||||
metrics_enabled Boolean @default(true)
|
||||
metrics_anonymous_id String?
|
||||
metrics_last_sent DateTime?
|
||||
color_theme String @default("default")
|
||||
}
|
||||
|
||||
model update_history {
|
||||
@@ -228,6 +227,8 @@ model users {
|
||||
tfa_secret String?
|
||||
first_name String?
|
||||
last_name String?
|
||||
theme_preference String? @default("dark")
|
||||
color_theme String? @default("cyber_blue")
|
||||
dashboard_preferences dashboard_preferences[]
|
||||
user_sessions user_sessions[]
|
||||
auto_enrollment_tokens auto_enrollment_tokens[]
|
||||
|
||||
105
backend/src/routes/userPreferencesRoutes.js
Normal file
105
backend/src/routes/userPreferencesRoutes.js
Normal file
@@ -0,0 +1,105 @@
|
||||
const express = require("express");
|
||||
const { getPrismaClient } = require("../config/prisma");
|
||||
const { authenticateToken } = require("../middleware/auth");
|
||||
|
||||
const router = express.Router();
|
||||
const prisma = getPrismaClient();
|
||||
|
||||
/**
|
||||
* GET /api/v1/user/preferences
|
||||
* Get current user's preferences (theme and color theme)
|
||||
*/
|
||||
router.get("/", authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const userId = req.user.id;
|
||||
|
||||
const user = await prisma.users.findUnique({
|
||||
where: { id: userId },
|
||||
select: {
|
||||
theme_preference: true,
|
||||
color_theme: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return res.status(404).json({ error: "User not found" });
|
||||
}
|
||||
|
||||
res.json({
|
||||
theme_preference: user.theme_preference || "dark",
|
||||
color_theme: user.color_theme || "cyber_blue",
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error fetching user preferences:", error);
|
||||
res.status(500).json({ error: "Failed to fetch user preferences" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* PATCH /api/v1/user/preferences
|
||||
* Update current user's preferences
|
||||
*/
|
||||
router.patch("/", authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const userId = req.user.id;
|
||||
const { theme_preference, color_theme } = req.body;
|
||||
|
||||
// Validate inputs
|
||||
const updateData = {};
|
||||
if (theme_preference !== undefined) {
|
||||
if (!["light", "dark"].includes(theme_preference)) {
|
||||
return res.status(400).json({
|
||||
error: "Invalid theme preference. Must be 'light' or 'dark'",
|
||||
});
|
||||
}
|
||||
updateData.theme_preference = theme_preference;
|
||||
}
|
||||
|
||||
if (color_theme !== undefined) {
|
||||
const validColorThemes = [
|
||||
"default",
|
||||
"cyber_blue",
|
||||
"neon_purple",
|
||||
"matrix_green",
|
||||
"ocean_blue",
|
||||
"sunset_gradient",
|
||||
];
|
||||
if (!validColorThemes.includes(color_theme)) {
|
||||
return res.status(400).json({
|
||||
error: `Invalid color theme. Must be one of: ${validColorThemes.join(", ")}`,
|
||||
});
|
||||
}
|
||||
updateData.color_theme = color_theme;
|
||||
}
|
||||
|
||||
if (Object.keys(updateData).length === 0) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "No preferences provided to update" });
|
||||
}
|
||||
|
||||
updateData.updated_at = new Date();
|
||||
|
||||
const updatedUser = await prisma.users.update({
|
||||
where: { id: userId },
|
||||
data: updateData,
|
||||
select: {
|
||||
theme_preference: true,
|
||||
color_theme: true,
|
||||
},
|
||||
});
|
||||
|
||||
res.json({
|
||||
message: "Preferences updated successfully",
|
||||
preferences: {
|
||||
theme_preference: updatedUser.theme_preference,
|
||||
color_theme: updatedUser.color_theme,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error updating user preferences:", error);
|
||||
res.status(500).json({ error: "Failed to update user preferences" });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -70,6 +70,7 @@ const integrationRoutes = require("./routes/integrationRoutes");
|
||||
const wsRoutes = require("./routes/wsRoutes");
|
||||
const agentVersionRoutes = require("./routes/agentVersionRoutes");
|
||||
const metricsRoutes = require("./routes/metricsRoutes");
|
||||
const userPreferencesRoutes = require("./routes/userPreferencesRoutes");
|
||||
const { initSettings } = require("./services/settingsService");
|
||||
const { queueManager } = require("./services/automation");
|
||||
const { authenticateToken, requireAdmin } = require("./middleware/auth");
|
||||
@@ -477,6 +478,7 @@ app.use(`/api/${apiVersion}/integrations`, integrationRoutes);
|
||||
app.use(`/api/${apiVersion}/ws`, wsRoutes);
|
||||
app.use(`/api/${apiVersion}/agent`, agentVersionRoutes);
|
||||
app.use(`/api/${apiVersion}/metrics`, metricsRoutes);
|
||||
app.use(`/api/${apiVersion}/user/preferences`, userPreferencesRoutes);
|
||||
|
||||
// Bull Board - will be populated after queue manager initializes
|
||||
let bullBoardRouter = null;
|
||||
|
||||
@@ -8,6 +8,7 @@ import SettingsLayout from "./components/SettingsLayout";
|
||||
import { isAuthPhase } from "./constants/authPhases";
|
||||
import { AuthProvider, useAuth } from "./contexts/AuthContext";
|
||||
import { ColorThemeProvider } from "./contexts/ColorThemeContext";
|
||||
import { SettingsProvider } from "./contexts/SettingsContext";
|
||||
import { ThemeProvider } from "./contexts/ThemeContext";
|
||||
import { UpdateNotificationProvider } from "./contexts/UpdateNotificationContext";
|
||||
|
||||
@@ -450,15 +451,17 @@ function AppRoutes() {
|
||||
function App() {
|
||||
return (
|
||||
<ThemeProvider>
|
||||
<ColorThemeProvider>
|
||||
<AuthProvider>
|
||||
<UpdateNotificationProvider>
|
||||
<LogoProvider>
|
||||
<AppRoutes />
|
||||
</LogoProvider>
|
||||
</UpdateNotificationProvider>
|
||||
</AuthProvider>
|
||||
</ColorThemeProvider>
|
||||
<AuthProvider>
|
||||
<SettingsProvider>
|
||||
<ColorThemeProvider>
|
||||
<UpdateNotificationProvider>
|
||||
<LogoProvider>
|
||||
<AppRoutes />
|
||||
</LogoProvider>
|
||||
</UpdateNotificationProvider>
|
||||
</ColorThemeProvider>
|
||||
</SettingsProvider>
|
||||
</AuthProvider>
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,17 +1,8 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useEffect } from "react";
|
||||
import { isAuthReady } from "../constants/authPhases";
|
||||
import { useAuth } from "../contexts/AuthContext";
|
||||
import { settingsAPI } from "../utils/api";
|
||||
import { useSettings } from "../contexts/SettingsContext";
|
||||
|
||||
const LogoProvider = ({ children }) => {
|
||||
const { authPhase, isAuthenticated } = useAuth();
|
||||
|
||||
const { data: settings } = useQuery({
|
||||
queryKey: ["settings"],
|
||||
queryFn: () => settingsAPI.get().then((res) => res.data),
|
||||
enabled: isAuthReady(authPhase, isAuthenticated()),
|
||||
});
|
||||
const { settings } = useSettings();
|
||||
|
||||
useEffect(() => {
|
||||
// Use custom favicon or fallback to default
|
||||
|
||||
@@ -1,14 +1,6 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
AlertCircle,
|
||||
Image,
|
||||
Palette,
|
||||
RotateCcw,
|
||||
Upload,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { AlertCircle, Image, RotateCcw, Upload, X } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { THEME_PRESETS, useColorTheme } from "../../contexts/ColorThemeContext";
|
||||
import { settingsAPI } from "../../utils/api";
|
||||
|
||||
const BrandingTab = () => {
|
||||
@@ -20,7 +12,6 @@ const BrandingTab = () => {
|
||||
});
|
||||
const [showLogoUploadModal, setShowLogoUploadModal] = useState(false);
|
||||
const [selectedLogoType, setSelectedLogoType] = useState("dark");
|
||||
const { colorTheme, setColorTheme } = useColorTheme();
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
@@ -84,22 +75,6 @@ const BrandingTab = () => {
|
||||
},
|
||||
});
|
||||
|
||||
// Theme update mutation
|
||||
const updateThemeMutation = useMutation({
|
||||
mutationFn: (theme) => settingsAPI.update({ colorTheme: theme }),
|
||||
onSuccess: (_data, theme) => {
|
||||
queryClient.invalidateQueries(["settings"]);
|
||||
setColorTheme(theme);
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error("Update theme error:", error);
|
||||
},
|
||||
});
|
||||
|
||||
const handleThemeChange = (theme) => {
|
||||
updateThemeMutation.mutate(theme);
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
@@ -137,93 +112,11 @@ const BrandingTab = () => {
|
||||
</h2>
|
||||
</div>
|
||||
<p className="text-sm text-secondary-500 dark:text-secondary-300 mb-6">
|
||||
Customize your PatchMon installation with custom logos, favicon, and
|
||||
color themes. These will be displayed throughout the application.
|
||||
Customize your PatchMon installation with custom logos and favicon.
|
||||
These will be displayed throughout the application.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Color Theme Selector */}
|
||||
<div className="bg-white dark:bg-secondary-800 rounded-lg p-6 border border-secondary-200 dark:border-secondary-600">
|
||||
<div className="flex items-center mb-4">
|
||||
<Palette className="h-5 w-5 text-primary-600 mr-2" />
|
||||
<h3 className="text-lg font-medium text-secondary-900 dark:text-white">
|
||||
Color Theme
|
||||
</h3>
|
||||
</div>
|
||||
<p className="text-sm text-secondary-600 dark:text-secondary-400 mb-6">
|
||||
Choose a color theme that will be applied to the login page and
|
||||
background areas throughout the app.
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-4">
|
||||
{Object.entries(THEME_PRESETS).map(([themeKey, theme]) => {
|
||||
const isSelected = colorTheme === themeKey;
|
||||
const gradientColors = theme.login.xColors;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={themeKey}
|
||||
type="button"
|
||||
onClick={() => handleThemeChange(themeKey)}
|
||||
disabled={updateThemeMutation.isPending}
|
||||
className={`relative p-4 rounded-lg border-2 transition-all ${
|
||||
isSelected
|
||||
? "border-primary-500 ring-2 ring-primary-200 dark:ring-primary-800"
|
||||
: "border-secondary-200 dark:border-secondary-600 hover:border-primary-300"
|
||||
} ${updateThemeMutation.isPending ? "opacity-50 cursor-not-allowed" : "cursor-pointer"}`}
|
||||
>
|
||||
{/* Theme Preview */}
|
||||
<div
|
||||
className="h-20 rounded-md mb-3 overflow-hidden"
|
||||
style={{
|
||||
background: `linear-gradient(135deg, ${gradientColors.join(", ")})`,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Theme Name */}
|
||||
<div className="text-sm font-medium text-secondary-900 dark:text-white mb-1">
|
||||
{theme.name}
|
||||
</div>
|
||||
|
||||
{/* Selected Indicator */}
|
||||
{isSelected && (
|
||||
<div className="absolute top-2 right-2 bg-primary-500 text-white rounded-full p-1">
|
||||
<svg
|
||||
className="w-4 h-4"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
aria-label="Selected theme"
|
||||
>
|
||||
<title>Selected</title>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{updateThemeMutation.isPending && (
|
||||
<div className="mt-4 flex items-center gap-2 text-sm text-primary-600 dark:text-primary-400">
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-current"></div>
|
||||
Updating theme...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{updateThemeMutation.isError && (
|
||||
<div className="mt-4 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-md p-3">
|
||||
<p className="text-sm text-red-800 dark:text-red-200">
|
||||
Failed to update theme: {updateThemeMutation.error?.message}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Logo Section Header */}
|
||||
<div className="flex items-center mb-4">
|
||||
<Image className="h-5 w-5 text-primary-600 mr-2" />
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { createContext, useContext, useEffect, useState } from "react";
|
||||
import { userPreferencesAPI } from "../utils/api";
|
||||
|
||||
const ColorThemeContext = createContext();
|
||||
|
||||
@@ -121,54 +123,40 @@ export const THEME_PRESETS = {
|
||||
};
|
||||
|
||||
export const ColorThemeProvider = ({ children }) => {
|
||||
const [colorTheme, setColorTheme] = useState("default");
|
||||
const [colorTheme, setColorTheme] = useState(() => {
|
||||
// Initialize from localStorage for immediate render
|
||||
return localStorage.getItem("colorTheme") || "cyber_blue";
|
||||
});
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
// Fetch theme from settings on mount
|
||||
// Fetch user preferences from backend
|
||||
const { data: userPreferences } = useQuery({
|
||||
queryKey: ["userPreferences"],
|
||||
queryFn: () => userPreferencesAPI.get().then((res) => res.data),
|
||||
retry: 1,
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||
});
|
||||
|
||||
// Update theme when preferences are loaded
|
||||
useEffect(() => {
|
||||
const fetchTheme = async () => {
|
||||
try {
|
||||
// Check localStorage first for unauthenticated pages (login)
|
||||
const cachedTheme = localStorage.getItem("colorTheme");
|
||||
if (cachedTheme) {
|
||||
setColorTheme(cachedTheme);
|
||||
}
|
||||
if (userPreferences?.color_theme) {
|
||||
setColorTheme(userPreferences.color_theme);
|
||||
localStorage.setItem("colorTheme", userPreferences.color_theme);
|
||||
}
|
||||
setIsLoading(false);
|
||||
}, [userPreferences]);
|
||||
|
||||
// Try to fetch from API (will fail on login page, that's ok)
|
||||
try {
|
||||
const token = localStorage.getItem("token");
|
||||
if (token) {
|
||||
const response = await fetch("/api/v1/settings", {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
if (data.color_theme) {
|
||||
setColorTheme(data.color_theme);
|
||||
localStorage.setItem("colorTheme", data.color_theme);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (_apiError) {
|
||||
// Silent fail - use cached or default theme
|
||||
console.log("Could not fetch theme from API, using cached/default");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error loading color theme:", error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchTheme();
|
||||
}, []);
|
||||
|
||||
const updateColorTheme = (theme) => {
|
||||
const updateColorTheme = async (theme) => {
|
||||
setColorTheme(theme);
|
||||
localStorage.setItem("colorTheme", theme);
|
||||
|
||||
// Save to backend
|
||||
try {
|
||||
await userPreferencesAPI.update({ color_theme: theme });
|
||||
} catch (error) {
|
||||
console.error("Failed to save color theme preference:", error);
|
||||
// Theme is already set locally, so user still sees the change
|
||||
}
|
||||
};
|
||||
|
||||
const value = {
|
||||
|
||||
45
frontend/src/contexts/SettingsContext.jsx
Normal file
45
frontend/src/contexts/SettingsContext.jsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { createContext, useContext } from "react";
|
||||
import { isAuthReady } from "../constants/authPhases";
|
||||
import { settingsAPI } from "../utils/api";
|
||||
import { useAuth } from "./AuthContext";
|
||||
|
||||
const SettingsContext = createContext();
|
||||
|
||||
export const useSettings = () => {
|
||||
const context = useContext(SettingsContext);
|
||||
if (!context) {
|
||||
throw new Error("useSettings must be used within a SettingsProvider");
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
export const SettingsProvider = ({ children }) => {
|
||||
const { authPhase, isAuthenticated } = useAuth();
|
||||
|
||||
const {
|
||||
data: settings,
|
||||
isLoading,
|
||||
error,
|
||||
refetch,
|
||||
} = useQuery({
|
||||
queryKey: ["settings"],
|
||||
queryFn: () => settingsAPI.get().then((res) => res.data),
|
||||
staleTime: 5 * 60 * 1000, // Settings stay fresh for 5 minutes
|
||||
refetchOnWindowFocus: false,
|
||||
enabled: isAuthReady(authPhase, isAuthenticated()),
|
||||
});
|
||||
|
||||
const value = {
|
||||
settings,
|
||||
isLoading,
|
||||
error,
|
||||
refetch,
|
||||
};
|
||||
|
||||
return (
|
||||
<SettingsContext.Provider value={value}>
|
||||
{children}
|
||||
</SettingsContext.Provider>
|
||||
);
|
||||
};
|
||||
@@ -1,4 +1,6 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { createContext, useContext, useEffect, useState } from "react";
|
||||
import { userPreferencesAPI } from "../utils/api";
|
||||
|
||||
const ThemeContext = createContext();
|
||||
|
||||
@@ -12,7 +14,7 @@ export const useTheme = () => {
|
||||
|
||||
export const ThemeProvider = ({ children }) => {
|
||||
const [theme, setTheme] = useState(() => {
|
||||
// Check localStorage first, then system preference
|
||||
// Check localStorage first for immediate render
|
||||
const savedTheme = localStorage.getItem("theme");
|
||||
if (savedTheme) {
|
||||
return savedTheme;
|
||||
@@ -24,6 +26,22 @@ export const ThemeProvider = ({ children }) => {
|
||||
return "light";
|
||||
});
|
||||
|
||||
// Fetch user preferences from backend
|
||||
const { data: userPreferences } = useQuery({
|
||||
queryKey: ["userPreferences"],
|
||||
queryFn: () => userPreferencesAPI.get().then((res) => res.data),
|
||||
retry: 1,
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||
});
|
||||
|
||||
// Sync with user preferences from backend
|
||||
useEffect(() => {
|
||||
if (userPreferences?.theme_preference) {
|
||||
setTheme(userPreferences.theme_preference);
|
||||
localStorage.setItem("theme", userPreferences.theme_preference);
|
||||
}
|
||||
}, [userPreferences]);
|
||||
|
||||
useEffect(() => {
|
||||
// Apply theme to document
|
||||
if (theme === "dark") {
|
||||
@@ -36,8 +54,17 @@ export const ThemeProvider = ({ children }) => {
|
||||
localStorage.setItem("theme", theme);
|
||||
}, [theme]);
|
||||
|
||||
const toggleTheme = () => {
|
||||
setTheme((prevTheme) => (prevTheme === "light" ? "dark" : "light"));
|
||||
const toggleTheme = async () => {
|
||||
const newTheme = theme === "light" ? "dark" : "light";
|
||||
setTheme(newTheme);
|
||||
|
||||
// Save to backend
|
||||
try {
|
||||
await userPreferencesAPI.update({ theme_preference: newTheme });
|
||||
} catch (error) {
|
||||
console.error("Failed to save theme preference:", error);
|
||||
// Theme is already set locally, so user still sees the change
|
||||
}
|
||||
};
|
||||
|
||||
const value = {
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { createContext, useContext, useState } from "react";
|
||||
import { isAuthReady } from "../constants/authPhases";
|
||||
import { settingsAPI } from "../utils/api";
|
||||
import { useAuth } from "./AuthContext";
|
||||
import { useSettings } from "./SettingsContext";
|
||||
|
||||
const UpdateNotificationContext = createContext();
|
||||
|
||||
@@ -18,17 +15,7 @@ export const useUpdateNotification = () => {
|
||||
|
||||
export const UpdateNotificationProvider = ({ children }) => {
|
||||
const [dismissed, setDismissed] = useState(false);
|
||||
const { authPhase, isAuthenticated } = useAuth();
|
||||
|
||||
// Ensure settings are loaded - but only after auth is fully ready
|
||||
// This reads cached update info from backend (updated by scheduler)
|
||||
const { data: settings, isLoading: settingsLoading } = useQuery({
|
||||
queryKey: ["settings"],
|
||||
queryFn: () => settingsAPI.get().then((res) => res.data),
|
||||
staleTime: 5 * 60 * 1000, // Settings stay fresh for 5 minutes
|
||||
refetchOnWindowFocus: false,
|
||||
enabled: isAuthReady(authPhase, isAuthenticated()),
|
||||
});
|
||||
const { settings, isLoading: settingsLoading } = useSettings();
|
||||
|
||||
// Read cached update information from settings (no GitHub API calls)
|
||||
// The backend scheduler updates this data periodically
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
import { useEffect, useId, useState } from "react";
|
||||
|
||||
import { useAuth } from "../contexts/AuthContext";
|
||||
import { THEME_PRESETS, useColorTheme } from "../contexts/ColorThemeContext";
|
||||
import { useTheme } from "../contexts/ThemeContext";
|
||||
import { isCorsError, tfaAPI } from "../utils/api";
|
||||
|
||||
@@ -38,6 +39,7 @@ const Profile = () => {
|
||||
const confirmPasswordId = useId();
|
||||
const { user, updateProfile, changePassword } = useAuth();
|
||||
const { toggleTheme, isDark } = useTheme();
|
||||
const { colorTheme, setColorTheme } = useColorTheme();
|
||||
const [activeTab, setActiveTab] = useState("profile");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [message, setMessage] = useState({ type: "", text: "" });
|
||||
@@ -411,6 +413,68 @@ const Profile = () => {
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Color Theme Settings */}
|
||||
<div className="mt-6 pt-6 border-t border-secondary-200 dark:border-secondary-600">
|
||||
<h4 className="text-sm font-medium text-secondary-900 dark:text-white mb-2">
|
||||
Color Theme
|
||||
</h4>
|
||||
<p className="text-xs text-secondary-500 dark:text-secondary-400 mb-4">
|
||||
Choose your preferred color scheme for the application
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-4">
|
||||
{Object.entries(THEME_PRESETS).map(([themeKey, theme]) => {
|
||||
const isSelected = colorTheme === themeKey;
|
||||
const gradientColors = theme.login.xColors;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={themeKey}
|
||||
type="button"
|
||||
onClick={() => setColorTheme(themeKey)}
|
||||
className={`relative p-4 rounded-lg border-2 transition-all ${
|
||||
isSelected
|
||||
? "border-primary-500 ring-2 ring-primary-200 dark:ring-primary-800"
|
||||
: "border-secondary-200 dark:border-secondary-600 hover:border-primary-300"
|
||||
} cursor-pointer`}
|
||||
>
|
||||
{/* Theme Preview */}
|
||||
<div
|
||||
className="h-20 rounded-md mb-3 overflow-hidden"
|
||||
style={{
|
||||
background: `linear-gradient(135deg, ${gradientColors.join(", ")})`,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Theme Name */}
|
||||
<div className="text-sm font-medium text-secondary-900 dark:text-white mb-1">
|
||||
{theme.name}
|
||||
</div>
|
||||
|
||||
{/* Selected Indicator */}
|
||||
{isSelected && (
|
||||
<div className="absolute top-2 right-2 bg-primary-500 text-white rounded-full p-1">
|
||||
<svg
|
||||
className="w-4 h-4"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
aria-label="Selected theme"
|
||||
>
|
||||
<title>Selected</title>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
AlertCircle,
|
||||
BarChart3,
|
||||
BookOpen,
|
||||
CheckCircle,
|
||||
Eye,
|
||||
EyeOff,
|
||||
@@ -178,6 +179,19 @@ const SettingsMetrics = () => {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* More Information Button */}
|
||||
<div className="mt-4 pt-4 border-t border-blue-200 dark:border-blue-700">
|
||||
<a
|
||||
href="https://docs.patchmon.net/books/patchmon-application-documentation/page/metrics-collection-information"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center px-4 py-2 text-sm font-medium text-blue-700 dark:text-blue-300 bg-blue-100 dark:bg-blue-900/50 rounded-md hover:bg-blue-200 dark:hover:bg-blue-900/70 transition-colors"
|
||||
>
|
||||
<BookOpen className="h-4 w-4 mr-2" />
|
||||
More Information
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Metrics Toggle */}
|
||||
|
||||
@@ -169,6 +169,12 @@ export const settingsAPI = {
|
||||
getServerUrl: () => api.get("/settings/server-url"),
|
||||
};
|
||||
|
||||
// User Preferences API
|
||||
export const userPreferencesAPI = {
|
||||
get: () => api.get("/user/preferences"),
|
||||
update: (preferences) => api.patch("/user/preferences", preferences),
|
||||
};
|
||||
|
||||
// Agent File Management API
|
||||
export const agentFileAPI = {
|
||||
getInfo: () => api.get("/hosts/agent/info"),
|
||||
|
||||
Reference in New Issue
Block a user