mirror of
https://github.com/9technologygroup/patchmon.net.git
synced 2026-01-06 13:09:34 -06:00
Ui Fixes
Package Descriptions Social Metrics
This commit is contained in:
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -354,14 +354,29 @@ router.post(
|
||||
const _crypto = require("node:crypto");
|
||||
|
||||
// Create assets directory if it doesn't exist
|
||||
// In Docker: use ASSETS_DIR environment variable (mounted volume)
|
||||
// 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");
|
||||
// In production (non-Docker): save to public/assets (build copies to dist/)
|
||||
const assetsDir = process.env.ASSETS_DIR
|
||||
? path.resolve(process.env.ASSETS_DIR)
|
||||
: path.resolve(__dirname, "../../../frontend/public/assets");
|
||||
|
||||
console.log(`📁 Assets directory: ${assetsDir}`);
|
||||
|
||||
await fs.mkdir(assetsDir, { recursive: true });
|
||||
|
||||
// Verify directory exists
|
||||
const dirExists = await fs
|
||||
.access(assetsDir)
|
||||
.then(() => true)
|
||||
.catch(() => false);
|
||||
|
||||
if (!dirExists) {
|
||||
throw new Error(
|
||||
`Failed to create or access assets directory: ${assetsDir}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Determine file extension and path
|
||||
let fileExtension;
|
||||
let fileName_final;
|
||||
@@ -386,7 +401,8 @@ router.post(
|
||||
fileName_final = fileName || `logo_${logoType}${fileExtension}`;
|
||||
}
|
||||
|
||||
const filePath = path.join(assetsDir, fileName_final);
|
||||
const filePath = path.resolve(assetsDir, fileName_final);
|
||||
console.log(`📄 Full file path: ${filePath}`);
|
||||
|
||||
// Handle base64 data URLs
|
||||
let fileBuffer;
|
||||
@@ -412,6 +428,17 @@ router.post(
|
||||
|
||||
// Write new logo file
|
||||
await fs.writeFile(filePath, fileBuffer);
|
||||
console.log(`✅ Logo file written to: ${filePath}`);
|
||||
|
||||
// Verify file was written
|
||||
const fileExists = await fs
|
||||
.access(filePath)
|
||||
.then(() => true)
|
||||
.catch(() => false);
|
||||
|
||||
if (!fileExists) {
|
||||
throw new Error(`Failed to verify file was written: ${filePath}`);
|
||||
}
|
||||
|
||||
// Update settings with new logo path
|
||||
const settings = await getSettings();
|
||||
@@ -426,7 +453,10 @@ router.post(
|
||||
updateData.favicon = logoPath;
|
||||
}
|
||||
|
||||
await updateSettings(settings.id, updateData);
|
||||
const updatedSettings = await updateSettings(settings.id, updateData);
|
||||
console.log(
|
||||
`✅ Settings updated with new ${logoType} logo path: ${logoPath}`,
|
||||
);
|
||||
|
||||
// Get file stats
|
||||
const stats = await fs.stat(filePath);
|
||||
@@ -437,10 +467,15 @@ router.post(
|
||||
path: logoPath,
|
||||
size: stats.size,
|
||||
sizeFormatted: `${(stats.size / 1024).toFixed(1)} KB`,
|
||||
timestamp: updatedSettings.updated_at.getTime(), // Include timestamp for cache busting
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Upload logo error:", error);
|
||||
res.status(500).json({ error: "Failed to upload logo" });
|
||||
res.status(500).json({
|
||||
error: "Failed to upload logo",
|
||||
details: error.message || "Unknown error",
|
||||
stack: process.env.NODE_ENV === "development" ? error.stack : undefined,
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
@@ -386,7 +386,7 @@ app.use(
|
||||
credentials: true,
|
||||
// Additional CORS options for better cookie handling
|
||||
optionsSuccessStatus: 200,
|
||||
methods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"],
|
||||
methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
|
||||
allowedHeaders: [
|
||||
"Content-Type",
|
||||
"Authorization",
|
||||
|
||||
@@ -68,10 +68,13 @@ services:
|
||||
REDIS_PORT: 6379
|
||||
REDIS_PASSWORD: 1NS3CU6E_DEV_R3DIS_PASSW0RD
|
||||
REDIS_DB: 0
|
||||
# Assets directory for custom branding (logos, favicons)
|
||||
ASSETS_DIR: /app/assets
|
||||
ports:
|
||||
- "3001:3001"
|
||||
volumes:
|
||||
- ./compose_dev_data/agents:/app/agents
|
||||
- ./compose_dev_data/assets:/app/assets
|
||||
depends_on:
|
||||
database:
|
||||
condition: service_healthy
|
||||
@@ -102,6 +105,8 @@ services:
|
||||
BACKEND_PORT: 3001
|
||||
ports:
|
||||
- "3000:3000"
|
||||
volumes:
|
||||
- ./compose_dev_data/assets:/app/frontend/public/assets
|
||||
depends_on:
|
||||
backend:
|
||||
condition: service_healthy
|
||||
|
||||
@@ -74,8 +74,11 @@ services:
|
||||
REDIS_PORT: 6379
|
||||
REDIS_PASSWORD: your-redis-password-here
|
||||
REDIS_DB: 0
|
||||
# Assets directory for custom branding (logos, favicons)
|
||||
ASSETS_DIR: /app/assets
|
||||
volumes:
|
||||
- agent_files:/app/agents
|
||||
- branding_assets:/app/assets
|
||||
depends_on:
|
||||
database:
|
||||
condition: service_healthy
|
||||
@@ -87,6 +90,8 @@ services:
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "3000:3000"
|
||||
volumes:
|
||||
- branding_assets:/usr/share/nginx/html/assets
|
||||
depends_on:
|
||||
backend:
|
||||
condition: service_healthy
|
||||
@@ -95,3 +100,4 @@ volumes:
|
||||
postgres_data:
|
||||
redis_data:
|
||||
agent_files:
|
||||
branding_assets:
|
||||
|
||||
@@ -95,6 +95,16 @@ server {
|
||||
}
|
||||
|
||||
|
||||
# Custom branding assets (logos, favicons) - served from mounted volume
|
||||
# This allows logos to persist across container restarts
|
||||
location /assets/ {
|
||||
alias /usr/share/nginx/html/assets/;
|
||||
expires 1h;
|
||||
add_header Cache-Control "public, must-revalidate";
|
||||
# Allow CORS for assets if needed
|
||||
add_header Access-Control-Allow-Origin *;
|
||||
}
|
||||
|
||||
# Static assets caching (exclude Bull Board assets)
|
||||
location ~* ^/(?!bullboard).*\.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
||||
expires 1y;
|
||||
|
||||
@@ -25,13 +25,13 @@ import {
|
||||
X,
|
||||
Zap,
|
||||
} from "lucide-react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { FaLinkedin, FaYoutube } from "react-icons/fa";
|
||||
import { Link, useLocation, useNavigate } from "react-router-dom";
|
||||
import { useAuth } from "../contexts/AuthContext";
|
||||
import { useColorTheme } from "../contexts/ColorThemeContext";
|
||||
import { useUpdateNotification } from "../contexts/UpdateNotificationContext";
|
||||
import { dashboardAPI, versionAPI } from "../utils/api";
|
||||
import { dashboardAPI, settingsAPI, versionAPI } from "../utils/api";
|
||||
import DiscordIcon from "./DiscordIcon";
|
||||
import GlobalSearch from "./GlobalSearch";
|
||||
import Logo from "./Logo";
|
||||
@@ -96,6 +96,12 @@ const Layout = ({ children }) => {
|
||||
refetchOnWindowFocus: false, // Don't refetch when window regains focus
|
||||
});
|
||||
|
||||
// Fetch settings for favicon
|
||||
const { data: settings } = useQuery({
|
||||
queryKey: ["settings"],
|
||||
queryFn: () => settingsAPI.get().then((res) => res.data),
|
||||
});
|
||||
|
||||
// Fetch version info
|
||||
const { data: versionInfo } = useQuery({
|
||||
queryKey: ["versionInfo"],
|
||||
@@ -403,24 +409,51 @@ const Layout = ({ children }) => {
|
||||
};
|
||||
}, [themeConfig]);
|
||||
|
||||
// Fetch social media stats from cache
|
||||
const fetchSocialMediaStats = useCallback(async () => {
|
||||
try {
|
||||
const response = await fetch("/api/v1/social-media-stats");
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setSocialMediaStats({
|
||||
github_stars: data.github_stars,
|
||||
discord_members: data.discord_members,
|
||||
buymeacoffee_supporters: data.buymeacoffee_supporters,
|
||||
youtube_subscribers: data.youtube_subscribers,
|
||||
linkedin_followers: data.linkedin_followers,
|
||||
});
|
||||
// Fetch social media stats from cache - only once on mount
|
||||
// Using useRef to track if we've already fetched to prevent re-fetching on settings updates
|
||||
const hasFetchedSocialMedia = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
// Only fetch once on component mount, not when settings change
|
||||
if (hasFetchedSocialMedia.current) return;
|
||||
|
||||
const fetchSocialMediaStats = async () => {
|
||||
try {
|
||||
const response = await fetch("/api/v1/social-media-stats");
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
// Only update stats that are not null - preserve existing values if fetch failed
|
||||
setSocialMediaStats((prev) => ({
|
||||
github_stars:
|
||||
data.github_stars !== null
|
||||
? data.github_stars
|
||||
: prev.github_stars,
|
||||
discord_members:
|
||||
data.discord_members !== null
|
||||
? data.discord_members
|
||||
: prev.discord_members,
|
||||
buymeacoffee_supporters:
|
||||
data.buymeacoffee_supporters !== null
|
||||
? data.buymeacoffee_supporters
|
||||
: prev.buymeacoffee_supporters,
|
||||
youtube_subscribers:
|
||||
data.youtube_subscribers !== null
|
||||
? data.youtube_subscribers
|
||||
: prev.youtube_subscribers,
|
||||
linkedin_followers:
|
||||
data.linkedin_followers !== null
|
||||
? data.linkedin_followers
|
||||
: prev.linkedin_followers,
|
||||
}));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch social media stats:", error);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch social media stats:", error);
|
||||
}
|
||||
}, []);
|
||||
};
|
||||
|
||||
fetchSocialMediaStats();
|
||||
hasFetchedSocialMedia.current = true;
|
||||
}, []); // Empty dependency array - only run once on mount
|
||||
|
||||
// Short format for navigation area
|
||||
const formatRelativeTimeShort = (date) => {
|
||||
@@ -463,11 +496,6 @@ const Layout = ({ children }) => {
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Fetch social media stats on component mount
|
||||
useEffect(() => {
|
||||
fetchSocialMediaStats();
|
||||
}, [fetchSocialMediaStats]);
|
||||
|
||||
// Set CSS custom properties for glassmorphism and theme colors in dark mode
|
||||
useEffect(() => {
|
||||
const updateThemeStyles = () => {
|
||||
@@ -815,9 +843,28 @@ const Layout = ({ children }) => {
|
||||
{sidebarCollapsed ? (
|
||||
<Link to="/" className="flex items-center">
|
||||
<img
|
||||
src="/assets/favicon.svg"
|
||||
src={
|
||||
settings?.favicon
|
||||
? `${(() => {
|
||||
const parts = settings.favicon.split("/");
|
||||
const filename = parts.pop();
|
||||
const directory = parts.join("/");
|
||||
const encodedPath = directory
|
||||
? `${directory}/${encodeURIComponent(filename)}`
|
||||
: encodeURIComponent(filename);
|
||||
return `${encodedPath}?v=${
|
||||
settings?.updated_at
|
||||
? new Date(settings.updated_at).getTime()
|
||||
: Date.now()
|
||||
}`;
|
||||
})()}`
|
||||
: "/assets/favicon.svg"
|
||||
}
|
||||
alt="PatchMon"
|
||||
className="h-12 w-12 object-contain"
|
||||
onError={(e) => {
|
||||
e.target.src = "/assets/favicon.svg";
|
||||
}}
|
||||
/>
|
||||
</Link>
|
||||
) : (
|
||||
@@ -904,6 +951,20 @@ const Layout = ({ children }) => {
|
||||
{stats.cards.totalHosts}
|
||||
</span>
|
||||
)}
|
||||
{/* {subItem.name === "Packages" &&
|
||||
stats?.cards?.totalOutdatedPackages !==
|
||||
undefined && (
|
||||
<span className="ml-2 inline-flex items-center justify-center px-1.5 py-0.5 text-xs rounded bg-secondary-100 text-secondary-700">
|
||||
{stats.cards.totalOutdatedPackages}
|
||||
</span>
|
||||
)} */}
|
||||
{/* {subItem.name === "Repos" &&
|
||||
stats?.cards?.totalRepos !==
|
||||
undefined && (
|
||||
<span className="ml-2 inline-flex items-center justify-center px-1.5 py-0.5 text-xs rounded bg-secondary-100 text-secondary-700">
|
||||
{stats.cards.totalRepos}
|
||||
</span>
|
||||
)} */}
|
||||
</span>
|
||||
)}
|
||||
{!sidebarCollapsed && (
|
||||
@@ -962,6 +1023,20 @@ const Layout = ({ children }) => {
|
||||
{stats.cards.totalHosts}
|
||||
</span>
|
||||
)}
|
||||
{/* {subItem.name === "Packages" &&
|
||||
stats?.cards?.totalOutdatedPackages !==
|
||||
undefined && (
|
||||
<span className="ml-2 inline-flex items-center justify-center px-1.5 py-0.5 text-xs rounded bg-secondary-100 text-secondary-700">
|
||||
{stats.cards.totalOutdatedPackages}
|
||||
</span>
|
||||
)} */}
|
||||
{/* {subItem.name === "Repos" &&
|
||||
stats?.cards?.totalRepos !==
|
||||
undefined && (
|
||||
<span className="ml-2 inline-flex items-center justify-center px-1.5 py-0.5 text-xs rounded bg-secondary-100 text-secondary-700">
|
||||
{stats.cards.totalRepos}
|
||||
</span>
|
||||
)} */}
|
||||
{subItem.comingSoon && (
|
||||
<span className="text-xs bg-secondary-100 text-secondary-600 px-1.5 py-0.5 rounded">
|
||||
Soon
|
||||
@@ -1048,70 +1123,50 @@ const Layout = ({ children }) => {
|
||||
)}
|
||||
|
||||
{/* External Links Section - Roadmap, Documentation, Email, Website */}
|
||||
<div className="border-t border-secondary-200 dark:border-secondary-600 pt-4 mt-4">
|
||||
<div
|
||||
className={`flex ${sidebarCollapsed ? "flex-col items-center gap-2" : "flex-wrap gap-2 -mx-2 px-2"}`}
|
||||
>
|
||||
{/* Roadmap */}
|
||||
<a
|
||||
href="https://github.com/orgs/PatchMon/projects/2/views/1"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={`flex items-center justify-center ${sidebarCollapsed ? "w-10 h-10" : "flex-1 min-w-[calc(50%-0.25rem)] max-w-[calc(50%-0.25rem)] h-10"} bg-secondary-50 dark:bg-secondary-800 text-secondary-600 dark:text-secondary-300 hover:bg-secondary-100 dark:hover:bg-secondary-700 rounded-lg transition-colors`}
|
||||
title={sidebarCollapsed ? "Roadmap" : ""}
|
||||
>
|
||||
<Route className="h-5 w-5" />
|
||||
{!sidebarCollapsed && (
|
||||
<span className="ml-2 text-sm font-medium truncate">
|
||||
Roadmap
|
||||
</span>
|
||||
)}
|
||||
</a>
|
||||
{/* Documentation */}
|
||||
<a
|
||||
href="https://docs.patchmon.net"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={`flex items-center justify-center ${sidebarCollapsed ? "w-10 h-10" : "flex-1 min-w-[calc(50%-0.25rem)] max-w-[calc(50%-0.25rem)] h-10"} bg-secondary-50 dark:bg-secondary-800 text-secondary-600 dark:text-secondary-300 hover:bg-secondary-100 dark:hover:bg-secondary-700 rounded-lg transition-colors`}
|
||||
title={sidebarCollapsed ? "Documentation" : ""}
|
||||
>
|
||||
<BookOpen className="h-5 w-5" />
|
||||
{!sidebarCollapsed && (
|
||||
<span className="ml-2 text-sm font-medium truncate">
|
||||
Docs
|
||||
</span>
|
||||
)}
|
||||
</a>
|
||||
{/* Email */}
|
||||
<a
|
||||
href="mailto:support@patchmon.net"
|
||||
className={`flex items-center justify-center ${sidebarCollapsed ? "w-10 h-10" : "flex-1 min-w-[calc(50%-0.25rem)] max-w-[calc(50%-0.25rem)] h-10"} bg-secondary-50 dark:bg-secondary-800 text-secondary-600 dark:text-secondary-300 hover:bg-secondary-100 dark:hover:bg-secondary-700 rounded-lg transition-colors`}
|
||||
title={sidebarCollapsed ? "Email Support" : ""}
|
||||
>
|
||||
<Mail className="h-5 w-5" />
|
||||
{!sidebarCollapsed && (
|
||||
<span className="ml-2 text-sm font-medium truncate">
|
||||
Email
|
||||
</span>
|
||||
)}
|
||||
</a>
|
||||
{/* Website */}
|
||||
<a
|
||||
href="https://patchmon.net"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={`flex items-center justify-center ${sidebarCollapsed ? "w-10 h-10" : "flex-1 min-w-[calc(50%-0.25rem)] max-w-[calc(50%-0.25rem)] h-10"} bg-secondary-50 dark:bg-secondary-800 text-secondary-600 dark:text-secondary-300 hover:bg-secondary-100 dark:hover:bg-secondary-700 rounded-lg transition-colors`}
|
||||
title={sidebarCollapsed ? "Website" : ""}
|
||||
>
|
||||
<Globe className="h-5 w-5" />
|
||||
{!sidebarCollapsed && (
|
||||
<span className="ml-2 text-sm font-medium truncate">
|
||||
Website
|
||||
</span>
|
||||
)}
|
||||
</a>
|
||||
{!sidebarCollapsed && (
|
||||
<div className="border-t border-secondary-200 dark:border-secondary-600 pt-4 mt-4">
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
{/* Roadmap */}
|
||||
<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-secondary-50 dark:bg-secondary-800 text-secondary-600 dark:text-secondary-300 hover:bg-secondary-100 dark:hover:bg-secondary-700 rounded-lg transition-colors"
|
||||
title="Roadmap"
|
||||
>
|
||||
<Route className="h-5 w-5" />
|
||||
</a>
|
||||
{/* Documentation */}
|
||||
<a
|
||||
href="https://docs.patchmon.net"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center justify-center w-10 h-10 bg-secondary-50 dark:bg-secondary-800 text-secondary-600 dark:text-secondary-300 hover:bg-secondary-100 dark:hover:bg-secondary-700 rounded-lg transition-colors"
|
||||
title="Documentation"
|
||||
>
|
||||
<BookOpen className="h-5 w-5" />
|
||||
</a>
|
||||
{/* Email */}
|
||||
<a
|
||||
href="mailto:support@patchmon.net"
|
||||
className="flex items-center justify-center w-10 h-10 bg-secondary-50 dark:bg-secondary-800 text-secondary-600 dark:text-secondary-300 hover:bg-secondary-100 dark:hover:bg-secondary-700 rounded-lg transition-colors"
|
||||
title="Email Support"
|
||||
>
|
||||
<Mail className="h-5 w-5" />
|
||||
</a>
|
||||
{/* Website */}
|
||||
<a
|
||||
href="https://patchmon.net"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center justify-center w-10 h-10 bg-secondary-50 dark:bg-secondary-800 text-secondary-600 dark:text-secondary-300 hover:bg-secondary-100 dark:hover:bg-secondary-700 rounded-lg transition-colors"
|
||||
title="Website"
|
||||
>
|
||||
<Globe className="h-5 w-5" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</nav>
|
||||
|
||||
{/* Profile Section - Bottom of Sidebar */}
|
||||
@@ -1455,7 +1510,7 @@ const Layout = ({ children }) => {
|
||||
href="https://github.com/PatchMon/PatchMon"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center justify-center gap-1.5 px-3 py-2 bg-gray-50 dark:bg-transparent text-secondary-600 dark:text-secondary-300 hover:bg-gray-100 dark:hover:bg-white/10 rounded-lg transition-colors shadow-sm group relative"
|
||||
className="flex items-center justify-center gap-1.5 w-auto px-2.5 h-10 bg-gray-50 dark:bg-transparent text-secondary-600 dark:text-secondary-300 hover:bg-gray-100 dark:hover:bg-white/10 rounded-lg transition-colors shadow-sm group relative"
|
||||
style={{
|
||||
backgroundColor: "var(--button-bg, rgb(249, 250, 251))",
|
||||
backdropFilter: "var(--button-blur, none)",
|
||||
|
||||
@@ -14,16 +14,32 @@ const Logo = ({
|
||||
queryFn: () => settingsAPI.get().then((res) => res.data),
|
||||
});
|
||||
|
||||
// Helper function to encode logo path for URLs (handles spaces and special characters)
|
||||
const encodeLogoPath = (path) => {
|
||||
if (!path) return path;
|
||||
// Split path into directory and filename
|
||||
const parts = path.split("/");
|
||||
const filename = parts.pop();
|
||||
const directory = parts.join("/");
|
||||
// Encode only the filename part, keep directory structure
|
||||
return directory
|
||||
? `${directory}/${encodeURIComponent(filename)}`
|
||||
: encodeURIComponent(filename);
|
||||
};
|
||||
|
||||
// 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";
|
||||
|
||||
// Encode the path to handle spaces and special characters
|
||||
const encodedLogoSrc = encodeLogoPath(logoSrc);
|
||||
|
||||
// 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}`;
|
||||
const logoSrcWithCache = `${encodedLogoSrc}?v=${cacheBuster}`;
|
||||
|
||||
return (
|
||||
<img
|
||||
|
||||
@@ -8,11 +8,23 @@ const LogoProvider = ({ children }) => {
|
||||
// Use custom favicon or fallback to default
|
||||
const faviconUrl = settings?.favicon || "/assets/favicon.svg";
|
||||
|
||||
// Encode the path to handle spaces and special characters
|
||||
const encodeLogoPath = (path) => {
|
||||
if (!path) return path;
|
||||
const parts = path.split("/");
|
||||
const filename = parts.pop();
|
||||
const directory = parts.join("/");
|
||||
return directory
|
||||
? `${directory}/${encodeURIComponent(filename)}`
|
||||
: encodeURIComponent(filename);
|
||||
};
|
||||
const encodedFaviconUrl = encodeLogoPath(faviconUrl);
|
||||
|
||||
// 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}`;
|
||||
const faviconUrlWithCache = `${encodedFaviconUrl}?v=${cacheBuster}`;
|
||||
|
||||
// Update favicon
|
||||
const favicon = document.querySelector('link[rel="icon"]');
|
||||
|
||||
@@ -25,19 +25,45 @@ const BrandingTab = () => {
|
||||
queryFn: () => settingsAPI.get().then((res) => res.data),
|
||||
});
|
||||
|
||||
// Helper function to encode logo path for URLs (handles spaces and special characters)
|
||||
const encodeLogoPath = (path) => {
|
||||
if (!path) return path;
|
||||
// Split path into directory and filename
|
||||
const parts = path.split("/");
|
||||
const filename = parts.pop();
|
||||
const directory = parts.join("/");
|
||||
// Encode only the filename part, keep directory structure
|
||||
return directory
|
||||
? `${directory}/${encodeURIComponent(filename)}`
|
||||
: encodeURIComponent(filename);
|
||||
};
|
||||
|
||||
// Logo upload mutation
|
||||
const uploadLogoMutation = useMutation({
|
||||
mutationFn: ({ logoType, fileContent, fileName }) =>
|
||||
fetch("/api/v1/settings/logos/upload", {
|
||||
mutationFn: async ({ logoType, fileContent, fileName }) => {
|
||||
const res = await 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) => {
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
throw new Error(data.error || data.details || "Failed to upload logo");
|
||||
}
|
||||
return data;
|
||||
},
|
||||
onSuccess: async (data, variables) => {
|
||||
// Invalidate and refetch settings to get updated timestamp
|
||||
queryClient.invalidateQueries(["settings"]);
|
||||
// Wait for refetch to complete before closing modal
|
||||
try {
|
||||
await queryClient.refetchQueries(["settings"]);
|
||||
} catch (error) {
|
||||
// Continue anyway - settings will update on next render
|
||||
}
|
||||
setLogoUploadState((prev) => ({
|
||||
...prev,
|
||||
[variables.logoType]: { uploading: false, error: null },
|
||||
@@ -133,7 +159,12 @@ const BrandingTab = () => {
|
||||
</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()}`}
|
||||
key={`dark-${settings?.logo_dark}-${settings?.updated_at}`}
|
||||
src={`${encodeLogoPath(settings?.logo_dark || "/assets/logo_dark.png")}?v=${
|
||||
settings?.updated_at
|
||||
? new Date(settings.updated_at).getTime()
|
||||
: Date.now()
|
||||
}`}
|
||||
alt="Dark Logo"
|
||||
className="max-h-16 max-w-full object-contain"
|
||||
onError={(e) => {
|
||||
@@ -194,7 +225,12 @@ const BrandingTab = () => {
|
||||
</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()}`}
|
||||
key={`light-${settings?.logo_light}-${settings?.updated_at}`}
|
||||
src={`${encodeLogoPath(settings?.logo_light || "/assets/logo_light.png")}?v=${
|
||||
settings?.updated_at
|
||||
? new Date(settings.updated_at).getTime()
|
||||
: Date.now()
|
||||
}`}
|
||||
alt="Light Logo"
|
||||
className="max-h-16 max-w-full object-contain"
|
||||
onError={(e) => {
|
||||
@@ -255,7 +291,12 @@ const BrandingTab = () => {
|
||||
</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()}`}
|
||||
key={`favicon-${settings?.favicon}-${settings?.updated_at}`}
|
||||
src={`${encodeLogoPath(settings?.favicon || "/assets/favicon.svg")}?v=${
|
||||
settings?.updated_at
|
||||
? new Date(settings.updated_at).getTime()
|
||||
: Date.now()
|
||||
}`}
|
||||
alt="Favicon"
|
||||
className="h-8 w-8 object-contain"
|
||||
onError={(e) => {
|
||||
|
||||
@@ -1604,7 +1604,7 @@ const Dashboard = () => {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowSettingsModal(true)}
|
||||
className="hidden md:flex btn-outline items-center gap-2"
|
||||
className="hidden md:flex btn-outline items-center gap-2 min-h-[44px] min-w-[44px] justify-center"
|
||||
title="Customize dashboard layout"
|
||||
>
|
||||
<Settings className="h-4 w-4" />
|
||||
|
||||
@@ -881,7 +881,7 @@ const HostDetail = () => {
|
||||
<p className="text-xs text-secondary-500 dark:text-secondary-300 mb-3">
|
||||
Network Interfaces
|
||||
</p>
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{host.network_interfaces.map((iface) => (
|
||||
<div
|
||||
key={iface.name}
|
||||
@@ -1812,7 +1812,7 @@ const HostDetail = () => {
|
||||
<Wifi className="h-4 w-4 text-primary-600 dark:text-primary-400" />
|
||||
Network Interfaces
|
||||
</h4>
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{host.network_interfaces.map((iface) => (
|
||||
<div
|
||||
key={iface.name}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
AlertCircle,
|
||||
ArrowLeft,
|
||||
@@ -12,7 +13,6 @@ import {
|
||||
Star,
|
||||
User,
|
||||
} from "lucide-react";
|
||||
|
||||
import { useEffect, useId, useRef, useState } from "react";
|
||||
import { FaLinkedin, FaYoutube } from "react-icons/fa";
|
||||
|
||||
@@ -20,9 +20,18 @@ import { useNavigate } from "react-router-dom";
|
||||
import DiscordIcon from "../components/DiscordIcon";
|
||||
import { useAuth } from "../contexts/AuthContext";
|
||||
import { useColorTheme } from "../contexts/ColorThemeContext";
|
||||
import { authAPI, isCorsError } from "../utils/api";
|
||||
import { authAPI, isCorsError, settingsAPI } from "../utils/api";
|
||||
|
||||
const Login = () => {
|
||||
// Helper function to format numbers in k format (e.g., 1704 -> 1.8k)
|
||||
const formatNumber = (num) => {
|
||||
if (num >= 1000) {
|
||||
const rounded = Math.ceil((num / 1000) * 10) / 10; // Round up to 1 decimal place
|
||||
return `${rounded.toFixed(1)}K`;
|
||||
}
|
||||
return num.toString();
|
||||
};
|
||||
|
||||
const usernameId = useId();
|
||||
const firstNameId = useId();
|
||||
const lastNameId = useId();
|
||||
@@ -64,6 +73,12 @@ const Login = () => {
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
// Fetch settings for favicon
|
||||
const { data: settings } = useQuery({
|
||||
queryKey: ["settings"],
|
||||
queryFn: () => settingsAPI.get().then((res) => res.data),
|
||||
});
|
||||
|
||||
// Generate clean radial gradient background with subtle triangular accents
|
||||
useEffect(() => {
|
||||
const generateBackground = () => {
|
||||
@@ -204,13 +219,29 @@ const Login = () => {
|
||||
const statsResponse = await fetch("/api/v1/social-media-stats");
|
||||
if (statsResponse.ok) {
|
||||
const statsData = await statsResponse.json();
|
||||
setSocialMediaStats({
|
||||
github_stars: statsData.github_stars,
|
||||
discord_members: statsData.discord_members,
|
||||
buymeacoffee_supporters: statsData.buymeacoffee_supporters,
|
||||
youtube_subscribers: statsData.youtube_subscribers,
|
||||
linkedin_followers: statsData.linkedin_followers,
|
||||
});
|
||||
// Only update stats that are not null - preserve existing values if fetch failed
|
||||
setSocialMediaStats((prev) => ({
|
||||
github_stars:
|
||||
statsData.github_stars !== null
|
||||
? statsData.github_stars
|
||||
: prev.github_stars,
|
||||
discord_members:
|
||||
statsData.discord_members !== null
|
||||
? statsData.discord_members
|
||||
: prev.discord_members,
|
||||
buymeacoffee_supporters:
|
||||
statsData.buymeacoffee_supporters !== null
|
||||
? statsData.buymeacoffee_supporters
|
||||
: prev.buymeacoffee_supporters,
|
||||
youtube_subscribers:
|
||||
statsData.youtube_subscribers !== null
|
||||
? statsData.youtube_subscribers
|
||||
: prev.youtube_subscribers,
|
||||
linkedin_followers:
|
||||
statsData.linkedin_followers !== null
|
||||
? statsData.linkedin_followers
|
||||
: prev.linkedin_followers,
|
||||
}));
|
||||
}
|
||||
|
||||
// Use cache if less than 1 hour old
|
||||
@@ -572,12 +603,60 @@ const Login = () => {
|
||||
<div className="flex items-center gap-1">
|
||||
<Star className="h-3.5 w-3.5 fill-current text-yellow-400" />
|
||||
<span className="text-sm font-medium text-white">
|
||||
{socialMediaStats.github_stars}
|
||||
{formatNumber(socialMediaStats.github_stars)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</a>
|
||||
|
||||
{/* Discord */}
|
||||
<a
|
||||
href="https://patchmon.net/discord"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center justify-center gap-1.5 px-3 h-10 bg-white/10 hover:bg-white/20 backdrop-blur-sm rounded-lg transition-colors border border-white/10"
|
||||
title="Discord Community"
|
||||
>
|
||||
<DiscordIcon className="h-5 w-5 text-white" />
|
||||
{socialMediaStats.discord_members !== null && (
|
||||
<span className="text-sm font-medium text-white">
|
||||
{socialMediaStats.discord_members}
|
||||
</span>
|
||||
)}
|
||||
</a>
|
||||
|
||||
{/* LinkedIn */}
|
||||
<a
|
||||
href="https://linkedin.com/company/patchmon"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center justify-center gap-1.5 px-3 h-10 bg-white/10 hover:bg-white/20 backdrop-blur-sm rounded-lg transition-colors border border-white/10"
|
||||
title="LinkedIn Company Page"
|
||||
>
|
||||
<FaLinkedin className="h-5 w-5 text-[#0077B5]" />
|
||||
{socialMediaStats.linkedin_followers !== null && (
|
||||
<span className="text-sm font-medium text-white">
|
||||
{socialMediaStats.linkedin_followers}
|
||||
</span>
|
||||
)}
|
||||
</a>
|
||||
|
||||
{/* YouTube */}
|
||||
<a
|
||||
href="https://youtube.com/@patchmonTV"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center justify-center gap-1.5 px-3 h-10 bg-white/10 hover:bg-white/20 backdrop-blur-sm rounded-lg transition-colors border border-white/10"
|
||||
title="YouTube Channel"
|
||||
>
|
||||
<FaYoutube className="h-5 w-5 text-[#FF0000]" />
|
||||
{socialMediaStats.youtube_subscribers !== null && (
|
||||
<span className="text-sm font-medium text-white">
|
||||
{socialMediaStats.youtube_subscribers}
|
||||
</span>
|
||||
)}
|
||||
</a>
|
||||
|
||||
{/* Roadmap */}
|
||||
<a
|
||||
href="https://github.com/orgs/PatchMon/projects/2/views/1"
|
||||
@@ -589,7 +668,7 @@ const Login = () => {
|
||||
<Route className="h-5 w-5 text-white" />
|
||||
</a>
|
||||
|
||||
{/* Docs */}
|
||||
{/* Documentation */}
|
||||
<a
|
||||
href="https://docs.patchmon.net"
|
||||
target="_blank"
|
||||
@@ -600,48 +679,6 @@ const Login = () => {
|
||||
<BookOpen className="h-5 w-5 text-white" />
|
||||
</a>
|
||||
|
||||
{/* Discord */}
|
||||
<a
|
||||
href="https://patchmon.net/discord"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center justify-center w-10 h-10 bg-white/10 hover:bg-white/20 backdrop-blur-sm rounded-lg transition-colors border border-white/10"
|
||||
title="Discord Community"
|
||||
>
|
||||
<DiscordIcon className="h-5 w-5 text-white" />
|
||||
</a>
|
||||
|
||||
{/* Email */}
|
||||
<a
|
||||
href="mailto:support@patchmon.net"
|
||||
className="flex items-center justify-center w-10 h-10 bg-white/10 hover:bg-white/20 backdrop-blur-sm rounded-lg transition-colors border border-white/10"
|
||||
title="Email Support"
|
||||
>
|
||||
<Mail className="h-5 w-5 text-white" />
|
||||
</a>
|
||||
|
||||
{/* YouTube */}
|
||||
<a
|
||||
href="https://youtube.com/@patchmonTV"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center justify-center w-10 h-10 bg-white/10 hover:bg-white/20 backdrop-blur-sm rounded-lg transition-colors border border-white/10"
|
||||
title="YouTube Channel"
|
||||
>
|
||||
<FaYoutube className="h-5 w-5 text-[#FF0000]" />
|
||||
</a>
|
||||
|
||||
{/* Reddit */}
|
||||
<a
|
||||
href="https://linkedin.com/company/patchmon"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center justify-center w-10 h-10 bg-white/10 hover:bg-white/20 backdrop-blur-sm rounded-lg transition-colors border border-white/10"
|
||||
title="LinkedIn Company Page"
|
||||
>
|
||||
<FaLinkedin className="h-5 w-5 text-[#0077B5]" />
|
||||
</a>
|
||||
|
||||
{/* Website */}
|
||||
<a
|
||||
href="https://patchmon.net"
|
||||
@@ -667,9 +704,28 @@ const Login = () => {
|
||||
<div>
|
||||
<div className="mx-auto h-16 w-16 flex items-center justify-center">
|
||||
<img
|
||||
src="/assets/favicon.svg"
|
||||
src={
|
||||
settings?.favicon
|
||||
? `${(() => {
|
||||
const parts = settings.favicon.split("/");
|
||||
const filename = parts.pop();
|
||||
const directory = parts.join("/");
|
||||
const encodedPath = directory
|
||||
? `${directory}/${encodeURIComponent(filename)}`
|
||||
: encodeURIComponent(filename);
|
||||
return `${encodedPath}?v=${
|
||||
settings?.updated_at
|
||||
? new Date(settings.updated_at).getTime()
|
||||
: Date.now()
|
||||
}`;
|
||||
})()}`
|
||||
: "/assets/favicon.svg"
|
||||
}
|
||||
alt="PatchMon Logo"
|
||||
className="h-16 w-16"
|
||||
onError={(e) => {
|
||||
e.target.src = "/assets/favicon.svg";
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<h2 className="mt-6 text-center text-3xl font-extrabold text-secondary-900 dark:text-secondary-100">
|
||||
@@ -880,9 +936,20 @@ const Login = () => {
|
||||
<div className="text-center">
|
||||
<div className="mx-auto h-16 w-16 flex items-center justify-center">
|
||||
<img
|
||||
src="/assets/favicon.svg"
|
||||
src={
|
||||
settings?.favicon
|
||||
? `${settings.favicon}?v=${
|
||||
settings?.updated_at
|
||||
? new Date(settings.updated_at).getTime()
|
||||
: Date.now()
|
||||
}`
|
||||
: "/assets/favicon.svg"
|
||||
}
|
||||
alt="PatchMon Logo"
|
||||
className="h-16 w-16"
|
||||
onError={(e) => {
|
||||
e.target.src = "/assets/favicon.svg";
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<h3 className="mt-4 text-lg font-medium text-secondary-900 dark:text-secondary-100">
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
Eye as EyeIcon,
|
||||
EyeOff as EyeOffIcon,
|
||||
GripVertical,
|
||||
Info,
|
||||
Package,
|
||||
RefreshCw,
|
||||
Search,
|
||||
@@ -29,6 +30,7 @@ const Packages = () => {
|
||||
const [sortField, setSortField] = useState("name");
|
||||
const [sortDirection, setSortDirection] = useState("asc");
|
||||
const [showColumnSettings, setShowColumnSettings] = useState(false);
|
||||
const [descriptionModal, setDescriptionModal] = useState(null); // { packageName, description }
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(() => {
|
||||
const saved = localStorage.getItem("packages-page-size");
|
||||
@@ -168,13 +170,18 @@ const Packages = () => {
|
||||
}, [packagesResponse]);
|
||||
|
||||
// Fetch dashboard stats for card counts (consistent with homepage)
|
||||
const { data: dashboardStats } = useQuery({
|
||||
const { data: dashboardStats, refetch: refetchDashboardStats } = useQuery({
|
||||
queryKey: ["dashboardStats"],
|
||||
queryFn: () => dashboardAPI.getStats().then((res) => res.data),
|
||||
staleTime: 5 * 60 * 1000, // Data stays fresh for 5 minutes
|
||||
refetchOnWindowFocus: false, // Don't refetch when window regains focus
|
||||
});
|
||||
|
||||
// Handle refresh - refetch all related data
|
||||
const handleRefresh = async () => {
|
||||
await Promise.all([refetch(), refetchDashboardStats()]);
|
||||
};
|
||||
|
||||
// Fetch hosts data to get total packages count
|
||||
const { data: hosts } = useQuery({
|
||||
queryKey: ["hosts"],
|
||||
@@ -363,28 +370,41 @@ const Packages = () => {
|
||||
switch (column.id) {
|
||||
case "name":
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate(`/packages/${pkg.id}`)}
|
||||
className="flex items-center text-left hover:bg-secondary-100 dark:hover:bg-secondary-700 rounded p-2 -m-2 transition-colors group w-full"
|
||||
>
|
||||
<Package className="h-5 w-5 text-secondary-400 mr-3 flex-shrink-0" />
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-medium text-secondary-900 dark:text-white group-hover:text-primary-600 dark:group-hover:text-primary-400">
|
||||
{pkg.name}
|
||||
<div className="flex items-center text-left w-full">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate(`/packages/${pkg.id}`)}
|
||||
className="flex items-center text-left hover:bg-secondary-100 dark:hover:bg-secondary-700 rounded p-2 -m-2 transition-colors group flex-1"
|
||||
>
|
||||
<Package className="h-5 w-5 text-secondary-400 mr-3 flex-shrink-0" />
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-medium text-secondary-900 dark:text-white group-hover:text-primary-600 dark:group-hover:text-primary-400">
|
||||
{pkg.name}
|
||||
</div>
|
||||
{pkg.category && (
|
||||
<div className="text-xs text-secondary-400 dark:text-secondary-400">
|
||||
Category: {pkg.category}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{pkg.description && (
|
||||
<div className="text-sm text-secondary-600 dark:text-secondary-400">
|
||||
{pkg.description}
|
||||
</div>
|
||||
)}
|
||||
{pkg.category && (
|
||||
<div className="text-xs text-secondary-400 dark:text-secondary-400">
|
||||
Category: {pkg.category}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
</button>
|
||||
{pkg.description && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setDescriptionModal({
|
||||
packageName: pkg.name,
|
||||
description: pkg.description,
|
||||
});
|
||||
}}
|
||||
className="ml-1 flex-shrink-0 p-1 hover:bg-secondary-100 dark:hover:bg-secondary-700 rounded transition-colors"
|
||||
title="View description"
|
||||
>
|
||||
<Info className="h-4 w-4 text-secondary-400 hover:text-secondary-600 dark:hover:text-secondary-300" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
case "packageHosts": {
|
||||
// Show total number of hosts where this package is installed
|
||||
@@ -526,17 +546,17 @@ const Packages = () => {
|
||||
<h1 className="text-2xl font-semibold text-secondary-900 dark:text-white">
|
||||
Packages
|
||||
</h1>
|
||||
<p className="text-sm text-secondary-600 dark:text-secondary-400 mt-1">
|
||||
<p className="text-sm text-secondary-600 dark:text-white mt-1">
|
||||
Manage package updates and security patches
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => refetch()}
|
||||
onClick={handleRefresh}
|
||||
disabled={isFetching}
|
||||
className="btn-outline flex items-center gap-2"
|
||||
title="Refresh packages data"
|
||||
title="Refresh packages and statistics data"
|
||||
>
|
||||
<RefreshCw
|
||||
className={`h-4 w-4 ${isFetching ? "animate-spin" : ""}`}
|
||||
@@ -755,27 +775,36 @@ const Packages = () => {
|
||||
{paginatedPackages.map((pkg) => (
|
||||
<div key={pkg.id} className="card p-4 space-y-3">
|
||||
{/* Package Name */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate(`/packages/${pkg.id}`)}
|
||||
className="text-left w-full"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Package className="h-5 w-5 text-secondary-400 flex-shrink-0" />
|
||||
<div className="text-base font-semibold text-secondary-900 dark:text-white hover:text-primary-600 dark:hover:text-primary-400">
|
||||
{pkg.name}
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate(`/packages/${pkg.id}`)}
|
||||
className="text-left flex-1"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Package className="h-5 w-5 text-secondary-400 flex-shrink-0" />
|
||||
<div className="text-base font-semibold text-secondary-900 dark:text-white hover:text-primary-600 dark:hover:text-primary-400">
|
||||
{pkg.name}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Description (only rendered if a description exists) */}
|
||||
{pkg.description && (
|
||||
<div className="text-sm text-secondary-600 dark:text-secondary-400 bg-secondary-50 dark:bg-secondary-800/50 rounded-md p-2 border border-secondary-200 dark:border-secondary-700/50">
|
||||
<div className="line-clamp-2 whitespace-normal">
|
||||
{pkg.description}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
{pkg.description && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setDescriptionModal({
|
||||
packageName: pkg.name,
|
||||
description: pkg.description,
|
||||
});
|
||||
}}
|
||||
className="flex-shrink-0 p-1 hover:bg-secondary-100 dark:hover:bg-secondary-700 rounded transition-colors"
|
||||
title="View description"
|
||||
>
|
||||
<Info className="h-4 w-4 text-secondary-400 hover:text-secondary-600 dark:hover:text-secondary-300" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Status and Hosts on same line */}
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
@@ -961,6 +990,48 @@ const Packages = () => {
|
||||
onReset={resetColumns}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Description Modal */}
|
||||
{descriptionModal && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDescriptionModal(null)}
|
||||
className="fixed inset-0 cursor-default"
|
||||
aria-label="Close modal"
|
||||
/>
|
||||
<div className="bg-white dark:bg-secondary-800 rounded-lg shadow-xl max-w-lg w-full mx-4 relative z-10">
|
||||
<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">
|
||||
{descriptionModal.packageName}
|
||||
</h3>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDescriptionModal(null)}
|
||||
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>
|
||||
<div className="px-6 py-4">
|
||||
<p className="text-sm text-secondary-700 dark:text-secondary-300 whitespace-pre-wrap">
|
||||
{descriptionModal.description}
|
||||
</p>
|
||||
</div>
|
||||
<div className="px-6 py-4 border-t border-secondary-200 dark:border-secondary-600 flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDescriptionModal(null)}
|
||||
className="px-4 py-2 text-sm font-medium text-white bg-primary-600 rounded-md hover:bg-primary-700"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user