mirror of
				https://github.com/9technologygroup/patchmon.net.git
				synced 2025-11-04 05:53:27 +00:00 
			
		
		
		
	Made github version checking better
Added functionality of Logo branding Modified sidebar width
This commit is contained in:
		@@ -0,0 +1,4 @@
 | 
			
		||||
-- AddLogoFieldsToSettings
 | 
			
		||||
ALTER TABLE "settings" ADD COLUMN "logo_dark" VARCHAR(255) DEFAULT '/assets/logo_dark.png';
 | 
			
		||||
ALTER TABLE "settings" ADD COLUMN "logo_light" VARCHAR(255) DEFAULT '/assets/logo_light.png';
 | 
			
		||||
ALTER TABLE "settings" ADD COLUMN "favicon" VARCHAR(255) DEFAULT '/assets/logo_square.svg';
 | 
			
		||||
@@ -164,6 +164,9 @@ model settings {
 | 
			
		||||
  signup_enabled    Boolean   @default(false)
 | 
			
		||||
  default_user_role String    @default("user")
 | 
			
		||||
  ignore_ssl_self_signed Boolean @default(false)
 | 
			
		||||
  logo_dark         String?   @default("/assets/logo_dark.png")
 | 
			
		||||
  logo_light        String?   @default("/assets/logo_light.png")
 | 
			
		||||
  favicon           String?   @default("/assets/logo_square.svg")
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
model update_history {
 | 
			
		||||
 
 | 
			
		||||
@@ -215,6 +215,18 @@ router.put(
 | 
			
		||||
				}
 | 
			
		||||
				return true;
 | 
			
		||||
			}),
 | 
			
		||||
		body("logoDark")
 | 
			
		||||
			.optional()
 | 
			
		||||
			.isLength({ min: 1 })
 | 
			
		||||
			.withMessage("Logo dark path must be a non-empty string"),
 | 
			
		||||
		body("logoLight")
 | 
			
		||||
			.optional()
 | 
			
		||||
			.isLength({ min: 1 })
 | 
			
		||||
			.withMessage("Logo light path must be a non-empty string"),
 | 
			
		||||
		body("favicon")
 | 
			
		||||
			.optional()
 | 
			
		||||
			.isLength({ min: 1 })
 | 
			
		||||
			.withMessage("Favicon path must be a non-empty string"),
 | 
			
		||||
	],
 | 
			
		||||
	async (req, res) => {
 | 
			
		||||
		try {
 | 
			
		||||
@@ -236,6 +248,9 @@ router.put(
 | 
			
		||||
				githubRepoUrl,
 | 
			
		||||
				repositoryType,
 | 
			
		||||
				sshKeyPath,
 | 
			
		||||
				logoDark,
 | 
			
		||||
				logoLight,
 | 
			
		||||
				favicon,
 | 
			
		||||
			} = req.body;
 | 
			
		||||
 | 
			
		||||
			// Get current settings to check for update interval changes
 | 
			
		||||
@@ -264,6 +279,9 @@ router.put(
 | 
			
		||||
			if (repositoryType !== undefined)
 | 
			
		||||
				updateData.repository_type = repositoryType;
 | 
			
		||||
			if (sshKeyPath !== undefined) updateData.ssh_key_path = sshKeyPath;
 | 
			
		||||
			if (logoDark !== undefined) updateData.logo_dark = logoDark;
 | 
			
		||||
			if (logoLight !== undefined) updateData.logo_light = logoLight;
 | 
			
		||||
			if (favicon !== undefined) updateData.favicon = favicon;
 | 
			
		||||
 | 
			
		||||
			const updatedSettings = await updateSettings(
 | 
			
		||||
				currentSettings.id,
 | 
			
		||||
@@ -351,4 +369,175 @@ router.get("/auto-update", async (_req, res) => {
 | 
			
		||||
	}
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
// Upload logo files
 | 
			
		||||
router.post(
 | 
			
		||||
	"/logos/upload",
 | 
			
		||||
	authenticateToken,
 | 
			
		||||
	requireManageSettings,
 | 
			
		||||
	async (req, res) => {
 | 
			
		||||
		try {
 | 
			
		||||
			const { logoType, fileContent, fileName } = req.body;
 | 
			
		||||
 | 
			
		||||
			if (!logoType || !fileContent) {
 | 
			
		||||
				return res.status(400).json({
 | 
			
		||||
					error: "Logo type and file content are required",
 | 
			
		||||
				});
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			if (!["dark", "light", "favicon"].includes(logoType)) {
 | 
			
		||||
				return res.status(400).json({
 | 
			
		||||
					error: "Logo type must be 'dark', 'light', or 'favicon'",
 | 
			
		||||
				});
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			// Validate file content (basic checks)
 | 
			
		||||
			if (typeof fileContent !== "string") {
 | 
			
		||||
				return res.status(400).json({
 | 
			
		||||
					error: "File content must be a base64 string",
 | 
			
		||||
				});
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			const fs = require("node:fs").promises;
 | 
			
		||||
			const path = require("node:path");
 | 
			
		||||
			const _crypto = require("node:crypto");
 | 
			
		||||
 | 
			
		||||
			// Create assets directory if it doesn't exist
 | 
			
		||||
			// In development: save to public/assets (served by Vite)
 | 
			
		||||
			// In production: save to dist/assets (served by built app)
 | 
			
		||||
			const isDevelopment = process.env.NODE_ENV !== "production";
 | 
			
		||||
			const assetsDir = isDevelopment
 | 
			
		||||
				? path.join(__dirname, "../../../frontend/public/assets")
 | 
			
		||||
				: path.join(__dirname, "../../../frontend/dist/assets");
 | 
			
		||||
			await fs.mkdir(assetsDir, { recursive: true });
 | 
			
		||||
 | 
			
		||||
			// Determine file extension and path
 | 
			
		||||
			let fileExtension;
 | 
			
		||||
			let fileName_final;
 | 
			
		||||
 | 
			
		||||
			if (logoType === "favicon") {
 | 
			
		||||
				fileExtension = ".svg";
 | 
			
		||||
				fileName_final = fileName || "logo_square.svg";
 | 
			
		||||
			} else {
 | 
			
		||||
				// Determine extension from file content or use default
 | 
			
		||||
				if (fileContent.startsWith("data:image/png")) {
 | 
			
		||||
					fileExtension = ".png";
 | 
			
		||||
				} else if (fileContent.startsWith("data:image/svg")) {
 | 
			
		||||
					fileExtension = ".svg";
 | 
			
		||||
				} else if (
 | 
			
		||||
					fileContent.startsWith("data:image/jpeg") ||
 | 
			
		||||
					fileContent.startsWith("data:image/jpg")
 | 
			
		||||
				) {
 | 
			
		||||
					fileExtension = ".jpg";
 | 
			
		||||
				} else {
 | 
			
		||||
					fileExtension = ".png"; // Default to PNG
 | 
			
		||||
				}
 | 
			
		||||
				fileName_final = fileName || `logo_${logoType}${fileExtension}`;
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			const filePath = path.join(assetsDir, fileName_final);
 | 
			
		||||
 | 
			
		||||
			// Handle base64 data URLs
 | 
			
		||||
			let fileBuffer;
 | 
			
		||||
			if (fileContent.startsWith("data:")) {
 | 
			
		||||
				const base64Data = fileContent.split(",")[1];
 | 
			
		||||
				fileBuffer = Buffer.from(base64Data, "base64");
 | 
			
		||||
			} else {
 | 
			
		||||
				// Assume it's already base64
 | 
			
		||||
				fileBuffer = Buffer.from(fileContent, "base64");
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			// Create backup of existing file
 | 
			
		||||
			try {
 | 
			
		||||
				const backupPath = `${filePath}.backup.${Date.now()}`;
 | 
			
		||||
				await fs.copyFile(filePath, backupPath);
 | 
			
		||||
				console.log(`Created backup: ${backupPath}`);
 | 
			
		||||
			} catch (error) {
 | 
			
		||||
				// Ignore if original doesn't exist
 | 
			
		||||
				if (error.code !== "ENOENT") {
 | 
			
		||||
					console.warn("Failed to create backup:", error.message);
 | 
			
		||||
				}
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			// Write new logo file
 | 
			
		||||
			await fs.writeFile(filePath, fileBuffer);
 | 
			
		||||
 | 
			
		||||
			// Update settings with new logo path
 | 
			
		||||
			const settings = await getSettings();
 | 
			
		||||
			const logoPath = `/assets/${fileName_final}`;
 | 
			
		||||
 | 
			
		||||
			const updateData = {};
 | 
			
		||||
			if (logoType === "dark") {
 | 
			
		||||
				updateData.logo_dark = logoPath;
 | 
			
		||||
			} else if (logoType === "light") {
 | 
			
		||||
				updateData.logo_light = logoPath;
 | 
			
		||||
			} else if (logoType === "favicon") {
 | 
			
		||||
				updateData.favicon = logoPath;
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			await updateSettings(settings.id, updateData);
 | 
			
		||||
 | 
			
		||||
			// Get file stats
 | 
			
		||||
			const stats = await fs.stat(filePath);
 | 
			
		||||
 | 
			
		||||
			res.json({
 | 
			
		||||
				message: `${logoType} logo uploaded successfully`,
 | 
			
		||||
				fileName: fileName_final,
 | 
			
		||||
				path: logoPath,
 | 
			
		||||
				size: stats.size,
 | 
			
		||||
				sizeFormatted: `${(stats.size / 1024).toFixed(1)} KB`,
 | 
			
		||||
			});
 | 
			
		||||
		} catch (error) {
 | 
			
		||||
			console.error("Upload logo error:", error);
 | 
			
		||||
			res.status(500).json({ error: "Failed to upload logo" });
 | 
			
		||||
		}
 | 
			
		||||
	},
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
// Reset logo to default
 | 
			
		||||
router.post(
 | 
			
		||||
	"/logos/reset",
 | 
			
		||||
	authenticateToken,
 | 
			
		||||
	requireManageSettings,
 | 
			
		||||
	async (req, res) => {
 | 
			
		||||
		try {
 | 
			
		||||
			const { logoType } = req.body;
 | 
			
		||||
 | 
			
		||||
			if (!logoType) {
 | 
			
		||||
				return res.status(400).json({
 | 
			
		||||
					error: "Logo type is required",
 | 
			
		||||
				});
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			if (!["dark", "light", "favicon"].includes(logoType)) {
 | 
			
		||||
				return res.status(400).json({
 | 
			
		||||
					error: "Logo type must be 'dark', 'light', or 'favicon'",
 | 
			
		||||
				});
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			// Get current settings
 | 
			
		||||
			const settings = await getSettings();
 | 
			
		||||
 | 
			
		||||
			// Clear the custom logo path to revert to default
 | 
			
		||||
			const updateData = {};
 | 
			
		||||
			if (logoType === "dark") {
 | 
			
		||||
				updateData.logo_dark = null;
 | 
			
		||||
			} else if (logoType === "light") {
 | 
			
		||||
				updateData.logo_light = null;
 | 
			
		||||
			} else if (logoType === "favicon") {
 | 
			
		||||
				updateData.favicon = null;
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			await updateSettings(settings.id, updateData);
 | 
			
		||||
 | 
			
		||||
			res.json({
 | 
			
		||||
				message: `${logoType} logo reset to default successfully`,
 | 
			
		||||
				logoType,
 | 
			
		||||
			});
 | 
			
		||||
		} catch (error) {
 | 
			
		||||
			console.error("Reset logo error:", error);
 | 
			
		||||
			res.status(500).json({ error: "Failed to reset logo" });
 | 
			
		||||
		}
 | 
			
		||||
	},
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
module.exports = router;
 | 
			
		||||
 
 | 
			
		||||
@@ -188,72 +188,24 @@ router.get("/current", authenticateToken, async (_req, res) => {
 | 
			
		||||
	try {
 | 
			
		||||
		const currentVersion = getCurrentVersion();
 | 
			
		||||
 | 
			
		||||
		// Get GitHub repository info from settings or use default
 | 
			
		||||
		// Get settings with cached update info (no GitHub API calls)
 | 
			
		||||
		const settings = await prisma.settings.findFirst();
 | 
			
		||||
		const githubRepoUrl = settings?.githubRepoUrl || DEFAULT_GITHUB_REPO;
 | 
			
		||||
		const { owner, repo } = parseGitHubRepo(githubRepoUrl);
 | 
			
		||||
 | 
			
		||||
		let latestRelease = null;
 | 
			
		||||
		let latestCommit = null;
 | 
			
		||||
		let commitDifference = null;
 | 
			
		||||
 | 
			
		||||
		// Fetch GitHub data if we have valid owner/repo
 | 
			
		||||
		if (owner && repo) {
 | 
			
		||||
			try {
 | 
			
		||||
				// Fetch latest release, latest commit, and commit difference in parallel
 | 
			
		||||
				const [releaseData, commitData, differenceData] = await Promise.all([
 | 
			
		||||
					getLatestRelease(owner, repo),
 | 
			
		||||
					getLatestCommit(owner, repo),
 | 
			
		||||
					getCommitDifference(owner, repo, currentVersion),
 | 
			
		||||
				]);
 | 
			
		||||
 | 
			
		||||
				latestRelease = releaseData;
 | 
			
		||||
				latestCommit = commitData;
 | 
			
		||||
				commitDifference = differenceData;
 | 
			
		||||
			} catch (githubError) {
 | 
			
		||||
				console.warn("Failed to fetch GitHub data:", githubError.message);
 | 
			
		||||
 | 
			
		||||
				// Provide fallback data when GitHub API is rate-limited
 | 
			
		||||
				if (
 | 
			
		||||
					githubError.message.includes("rate limit") ||
 | 
			
		||||
					githubError.message.includes("API rate limit")
 | 
			
		||||
				) {
 | 
			
		||||
					console.log("GitHub API rate limited, providing fallback data");
 | 
			
		||||
					latestRelease = {
 | 
			
		||||
						tagName: "v1.2.7",
 | 
			
		||||
						version: "1.2.7",
 | 
			
		||||
						publishedAt: "2025-10-02T17:12:53Z",
 | 
			
		||||
						htmlUrl: "https://github.com/PatchMon/PatchMon/releases/tag/v1.2.7",
 | 
			
		||||
					};
 | 
			
		||||
					latestCommit = {
 | 
			
		||||
						sha: "cc89df161b8ea5d48ff95b0eb405fe69042052cd",
 | 
			
		||||
						message: "Update README.md\n\nAdded Documentation Links",
 | 
			
		||||
						author: "9 Technology Group LTD",
 | 
			
		||||
						date: "2025-10-04T18:38:09Z",
 | 
			
		||||
						htmlUrl:
 | 
			
		||||
							"https://github.com/PatchMon/PatchMon/commit/cc89df161b8ea5d48ff95b0eb405fe69042052cd",
 | 
			
		||||
					};
 | 
			
		||||
					commitDifference = {
 | 
			
		||||
						commitsBehind: 0,
 | 
			
		||||
						commitsAhead: 3, // Main branch is ahead of release
 | 
			
		||||
						totalCommits: 3,
 | 
			
		||||
						branchInfo: "main branch vs release",
 | 
			
		||||
					};
 | 
			
		||||
				}
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		// Return current version and cached update information
 | 
			
		||||
		// The backend scheduler updates this data periodically
 | 
			
		||||
		res.json({
 | 
			
		||||
			version: currentVersion,
 | 
			
		||||
			latest_version: settings?.latest_version || null,
 | 
			
		||||
			is_update_available: settings?.is_update_available || false,
 | 
			
		||||
			last_update_check: settings?.last_update_check || null,
 | 
			
		||||
			buildDate: new Date().toISOString(),
 | 
			
		||||
			environment: process.env.NODE_ENV || "development",
 | 
			
		||||
			github: {
 | 
			
		||||
				repository: githubRepoUrl,
 | 
			
		||||
				owner: owner,
 | 
			
		||||
				repo: repo,
 | 
			
		||||
				latestRelease: latestRelease,
 | 
			
		||||
				latestCommit: latestCommit,
 | 
			
		||||
				commitDifference: commitDifference,
 | 
			
		||||
			},
 | 
			
		||||
		});
 | 
			
		||||
	} catch (error) {
 | 
			
		||||
 
 | 
			
		||||
@@ -1,5 +1,5 @@
 | 
			
		||||
const jwt = require("jsonwebtoken");
 | 
			
		||||
const crypto = require("crypto");
 | 
			
		||||
const crypto = require("node:crypto");
 | 
			
		||||
const { PrismaClient } = require("@prisma/client");
 | 
			
		||||
 | 
			
		||||
const prisma = new PrismaClient();
 | 
			
		||||
 
 | 
			
		||||
@@ -2,7 +2,7 @@
 | 
			
		||||
<html lang="en">
 | 
			
		||||
  <head>
 | 
			
		||||
    <meta charset="UTF-8" />
 | 
			
		||||
    <link rel="icon" type="image/svg+xml" href="/vite.svg" />
 | 
			
		||||
    <link rel="icon" type="image/svg+xml" href="/assets/favicon.svg" />
 | 
			
		||||
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
 | 
			
		||||
    <title>PatchMon - Linux Patch Monitoring Dashboard</title>
 | 
			
		||||
    <link rel="preconnect" href="https://fonts.googleapis.com">
 | 
			
		||||
 
 | 
			
		||||
@@ -1,6 +1,7 @@
 | 
			
		||||
import { Route, Routes } from "react-router-dom";
 | 
			
		||||
import FirstTimeAdminSetup from "./components/FirstTimeAdminSetup";
 | 
			
		||||
import Layout from "./components/Layout";
 | 
			
		||||
import LogoProvider from "./components/LogoProvider";
 | 
			
		||||
import ProtectedRoute from "./components/ProtectedRoute";
 | 
			
		||||
import SettingsLayout from "./components/SettingsLayout";
 | 
			
		||||
import { isAuthPhase } from "./constants/authPhases";
 | 
			
		||||
@@ -290,6 +291,16 @@ function AppRoutes() {
 | 
			
		||||
					</ProtectedRoute>
 | 
			
		||||
				}
 | 
			
		||||
			/>
 | 
			
		||||
			<Route
 | 
			
		||||
				path="/settings/branding"
 | 
			
		||||
				element={
 | 
			
		||||
					<ProtectedRoute requirePermission="can_manage_settings">
 | 
			
		||||
						<Layout>
 | 
			
		||||
							<SettingsServerConfig />
 | 
			
		||||
						</Layout>
 | 
			
		||||
					</ProtectedRoute>
 | 
			
		||||
				}
 | 
			
		||||
			/>
 | 
			
		||||
			<Route
 | 
			
		||||
				path="/settings/agent-version"
 | 
			
		||||
				element={
 | 
			
		||||
@@ -329,7 +340,9 @@ function App() {
 | 
			
		||||
		<ThemeProvider>
 | 
			
		||||
			<AuthProvider>
 | 
			
		||||
				<UpdateNotificationProvider>
 | 
			
		||||
					<LogoProvider>
 | 
			
		||||
						<AppRoutes />
 | 
			
		||||
					</LogoProvider>
 | 
			
		||||
				</UpdateNotificationProvider>
 | 
			
		||||
			</AuthProvider>
 | 
			
		||||
		</ThemeProvider>
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										16
									
								
								frontend/src/components/DiscordIcon.jsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										16
									
								
								frontend/src/components/DiscordIcon.jsx
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,16 @@
 | 
			
		||||
const DiscordIcon = ({ className = "h-5 w-5" }) => {
 | 
			
		||||
	return (
 | 
			
		||||
		<svg
 | 
			
		||||
			viewBox="0 0 24 24"
 | 
			
		||||
			fill="currentColor"
 | 
			
		||||
			className={className}
 | 
			
		||||
			xmlns="http://www.w3.org/2000/svg"
 | 
			
		||||
			aria-label="Discord"
 | 
			
		||||
		>
 | 
			
		||||
			<title>Discord</title>
 | 
			
		||||
			<path d="M20.317 4.3698a19.7913 19.7913 0 00-4.8851-1.5152.0741.0741 0 00-.0785.0371c-.211.3753-.4447.8648-.6083 1.2495-1.8447-.2762-3.68-.2762-5.4868 0-.1636-.3933-.4058-.8742-.6177-1.2495a.077.077 0 00-.0785-.037 19.7363 19.7363 0 00-4.8852 1.515.0699.0699 0 00-.0321.0277C.5334 9.0458-.319 13.5799.0992 18.0578a.0824.0824 0 00.0312.0561c2.0528 1.5076 4.0413 2.4228 5.9929 3.0294a.0777.0777 0 00.0842-.0276c.4616-.6304.8731-1.2952 1.226-1.9942a.076.076 0 00-.0416-.1057c-.6528-.2476-1.2743-.5495-1.8722-.8923a.077.077 0 01-.0076-.1277c.1258-.0943.2517-.1923.3718-.2914a.0743.0743 0 01.0776-.0105c3.9278 1.7933 8.18 1.7933 12.0614 0a.0739.0739 0 01.0785.0095c.1202.099.246.1981.3728.2924a.077.077 0 01-.0066.1276 12.2986 12.2986 0 01-1.873.8914.0766.0766 0 00-.0407.1067c.3604.698.7719 1.3628 1.225 1.9932a.076.076 0 00.0842.0286c1.961-.6067 3.9495-1.5219 6.0023-3.0294a.077.077 0 00.0313-.0552c.5004-5.177-.8382-9.6739-3.5485-13.6604a.061.061 0 00-.0312-.0286zM8.02 15.3312c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9555-2.4189 2.157-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.9555 2.4189-2.1569 2.4189zm7.9748 0c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9554-2.4189 2.1569-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.946 2.4189-2.1568 2.4189z" />
 | 
			
		||||
		</svg>
 | 
			
		||||
	);
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export default DiscordIcon;
 | 
			
		||||
@@ -250,7 +250,7 @@ const GlobalSearch = () => {
 | 
			
		||||
									<div className="sticky top-0 z-10 bg-secondary-50 px-3 py-1.5 text-xs font-semibold uppercase tracking-wider text-secondary-500 dark:bg-secondary-700 dark:text-secondary-400">
 | 
			
		||||
										Hosts
 | 
			
		||||
									</div>
 | 
			
		||||
									{results.hosts.map((host, idx) => {
 | 
			
		||||
									{results.hosts.map((host, _idx) => {
 | 
			
		||||
										const display = getResultDisplay(host);
 | 
			
		||||
										const globalIdx = navigableResults.findIndex(
 | 
			
		||||
											(r) => r.id === host.id && r.type === "host",
 | 
			
		||||
@@ -291,7 +291,7 @@ const GlobalSearch = () => {
 | 
			
		||||
									<div className="sticky top-0 z-10 bg-secondary-50 px-3 py-1.5 text-xs font-semibold uppercase tracking-wider text-secondary-500 dark:bg-secondary-700 dark:text-secondary-400">
 | 
			
		||||
										Packages
 | 
			
		||||
									</div>
 | 
			
		||||
									{results.packages.map((pkg, idx) => {
 | 
			
		||||
									{results.packages.map((pkg, _idx) => {
 | 
			
		||||
										const display = getResultDisplay(pkg);
 | 
			
		||||
										const globalIdx = navigableResults.findIndex(
 | 
			
		||||
											(r) => r.id === pkg.id && r.type === "package",
 | 
			
		||||
@@ -338,7 +338,7 @@ const GlobalSearch = () => {
 | 
			
		||||
									<div className="sticky top-0 z-10 bg-secondary-50 px-3 py-1.5 text-xs font-semibold uppercase tracking-wider text-secondary-500 dark:bg-secondary-700 dark:text-secondary-400">
 | 
			
		||||
										Repositories
 | 
			
		||||
									</div>
 | 
			
		||||
									{results.repositories.map((repo, idx) => {
 | 
			
		||||
									{results.repositories.map((repo, _idx) => {
 | 
			
		||||
										const display = getResultDisplay(repo);
 | 
			
		||||
										const globalIdx = navigableResults.findIndex(
 | 
			
		||||
											(r) => r.id === repo.id && r.type === "repository",
 | 
			
		||||
@@ -379,7 +379,7 @@ const GlobalSearch = () => {
 | 
			
		||||
									<div className="sticky top-0 z-10 bg-secondary-50 px-3 py-1.5 text-xs font-semibold uppercase tracking-wider text-secondary-500 dark:bg-secondary-700 dark:text-secondary-400">
 | 
			
		||||
										Users
 | 
			
		||||
									</div>
 | 
			
		||||
									{results.users.map((user, idx) => {
 | 
			
		||||
									{results.users.map((user, _idx) => {
 | 
			
		||||
										const display = getResultDisplay(user);
 | 
			
		||||
										const globalIdx = navigableResults.findIndex(
 | 
			
		||||
											(r) => r.id === user.id && r.type === "user",
 | 
			
		||||
 
 | 
			
		||||
@@ -2,6 +2,7 @@ import { useQuery } from "@tanstack/react-query";
 | 
			
		||||
import {
 | 
			
		||||
	Activity,
 | 
			
		||||
	BarChart3,
 | 
			
		||||
	BookOpen,
 | 
			
		||||
	ChevronLeft,
 | 
			
		||||
	ChevronRight,
 | 
			
		||||
	Clock,
 | 
			
		||||
@@ -13,13 +14,12 @@ import {
 | 
			
		||||
	LogOut,
 | 
			
		||||
	Mail,
 | 
			
		||||
	Menu,
 | 
			
		||||
	MessageCircle,
 | 
			
		||||
	Package,
 | 
			
		||||
	Plus,
 | 
			
		||||
	RefreshCw,
 | 
			
		||||
	Route,
 | 
			
		||||
	Server,
 | 
			
		||||
	Settings,
 | 
			
		||||
	Shield,
 | 
			
		||||
	Star,
 | 
			
		||||
	UserCircle,
 | 
			
		||||
	X,
 | 
			
		||||
@@ -29,7 +29,9 @@ import { Link, useLocation, useNavigate } from "react-router-dom";
 | 
			
		||||
import { useAuth } from "../contexts/AuthContext";
 | 
			
		||||
import { useUpdateNotification } from "../contexts/UpdateNotificationContext";
 | 
			
		||||
import { dashboardAPI, versionAPI } from "../utils/api";
 | 
			
		||||
import DiscordIcon from "./DiscordIcon";
 | 
			
		||||
import GlobalSearch from "./GlobalSearch";
 | 
			
		||||
import Logo from "./Logo";
 | 
			
		||||
import UpgradeNotificationIcon from "./UpgradeNotificationIcon";
 | 
			
		||||
 | 
			
		||||
const Layout = ({ children }) => {
 | 
			
		||||
@@ -293,7 +295,7 @@ const Layout = ({ children }) => {
 | 
			
		||||
					onClick={() => setSidebarOpen(false)}
 | 
			
		||||
					aria-label="Close sidebar"
 | 
			
		||||
				/>
 | 
			
		||||
				<div className="relative flex w-full max-w-xs flex-col bg-white pb-4 pt-5 shadow-xl">
 | 
			
		||||
				<div className="relative flex w-full max-w-[280px] flex-col bg-white pb-4 pt-5 shadow-xl">
 | 
			
		||||
					<div className="absolute right-0 top-0 -mr-12 pt-2">
 | 
			
		||||
						<button
 | 
			
		||||
							type="button"
 | 
			
		||||
@@ -303,13 +305,10 @@ const Layout = ({ children }) => {
 | 
			
		||||
							<X className="h-6 w-6 text-white" />
 | 
			
		||||
						</button>
 | 
			
		||||
					</div>
 | 
			
		||||
					<div className="flex flex-shrink-0 items-center px-4">
 | 
			
		||||
						<div className="flex items-center">
 | 
			
		||||
							<Shield className="h-8 w-8 text-primary-600" />
 | 
			
		||||
							<h1 className="ml-2 text-xl font-bold text-secondary-900 dark:text-white">
 | 
			
		||||
								PatchMon
 | 
			
		||||
							</h1>
 | 
			
		||||
						</div>
 | 
			
		||||
					<div className="flex flex-shrink-0 items-center justify-center px-4">
 | 
			
		||||
						<Link to="/" className="flex items-center">
 | 
			
		||||
							<Logo className="h-10 w-auto" alt="PatchMon Logo" />
 | 
			
		||||
						</Link>
 | 
			
		||||
					</div>
 | 
			
		||||
					<nav className="mt-8 flex-1 space-y-6 px-2">
 | 
			
		||||
						{/* Show message for users with very limited permissions */}
 | 
			
		||||
@@ -345,7 +344,7 @@ const Layout = ({ children }) => {
 | 
			
		||||
								// Section with items
 | 
			
		||||
								return (
 | 
			
		||||
									<div key={item.section}>
 | 
			
		||||
										<h3 className="text-xs font-semibold text-secondary-500 uppercase tracking-wider mb-2 px-2">
 | 
			
		||||
										<h3 className="text-xs font-semibold text-secondary-500 uppercase tracking-wider mb-2">
 | 
			
		||||
											{item.section}
 | 
			
		||||
										</h3>
 | 
			
		||||
										<div className="space-y-1">
 | 
			
		||||
@@ -465,8 +464,8 @@ const Layout = ({ children }) => {
 | 
			
		||||
 | 
			
		||||
			{/* Desktop sidebar */}
 | 
			
		||||
			<div
 | 
			
		||||
				className={`hidden lg:fixed lg:inset-y-0 lg:z-50 lg:flex lg:flex-col transition-all duration-300 ${
 | 
			
		||||
					sidebarCollapsed ? "lg:w-16" : "lg:w-64"
 | 
			
		||||
				className={`hidden lg:fixed lg:inset-y-0 lg:z-50 lg:flex lg:flex-col transition-all duration-300 relative ${
 | 
			
		||||
					sidebarCollapsed ? "lg:w-16" : "lg:w-56"
 | 
			
		||||
				} bg-white dark:bg-secondary-800`}
 | 
			
		||||
			>
 | 
			
		||||
				<div
 | 
			
		||||
@@ -475,38 +474,37 @@ const Layout = ({ children }) => {
 | 
			
		||||
					}`}
 | 
			
		||||
				>
 | 
			
		||||
					<div
 | 
			
		||||
						className={`flex h-16 shrink-0 items-center border-b border-secondary-200 ${
 | 
			
		||||
							sidebarCollapsed ? "justify-center" : "justify-between"
 | 
			
		||||
						className={`flex h-16 shrink-0 items-center border-b border-secondary-200 dark:border-secondary-600 ${
 | 
			
		||||
							sidebarCollapsed ? "justify-center" : "justify-center"
 | 
			
		||||
						}`}
 | 
			
		||||
					>
 | 
			
		||||
						{sidebarCollapsed ? (
 | 
			
		||||
							<button
 | 
			
		||||
								type="button"
 | 
			
		||||
								onClick={() => setSidebarCollapsed(!sidebarCollapsed)}
 | 
			
		||||
								className="flex items-center justify-center w-8 h-8 rounded-md hover:bg-secondary-100 transition-colors"
 | 
			
		||||
								title="Expand sidebar"
 | 
			
		||||
							>
 | 
			
		||||
								<ChevronRight className="h-5 w-5 text-secondary-700 dark:text-white" />
 | 
			
		||||
							</button>
 | 
			
		||||
							<Link to="/" className="flex items-center">
 | 
			
		||||
								<img
 | 
			
		||||
									src="/assets/favicon.svg"
 | 
			
		||||
									alt="PatchMon"
 | 
			
		||||
									className="h-12 w-12 object-contain"
 | 
			
		||||
								/>
 | 
			
		||||
							</Link>
 | 
			
		||||
						) : (
 | 
			
		||||
							<>
 | 
			
		||||
								<div className="flex items-center">
 | 
			
		||||
									<Shield className="h-8 w-8 text-primary-600" />
 | 
			
		||||
									<h1 className="ml-2 text-xl font-bold text-secondary-900 dark:text-white">
 | 
			
		||||
										PatchMon
 | 
			
		||||
									</h1>
 | 
			
		||||
								</div>
 | 
			
		||||
								<button
 | 
			
		||||
									type="button"
 | 
			
		||||
									onClick={() => setSidebarCollapsed(!sidebarCollapsed)}
 | 
			
		||||
									className="flex items-center justify-center w-8 h-8 rounded-md hover:bg-secondary-100 transition-colors"
 | 
			
		||||
									title="Collapse sidebar"
 | 
			
		||||
								>
 | 
			
		||||
									<ChevronLeft className="h-5 w-5 text-secondary-700 dark:text-white" />
 | 
			
		||||
								</button>
 | 
			
		||||
							</>
 | 
			
		||||
							<Link to="/" className="flex items-center">
 | 
			
		||||
								<Logo className="h-10 w-auto" alt="PatchMon Logo" />
 | 
			
		||||
							</Link>
 | 
			
		||||
						)}
 | 
			
		||||
					</div>
 | 
			
		||||
					{/* Collapse/Expand button on border */}
 | 
			
		||||
					<button
 | 
			
		||||
						type="button"
 | 
			
		||||
						onClick={() => setSidebarCollapsed(!sidebarCollapsed)}
 | 
			
		||||
						className="absolute top-5 -right-3 z-10 flex items-center justify-center w-6 h-6 rounded-full bg-white dark:bg-secondary-800 border border-secondary-300 dark:border-secondary-600 shadow-md hover:bg-secondary-50 dark:hover:bg-secondary-700 transition-colors"
 | 
			
		||||
						title={sidebarCollapsed ? "Expand sidebar" : "Collapse sidebar"}
 | 
			
		||||
					>
 | 
			
		||||
						{sidebarCollapsed ? (
 | 
			
		||||
							<ChevronRight className="h-4 w-4 text-secondary-700 dark:text-white" />
 | 
			
		||||
						) : (
 | 
			
		||||
							<ChevronLeft className="h-4 w-4 text-secondary-700 dark:text-white" />
 | 
			
		||||
						)}
 | 
			
		||||
					</button>
 | 
			
		||||
					<nav className="flex flex-1 flex-col">
 | 
			
		||||
						<ul className="flex flex-1 flex-col gap-y-6">
 | 
			
		||||
							{/* Show message for users with very limited permissions */}
 | 
			
		||||
@@ -524,7 +522,10 @@ const Layout = ({ children }) => {
 | 
			
		||||
								if (item.name) {
 | 
			
		||||
									// Single item (Dashboard)
 | 
			
		||||
									return (
 | 
			
		||||
										<li key={item.name}>
 | 
			
		||||
										<li
 | 
			
		||||
											key={item.name}
 | 
			
		||||
											className={sidebarCollapsed ? "" : "-mx-2"}
 | 
			
		||||
										>
 | 
			
		||||
											<Link
 | 
			
		||||
												to={item.href}
 | 
			
		||||
												className={`group flex gap-x-3 rounded-md text-sm leading-6 font-semibold transition-all duration-200 ${
 | 
			
		||||
@@ -548,7 +549,7 @@ const Layout = ({ children }) => {
 | 
			
		||||
									return (
 | 
			
		||||
										<li key={item.section}>
 | 
			
		||||
											{!sidebarCollapsed && (
 | 
			
		||||
												<h3 className="text-xs font-semibold text-secondary-500 dark:text-secondary-300 uppercase tracking-wider mb-2 px-2">
 | 
			
		||||
												<h3 className="text-xs font-semibold text-secondary-500 dark:text-secondary-300 uppercase tracking-wider mb-2">
 | 
			
		||||
													{item.section}
 | 
			
		||||
												</h3>
 | 
			
		||||
											)}
 | 
			
		||||
@@ -850,7 +851,7 @@ const Layout = ({ children }) => {
 | 
			
		||||
			{/* Main content */}
 | 
			
		||||
			<div
 | 
			
		||||
				className={`flex flex-col min-h-screen transition-all duration-300 ${
 | 
			
		||||
					sidebarCollapsed ? "lg:pl-16" : "lg:pl-64"
 | 
			
		||||
					sidebarCollapsed ? "lg:pl-16" : "lg:pl-56"
 | 
			
		||||
				}`}
 | 
			
		||||
			>
 | 
			
		||||
				{/* Top bar */}
 | 
			
		||||
@@ -895,6 +896,15 @@ const Layout = ({ children }) => {
 | 
			
		||||
										</div>
 | 
			
		||||
									)}
 | 
			
		||||
								</a>
 | 
			
		||||
								<a
 | 
			
		||||
									href="https://github.com/orgs/PatchMon/projects/2/views/1"
 | 
			
		||||
									target="_blank"
 | 
			
		||||
									rel="noopener noreferrer"
 | 
			
		||||
									className="flex items-center justify-center w-10 h-10 bg-gray-50 dark:bg-gray-800 text-secondary-600 dark:text-secondary-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors shadow-sm"
 | 
			
		||||
									title="Roadmap"
 | 
			
		||||
								>
 | 
			
		||||
									<Route className="h-5 w-5" />
 | 
			
		||||
								</a>
 | 
			
		||||
								<a
 | 
			
		||||
									href="https://patchmon.net/discord"
 | 
			
		||||
									target="_blank"
 | 
			
		||||
@@ -902,7 +912,16 @@ const Layout = ({ children }) => {
 | 
			
		||||
									className="flex items-center justify-center w-10 h-10 bg-gray-50 dark:bg-gray-800 text-secondary-600 dark:text-secondary-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors shadow-sm"
 | 
			
		||||
									title="Discord"
 | 
			
		||||
								>
 | 
			
		||||
									<MessageCircle className="h-5 w-5" />
 | 
			
		||||
									<DiscordIcon className="h-5 w-5" />
 | 
			
		||||
								</a>
 | 
			
		||||
								<a
 | 
			
		||||
									href="https://docs.patchmon.net"
 | 
			
		||||
									target="_blank"
 | 
			
		||||
									rel="noopener noreferrer"
 | 
			
		||||
									className="flex items-center justify-center w-10 h-10 bg-gray-50 dark:bg-gray-800 text-secondary-600 dark:text-secondary-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors shadow-sm"
 | 
			
		||||
									title="Documentation"
 | 
			
		||||
								>
 | 
			
		||||
									<BookOpen className="h-5 w-5" />
 | 
			
		||||
								</a>
 | 
			
		||||
								<a
 | 
			
		||||
									href="mailto:support@patchmon.net"
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										44
									
								
								frontend/src/components/Logo.jsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										44
									
								
								frontend/src/components/Logo.jsx
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,44 @@
 | 
			
		||||
import { useQuery } from "@tanstack/react-query";
 | 
			
		||||
import { useTheme } from "../contexts/ThemeContext";
 | 
			
		||||
import { settingsAPI } from "../utils/api";
 | 
			
		||||
 | 
			
		||||
const Logo = ({
 | 
			
		||||
	className = "h-8 w-auto",
 | 
			
		||||
	alt = "PatchMon Logo",
 | 
			
		||||
	...props
 | 
			
		||||
}) => {
 | 
			
		||||
	const { isDark } = useTheme();
 | 
			
		||||
 | 
			
		||||
	const { data: settings } = useQuery({
 | 
			
		||||
		queryKey: ["settings"],
 | 
			
		||||
		queryFn: () => settingsAPI.get().then((res) => res.data),
 | 
			
		||||
	});
 | 
			
		||||
 | 
			
		||||
	// Determine which logo to use based on theme
 | 
			
		||||
	const logoSrc = isDark
 | 
			
		||||
		? settings?.logo_dark || "/assets/logo_dark.png"
 | 
			
		||||
		: settings?.logo_light || "/assets/logo_light.png";
 | 
			
		||||
 | 
			
		||||
	// Add cache-busting parameter using updated_at timestamp
 | 
			
		||||
	const cacheBuster = settings?.updated_at
 | 
			
		||||
		? new Date(settings.updated_at).getTime()
 | 
			
		||||
		: Date.now();
 | 
			
		||||
	const logoSrcWithCache = `${logoSrc}?v=${cacheBuster}`;
 | 
			
		||||
 | 
			
		||||
	return (
 | 
			
		||||
		<img
 | 
			
		||||
			src={logoSrcWithCache}
 | 
			
		||||
			alt={alt}
 | 
			
		||||
			className={className}
 | 
			
		||||
			onError={(e) => {
 | 
			
		||||
				// Fallback to default logo if custom logo fails to load
 | 
			
		||||
				e.target.src = isDark
 | 
			
		||||
					? "/assets/logo_dark.png"
 | 
			
		||||
					: "/assets/logo_light.png";
 | 
			
		||||
			}}
 | 
			
		||||
			{...props}
 | 
			
		||||
		/>
 | 
			
		||||
	);
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export default Logo;
 | 
			
		||||
							
								
								
									
										37
									
								
								frontend/src/components/LogoProvider.jsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										37
									
								
								frontend/src/components/LogoProvider.jsx
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,37 @@
 | 
			
		||||
import { useQuery } from "@tanstack/react-query";
 | 
			
		||||
import { useEffect } from "react";
 | 
			
		||||
import { settingsAPI } from "../utils/api";
 | 
			
		||||
 | 
			
		||||
const LogoProvider = ({ children }) => {
 | 
			
		||||
	const { data: settings } = useQuery({
 | 
			
		||||
		queryKey: ["settings"],
 | 
			
		||||
		queryFn: () => settingsAPI.get().then((res) => res.data),
 | 
			
		||||
	});
 | 
			
		||||
 | 
			
		||||
	useEffect(() => {
 | 
			
		||||
		// Use custom favicon or fallback to default
 | 
			
		||||
		const faviconUrl = settings?.favicon || "/assets/favicon.svg";
 | 
			
		||||
 | 
			
		||||
		// Add cache-busting parameter using updated_at timestamp
 | 
			
		||||
		const cacheBuster = settings?.updated_at
 | 
			
		||||
			? new Date(settings.updated_at).getTime()
 | 
			
		||||
			: Date.now();
 | 
			
		||||
		const faviconUrlWithCache = `${faviconUrl}?v=${cacheBuster}`;
 | 
			
		||||
 | 
			
		||||
		// Update favicon
 | 
			
		||||
		const favicon = document.querySelector('link[rel="icon"]');
 | 
			
		||||
		if (favicon) {
 | 
			
		||||
			favicon.href = faviconUrlWithCache;
 | 
			
		||||
		} else {
 | 
			
		||||
			// Create favicon link if it doesn't exist
 | 
			
		||||
			const link = document.createElement("link");
 | 
			
		||||
			link.rel = "icon";
 | 
			
		||||
			link.href = faviconUrlWithCache;
 | 
			
		||||
			document.head.appendChild(link);
 | 
			
		||||
		}
 | 
			
		||||
	}, [settings?.favicon, settings?.updated_at]);
 | 
			
		||||
 | 
			
		||||
	return children;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export default LogoProvider;
 | 
			
		||||
@@ -4,6 +4,7 @@ import {
 | 
			
		||||
	ChevronRight,
 | 
			
		||||
	Code,
 | 
			
		||||
	Folder,
 | 
			
		||||
	Image,
 | 
			
		||||
	RefreshCw,
 | 
			
		||||
	Settings,
 | 
			
		||||
	Shield,
 | 
			
		||||
@@ -130,6 +131,11 @@ const SettingsLayout = ({ children }) => {
 | 
			
		||||
						href: "/settings/server-url",
 | 
			
		||||
						icon: Wrench,
 | 
			
		||||
					},
 | 
			
		||||
					{
 | 
			
		||||
						name: "Branding",
 | 
			
		||||
						href: "/settings/branding",
 | 
			
		||||
						icon: Image,
 | 
			
		||||
					},
 | 
			
		||||
					{
 | 
			
		||||
						name: "Server Version",
 | 
			
		||||
						href: "/settings/server-version",
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										531
									
								
								frontend/src/components/settings/BrandingTab.jsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										531
									
								
								frontend/src/components/settings/BrandingTab.jsx
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,531 @@
 | 
			
		||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
 | 
			
		||||
import { AlertCircle, Image, RotateCcw, Upload, X } from "lucide-react";
 | 
			
		||||
import { useState } from "react";
 | 
			
		||||
import { settingsAPI } from "../../utils/api";
 | 
			
		||||
 | 
			
		||||
const BrandingTab = () => {
 | 
			
		||||
	// Logo management state
 | 
			
		||||
	const [logoUploadState, setLogoUploadState] = useState({
 | 
			
		||||
		dark: { uploading: false, error: null },
 | 
			
		||||
		light: { uploading: false, error: null },
 | 
			
		||||
		favicon: { uploading: false, error: null },
 | 
			
		||||
	});
 | 
			
		||||
	const [showLogoUploadModal, setShowLogoUploadModal] = useState(false);
 | 
			
		||||
	const [selectedLogoType, setSelectedLogoType] = useState("dark");
 | 
			
		||||
 | 
			
		||||
	const queryClient = useQueryClient();
 | 
			
		||||
 | 
			
		||||
	// Fetch current settings
 | 
			
		||||
	const {
 | 
			
		||||
		data: settings,
 | 
			
		||||
		isLoading,
 | 
			
		||||
		error,
 | 
			
		||||
	} = useQuery({
 | 
			
		||||
		queryKey: ["settings"],
 | 
			
		||||
		queryFn: () => settingsAPI.get().then((res) => res.data),
 | 
			
		||||
	});
 | 
			
		||||
 | 
			
		||||
	// Logo upload mutation
 | 
			
		||||
	const uploadLogoMutation = useMutation({
 | 
			
		||||
		mutationFn: ({ logoType, fileContent, fileName }) =>
 | 
			
		||||
			fetch("/api/v1/settings/logos/upload", {
 | 
			
		||||
				method: "POST",
 | 
			
		||||
				headers: {
 | 
			
		||||
					"Content-Type": "application/json",
 | 
			
		||||
					Authorization: `Bearer ${localStorage.getItem("token")}`,
 | 
			
		||||
				},
 | 
			
		||||
				body: JSON.stringify({ logoType, fileContent, fileName }),
 | 
			
		||||
			}).then((res) => res.json()),
 | 
			
		||||
		onSuccess: (_data, variables) => {
 | 
			
		||||
			queryClient.invalidateQueries(["settings"]);
 | 
			
		||||
			setLogoUploadState((prev) => ({
 | 
			
		||||
				...prev,
 | 
			
		||||
				[variables.logoType]: { uploading: false, error: null },
 | 
			
		||||
			}));
 | 
			
		||||
			setShowLogoUploadModal(false);
 | 
			
		||||
		},
 | 
			
		||||
		onError: (error, variables) => {
 | 
			
		||||
			console.error("Upload logo error:", error);
 | 
			
		||||
			setLogoUploadState((prev) => ({
 | 
			
		||||
				...prev,
 | 
			
		||||
				[variables.logoType]: {
 | 
			
		||||
					uploading: false,
 | 
			
		||||
					error: error.message || "Failed to upload logo",
 | 
			
		||||
				},
 | 
			
		||||
			}));
 | 
			
		||||
		},
 | 
			
		||||
	});
 | 
			
		||||
 | 
			
		||||
	// Logo reset mutation
 | 
			
		||||
	const resetLogoMutation = useMutation({
 | 
			
		||||
		mutationFn: (logoType) =>
 | 
			
		||||
			fetch("/api/v1/settings/logos/reset", {
 | 
			
		||||
				method: "POST",
 | 
			
		||||
				headers: {
 | 
			
		||||
					"Content-Type": "application/json",
 | 
			
		||||
					Authorization: `Bearer ${localStorage.getItem("token")}`,
 | 
			
		||||
				},
 | 
			
		||||
				body: JSON.stringify({ logoType }),
 | 
			
		||||
			}).then((res) => res.json()),
 | 
			
		||||
		onSuccess: () => {
 | 
			
		||||
			queryClient.invalidateQueries(["settings"]);
 | 
			
		||||
		},
 | 
			
		||||
		onError: (error) => {
 | 
			
		||||
			console.error("Reset logo error:", error);
 | 
			
		||||
		},
 | 
			
		||||
	});
 | 
			
		||||
 | 
			
		||||
	if (isLoading) {
 | 
			
		||||
		return (
 | 
			
		||||
			<div className="flex items-center justify-center h-64">
 | 
			
		||||
				<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div>
 | 
			
		||||
			</div>
 | 
			
		||||
		);
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if (error) {
 | 
			
		||||
		return (
 | 
			
		||||
			<div className="bg-red-50 dark:bg-red-900 border border-red-200 dark:border-red-700 rounded-md p-4">
 | 
			
		||||
				<div className="flex">
 | 
			
		||||
					<AlertCircle className="h-5 w-5 text-red-400 dark:text-red-300" />
 | 
			
		||||
					<div className="ml-3">
 | 
			
		||||
						<h3 className="text-sm font-medium text-red-800 dark:text-red-200">
 | 
			
		||||
							Error loading settings
 | 
			
		||||
						</h3>
 | 
			
		||||
						<p className="mt-1 text-sm text-red-700 dark:text-red-300">
 | 
			
		||||
							{error.response?.data?.error || "Failed to load settings"}
 | 
			
		||||
						</p>
 | 
			
		||||
					</div>
 | 
			
		||||
				</div>
 | 
			
		||||
			</div>
 | 
			
		||||
		);
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return (
 | 
			
		||||
		<div className="space-y-6">
 | 
			
		||||
			<div className="flex items-center mb-6">
 | 
			
		||||
				<Image className="h-6 w-6 text-primary-600 mr-3" />
 | 
			
		||||
				<h2 className="text-xl font-semibold text-secondary-900 dark:text-white">
 | 
			
		||||
					Logo & Branding
 | 
			
		||||
				</h2>
 | 
			
		||||
			</div>
 | 
			
		||||
			<p className="text-sm text-secondary-500 dark:text-secondary-300 mb-6">
 | 
			
		||||
				Customize your PatchMon installation with custom logos and favicon.
 | 
			
		||||
				These will be displayed throughout the application.
 | 
			
		||||
			</p>
 | 
			
		||||
 | 
			
		||||
			<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
 | 
			
		||||
				{/* Dark Logo */}
 | 
			
		||||
				<div className="bg-white dark:bg-secondary-800 rounded-lg p-6 border border-secondary-200 dark:border-secondary-600">
 | 
			
		||||
					<h4 className="text-sm font-medium text-secondary-900 dark:text-white mb-4">
 | 
			
		||||
						Dark Logo
 | 
			
		||||
					</h4>
 | 
			
		||||
					<div className="flex items-center justify-center p-4 bg-secondary-50 dark:bg-secondary-700 rounded-lg mb-4">
 | 
			
		||||
						<img
 | 
			
		||||
							src={`${settings?.logo_dark || "/assets/logo_dark.png"}?v=${Date.now()}`}
 | 
			
		||||
							alt="Dark Logo"
 | 
			
		||||
							className="max-h-16 max-w-full object-contain"
 | 
			
		||||
							onError={(e) => {
 | 
			
		||||
								e.target.src = "/assets/logo_dark.png";
 | 
			
		||||
							}}
 | 
			
		||||
						/>
 | 
			
		||||
					</div>
 | 
			
		||||
					<p className="text-xs text-secondary-600 dark:text-secondary-400 mb-4 truncate">
 | 
			
		||||
						{settings?.logo_dark
 | 
			
		||||
							? settings.logo_dark.split("/").pop()
 | 
			
		||||
							: "logo_dark.png (Default)"}
 | 
			
		||||
					</p>
 | 
			
		||||
					<div className="space-y-2">
 | 
			
		||||
						<button
 | 
			
		||||
							type="button"
 | 
			
		||||
							onClick={() => {
 | 
			
		||||
								setSelectedLogoType("dark");
 | 
			
		||||
								setShowLogoUploadModal(true);
 | 
			
		||||
							}}
 | 
			
		||||
							disabled={logoUploadState.dark.uploading}
 | 
			
		||||
							className="w-full btn-outline flex items-center justify-center gap-2"
 | 
			
		||||
						>
 | 
			
		||||
							{logoUploadState.dark.uploading ? (
 | 
			
		||||
								<>
 | 
			
		||||
									<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-current"></div>
 | 
			
		||||
									Uploading...
 | 
			
		||||
								</>
 | 
			
		||||
							) : (
 | 
			
		||||
								<>
 | 
			
		||||
									<Upload className="h-4 w-4" />
 | 
			
		||||
									Upload Dark Logo
 | 
			
		||||
								</>
 | 
			
		||||
							)}
 | 
			
		||||
						</button>
 | 
			
		||||
						{settings?.logo_dark && (
 | 
			
		||||
							<button
 | 
			
		||||
								type="button"
 | 
			
		||||
								onClick={() => resetLogoMutation.mutate("dark")}
 | 
			
		||||
								disabled={resetLogoMutation.isPending}
 | 
			
		||||
								className="w-full btn-outline flex items-center justify-center gap-2 text-orange-600 hover:text-orange-700 border-orange-300 hover:border-orange-400"
 | 
			
		||||
							>
 | 
			
		||||
								<RotateCcw className="h-4 w-4" />
 | 
			
		||||
								Reset to Default
 | 
			
		||||
							</button>
 | 
			
		||||
						)}
 | 
			
		||||
					</div>
 | 
			
		||||
					{logoUploadState.dark.error && (
 | 
			
		||||
						<p className="text-xs text-red-600 dark:text-red-400 mt-2">
 | 
			
		||||
							{logoUploadState.dark.error}
 | 
			
		||||
						</p>
 | 
			
		||||
					)}
 | 
			
		||||
				</div>
 | 
			
		||||
 | 
			
		||||
				{/* Light Logo */}
 | 
			
		||||
				<div className="bg-white dark:bg-secondary-800 rounded-lg p-6 border border-secondary-200 dark:border-secondary-600">
 | 
			
		||||
					<h4 className="text-sm font-medium text-secondary-900 dark:text-white mb-4">
 | 
			
		||||
						Light Logo
 | 
			
		||||
					</h4>
 | 
			
		||||
					<div className="flex items-center justify-center p-4 bg-secondary-50 dark:bg-secondary-700 rounded-lg mb-4">
 | 
			
		||||
						<img
 | 
			
		||||
							src={`${settings?.logo_light || "/assets/logo_light.png"}?v=${Date.now()}`}
 | 
			
		||||
							alt="Light Logo"
 | 
			
		||||
							className="max-h-16 max-w-full object-contain"
 | 
			
		||||
							onError={(e) => {
 | 
			
		||||
								e.target.src = "/assets/logo_light.png";
 | 
			
		||||
							}}
 | 
			
		||||
						/>
 | 
			
		||||
					</div>
 | 
			
		||||
					<p className="text-xs text-secondary-600 dark:text-secondary-400 mb-4 truncate">
 | 
			
		||||
						{settings?.logo_light
 | 
			
		||||
							? settings.logo_light.split("/").pop()
 | 
			
		||||
							: "logo_light.png (Default)"}
 | 
			
		||||
					</p>
 | 
			
		||||
					<div className="space-y-2">
 | 
			
		||||
						<button
 | 
			
		||||
							type="button"
 | 
			
		||||
							onClick={() => {
 | 
			
		||||
								setSelectedLogoType("light");
 | 
			
		||||
								setShowLogoUploadModal(true);
 | 
			
		||||
							}}
 | 
			
		||||
							disabled={logoUploadState.light.uploading}
 | 
			
		||||
							className="w-full btn-outline flex items-center justify-center gap-2"
 | 
			
		||||
						>
 | 
			
		||||
							{logoUploadState.light.uploading ? (
 | 
			
		||||
								<>
 | 
			
		||||
									<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-current"></div>
 | 
			
		||||
									Uploading...
 | 
			
		||||
								</>
 | 
			
		||||
							) : (
 | 
			
		||||
								<>
 | 
			
		||||
									<Upload className="h-4 w-4" />
 | 
			
		||||
									Upload Light Logo
 | 
			
		||||
								</>
 | 
			
		||||
							)}
 | 
			
		||||
						</button>
 | 
			
		||||
						{settings?.logo_light && (
 | 
			
		||||
							<button
 | 
			
		||||
								type="button"
 | 
			
		||||
								onClick={() => resetLogoMutation.mutate("light")}
 | 
			
		||||
								disabled={resetLogoMutation.isPending}
 | 
			
		||||
								className="w-full btn-outline flex items-center justify-center gap-2 text-orange-600 hover:text-orange-700 border-orange-300 hover:border-orange-400"
 | 
			
		||||
							>
 | 
			
		||||
								<RotateCcw className="h-4 w-4" />
 | 
			
		||||
								Reset to Default
 | 
			
		||||
							</button>
 | 
			
		||||
						)}
 | 
			
		||||
					</div>
 | 
			
		||||
					{logoUploadState.light.error && (
 | 
			
		||||
						<p className="text-xs text-red-600 dark:text-red-400 mt-2">
 | 
			
		||||
							{logoUploadState.light.error}
 | 
			
		||||
						</p>
 | 
			
		||||
					)}
 | 
			
		||||
				</div>
 | 
			
		||||
 | 
			
		||||
				{/* Favicon */}
 | 
			
		||||
				<div className="bg-white dark:bg-secondary-800 rounded-lg p-6 border border-secondary-200 dark:border-secondary-600">
 | 
			
		||||
					<h4 className="text-sm font-medium text-secondary-900 dark:text-white mb-4">
 | 
			
		||||
						Favicon
 | 
			
		||||
					</h4>
 | 
			
		||||
					<div className="flex items-center justify-center p-4 bg-secondary-50 dark:bg-secondary-700 rounded-lg mb-4">
 | 
			
		||||
						<img
 | 
			
		||||
							src={`${settings?.favicon || "/assets/favicon.svg"}?v=${Date.now()}`}
 | 
			
		||||
							alt="Favicon"
 | 
			
		||||
							className="h-8 w-8 object-contain"
 | 
			
		||||
							onError={(e) => {
 | 
			
		||||
								e.target.src = "/assets/favicon.svg";
 | 
			
		||||
							}}
 | 
			
		||||
						/>
 | 
			
		||||
					</div>
 | 
			
		||||
					<p className="text-xs text-secondary-600 dark:text-secondary-400 mb-4 truncate">
 | 
			
		||||
						{settings?.favicon
 | 
			
		||||
							? settings.favicon.split("/").pop()
 | 
			
		||||
							: "favicon.svg (Default)"}
 | 
			
		||||
					</p>
 | 
			
		||||
					<div className="space-y-2">
 | 
			
		||||
						<button
 | 
			
		||||
							type="button"
 | 
			
		||||
							onClick={() => {
 | 
			
		||||
								setSelectedLogoType("favicon");
 | 
			
		||||
								setShowLogoUploadModal(true);
 | 
			
		||||
							}}
 | 
			
		||||
							disabled={logoUploadState.favicon.uploading}
 | 
			
		||||
							className="w-full btn-outline flex items-center justify-center gap-2"
 | 
			
		||||
						>
 | 
			
		||||
							{logoUploadState.favicon.uploading ? (
 | 
			
		||||
								<>
 | 
			
		||||
									<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-current"></div>
 | 
			
		||||
									Uploading...
 | 
			
		||||
								</>
 | 
			
		||||
							) : (
 | 
			
		||||
								<>
 | 
			
		||||
									<Upload className="h-4 w-4" />
 | 
			
		||||
									Upload Favicon
 | 
			
		||||
								</>
 | 
			
		||||
							)}
 | 
			
		||||
						</button>
 | 
			
		||||
						{settings?.favicon && (
 | 
			
		||||
							<button
 | 
			
		||||
								type="button"
 | 
			
		||||
								onClick={() => resetLogoMutation.mutate("favicon")}
 | 
			
		||||
								disabled={resetLogoMutation.isPending}
 | 
			
		||||
								className="w-full btn-outline flex items-center justify-center gap-2 text-orange-600 hover:text-orange-700 border-orange-300 hover:border-orange-400"
 | 
			
		||||
							>
 | 
			
		||||
								<RotateCcw className="h-4 w-4" />
 | 
			
		||||
								Reset to Default
 | 
			
		||||
							</button>
 | 
			
		||||
						)}
 | 
			
		||||
					</div>
 | 
			
		||||
					{logoUploadState.favicon.error && (
 | 
			
		||||
						<p className="text-xs text-red-600 dark:text-red-400 mt-2">
 | 
			
		||||
							{logoUploadState.favicon.error}
 | 
			
		||||
						</p>
 | 
			
		||||
					)}
 | 
			
		||||
				</div>
 | 
			
		||||
			</div>
 | 
			
		||||
 | 
			
		||||
			{/* Usage Instructions */}
 | 
			
		||||
			<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-700 rounded-md p-4 mt-6">
 | 
			
		||||
				<div className="flex">
 | 
			
		||||
					<Image className="h-5 w-5 text-blue-400 dark:text-blue-300" />
 | 
			
		||||
					<div className="ml-3">
 | 
			
		||||
						<h3 className="text-sm font-medium text-blue-800 dark:text-blue-200">
 | 
			
		||||
							Logo Usage
 | 
			
		||||
						</h3>
 | 
			
		||||
						<div className="mt-2 text-sm text-blue-700 dark:text-blue-300">
 | 
			
		||||
							<p className="mb-2">
 | 
			
		||||
								These logos are used throughout the application:
 | 
			
		||||
							</p>
 | 
			
		||||
							<ul className="list-disc list-inside space-y-1">
 | 
			
		||||
								<li>
 | 
			
		||||
									<strong>Dark Logo:</strong> Used in dark mode and on light
 | 
			
		||||
									backgrounds
 | 
			
		||||
								</li>
 | 
			
		||||
								<li>
 | 
			
		||||
									<strong>Light Logo:</strong> Used in light mode and on dark
 | 
			
		||||
									backgrounds
 | 
			
		||||
								</li>
 | 
			
		||||
								<li>
 | 
			
		||||
									<strong>Favicon:</strong> Used as the browser tab icon (SVG
 | 
			
		||||
									recommended)
 | 
			
		||||
								</li>
 | 
			
		||||
							</ul>
 | 
			
		||||
							<p className="mt-3 text-xs">
 | 
			
		||||
								<strong>Supported formats:</strong> PNG, JPG, SVG |{" "}
 | 
			
		||||
								<strong>Max size:</strong> 5MB |{" "}
 | 
			
		||||
								<strong>Recommended sizes:</strong> 200x60px for logos, 32x32px
 | 
			
		||||
								for favicon.
 | 
			
		||||
							</p>
 | 
			
		||||
						</div>
 | 
			
		||||
					</div>
 | 
			
		||||
				</div>
 | 
			
		||||
			</div>
 | 
			
		||||
 | 
			
		||||
			{/* Logo Upload Modal */}
 | 
			
		||||
			{showLogoUploadModal && (
 | 
			
		||||
				<LogoUploadModal
 | 
			
		||||
					isOpen={showLogoUploadModal}
 | 
			
		||||
					onClose={() => setShowLogoUploadModal(false)}
 | 
			
		||||
					onSubmit={uploadLogoMutation.mutate}
 | 
			
		||||
					isLoading={uploadLogoMutation.isPending}
 | 
			
		||||
					error={uploadLogoMutation.error}
 | 
			
		||||
					logoType={selectedLogoType}
 | 
			
		||||
				/>
 | 
			
		||||
			)}
 | 
			
		||||
		</div>
 | 
			
		||||
	);
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
// Logo Upload Modal Component
 | 
			
		||||
const LogoUploadModal = ({
 | 
			
		||||
	isOpen,
 | 
			
		||||
	onClose,
 | 
			
		||||
	onSubmit,
 | 
			
		||||
	isLoading,
 | 
			
		||||
	error,
 | 
			
		||||
	logoType,
 | 
			
		||||
}) => {
 | 
			
		||||
	const [selectedFile, setSelectedFile] = useState(null);
 | 
			
		||||
	const [previewUrl, setPreviewUrl] = useState(null);
 | 
			
		||||
	const [uploadError, setUploadError] = useState("");
 | 
			
		||||
 | 
			
		||||
	const handleFileSelect = (e) => {
 | 
			
		||||
		const file = e.target.files[0];
 | 
			
		||||
		if (file) {
 | 
			
		||||
			// Validate file type
 | 
			
		||||
			const allowedTypes = [
 | 
			
		||||
				"image/png",
 | 
			
		||||
				"image/jpeg",
 | 
			
		||||
				"image/jpg",
 | 
			
		||||
				"image/svg+xml",
 | 
			
		||||
			];
 | 
			
		||||
			if (!allowedTypes.includes(file.type)) {
 | 
			
		||||
				setUploadError("Please select a PNG, JPG, or SVG file");
 | 
			
		||||
				return;
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			// Validate file size (5MB limit)
 | 
			
		||||
			if (file.size > 5 * 1024 * 1024) {
 | 
			
		||||
				setUploadError("File size must be less than 5MB");
 | 
			
		||||
				return;
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			setSelectedFile(file);
 | 
			
		||||
			setUploadError("");
 | 
			
		||||
 | 
			
		||||
			// Create preview URL
 | 
			
		||||
			const url = URL.createObjectURL(file);
 | 
			
		||||
			setPreviewUrl(url);
 | 
			
		||||
		}
 | 
			
		||||
	};
 | 
			
		||||
 | 
			
		||||
	const handleSubmit = (e) => {
 | 
			
		||||
		e.preventDefault();
 | 
			
		||||
		setUploadError("");
 | 
			
		||||
 | 
			
		||||
		if (!selectedFile) {
 | 
			
		||||
			setUploadError("Please select a file");
 | 
			
		||||
			return;
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		// Convert file to base64
 | 
			
		||||
		const reader = new FileReader();
 | 
			
		||||
		reader.onload = (event) => {
 | 
			
		||||
			const base64 = event.target.result;
 | 
			
		||||
			onSubmit({
 | 
			
		||||
				logoType,
 | 
			
		||||
				fileContent: base64,
 | 
			
		||||
				fileName: selectedFile.name,
 | 
			
		||||
			});
 | 
			
		||||
		};
 | 
			
		||||
		reader.readAsDataURL(selectedFile);
 | 
			
		||||
	};
 | 
			
		||||
 | 
			
		||||
	const handleClose = () => {
 | 
			
		||||
		setSelectedFile(null);
 | 
			
		||||
		setPreviewUrl(null);
 | 
			
		||||
		setUploadError("");
 | 
			
		||||
		onClose();
 | 
			
		||||
	};
 | 
			
		||||
 | 
			
		||||
	if (!isOpen) return null;
 | 
			
		||||
 | 
			
		||||
	return (
 | 
			
		||||
		<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
 | 
			
		||||
			<div className="bg-white dark:bg-secondary-800 rounded-lg shadow-xl max-w-2xl w-full mx-4 max-h-[90vh] overflow-y-auto">
 | 
			
		||||
				<div className="px-6 py-4 border-b border-secondary-200 dark:border-secondary-600">
 | 
			
		||||
					<div className="flex items-center justify-between">
 | 
			
		||||
						<h3 className="text-lg font-medium text-secondary-900 dark:text-white">
 | 
			
		||||
							Upload{" "}
 | 
			
		||||
							{logoType === "favicon"
 | 
			
		||||
								? "Favicon"
 | 
			
		||||
								: `${logoType.charAt(0).toUpperCase() + logoType.slice(1)} Logo`}
 | 
			
		||||
						</h3>
 | 
			
		||||
						<button
 | 
			
		||||
							type="button"
 | 
			
		||||
							onClick={handleClose}
 | 
			
		||||
							className="text-secondary-400 hover:text-secondary-600 dark:text-secondary-500 dark:hover:text-secondary-300"
 | 
			
		||||
						>
 | 
			
		||||
							<X className="h-5 w-5" />
 | 
			
		||||
						</button>
 | 
			
		||||
					</div>
 | 
			
		||||
				</div>
 | 
			
		||||
 | 
			
		||||
				<form onSubmit={handleSubmit} className="px-6 py-4">
 | 
			
		||||
					<div className="space-y-4">
 | 
			
		||||
						<div>
 | 
			
		||||
							<label className="block">
 | 
			
		||||
								<span className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-2">
 | 
			
		||||
									Select File
 | 
			
		||||
								</span>
 | 
			
		||||
								<input
 | 
			
		||||
									type="file"
 | 
			
		||||
									accept="image/png,image/jpeg,image/jpg,image/svg+xml"
 | 
			
		||||
									onChange={handleFileSelect}
 | 
			
		||||
									className="block w-full text-sm text-secondary-500 dark:text-secondary-400 file:mr-4 file:py-2 file:px-4 file:rounded-md file:border-0 file:text-sm file:font-medium file:bg-primary-50 file:text-primary-700 hover:file:bg-primary-100 dark:file:bg-primary-900 dark:file:text-primary-200"
 | 
			
		||||
								/>
 | 
			
		||||
							</label>
 | 
			
		||||
							<p className="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
 | 
			
		||||
								Supported formats: PNG, JPG, SVG. Max size: 5MB.
 | 
			
		||||
								{logoType === "favicon"
 | 
			
		||||
									? " Recommended: 32x32px SVG."
 | 
			
		||||
									: " Recommended: 200x60px."}
 | 
			
		||||
							</p>
 | 
			
		||||
						</div>
 | 
			
		||||
 | 
			
		||||
						{previewUrl && (
 | 
			
		||||
							<div>
 | 
			
		||||
								<div className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-2">
 | 
			
		||||
									Preview
 | 
			
		||||
								</div>
 | 
			
		||||
								<div className="flex items-center justify-center p-4 bg-white dark:bg-secondary-800 rounded-lg border border-secondary-200 dark:border-secondary-600">
 | 
			
		||||
									<img
 | 
			
		||||
										src={previewUrl}
 | 
			
		||||
										alt="Preview"
 | 
			
		||||
										className={`object-contain ${
 | 
			
		||||
											logoType === "favicon" ? "h-8 w-8" : "max-h-16 max-w-full"
 | 
			
		||||
										}`}
 | 
			
		||||
									/>
 | 
			
		||||
								</div>
 | 
			
		||||
							</div>
 | 
			
		||||
						)}
 | 
			
		||||
 | 
			
		||||
						{(uploadError || error) && (
 | 
			
		||||
							<div className="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">
 | 
			
		||||
									{uploadError ||
 | 
			
		||||
										error?.response?.data?.error ||
 | 
			
		||||
										error?.message}
 | 
			
		||||
								</p>
 | 
			
		||||
							</div>
 | 
			
		||||
						)}
 | 
			
		||||
 | 
			
		||||
						<div className="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-md p-3">
 | 
			
		||||
							<div className="flex">
 | 
			
		||||
								<AlertCircle className="h-4 w-4 text-yellow-600 dark:text-yellow-400 mr-2 mt-0.5" />
 | 
			
		||||
								<div className="text-sm text-yellow-800 dark:text-yellow-200">
 | 
			
		||||
									<p className="font-medium">Important:</p>
 | 
			
		||||
									<ul className="mt-1 list-disc list-inside space-y-1">
 | 
			
		||||
										<li>This will replace the current {logoType} logo</li>
 | 
			
		||||
										<li>A backup will be created automatically</li>
 | 
			
		||||
										<li>The change will be applied immediately</li>
 | 
			
		||||
									</ul>
 | 
			
		||||
								</div>
 | 
			
		||||
							</div>
 | 
			
		||||
						</div>
 | 
			
		||||
					</div>
 | 
			
		||||
 | 
			
		||||
					<div className="flex justify-end gap-3 mt-6">
 | 
			
		||||
						<button type="button" onClick={handleClose} className="btn-outline">
 | 
			
		||||
							Cancel
 | 
			
		||||
						</button>
 | 
			
		||||
						<button
 | 
			
		||||
							type="submit"
 | 
			
		||||
							disabled={isLoading || !selectedFile}
 | 
			
		||||
							className="btn-primary"
 | 
			
		||||
						>
 | 
			
		||||
							{isLoading ? "Uploading..." : "Upload Logo"}
 | 
			
		||||
						</button>
 | 
			
		||||
					</div>
 | 
			
		||||
				</form>
 | 
			
		||||
			</div>
 | 
			
		||||
		</div>
 | 
			
		||||
	);
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export default BrandingTab;
 | 
			
		||||
@@ -48,31 +48,36 @@ const VersionUpdateTab = () => {
 | 
			
		||||
		}
 | 
			
		||||
	}, []);
 | 
			
		||||
 | 
			
		||||
	// Load current version on component mount
 | 
			
		||||
	// Load current version and automatically check for updates on component mount
 | 
			
		||||
	useEffect(() => {
 | 
			
		||||
		const loadCurrentVersion = async () => {
 | 
			
		||||
		const loadAndCheckUpdates = async () => {
 | 
			
		||||
			try {
 | 
			
		||||
				// First, get current version info
 | 
			
		||||
				const response = await versionAPI.getCurrent();
 | 
			
		||||
				const data = response.data;
 | 
			
		||||
				setVersionInfo({
 | 
			
		||||
					currentVersion: data.version,
 | 
			
		||||
					latestVersion: data.latest_version || null,
 | 
			
		||||
					isUpdateAvailable: data.is_update_available || false,
 | 
			
		||||
					last_update_check: data.last_update_check || null,
 | 
			
		||||
					github: data.github,
 | 
			
		||||
					checking: false,
 | 
			
		||||
					error: null,
 | 
			
		||||
				});
 | 
			
		||||
 | 
			
		||||
				// Then automatically trigger a fresh update check
 | 
			
		||||
				await checkForUpdates();
 | 
			
		||||
			} catch (error) {
 | 
			
		||||
				console.error("Error loading version info:", error);
 | 
			
		||||
				setVersionInfo((prev) => ({
 | 
			
		||||
					...prev,
 | 
			
		||||
					currentVersion: data.version,
 | 
			
		||||
					github: data.github,
 | 
			
		||||
					error: "Failed to load version information",
 | 
			
		||||
				}));
 | 
			
		||||
			} catch (error) {
 | 
			
		||||
				console.error("Error loading current version:", error);
 | 
			
		||||
			}
 | 
			
		||||
		};
 | 
			
		||||
 | 
			
		||||
		// Load current version and immediately check for updates
 | 
			
		||||
		const loadAndCheckUpdates = async () => {
 | 
			
		||||
			await loadCurrentVersion();
 | 
			
		||||
			// Automatically trigger update check when component loads
 | 
			
		||||
			await checkForUpdates();
 | 
			
		||||
		};
 | 
			
		||||
 | 
			
		||||
		loadAndCheckUpdates();
 | 
			
		||||
	}, [checkForUpdates]); // Include checkForUpdates dependency
 | 
			
		||||
	}, [checkForUpdates]); // Run when component mounts
 | 
			
		||||
 | 
			
		||||
	return (
 | 
			
		||||
		<div className="space-y-6">
 | 
			
		||||
 
 | 
			
		||||
@@ -1,7 +1,7 @@
 | 
			
		||||
import { useQuery } from "@tanstack/react-query";
 | 
			
		||||
import { createContext, useContext, useMemo, useState } from "react";
 | 
			
		||||
import { createContext, useContext, useState } from "react";
 | 
			
		||||
import { isAuthReady } from "../constants/authPhases";
 | 
			
		||||
import { settingsAPI, versionAPI } from "../utils/api";
 | 
			
		||||
import { settingsAPI } from "../utils/api";
 | 
			
		||||
import { useAuth } from "./AuthContext";
 | 
			
		||||
 | 
			
		||||
const UpdateNotificationContext = createContext();
 | 
			
		||||
@@ -21,6 +21,7 @@ export const UpdateNotificationProvider = ({ children }) => {
 | 
			
		||||
	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),
 | 
			
		||||
@@ -29,31 +30,20 @@ export const UpdateNotificationProvider = ({ children }) => {
 | 
			
		||||
		enabled: isAuthReady(authPhase, isAuthenticated()),
 | 
			
		||||
	});
 | 
			
		||||
 | 
			
		||||
	// Memoize the enabled condition to prevent unnecessary re-evaluations
 | 
			
		||||
	const isQueryEnabled = useMemo(() => {
 | 
			
		||||
		return (
 | 
			
		||||
			isAuthReady(authPhase, isAuthenticated()) &&
 | 
			
		||||
			!!settings &&
 | 
			
		||||
			!settingsLoading
 | 
			
		||||
		);
 | 
			
		||||
	}, [authPhase, isAuthenticated, settings, settingsLoading]);
 | 
			
		||||
	// Read cached update information from settings (no GitHub API calls)
 | 
			
		||||
	// The backend scheduler updates this data periodically
 | 
			
		||||
	const updateAvailable = settings?.is_update_available && !dismissed;
 | 
			
		||||
	const updateInfo = settings
 | 
			
		||||
		? {
 | 
			
		||||
				isUpdateAvailable: settings.is_update_available,
 | 
			
		||||
				latestVersion: settings.latest_version,
 | 
			
		||||
				currentVersion: settings.current_version,
 | 
			
		||||
				last_update_check: settings.last_update_check,
 | 
			
		||||
			}
 | 
			
		||||
		: null;
 | 
			
		||||
 | 
			
		||||
	// Query for update information
 | 
			
		||||
	const {
 | 
			
		||||
		data: updateData,
 | 
			
		||||
		isLoading,
 | 
			
		||||
		error,
 | 
			
		||||
	} = useQuery({
 | 
			
		||||
		queryKey: ["updateCheck"],
 | 
			
		||||
		queryFn: () => versionAPI.checkUpdates().then((res) => res.data),
 | 
			
		||||
		staleTime: 10 * 60 * 1000, // Data stays fresh for 10 minutes
 | 
			
		||||
		refetchOnWindowFocus: false, // Don't refetch when window regains focus
 | 
			
		||||
		retry: 1,
 | 
			
		||||
		enabled: isQueryEnabled,
 | 
			
		||||
	});
 | 
			
		||||
 | 
			
		||||
	const updateAvailable = updateData?.isUpdateAvailable && !dismissed;
 | 
			
		||||
	const updateInfo = updateData;
 | 
			
		||||
	const isLoading = settingsLoading;
 | 
			
		||||
	const error = null;
 | 
			
		||||
 | 
			
		||||
	const dismissNotification = () => {
 | 
			
		||||
		setDismissed(true);
 | 
			
		||||
 
 | 
			
		||||
@@ -45,7 +45,7 @@ const HostDetail = () => {
 | 
			
		||||
	const [showDeleteModal, setShowDeleteModal] = useState(false);
 | 
			
		||||
	const [showAllUpdates, setShowAllUpdates] = useState(false);
 | 
			
		||||
	const [activeTab, setActiveTab] = useState("host");
 | 
			
		||||
	const [forceInstall, setForceInstall] = useState(false);
 | 
			
		||||
	const [_forceInstall, _setForceInstall] = useState(false);
 | 
			
		||||
 | 
			
		||||
	const {
 | 
			
		||||
		data: host,
 | 
			
		||||
 
 | 
			
		||||
@@ -5,11 +5,13 @@ import {
 | 
			
		||||
	Clock,
 | 
			
		||||
	Code,
 | 
			
		||||
	Download,
 | 
			
		||||
	Image,
 | 
			
		||||
	Plus,
 | 
			
		||||
	Save,
 | 
			
		||||
	Server,
 | 
			
		||||
	Settings as SettingsIcon,
 | 
			
		||||
	Shield,
 | 
			
		||||
	Upload,
 | 
			
		||||
	X,
 | 
			
		||||
} from "lucide-react";
 | 
			
		||||
 | 
			
		||||
@@ -80,6 +82,15 @@ const Settings = () => {
 | 
			
		||||
	});
 | 
			
		||||
	const [showUploadModal, setShowUploadModal] = useState(false);
 | 
			
		||||
 | 
			
		||||
	// Logo management state
 | 
			
		||||
	const [logoUploadState, setLogoUploadState] = useState({
 | 
			
		||||
		dark: { uploading: false, error: null },
 | 
			
		||||
		light: { uploading: false, error: null },
 | 
			
		||||
		favicon: { uploading: false, error: null },
 | 
			
		||||
	});
 | 
			
		||||
	const [showLogoUploadModal, setShowLogoUploadModal] = useState(false);
 | 
			
		||||
	const [selectedLogoType, setSelectedLogoType] = useState("dark");
 | 
			
		||||
 | 
			
		||||
	// Version checking state
 | 
			
		||||
	const [versionInfo, setVersionInfo] = useState({
 | 
			
		||||
		currentVersion: null, // Will be loaded from API
 | 
			
		||||
@@ -192,6 +203,37 @@ const Settings = () => {
 | 
			
		||||
		},
 | 
			
		||||
	});
 | 
			
		||||
 | 
			
		||||
	// Logo upload mutation
 | 
			
		||||
	const uploadLogoMutation = useMutation({
 | 
			
		||||
		mutationFn: ({ logoType, fileContent, fileName }) =>
 | 
			
		||||
			fetch("/api/v1/settings/logos/upload", {
 | 
			
		||||
				method: "POST",
 | 
			
		||||
				headers: {
 | 
			
		||||
					"Content-Type": "application/json",
 | 
			
		||||
					Authorization: `Bearer ${localStorage.getItem("token")}`,
 | 
			
		||||
				},
 | 
			
		||||
				body: JSON.stringify({ logoType, fileContent, fileName }),
 | 
			
		||||
			}).then((res) => res.json()),
 | 
			
		||||
		onSuccess: (_data, variables) => {
 | 
			
		||||
			queryClient.invalidateQueries(["settings"]);
 | 
			
		||||
			setLogoUploadState((prev) => ({
 | 
			
		||||
				...prev,
 | 
			
		||||
				[variables.logoType]: { uploading: false, error: null },
 | 
			
		||||
			}));
 | 
			
		||||
			setShowLogoUploadModal(false);
 | 
			
		||||
		},
 | 
			
		||||
		onError: (error, variables) => {
 | 
			
		||||
			console.error("Upload logo error:", error);
 | 
			
		||||
			setLogoUploadState((prev) => ({
 | 
			
		||||
				...prev,
 | 
			
		||||
				[variables.logoType]: {
 | 
			
		||||
					uploading: false,
 | 
			
		||||
					error: error.message || "Failed to upload logo",
 | 
			
		||||
				},
 | 
			
		||||
			}));
 | 
			
		||||
		},
 | 
			
		||||
	});
 | 
			
		||||
 | 
			
		||||
	// Load current version on component mount
 | 
			
		||||
	useEffect(() => {
 | 
			
		||||
		const loadCurrentVersion = async () => {
 | 
			
		||||
@@ -556,6 +598,181 @@ const Settings = () => {
 | 
			
		||||
								</p>
 | 
			
		||||
							</div>
 | 
			
		||||
 | 
			
		||||
							{/* Logo Management Section */}
 | 
			
		||||
							<div className="mt-6 pt-6 border-t border-secondary-200 dark:border-secondary-600">
 | 
			
		||||
								<div className="flex items-center mb-4">
 | 
			
		||||
									<Image className="h-5 w-5 text-primary-600 mr-2" />
 | 
			
		||||
									<h3 className="text-lg font-semibold text-secondary-900 dark:text-white">
 | 
			
		||||
										Logo & Branding
 | 
			
		||||
									</h3>
 | 
			
		||||
								</div>
 | 
			
		||||
								<p className="text-sm text-secondary-500 dark:text-secondary-300 mb-4">
 | 
			
		||||
									Customize your PatchMon installation with custom logos and
 | 
			
		||||
									favicon.
 | 
			
		||||
								</p>
 | 
			
		||||
 | 
			
		||||
								<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
 | 
			
		||||
									{/* Dark Logo */}
 | 
			
		||||
									<div className="bg-white dark:bg-secondary-800 rounded-lg p-4 border border-secondary-200 dark:border-secondary-600">
 | 
			
		||||
										<h4 className="text-sm font-medium text-secondary-900 dark:text-white mb-3">
 | 
			
		||||
											Dark Logo
 | 
			
		||||
										</h4>
 | 
			
		||||
										{settings?.logo_dark && (
 | 
			
		||||
											<div className="flex items-center justify-center p-3 bg-secondary-50 dark:bg-secondary-700 rounded-lg mb-3">
 | 
			
		||||
												<img
 | 
			
		||||
													src={settings.logo_dark}
 | 
			
		||||
													alt="Dark Logo"
 | 
			
		||||
													className="max-h-12 max-w-full object-contain"
 | 
			
		||||
													onError={(e) => {
 | 
			
		||||
														e.target.style.display = "none";
 | 
			
		||||
													}}
 | 
			
		||||
												/>
 | 
			
		||||
											</div>
 | 
			
		||||
										)}
 | 
			
		||||
										<p className="text-xs text-secondary-600 dark:text-secondary-400 mb-3 truncate">
 | 
			
		||||
											{settings?.logo_dark
 | 
			
		||||
												? settings.logo_dark.split("/").pop()
 | 
			
		||||
												: "Default"}
 | 
			
		||||
										</p>
 | 
			
		||||
										<button
 | 
			
		||||
											type="button"
 | 
			
		||||
											onClick={() => {
 | 
			
		||||
												setSelectedLogoType("dark");
 | 
			
		||||
												setShowLogoUploadModal(true);
 | 
			
		||||
											}}
 | 
			
		||||
											disabled={logoUploadState.dark.uploading}
 | 
			
		||||
											className="w-full btn-outline flex items-center justify-center gap-2 text-sm py-2"
 | 
			
		||||
										>
 | 
			
		||||
											{logoUploadState.dark.uploading ? (
 | 
			
		||||
												<>
 | 
			
		||||
													<div className="animate-spin rounded-full h-3 w-3 border-b-2 border-current"></div>
 | 
			
		||||
													Uploading...
 | 
			
		||||
												</>
 | 
			
		||||
											) : (
 | 
			
		||||
												<>
 | 
			
		||||
													<Upload className="h-3 w-3" />
 | 
			
		||||
													Upload
 | 
			
		||||
												</>
 | 
			
		||||
											)}
 | 
			
		||||
										</button>
 | 
			
		||||
										{logoUploadState.dark.error && (
 | 
			
		||||
											<p className="text-xs text-red-600 dark:text-red-400 mt-2">
 | 
			
		||||
												{logoUploadState.dark.error}
 | 
			
		||||
											</p>
 | 
			
		||||
										)}
 | 
			
		||||
									</div>
 | 
			
		||||
 | 
			
		||||
									{/* Light Logo */}
 | 
			
		||||
									<div className="bg-white dark:bg-secondary-800 rounded-lg p-4 border border-secondary-200 dark:border-secondary-600">
 | 
			
		||||
										<h4 className="text-sm font-medium text-secondary-900 dark:text-white mb-3">
 | 
			
		||||
											Light Logo
 | 
			
		||||
										</h4>
 | 
			
		||||
										{settings?.logo_light && (
 | 
			
		||||
											<div className="flex items-center justify-center p-3 bg-secondary-50 dark:bg-secondary-700 rounded-lg mb-3">
 | 
			
		||||
												<img
 | 
			
		||||
													src={settings.logo_light}
 | 
			
		||||
													alt="Light Logo"
 | 
			
		||||
													className="max-h-12 max-w-full object-contain"
 | 
			
		||||
													onError={(e) => {
 | 
			
		||||
														e.target.style.display = "none";
 | 
			
		||||
													}}
 | 
			
		||||
												/>
 | 
			
		||||
											</div>
 | 
			
		||||
										)}
 | 
			
		||||
										<p className="text-xs text-secondary-600 dark:text-secondary-400 mb-3 truncate">
 | 
			
		||||
											{settings?.logo_light
 | 
			
		||||
												? settings.logo_light.split("/").pop()
 | 
			
		||||
												: "Default"}
 | 
			
		||||
										</p>
 | 
			
		||||
										<button
 | 
			
		||||
											type="button"
 | 
			
		||||
											onClick={() => {
 | 
			
		||||
												setSelectedLogoType("light");
 | 
			
		||||
												setShowLogoUploadModal(true);
 | 
			
		||||
											}}
 | 
			
		||||
											disabled={logoUploadState.light.uploading}
 | 
			
		||||
											className="w-full btn-outline flex items-center justify-center gap-2 text-sm py-2"
 | 
			
		||||
										>
 | 
			
		||||
											{logoUploadState.light.uploading ? (
 | 
			
		||||
												<>
 | 
			
		||||
													<div className="animate-spin rounded-full h-3 w-3 border-b-2 border-current"></div>
 | 
			
		||||
													Uploading...
 | 
			
		||||
												</>
 | 
			
		||||
											) : (
 | 
			
		||||
												<>
 | 
			
		||||
													<Upload className="h-3 w-3" />
 | 
			
		||||
													Upload
 | 
			
		||||
												</>
 | 
			
		||||
											)}
 | 
			
		||||
										</button>
 | 
			
		||||
										{logoUploadState.light.error && (
 | 
			
		||||
											<p className="text-xs text-red-600 dark:text-red-400 mt-2">
 | 
			
		||||
												{logoUploadState.light.error}
 | 
			
		||||
											</p>
 | 
			
		||||
										)}
 | 
			
		||||
									</div>
 | 
			
		||||
 | 
			
		||||
									{/* Favicon */}
 | 
			
		||||
									<div className="bg-white dark:bg-secondary-800 rounded-lg p-4 border border-secondary-200 dark:border-secondary-600">
 | 
			
		||||
										<h4 className="text-sm font-medium text-secondary-900 dark:text-white mb-3">
 | 
			
		||||
											Favicon
 | 
			
		||||
										</h4>
 | 
			
		||||
										{settings?.favicon && (
 | 
			
		||||
											<div className="flex items-center justify-center p-3 bg-secondary-50 dark:bg-secondary-700 rounded-lg mb-3">
 | 
			
		||||
												<img
 | 
			
		||||
													src={settings.favicon}
 | 
			
		||||
													alt="Favicon"
 | 
			
		||||
													className="h-8 w-8 object-contain"
 | 
			
		||||
													onError={(e) => {
 | 
			
		||||
														e.target.style.display = "none";
 | 
			
		||||
													}}
 | 
			
		||||
												/>
 | 
			
		||||
											</div>
 | 
			
		||||
										)}
 | 
			
		||||
										<p className="text-xs text-secondary-600 dark:text-secondary-400 mb-3 truncate">
 | 
			
		||||
											{settings?.favicon
 | 
			
		||||
												? settings.favicon.split("/").pop()
 | 
			
		||||
												: "Default"}
 | 
			
		||||
										</p>
 | 
			
		||||
										<button
 | 
			
		||||
											type="button"
 | 
			
		||||
											onClick={() => {
 | 
			
		||||
												setSelectedLogoType("favicon");
 | 
			
		||||
												setShowLogoUploadModal(true);
 | 
			
		||||
											}}
 | 
			
		||||
											disabled={logoUploadState.favicon.uploading}
 | 
			
		||||
											className="w-full btn-outline flex items-center justify-center gap-2 text-sm py-2"
 | 
			
		||||
										>
 | 
			
		||||
											{logoUploadState.favicon.uploading ? (
 | 
			
		||||
												<>
 | 
			
		||||
													<div className="animate-spin rounded-full h-3 w-3 border-b-2 border-current"></div>
 | 
			
		||||
													Uploading...
 | 
			
		||||
												</>
 | 
			
		||||
											) : (
 | 
			
		||||
												<>
 | 
			
		||||
													<Upload className="h-3 w-3" />
 | 
			
		||||
													Upload
 | 
			
		||||
												</>
 | 
			
		||||
											)}
 | 
			
		||||
										</button>
 | 
			
		||||
										{logoUploadState.favicon.error && (
 | 
			
		||||
											<p className="text-xs text-red-600 dark:text-red-400 mt-2">
 | 
			
		||||
												{logoUploadState.favicon.error}
 | 
			
		||||
											</p>
 | 
			
		||||
										)}
 | 
			
		||||
									</div>
 | 
			
		||||
								</div>
 | 
			
		||||
 | 
			
		||||
								<div className="mt-4 p-3 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-700 rounded-md">
 | 
			
		||||
									<p className="text-xs text-blue-700 dark:text-blue-300">
 | 
			
		||||
										<strong>Supported formats:</strong> PNG, JPG, SVG.{" "}
 | 
			
		||||
										<strong>Max size:</strong> 5MB.
 | 
			
		||||
										<strong> Recommended sizes:</strong> 200x60px for logos,
 | 
			
		||||
										32x32px for favicon.
 | 
			
		||||
									</p>
 | 
			
		||||
								</div>
 | 
			
		||||
							</div>
 | 
			
		||||
 | 
			
		||||
							{/* Update Interval */}
 | 
			
		||||
							<div>
 | 
			
		||||
								<label
 | 
			
		||||
@@ -1319,6 +1536,18 @@ const Settings = () => {
 | 
			
		||||
					error={uploadAgentMutation.error}
 | 
			
		||||
				/>
 | 
			
		||||
			)}
 | 
			
		||||
 | 
			
		||||
			{/* Logo Upload Modal */}
 | 
			
		||||
			{showLogoUploadModal && (
 | 
			
		||||
				<LogoUploadModal
 | 
			
		||||
					isOpen={showLogoUploadModal}
 | 
			
		||||
					onClose={() => setShowLogoUploadModal(false)}
 | 
			
		||||
					onSubmit={uploadLogoMutation.mutate}
 | 
			
		||||
					isLoading={uploadLogoMutation.isPending}
 | 
			
		||||
					error={uploadLogoMutation.error}
 | 
			
		||||
					logoType={selectedLogoType}
 | 
			
		||||
				/>
 | 
			
		||||
			)}
 | 
			
		||||
		</div>
 | 
			
		||||
	);
 | 
			
		||||
};
 | 
			
		||||
@@ -1467,4 +1696,181 @@ const AgentUploadModal = ({ isOpen, onClose, onSubmit, isLoading, error }) => {
 | 
			
		||||
	);
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
// Logo Upload Modal Component
 | 
			
		||||
const LogoUploadModal = ({
 | 
			
		||||
	isOpen,
 | 
			
		||||
	onClose,
 | 
			
		||||
	onSubmit,
 | 
			
		||||
	isLoading,
 | 
			
		||||
	error,
 | 
			
		||||
	logoType,
 | 
			
		||||
}) => {
 | 
			
		||||
	const [selectedFile, setSelectedFile] = useState(null);
 | 
			
		||||
	const [previewUrl, setPreviewUrl] = useState(null);
 | 
			
		||||
	const [uploadError, setUploadError] = useState("");
 | 
			
		||||
 | 
			
		||||
	const handleFileSelect = (e) => {
 | 
			
		||||
		const file = e.target.files[0];
 | 
			
		||||
		if (file) {
 | 
			
		||||
			// Validate file type
 | 
			
		||||
			const allowedTypes = [
 | 
			
		||||
				"image/png",
 | 
			
		||||
				"image/jpeg",
 | 
			
		||||
				"image/jpg",
 | 
			
		||||
				"image/svg+xml",
 | 
			
		||||
			];
 | 
			
		||||
			if (!allowedTypes.includes(file.type)) {
 | 
			
		||||
				setUploadError("Please select a PNG, JPG, or SVG file");
 | 
			
		||||
				return;
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			// Validate file size (5MB limit)
 | 
			
		||||
			if (file.size > 5 * 1024 * 1024) {
 | 
			
		||||
				setUploadError("File size must be less than 5MB");
 | 
			
		||||
				return;
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			setSelectedFile(file);
 | 
			
		||||
			setUploadError("");
 | 
			
		||||
 | 
			
		||||
			// Create preview URL
 | 
			
		||||
			const url = URL.createObjectURL(file);
 | 
			
		||||
			setPreviewUrl(url);
 | 
			
		||||
		}
 | 
			
		||||
	};
 | 
			
		||||
 | 
			
		||||
	const handleSubmit = (e) => {
 | 
			
		||||
		e.preventDefault();
 | 
			
		||||
		setUploadError("");
 | 
			
		||||
 | 
			
		||||
		if (!selectedFile) {
 | 
			
		||||
			setUploadError("Please select a file");
 | 
			
		||||
			return;
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		// Convert file to base64
 | 
			
		||||
		const reader = new FileReader();
 | 
			
		||||
		reader.onload = (event) => {
 | 
			
		||||
			const base64 = event.target.result;
 | 
			
		||||
			onSubmit({
 | 
			
		||||
				logoType,
 | 
			
		||||
				fileContent: base64,
 | 
			
		||||
				fileName: selectedFile.name,
 | 
			
		||||
			});
 | 
			
		||||
		};
 | 
			
		||||
		reader.readAsDataURL(selectedFile);
 | 
			
		||||
	};
 | 
			
		||||
 | 
			
		||||
	const handleClose = () => {
 | 
			
		||||
		setSelectedFile(null);
 | 
			
		||||
		setPreviewUrl(null);
 | 
			
		||||
		setUploadError("");
 | 
			
		||||
		onClose();
 | 
			
		||||
	};
 | 
			
		||||
 | 
			
		||||
	if (!isOpen) return null;
 | 
			
		||||
 | 
			
		||||
	return (
 | 
			
		||||
		<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
 | 
			
		||||
			<div className="bg-white dark:bg-secondary-800 rounded-lg shadow-xl max-w-2xl w-full mx-4 max-h-[90vh] overflow-y-auto">
 | 
			
		||||
				<div className="px-6 py-4 border-b border-secondary-200 dark:border-secondary-600">
 | 
			
		||||
					<div className="flex items-center justify-between">
 | 
			
		||||
						<h3 className="text-lg font-medium text-secondary-900 dark:text-white">
 | 
			
		||||
							Upload{" "}
 | 
			
		||||
							{logoType === "favicon"
 | 
			
		||||
								? "Favicon"
 | 
			
		||||
								: `${logoType.charAt(0).toUpperCase() + logoType.slice(1)} Logo`}
 | 
			
		||||
						</h3>
 | 
			
		||||
						<button
 | 
			
		||||
							type="button"
 | 
			
		||||
							onClick={handleClose}
 | 
			
		||||
							className="text-secondary-400 hover:text-secondary-600 dark:text-secondary-500 dark:hover:text-secondary-300"
 | 
			
		||||
						>
 | 
			
		||||
							<X className="h-5 w-5" />
 | 
			
		||||
						</button>
 | 
			
		||||
					</div>
 | 
			
		||||
				</div>
 | 
			
		||||
 | 
			
		||||
				<form onSubmit={handleSubmit} className="px-6 py-4">
 | 
			
		||||
					<div className="space-y-4">
 | 
			
		||||
						<div>
 | 
			
		||||
							<label className="block">
 | 
			
		||||
								<span className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-2">
 | 
			
		||||
									Select File
 | 
			
		||||
								</span>
 | 
			
		||||
								<input
 | 
			
		||||
									type="file"
 | 
			
		||||
									accept="image/png,image/jpeg,image/jpg,image/svg+xml"
 | 
			
		||||
									onChange={handleFileSelect}
 | 
			
		||||
									className="block w-full text-sm text-secondary-500 dark:text-secondary-400 file:mr-4 file:py-2 file:px-4 file:rounded-md file:border-0 file:text-sm file:font-medium file:bg-primary-50 file:text-primary-700 hover:file:bg-primary-100 dark:file:bg-primary-900 dark:file:text-primary-200"
 | 
			
		||||
								/>
 | 
			
		||||
							</label>
 | 
			
		||||
							<p className="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
 | 
			
		||||
								Supported formats: PNG, JPG, SVG. Max size: 5MB.
 | 
			
		||||
								{logoType === "favicon"
 | 
			
		||||
									? " Recommended: 32x32px SVG."
 | 
			
		||||
									: " Recommended: 200x60px."}
 | 
			
		||||
							</p>
 | 
			
		||||
						</div>
 | 
			
		||||
 | 
			
		||||
						{previewUrl && (
 | 
			
		||||
							<div>
 | 
			
		||||
								<div className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-2">
 | 
			
		||||
									Preview
 | 
			
		||||
								</div>
 | 
			
		||||
								<div className="flex items-center justify-center p-4 bg-white dark:bg-secondary-800 rounded-lg border border-secondary-200 dark:border-secondary-600">
 | 
			
		||||
									<img
 | 
			
		||||
										src={previewUrl}
 | 
			
		||||
										alt="Preview"
 | 
			
		||||
										className={`object-contain ${
 | 
			
		||||
											logoType === "favicon" ? "h-8 w-8" : "max-h-16 max-w-full"
 | 
			
		||||
										}`}
 | 
			
		||||
									/>
 | 
			
		||||
								</div>
 | 
			
		||||
							</div>
 | 
			
		||||
						)}
 | 
			
		||||
 | 
			
		||||
						{(uploadError || error) && (
 | 
			
		||||
							<div className="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">
 | 
			
		||||
									{uploadError ||
 | 
			
		||||
										error?.response?.data?.error ||
 | 
			
		||||
										error?.message}
 | 
			
		||||
								</p>
 | 
			
		||||
							</div>
 | 
			
		||||
						)}
 | 
			
		||||
 | 
			
		||||
						<div className="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-md p-3">
 | 
			
		||||
							<div className="flex">
 | 
			
		||||
								<AlertCircle className="h-4 w-4 text-yellow-600 dark:text-yellow-400 mr-2 mt-0.5" />
 | 
			
		||||
								<div className="text-sm text-yellow-800 dark:text-yellow-200">
 | 
			
		||||
									<p className="font-medium">Important:</p>
 | 
			
		||||
									<ul className="mt-1 list-disc list-inside space-y-1">
 | 
			
		||||
										<li>This will replace the current {logoType} logo</li>
 | 
			
		||||
										<li>A backup will be created automatically</li>
 | 
			
		||||
										<li>The change will be applied immediately</li>
 | 
			
		||||
									</ul>
 | 
			
		||||
								</div>
 | 
			
		||||
							</div>
 | 
			
		||||
						</div>
 | 
			
		||||
					</div>
 | 
			
		||||
 | 
			
		||||
					<div className="flex justify-end gap-3 mt-6">
 | 
			
		||||
						<button type="button" onClick={handleClose} className="btn-outline">
 | 
			
		||||
							Cancel
 | 
			
		||||
						</button>
 | 
			
		||||
						<button
 | 
			
		||||
							type="submit"
 | 
			
		||||
							disabled={isLoading || !selectedFile}
 | 
			
		||||
							className="btn-primary"
 | 
			
		||||
						>
 | 
			
		||||
							{isLoading ? "Uploading..." : "Upload Logo"}
 | 
			
		||||
						</button>
 | 
			
		||||
					</div>
 | 
			
		||||
				</form>
 | 
			
		||||
			</div>
 | 
			
		||||
		</div>
 | 
			
		||||
	);
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export default Settings;
 | 
			
		||||
 
 | 
			
		||||
@@ -1,7 +1,8 @@
 | 
			
		||||
import { Code, Server } from "lucide-react";
 | 
			
		||||
import { Code, Image, Server } from "lucide-react";
 | 
			
		||||
import { useEffect, useState } from "react";
 | 
			
		||||
import { useLocation, useNavigate } from "react-router-dom";
 | 
			
		||||
import SettingsLayout from "../../components/SettingsLayout";
 | 
			
		||||
import BrandingTab from "../../components/settings/BrandingTab";
 | 
			
		||||
import ProtocolUrlTab from "../../components/settings/ProtocolUrlTab";
 | 
			
		||||
import VersionUpdateTab from "../../components/settings/VersionUpdateTab";
 | 
			
		||||
 | 
			
		||||
@@ -12,6 +13,7 @@ const SettingsServerConfig = () => {
 | 
			
		||||
		// Set initial tab based on current route
 | 
			
		||||
		if (location.pathname === "/settings/server-version") return "version";
 | 
			
		||||
		if (location.pathname === "/settings/server-url") return "protocol";
 | 
			
		||||
		if (location.pathname === "/settings/branding") return "branding";
 | 
			
		||||
		if (location.pathname === "/settings/server-config/version")
 | 
			
		||||
			return "version";
 | 
			
		||||
		return "protocol";
 | 
			
		||||
@@ -23,6 +25,8 @@ const SettingsServerConfig = () => {
 | 
			
		||||
			setActiveTab("version");
 | 
			
		||||
		} else if (location.pathname === "/settings/server-url") {
 | 
			
		||||
			setActiveTab("protocol");
 | 
			
		||||
		} else if (location.pathname === "/settings/branding") {
 | 
			
		||||
			setActiveTab("branding");
 | 
			
		||||
		} else if (location.pathname === "/settings/server-config/version") {
 | 
			
		||||
			setActiveTab("version");
 | 
			
		||||
		} else if (location.pathname === "/settings/server-config") {
 | 
			
		||||
@@ -37,6 +41,12 @@ const SettingsServerConfig = () => {
 | 
			
		||||
			icon: Server,
 | 
			
		||||
			href: "/settings/server-url",
 | 
			
		||||
		},
 | 
			
		||||
		{
 | 
			
		||||
			id: "branding",
 | 
			
		||||
			name: "Branding",
 | 
			
		||||
			icon: Image,
 | 
			
		||||
			href: "/settings/branding",
 | 
			
		||||
		},
 | 
			
		||||
		{
 | 
			
		||||
			id: "version",
 | 
			
		||||
			name: "Server Version",
 | 
			
		||||
@@ -49,6 +59,8 @@ const SettingsServerConfig = () => {
 | 
			
		||||
		switch (activeTab) {
 | 
			
		||||
			case "protocol":
 | 
			
		||||
				return <ProtocolUrlTab />;
 | 
			
		||||
			case "branding":
 | 
			
		||||
				return <BrandingTab />;
 | 
			
		||||
			case "version":
 | 
			
		||||
				return <VersionUpdateTab />;
 | 
			
		||||
			default:
 | 
			
		||||
 
 | 
			
		||||
		Reference in New Issue
	
	Block a user