style: redesign dashboard (#586)
style: redesign dashboard style(tables): same height row in all pages
This commit is contained in:
parent
942bf2b31b
commit
b2d2f28b40
20 changed files with 233 additions and 171 deletions
|
|
@ -53,6 +53,8 @@ export function AppSidebar() {
|
|||
const { updates, hasUpdate } = useUpdates();
|
||||
const [showReleaseNotes, setShowReleaseNotes] = useState(false);
|
||||
|
||||
const isCollapsed = state === "collapsed";
|
||||
|
||||
const displayVersion = APP_VERSION.startsWith("v") || APP_VERSION === "dev" ? APP_VERSION : `v${APP_VERSION}`;
|
||||
const releaseUrl =
|
||||
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")} />
|
||||
<span
|
||||
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
|
||||
|
|
@ -82,18 +84,43 @@ export function AppSidebar() {
|
|||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<SidebarMenuButton asChild>
|
||||
<Link to={item.url} onClick={() => isMobile && setOpenMobile(false)}>
|
||||
<SidebarMenuButton asChild className="relative overflow-hidden">
|
||||
<Link
|
||||
to={item.url}
|
||||
onClick={() => isMobile && setOpenMobile(false)}
|
||||
activeProps={{ className: "bg-strong-accent/10" }}
|
||||
className="w-full flex items-center gap-2"
|
||||
>
|
||||
{({ isActive }) => (
|
||||
<>
|
||||
<item.icon className={cn({ "text-strong-accent": isActive })} />
|
||||
<span className={cn({ "text-strong-accent": isActive })}>{item.title}</span>
|
||||
{isActive && (
|
||||
<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>
|
||||
</SidebarMenuButton>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right" className={cn({ hidden: state !== "collapsed" })}>
|
||||
<TooltipContent side="right" className={cn({ hidden: !isCollapsed })}>
|
||||
<p>{item.title}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
|
|
|||
|
|
@ -9,16 +9,17 @@ interface GridBackgroundProps {
|
|||
|
||||
export function GridBackground({ children, className, containerClassName }: GridBackgroundProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"relative min-h-full w-full",
|
||||
"bg-size-[40px_40px]",
|
||||
"bg-[linear-gradient(to_right,#e4e4e7_1px,transparent_1px),linear-gradient(to_bottom,#e4e4e7_1px,transparent_1px)]",
|
||||
"dark:bg-[linear-gradient(to_right,#262626_1px,transparent_1px),linear-gradient(to_bottom,#262626_1px,transparent_1px)]",
|
||||
containerClassName,
|
||||
)}
|
||||
>
|
||||
<div className={cn("relative container m-auto", className)}>{children}</div>
|
||||
<div className={cn("relative min-h-full w-full", containerClassName)}>
|
||||
<div
|
||||
className={cn(
|
||||
"pointer-events-none absolute inset-0 w-full h-full",
|
||||
"bg-[size:40px_40px]",
|
||||
"bg-[linear-gradient(to_right,#e4e4e7_1px,transparent_1px),linear-gradient(to_bottom,#e4e4e7_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_40%,transparent_100%)]",
|
||||
)}
|
||||
/>
|
||||
<div className={cn("relative container m-auto z-10", className)}>{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { LifeBuoy } from "lucide-react";
|
||||
import { LifeBuoy, LogOut } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { type AppContext } from "~/context";
|
||||
import { GridBackground } from "./grid-background";
|
||||
|
|
@ -9,6 +9,7 @@ import { authClient } from "../lib/auth-client";
|
|||
import { DevPanelListener } from "./dev-panel-listener";
|
||||
import { Outlet, useNavigate } from "@tanstack/react-router";
|
||||
import { AppBreadcrumb } from "./app-breadcrumb";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
|
||||
|
||||
type Props = {
|
||||
loaderData: AppContext;
|
||||
|
|
@ -34,34 +35,49 @@ export function Layout({ loaderData }: Props) {
|
|||
<SidebarProvider defaultOpen={loaderData.sidebarOpen}>
|
||||
<AppSidebar />
|
||||
<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">
|
||||
<div className="flex items-center justify-between py-3 sm:py-4 px-2 sm:px-8 mx-auto container gap-4">
|
||||
<header className="z-50 bg-card-header border-b border-border/50 shrink-0 h-16.25">
|
||||
<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">
|
||||
<SidebarTrigger />
|
||||
<AppBreadcrumb />
|
||||
</div>
|
||||
{loaderData.user && (
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-sm text-muted-foreground hidden md:inline-flex">
|
||||
Welcome,
|
||||
<span className="text-strong-accent">{loaderData.user?.username}</span>
|
||||
<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 pl-2 mr-5">
|
||||
<span className="text-foreground">{loaderData.user?.username}</span>
|
||||
</span>
|
||||
<Button variant="default" size="sm" onClick={handleLogout}>
|
||||
Logout
|
||||
</Button>
|
||||
<Button variant="default" size="sm" className="relative overflow-hidden hidden lg:inline-flex">
|
||||
<a
|
||||
href="https://github.com/nicotsx/zerobyte/issues/new"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<LifeBuoy />
|
||||
<span>Report an issue</span>
|
||||
</span>
|
||||
</a>
|
||||
</Button>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="rounded-full h-7 text-xs text-muted-foreground hover:text-white"
|
||||
onClick={handleLogout}
|
||||
>
|
||||
<LogOut className="w-4 h-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Logout</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<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>
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ export const OnOff = ({ isOn, toggle, enabledLabel, disabledLabel, disabled }: P
|
|||
className={cn(
|
||||
"flex items-center gap-2 rounded-full border px-3 py-1.5 text-xs font-semibold uppercase tracking-wide transition-colors",
|
||||
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",
|
||||
)}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ interface StatusDotProps {
|
|||
export const StatusDot = ({ variant, label, animated }: StatusDotProps) => {
|
||||
const statusMapping = {
|
||||
success: {
|
||||
color: "bg-green-500",
|
||||
color: "bg-emerald-500 shadow-[0_0_8px_rgba(16,185,129,0.5)]",
|
||||
colorLight: "bg-emerald-400",
|
||||
animated: animated ?? true,
|
||||
},
|
||||
|
|
@ -45,7 +45,7 @@ export const StatusDot = ({ variant, label, animated }: StatusDotProps) => {
|
|||
{statusMapping?.animated && (
|
||||
<span
|
||||
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}`,
|
||||
)}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -7,11 +7,12 @@ import { cn } from "~/client/lib/utils";
|
|||
import { useMinimumDuration } from "~/client/hooks/useMinimumDuration";
|
||||
|
||||
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: {
|
||||
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",
|
||||
destructive:
|
||||
"border border-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/50 text-destructive hover:text-white",
|
||||
|
|
|
|||
|
|
@ -2,14 +2,25 @@ import type * as React from "react";
|
|||
|
||||
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 (
|
||||
<div
|
||||
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}
|
||||
>
|
||||
<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-4 w-0.5 bg-white/80" />
|
||||
<span className="absolute -right-0.5 -top-0.5 h-0.5 w-4 bg-white/80" />
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
|||
data-slot="input"
|
||||
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 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]",
|
||||
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
className,
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ function SelectTrigger({
|
|||
data-slot="select-trigger"
|
||||
data-size={size}
|
||||
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,
|
||||
)}
|
||||
{...props}
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@ function TabsTrigger({ className, ...props }: React.ComponentProps<typeof TabsPr
|
|||
// 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]",
|
||||
// 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,
|
||||
)}
|
||||
{...props}
|
||||
|
|
|
|||
|
|
@ -49,7 +49,7 @@ export const VolumeIcon = ({ backend }: VolumeIconProps) => {
|
|||
const { icon: Icon, label } = getIconAndLabel(backend);
|
||||
|
||||
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" />
|
||||
{label}
|
||||
</span>
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import { Link } from "@tanstack/react-router";
|
|||
export const BackupCard = ({ schedule }: { schedule: BackupSchedule }) => {
|
||||
return (
|
||||
<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">
|
||||
<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">
|
||||
|
|
@ -23,25 +23,32 @@ export const BackupCard = ({ schedule }: { schedule: BackupSchedule }) => {
|
|||
</div>
|
||||
<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" />
|
||||
<span className="truncate">{schedule.volume.name}</span>
|
||||
<span className="truncate font-mono">{schedule.volume.name}</span>
|
||||
<span className="text-muted-foreground shrink-0">→</span>
|
||||
<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>
|
||||
</CardHeader>
|
||||
<CardContent className="flex-1 space-y-4">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">Schedule</span>
|
||||
<code className="text-xs bg-muted px-2 py-1 rounded">{schedule.cronExpression}</code>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center text-sm gap-2">
|
||||
<span className="text-zinc-500 shrink-0">Schedule</span>
|
||||
<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 className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">Last backup</span>
|
||||
<span className="font-medium">{formatTimeAgo(schedule.lastBackupAt)}</span>
|
||||
<div className="flex items-center text-sm gap-2">
|
||||
<span className="text-zinc-500 shrink-0">Last backup</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 className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">Next backup</span>
|
||||
<span className="font-medium">{formatShortDateTime(schedule.nextBackupAt)}</span>
|
||||
<div className="flex items-center text-sm gap-2">
|
||||
<span className="text-zinc-500 shrink-0">Next backup</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>
|
||||
</CardContent>
|
||||
|
|
|
|||
|
|
@ -13,7 +13,6 @@ import { CalendarClock, Plus } from "lucide-react";
|
|||
import { useState } from "react";
|
||||
import { EmptyState } from "~/client/components/empty-state";
|
||||
import { Button } from "~/client/components/ui/button";
|
||||
import { Card, CardContent } from "~/client/components/ui/card";
|
||||
import {
|
||||
listBackupSchedulesOptions,
|
||||
reorderBackupSchedulesMutation,
|
||||
|
|
@ -110,13 +109,17 @@ export function BackupsPage() {
|
|||
</SortableCard>
|
||||
);
|
||||
})}
|
||||
<Link to="/backups/create">
|
||||
<Card className="flex flex-col items-center justify-center h-full hover:bg-muted/50 transition-colors cursor-pointer">
|
||||
<CardContent className="flex flex-col items-center justify-center gap-2">
|
||||
<Plus className="h-8 w-8 text-muted-foreground" />
|
||||
<span className="text-sm font-medium text-muted-foreground">Create a backup job</span>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Link to="/backups/create" className="h-full">
|
||||
<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">
|
||||
<div className="flex flex-col items-center justify-center gap-3">
|
||||
<div className="p-3 rounded-full bg-muted/20 group-hover:bg-muted/50 transition-all group-hover:scale-110 duration-300">
|
||||
<Plus className="h-6 w-6 text-muted-foreground group-hover:text-foreground transition-colors" />
|
||||
</div>
|
||||
<span className="text-sm font-medium text-muted-foreground group-hover:text-foreground transition-colors">
|
||||
Create a backup job
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
</SortableContext>
|
||||
|
|
|
|||
|
|
@ -97,7 +97,7 @@ export function NotificationDetailsPage({ notificationId }: { notificationId: st
|
|||
<div className="text-sm font-semibold text-muted-foreground flex items-center gap-2">
|
||||
<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": data.enabled,
|
||||
"bg-green-500/10 text-emerald-500": data.enabled,
|
||||
"bg-red-500/10 text-red-500": !data.enabled,
|
||||
})}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~
|
|||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "~/client/components/ui/table";
|
||||
import { listNotificationDestinationsOptions } from "~/client/api-client/@tanstack/react-query.gen";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
import { cn } from "~/client/lib/utils";
|
||||
|
||||
export function NotificationsPage() {
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
|
|
@ -57,15 +58,15 @@ export function NotificationsPage() {
|
|||
return (
|
||||
<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">
|
||||
<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
|
||||
className="w-full lg:w-45 min-w-45 -mr-px -mt-px"
|
||||
placeholder="Search destinations…"
|
||||
className="w-full lg:w-45 min-w-45"
|
||||
placeholder="Search…"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
<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" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
|
|
@ -80,7 +81,7 @@ export function NotificationsPage() {
|
|||
</SelectContent>
|
||||
</Select>
|
||||
<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" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
|
|
@ -110,36 +111,33 @@ export function NotificationsPage() {
|
|||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{hasNoFilteredNotifications ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={3} className="text-center py-12">
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<p className="text-muted-foreground">No destinations match your filters.</p>
|
||||
<Button onClick={clearFilters} variant="outline" size="sm">
|
||||
<RotateCcw className="h-4 w-4 mr-2" />
|
||||
Clear filters
|
||||
</Button>
|
||||
</div>
|
||||
<TableRow className={cn({ hidden: !hasNoFilteredNotifications })}>
|
||||
<TableCell colSpan={3} className="text-center py-12">
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<p className="text-muted-foreground">No destinations match your filters.</p>
|
||||
<Button onClick={clearFilters} variant="outline" size="sm">
|
||||
<RotateCcw className="h-4 w-4 mr-2" />
|
||||
Clear filters
|
||||
</Button>
|
||||
</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>
|
||||
</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>
|
||||
</Table>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -58,15 +58,15 @@ export function RepositoriesPage() {
|
|||
return (
|
||||
<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">
|
||||
<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
|
||||
className="w-full lg:w-[180px] min-w-[180px] -mr-px -mt-px"
|
||||
placeholder="Search repositories…"
|
||||
className="w-full lg:w-45 min-w-45"
|
||||
placeholder="Search…"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
<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" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
|
|
@ -76,7 +76,7 @@ export function RepositoriesPage() {
|
|||
</SelectContent>
|
||||
</Select>
|
||||
<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" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
|
|
@ -109,53 +109,50 @@ export function RepositoriesPage() {
|
|||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{hasNoFilteredRepositories ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={4} className="text-center py-12">
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<p className="text-muted-foreground">No repositories match your filters.</p>
|
||||
<Button onClick={clearFilters} variant="outline" size="sm">
|
||||
<RotateCcw className="h-4 w-4 mr-2" />
|
||||
Clear filters
|
||||
</Button>
|
||||
</div>
|
||||
<TableRow className={cn({ hidden: !hasNoFilteredRepositories })}>
|
||||
<TableCell colSpan={4} className="text-center py-12">
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<p className="text-muted-foreground">No repositories match your filters.</p>
|
||||
<Button onClick={clearFilters} variant="outline" size="sm">
|
||||
<RotateCcw className="h-4 w-4 mr-2" />
|
||||
Clear filters
|
||||
</Button>
|
||||
</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>
|
||||
</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>
|
||||
</Table>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -144,7 +144,7 @@ export function UserManagement() {
|
|||
<Badge variant="outline" className={cn("text-red-500 border-red-500", { hidden: !user.banned })}>
|
||||
Banned
|
||||
</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
|
||||
</Badge>
|
||||
</TableCell>
|
||||
|
|
|
|||
|
|
@ -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 && 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 && (
|
||||
<XCircle className="mr-2 h-4 w-4 text-red-500" />
|
||||
|
|
@ -255,7 +255,7 @@ export const CreateVolumeForm = ({ onSubmit, mode = "create", initialValues, for
|
|||
{testMessage && (
|
||||
<div
|
||||
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,
|
||||
})}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -70,15 +70,15 @@ export function VolumesPage() {
|
|||
return (
|
||||
<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">
|
||||
<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
|
||||
className="w-full lg:w-45 min-w-45 -mr-px -mt-px"
|
||||
placeholder="Search volumes…"
|
||||
className="w-full lg:w-45 min-w-45"
|
||||
placeholder="Search…"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
<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" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
|
|
@ -88,7 +88,7 @@ export function VolumesPage() {
|
|||
</SelectContent>
|
||||
</Select>
|
||||
<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" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
|
|
@ -135,14 +135,14 @@ export function VolumesPage() {
|
|||
filteredVolumes.map((volume) => (
|
||||
<TableRow
|
||||
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}` })}
|
||||
>
|
||||
<TableCell className="font-medium text-strong-accent">{volume.name}</TableCell>
|
||||
<TableCell>
|
||||
<TableCell className="font-medium font-mono text-strong-accent">{volume.name}</TableCell>
|
||||
<TableCell className="font-mono text-muted-foreground">
|
||||
<VolumeIcon backend={volume.type} />
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<TableCell className="text-center font-mono">
|
||||
<StatusDot
|
||||
variant={getVolumeStatusVariant(volume.status)}
|
||||
label={volume.status[0].toUpperCase() + volume.status.slice(1)}
|
||||
|
|
@ -154,12 +154,12 @@ export function VolumesPage() {
|
|||
</TableBody>
|
||||
</Table>
|
||||
</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 ? (
|
||||
"No volumes match filters."
|
||||
) : (
|
||||
<span>
|
||||
<span className="text-strong-accent">{filteredVolumes.length}</span> volume
|
||||
<span className="font-mono">
|
||||
<span className="text-strong-accent font-bold">{filteredVolumes.length}</span> volume
|
||||
{filteredVolumes.length > 1 ? "s" : ""}
|
||||
</span>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ export const fetchUser = createServerFn({ method: "GET" }).handler(async () => {
|
|||
const hasUsers = await authService.hasUsers();
|
||||
|
||||
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 };
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue