mirror of
https://github.com/MengMengCode/CLICD.git
synced 2026-08-05 05:36:07 +08:00
release: v1.0.7
This commit is contained in:
@@ -9,6 +9,7 @@ import (
|
|||||||
|
|
||||||
"clicd/internal/config"
|
"clicd/internal/config"
|
||||||
"clicd/internal/lxc"
|
"clicd/internal/lxc"
|
||||||
|
"clicd/internal/version"
|
||||||
)
|
)
|
||||||
|
|
||||||
var lxcManager = lxc.NewManager()
|
var lxcManager = lxc.NewManager()
|
||||||
@@ -451,3 +452,14 @@ func deletePortMapping(w http.ResponseWriter, r *http.Request, id int, indexStr
|
|||||||
}
|
}
|
||||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: mappings})
|
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: mappings})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// HandleVersion returns the current CLICD version.
|
||||||
|
func HandleVersion(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodGet {
|
||||||
|
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: map[string]string{
|
||||||
|
"version": version.Current(),
|
||||||
|
}})
|
||||||
|
}
|
||||||
|
|||||||
@@ -112,6 +112,9 @@ func setupRoutes(mux *http.ServeMux) {
|
|||||||
mux.HandleFunc("/api/api-keys", corsMiddleware(api.AdminMiddleware(api.HandleApiKeys)))
|
mux.HandleFunc("/api/api-keys", corsMiddleware(api.AdminMiddleware(api.HandleApiKeys)))
|
||||||
mux.HandleFunc("/api/api-keys/", corsMiddleware(api.AdminMiddleware(api.HandleApiKeyDelete)))
|
mux.HandleFunc("/api/api-keys/", corsMiddleware(api.AdminMiddleware(api.HandleApiKeyDelete)))
|
||||||
|
|
||||||
|
// Version (public)
|
||||||
|
mux.HandleFunc("/api/version", corsMiddleware(api.HandleVersion))
|
||||||
|
|
||||||
// Static files
|
// Static files
|
||||||
if webFS != nil {
|
if webFS != nil {
|
||||||
fs := http.FileServer(webFS)
|
fs := http.FileServer(webFS)
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
package version
|
package version
|
||||||
|
|
||||||
var (
|
var (
|
||||||
Version = "1.0.6"
|
Version = "1.0.7"
|
||||||
Repo = "MengMengCode/CLICD"
|
Repo = "MengMengCode/CLICD"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -12,3 +12,4 @@ func Current() string {
|
|||||||
return Version
|
return Version
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+9
-1
@@ -5,8 +5,16 @@
|
|||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/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>CLICD - LXC Container Manager</title>
|
<title>CLICD - LXC Container Manager</title>
|
||||||
|
<script>
|
||||||
|
(function() {
|
||||||
|
var theme = localStorage.getItem('clicd_theme');
|
||||||
|
if (theme === 'dark' || (!theme && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
|
||||||
|
document.documentElement.classList.add('dark');
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
</head>
|
</head>
|
||||||
<body class="bg-white text-black">
|
<body class="bg-white text-black dark:bg-gray-950 dark:text-white">
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
<script type="module" src="/src/main.tsx"></script>
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -19,8 +19,8 @@ function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
|||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex items-center justify-center bg-white">
|
<div className="min-h-screen flex items-center justify-center bg-white dark:bg-gray-950">
|
||||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-black"></div>
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-black dark:border-white"></div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ export default function Layout() {
|
|||||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(false)
|
const [sidebarCollapsed, setSidebarCollapsed] = useState(false)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50 flex">
|
<div className="min-h-screen bg-gray-50 flex dark:bg-gray-950">
|
||||||
<Sidebar collapsed={sidebarCollapsed} onToggle={() => setSidebarCollapsed(!sidebarCollapsed)} />
|
<Sidebar collapsed={sidebarCollapsed} onToggle={() => setSidebarCollapsed(!sidebarCollapsed)} />
|
||||||
<main className={`flex-1 transition-all duration-300 ${sidebarCollapsed ? 'ml-16' : 'ml-60'}`}>
|
<main className={`flex-1 transition-all duration-300 ${sidebarCollapsed ? 'ml-16' : 'ml-60'}`}>
|
||||||
<div className="p-6">
|
<div className="p-6">
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { ReactNode } from 'react'
|
import { ReactNode } from 'react'
|
||||||
import { RefreshCw } from 'lucide-react'
|
import { RefreshCw } from 'lucide-react'
|
||||||
|
import { useTheme } from '../contexts/ThemeContext'
|
||||||
|
|
||||||
export type StatsRangeKey = '30m' | '1h' | '1d' | '1w'
|
export type StatsRangeKey = '30m' | '1h' | '1d' | '1w'
|
||||||
|
|
||||||
@@ -45,17 +46,19 @@ export default function ResourceStatsPanel({
|
|||||||
charts: ResourceChartConfig[]
|
charts: ResourceChartConfig[]
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<section className="border border-gray-200 rounded-lg bg-white overflow-hidden">
|
<section className="border border-gray-200 dark:border-gray-700 rounded-lg bg-white dark:bg-gray-900 overflow-hidden">
|
||||||
<div className="flex items-center justify-between gap-3 px-4 py-2.5 border-b border-gray-200 bg-white">
|
<div className="flex items-center justify-between gap-3 px-4 py-2.5 border-b border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900">
|
||||||
<h2 className="text-sm font-semibold text-gray-950">统计信息</h2>
|
<h2 className="text-sm font-semibold text-gray-950 dark:text-white">统计信息</h2>
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<div className="inline-flex rounded border border-gray-200 bg-gray-50 p-0.5">
|
<div className="inline-flex rounded border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800 p-0.5">
|
||||||
{(Object.keys(rangeLabels) as StatsRangeKey[]).map((item) => (
|
{(Object.keys(rangeLabels) as StatsRangeKey[]).map((item) => (
|
||||||
<button
|
<button
|
||||||
key={item}
|
key={item}
|
||||||
onClick={() => onRangeChange(item)}
|
onClick={() => onRangeChange(item)}
|
||||||
className={`h-7 px-3 rounded text-xs font-medium transition-colors ${
|
className={`h-7 px-3 rounded text-xs font-medium transition-colors ${
|
||||||
range === item ? 'bg-gray-800 text-white shadow-sm' : 'text-gray-500 hover:text-gray-900'
|
range === item
|
||||||
|
? 'bg-gray-800 text-white shadow-sm dark:bg-white dark:text-black'
|
||||||
|
: 'text-gray-500 hover:text-gray-900 dark:text-gray-400 dark:hover:text-white'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{rangeLabels[item]}
|
{rangeLabels[item]}
|
||||||
@@ -64,7 +67,7 @@ export default function ResourceStatsPanel({
|
|||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={onRefresh}
|
onClick={onRefresh}
|
||||||
className="h-8 w-8 inline-flex items-center justify-center rounded border border-gray-200 text-gray-500 hover:bg-gray-50 hover:text-gray-900"
|
className="h-8 w-8 inline-flex items-center justify-center rounded border border-gray-200 dark:border-gray-700 text-gray-500 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-800 hover:text-gray-900 dark:hover:text-white"
|
||||||
title="刷新"
|
title="刷新"
|
||||||
>
|
>
|
||||||
<RefreshCw className="w-4 h-4" />
|
<RefreshCw className="w-4 h-4" />
|
||||||
@@ -90,11 +93,11 @@ function DetailedChart({ chart, className }: { chart: ResourceChartConfig; class
|
|||||||
<div className={`p-4 ${className}`}>
|
<div className={`p-4 ${className}`}>
|
||||||
<div className="flex items-start justify-between gap-3 mb-2">
|
<div className="flex items-start justify-between gap-3 mb-2">
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center gap-1.5 text-sm font-semibold text-gray-950">
|
<div className="flex items-center gap-1.5 text-sm font-semibold text-gray-950 dark:text-white">
|
||||||
<span className="text-gray-500">{chart.icon}</span>
|
<span className="text-gray-500 dark:text-gray-400">{chart.icon}</span>
|
||||||
<span>{chart.title}</span>
|
<span>{chart.title}</span>
|
||||||
</div>
|
</div>
|
||||||
{chart.detail && <p className="mt-0.5 text-[11px] text-gray-400">{chart.detail}</p>}
|
{chart.detail && <p className="mt-0.5 text-[11px] text-gray-400 dark:text-gray-500">{chart.detail}</p>}
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-3 gap-3 text-right">
|
<div className="grid grid-cols-3 gap-3 text-right">
|
||||||
<Stat label="当前" value={chart.formatValue(chart.current)} />
|
<Stat label="当前" value={chart.formatValue(chart.current)} />
|
||||||
@@ -115,8 +118,8 @@ function DetailedChart({ chart, className }: { chart: ResourceChartConfig; class
|
|||||||
function Stat({ label, value }: { label: string; value: string }) {
|
function Stat({ label, value }: { label: string; value: string }) {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="text-[10px] text-gray-400">{label}</div>
|
<div className="text-[10px] text-gray-400 dark:text-gray-500">{label}</div>
|
||||||
<div className="text-xs font-semibold text-gray-900 tabular-nums whitespace-nowrap">{value}</div>
|
<div className="text-xs font-semibold text-gray-900 dark:text-gray-100 tabular-nums whitespace-nowrap">{value}</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -132,6 +135,9 @@ function LineAreaChart({
|
|||||||
formatValue: (value: number) => string
|
formatValue: (value: number) => string
|
||||||
unitLabel?: string
|
unitLabel?: string
|
||||||
}) {
|
}) {
|
||||||
|
const { theme } = useTheme()
|
||||||
|
const isDark = theme === 'dark'
|
||||||
|
|
||||||
const width = 520
|
const width = 520
|
||||||
const height = 150
|
const height = 150
|
||||||
const left = 50
|
const left = 50
|
||||||
@@ -158,12 +164,20 @@ function LineAreaChart({
|
|||||||
const yTicks = [1, 0.5, 0]
|
const yTicks = [1, 0.5, 0]
|
||||||
const xTicks = [0, 0.5, 1]
|
const xTicks = [0, 0.5, 1]
|
||||||
|
|
||||||
|
// Dark mode colors
|
||||||
|
const gridStroke = isDark ? '#374151' : '#e5e7eb'
|
||||||
|
const gridStrokeV = isDark ? '#1f2937' : '#edf0f2'
|
||||||
|
const axisStroke = isDark ? '#9ca3af' : '#888'
|
||||||
|
const lineStroke = isDark ? '#f9fafb' : '#444'
|
||||||
|
const gradientTop = isDark ? '#f9fafb' : '#555'
|
||||||
|
const gradientBottom = isDark ? '#374151' : '#555'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<svg viewBox={`0 0 ${width} ${height}`} className="w-full h-[140px]" preserveAspectRatio="none">
|
<svg viewBox={`0 0 ${width} ${height}`} className="w-full h-[140px]" preserveAspectRatio="none">
|
||||||
<defs>
|
<defs>
|
||||||
<linearGradient id="resource-chart-fill" x1="0" x2="0" y1="0" y2="1">
|
<linearGradient id="resource-chart-fill" x1="0" x2="0" y1="0" y2="1">
|
||||||
<stop offset="0%" stopColor="#555" stopOpacity="0.25" />
|
<stop offset="0%" stopColor={gradientTop} stopOpacity="0.25" />
|
||||||
<stop offset="100%" stopColor="#555" stopOpacity="0.02" />
|
<stop offset="100%" stopColor={gradientBottom} stopOpacity="0.02" />
|
||||||
</linearGradient>
|
</linearGradient>
|
||||||
</defs>
|
</defs>
|
||||||
|
|
||||||
@@ -171,8 +185,8 @@ function LineAreaChart({
|
|||||||
const y = top + (1 - tick) * innerHeight
|
const y = top + (1 - tick) * innerHeight
|
||||||
return (
|
return (
|
||||||
<g key={tick}>
|
<g key={tick}>
|
||||||
<line x1={left} y1={y} x2={left + innerWidth} y2={y} stroke="#e5e7eb" strokeDasharray="3 3" />
|
<line x1={left} y1={y} x2={left + innerWidth} y2={y} stroke={gridStroke} strokeDasharray="3 3" />
|
||||||
<text x={left - 8} y={y + 3} textAnchor="end" fontSize="10" fill="#888">
|
<text x={left - 8} y={y + 3} textAnchor="end" fontSize="10" fill={axisStroke}>
|
||||||
{formatValue(maxValue * tick)}
|
{formatValue(maxValue * tick)}
|
||||||
</text>
|
</text>
|
||||||
</g>
|
</g>
|
||||||
@@ -184,8 +198,8 @@ function LineAreaChart({
|
|||||||
const ts = minTs + tick * span
|
const ts = minTs + tick * span
|
||||||
return (
|
return (
|
||||||
<g key={tick}>
|
<g key={tick}>
|
||||||
<line x1={x} y1={top} x2={x} y2={top + innerHeight} stroke="#edf0f2" strokeDasharray="3 3" />
|
<line x1={x} y1={top} x2={x} y2={top + innerHeight} stroke={gridStrokeV} strokeDasharray="3 3" />
|
||||||
<text x={x} y={height - 5} textAnchor={tick === 0 ? 'start' : tick === 1 ? 'end' : 'middle'} fontSize="10" fill="#888">
|
<text x={x} y={height - 5} textAnchor={tick === 0 ? 'start' : tick === 1 ? 'end' : 'middle'} fontSize="10" fill={axisStroke}>
|
||||||
{formatTime(ts)}
|
{formatTime(ts)}
|
||||||
</text>
|
</text>
|
||||||
</g>
|
</g>
|
||||||
@@ -193,15 +207,15 @@ function LineAreaChart({
|
|||||||
})}
|
})}
|
||||||
|
|
||||||
{unitLabel && (
|
{unitLabel && (
|
||||||
<text x={left - 45} y={top + 10} fontSize="10" fill="#888">
|
<text x={left - 45} y={top + 10} fontSize="10" fill={axisStroke}>
|
||||||
{unitLabel}
|
{unitLabel}
|
||||||
</text>
|
</text>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<line x1={left} y1={top} x2={left} y2={top + innerHeight} stroke="#888" />
|
<line x1={left} y1={top} x2={left} y2={top + innerHeight} stroke={axisStroke} />
|
||||||
<line x1={left} y1={top + innerHeight} x2={left + innerWidth} y2={top + innerHeight} stroke="#888" />
|
<line x1={left} y1={top + innerHeight} x2={left + innerWidth} y2={top + innerHeight} stroke={axisStroke} />
|
||||||
<polygon points={area} fill="url(#resource-chart-fill)" />
|
<polygon points={area} fill="url(#resource-chart-fill)" />
|
||||||
<polyline points={line} fill="none" stroke="#444" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
<polyline points={line} fill="none" stroke={lineStroke} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
||||||
</svg>
|
</svg>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { ReactNode } from 'react'
|
import type { ReactNode } from 'react'
|
||||||
|
import { useTheme } from '../contexts/ThemeContext'
|
||||||
|
|
||||||
interface RingStatProps {
|
interface RingStatProps {
|
||||||
value: number
|
value: number
|
||||||
@@ -10,11 +11,17 @@ interface RingStatProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function RingStat({ value, max = 100, label, subLabel, size = 120, strokeWidth = 8 }: RingStatProps) {
|
export function RingStat({ value, max = 100, label, subLabel, size = 120, strokeWidth = 8 }: RingStatProps) {
|
||||||
|
const { theme } = useTheme()
|
||||||
|
const isDark = theme === 'dark'
|
||||||
|
|
||||||
const radius = (size - strokeWidth) / 2
|
const radius = (size - strokeWidth) / 2
|
||||||
const circumference = radius * 2 * Math.PI
|
const circumference = radius * 2 * Math.PI
|
||||||
const percentage = Math.min(Math.max(value / max * 100, 0), 100)
|
const percentage = Math.min(Math.max(value / max * 100, 0), 100)
|
||||||
const strokeDashoffset = circumference - (percentage / 100) * circumference
|
const strokeDashoffset = circumference - (percentage / 100) * circumference
|
||||||
|
|
||||||
|
const bgStroke = isDark ? '#374151' : '#f3f4f6'
|
||||||
|
const progressStroke = isDark ? '#f9fafb' : '#000000'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center">
|
<div className="flex flex-col items-center">
|
||||||
<div className="relative" style={{ width: size, height: size }}>
|
<div className="relative" style={{ width: size, height: size }}>
|
||||||
@@ -25,7 +32,7 @@ export function RingStat({ value, max = 100, label, subLabel, size = 120, stroke
|
|||||||
cy={size / 2}
|
cy={size / 2}
|
||||||
r={radius}
|
r={radius}
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke="#f3f4f6"
|
stroke={bgStroke}
|
||||||
strokeWidth={strokeWidth}
|
strokeWidth={strokeWidth}
|
||||||
/>
|
/>
|
||||||
{/* Progress ring */}
|
{/* Progress ring */}
|
||||||
@@ -34,7 +41,7 @@ export function RingStat({ value, max = 100, label, subLabel, size = 120, stroke
|
|||||||
cy={size / 2}
|
cy={size / 2}
|
||||||
r={radius}
|
r={radius}
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke="#000000"
|
stroke={progressStroke}
|
||||||
strokeWidth={strokeWidth}
|
strokeWidth={strokeWidth}
|
||||||
strokeLinecap="round"
|
strokeLinecap="round"
|
||||||
strokeDasharray={circumference}
|
strokeDasharray={circumference}
|
||||||
@@ -44,12 +51,12 @@ export function RingStat({ value, max = 100, label, subLabel, size = 120, stroke
|
|||||||
</svg>
|
</svg>
|
||||||
{/* Center value */}
|
{/* Center value */}
|
||||||
<div className="absolute inset-0 flex flex-col items-center justify-center">
|
<div className="absolute inset-0 flex flex-col items-center justify-center">
|
||||||
<span className="text-2xl font-bold text-black">{value.toFixed(percentage < 1 ? 2 : 1)}%</span>
|
<span className="text-2xl font-bold text-black dark:text-white">{value.toFixed(percentage < 1 ? 2 : 1)}%</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-2 text-center">
|
<div className="mt-2 text-center">
|
||||||
<div className="text-sm font-medium text-gray-800">{label}</div>
|
<div className="text-sm font-medium text-gray-800 dark:text-gray-200">{label}</div>
|
||||||
{subLabel && <div className="text-xs text-gray-400 mt-0.5">{subLabel}</div>}
|
{subLabel && <div className="text-xs text-gray-400 dark:text-gray-500 mt-0.5">{subLabel}</div>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -96,8 +103,8 @@ export default function RingStats({
|
|||||||
const hasSwap = swapTotal > 0
|
const hasSwap = swapTotal > 0
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-white border border-gray-200 rounded-lg p-5">
|
<div className="bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-lg p-5">
|
||||||
<h2 className="text-sm font-semibold text-black mb-4">状态</h2>
|
<h2 className="text-sm font-semibold text-black dark:text-white mb-4">状态</h2>
|
||||||
<div className={`grid ${hasSwap ? 'grid-cols-5' : 'grid-cols-4'} gap-3`}>
|
<div className={`grid ${hasSwap ? 'grid-cols-5' : 'grid-cols-4'} gap-3`}>
|
||||||
<RingStat
|
<RingStat
|
||||||
value={cpuPercent}
|
value={cpuPercent}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
import { useLocation, useNavigate } from 'react-router-dom'
|
import { useLocation, useNavigate } from 'react-router-dom'
|
||||||
import {
|
import {
|
||||||
ChevronLeft,
|
ChevronLeft,
|
||||||
@@ -6,15 +7,19 @@ import {
|
|||||||
Camera,
|
Camera,
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
LogOut,
|
LogOut,
|
||||||
|
Moon,
|
||||||
Package,
|
Package,
|
||||||
Route,
|
Route,
|
||||||
ScrollText,
|
ScrollText,
|
||||||
Server,
|
Server,
|
||||||
Settings2,
|
Settings2,
|
||||||
ShieldAlert,
|
ShieldAlert,
|
||||||
|
Sun,
|
||||||
UserCog,
|
UserCog,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { useAuth } from '../contexts/AuthContext'
|
import { useAuth } from '../contexts/AuthContext'
|
||||||
|
import { useTheme } from '../contexts/ThemeContext'
|
||||||
|
import { getVersion } from '../services/api'
|
||||||
import AppIcon from './AppIcon'
|
import AppIcon from './AppIcon'
|
||||||
|
|
||||||
interface SidebarProps {
|
interface SidebarProps {
|
||||||
@@ -26,6 +31,18 @@ export default function Sidebar({ collapsed, onToggle }: SidebarProps) {
|
|||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
const { logout, isSubUser } = useAuth()
|
const { logout, isSubUser } = useAuth()
|
||||||
|
const { theme, toggleTheme } = useTheme()
|
||||||
|
const [version, setVersion] = useState('')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getVersion()
|
||||||
|
.then(res => {
|
||||||
|
if (res.data?.data?.version) {
|
||||||
|
setVersion(res.data.data.version)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {})
|
||||||
|
}, [])
|
||||||
|
|
||||||
const isContainerPage =
|
const isContainerPage =
|
||||||
location.pathname.startsWith('/containers') ||
|
location.pathname.startsWith('/containers') ||
|
||||||
@@ -42,27 +59,27 @@ export default function Sidebar({ collapsed, onToggle }: SidebarProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<aside
|
<aside
|
||||||
className={`fixed left-0 top-0 h-full bg-white border-r border-gray-200 flex flex-col transition-all duration-300 z-30 ${
|
className={`fixed left-0 top-0 h-full bg-white border-r border-gray-200 flex flex-col transition-all duration-300 z-30 dark:bg-gray-900 dark:border-gray-700 ${
|
||||||
collapsed ? 'w-16' : 'w-60'
|
collapsed ? 'w-16' : 'w-60'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between h-14 px-4 border-b border-gray-200">
|
<div className="flex items-center justify-between h-14 px-4 border-b border-gray-200 dark:border-gray-700">
|
||||||
{!collapsed && (
|
{!collapsed && (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="w-7 h-7 bg-gray-100 rounded flex items-center justify-center">
|
<div className="w-7 h-7 bg-gray-100 rounded flex items-center justify-center dark:bg-gray-800">
|
||||||
<AppIcon className="w-5 h-5" />
|
<AppIcon className="w-5 h-5" />
|
||||||
</div>
|
</div>
|
||||||
<span className="font-bold text-black text-sm">CLICD</span>
|
<span className="font-bold text-black text-sm dark:text-white">CLICD</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{collapsed && (
|
{collapsed && (
|
||||||
<div className="w-7 h-7 bg-gray-100 rounded flex items-center justify-center mx-auto">
|
<div className="w-7 h-7 bg-gray-100 rounded flex items-center justify-center mx-auto dark:bg-gray-800">
|
||||||
<AppIcon className="w-5 h-5" />
|
<AppIcon className="w-5 h-5" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={onToggle}
|
onClick={onToggle}
|
||||||
className="p-1 rounded hover:bg-gray-100 text-gray-500"
|
className="p-1 rounded hover:bg-gray-100 text-gray-500 dark:hover:bg-gray-800 dark:text-gray-400"
|
||||||
title="切换侧边栏"
|
title="切换侧边栏"
|
||||||
>
|
>
|
||||||
{collapsed ? (
|
{collapsed ? (
|
||||||
@@ -79,8 +96,8 @@ export default function Sidebar({ collapsed, onToggle }: SidebarProps) {
|
|||||||
onClick={() => navigate('/')}
|
onClick={() => navigate('/')}
|
||||||
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-md text-sm transition-colors ${
|
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-md text-sm transition-colors ${
|
||||||
location.pathname === '/'
|
location.pathname === '/'
|
||||||
? 'bg-black text-white'
|
? 'bg-black text-white dark:bg-white dark:text-black'
|
||||||
: 'text-gray-700 hover:bg-gray-100'
|
: 'text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-800'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<LayoutDashboard className="w-4 h-4" />
|
<LayoutDashboard className="w-4 h-4" />
|
||||||
@@ -92,8 +109,8 @@ export default function Sidebar({ collapsed, onToggle }: SidebarProps) {
|
|||||||
onClick={() => navigate('/containers')}
|
onClick={() => navigate('/containers')}
|
||||||
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-md text-sm transition-colors ${
|
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-md text-sm transition-colors ${
|
||||||
isContainerPage
|
isContainerPage
|
||||||
? 'bg-black text-white'
|
? 'bg-black text-white dark:bg-white dark:text-black'
|
||||||
: 'text-gray-700 hover:bg-gray-100'
|
: 'text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-800'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<Server className="w-4 h-4" />
|
<Server className="w-4 h-4" />
|
||||||
@@ -105,8 +122,8 @@ export default function Sidebar({ collapsed, onToggle }: SidebarProps) {
|
|||||||
onClick={() => navigate('/images')}
|
onClick={() => navigate('/images')}
|
||||||
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-md text-sm transition-colors ${
|
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-md text-sm transition-colors ${
|
||||||
isImagesPage
|
isImagesPage
|
||||||
? 'bg-black text-white'
|
? 'bg-black text-white dark:bg-white dark:text-black'
|
||||||
: 'text-gray-700 hover:bg-gray-100'
|
: 'text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-800'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<Package className="w-4 h-4" />
|
<Package className="w-4 h-4" />
|
||||||
@@ -120,8 +137,8 @@ export default function Sidebar({ collapsed, onToggle }: SidebarProps) {
|
|||||||
onClick={() => navigate('/oversell')}
|
onClick={() => navigate('/oversell')}
|
||||||
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-md text-sm transition-colors ${
|
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-md text-sm transition-colors ${
|
||||||
isOversellPage
|
isOversellPage
|
||||||
? 'bg-black text-white'
|
? 'bg-black text-white dark:bg-white dark:text-black'
|
||||||
: 'text-gray-700 hover:bg-gray-100'
|
: 'text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-800'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<Settings2 className="w-4 h-4" />
|
<Settings2 className="w-4 h-4" />
|
||||||
@@ -132,8 +149,8 @@ export default function Sidebar({ collapsed, onToggle }: SidebarProps) {
|
|||||||
onClick={() => navigate('/security')}
|
onClick={() => navigate('/security')}
|
||||||
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-md text-sm transition-colors ${
|
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-md text-sm transition-colors ${
|
||||||
isSecurityPage
|
isSecurityPage
|
||||||
? 'bg-black text-white'
|
? 'bg-black text-white dark:bg-white dark:text-black'
|
||||||
: 'text-gray-700 hover:bg-gray-100'
|
: 'text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-800'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<ShieldAlert className="w-4 h-4" />
|
<ShieldAlert className="w-4 h-4" />
|
||||||
@@ -144,8 +161,8 @@ export default function Sidebar({ collapsed, onToggle }: SidebarProps) {
|
|||||||
onClick={() => navigate('/snapshots')}
|
onClick={() => navigate('/snapshots')}
|
||||||
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-md text-sm transition-colors ${
|
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-md text-sm transition-colors ${
|
||||||
isSnapshotsPage
|
isSnapshotsPage
|
||||||
? 'bg-black text-white'
|
? 'bg-black text-white dark:bg-white dark:text-black'
|
||||||
: 'text-gray-700 hover:bg-gray-100'
|
: 'text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-800'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<Camera className="w-4 h-4" />
|
<Camera className="w-4 h-4" />
|
||||||
@@ -156,8 +173,8 @@ export default function Sidebar({ collapsed, onToggle }: SidebarProps) {
|
|||||||
onClick={() => navigate('/routing')}
|
onClick={() => navigate('/routing')}
|
||||||
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-md text-sm transition-colors ${
|
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-md text-sm transition-colors ${
|
||||||
isRoutingPage
|
isRoutingPage
|
||||||
? 'bg-black text-white'
|
? 'bg-black text-white dark:bg-white dark:text-black'
|
||||||
: 'text-gray-700 hover:bg-gray-100'
|
: 'text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-800'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<Route className="w-4 h-4" />
|
<Route className="w-4 h-4" />
|
||||||
@@ -168,8 +185,8 @@ export default function Sidebar({ collapsed, onToggle }: SidebarProps) {
|
|||||||
onClick={() => navigate('/audit-logs')}
|
onClick={() => navigate('/audit-logs')}
|
||||||
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-md text-sm transition-colors ${
|
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-md text-sm transition-colors ${
|
||||||
isAuditLogsPage
|
isAuditLogsPage
|
||||||
? 'bg-black text-white'
|
? 'bg-black text-white dark:bg-white dark:text-black'
|
||||||
: 'text-gray-700 hover:bg-gray-100'
|
: 'text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-800'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<ScrollText className="w-4 h-4" />
|
<ScrollText className="w-4 h-4" />
|
||||||
@@ -180,8 +197,8 @@ export default function Sidebar({ collapsed, onToggle }: SidebarProps) {
|
|||||||
onClick={() => navigate('/api-integration')}
|
onClick={() => navigate('/api-integration')}
|
||||||
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-md text-sm transition-colors ${
|
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-md text-sm transition-colors ${
|
||||||
isApiIntegrationPage
|
isApiIntegrationPage
|
||||||
? 'bg-black text-white'
|
? 'bg-black text-white dark:bg-white dark:text-black'
|
||||||
: 'text-gray-700 hover:bg-gray-100'
|
: 'text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-800'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<Code2 className="w-4 h-4" />
|
<Code2 className="w-4 h-4" />
|
||||||
@@ -192,8 +209,8 @@ export default function Sidebar({ collapsed, onToggle }: SidebarProps) {
|
|||||||
onClick={() => navigate('/settings')}
|
onClick={() => navigate('/settings')}
|
||||||
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-md text-sm transition-colors ${
|
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-md text-sm transition-colors ${
|
||||||
isSettingsPage
|
isSettingsPage
|
||||||
? 'bg-black text-white'
|
? 'bg-black text-white dark:bg-white dark:text-black'
|
||||||
: 'text-gray-700 hover:bg-gray-100'
|
: 'text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-800'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<UserCog className="w-4 h-4" />
|
<UserCog className="w-4 h-4" />
|
||||||
@@ -203,10 +220,36 @@ export default function Sidebar({ collapsed, onToggle }: SidebarProps) {
|
|||||||
)}
|
)}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div className="border-t border-gray-200 p-2">
|
<div className="border-t border-gray-200 dark:border-gray-700 p-2 space-y-1">
|
||||||
|
{/* Theme Toggle */}
|
||||||
|
<button
|
||||||
|
onClick={toggleTheme}
|
||||||
|
className="w-full flex items-center gap-3 px-3 py-2.5 rounded-md text-sm text-gray-600 hover:bg-gray-100 transition-colors dark:text-gray-400 dark:hover:bg-gray-800"
|
||||||
|
title={theme === 'dark' ? '切换亮色模式' : '切换暗黑模式'}
|
||||||
|
>
|
||||||
|
{theme === 'dark' ? (
|
||||||
|
<Sun className="w-4 h-4" />
|
||||||
|
) : (
|
||||||
|
<Moon className="w-4 h-4" />
|
||||||
|
)}
|
||||||
|
{!collapsed && <span>{theme === 'dark' ? '亮色模式' : '暗黑模式'}</span>}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Version */}
|
||||||
|
{version && (
|
||||||
|
<div className={`px-3 py-2 text-xs text-gray-400 dark:text-gray-500 ${collapsed ? 'text-center' : ''}`}>
|
||||||
|
{collapsed ? (
|
||||||
|
<span title={`v${version}`}>v{version.split('.').slice(0, 2).join('.')}</span>
|
||||||
|
) : (
|
||||||
|
<span>v{version}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Logout */}
|
||||||
<button
|
<button
|
||||||
onClick={logout}
|
onClick={logout}
|
||||||
className="w-full flex items-center gap-3 px-3 py-2.5 rounded-md text-sm text-gray-600 hover:bg-gray-100 transition-colors"
|
className="w-full flex items-center gap-3 px-3 py-2.5 rounded-md text-sm text-gray-600 hover:bg-gray-100 transition-colors dark:text-gray-400 dark:hover:bg-gray-800"
|
||||||
>
|
>
|
||||||
<LogOut className="w-4 h-4" />
|
<LogOut className="w-4 h-4" />
|
||||||
{!collapsed && <span>退出登录</span>}
|
{!collapsed && <span>退出登录</span>}
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { createContext, useContext, useEffect, useState, useCallback } from 'react'
|
||||||
|
|
||||||
|
type Theme = 'light' | 'dark'
|
||||||
|
|
||||||
|
interface ThemeContextType {
|
||||||
|
theme: Theme
|
||||||
|
toggleTheme: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const ThemeContext = createContext<ThemeContextType>({ theme: 'light', toggleTheme: () => {} })
|
||||||
|
|
||||||
|
export function ThemeProvider({ children }: { children: React.ReactNode }) {
|
||||||
|
const [theme, setTheme] = useState<Theme>(() => {
|
||||||
|
if (typeof window === 'undefined') return 'light'
|
||||||
|
const stored = localStorage.getItem('clicd_theme') as Theme | null
|
||||||
|
if (stored === 'dark' || stored === 'light') return stored
|
||||||
|
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
|
||||||
|
})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const root = document.documentElement
|
||||||
|
if (theme === 'dark') {
|
||||||
|
root.classList.add('dark')
|
||||||
|
} else {
|
||||||
|
root.classList.remove('dark')
|
||||||
|
}
|
||||||
|
localStorage.setItem('clicd_theme', theme)
|
||||||
|
}, [theme])
|
||||||
|
|
||||||
|
const toggleTheme = useCallback(() => {
|
||||||
|
setTheme(prev => (prev === 'dark' ? 'light' : 'dark'))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ThemeContext.Provider value={{ theme, toggleTheme }}>
|
||||||
|
{children}
|
||||||
|
</ThemeContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTheme() {
|
||||||
|
return useContext(ThemeContext)
|
||||||
|
}
|
||||||
+131
-3
@@ -14,19 +14,147 @@ body {
|
|||||||
color: #000000;
|
color: #000000;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Scrollbar */
|
||||||
::-webkit-scrollbar {
|
::-webkit-scrollbar {
|
||||||
width: 6px;
|
width: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
::-webkit-scrollbar-track {
|
::-webkit-scrollbar-track {
|
||||||
background: #f1f1f1;
|
background: #f1f1f1;
|
||||||
}
|
}
|
||||||
|
|
||||||
::-webkit-scrollbar-thumb {
|
::-webkit-scrollbar-thumb {
|
||||||
background: #888;
|
background: #888;
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
}
|
}
|
||||||
|
|
||||||
::-webkit-scrollbar-thumb:hover {
|
::-webkit-scrollbar-thumb:hover {
|
||||||
background: #555;
|
background: #555;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ============ DARK MODE OVERRIDES ============ */
|
||||||
|
|
||||||
|
.dark ::-webkit-scrollbar-track {
|
||||||
|
background: #1f2937;
|
||||||
|
}
|
||||||
|
.dark ::-webkit-scrollbar-thumb {
|
||||||
|
background: #4b5563;
|
||||||
|
}
|
||||||
|
.dark ::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark body {
|
||||||
|
background-color: #030712;
|
||||||
|
color: #f9fafb;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Background overrides */
|
||||||
|
.dark .bg-white { background-color: #111827 !important; }
|
||||||
|
.dark .bg-gray-50 { background-color: #030712 !important; }
|
||||||
|
.dark .bg-gray-100 { background-color: #1f2937 !important; }
|
||||||
|
.dark .bg-gray-200 { background-color: #374151 !important; }
|
||||||
|
|
||||||
|
/* Text overrides */
|
||||||
|
.dark .text-black { color: #f9fafb !important; }
|
||||||
|
.dark .text-gray-950 { color: #f9fafb !important; }
|
||||||
|
.dark .text-gray-900 { color: #f3f4f6 !important; }
|
||||||
|
.dark .text-gray-800 { color: #e5e7eb !important; }
|
||||||
|
.dark .text-gray-700 { color: #d1d5db !important; }
|
||||||
|
.dark .text-gray-600 { color: #9ca3af !important; }
|
||||||
|
.dark .text-gray-500 { color: #9ca3af !important; }
|
||||||
|
.dark .text-gray-400 { color: #6b7280 !important; }
|
||||||
|
|
||||||
|
/* Border overrides */
|
||||||
|
.dark .border-gray-100 { border-color: #1f2937 !important; }
|
||||||
|
.dark .border-gray-200 { border-color: #374151 !important; }
|
||||||
|
.dark .border-gray-300 { border-color: #4b5563 !important; }
|
||||||
|
|
||||||
|
/* Divider overrides */
|
||||||
|
.dark .divide-gray-50 > :not([hidden]) ~ :not([hidden]) { border-color: #1f2937 !important; }
|
||||||
|
.dark .divide-gray-100 > :not([hidden]) ~ :not([hidden]) { border-color: #1f2937 !important; }
|
||||||
|
|
||||||
|
/* Hover background overrides */
|
||||||
|
.dark .hover\:bg-gray-50:hover { background-color: #1f2937 !important; }
|
||||||
|
.dark .hover\:bg-gray-100:hover { background-color: #1f2937 !important; }
|
||||||
|
.dark .hover\:bg-gray-200:hover { background-color: #374151 !important; }
|
||||||
|
|
||||||
|
/* Hover text overrides */
|
||||||
|
.dark .hover\:text-black:hover { color: #f9fafb !important; }
|
||||||
|
.dark .hover\:text-gray-900:hover { color: #f3f4f6 !important; }
|
||||||
|
|
||||||
|
/* Shadow */
|
||||||
|
.dark .shadow-sm { box-shadow: 0 1px 2px 0 rgba(0,0,0,0.3) !important; }
|
||||||
|
.dark .shadow-md { box-shadow: 0 4px 6px -1px rgba(0,0,0,0.4) !important; }
|
||||||
|
|
||||||
|
/* bg-black buttons in dark mode -> light */
|
||||||
|
.dark .bg-black { background-color: #f9fafb !important; }
|
||||||
|
.dark .bg-black + span,
|
||||||
|
.dark button.bg-black { color: #111827 !important; }
|
||||||
|
.dark button.bg-black span { color: #111827 !important; }
|
||||||
|
|
||||||
|
/* Fix for CTA buttons (bg-black text-white) */
|
||||||
|
.dark button.bg-black,
|
||||||
|
.dark a.bg-black {
|
||||||
|
background-color: #f9fafb !important;
|
||||||
|
color: #111827 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Fix nested text-white inside bg-black in dark mode */
|
||||||
|
.dark .bg-black .text-white,
|
||||||
|
.dark .bg-black.text-white {
|
||||||
|
color: #111827 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Invert sidebar active state */
|
||||||
|
.dark button.bg-black.text-white,
|
||||||
|
.dark button.bg-black > span {
|
||||||
|
color: #111827 !important;
|
||||||
|
}
|
||||||
|
.dark button.bg-black svg {
|
||||||
|
color: #111827 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hover: bg-gray-800 in dark mode */
|
||||||
|
.dark .hover\:bg-gray-800:hover { background-color: #e5e7eb !important; color: #111827 !important; }
|
||||||
|
|
||||||
|
/* Status badge backgrounds */
|
||||||
|
.dark .bg-green-50 { background-color: #064e3b !important; }
|
||||||
|
.dark .bg-red-50 { background-color: #450a0a !important; }
|
||||||
|
.dark .bg-amber-50 { background-color: #451a03 !important; }
|
||||||
|
.dark .bg-emerald-50 { background-color: #064e3b !important; }
|
||||||
|
.dark .bg-amber-100 { background-color: #78350f !important; }
|
||||||
|
|
||||||
|
/* Status badge text */
|
||||||
|
.dark .text-green-700 { color: #6ee7b7 !important; }
|
||||||
|
.dark .text-red-600 { color: #fca5a5 !important; }
|
||||||
|
.dark .text-red-700 { color: #fca5a5 !important; }
|
||||||
|
.dark .text-amber-600 { color: #fcd34d !important; }
|
||||||
|
.dark .text-amber-700 { color: #fcd34d !important; }
|
||||||
|
.dark .text-emerald-700 { color: #6ee7b7 !important; }
|
||||||
|
|
||||||
|
/* Focus ring */
|
||||||
|
.dark .focus\:ring-black:focus { --tw-ring-color: #f9fafb !important; }
|
||||||
|
.dark .focus\:border-black:focus { border-color: #f9fafb !important; }
|
||||||
|
|
||||||
|
/* Accent */
|
||||||
|
.dark .accent-black { accent-color: #f9fafb !important; }
|
||||||
|
|
||||||
|
/* Spinner */
|
||||||
|
.dark .border-black { border-color: #f9fafb !important; }
|
||||||
|
.dark .border-b-black { border-bottom-color: #f9fafb !important; }
|
||||||
|
.dark .border-t-black { border-top-color: #f9fafb !important; }
|
||||||
|
.dark .animate-spin.rounded-full { border-color: #f9fafb !important; border-bottom-color: transparent !important; }
|
||||||
|
|
||||||
|
/* Placeholder */
|
||||||
|
.dark .placeholder-gray-400::placeholder { color: #6b7280 !important; }
|
||||||
|
|
||||||
|
/* Success/Error text standalone */
|
||||||
|
.dark .text-green-600 { color: #6ee7b7 !important; }
|
||||||
|
|
||||||
|
/* Modal backdrop */
|
||||||
|
.dark .bg-black\/50 { background-color: rgba(0,0,0,0.7) !important; }
|
||||||
|
|
||||||
|
/* Toggle / switch */
|
||||||
|
.dark .bg-gray-300 { background-color: #4b5563 !important; }
|
||||||
|
.dark .peer-checked\:bg-black:checked ~ * { background-color: #f9fafb !important; }
|
||||||
|
.dark .peer-checked\:bg-black:checked + *,
|
||||||
|
.dark input.peer:checked + .peer-checked\:bg-black { background-color: #f9fafb !important; }
|
||||||
|
|
||||||
|
|||||||
@@ -3,17 +3,20 @@ import ReactDOM from 'react-dom/client'
|
|||||||
import { BrowserRouter } from 'react-router-dom'
|
import { BrowserRouter } from 'react-router-dom'
|
||||||
import App from './App'
|
import App from './App'
|
||||||
import { AuthProvider } from './contexts/AuthContext'
|
import { AuthProvider } from './contexts/AuthContext'
|
||||||
|
import { ThemeProvider } from './contexts/ThemeContext'
|
||||||
import { DialogProvider } from './components/Dialog'
|
import { DialogProvider } from './components/Dialog'
|
||||||
import './index.css'
|
import './index.css'
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
|
<ThemeProvider>
|
||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
<DialogProvider>
|
<DialogProvider>
|
||||||
<App />
|
<App />
|
||||||
</DialogProvider>
|
</DialogProvider>
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
|
</ThemeProvider>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
</React.StrictMode>,
|
</React.StrictMode>,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -553,4 +553,8 @@ export const getSecuritySummary = () =>
|
|||||||
export const createWebSSHTicket = (containerName: string) =>
|
export const createWebSSHTicket = (containerName: string) =>
|
||||||
api.post<APIResponse<{ ticket: string }>>('/ssh-ticket', { container_name: containerName })
|
api.post<APIResponse<{ ticket: string }>>('/ssh-ticket', { container_name: containerName })
|
||||||
|
|
||||||
|
// Version
|
||||||
|
export const getVersion = () =>
|
||||||
|
api.get<APIResponse<{ version: string }>>('/version')
|
||||||
|
|
||||||
export default api
|
export default api
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
/** @type {import('tailwindcss').Config} */
|
/** @type {import('tailwindcss').Config} */
|
||||||
export default {
|
export default {
|
||||||
|
darkMode: 'class',
|
||||||
content: [
|
content: [
|
||||||
"./index.html",
|
"./index.html",
|
||||||
"./src/**/*.{js,ts,jsx,tsx}",
|
"./src/**/*.{js,ts,jsx,tsx}",
|
||||||
|
|||||||
Reference in New Issue
Block a user