Files
patchmon.net/frontend/src/components/settings/BrandingTab.jsx
Muhammad Ibrahim 48ce1951de fix: Resolve all linting errors
- Remove unused imports and variables in metricsRoutes.js
- Prefix unused error variables with underscore
- Fix useEffect dependency in Login.jsx
- Add aria-label and title to all SVG elements for accessibility
2025-10-28 16:06:36 +00:00

650 lines
20 KiB
JavaScript

import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
AlertCircle,
Image,
Palette,
RotateCcw,
Upload,
X,
} from "lucide-react";
import { useState } from "react";
import { THEME_PRESETS, useColorTheme } from "../../contexts/ColorThemeContext";
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 { colorTheme, setColorTheme } = useColorTheme();
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);
},
});
// Theme update mutation
const updateThemeMutation = useMutation({
mutationFn: (theme) => settingsAPI.update({ colorTheme: theme }),
onSuccess: (_data, theme) => {
queryClient.invalidateQueries(["settings"]);
setColorTheme(theme);
},
onError: (error) => {
console.error("Update theme error:", error);
},
});
const handleThemeChange = (theme) => {
updateThemeMutation.mutate(theme);
};
if (isLoading) {
return (
<div className="flex items-center justify-center h-64">
<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-8">
{/* Header */}
<div>
<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, favicon, and
color themes. These will be displayed throughout the application.
</p>
</div>
{/* Color Theme Selector */}
<div className="bg-white dark:bg-secondary-800 rounded-lg p-6 border border-secondary-200 dark:border-secondary-600">
<div className="flex items-center mb-4">
<Palette className="h-5 w-5 text-primary-600 mr-2" />
<h3 className="text-lg font-medium text-secondary-900 dark:text-white">
Color Theme
</h3>
</div>
<p className="text-sm text-secondary-600 dark:text-secondary-400 mb-6">
Choose a color theme that will be applied to the login page and
background areas throughout the app.
</p>
<div className="grid grid-cols-2 md:grid-cols-3 gap-4">
{Object.entries(THEME_PRESETS).map(([themeKey, theme]) => {
const isSelected = colorTheme === themeKey;
const gradientColors = theme.login.xColors;
return (
<button
key={themeKey}
type="button"
onClick={() => handleThemeChange(themeKey)}
disabled={updateThemeMutation.isPending}
className={`relative p-4 rounded-lg border-2 transition-all ${
isSelected
? "border-primary-500 ring-2 ring-primary-200 dark:ring-primary-800"
: "border-secondary-200 dark:border-secondary-600 hover:border-primary-300"
} ${updateThemeMutation.isPending ? "opacity-50 cursor-not-allowed" : "cursor-pointer"}`}
>
{/* Theme Preview */}
<div
className="h-20 rounded-md mb-3 overflow-hidden"
style={{
background: `linear-gradient(135deg, ${gradientColors.join(", ")})`,
}}
/>
{/* Theme Name */}
<div className="text-sm font-medium text-secondary-900 dark:text-white mb-1">
{theme.name}
</div>
{/* Selected Indicator */}
{isSelected && (
<div className="absolute top-2 right-2 bg-primary-500 text-white rounded-full p-1">
<svg
className="w-4 h-4"
fill="currentColor"
viewBox="0 0 20 20"
aria-label="Selected theme"
>
<title>Selected</title>
<path
fillRule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clipRule="evenodd"
/>
</svg>
</div>
)}
</button>
);
})}
</div>
{updateThemeMutation.isPending && (
<div className="mt-4 flex items-center gap-2 text-sm text-primary-600 dark:text-primary-400">
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-current"></div>
Updating theme...
</div>
)}
{updateThemeMutation.isError && (
<div className="mt-4 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-md p-3">
<p className="text-sm text-red-800 dark:text-red-200">
Failed to update theme: {updateThemeMutation.error?.message}
</p>
</div>
)}
</div>
{/* Logo Section Header */}
<div className="flex items-center mb-4">
<Image className="h-5 w-5 text-primary-600 mr-2" />
<h3 className="text-lg font-medium text-secondary-900 dark:text-white">
Logos
</h3>
</div>
<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;