mirror of
https://github.com/9technologygroup/patchmon.net.git
synced 2025-11-06 06:53:24 +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)
|
signup_enabled Boolean @default(false)
|
||||||
default_user_role String @default("user")
|
default_user_role String @default("user")
|
||||||
ignore_ssl_self_signed Boolean @default(false)
|
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 {
|
model update_history {
|
||||||
|
|||||||
@@ -215,6 +215,18 @@ router.put(
|
|||||||
}
|
}
|
||||||
return true;
|
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) => {
|
async (req, res) => {
|
||||||
try {
|
try {
|
||||||
@@ -236,6 +248,9 @@ router.put(
|
|||||||
githubRepoUrl,
|
githubRepoUrl,
|
||||||
repositoryType,
|
repositoryType,
|
||||||
sshKeyPath,
|
sshKeyPath,
|
||||||
|
logoDark,
|
||||||
|
logoLight,
|
||||||
|
favicon,
|
||||||
} = req.body;
|
} = req.body;
|
||||||
|
|
||||||
// Get current settings to check for update interval changes
|
// Get current settings to check for update interval changes
|
||||||
@@ -264,6 +279,9 @@ router.put(
|
|||||||
if (repositoryType !== undefined)
|
if (repositoryType !== undefined)
|
||||||
updateData.repository_type = repositoryType;
|
updateData.repository_type = repositoryType;
|
||||||
if (sshKeyPath !== undefined) updateData.ssh_key_path = sshKeyPath;
|
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(
|
const updatedSettings = await updateSettings(
|
||||||
currentSettings.id,
|
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;
|
module.exports = router;
|
||||||
|
|||||||
@@ -188,72 +188,24 @@ router.get("/current", authenticateToken, async (_req, res) => {
|
|||||||
try {
|
try {
|
||||||
const currentVersion = getCurrentVersion();
|
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 settings = await prisma.settings.findFirst();
|
||||||
const githubRepoUrl = settings?.githubRepoUrl || DEFAULT_GITHUB_REPO;
|
const githubRepoUrl = settings?.githubRepoUrl || DEFAULT_GITHUB_REPO;
|
||||||
const { owner, repo } = parseGitHubRepo(githubRepoUrl);
|
const { owner, repo } = parseGitHubRepo(githubRepoUrl);
|
||||||
|
|
||||||
let latestRelease = null;
|
// Return current version and cached update information
|
||||||
let latestCommit = null;
|
// The backend scheduler updates this data periodically
|
||||||
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",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
version: currentVersion,
|
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(),
|
buildDate: new Date().toISOString(),
|
||||||
environment: process.env.NODE_ENV || "development",
|
environment: process.env.NODE_ENV || "development",
|
||||||
github: {
|
github: {
|
||||||
repository: githubRepoUrl,
|
repository: githubRepoUrl,
|
||||||
owner: owner,
|
owner: owner,
|
||||||
repo: repo,
|
repo: repo,
|
||||||
latestRelease: latestRelease,
|
|
||||||
latestCommit: latestCommit,
|
|
||||||
commitDifference: commitDifference,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
const jwt = require("jsonwebtoken");
|
const jwt = require("jsonwebtoken");
|
||||||
const crypto = require("crypto");
|
const crypto = require("node:crypto");
|
||||||
const { PrismaClient } = require("@prisma/client");
|
const { PrismaClient } = require("@prisma/client");
|
||||||
|
|
||||||
const prisma = new PrismaClient();
|
const prisma = new PrismaClient();
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<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" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>PatchMon - Linux Patch Monitoring Dashboard</title>
|
<title>PatchMon - Linux Patch Monitoring Dashboard</title>
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Route, Routes } from "react-router-dom";
|
import { Route, Routes } from "react-router-dom";
|
||||||
import FirstTimeAdminSetup from "./components/FirstTimeAdminSetup";
|
import FirstTimeAdminSetup from "./components/FirstTimeAdminSetup";
|
||||||
import Layout from "./components/Layout";
|
import Layout from "./components/Layout";
|
||||||
|
import LogoProvider from "./components/LogoProvider";
|
||||||
import ProtectedRoute from "./components/ProtectedRoute";
|
import ProtectedRoute from "./components/ProtectedRoute";
|
||||||
import SettingsLayout from "./components/SettingsLayout";
|
import SettingsLayout from "./components/SettingsLayout";
|
||||||
import { isAuthPhase } from "./constants/authPhases";
|
import { isAuthPhase } from "./constants/authPhases";
|
||||||
@@ -290,6 +291,16 @@ function AppRoutes() {
|
|||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="/settings/branding"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute requirePermission="can_manage_settings">
|
||||||
|
<Layout>
|
||||||
|
<SettingsServerConfig />
|
||||||
|
</Layout>
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="/settings/agent-version"
|
path="/settings/agent-version"
|
||||||
element={
|
element={
|
||||||
@@ -329,7 +340,9 @@ function App() {
|
|||||||
<ThemeProvider>
|
<ThemeProvider>
|
||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
<UpdateNotificationProvider>
|
<UpdateNotificationProvider>
|
||||||
<AppRoutes />
|
<LogoProvider>
|
||||||
|
<AppRoutes />
|
||||||
|
</LogoProvider>
|
||||||
</UpdateNotificationProvider>
|
</UpdateNotificationProvider>
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
</ThemeProvider>
|
</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">
|
<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
|
Hosts
|
||||||
</div>
|
</div>
|
||||||
{results.hosts.map((host, idx) => {
|
{results.hosts.map((host, _idx) => {
|
||||||
const display = getResultDisplay(host);
|
const display = getResultDisplay(host);
|
||||||
const globalIdx = navigableResults.findIndex(
|
const globalIdx = navigableResults.findIndex(
|
||||||
(r) => r.id === host.id && r.type === "host",
|
(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">
|
<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
|
Packages
|
||||||
</div>
|
</div>
|
||||||
{results.packages.map((pkg, idx) => {
|
{results.packages.map((pkg, _idx) => {
|
||||||
const display = getResultDisplay(pkg);
|
const display = getResultDisplay(pkg);
|
||||||
const globalIdx = navigableResults.findIndex(
|
const globalIdx = navigableResults.findIndex(
|
||||||
(r) => r.id === pkg.id && r.type === "package",
|
(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">
|
<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
|
Repositories
|
||||||
</div>
|
</div>
|
||||||
{results.repositories.map((repo, idx) => {
|
{results.repositories.map((repo, _idx) => {
|
||||||
const display = getResultDisplay(repo);
|
const display = getResultDisplay(repo);
|
||||||
const globalIdx = navigableResults.findIndex(
|
const globalIdx = navigableResults.findIndex(
|
||||||
(r) => r.id === repo.id && r.type === "repository",
|
(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">
|
<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
|
Users
|
||||||
</div>
|
</div>
|
||||||
{results.users.map((user, idx) => {
|
{results.users.map((user, _idx) => {
|
||||||
const display = getResultDisplay(user);
|
const display = getResultDisplay(user);
|
||||||
const globalIdx = navigableResults.findIndex(
|
const globalIdx = navigableResults.findIndex(
|
||||||
(r) => r.id === user.id && r.type === "user",
|
(r) => r.id === user.id && r.type === "user",
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useQuery } from "@tanstack/react-query";
|
|||||||
import {
|
import {
|
||||||
Activity,
|
Activity,
|
||||||
BarChart3,
|
BarChart3,
|
||||||
|
BookOpen,
|
||||||
ChevronLeft,
|
ChevronLeft,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
Clock,
|
Clock,
|
||||||
@@ -13,13 +14,12 @@ import {
|
|||||||
LogOut,
|
LogOut,
|
||||||
Mail,
|
Mail,
|
||||||
Menu,
|
Menu,
|
||||||
MessageCircle,
|
|
||||||
Package,
|
Package,
|
||||||
Plus,
|
Plus,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
|
Route,
|
||||||
Server,
|
Server,
|
||||||
Settings,
|
Settings,
|
||||||
Shield,
|
|
||||||
Star,
|
Star,
|
||||||
UserCircle,
|
UserCircle,
|
||||||
X,
|
X,
|
||||||
@@ -29,7 +29,9 @@ import { Link, useLocation, useNavigate } from "react-router-dom";
|
|||||||
import { useAuth } from "../contexts/AuthContext";
|
import { useAuth } from "../contexts/AuthContext";
|
||||||
import { useUpdateNotification } from "../contexts/UpdateNotificationContext";
|
import { useUpdateNotification } from "../contexts/UpdateNotificationContext";
|
||||||
import { dashboardAPI, versionAPI } from "../utils/api";
|
import { dashboardAPI, versionAPI } from "../utils/api";
|
||||||
|
import DiscordIcon from "./DiscordIcon";
|
||||||
import GlobalSearch from "./GlobalSearch";
|
import GlobalSearch from "./GlobalSearch";
|
||||||
|
import Logo from "./Logo";
|
||||||
import UpgradeNotificationIcon from "./UpgradeNotificationIcon";
|
import UpgradeNotificationIcon from "./UpgradeNotificationIcon";
|
||||||
|
|
||||||
const Layout = ({ children }) => {
|
const Layout = ({ children }) => {
|
||||||
@@ -293,7 +295,7 @@ const Layout = ({ children }) => {
|
|||||||
onClick={() => setSidebarOpen(false)}
|
onClick={() => setSidebarOpen(false)}
|
||||||
aria-label="Close sidebar"
|
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">
|
<div className="absolute right-0 top-0 -mr-12 pt-2">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -303,13 +305,10 @@ const Layout = ({ children }) => {
|
|||||||
<X className="h-6 w-6 text-white" />
|
<X className="h-6 w-6 text-white" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-shrink-0 items-center px-4">
|
<div className="flex flex-shrink-0 items-center justify-center px-4">
|
||||||
<div className="flex items-center">
|
<Link to="/" className="flex items-center">
|
||||||
<Shield className="h-8 w-8 text-primary-600" />
|
<Logo className="h-10 w-auto" alt="PatchMon Logo" />
|
||||||
<h1 className="ml-2 text-xl font-bold text-secondary-900 dark:text-white">
|
</Link>
|
||||||
PatchMon
|
|
||||||
</h1>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<nav className="mt-8 flex-1 space-y-6 px-2">
|
<nav className="mt-8 flex-1 space-y-6 px-2">
|
||||||
{/* Show message for users with very limited permissions */}
|
{/* Show message for users with very limited permissions */}
|
||||||
@@ -345,7 +344,7 @@ const Layout = ({ children }) => {
|
|||||||
// Section with items
|
// Section with items
|
||||||
return (
|
return (
|
||||||
<div key={item.section}>
|
<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}
|
{item.section}
|
||||||
</h3>
|
</h3>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
@@ -465,8 +464,8 @@ const Layout = ({ children }) => {
|
|||||||
|
|
||||||
{/* Desktop sidebar */}
|
{/* Desktop sidebar */}
|
||||||
<div
|
<div
|
||||||
className={`hidden lg:fixed lg:inset-y-0 lg:z-50 lg:flex lg:flex-col transition-all duration-300 ${
|
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-64"
|
sidebarCollapsed ? "lg:w-16" : "lg:w-56"
|
||||||
} bg-white dark:bg-secondary-800`}
|
} bg-white dark:bg-secondary-800`}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
@@ -475,38 +474,37 @@ const Layout = ({ children }) => {
|
|||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={`flex h-16 shrink-0 items-center border-b border-secondary-200 ${
|
className={`flex h-16 shrink-0 items-center border-b border-secondary-200 dark:border-secondary-600 ${
|
||||||
sidebarCollapsed ? "justify-center" : "justify-between"
|
sidebarCollapsed ? "justify-center" : "justify-center"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{sidebarCollapsed ? (
|
{sidebarCollapsed ? (
|
||||||
<button
|
<Link to="/" className="flex items-center">
|
||||||
type="button"
|
<img
|
||||||
onClick={() => setSidebarCollapsed(!sidebarCollapsed)}
|
src="/assets/favicon.svg"
|
||||||
className="flex items-center justify-center w-8 h-8 rounded-md hover:bg-secondary-100 transition-colors"
|
alt="PatchMon"
|
||||||
title="Expand sidebar"
|
className="h-12 w-12 object-contain"
|
||||||
>
|
/>
|
||||||
<ChevronRight className="h-5 w-5 text-secondary-700 dark:text-white" />
|
</Link>
|
||||||
</button>
|
|
||||||
) : (
|
) : (
|
||||||
<>
|
<Link to="/" className="flex items-center">
|
||||||
<div className="flex items-center">
|
<Logo className="h-10 w-auto" alt="PatchMon Logo" />
|
||||||
<Shield className="h-8 w-8 text-primary-600" />
|
</Link>
|
||||||
<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>
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</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">
|
<nav className="flex flex-1 flex-col">
|
||||||
<ul className="flex flex-1 flex-col gap-y-6">
|
<ul className="flex flex-1 flex-col gap-y-6">
|
||||||
{/* Show message for users with very limited permissions */}
|
{/* Show message for users with very limited permissions */}
|
||||||
@@ -524,7 +522,10 @@ const Layout = ({ children }) => {
|
|||||||
if (item.name) {
|
if (item.name) {
|
||||||
// Single item (Dashboard)
|
// Single item (Dashboard)
|
||||||
return (
|
return (
|
||||||
<li key={item.name}>
|
<li
|
||||||
|
key={item.name}
|
||||||
|
className={sidebarCollapsed ? "" : "-mx-2"}
|
||||||
|
>
|
||||||
<Link
|
<Link
|
||||||
to={item.href}
|
to={item.href}
|
||||||
className={`group flex gap-x-3 rounded-md text-sm leading-6 font-semibold transition-all duration-200 ${
|
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 (
|
return (
|
||||||
<li key={item.section}>
|
<li key={item.section}>
|
||||||
{!sidebarCollapsed && (
|
{!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}
|
{item.section}
|
||||||
</h3>
|
</h3>
|
||||||
)}
|
)}
|
||||||
@@ -850,7 +851,7 @@ const Layout = ({ children }) => {
|
|||||||
{/* Main content */}
|
{/* Main content */}
|
||||||
<div
|
<div
|
||||||
className={`flex flex-col min-h-screen transition-all duration-300 ${
|
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 */}
|
{/* Top bar */}
|
||||||
@@ -895,6 +896,15 @@ const Layout = ({ children }) => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</a>
|
</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
|
<a
|
||||||
href="https://patchmon.net/discord"
|
href="https://patchmon.net/discord"
|
||||||
target="_blank"
|
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"
|
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"
|
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>
|
||||||
<a
|
<a
|
||||||
href="mailto:support@patchmon.net"
|
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,
|
ChevronRight,
|
||||||
Code,
|
Code,
|
||||||
Folder,
|
Folder,
|
||||||
|
Image,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
Settings,
|
Settings,
|
||||||
Shield,
|
Shield,
|
||||||
@@ -130,6 +131,11 @@ const SettingsLayout = ({ children }) => {
|
|||||||
href: "/settings/server-url",
|
href: "/settings/server-url",
|
||||||
icon: Wrench,
|
icon: Wrench,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "Branding",
|
||||||
|
href: "/settings/branding",
|
||||||
|
icon: Image,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "Server Version",
|
name: "Server Version",
|
||||||
href: "/settings/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(() => {
|
useEffect(() => {
|
||||||
const loadCurrentVersion = async () => {
|
const loadAndCheckUpdates = async () => {
|
||||||
try {
|
try {
|
||||||
|
// First, get current version info
|
||||||
const response = await versionAPI.getCurrent();
|
const response = await versionAPI.getCurrent();
|
||||||
const data = response.data;
|
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) => ({
|
setVersionInfo((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
currentVersion: data.version,
|
error: "Failed to load version information",
|
||||||
github: data.github,
|
|
||||||
}));
|
}));
|
||||||
} 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();
|
loadAndCheckUpdates();
|
||||||
}, [checkForUpdates]); // Include checkForUpdates dependency
|
}, [checkForUpdates]); // Run when component mounts
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
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 { isAuthReady } from "../constants/authPhases";
|
||||||
import { settingsAPI, versionAPI } from "../utils/api";
|
import { settingsAPI } from "../utils/api";
|
||||||
import { useAuth } from "./AuthContext";
|
import { useAuth } from "./AuthContext";
|
||||||
|
|
||||||
const UpdateNotificationContext = createContext();
|
const UpdateNotificationContext = createContext();
|
||||||
@@ -21,6 +21,7 @@ export const UpdateNotificationProvider = ({ children }) => {
|
|||||||
const { authPhase, isAuthenticated } = useAuth();
|
const { authPhase, isAuthenticated } = useAuth();
|
||||||
|
|
||||||
// Ensure settings are loaded - but only after auth is fully ready
|
// 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({
|
const { data: settings, isLoading: settingsLoading } = useQuery({
|
||||||
queryKey: ["settings"],
|
queryKey: ["settings"],
|
||||||
queryFn: () => settingsAPI.get().then((res) => res.data),
|
queryFn: () => settingsAPI.get().then((res) => res.data),
|
||||||
@@ -29,31 +30,20 @@ export const UpdateNotificationProvider = ({ children }) => {
|
|||||||
enabled: isAuthReady(authPhase, isAuthenticated()),
|
enabled: isAuthReady(authPhase, isAuthenticated()),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Memoize the enabled condition to prevent unnecessary re-evaluations
|
// Read cached update information from settings (no GitHub API calls)
|
||||||
const isQueryEnabled = useMemo(() => {
|
// The backend scheduler updates this data periodically
|
||||||
return (
|
const updateAvailable = settings?.is_update_available && !dismissed;
|
||||||
isAuthReady(authPhase, isAuthenticated()) &&
|
const updateInfo = settings
|
||||||
!!settings &&
|
? {
|
||||||
!settingsLoading
|
isUpdateAvailable: settings.is_update_available,
|
||||||
);
|
latestVersion: settings.latest_version,
|
||||||
}, [authPhase, isAuthenticated, settings, settingsLoading]);
|
currentVersion: settings.current_version,
|
||||||
|
last_update_check: settings.last_update_check,
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
|
||||||
// Query for update information
|
const isLoading = settingsLoading;
|
||||||
const {
|
const error = null;
|
||||||
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 dismissNotification = () => {
|
const dismissNotification = () => {
|
||||||
setDismissed(true);
|
setDismissed(true);
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ const HostDetail = () => {
|
|||||||
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
||||||
const [showAllUpdates, setShowAllUpdates] = useState(false);
|
const [showAllUpdates, setShowAllUpdates] = useState(false);
|
||||||
const [activeTab, setActiveTab] = useState("host");
|
const [activeTab, setActiveTab] = useState("host");
|
||||||
const [forceInstall, setForceInstall] = useState(false);
|
const [_forceInstall, _setForceInstall] = useState(false);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
data: host,
|
data: host,
|
||||||
|
|||||||
@@ -5,11 +5,13 @@ import {
|
|||||||
Clock,
|
Clock,
|
||||||
Code,
|
Code,
|
||||||
Download,
|
Download,
|
||||||
|
Image,
|
||||||
Plus,
|
Plus,
|
||||||
Save,
|
Save,
|
||||||
Server,
|
Server,
|
||||||
Settings as SettingsIcon,
|
Settings as SettingsIcon,
|
||||||
Shield,
|
Shield,
|
||||||
|
Upload,
|
||||||
X,
|
X,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
@@ -80,6 +82,15 @@ const Settings = () => {
|
|||||||
});
|
});
|
||||||
const [showUploadModal, setShowUploadModal] = useState(false);
|
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
|
// Version checking state
|
||||||
const [versionInfo, setVersionInfo] = useState({
|
const [versionInfo, setVersionInfo] = useState({
|
||||||
currentVersion: null, // Will be loaded from API
|
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
|
// Load current version on component mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadCurrentVersion = async () => {
|
const loadCurrentVersion = async () => {
|
||||||
@@ -556,6 +598,181 @@ const Settings = () => {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</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 */}
|
{/* Update Interval */}
|
||||||
<div>
|
<div>
|
||||||
<label
|
<label
|
||||||
@@ -1319,6 +1536,18 @@ const Settings = () => {
|
|||||||
error={uploadAgentMutation.error}
|
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>
|
</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;
|
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 { useEffect, useState } from "react";
|
||||||
import { useLocation, useNavigate } from "react-router-dom";
|
import { useLocation, useNavigate } from "react-router-dom";
|
||||||
import SettingsLayout from "../../components/SettingsLayout";
|
import SettingsLayout from "../../components/SettingsLayout";
|
||||||
|
import BrandingTab from "../../components/settings/BrandingTab";
|
||||||
import ProtocolUrlTab from "../../components/settings/ProtocolUrlTab";
|
import ProtocolUrlTab from "../../components/settings/ProtocolUrlTab";
|
||||||
import VersionUpdateTab from "../../components/settings/VersionUpdateTab";
|
import VersionUpdateTab from "../../components/settings/VersionUpdateTab";
|
||||||
|
|
||||||
@@ -12,6 +13,7 @@ const SettingsServerConfig = () => {
|
|||||||
// Set initial tab based on current route
|
// Set initial tab based on current route
|
||||||
if (location.pathname === "/settings/server-version") return "version";
|
if (location.pathname === "/settings/server-version") return "version";
|
||||||
if (location.pathname === "/settings/server-url") return "protocol";
|
if (location.pathname === "/settings/server-url") return "protocol";
|
||||||
|
if (location.pathname === "/settings/branding") return "branding";
|
||||||
if (location.pathname === "/settings/server-config/version")
|
if (location.pathname === "/settings/server-config/version")
|
||||||
return "version";
|
return "version";
|
||||||
return "protocol";
|
return "protocol";
|
||||||
@@ -23,6 +25,8 @@ const SettingsServerConfig = () => {
|
|||||||
setActiveTab("version");
|
setActiveTab("version");
|
||||||
} else if (location.pathname === "/settings/server-url") {
|
} else if (location.pathname === "/settings/server-url") {
|
||||||
setActiveTab("protocol");
|
setActiveTab("protocol");
|
||||||
|
} else if (location.pathname === "/settings/branding") {
|
||||||
|
setActiveTab("branding");
|
||||||
} else if (location.pathname === "/settings/server-config/version") {
|
} else if (location.pathname === "/settings/server-config/version") {
|
||||||
setActiveTab("version");
|
setActiveTab("version");
|
||||||
} else if (location.pathname === "/settings/server-config") {
|
} else if (location.pathname === "/settings/server-config") {
|
||||||
@@ -37,6 +41,12 @@ const SettingsServerConfig = () => {
|
|||||||
icon: Server,
|
icon: Server,
|
||||||
href: "/settings/server-url",
|
href: "/settings/server-url",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "branding",
|
||||||
|
name: "Branding",
|
||||||
|
icon: Image,
|
||||||
|
href: "/settings/branding",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: "version",
|
id: "version",
|
||||||
name: "Server Version",
|
name: "Server Version",
|
||||||
@@ -49,6 +59,8 @@ const SettingsServerConfig = () => {
|
|||||||
switch (activeTab) {
|
switch (activeTab) {
|
||||||
case "protocol":
|
case "protocol":
|
||||||
return <ProtocolUrlTab />;
|
return <ProtocolUrlTab />;
|
||||||
|
case "branding":
|
||||||
|
return <BrandingTab />;
|
||||||
case "version":
|
case "version":
|
||||||
return <VersionUpdateTab />;
|
return <VersionUpdateTab />;
|
||||||
default:
|
default:
|
||||||
|
|||||||
Reference in New Issue
Block a user