feat: light theme (#638)

This commit is contained in:
Nico 2026-03-09 18:16:50 +01:00 committed by GitHub
parent 8a10266960
commit dd8f471d60
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
30 changed files with 189 additions and 89 deletions

View file

@ -114,6 +114,7 @@
"unicorn/prefer-set-size": "warn", "unicorn/prefer-set-size": "warn",
"unicorn/prefer-string-starts-ends-with": "warn", "unicorn/prefer-string-starts-ends-with": "warn",
"import/no-cycle": "error", "import/no-cycle": "error",
"import/no-unused-modules": ["error", { "unusedExports": true }],
"eslint/no-console": ["warn", { "allow": ["warn", "error", "info"] }] "eslint/no-console": ["warn", { "allow": ["warn", "error", "info"] }]
}, },
"settings": { "settings": {

View file

@ -37,7 +37,6 @@ body {
} }
body { body {
@apply bg-[#131313];
min-height: 100dvh; min-height: 100dvh;
} }
@ -67,6 +66,8 @@ body {
--color-accent: var(--accent); --color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground); --color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive); --color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-success: var(--success);
--color-border: var(--border); --color-border: var(--border);
--color-input: var(--input); --color-input: var(--input);
--color-ring: var(--ring); --color-ring: var(--ring);
@ -88,38 +89,40 @@ body {
:root { :root {
--radius: 0.625rem; --radius: 0.625rem;
--background: oklch(1 0 0); --background: oklch(0.975 0.005 80);
--foreground: oklch(0.145 0 0); --foreground: oklch(0.25 0.015 55);
--card: oklch(1 0 0); --card: oklch(0.988 0.004 80);
--card-foreground: oklch(0.145 0 0); --card-foreground: oklch(0.25 0.015 55);
--card-header: oklch(0.922 0 0); --card-header: oklch(0.945 0.008 80);
--popover: oklch(1 0 0); --popover: oklch(0.988 0.004 80);
--popover-foreground: oklch(0.145 0 0); --popover-foreground: oklch(0.25 0.015 55);
--primary: oklch(0.205 0 0); --primary: oklch(0.25 0.015 55);
--primary-foreground: oklch(0.985 0 0); --primary-foreground: oklch(0.975 0.005 80);
--secondary: oklch(0.97 0 0); --secondary: oklch(0.935 0.008 70);
--secondary-foreground: oklch(0.205 0 0); --secondary-foreground: oklch(0.25 0.015 55);
--muted: oklch(0.97 0 0); --muted: oklch(0.935 0.008 70);
--muted-foreground: oklch(0.556 0 0); --muted-foreground: oklch(0.58 0.025 60);
--accent: oklch(0.97 0 0); --accent: oklch(0.935 0.008 70);
--accent-foreground: oklch(0.205 0 0); --accent-foreground: oklch(0.25 0.015 55);
--destructive: oklch(0.577 0.245 27.325); --destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0); --destructive-foreground: #fff;
--input: oklch(0.922 0 0); --success: oklch(0.57 0.16 148);
--ring: oklch(0.708 0 0); --border: oklch(0.9 0.01 70);
--input: oklch(0.9 0.01 70);
--ring: oklch(0.7 0.015 60);
--chart-1: oklch(0.646 0.222 41.116); --chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704); --chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392); --chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429); --chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08); --chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.985 0 0); --sidebar: oklch(0.96 0.007 75);
--sidebar-foreground: oklch(0.145 0 0); --sidebar-foreground: oklch(0.25 0.015 55);
--sidebar-primary: oklch(0.205 0 0); --sidebar-primary: oklch(0.25 0.015 55);
--sidebar-primary-foreground: oklch(0.985 0 0); --sidebar-primary-foreground: oklch(0.975 0.005 80);
--sidebar-accent: oklch(0.97 0 0); --sidebar-accent: oklch(0.935 0.008 70);
--sidebar-accent-foreground: oklch(0.205 0 0); --sidebar-accent-foreground: oklch(0.25 0.015 55);
--sidebar-border: oklch(0.922 0 0); --sidebar-border: oklch(0.9 0.01 70);
--sidebar-ring: oklch(0.708 0 0); --sidebar-ring: oklch(0.7 0.015 60);
--strong-accent: #ff543a; --strong-accent: #ff543a;
} }
@ -144,6 +147,8 @@ body {
--accent: oklch(0.269 0 0); --accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0); --accent-foreground: oklch(0.985 0 0);
--destructive: #ff543a; --destructive: #ff543a;
--destructive-foreground: #fff;
--success: oklch(0.696 0.17 162.48);
--border: oklch(1 0 0 / 10%); --border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%); --input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0); --ring: oklch(0.556 0 0);
@ -190,6 +195,17 @@ body {
} }
} }
.snapshot-scrollable::-webkit-scrollbar {
height: 6px;
}
.snapshot-scrollable::-webkit-scrollbar-track {
background: transparent;
}
.snapshot-scrollable::-webkit-scrollbar-thumb {
background: var(--border);
border-radius: 999px;
}
/* Hide built-in password reveal/clear controls (notably in Edge on Windows) */ /* Hide built-in password reveal/clear controls (notably in Edge on Windows) */
[data-secret-input] input[type="password"]::-ms-reveal, [data-secret-input] input[type="password"]::-ms-reveal,
[data-secret-input] input[type="password"]::-ms-clear { [data-secret-input] input[type="password"]::-ms-clear {

View file

@ -115,7 +115,7 @@ export function AppSidebar({ isInstanceAdmin }: Props) {
/> />
<span <span
className={cn({ className={cn({
"text-white font-medium": isActive, "text-foreground font-medium": isActive,
"text-muted-foreground": !isActive, "text-muted-foreground": !isActive,
})} })}
> >
@ -171,7 +171,7 @@ export function AppSidebar({ isInstanceAdmin }: Props) {
/> />
<span <span
className={cn({ className={cn({
"text-white font-medium": isActive, "text-foreground font-medium": isActive,
"text-muted-foreground": !isActive, "text-muted-foreground": !isActive,
})} })}
> >

View file

@ -59,7 +59,7 @@ export function CronInput({ value, onChange, error }: CronInputProps) {
{value && ( {value && (
<div> <div>
{isValid ? ( {isValid ? (
<CheckCircle2 className="h-4 w-4 text-green-500" /> <CheckCircle2 className="h-4 w-4 text-success" />
) : ( ) : (
<AlertCircle className="h-4 w-4 text-destructive" /> <AlertCircle className="h-4 w-4 text-destructive" />
)} )}

View file

@ -14,7 +14,7 @@ export function GridBackground({ children, className, containerClassName }: Grid
className={cn( className={cn(
"pointer-events-none absolute inset-0 w-full h-full", "pointer-events-none absolute inset-0 w-full h-full",
"bg-[size:40px_40px]", "bg-[size:40px_40px]",
"bg-[linear-gradient(to_right,#e4e4e7_1px,transparent_1px),linear-gradient(to_bottom,#e4e4e7_1px,transparent_1px)]", "bg-[linear-gradient(to_right,#EAE5DF_1px,transparent_1px),linear-gradient(to_bottom,#EAE5DF_1px,transparent_1px)]",
"dark:bg-[linear-gradient(to_right,#262626_1px,transparent_1px),linear-gradient(to_bottom,#262626_1px,transparent_1px)]", "dark:bg-[linear-gradient(to_right,#262626_1px,transparent_1px),linear-gradient(to_bottom,#262626_1px,transparent_1px)]",
"[mask-image:radial-gradient(ellipse_at_top,black_70%,transparent_100%)]", "[mask-image:radial-gradient(ellipse_at_top,black_70%,transparent_100%)]",
)} )}

View file

@ -10,6 +10,7 @@ import { DevPanelListener } from "./dev-panel-listener";
import { Outlet, useNavigate } from "@tanstack/react-router"; import { Outlet, useNavigate } from "@tanstack/react-router";
import { AppBreadcrumb } from "./app-breadcrumb"; import { AppBreadcrumb } from "./app-breadcrumb";
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip"; import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
import { ThemeToggle } from "./theme-toggle";
type Props = { type Props = {
loaderData: AppContext; loaderData: AppContext;
@ -46,12 +47,13 @@ export function Layout({ loaderData }: Props) {
<span className="text-sm text-muted-foreground hidden md:inline-flex pl-2 mr-5"> <span className="text-sm text-muted-foreground hidden md:inline-flex pl-2 mr-5">
<span className="text-foreground">{loaderData.user.name}</span> <span className="text-foreground">{loaderData.user.name}</span>
</span> </span>
<ThemeToggle />
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
className="rounded-full h-7 text-xs text-muted-foreground hover:text-white" className="rounded-full h-7 text-xs text-muted-foreground hover:text-foreground"
onClick={handleLogout} onClick={handleLogout}
> >
<LogOut className="w-4 h-4" /> <LogOut className="w-4 h-4" />
@ -64,7 +66,7 @@ export function Layout({ loaderData }: Props) {
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
className="relative overflow-hidden hidden lg:inline-flex rounded-full h-7 w-7 text-muted-foreground hover:text-white" className="relative overflow-hidden hidden lg:inline-flex rounded-full h-7 w-7 text-muted-foreground hover:text-foreground"
> >
<a <a
href="https://github.com/nicotsx/zerobyte/issues/new" href="https://github.com/nicotsx/zerobyte/issues/new"

View file

@ -15,7 +15,7 @@ export const OnOff = ({ isOn, toggle, enabledLabel, disabledLabel, disabled }: P
className={cn( className={cn(
"flex items-center gap-2 rounded-full border px-3 py-1.5 text-xs font-semibold uppercase tracking-wide transition-colors", "flex items-center gap-2 rounded-full border px-3 py-1.5 text-xs font-semibold uppercase tracking-wide transition-colors",
isOn isOn
? "border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-500/40 dark:bg-emerald-500/10 dark:text-emerald-200" ? "border-success/30 bg-success/10 text-success"
: "border-muted bg-muted/40 text-muted-foreground dark:border-muted/60 dark:bg-muted/10", : "border-muted bg-muted/40 text-muted-foreground dark:border-muted/60 dark:bg-muted/10",
)} )}
> >

View file

@ -59,7 +59,7 @@ export function OrganizationSwitcher() {
size="lg" size="lg"
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground" className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
> >
<div className="bg-black text-sidebar-primary-foreground flex aspect-square size-8 shrink-0 items-center justify-center overflow-hidden rounded-lg"> <div className="bg-foreground text-sidebar-primary-foreground flex aspect-square size-8 shrink-0 items-center justify-center overflow-hidden rounded-lg">
{activeOrganization?.logo ? ( {activeOrganization?.logo ? (
<img <img
src={activeOrganization.logo} src={activeOrganization.logo}

View file

@ -12,8 +12,8 @@ interface StatusDotProps {
export const StatusDot = ({ variant, label, animated }: StatusDotProps) => { export const StatusDot = ({ variant, label, animated }: StatusDotProps) => {
const statusMapping = { const statusMapping = {
success: { success: {
color: "bg-emerald-500 shadow-[0_0_8px_rgba(16,185,129,0.5)]", color: "bg-success shadow-[0_0_8px_rgba(16,185,129,0.5)]",
colorLight: "bg-emerald-400", colorLight: "bg-success/60",
animated: animated ?? true, animated: animated ?? true,
}, },
neutral: { neutral: {

View file

@ -0,0 +1,35 @@
import { createContext, useCallback, useContext, useState } from "react";
type Theme = "light" | "dark";
type ThemeContextValue = {
theme: Theme;
setTheme: (theme: Theme) => void;
};
const ThemeContext = createContext<ThemeContextValue | null>(null);
export const THEME_COOKIE_NAME = "theme";
export const THEME_COOKIE_MAX_AGE = 60 * 60 * 24 * 365; // 1 year
const DEFAULT_THEME: Theme = "dark";
export function ThemeProvider({ children, initialTheme }: { children: React.ReactNode; initialTheme?: Theme }) {
const [theme, setThemeState] = useState<Theme>(initialTheme ?? DEFAULT_THEME);
const setTheme = useCallback((newTheme: Theme) => {
setThemeState(newTheme);
document.cookie = `${THEME_COOKIE_NAME}=${newTheme}; path=/; max-age=${THEME_COOKIE_MAX_AGE}`;
document.documentElement.classList.toggle("dark", newTheme === "dark");
document.documentElement.style.colorScheme = newTheme;
}, []);
return <ThemeContext value={{ theme, setTheme }}>{children}</ThemeContext>;
}
export function useTheme() {
const context = useContext(ThemeContext);
if (!context) {
throw new Error("useTheme must be used within a ThemeProvider");
}
return context;
}

View file

@ -0,0 +1,26 @@
import { Moon, Sun } from "lucide-react";
import { useTheme } from "./theme-provider";
import { Button } from "./ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
export function ThemeToggle() {
const { theme, setTheme } = useTheme();
return (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="relative rounded-full h-7 w-7 text-muted-foreground hover:text-foreground"
onClick={() => setTheme(theme === "dark" ? "light" : "dark")}
aria-label="Toggle theme"
>
<Sun className="h-4 w-4 rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
<Moon className="absolute h-4 w-4 rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
</Button>
</TooltipTrigger>
<TooltipContent>Toggle theme</TooltipContent>
</Tooltip>
);
}

View file

@ -11,13 +11,12 @@ const buttonVariants = cva(
{ {
variants: { variants: {
variant: { variant: {
default: default: "bg-transparent text-foreground hover:bg-muted border hover:border-border",
"bg-transparent text-white hover:bg-[#3A3A3A]/80 border hover:border-white/40 dark:text-white dark:hover:bg-[#3A3A3A]/80",
primary: "bg-strong-accent text-white hover:bg-strong-accent/90 focus-visible:ring-strong-accent/50", primary: "bg-strong-accent text-white hover:bg-strong-accent/90 focus-visible:ring-strong-accent/50",
destructive: destructive:
"border border-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/50 text-destructive hover:text-white", "border border-destructive text-destructive hover:bg-destructive hover:text-destructive-foreground focus-visible:ring-destructive/50",
outline: "border border-border bg-background hover:bg-accent hover:text-accent-foreground", outline: "border border-border bg-background hover:bg-accent hover:text-accent-foreground",
secondary: "bg-transparent text-white hover:bg-[#3A3A3A]/80 border dark:text-white dark:hover:bg-[#3A3A3A]/80", secondary: "bg-transparent text-foreground hover:bg-muted border",
ghost: "hover:bg-accent hover:text-accent-foreground", ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline", link: "text-primary underline-offset-4 hover:underline",
}, },

View file

@ -7,7 +7,7 @@ function Card({ className, children, interactive, ...props }: React.ComponentPro
<div <div
data-slot="card" data-slot="card"
className={cn( className={cn(
"bg-card text-card-foreground group relative flex flex-col gap-6 border border-border py-6 shadow-[inset_0_0_0_1px_rgba(255,255,255,0.02)] transition-colors duration-300 ", "bg-card text-card-foreground group relative flex flex-col gap-6 border border-border py-6 shadow-[inset_0_0_0_1px_rgba(0,0,0,0.03)] dark:shadow-[inset_0_0_0_1px_rgba(255,255,255,0.02)] transition-colors duration-300 ",
className, className,
)} )}
{...props} {...props}
@ -21,14 +21,14 @@ function Card({ className, children, interactive, ...props }: React.ComponentPro
}, },
)} )}
> >
<span className="absolute -left-0.5 -top-0.5 h-0.5 w-4 bg-white/80" /> <span className="absolute -left-0.5 -top-0.5 h-0.5 w-4 bg-foreground" />
<span className="absolute -left-0.5 -top-0.5 h-4 w-0.5 bg-white/80" /> <span className="absolute -left-0.5 -top-0.5 h-4 w-0.5 bg-foreground" />
<span className="absolute -right-0.5 -top-0.5 h-0.5 w-4 bg-white/80" /> <span className="absolute -right-0.5 -top-0.5 h-0.5 w-4 bg-foreground" />
<span className="absolute -right-0.5 -top-0.5 h-4 w-0.5 bg-white/80" /> <span className="absolute -right-0.5 -top-0.5 h-4 w-0.5 bg-foreground" />
<span className="absolute -left-0.5 -bottom-0.5 h-0.5 w-4 bg-white/80" /> <span className="absolute -left-0.5 -bottom-0.5 h-0.5 w-4 bg-foreground" />
<span className="absolute -left-0.5 -bottom-0.5 h-4 w-0.5 bg-white/80" /> <span className="absolute -left-0.5 -bottom-0.5 h-4 w-0.5 bg-foreground" />
<span className="absolute -right-0.5 -bottom-0.5 h-0.5 w-4 bg-white/80" /> <span className="absolute -right-0.5 -bottom-0.5 h-0.5 w-4 bg-foreground" />
<span className="absolute -right-0.5 -bottom-0.5 h-4 w-0.5 bg-white/80" /> <span className="absolute -right-0.5 -bottom-0.5 h-4 w-0.5 bg-foreground" />
</span> </span>
{children} {children}
</div> </div>

View file

@ -8,8 +8,8 @@ interface CodeBlockProps {
export const CodeBlock: React.FC<CodeBlockProps> = ({ code, filename }) => { export const CodeBlock: React.FC<CodeBlockProps> = ({ code, filename }) => {
return ( return (
<div className="overflow-hidden rounded-sm bg-card-header ring-1 ring-white/10"> <div className="overflow-hidden rounded-sm bg-card-header ring-1 ring-border">
<div className="flex items-center justify-between border-b border-white/10 px-4 py-2 text-xs"> <div className="flex items-center justify-between border-b border-border px-4 py-2 text-xs">
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<span className="h-2.5 w-2.5 rounded-full bg-rose-500" /> <span className="h-2.5 w-2.5 rounded-full bg-rose-500" />
<span className="h-2.5 w-2.5 rounded-full bg-amber-500" /> <span className="h-2.5 w-2.5 rounded-full bg-amber-500" />
@ -18,7 +18,7 @@ export const CodeBlock: React.FC<CodeBlockProps> = ({ code, filename }) => {
</div> </div>
</div> </div>
<pre className="text-xs m-0 px-4 py-2 bg-card-header"> <pre className="text-xs m-0 px-4 py-2 bg-card-header">
<code className="text-white/80 select-all">{code}</code> <code className="text-foreground/80 select-all">{code}</code>
</pre> </pre>
</div> </div>
); );

View file

@ -1,4 +1,4 @@
import { useTheme } from "next-themes"; import { useTheme } from "~/client/components/theme-provider";
import { Toaster as Sonner, type ToasterProps } from "sonner"; import { Toaster as Sonner, type ToasterProps } from "sonner";
const Toaster = ({ ...props }: ToasterProps) => { const Toaster = ({ ...props }: ToasterProps) => {

View file

@ -37,11 +37,11 @@ function TabsTrigger({ className, onClick, ...props }: React.ComponentProps<type
// Padding: 20px horizontal (8px for bracket tick + 12px gap to text) // Padding: 20px horizontal (8px for bracket tick + 12px gap to text)
"px-5", "px-5",
// Transparent orange background for active state // Transparent orange background for active state
"data-[state=active]:bg-[#FF453A]/10", "data-[state=active]:bg-strong-accent/10",
// Left bracket - vertical line // Left bracket - vertical line
"before:absolute before:left-0 before:top-0 before:h-7 before:w-0.5 before:bg-[#5D6570] before:transition-colors data-[state=active]:before:bg-[#FF453A]", "before:absolute before:left-0 before:top-0 before:h-7 before:w-0.5 before:bg-muted-foreground/60 before:transition-colors data-[state=active]:before:bg-strong-accent",
// Left bracket - top tick // Left bracket - top tick
"after:absolute after:left-0 after:-top-px after:w-2 after:h-0.5 after:bg-[#5D6570] after:transition-colors data-[state=active]:after:bg-[#FF453A]", "after:absolute after:left-0 after:-top-px after:w-2 after:h-0.5 after:bg-muted-foreground/60 after:transition-colors data-[state=active]:after:bg-strong-accent",
className, className,
)} )}
onClick={handleClick} onClick={handleClick}
@ -49,13 +49,13 @@ function TabsTrigger({ className, onClick, ...props }: React.ComponentProps<type
> >
<span className="relative z-10">{props.children}</span> <span className="relative z-10">{props.children}</span>
{/* Left bracket - bottom tick */} {/* Left bracket - bottom tick */}
<span className="absolute left-0 bottom-[-1px] h-0.5 w-2 bg-[#5D6570] transition-colors group-data-[state=active]:bg-[#FF453A]" /> <span className="absolute left-0 bottom-[-1px] h-0.5 w-2 bg-muted-foreground/60 transition-colors group-data-[state=active]:bg-strong-accent" />
{/* Right bracket - top tick */} {/* Right bracket - top tick */}
<span className="absolute right-0 top-[-1px] h-0.5 w-2 bg-[#5D6570] transition-colors group-data-[state=active]:bg-[#FF453A]" /> <span className="absolute right-0 top-[-1px] h-0.5 w-2 bg-muted-foreground/60 transition-colors group-data-[state=active]:bg-strong-accent" />
{/* Right bracket - vertical line */} {/* Right bracket - vertical line */}
<span className="absolute right-0 top-0 h-7 w-0.5 bg-[#5D6570] transition-colors group-data-[state=active]:bg-[#FF453A]" /> <span className="absolute right-0 top-0 h-7 w-0.5 bg-muted-foreground/60 transition-colors group-data-[state=active]:bg-strong-accent" />
{/* Right bracket - bottom tick */} {/* Right bracket - bottom tick */}
<span className="absolute right-0 bottom-[-1px] h-0.5 w-2 bg-[#5D6570] transition-colors group-data-[state=active]:bg-[#FF453A]" /> <span className="absolute right-0 bottom-[-1px] h-0.5 w-2 bg-muted-foreground/60 transition-colors group-data-[state=active]:bg-strong-accent" />
</TabsPrimitive.Trigger> </TabsPrimitive.Trigger>
); );
} }

View file

@ -32,21 +32,21 @@ export const BackupCard = ({ schedule }: { schedule: BackupSchedule }) => {
<CardContent className="flex-1 space-y-4"> <CardContent className="flex-1 space-y-4">
<div className="space-y-3"> <div className="space-y-3">
<div className="flex items-center text-sm gap-2"> <div className="flex items-center text-sm gap-2">
<span className="text-zinc-500 shrink-0">Schedule</span> <span className="text-muted-foreground shrink-0">Schedule</span>
<div className="flex-1 border-b border-dashed border-white/10" /> <div className="flex-1 border-b border-dashed border-border/50" />
<code className="text-xs text-zinc-100 font-mono bg-muted px-2 py-1 rounded shrink-0"> <code className="text-xs text-foreground font-mono bg-muted px-2 py-1 rounded shrink-0">
{schedule.cronExpression} {schedule.cronExpression}
</code> </code>
</div> </div>
<div className="flex items-center text-sm gap-2"> <div className="flex items-center text-sm gap-2">
<span className="text-zinc-500 shrink-0">Last backup</span> <span className="text-muted-foreground shrink-0">Last backup</span>
<div className="flex-1 border-b border-dashed border-white/10" /> <div className="flex-1 border-b border-dashed border-border/50" />
<span className="text-zinc-100 font-mono text-sm shrink-0">{formatTimeAgo(schedule.lastBackupAt)}</span> <span className="text-foreground font-mono text-sm shrink-0">{formatTimeAgo(schedule.lastBackupAt)}</span>
</div> </div>
<div className="flex items-center text-sm gap-2"> <div className="flex items-center text-sm gap-2">
<span className="text-zinc-500 shrink-0">Next backup</span> <span className="text-muted-foreground shrink-0">Next backup</span>
<div className="flex-1 border-b border-dashed border-white/10" /> <div className="flex-1 border-b border-dashed border-border/50" />
<span className="text-zinc-100 font-mono text-sm shrink-0"> <span className="text-foreground font-mono text-sm shrink-0">
{formatShortDateTime(schedule.nextBackupAt)} {formatShortDateTime(schedule.nextBackupAt)}
</span> </span>
</div> </div>

View file

@ -1,3 +1,4 @@
import { useRef } from "react";
import type { ListSnapshotsResponse } from "~/client/api-client"; import type { ListSnapshotsResponse } from "~/client/api-client";
import { ByteSize } from "~/client/components/bytes-size"; import { ByteSize } from "~/client/components/bytes-size";
import { Card, CardContent } from "~/client/components/ui/card"; import { Card, CardContent } from "~/client/components/ui/card";
@ -15,6 +16,7 @@ interface Props {
export const SnapshotTimeline = (props: Props) => { export const SnapshotTimeline = (props: Props) => {
const { snapshots, snapshotId, loading, onSnapshotSelect, error } = props; const { snapshots, snapshotId, loading, onSnapshotSelect, error } = props;
const selectedRef = useRef<HTMLButtonElement>(null);
if (error) { if (error) {
return ( return (
@ -51,13 +53,14 @@ export const SnapshotTimeline = (props: Props) => {
<div className="w-full bg-card"> <div className="w-full bg-card">
<div className="relative flex items-center"> <div className="relative flex items-center">
<div className="flex-1 overflow-hidden"> <div className="flex-1 overflow-hidden">
<div className="flex gap-4 overflow-x-auto pb-2 *:first:ml-2 *:last:mr-2"> <div className="snapshot-scrollable flex gap-4 overflow-x-auto pb-2 *:first:ml-2 *:last:mr-2">
{snapshots.map((snapshot) => { {snapshots.map((snapshot) => {
const date = new Date(snapshot.time); const date = new Date(snapshot.time);
const isSelected = snapshotId === snapshot.short_id; const isSelected = snapshotId === snapshot.short_id;
return ( return (
<button <button
ref={isSelected ? selectedRef : undefined}
type="button" type="button"
key={snapshot.short_id} key={snapshot.short_id}
onClick={() => onSnapshotSelect(snapshot.short_id)} onClick={() => onSnapshotSelect(snapshot.short_id)}

View file

@ -65,7 +65,9 @@ export function ScheduleDetailsPage(props: Props) {
const searchParams = useSearch({ from: "/(dashboard)/backups/$backupId/" }); const searchParams = useSearch({ from: "/(dashboard)/backups/$backupId/" });
const [isEditMode, setIsEditMode] = useState(false); const [isEditMode, setIsEditMode] = useState(false);
const formId = useId(); const formId = useId();
const [selectedSnapshotId, setSelectedSnapshotId] = useState<string | undefined>(initialSnapshotId); const [selectedSnapshotId, setSelectedSnapshotId] = useState<string | undefined>(
initialSnapshotId ?? loaderData.snapshots?.at(-1)?.short_id,
);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [snapshotToDelete, setSnapshotToDelete] = useState<string | null>(null); const [snapshotToDelete, setSnapshotToDelete] = useState<string | null>(null);

View file

@ -97,7 +97,7 @@ export function NotificationDetailsPage({ notificationId }: { notificationId: st
<div className="text-sm font-semibold text-muted-foreground flex items-center gap-2"> <div className="text-sm font-semibold text-muted-foreground flex items-center gap-2">
<span <span
className={cn("inline-flex items-center gap-2 px-2 py-1 rounded-md text-xs bg-gray-500/10 text-gray-500", { className={cn("inline-flex items-center gap-2 px-2 py-1 rounded-md text-xs bg-gray-500/10 text-gray-500", {
"bg-green-500/10 text-emerald-500": data.enabled, "bg-success/10 text-success": data.enabled,
"bg-red-500/10 text-red-500": !data.enabled, "bg-red-500/10 text-red-500": !data.enabled,
})} })}
> >

View file

@ -41,7 +41,7 @@ export const DoctorReport = ({ result, repositoryStatus }: Props) => {
<CollapsibleTrigger className="w-full flex items-center justify-between p-3 hover:bg-muted/50 transition-colors"> <CollapsibleTrigger className="w-full flex items-center justify-between p-3 hover:bg-muted/50 transition-colors">
<span className="text-sm font-medium capitalize">{step.step.replaceAll("_", " ")}</span> <span className="text-sm font-medium capitalize">{step.step.replaceAll("_", " ")}</span>
{step.success ? ( {step.success ? (
<CheckCircle2 className="h-4 w-4 text-emerald-500" /> <CheckCircle2 className="h-4 w-4 text-success" />
) : ( ) : (
<AlertCircle className="h-4 w-4 text-red-500" /> <AlertCircle className="h-4 w-4 text-red-500" />
)} )}

View file

@ -143,7 +143,7 @@ export function RepositoriesPage() {
className={cn( className={cn(
"inline-flex items-center gap-2 px-2 py-1 rounded-md text-xs bg-gray-500/10 text-gray-500", "inline-flex items-center gap-2 px-2 py-1 rounded-md text-xs bg-gray-500/10 text-gray-500",
{ {
"bg-green-500/10 text-emerald-500": repository.status === "healthy", "bg-success/10 text-success": repository.status === "healthy",
"bg-red-500/10 text-red-500": repository.status === "error", "bg-red-500/10 text-red-500": repository.status === "error",
}, },
)} )}

View file

@ -187,7 +187,7 @@ export const RepositoryInfoTabContent = ({ repository, initialStats }: Props) =>
<p className="text-sm flex items-center gap-2"> <p className="text-sm flex items-center gap-2">
<span <span
className={cn("w-2 h-2 rounded-full", { className={cn("w-2 h-2 rounded-full", {
"bg-emerald-500": repository.status === "healthy", "bg-success": repository.status === "healthy",
"bg-red-500": repository.status === "error", "bg-red-500": repository.status === "error",
"bg-amber-500": repository.status !== "healthy" && repository.status !== "error", "bg-amber-500": repository.status !== "healthy" && repository.status !== "error",
"animate-pulse": repository.status === "doctor", "animate-pulse": repository.status === "doctor",
@ -209,7 +209,7 @@ export const RepositoryInfoTabContent = ({ repository, initialStats }: Props) =>
{hasCaCert && ( {hasCaCert && (
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<div className="text-sm font-medium text-muted-foreground">CA Certificate</div> <div className="text-sm font-medium text-muted-foreground">CA Certificate</div>
<p className="text-sm text-green-500">Configured</p> <p className="text-sm text-success">Configured</p>
</div> </div>
)} )}
{hasInsecureTlsConfig && ( {hasInsecureTlsConfig && (
@ -217,7 +217,7 @@ export const RepositoryInfoTabContent = ({ repository, initialStats }: Props) =>
<div className="text-sm font-medium text-muted-foreground">TLS Validation</div> <div className="text-sm font-medium text-muted-foreground">TLS Validation</div>
<p className="text-sm"> <p className="text-sm">
<span className={cn("text-red-500", { hidden: !isTlsValidationDisabled })}>Disabled</span> <span className={cn("text-red-500", { hidden: !isTlsValidationDisabled })}>Disabled</span>
<span className={cn("text-green-500", { hidden: isTlsValidationDisabled })}>Enabled</span> <span className={cn("text-success", { hidden: isTlsValidationDisabled })}>Enabled</span>
</p> </p>
</div> </div>
)} )}

View file

@ -34,7 +34,7 @@ export const TwoFactorSection = ({ twoFactorEnabled }: TwoFactorSectionProps) =>
<p className="text-sm font-medium"> <p className="text-sm font-medium">
Status:&nbsp; Status:&nbsp;
{twoFactorEnabled ? ( {twoFactorEnabled ? (
<span className="text-green-500">Enabled</span> <span className="text-success">Enabled</span>
) : ( ) : (
<span className="text-muted-foreground">Disabled</span> <span className="text-muted-foreground">Disabled</span>
)} )}

View file

@ -154,7 +154,7 @@ export function UserManagement({ currentUser }: { currentUser: { id: string } |
<Badge variant="outline" className={cn("text-red-500 border-red-500", { hidden: !user.banned })}> <Badge variant="outline" className={cn("text-red-500 border-red-500", { hidden: !user.banned })}>
Banned Banned
</Badge> </Badge>
<Badge variant="outline" className={cn("bg-green-500/10 text-emerald-500", { hidden: user.banned })}> <Badge variant="outline" className={cn("bg-success/10 text-success", { hidden: user.banned })}>
Active Active
</Badge> </Badge>
</TableCell> </TableCell>

View file

@ -237,7 +237,7 @@ export const CreateVolumeForm = ({ onSubmit, mode = "create", initialValues, for
> >
{testBackendConnection.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />} {testBackendConnection.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{!testBackendConnection.isPending && testMessage?.success && ( {!testBackendConnection.isPending && testMessage?.success && (
<CheckCircle className="mr-2 h-4 w-4 text-emerald-500" /> <CheckCircle className="mr-2 h-4 w-4 text-success" />
)} )}
{!testBackendConnection.isPending && testMessage && !testMessage.success && ( {!testBackendConnection.isPending && testMessage && !testMessage.success && (
<XCircle className="mr-2 h-4 w-4 text-red-500" /> <XCircle className="mr-2 h-4 w-4 text-red-500" />
@ -255,7 +255,7 @@ export const CreateVolumeForm = ({ onSubmit, mode = "create", initialValues, for
{testMessage && ( {testMessage && (
<div <div
className={cn("text-xs p-2 rounded-md text-wrap wrap-anywhere", { className={cn("text-xs p-2 rounded-md text-wrap wrap-anywhere", {
"bg-green-50 text-emerald-700 border border-green-200": testMessage.success, "bg-success/10 text-success border border-success/30": testMessage.success,
"bg-red-50 text-red-700 border border-red-200": !testMessage.success, "bg-red-50 text-red-700 border border-red-200": !testMessage.success,
})} })}
> >

View file

@ -51,7 +51,7 @@ export const HealthchecksCard = ({ volume }: Props) => {
<CardContent> <CardContent>
<div className="flex flex-col flex-1 justify-start"> <div className="flex flex-col flex-1 justify-start">
{volume.lastError && <span className="text-sm text-red-500 wrap-break-word">{volume.lastError}</span>} {volume.lastError && <span className="text-sm text-red-500 wrap-break-word">{volume.lastError}</span>}
{volume.status === "mounted" && <span className="text-md text-green-500">Healthy</span>} {volume.status === "mounted" && <span className="text-md text-success">Healthy</span>}
{volume.status !== "unmounted" && ( {volume.status !== "unmounted" && (
<span className="text-xs text-muted-foreground mb-4">Checked {formatTimeAgo(volume.lastHealthCheck)}</span> <span className="text-xs text-muted-foreground mb-4">Checked {formatTimeAgo(volume.lastHealthCheck)}</span>
)} )}

View file

@ -134,7 +134,7 @@ export function VolumesPage() {
{filteredVolumes.map((volume) => ( {filteredVolumes.map((volume) => (
<TableRow <TableRow
key={volume.name} key={volume.name}
className="hover:bg-white/2 hover:cursor-pointer transition-colors h-12" className="hover:bg-muted/50 hover:cursor-pointer transition-colors h-12"
onClick={() => navigate({ to: `/volumes/${volume.shortId}` })} onClick={() => navigate({ to: `/volumes/${volume.shortId}` })}
> >
<TableCell className="font-medium font-mono text-strong-accent">{volume.name}</TableCell> <TableCell className="font-medium font-mono text-strong-accent">{volume.name}</TableCell>

View file

@ -6,11 +6,23 @@ import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
import { Toaster } from "~/client/components/ui/sonner"; import { Toaster } from "~/client/components/ui/sonner";
import { useServerEvents } from "~/client/hooks/use-server-events"; import { useServerEvents } from "~/client/hooks/use-server-events";
import { useEffect } from "react"; import { useEffect } from "react";
import { ThemeProvider, THEME_COOKIE_NAME } from "~/client/components/theme-provider";
import { createServerFn } from "@tanstack/react-start";
import { getCookie } from "@tanstack/react-start/server";
const fetchTheme = createServerFn({ method: "GET" }).handler(async () => {
const themeCookie = getCookie(THEME_COOKIE_NAME);
return (themeCookie === "light" ? "light" : "dark") as "light" | "dark";
});
export const Route = createRootRouteWithContext<{ queryClient: QueryClient }>()({ export const Route = createRootRouteWithContext<{ queryClient: QueryClient }>()({
server: { server: {
middleware: [apiClientMiddleware], middleware: [apiClientMiddleware],
}, },
loader: async () => {
const theme = await fetchTheme();
return { theme };
},
head: () => ({ head: () => ({
meta: [{ title: "Zerobyte - Open Source Backup Solution" }], meta: [{ title: "Zerobyte - Open Source Backup Solution" }],
links: [ links: [
@ -35,6 +47,7 @@ export const Route = createRootRouteWithContext<{ queryClient: QueryClient }>()(
}); });
function RootLayout() { function RootLayout() {
const { theme } = Route.useLoaderData();
useServerEvents(); useServerEvents();
useEffect(() => { useEffect(() => {
document.body.setAttribute("data-app-ready", "true"); document.body.setAttribute("data-app-ready", "true");
@ -44,7 +57,7 @@ function RootLayout() {
}, []); }, []);
return ( return (
<html lang="en"> <html lang="en" className={theme === "dark" ? "dark" : undefined} style={{ colorScheme: theme }}>
<head> <head>
<meta charSet="utf-8" /> <meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no" /> <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no" />
@ -56,10 +69,12 @@ function RootLayout() {
<link rel="manifest" href="/images/favicon/site.webmanifest" /> <link rel="manifest" href="/images/favicon/site.webmanifest" />
<HeadContent /> <HeadContent />
</head> </head>
<body className="dark"> <body>
<Outlet /> <ThemeProvider initialTheme={theme}>
<Toaster /> <Outlet />
<ReactQueryDevtools buttonPosition="bottom-right" /> <Toaster />
<ReactQueryDevtools buttonPosition="bottom-right" />
</ThemeProvider>
<Scripts /> <Scripts />
</body> </body>
</html> </html>

View file

@ -7,6 +7,7 @@ const flushMicrotasks = async () => {
}; };
const mockTimeZone = (timeZone: string) => { const mockTimeZone = (timeZone: string) => {
// oxlint-disable-next-line typescript/unbound-method
const resolvedOptions = Intl.DateTimeFormat.prototype.resolvedOptions; const resolvedOptions = Intl.DateTimeFormat.prototype.resolvedOptions;
return spyOn(Intl.DateTimeFormat.prototype, "resolvedOptions").mockImplementation( return spyOn(Intl.DateTimeFormat.prototype, "resolvedOptions").mockImplementation(
function (this: Intl.DateTimeFormat) { function (this: Intl.DateTimeFormat) {