theme switcher cleanup + stuff
This commit is contained in:
@@ -1,3 +1,4 @@
|
|||||||
|
import { useTheme } from "next-themes";
|
||||||
import { Toaster as Sonner, type ToasterProps } from "sonner";
|
import { Toaster as Sonner, type ToasterProps } from "sonner";
|
||||||
import {
|
import {
|
||||||
CircleCheckIcon,
|
CircleCheckIcon,
|
||||||
@@ -8,9 +9,11 @@ import {
|
|||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
const Toaster = ({ ...props }: ToasterProps) => {
|
const Toaster = ({ ...props }: ToasterProps) => {
|
||||||
|
const { theme = "system" } = useTheme();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Sonner
|
<Sonner
|
||||||
theme="system"
|
theme={theme as ToasterProps["theme"]}
|
||||||
className="toaster group"
|
className="toaster group"
|
||||||
icons={{
|
icons={{
|
||||||
success: <CircleCheckIcon className="size-4" />,
|
success: <CircleCheckIcon className="size-4" />,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { NavLink, Outlet, useLocation } from "react-router-dom";
|
import { NavLink, Outlet, useLocation } from "react-router-dom";
|
||||||
|
import { useTheme } from "next-themes";
|
||||||
import {
|
import {
|
||||||
Moon,
|
Moon,
|
||||||
Sun,
|
Sun,
|
||||||
@@ -62,19 +63,6 @@ const navSections: NavSection[] = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
type Theme = "light" | "dark";
|
|
||||||
|
|
||||||
const THEME_KEY = "wakey-ui-theme";
|
|
||||||
|
|
||||||
function resolveInitialTheme(): Theme {
|
|
||||||
const stored = window.localStorage.getItem(THEME_KEY);
|
|
||||||
if (stored === "light" || stored === "dark") return stored;
|
|
||||||
|
|
||||||
return window.matchMedia("(prefers-color-scheme: dark)").matches
|
|
||||||
? "dark"
|
|
||||||
: "light";
|
|
||||||
}
|
|
||||||
|
|
||||||
function usePageTitle() {
|
function usePageTitle() {
|
||||||
const { pathname } = useLocation();
|
const { pathname } = useLocation();
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -89,19 +77,14 @@ function usePageTitle() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function AppLayout() {
|
export function AppLayout() {
|
||||||
const [theme, setTheme] = useState<Theme>(() => resolveInitialTheme());
|
const { resolvedTheme, setTheme } = useTheme();
|
||||||
|
const isDark = resolvedTheme === "dark";
|
||||||
const [collapsed, setCollapsed] = useState(() => {
|
const [collapsed, setCollapsed] = useState(() => {
|
||||||
return window.localStorage.getItem(SIDEBAR_KEY) === "true";
|
return window.localStorage.getItem(SIDEBAR_KEY) === "true";
|
||||||
});
|
});
|
||||||
|
|
||||||
usePageTitle();
|
usePageTitle();
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const root = document.documentElement;
|
|
||||||
root.classList.toggle("dark", theme === "dark");
|
|
||||||
window.localStorage.setItem(THEME_KEY, theme);
|
|
||||||
}, [theme]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
window.localStorage.setItem(SIDEBAR_KEY, String(collapsed));
|
window.localStorage.setItem(SIDEBAR_KEY, String(collapsed));
|
||||||
}, [collapsed]);
|
}, [collapsed]);
|
||||||
@@ -180,27 +163,23 @@ export function AppLayout() {
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="sidebar-footer-btn"
|
className="sidebar-footer-btn"
|
||||||
aria-label={
|
aria-label={isDark ? "Switch to light" : "Switch to dark"}
|
||||||
theme === "dark" ? "Switch to light" : "Switch to dark"
|
onClick={() => setTheme(isDark ? "light" : "dark")}
|
||||||
}
|
|
||||||
onClick={() =>
|
|
||||||
setTheme((c) => (c === "dark" ? "light" : "dark"))
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
{theme === "dark" ? (
|
{isDark ? (
|
||||||
<Sun className="size-4" aria-hidden />
|
<Sun className="size-4" aria-hidden />
|
||||||
) : (
|
) : (
|
||||||
<Moon className="size-4" aria-hidden />
|
<Moon className="size-4" aria-hidden />
|
||||||
)}
|
)}
|
||||||
{!collapsed && (
|
{!collapsed && (
|
||||||
<span className="sidebar-link-label">
|
<span className="sidebar-link-label">
|
||||||
{theme === "dark" ? "Light mode" : "Dark mode"}
|
{isDark ? "Light mode" : "Dark mode"}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="right">
|
<TooltipContent side="right">
|
||||||
{theme === "dark" ? "Switch to light" : "Switch to dark"}
|
{isDark ? "Switch to light" : "Switch to dark"}
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
|
|||||||
+11
-3
@@ -1,6 +1,7 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { createRoot } from "react-dom/client";
|
import { createRoot } from "react-dom/client";
|
||||||
import { BrowserRouter } from "react-router-dom";
|
import { BrowserRouter } from "react-router-dom";
|
||||||
|
import { ThemeProvider } from "next-themes";
|
||||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||||
import { App } from "@/App";
|
import { App } from "@/App";
|
||||||
import "./styles.css";
|
import "./styles.css";
|
||||||
@@ -8,9 +9,16 @@ import "./styles.css";
|
|||||||
createRoot(document.getElementById("root")!).render(
|
createRoot(document.getElementById("root")!).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
<BrowserRouter basename="/ui">
|
<BrowserRouter basename="/ui">
|
||||||
<TooltipProvider>
|
<ThemeProvider
|
||||||
<App />
|
attribute="class"
|
||||||
</TooltipProvider>
|
defaultTheme="system"
|
||||||
|
storageKey="wakey-ui-theme"
|
||||||
|
enableSystem
|
||||||
|
>
|
||||||
|
<TooltipProvider>
|
||||||
|
<App />
|
||||||
|
</TooltipProvider>
|
||||||
|
</ThemeProvider>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
</React.StrictMode>,
|
</React.StrictMode>,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,12 +1,5 @@
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import {
|
import { Inbox, MoreHorizontal, RefreshCw, Search, Zap } from "lucide-react";
|
||||||
Check,
|
|
||||||
Inbox,
|
|
||||||
MoreHorizontal,
|
|
||||||
RefreshCw,
|
|
||||||
Search,
|
|
||||||
Zap,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -74,14 +67,10 @@ export function DevicesPage({ agents, onAfterWake }: Props) {
|
|||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [refreshing, setRefreshing] = useState(false);
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
const [wakeBusyKey, setWakeBusyKey] = useState("");
|
const [wakeBusyKey, setWakeBusyKey] = useState("");
|
||||||
const [error, setError] = useState("");
|
|
||||||
const [status, setStatus] = useState("");
|
|
||||||
const [copied, setCopied] = useState("");
|
|
||||||
const [details, setDetails] = useState<FleetDevice | null>(null);
|
const [details, setDetails] = useState<FleetDevice | null>(null);
|
||||||
|
|
||||||
async function loadFleet() {
|
async function loadFleet() {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError("");
|
|
||||||
try {
|
try {
|
||||||
const [nextDevices, nextKnownDevices] = await Promise.all([
|
const [nextDevices, nextKnownDevices] = await Promise.all([
|
||||||
fetchFleetDevices({
|
fetchFleetDevices({
|
||||||
@@ -96,7 +85,7 @@ export function DevicesPage({ agents, onAfterWake }: Props) {
|
|||||||
setDevices(nextDevices);
|
setDevices(nextDevices);
|
||||||
setKnownDevices(nextKnownDevices);
|
setKnownDevices(nextKnownDevices);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(String(err));
|
toast.error("Failed to load fleet", { description: String(err) });
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -104,19 +93,15 @@ export function DevicesPage({ agents, onAfterWake }: Props) {
|
|||||||
|
|
||||||
async function refreshAllConnected() {
|
async function refreshAllConnected() {
|
||||||
setRefreshing(true);
|
setRefreshing(true);
|
||||||
setStatus("");
|
|
||||||
setError("");
|
|
||||||
try {
|
try {
|
||||||
const result = await refreshFleetDevices();
|
const result = await refreshFleetDevices();
|
||||||
const failed = result.agents.filter((agent) => agent.status !== "ok");
|
const failed = result.agents.filter((agent) => agent.status !== "ok");
|
||||||
const msg = failed.length
|
const msg = failed.length
|
||||||
? `Stored ${result.total_accepted} observations; ${failed.length} agent refresh failed`
|
? `Stored ${result.total_accepted} observations; ${failed.length} agent refresh failed`
|
||||||
: `Stored ${result.total_accepted} observations from ${result.agents.length} connected agents`;
|
: `Stored ${result.total_accepted} observations from ${result.agents.length} connected agents`;
|
||||||
setStatus(msg);
|
|
||||||
toast.success(msg);
|
toast.success(msg);
|
||||||
await loadFleet();
|
await loadFleet();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(String(err));
|
|
||||||
toast.error("Fleet refresh failed", { description: String(err) });
|
toast.error("Fleet refresh failed", { description: String(err) });
|
||||||
} finally {
|
} finally {
|
||||||
setRefreshing(false);
|
setRefreshing(false);
|
||||||
@@ -126,8 +111,6 @@ export function DevicesPage({ agents, onAfterWake }: Props) {
|
|||||||
async function wakeDevice(device: FleetDevice, routeId?: string | null) {
|
async function wakeDevice(device: FleetDevice, routeId?: string | null) {
|
||||||
if (!device.recommended_route && !routeId) return;
|
if (!device.recommended_route && !routeId) return;
|
||||||
setWakeBusyKey(device.device_key);
|
setWakeBusyKey(device.device_key);
|
||||||
setStatus("");
|
|
||||||
setError("");
|
|
||||||
try {
|
try {
|
||||||
const result = await wakeFleetDevice({
|
const result = await wakeFleetDevice({
|
||||||
deviceKey: device.device_key,
|
deviceKey: device.device_key,
|
||||||
@@ -135,11 +118,9 @@ export function DevicesPage({ agents, onAfterWake }: Props) {
|
|||||||
});
|
});
|
||||||
const msg = `Wake sent via ${agentLabel(result.route.agent_id, result.route.nickname)} (${result.route.mac ?? "no mac"})`;
|
const msg = `Wake sent via ${agentLabel(result.route.agent_id, result.route.nickname)} (${result.route.mac ?? "no mac"})`;
|
||||||
toast.success(msg);
|
toast.success(msg);
|
||||||
setStatus(msg);
|
|
||||||
if (onAfterWake) await onAfterWake();
|
if (onAfterWake) await onAfterWake();
|
||||||
await loadFleet();
|
await loadFleet();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(String(err));
|
|
||||||
toast.error("Wake failed", { description: String(err) });
|
toast.error("Wake failed", { description: String(err) });
|
||||||
} finally {
|
} finally {
|
||||||
setWakeBusyKey("");
|
setWakeBusyKey("");
|
||||||
@@ -151,7 +132,6 @@ export function DevicesPage({ agents, onAfterWake }: Props) {
|
|||||||
if (!text) return;
|
if (!text) return;
|
||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(text);
|
await navigator.clipboard.writeText(text);
|
||||||
setCopied(label);
|
|
||||||
toast.success(`Copied ${label}`);
|
toast.success(`Copied ${label}`);
|
||||||
} catch {
|
} catch {
|
||||||
const area = document.createElement("textarea");
|
const area = document.createElement("textarea");
|
||||||
@@ -162,7 +142,6 @@ export function DevicesPage({ agents, onAfterWake }: Props) {
|
|||||||
area.select();
|
area.select();
|
||||||
document.execCommand("copy");
|
document.execCommand("copy");
|
||||||
document.body.removeChild(area);
|
document.body.removeChild(area);
|
||||||
setCopied(label);
|
|
||||||
toast.success(`Copied ${label}`);
|
toast.success(`Copied ${label}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -178,12 +157,6 @@ export function DevicesPage({ agents, onAfterWake }: Props) {
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [query]);
|
}, [query]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!copied) return;
|
|
||||||
const timer = window.setTimeout(() => setCopied(""), 1400);
|
|
||||||
return () => window.clearTimeout(timer);
|
|
||||||
}, [copied]);
|
|
||||||
|
|
||||||
const sortedDevices = useMemo(() => {
|
const sortedDevices = useMemo(() => {
|
||||||
const rows = [...devices];
|
const rows = [...devices];
|
||||||
rows.sort((a, b) => {
|
rows.sort((a, b) => {
|
||||||
@@ -316,23 +289,6 @@ export function DevicesPage({ agents, onAfterWake }: Props) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{status && (
|
|
||||||
<div className="rounded-md border bg-muted/50 px-3 py-2 text-sm">
|
|
||||||
{status}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{error && (
|
|
||||||
<pre className="max-h-80 overflow-auto rounded-md border border-destructive/60 bg-destructive/10 p-3 text-xs text-destructive">
|
|
||||||
{error}
|
|
||||||
</pre>
|
|
||||||
)}
|
|
||||||
{copied && (
|
|
||||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
|
||||||
<Check className="size-4" aria-hidden />
|
|
||||||
Copied {copied}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Skeleton loading */}
|
{/* Skeleton loading */}
|
||||||
{loading && !devices.length ? (
|
{loading && !devices.length ? (
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
|
|||||||
Reference in New Issue
Block a user