style: redesign dashboard (#586)

style: redesign dashboard

style(tables): same height row in all pages
This commit is contained in:
Nico 2026-02-26 19:57:39 +01:00 committed by GitHub
parent 942bf2b31b
commit b2d2f28b40
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 233 additions and 171 deletions

View file

@ -53,6 +53,8 @@ export function AppSidebar() {
const { updates, hasUpdate } = useUpdates(); const { updates, hasUpdate } = useUpdates();
const [showReleaseNotes, setShowReleaseNotes] = useState(false); const [showReleaseNotes, setShowReleaseNotes] = useState(false);
const isCollapsed = state === "collapsed";
const displayVersion = APP_VERSION.startsWith("v") || APP_VERSION === "dev" ? APP_VERSION : `v${APP_VERSION}`; const displayVersion = APP_VERSION.startsWith("v") || APP_VERSION === "dev" ? APP_VERSION : `v${APP_VERSION}`;
const releaseUrl = const releaseUrl =
APP_VERSION === "dev" APP_VERSION === "dev"
@ -66,7 +68,7 @@ export function AppSidebar() {
<img src="/images/zerobyte.png" alt="Zerobyte Logo" className={cn("h-8 w-8 shrink-0 object-contain -ml-2")} /> <img src="/images/zerobyte.png" alt="Zerobyte Logo" className={cn("h-8 w-8 shrink-0 object-contain -ml-2")} />
<span <span
className={cn("text-base transition-all duration-200 -ml-1", { className={cn("text-base transition-all duration-200 -ml-1", {
"opacity-0 w-0 overflow-hidden ": state === "collapsed", "opacity-0 w-0 overflow-hidden ": isCollapsed,
})} })}
> >
Zerobyte Zerobyte
@ -82,18 +84,43 @@ export function AppSidebar() {
<TooltipProvider> <TooltipProvider>
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<SidebarMenuButton asChild> <SidebarMenuButton asChild className="relative overflow-hidden">
<Link to={item.url} onClick={() => isMobile && setOpenMobile(false)}> <Link
to={item.url}
onClick={() => isMobile && setOpenMobile(false)}
activeProps={{ className: "bg-strong-accent/10" }}
className="w-full flex items-center gap-2"
>
{({ isActive }) => ( {({ isActive }) => (
<> <>
<item.icon className={cn({ "text-strong-accent": isActive })} /> {isActive && (
<span className={cn({ "text-strong-accent": isActive })}>{item.title}</span> <div
className={cn("absolute left-0 top-0 h-full w-0.75 bg-strong-accent mr-2", {
hidden: isCollapsed,
})}
/>
)}
<item.icon
className={cn("transition-all duration-200", {
"text-strong-accent": isActive,
"ml-1": isActive && !isCollapsed,
"text-muted-foreground": !isActive,
})}
/>
<span
className={cn({
"text-white font-medium": isActive,
"text-muted-foreground": !isActive,
})}
>
{item.title}
</span>
</> </>
)} )}
</Link> </Link>
</SidebarMenuButton> </SidebarMenuButton>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side="right" className={cn({ hidden: state !== "collapsed" })}> <TooltipContent side="right" className={cn({ hidden: !isCollapsed })}>
<p>{item.title}</p> <p>{item.title}</p>
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>

View file

@ -9,16 +9,17 @@ interface GridBackgroundProps {
export function GridBackground({ children, className, containerClassName }: GridBackgroundProps) { export function GridBackground({ children, className, containerClassName }: GridBackgroundProps) {
return ( return (
<div <div className={cn("relative min-h-full w-full", containerClassName)}>
className={cn( <div
"relative min-h-full w-full", className={cn(
"bg-size-[40px_40px]", "pointer-events-none absolute inset-0 w-full h-full",
"bg-[linear-gradient(to_right,#e4e4e7_1px,transparent_1px),linear-gradient(to_bottom,#e4e4e7_1px,transparent_1px)]", "bg-[size:40px_40px]",
"dark:bg-[linear-gradient(to_right,#262626_1px,transparent_1px),linear-gradient(to_bottom,#262626_1px,transparent_1px)]", "bg-[linear-gradient(to_right,#e4e4e7_1px,transparent_1px),linear-gradient(to_bottom,#e4e4e7_1px,transparent_1px)]",
containerClassName, "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_40%,transparent_100%)]",
> )}
<div className={cn("relative container m-auto", className)}>{children}</div> />
<div className={cn("relative container m-auto z-10", className)}>{children}</div>
</div> </div>
); );
} }

View file

@ -1,4 +1,4 @@
import { LifeBuoy } from "lucide-react"; import { LifeBuoy, LogOut } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import { type AppContext } from "~/context"; import { type AppContext } from "~/context";
import { GridBackground } from "./grid-background"; import { GridBackground } from "./grid-background";
@ -9,6 +9,7 @@ import { authClient } from "../lib/auth-client";
import { DevPanelListener } from "./dev-panel-listener"; 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";
type Props = { type Props = {
loaderData: AppContext; loaderData: AppContext;
@ -34,34 +35,49 @@ export function Layout({ loaderData }: Props) {
<SidebarProvider defaultOpen={loaderData.sidebarOpen}> <SidebarProvider defaultOpen={loaderData.sidebarOpen}>
<AppSidebar /> <AppSidebar />
<div className="w-full relative flex flex-col min-h-screen md:h-screen md:overflow-hidden"> <div className="w-full relative flex flex-col min-h-screen md:h-screen md:overflow-hidden">
<header className="z-50 bg-card-header border-b border-border/50 shrink-0"> <header className="z-50 bg-card-header border-b border-border/50 shrink-0 h-16.25">
<div className="flex items-center justify-between py-3 sm:py-4 px-2 sm:px-8 mx-auto container gap-4"> <div className="flex items-center h-full justify-between px-2 sm:px-8 mx-auto container gap-4">
<div className="flex items-center gap-4 min-w-0"> <div className="flex items-center gap-4 min-w-0">
<SidebarTrigger /> <SidebarTrigger />
<AppBreadcrumb /> <AppBreadcrumb />
</div> </div>
{loaderData.user && ( {loaderData.user && (
<div className="flex items-center gap-4"> <div className="flex items-center bg-muted/30 border border-border/50 px-2 py-1 rounded-full shadow-sm">
<span className="text-sm text-muted-foreground hidden md:inline-flex"> <span className="text-sm text-muted-foreground hidden md:inline-flex pl-2 mr-5">
Welcome,&nbsp; <span className="text-foreground">{loaderData.user?.username}</span>
<span className="text-strong-accent">{loaderData.user?.username}</span>
</span> </span>
<Button variant="default" size="sm" onClick={handleLogout}> <Tooltip>
Logout <TooltipTrigger asChild>
</Button> <Button
<Button variant="default" size="sm" className="relative overflow-hidden hidden lg:inline-flex"> variant="ghost"
<a size="icon"
href="https://github.com/nicotsx/zerobyte/issues/new" className="rounded-full h-7 text-xs text-muted-foreground hover:text-white"
target="_blank" onClick={handleLogout}
rel="noreferrer" >
className="flex items-center gap-2" <LogOut className="w-4 h-4" />
> </Button>
<span className="flex items-center gap-2"> </TooltipTrigger>
<LifeBuoy /> <TooltipContent>Logout</TooltipContent>
<span>Report an issue</span> </Tooltip>
</span> <Tooltip>
</a> <TooltipTrigger asChild>
</Button> <Button
variant="ghost"
size="icon"
className="relative overflow-hidden hidden lg:inline-flex rounded-full h-7 w-7 text-muted-foreground hover:text-white"
>
<a
href="https://github.com/nicotsx/zerobyte/issues/new"
target="_blank"
rel="noreferrer"
className="flex items-center justify-center w-full h-full"
>
<LifeBuoy className="w-4 h-4" />
</a>
</Button>
</TooltipTrigger>
<TooltipContent>Report an issue</TooltipContent>
</Tooltip>
</div> </div>
)} )}
</div> </div>

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-green-200 bg-green-50 text-green-700 dark:border-green-500/40 dark:bg-green-500/10 dark:text-green-200" ? "border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-500/40 dark:bg-emerald-500/10 dark:text-emerald-200"
: "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

@ -12,7 +12,7 @@ interface StatusDotProps {
export const StatusDot = ({ variant, label, animated }: StatusDotProps) => { export const StatusDot = ({ variant, label, animated }: StatusDotProps) => {
const statusMapping = { const statusMapping = {
success: { success: {
color: "bg-green-500", color: "bg-emerald-500 shadow-[0_0_8px_rgba(16,185,129,0.5)]",
colorLight: "bg-emerald-400", colorLight: "bg-emerald-400",
animated: animated ?? true, animated: animated ?? true,
}, },
@ -45,7 +45,7 @@ export const StatusDot = ({ variant, label, animated }: StatusDotProps) => {
{statusMapping?.animated && ( {statusMapping?.animated && (
<span <span
className={cn( className={cn(
"absolute inline-flex h-full w-full animate-ping rounded-full opacity-75", "absolute inline-flex h-full w-full animate-[ping_3s_cubic-bezier(0,0,0.2,1)_infinite] rounded-full opacity-50",
`${statusMapping.colorLight}`, `${statusMapping.colorLight}`,
)} )}
/> />

View file

@ -7,11 +7,12 @@ import { cn } from "~/client/lib/utils";
import { useMinimumDuration } from "~/client/hooks/useMinimumDuration"; import { useMinimumDuration } from "~/client/hooks/useMinimumDuration";
const buttonVariants = cva( const buttonVariants = cva(
"inline-flex cursor-pointer uppercase rounded-sm items-center justify-center gap-2 whitespace-nowrap text-xs font-semibold tracking-wide transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-ring border-0", "inline-flex cursor-pointer uppercase rounded-sm items-center justify-center gap-2 whitespace-nowrap text-xs font-semibold tracking-wide transition-all active:scale-[0.98] disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-ring border-0",
{ {
variants: { variants: {
variant: { variant: {
default: "bg-transparent text-white hover:bg-[#3A3A3A]/80 border dark:text-white dark:hover:bg-[#3A3A3A]/80", default:
"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-white hover:bg-destructive/90 focus-visible:ring-destructive/50 text-destructive hover:text-white",

View file

@ -2,14 +2,25 @@ import type * as React from "react";
import { cn } from "~/client/lib/utils"; import { cn } from "~/client/lib/utils";
function Card({ className, children, ...props }: React.ComponentProps<"div">) { function Card({ className, children, interactive, ...props }: React.ComponentProps<"div"> & { interactive?: boolean }) {
return ( return (
<div <div
data-slot="card" data-slot="card"
className={cn("bg-card text-card-foreground relative flex flex-col gap-6 border-2 py-6 shadow-sm", className)} className={cn(
"bg-card text-card-foreground group relative flex flex-col gap-6 border border-border/80 py-6 shadow-[inset_0_0_0_1px_rgba(255,255,255,0.02)] transition-colors duration-300 ",
className,
)}
{...props} {...props}
> >
<span aria-hidden="true" className="pointer-events-none absolute inset-0 z-10 select-none"> <span
aria-hidden="true"
className={cn(
"pointer-events-none absolute inset-0 z-10 select-none opacity-30 transition-opacity duration-300 ",
{
"group-hover:opacity-100": interactive,
},
)}
>
<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-white/80" />
<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-white/80" />
<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-white/80" />

View file

@ -9,6 +9,7 @@ function Input({ className, type, ...props }: React.ComponentProps<"input">) {
data-slot="input" data-slot="input"
className={cn( className={cn(
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 border bg-transparent px-3 py-1 text-sm shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm", "file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 border bg-transparent px-3 py-1 text-sm shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
// "file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground bg-black/5 dark:bg-white/5 flex h-9 w-full min-w-0 border-0 px-3 py-1 text-sm shadow-xs dark:shadow-[inset_0_1px_0_rgba(255,255,255,0.05)] transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]", "focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", "aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className, className,

View file

@ -29,7 +29,7 @@ function SelectTrigger({
data-slot="select-trigger" data-slot="select-trigger"
data-size={size} data-size={size}
className={cn( className={cn(
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", "border-input data-placeholder:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className, className,
)} )}
{...props} {...props}

View file

@ -33,7 +33,7 @@ function TabsTrigger({ className, ...props }: React.ComponentProps<typeof TabsPr
// 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-[#5D6570] before:transition-colors data-[state=active]:before:bg-[#FF453A]",
// Left bracket - top tick // Left bracket - top tick
"after:absolute after:left-0 after:top-[-1px] 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-[#5D6570] after:transition-colors data-[state=active]:after:bg-[#FF453A]",
className, className,
)} )}
{...props} {...props}

View file

@ -49,7 +49,7 @@ export const VolumeIcon = ({ backend }: VolumeIconProps) => {
const { icon: Icon, label } = getIconAndLabel(backend); const { icon: Icon, label } = getIconAndLabel(backend);
return ( return (
<span className={`flex items-center gap-2 rounded-md px-2 py-1`}> <span className={`flex items-center gap-2 rounded-md py-1`}>
<Icon className="h-4 w-4" /> <Icon className="h-4 w-4" />
{label} {label}
</span> </span>

View file

@ -8,7 +8,7 @@ import { Link } from "@tanstack/react-router";
export const BackupCard = ({ schedule }: { schedule: BackupSchedule }) => { export const BackupCard = ({ schedule }: { schedule: BackupSchedule }) => {
return ( return (
<Link key={schedule.shortId} to="/backups/$backupId" params={{ backupId: schedule.shortId }}> <Link key={schedule.shortId} to="/backups/$backupId" params={{ backupId: schedule.shortId }}>
<Card key={schedule.shortId} className="flex flex-col h-full"> <Card interactive key={schedule.shortId} className="flex flex-col h-full">
<CardHeader className="pb-3 overflow-hidden"> <CardHeader className="pb-3 overflow-hidden">
<div className="flex items-center justify-between gap-2 w-full"> <div className="flex items-center justify-between gap-2 w-full">
<div className="flex items-center gap-2 flex-1 min-w-0 w-0"> <div className="flex items-center gap-2 flex-1 min-w-0 w-0">
@ -23,25 +23,32 @@ export const BackupCard = ({ schedule }: { schedule: BackupSchedule }) => {
</div> </div>
<CardDescription className="ml-0.5 flex items-center gap-2 text-xs min-w-0"> <CardDescription className="ml-0.5 flex items-center gap-2 text-xs min-w-0">
<HardDrive className="h-3.5 w-3.5 shrink-0" /> <HardDrive className="h-3.5 w-3.5 shrink-0" />
<span className="truncate">{schedule.volume.name}</span> <span className="truncate font-mono">{schedule.volume.name}</span>
<span className="text-muted-foreground shrink-0"></span> <span className="text-muted-foreground shrink-0"></span>
<Database className="h-3.5 w-3.5 text-strong-accent shrink-0" /> <Database className="h-3.5 w-3.5 text-strong-accent shrink-0" />
<span className="truncate text-strong-accent">{schedule.repository.name}</span> <span className="truncate text-strong-accent font-mono">{schedule.repository.name}</span>
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent className="flex-1 space-y-4"> <CardContent className="flex-1 space-y-4">
<div className="space-y-2"> <div className="space-y-3">
<div className="flex items-center justify-between text-sm"> <div className="flex items-center text-sm gap-2">
<span className="text-muted-foreground">Schedule</span> <span className="text-zinc-500 shrink-0">Schedule</span>
<code className="text-xs bg-muted px-2 py-1 rounded">{schedule.cronExpression}</code> <div className="flex-1 border-b border-dashed border-white/10" />
<code className="text-xs text-zinc-100 font-mono bg-muted px-2 py-1 rounded shrink-0">
{schedule.cronExpression}
</code>
</div> </div>
<div className="flex items-center justify-between text-sm"> <div className="flex items-center text-sm gap-2">
<span className="text-muted-foreground">Last backup</span> <span className="text-zinc-500 shrink-0">Last backup</span>
<span className="font-medium">{formatTimeAgo(schedule.lastBackupAt)}</span> <div className="flex-1 border-b border-dashed border-white/10" />
<span className="text-zinc-100 font-mono text-sm shrink-0">{formatTimeAgo(schedule.lastBackupAt)}</span>
</div> </div>
<div className="flex items-center justify-between text-sm"> <div className="flex items-center text-sm gap-2">
<span className="text-muted-foreground">Next backup</span> <span className="text-zinc-500 shrink-0">Next backup</span>
<span className="font-medium">{formatShortDateTime(schedule.nextBackupAt)}</span> <div className="flex-1 border-b border-dashed border-white/10" />
<span className="text-zinc-100 font-mono text-sm shrink-0">
{formatShortDateTime(schedule.nextBackupAt)}
</span>
</div> </div>
</div> </div>
</CardContent> </CardContent>

View file

@ -13,7 +13,6 @@ import { CalendarClock, Plus } from "lucide-react";
import { useState } from "react"; import { useState } from "react";
import { EmptyState } from "~/client/components/empty-state"; import { EmptyState } from "~/client/components/empty-state";
import { Button } from "~/client/components/ui/button"; import { Button } from "~/client/components/ui/button";
import { Card, CardContent } from "~/client/components/ui/card";
import { import {
listBackupSchedulesOptions, listBackupSchedulesOptions,
reorderBackupSchedulesMutation, reorderBackupSchedulesMutation,
@ -110,13 +109,17 @@ export function BackupsPage() {
</SortableCard> </SortableCard>
); );
})} })}
<Link to="/backups/create"> <Link to="/backups/create" className="h-full">
<Card className="flex flex-col items-center justify-center h-full hover:bg-muted/50 transition-colors cursor-pointer"> <div className="group flex flex-col items-center justify-center h-full min-h-50 border border-dashed border-border/80 bg-card hover:bg-card/50 transition-colors cursor-pointer rounded-s shadow-sm hover:border-border">
<CardContent className="flex flex-col items-center justify-center gap-2"> <div className="flex flex-col items-center justify-center gap-3">
<Plus className="h-8 w-8 text-muted-foreground" /> <div className="p-3 rounded-full bg-muted/20 group-hover:bg-muted/50 transition-all group-hover:scale-110 duration-300">
<span className="text-sm font-medium text-muted-foreground">Create a backup job</span> <Plus className="h-6 w-6 text-muted-foreground group-hover:text-foreground transition-colors" />
</CardContent> </div>
</Card> <span className="text-sm font-medium text-muted-foreground group-hover:text-foreground transition-colors">
Create a backup job
</span>
</div>
</div>
</Link> </Link>
</div> </div>
</SortableContext> </SortableContext>

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-green-500": data.enabled, "bg-green-500/10 text-emerald-500": data.enabled,
"bg-red-500/10 text-red-500": !data.enabled, "bg-red-500/10 text-red-500": !data.enabled,
})} })}
> >

View file

@ -10,6 +10,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "~/client/components/ui/table"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "~/client/components/ui/table";
import { listNotificationDestinationsOptions } from "~/client/api-client/@tanstack/react-query.gen"; import { listNotificationDestinationsOptions } from "~/client/api-client/@tanstack/react-query.gen";
import { useNavigate } from "@tanstack/react-router"; import { useNavigate } from "@tanstack/react-router";
import { cn } from "~/client/lib/utils";
export function NotificationsPage() { export function NotificationsPage() {
const [searchQuery, setSearchQuery] = useState(""); const [searchQuery, setSearchQuery] = useState("");
@ -57,15 +58,15 @@ export function NotificationsPage() {
return ( return (
<Card className="p-0 gap-0"> <Card className="p-0 gap-0">
<div className="flex flex-col lg:flex-row items-stretch lg:items-center gap-2 md:justify-between p-4 bg-card-header py-4"> <div className="flex flex-col lg:flex-row items-stretch lg:items-center gap-2 md:justify-between p-4 bg-card-header py-4">
<span className="flex flex-col sm:flex-row items-stretch md:items-center gap-0 flex-wrap "> <span className="flex flex-col sm:flex-row items-stretch md:items-center gap-2 flex-wrap">
<Input <Input
className="w-full lg:w-45 min-w-45 -mr-px -mt-px" className="w-full lg:w-45 min-w-45"
placeholder="Search destinations…" placeholder="Search…"
value={searchQuery} value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)} onChange={(e) => setSearchQuery(e.target.value)}
/> />
<Select value={typeFilter} onValueChange={setTypeFilter}> <Select value={typeFilter} onValueChange={setTypeFilter}>
<SelectTrigger className="w-full lg:w-45 min-w-45 -mr-px -mt-px"> <SelectTrigger className="w-full lg:w-45 min-w-45">
<SelectValue placeholder="All types" /> <SelectValue placeholder="All types" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
@ -80,7 +81,7 @@ export function NotificationsPage() {
</SelectContent> </SelectContent>
</Select> </Select>
<Select value={statusFilter} onValueChange={setStatusFilter}> <Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger className="w-full lg:w-45 min-w-45 -mt-px"> <SelectTrigger className="w-full lg:w-45 min-w-45">
<SelectValue placeholder="All status" /> <SelectValue placeholder="All status" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
@ -110,36 +111,33 @@ export function NotificationsPage() {
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{hasNoFilteredNotifications ? ( <TableRow className={cn({ hidden: !hasNoFilteredNotifications })}>
<TableRow> <TableCell colSpan={3} className="text-center py-12">
<TableCell colSpan={3} className="text-center py-12"> <div className="flex flex-col items-center gap-3">
<div className="flex flex-col items-center gap-3"> <p className="text-muted-foreground">No destinations match your filters.</p>
<p className="text-muted-foreground">No destinations match your filters.</p> <Button onClick={clearFilters} variant="outline" size="sm">
<Button onClick={clearFilters} variant="outline" size="sm"> <RotateCcw className="h-4 w-4 mr-2" />
<RotateCcw className="h-4 w-4 mr-2" /> Clear filters
Clear filters </Button>
</Button> </div>
</div> </TableCell>
</TableRow>
{filteredNotifications.map((notification) => (
<TableRow
key={notification.id}
className="hover:bg-accent/50 hover:cursor-pointer h-12"
onClick={() => navigate({ to: `/notifications/${notification.id}` })}
>
<TableCell className="font-medium text-strong-accent">{notification.name}</TableCell>
<TableCell className="capitalize text-muted-foreground">{notification.type}</TableCell>
<TableCell className="text-center">
<StatusDot
variant={notification.enabled ? "success" : "neutral"}
label={notification.enabled ? "Enabled" : "Disabled"}
/>
</TableCell> </TableCell>
</TableRow> </TableRow>
) : ( ))}
filteredNotifications.map((notification) => (
<TableRow
key={notification.id}
className="hover:bg-accent/50 hover:cursor-pointer"
onClick={() => navigate({ to: `/notifications/${notification.id}` })}
>
<TableCell className="font-medium text-strong-accent">{notification.name}</TableCell>
<TableCell className="capitalize">{notification.type}</TableCell>
<TableCell className="text-center">
<StatusDot
variant={notification.enabled ? "success" : "neutral"}
label={notification.enabled ? "Enabled" : "Disabled"}
/>
</TableCell>
</TableRow>
))
)}
</TableBody> </TableBody>
</Table> </Table>
</div> </div>

View file

@ -58,15 +58,15 @@ export function RepositoriesPage() {
return ( return (
<Card className="p-0 gap-0"> <Card className="p-0 gap-0">
<div className="flex flex-col lg:flex-row items-stretch lg:items-center gap-2 md:justify-between p-4 bg-card-header py-4"> <div className="flex flex-col lg:flex-row items-stretch lg:items-center gap-2 md:justify-between p-4 bg-card-header py-4">
<span className="flex flex-col sm:flex-row items-stretch md:items-center gap-0 flex-wrap "> <span className="flex flex-col sm:flex-row items-stretch md:items-center gap-2 flex-wrap">
<Input <Input
className="w-full lg:w-[180px] min-w-[180px] -mr-px -mt-px" className="w-full lg:w-45 min-w-45"
placeholder="Search repositories…" placeholder="Search…"
value={searchQuery} value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)} onChange={(e) => setSearchQuery(e.target.value)}
/> />
<Select value={statusFilter} onValueChange={setStatusFilter}> <Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger className="w-full lg:w-[180px] min-w-[180px] -mr-px -mt-px"> <SelectTrigger className="w-full lg:w-45 min-w-45">
<SelectValue placeholder="All status" /> <SelectValue placeholder="All status" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
@ -76,7 +76,7 @@ export function RepositoriesPage() {
</SelectContent> </SelectContent>
</Select> </Select>
<Select value={backendFilter} onValueChange={setBackendFilter}> <Select value={backendFilter} onValueChange={setBackendFilter}>
<SelectTrigger className="w-full lg:w-[180px] min-w-[180px] -mt-px"> <SelectTrigger className="w-full lg:w-45 min-w-45">
<SelectValue placeholder="All backends" /> <SelectValue placeholder="All backends" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
@ -109,53 +109,50 @@ export function RepositoriesPage() {
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{hasNoFilteredRepositories ? ( <TableRow className={cn({ hidden: !hasNoFilteredRepositories })}>
<TableRow> <TableCell colSpan={4} className="text-center py-12">
<TableCell colSpan={4} className="text-center py-12"> <div className="flex flex-col items-center gap-3">
<div className="flex flex-col items-center gap-3"> <p className="text-muted-foreground">No repositories match your filters.</p>
<p className="text-muted-foreground">No repositories match your filters.</p> <Button onClick={clearFilters} variant="outline" size="sm">
<Button onClick={clearFilters} variant="outline" size="sm"> <RotateCcw className="h-4 w-4 mr-2" />
<RotateCcw className="h-4 w-4 mr-2" /> Clear filters
Clear filters </Button>
</Button> </div>
</div> </TableCell>
</TableRow>
{filteredRepositories.map((repository) => (
<TableRow
key={repository.id}
className="hover:bg-accent/50 hover:cursor-pointer h-12"
onClick={() => navigate({ to: `/repositories/${repository.shortId}` })}
>
<TableCell className="font-medium text-strong-accent">{repository.name}</TableCell>
<TableCell>
<span className="flex items-center gap-2 text-muted-foreground">
<RepositoryIcon backend={repository.type} />
{repository.type}
</span>
</TableCell>
<TableCell className="hidden sm:table-cell">
<span className="text-muted-foreground text-xs bg-primary/10 rounded-md px-2 py-1">
{repository.compressionMode || "off"}
</span>
</TableCell>
<TableCell className="text-center">
<span
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": repository.status === "healthy",
"bg-red-500/10 text-red-500": repository.status === "error",
},
)}
>
{repository.status || "unknown"}
</span>
</TableCell> </TableCell>
</TableRow> </TableRow>
) : ( ))}
filteredRepositories.map((repository) => (
<TableRow
key={repository.id}
className="hover:bg-accent/50 hover:cursor-pointer"
onClick={() => navigate({ to: `/repositories/${repository.shortId}` })}
>
<TableCell className="font-medium text-strong-accent">{repository.name}</TableCell>
<TableCell>
<span className="flex items-center gap-2">
<RepositoryIcon backend={repository.type} />
{repository.type}
</span>
</TableCell>
<TableCell className="hidden sm:table-cell">
<span className="text-muted-foreground text-xs bg-primary/10 rounded-md px-2 py-1">
{repository.compressionMode || "off"}
</span>
</TableCell>
<TableCell className="text-center">
<span
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-green-500": repository.status === "healthy",
"bg-red-500/10 text-red-500": repository.status === "error",
},
)}
>
{repository.status || "unknown"}
</span>
</TableCell>
</TableRow>
))
)}
</TableBody> </TableBody>
</Table> </Table>
</div> </div>

View file

@ -144,7 +144,7 @@ export function UserManagement() {
<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("text-green-600 border-green-600", { hidden: user.banned })}> <Badge variant="outline" className={cn("bg-green-500/10 text-emerald-500", { 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-green-500" /> <CheckCircle className="mr-2 h-4 w-4 text-emerald-500" />
)} )}
{!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-green-700 border border-green-200": testMessage.success, "bg-green-50 text-emerald-700 border border-green-200": 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

@ -70,15 +70,15 @@ export function VolumesPage() {
return ( return (
<Card className="p-0 gap-0"> <Card className="p-0 gap-0">
<div className="flex flex-col lg:flex-row items-stretch lg:items-center gap-2 md:justify-between p-4 bg-card-header py-4"> <div className="flex flex-col lg:flex-row items-stretch lg:items-center gap-2 md:justify-between p-4 bg-card-header py-4">
<span className="flex flex-col sm:flex-row items-stretch md:items-center gap-0 flex-wrap "> <span className="flex flex-col sm:flex-row items-stretch md:items-center gap-2 flex-wrap">
<Input <Input
className="w-full lg:w-45 min-w-45 -mr-px -mt-px" className="w-full lg:w-45 min-w-45"
placeholder="Search volumes…" placeholder="Search…"
value={searchQuery} value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)} onChange={(e) => setSearchQuery(e.target.value)}
/> />
<Select value={statusFilter} onValueChange={setStatusFilter}> <Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger className="w-full lg:w-45 min-w-45 -mr-px -mt-px"> <SelectTrigger className="w-full lg:w-45 min-w-45">
<SelectValue placeholder="All status" /> <SelectValue placeholder="All status" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
@ -88,7 +88,7 @@ export function VolumesPage() {
</SelectContent> </SelectContent>
</Select> </Select>
<Select value={backendFilter} onValueChange={setBackendFilter}> <Select value={backendFilter} onValueChange={setBackendFilter}>
<SelectTrigger className="w-full lg:w-45 min-w-45 -mt-px"> <SelectTrigger className="w-full lg:w-45 min-w-45">
<SelectValue placeholder="All backends" /> <SelectValue placeholder="All backends" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
@ -135,14 +135,14 @@ export function VolumesPage() {
filteredVolumes.map((volume) => ( filteredVolumes.map((volume) => (
<TableRow <TableRow
key={volume.name} key={volume.name}
className="hover:bg-accent/50 hover:cursor-pointer" className="hover:bg-white/2 hover:cursor-pointer transition-colors border-l-2 border-r-2 border-transparent hover:border-white/10 h-12"
onClick={() => navigate({ to: `/volumes/${volume.shortId}` })} onClick={() => navigate({ to: `/volumes/${volume.shortId}` })}
> >
<TableCell className="font-medium text-strong-accent">{volume.name}</TableCell> <TableCell className="font-medium font-mono text-strong-accent">{volume.name}</TableCell>
<TableCell> <TableCell className="font-mono text-muted-foreground">
<VolumeIcon backend={volume.type} /> <VolumeIcon backend={volume.type} />
</TableCell> </TableCell>
<TableCell className="text-center"> <TableCell className="text-center font-mono">
<StatusDot <StatusDot
variant={getVolumeStatusVariant(volume.status)} variant={getVolumeStatusVariant(volume.status)}
label={volume.status[0].toUpperCase() + volume.status.slice(1)} label={volume.status[0].toUpperCase() + volume.status.slice(1)}
@ -154,12 +154,12 @@ export function VolumesPage() {
</TableBody> </TableBody>
</Table> </Table>
</div> </div>
<div className="px-4 py-2 text-sm text-muted-foreground bg-card-header flex justify-end border-t"> <div className="px-4 py-2 text-sm text-muted-foreground bg-card-header flex justify-end border-t font-mono">
{hasNoFilteredVolumes ? ( {hasNoFilteredVolumes ? (
"No volumes match filters." "No volumes match filters."
) : ( ) : (
<span> <span className="font-mono">
<span className="text-strong-accent">{filteredVolumes.length}</span> volume <span className="text-strong-accent font-bold">{filteredVolumes.length}</span> volume
{filteredVolumes.length > 1 ? "s" : ""} {filteredVolumes.length > 1 ? "s" : ""}
</span> </span>
)} )}

View file

@ -13,7 +13,7 @@ export const fetchUser = createServerFn({ method: "GET" }).handler(async () => {
const hasUsers = await authService.hasUsers(); const hasUsers = await authService.hasUsers();
const sidebarCookie = getCookie(SIDEBAR_COOKIE_NAME); const sidebarCookie = getCookie(SIDEBAR_COOKIE_NAME);
const sidebarOpen = sidebarCookie === null ? true : sidebarCookie === "true"; const sidebarOpen = !sidebarCookie ? true : sidebarCookie === "true";
return { user: session?.user ?? null, hasUsers, sidebarOpen }; return { user: session?.user ?? null, hasUsers, sidebarOpen };
}); });