From b0c57171243f93192a7a08602e25eb797b2495e6 Mon Sep 17 00:00:00 2001 From: Roman Hrokholskyi Date: Sat, 18 Apr 2026 21:17:26 +0400 Subject: [PATCH 1/5] feat: redesign --- backend/backup-core/services/git_service.py | 9 +- frontend/src/app/dashboard/page.tsx | 591 ++++++++++++---- frontend/src/app/destinations/page.tsx | 332 ++++++--- frontend/src/app/favicon.ico | Bin 25931 -> 0 bytes frontend/src/app/globals.css | 302 ++++++-- frontend/src/app/layout.tsx | 2 +- frontend/src/app/login/page.tsx | 190 +++-- frontend/src/app/repos/page.tsx | 661 +++++++++++++----- .../src/app/settings/credentials/page.tsx | 17 +- frontend/src/app/settings/encryption/page.tsx | 1 + .../src/app/settings/notifications/page.tsx | 1 + frontend/src/app/settings/users/page.tsx | 2 + frontend/src/components/app-shell.tsx | 201 ++++-- frontend/src/components/backup-heatmap.tsx | 234 +++++-- frontend/src/components/repo-status.tsx | 89 ++- frontend/src/components/ui/badge.tsx | 25 +- frontend/src/components/ui/button.tsx | 37 +- frontend/src/components/ui/card.tsx | 19 +- frontend/src/components/ui/checkbox.tsx | 11 +- frontend/src/components/ui/dialog.tsx | 8 +- frontend/src/components/ui/dropdown-menu.tsx | 18 +- frontend/src/components/ui/form.tsx | 4 +- frontend/src/components/ui/input.tsx | 17 +- frontend/src/components/ui/label.tsx | 2 +- frontend/src/components/ui/select.tsx | 6 +- frontend/src/components/ui/separator.tsx | 2 +- frontend/src/components/ui/sheet.tsx | 20 +- frontend/src/components/ui/sonner.tsx | 25 +- frontend/src/components/ui/table.tsx | 8 +- frontend/src/components/ui/tabs.tsx | 10 +- frontend/src/components/ui/textarea.tsx | 8 +- frontend/src/components/ui/tooltip.tsx | 9 +- frontend/src/lib/auth.tsx | 80 ++- frontend/src/lib/providers.tsx | 2 +- 34 files changed, 2168 insertions(+), 775 deletions(-) delete mode 100644 frontend/src/app/favicon.ico diff --git a/backend/backup-core/services/git_service.py b/backend/backup-core/services/git_service.py index 910d912..507ed8f 100644 --- a/backend/backup-core/services/git_service.py +++ b/backend/backup-core/services/git_service.py @@ -100,10 +100,15 @@ def _credential_env( yield env, parsed._replace(netloc=netloc).geturl() return - # SSH_KEY — write to a temp file with strict permissions + # SSH_KEY — write to a temp file with strict permissions. + # libcrypto is strict about key file format: LF line endings only and + # a trailing newline. Otherwise it fails with "error in libcrypto". fd, keypath = tempfile.mkstemp(suffix=".key") try: - os.write(fd, secret.encode()) + normalized = secret.replace("\r\n", "\n").replace("\r", "\n") + if not normalized.endswith("\n"): + normalized += "\n" + os.write(fd, normalized.encode()) os.close(fd) os.chmod(keypath, 0o600) env["GIT_SSH_COMMAND"] = ( diff --git a/frontend/src/app/dashboard/page.tsx b/frontend/src/app/dashboard/page.tsx index d477710..f6b31ad 100644 --- a/frontend/src/app/dashboard/page.tsx +++ b/frontend/src/app/dashboard/page.tsx @@ -1,12 +1,17 @@ "use client"; import Link from "next/link"; +import { useMemo } from "react"; import { useQuery } from "@tanstack/react-query"; import { - GitBranch, - CheckCircle2, - XCircle, - HardDrive, + GitBranchIcon, + ShieldCheckIcon, + AlertTriangleIcon, + HardDriveIcon, + ArrowRightIcon, + PlusIcon, + ActivityIcon, + DatabaseIcon, } from "lucide-react"; import { useAuth } from "@/lib/auth"; import { formatBytes } from "@/lib/utils"; @@ -16,19 +21,138 @@ import { listDestinations, getBackupActivity, type Destination, + type Repository, } from "@/lib/api"; import { BackupHeatmap } from "@/components/backup-heatmap"; -import { - Card, - CardContent, - CardHeader, - CardTitle, -} from "@/components/ui/card"; - -function storageBarColor(pct: number): string { - if (pct > 90) return "bg-red-500"; - if (pct > 70) return "bg-amber-500"; - return "bg-emerald-500"; +import { RepoStatusBadge } from "@/components/repo-status"; +import { Button } from "@/components/ui/button"; + +/* ------------------------------------------------------------------ */ +/* Hero */ +/* ------------------------------------------------------------------ */ + +function greeting(): string { + const h = new Date().getHours(); + if (h < 5) return "Burning the midnight oil"; + if (h < 12) return "Good morning"; + if (h < 18) return "Good afternoon"; + return "Good evening"; +} + +function Hero({ userName }: { userName?: string }) { + const now = new Date().toLocaleDateString("en-US", { + weekday: "long", + month: "long", + day: "numeric", + }); + return ( +
+
+

+ {now} +

+

+ {greeting()} + {userName ? ( + <> + ,{" "} + {userName} + + ) : ( + <>. + )} +

+
+
+ + +
+
+ ); +} + +/* ------------------------------------------------------------------ */ +/* Stat cards */ +/* ------------------------------------------------------------------ */ + +type StatProps = { + label: string; + value: React.ReactNode; + sub?: React.ReactNode; + icon: React.ElementType; + tone: "neutral" | "ok" | "warn" | "err"; + href: string; +}; + +function StatCard({ label, value, sub, icon: Icon, tone, href }: StatProps) { + const toneColor = + tone === "ok" + ? "var(--mint)" + : tone === "warn" + ? "var(--warn)" + : tone === "err" + ? "var(--err)" + : "var(--muted-foreground)"; + + return ( + + +
+ + {label} + + + + +
+
+ + {value} + +
+ {sub && ( +
{sub}
+ )} + + + + + ); +} + +/* ------------------------------------------------------------------ */ +/* Storage */ +/* ------------------------------------------------------------------ */ + +function storageTone(pct: number): { color: string; label: string } { + if (pct > 90) return { color: "var(--err)", label: "Critical" }; + if (pct > 75) return { color: "var(--warn)", label: "High" }; + return { color: "var(--mint)", label: "Healthy" }; } function StorageOverview({ destinations }: { destinations: Destination[] }) { @@ -48,74 +172,106 @@ function StorageOverview({ destinations }: { destinations: Destination[] }) { ? (totalUsed / totalCapacity) * 100 : null; + const sorted = [...destinations].sort((a, b) => b.used_bytes - a.used_bytes); + return ( - - - - Storage Usage - +
+
+
+ +

Storage

+
- View all + Manage → - - - {/* Overall summary */} +
+ +
+ {/* Overall */}
-
- +
+ {formatBytes(totalUsed)} {totalCapacity != null && ( - + of {formatBytes(totalCapacity)} )}
{overallPct != null && ( -
-
-
+ <> +
+
+
+
+ + {overallPct.toFixed(1)}% used + + + {storageTone(overallPct).label} + +
+ )}
- {/* Per-destination breakdown */} - {destinations.length > 0 && ( -
- {destinations.map((d) => { + {/* Per-destination */} + {sorted.length > 0 ? ( +
+ {sorted.slice(0, 4).map((d) => { const cap = d.available_bytes != null ? d.used_bytes + d.available_bytes : null; const pct = - cap != null && cap > 0 - ? (d.used_bytes / cap) * 100 - : null; + cap != null && cap > 0 ? (d.used_bytes / cap) * 100 : null; + const tone = pct != null ? storageTone(pct) : null; return ( -
-
- - {d.alias} +
+
+ + + + {d.alias} + - + {formatBytes(d.used_bytes)} {cap != null && ( - - {" "}/ {formatBytes(cap)} + + {" / "} + {formatBytes(cap)} )}
{pct != null && ( -
+
)} @@ -123,20 +279,129 @@ function StorageOverview({ destinations }: { destinations: Destination[] }) { ); })}
+ ) : ( +
+ No destinations configured yet.{" "} + + Add one + + . +
)} +
+
+ ); +} + +/* ------------------------------------------------------------------ */ +/* Recent repos */ +/* ------------------------------------------------------------------ */ - {destinations.length === 0 && ( -

- No destinations configured. +function formatRelative(iso: string | null): string { + if (!iso) return "never"; + const then = new Date(iso).getTime(); + const diff = Date.now() - then; + const min = 60_000; + const hr = 60 * min; + const day = 24 * hr; + if (diff < min) return "just now"; + if (diff < hr) return `${Math.floor(diff / min)}m ago`; + if (diff < day) return `${Math.floor(diff / hr)}h ago`; + if (diff < 14 * day) return `${Math.floor(diff / day)}d ago`; + return new Date(iso).toLocaleDateString("en-US", { + month: "short", + day: "numeric", + }); +} + +function RecentRepos({ repos }: { repos: Repository[] }) { + const recent = useMemo(() => { + const copy = [...repos]; + copy.sort((a, b) => { + // Failed first, then by last_backup_at desc + const aFailed = a.status === "failed" || a.status === "access_error"; + const bFailed = b.status === "failed" || b.status === "access_error"; + if (aFailed !== bFailed) return aFailed ? -1 : 1; + const aT = a.last_backup_at ? new Date(a.last_backup_at).getTime() : 0; + const bT = b.last_backup_at ? new Date(b.last_backup_at).getTime() : 0; + return bT - aT; + }); + return copy.slice(0, 6); + }, [repos]); + + return ( +

+
+
+ +

Repositories

+ + {repos.length} + +
+ + View all → + +
+ + {recent.length === 0 ? ( +
+ +

+ No repositories yet.

- )} - - + +
+ ) : ( +
    + {recent.map((r) => ( +
  • + + + + + {r.name} + + + {r.url.replace(/^https?:\/\//, "")} + + + + {formatRelative(r.last_backup_at)} + + +
  • + ))} +
+ )} +
); } +/* ------------------------------------------------------------------ */ +/* Page */ +/* ------------------------------------------------------------------ */ + export default function DashboardPage() { - const { token } = useAuth(); + const { token, user } = useAuth(); const repos = useQuery({ queryKey: ["repositories"], @@ -160,128 +425,144 @@ export default function DashboardPage() { const repoData = repos.data ?? []; const totalRepos = repoData.length; const backedUp = repoData.filter((r) => r.status === "backed_up").length; + const running = repoData.filter( + (r) => r.status === "running" || r.status === "verifying", + ).length; const failed = repoData.filter( - (r) => r.status === "failed" || r.status === "access_error", + (r) => + r.status === "failed" || + r.status === "access_error" || + r.status === "unreachable", ).length; - const stats = [ + const encryptedCount = repoData.filter((r) => r.encrypt).length; + const encryptedPct = + totalRepos > 0 ? Math.round((encryptedCount / totalRepos) * 100) : 0; + + const stats: StatProps[] = [ { - label: "Total Repos", + label: "Repositories", value: totalRepos, - icon: GitBranch, - color: "text-foreground", - iconColor: "text-slate-500", + sub: + running > 0 ? ( + + + {running} running now + + ) : ( + {backedUp} backed up + ), + icon: GitBranchIcon, + tone: "neutral", href: "/repos", }, { - label: "Backed Up", + label: "Healthy", value: backedUp, - icon: CheckCircle2, - color: "text-emerald-600", - iconColor: "text-emerald-500", - href: "/repos", + sub: + totalRepos > 0 ? ( + + {Math.round((backedUp / totalRepos) * 100)}% of total + + ) : ( + "—" + ), + icon: ShieldCheckIcon, + tone: "ok", + href: "/repos?status=backed_up", }, { - label: "Failed", + label: "Need attention", value: failed, - icon: XCircle, - color: "text-red-600", - iconColor: "text-red-500", - href: "/repos", + sub: + failed > 0 ? ( + + Review in Repositories + + ) : ( + All clear + ), + icon: AlertTriangleIcon, + tone: failed > 0 ? "err" : "neutral", + href: "/repos?status=failed", }, { - label: "Destinations", - value: destinations.data?.length ?? 0, - icon: HardDrive, - color: "text-foreground", - iconColor: "text-slate-500", - href: "/destinations", + label: "Encrypted", + value: `${encryptedPct}%`, + sub: + totalRepos === 0 ? ( + "—" + ) : encryptedPct < 50 ? ( + + {encryptedCount} of {totalRepos} repos + + ) : ( + + {encryptedCount} of {totalRepos} repos + + ), + icon: HardDriveIcon, + tone: "ok", + href: "/settings/encryption", }, ]; + const displayName = user?.email ? user.email.split("@")[0] : undefined; + return ( -
-

Dashboard

- -
- {stats.map((stat) => ( - - - - - {stat.label} - - - - -

- {stat.value} -

-
-
- +
+ + + {/* Error banner */} + {(repos.isError || destinations.isError) && ( +
+ + Failed to load dashboard data. Please try again. +
+ )} + + {/* Stats */} +
+ {stats.map((s) => ( + ))}
-
- - - - Backup Activity - - - - - - + {/* Activity — full width */} +
+
+
+ +

Backup activity

+
+ + Past 12 months + +
+
+ +
+
+ {/* Storage + Recent repos */} +
+
- - {(repos.isError || destinations.isError) && ( - - - -

- Failed to load dashboard data. Please try again. -

-
-
- )} - - {failed > 0 && ( - - - -

- {failed} repo{failed > 1 ? "s" : ""} ha - {failed > 1 ? "ve" : "s"} errors. Check the{" "} - - Repos - {" "} - page for details. -

-
-
- )} - - {totalRepos === 0 && !repos.isLoading && ( - - -

- No repositories yet.{" "} - - Add your first repo - {" "} - to get started. -

-
-
- )}
); diff --git a/frontend/src/app/destinations/page.tsx b/frontend/src/app/destinations/page.tsx index 32ce703..6f25323 100644 --- a/frontend/src/app/destinations/page.tsx +++ b/frontend/src/app/destinations/page.tsx @@ -3,7 +3,7 @@ import { useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; -import { HardDrive, GitBranch } from "lucide-react"; +import { HardDrive, GitBranch, Trash2, Info } from "lucide-react"; import { useAuth } from "@/lib/auth"; import { formatBytes } from "@/lib/utils"; import { AppShell } from "@/components/app-shell"; @@ -11,6 +11,7 @@ import { createDestination, deleteDestination, listDestinations, + listRepositories, updateDestination, } from "@/lib/api"; import { Button } from "@/components/ui/button"; @@ -42,7 +43,7 @@ function StorageBar({ }) { if (available === null || available === 0) { return ( - + {formatBytes(used)} used ); @@ -50,19 +51,35 @@ function StorageBar({ const total = used + available; const pct = Math.min((used / total) * 100, 100); - const barColor = - pct > 90 ? "bg-red-500" : pct > 70 ? "bg-amber-500" : "bg-emerald-500"; + const tone = + pct > 85 ? "var(--err)" : pct > 70 ? "var(--warn)" : "var(--mint)"; + const pctColor = + pct > 85 + ? "var(--err)" + : pct > 70 + ? "var(--warn)" + : "var(--muted-foreground)"; return ( -
-
- {formatBytes(used)} used +
+
+ + + {formatBytes(used)} + {" "} + used ·{" "} + {pct.toFixed(1)}% + {formatBytes(available)} free
-
+
@@ -82,6 +99,20 @@ export default function DestinationsPage() { enabled: !!token, }); + const { data: repos = [] } = useQuery({ + queryKey: ["repositories"], + queryFn: () => listRepositories(token!), + enabled: !!token, + }); + + const totalUsed = destinations.reduce((s, d) => s + d.used_bytes, 0); + const totalCapacity = destinations.reduce( + (s, d) => s + d.used_bytes + (d.available_bytes ?? 0), + 0, + ); + const usedPct = + totalCapacity > 0 ? (totalUsed / totalCapacity) * 100 : 0; + const BACKUP_ROOT = "/data/backups"; const fullPath = path ? `${BACKUP_ROOT}/${path}` : BACKUP_ROOT; @@ -120,11 +151,17 @@ export default function DestinationsPage() { return (
-
+
-

Destinations

-

- Storage locations for backup archives. Paths must exist on disk. +

+ Storage +

+

+ Destinations +

+

+ Where archives land. Paths must exist on disk before saving — + the directory will be created if missing.

@@ -149,6 +186,7 @@ export default function DestinationsPage() { value={alias} onChange={(e) => setAlias(e.target.value)} placeholder="e.g. External SSD" + maxLength={64} required />
@@ -161,7 +199,14 @@ export default function DestinationsPage() { setPath(e.target.value.replace(/^\/+/, ""))} + onChange={(e) => + setPath( + e.target.value + .replace(/^\/+/, "") + .replace(/[^\w./\-]/g, ""), + ) + } + maxLength={128} placeholder="e.g. critical" className="flex-1 bg-transparent px-3 py-2 text-sm font-mono outline-none placeholder:text-muted-foreground" /> @@ -183,6 +228,52 @@ export default function DestinationsPage() {
+ {destinations.length > 0 && ( +
+
+
+ Total capacity +
+
+ + {formatBytes(totalCapacity).replace(/ .*/, "")} + + + {formatBytes(totalCapacity).replace(/^[\d.]+ /, "")} + +
+
+
+
+ Used +
+
+ + {formatBytes(totalUsed).replace(/ .*/, "")} + + + {formatBytes(totalUsed).replace(/^[\d.]+ /, "")} + {totalCapacity > 0 && ` · ${usedPct.toFixed(1)}%`} + +
+
+
+
+ Repos placed +
+
+ + {repos.length} + + + across {destinations.length} destination + {destinations.length === 1 ? "" : "s"} + +
+
+
+ )} + {isLoading ? (

Loading...

) : isError ? ( @@ -195,78 +286,147 @@ export default function DestinationsPage() { local destination.

) : ( - - - - Alias - Path - Repos - Storage - Status - - - - - {destinations.map((dest) => ( - - - - - {dest.alias} - - - - {dest.path} - - - - - {dest.repo_count} - - - - - - - {dest.is_default ? ( - Default - ) : ( - - )} - - - {!dest.is_default && ( - - )} - +
+
+ + + + Alias + + + Path + + + Repos + + + Capacity + + + Status + + - ))} - -
+ + + {destinations.map((dest) => { + const cap = + dest.available_bytes != null + ? dest.used_bytes + dest.available_bytes + : null; + const pct = + cap != null && cap > 0 + ? (dest.used_bytes / cap) * 100 + : null; + const health = + pct != null && pct > 85 + ? { color: "var(--err)", label: "Low space" } + : pct != null && pct > 70 + ? { color: "var(--warn)", label: "High usage" } + : { color: "var(--mint)", label: "Mounted" }; + return ( + + +
+ + + +
+
+ {dest.alias} +
+
+ Local volume +
+
+
+
+ + {dest.path} + + + + + + {dest.repo_count} + + + + + + + +
+ + + {health.label} + +
+ {dest.is_default && ( + + ★ Default + + )} +
+ + {!dest.is_default && ( + + )} + {!dest.is_default && ( + + )} + +
+ ); + })} +
+ +
+ )} + + {destinations.length > 0 && !isLoading && ( +

+ + The default destination is used when a repo is added without an + explicit choice. Removing a destination requires reassigning any + repos placed there. +

)}
diff --git a/frontend/src/app/favicon.ico b/frontend/src/app/favicon.ico deleted file mode 100644 index 718d6fea4835ec2d246af9800eddb7ffb276240c..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 25931 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 LrJugUA?W`A8`#=m diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index 64b55d9..b7abd7b 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -4,11 +4,18 @@ @custom-variant dark (&:is(.dark *)); +/* ---------------------------------------------------------------------------- + * Gitbacker design tokens — ported from the marketing site. + * Dark is the default (matches landing). `.dark` class is already applied + * via next-themes; a .light override is provided for the toggle. + * -------------------------------------------------------------------------- */ + @theme inline { --color-background: var(--background); --color-foreground: var(--foreground); --font-sans: var(--font-geist-sans); --font-mono: var(--font-geist-mono); + --font-serif: ui-serif, Georgia, Cambria, "Times New Roman", Times, serif; --color-sidebar-ring: var(--sidebar-ring); --color-sidebar-border: var(--sidebar-border); --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); @@ -47,82 +54,247 @@ --radius-4xl: calc(var(--radius) + 16px); } +/* Dark — default. Mirrors landing :root exactly. */ :root { - --radius: 0.625rem; - --background: oklch(1 0 0); - --foreground: oklch(0.145 0 0); - --card: oklch(1 0 0); - --card-foreground: oklch(0.145 0 0); - --popover: oklch(1 0 0); - --popover-foreground: oklch(0.145 0 0); - --primary: oklch(0.205 0 0); - --primary-foreground: oklch(0.985 0 0); - --secondary: oklch(0.97 0 0); - --secondary-foreground: oklch(0.205 0 0); - --muted: oklch(0.97 0 0); - --muted-foreground: oklch(0.556 0 0); - --accent: oklch(0.97 0 0); - --accent-foreground: oklch(0.205 0 0); - --destructive: oklch(0.577 0.245 27.325); - --border: oklch(0.922 0 0); - --input: oklch(0.922 0 0); - --ring: oklch(0.708 0 0); - --chart-1: oklch(0.646 0.222 41.116); - --chart-2: oklch(0.6 0.118 184.704); - --chart-3: oklch(0.398 0.07 227.392); - --chart-4: oklch(0.828 0.189 84.429); - --chart-5: oklch(0.769 0.188 70.08); - --sidebar: oklch(0.985 0 0); - --sidebar-foreground: oklch(0.145 0 0); - --sidebar-primary: oklch(0.205 0 0); - --sidebar-primary-foreground: oklch(0.985 0 0); - --sidebar-accent: oklch(0.97 0 0); - --sidebar-accent-foreground: oklch(0.205 0 0); - --sidebar-border: oklch(0.922 0 0); - --sidebar-ring: oklch(0.708 0 0); + --radius: 0.875rem; + + /* Surfaces */ + --background: oklch(0.16 0.012 220); + --foreground: oklch(0.985 0.005 220); + --bg-1: oklch(0.195 0.014 220); + --bg-2: oklch(0.22 0.014 220); + --bg-3: oklch(0.26 0.016 220); + + --card: oklch(0.195 0.014 220); + --card-foreground: oklch(0.985 0.005 220); + --popover: oklch(0.22 0.014 220); + --popover-foreground: oklch(0.985 0.005 220); + + /* Mint — primary / encryption / "safe" */ + --primary: oklch(0.82 0.14 165); + --primary-foreground: oklch(0.22 0.05 165); + --mint: oklch(0.82 0.14 165); + + --secondary: oklch(0.26 0.016 220); + --secondary-foreground: oklch(0.985 0.005 220); + + --muted: oklch(0.22 0.014 220); + --muted-foreground: oklch(0.74 0.015 220); + + --accent: oklch(0.26 0.016 220); + --accent-foreground: oklch(0.985 0.005 220); + + --destructive: oklch(0.70 0.22 25); + + --border: oklch(0.30 0.014 220); + --input: oklch(0.26 0.016 220); + --ring: oklch(0.82 0.14 165); + + /* Semantic status — used by repo-status, heatmap */ + --ok: oklch(0.78 0.16 150); + --warn: oklch(0.82 0.17 80); + --err: oklch(0.70 0.22 25); + --info: oklch(0.70 0.15 240); + + /* Charts / heatmap ramp (cool mint scale) */ + --chart-1: oklch(0.78 0.16 150); + --chart-2: oklch(0.82 0.14 165); + --chart-3: oklch(0.70 0.15 240); + --chart-4: oklch(0.82 0.17 80); + --chart-5: oklch(0.70 0.22 25); + + /* Glass */ + --glass: color-mix(in oklch, var(--bg-1) 70%, transparent); + + /* Sidebar (re-uses surfaces) */ + --sidebar: var(--bg-1); + --sidebar-foreground: var(--foreground); + --sidebar-primary: var(--primary); + --sidebar-primary-foreground: var(--primary-foreground); + --sidebar-accent: var(--bg-2); + --sidebar-accent-foreground: var(--foreground); + --sidebar-border: var(--border); + --sidebar-ring: var(--ring); +} + +/* Light — toggled by next-themes removing .dark. We also support an explicit + * .light class for forced override. */ +.light, :root:not(.dark).light-default { + --background: oklch(0.985 0.004 220); + --foreground: oklch(0.18 0.012 220); + --bg-1: #ffffff; + --bg-2: oklch(0.975 0.005 220); + --bg-3: oklch(0.955 0.007 220); + + --card: #ffffff; + --card-foreground: oklch(0.18 0.012 220); + --popover: #ffffff; + --popover-foreground: oklch(0.18 0.012 220); + + --primary: oklch(0.64 0.14 165); + --primary-foreground: #ffffff; + --mint: oklch(0.64 0.14 165); + + --secondary: oklch(0.955 0.007 220); + --secondary-foreground: oklch(0.18 0.012 220); + + --muted: oklch(0.96 0.006 220); + --muted-foreground: oklch(0.45 0.012 220); + + --accent: oklch(0.955 0.007 220); + --accent-foreground: oklch(0.18 0.012 220); + + --destructive: oklch(0.58 0.22 25); + + --border: oklch(0.90 0.008 220); + --input: oklch(0.94 0.006 220); + --ring: oklch(0.64 0.14 165); + + --ok: oklch(0.60 0.16 150); + --warn: oklch(0.70 0.17 80); + --err: oklch(0.58 0.22 25); + --info: oklch(0.58 0.15 240); + + --glass: color-mix(in oklch, #ffffff 70%, transparent); + + --sidebar: #ffffff; + --sidebar-foreground: var(--foreground); + --sidebar-primary: var(--primary); + --sidebar-primary-foreground: var(--primary-foreground); + --sidebar-accent: oklch(0.955 0.007 220); + --sidebar-accent-foreground: var(--foreground); + --sidebar-border: var(--border); + --sidebar-ring: var(--ring); } -.dark { - --background: oklch(0.145 0 0); - --foreground: oklch(0.985 0 0); - --card: oklch(0.205 0 0); - --card-foreground: oklch(0.985 0 0); - --popover: oklch(0.205 0 0); - --popover-foreground: oklch(0.985 0 0); - --primary: oklch(0.922 0 0); - --primary-foreground: oklch(0.205 0 0); - --secondary: oklch(0.269 0 0); - --secondary-foreground: oklch(0.985 0 0); - --muted: oklch(0.269 0 0); - --muted-foreground: oklch(0.708 0 0); - --accent: oklch(0.269 0 0); - --accent-foreground: oklch(0.985 0 0); - --destructive: oklch(0.704 0.191 22.216); - --border: oklch(1 0 0 / 10%); - --input: oklch(1 0 0 / 15%); - --ring: oklch(0.556 0 0); - --chart-1: oklch(0.488 0.243 264.376); - --chart-2: oklch(0.696 0.17 162.48); - --chart-3: oklch(0.769 0.188 70.08); - --chart-4: oklch(0.627 0.265 303.9); - --chart-5: oklch(0.645 0.246 16.439); - --sidebar: oklch(0.205 0 0); - --sidebar-foreground: oklch(0.985 0 0); - --sidebar-primary: oklch(0.488 0.243 264.376); - --sidebar-primary-foreground: oklch(0.985 0 0); - --sidebar-accent: oklch(0.269 0 0); - --sidebar-accent-foreground: oklch(0.985 0 0); - --sidebar-border: oklch(1 0 0 / 10%); - --sidebar-ring: oklch(0.556 0 0); +/* next-themes sets .dark on when dark is active. Our defaults above + * *are* dark, so .dark just re-asserts them (idempotent). When the user + * toggles light, next-themes removes .dark — we handle that with a rule + * on without .dark. */ +html:not(.dark) { + --background: oklch(0.985 0.004 220); + --foreground: oklch(0.18 0.012 220); + --bg-1: #ffffff; + --bg-2: oklch(0.975 0.005 220); + --bg-3: oklch(0.955 0.007 220); + --card: #ffffff; + --card-foreground: oklch(0.18 0.012 220); + --popover: #ffffff; + --popover-foreground: oklch(0.18 0.012 220); + --primary: oklch(0.64 0.14 165); + --primary-foreground: #ffffff; + --mint: oklch(0.64 0.14 165); + --secondary: oklch(0.955 0.007 220); + --secondary-foreground: oklch(0.18 0.012 220); + --muted: oklch(0.96 0.006 220); + --muted-foreground: oklch(0.45 0.012 220); + --accent: oklch(0.955 0.007 220); + --accent-foreground: oklch(0.18 0.012 220); + --destructive: oklch(0.58 0.22 25); + --border: oklch(0.90 0.008 220); + --input: oklch(0.94 0.006 220); + --ring: oklch(0.64 0.14 165); + --ok: oklch(0.60 0.16 150); + --warn: oklch(0.70 0.17 80); + --err: oklch(0.58 0.22 25); + --info: oklch(0.58 0.15 240); + --glass: color-mix(in oklch, #ffffff 70%, transparent); + --sidebar: #ffffff; + --sidebar-foreground: var(--foreground); + --sidebar-accent: oklch(0.955 0.007 220); + --sidebar-accent-foreground: var(--foreground); + --sidebar-border: var(--border); } @layer base { * { @apply border-border outline-ring/50; } + html { scroll-behavior: smooth; } body { @apply bg-background text-foreground; + font-feature-settings: 'ss01', 'cv11'; + letter-spacing: -0.005em; + -webkit-font-smoothing: antialiased; } + ::selection { background: var(--primary); color: var(--primary-foreground); } + + h1, h2, h3, h4 { letter-spacing: -0.02em; text-wrap: balance; } + p { text-wrap: pretty; } +} + +/* ---- Landing-style signature utilities ---------------------------------- */ +@layer utilities { + .font-serif { letter-spacing: -0.015em; } + .font-serif-italic { + font-family: ui-serif, Georgia, Cambria, "Times New Roman", Times, serif; + font-style: italic; + font-weight: 400; + letter-spacing: -0.015em; + } + /* Glass pill — used by nav, floating panels */ + .glass-pill { + background: var(--glass); + backdrop-filter: blur(16px) saturate(1.2); + -webkit-backdrop-filter: blur(16px) saturate(1.2); + border: 1px solid var(--border); + border-radius: 9999px; + box-shadow: + 0 1px 0 color-mix(in oklch, var(--foreground) 6%, transparent) inset, + 0 20px 40px -20px rgba(0, 0, 0, 0.45); + } + html:not(.dark) .glass-pill { + box-shadow: + 0 1px 0 rgba(255, 255, 255, 0.7) inset, + 0 12px 32px -16px rgba(12, 20, 30, 0.18); + } + /* Ambient mint glow used behind heroes */ + .ambient-glow { + position: absolute; inset: -20% -10% auto -10%; + height: 720px; pointer-events: none; z-index: 0; + background: + radial-gradient(60% 60% at 30% 30%, color-mix(in oklch, var(--primary) 24%, transparent) 0%, transparent 60%), + radial-gradient(50% 50% at 75% 40%, color-mix(in oklch, oklch(0.68 0.18 260) 20%, transparent) 0%, transparent 55%); + filter: blur(40px); + opacity: 0.85; + } + html:not(.dark) .ambient-glow { opacity: 0.55; } + /* Mint focus ring */ + .focus-mint:focus-visible { + outline: 2px solid var(--primary); + outline-offset: 2px; + } + /* Animated status dot */ + .pulse-ok::before { + content: ""; width: 7px; height: 7px; border-radius: 50%; + background: var(--ok); display: inline-block; margin-right: 8px; + box-shadow: 0 0 0 0 color-mix(in oklch, var(--ok) 60%, transparent); + animation: gb-pulse 2s infinite; + } + .pulse-err::before { + content: ""; width: 7px; height: 7px; border-radius: 50%; + background: var(--err); display: inline-block; margin-right: 8px; + box-shadow: 0 0 0 0 color-mix(in oklch, var(--err) 60%, transparent); + animation: gb-pulse 1.8s infinite; + } + @keyframes gb-pulse { + 0% { box-shadow: 0 0 0 0 color-mix(in oklch, currentColor 60%, transparent); } + 70% { box-shadow: 0 0 0 10px color-mix(in oklch, currentColor 0%, transparent); } + 100% { box-shadow: 0 0 0 0 color-mix(in oklch, currentColor 0%, transparent); } + } + /* Code/terminal block */ + .code-block { + font-family: var(--font-geist-mono), ui-monospace, 'SF Mono', Consolas, monospace; + font-size: 12.5px; + line-height: 1.65; + background: var(--bg-2); + border: 1px solid var(--border); + border-radius: 10px; + padding: 14px 16px; + color: var(--muted-foreground); + white-space: pre-wrap; + } + .code-block b { color: var(--foreground); font-weight: 500; } } /* Logo states */ @@ -135,4 +307,4 @@ .logo-stale:hover img { filter: grayscale(0%); opacity: 1; -} \ No newline at end of file +} diff --git a/frontend/src/app/layout.tsx b/frontend/src/app/layout.tsx index 4c6be84..79d8a7a 100644 --- a/frontend/src/app/layout.tsx +++ b/frontend/src/app/layout.tsx @@ -16,7 +16,7 @@ const geistMono = Geist_Mono({ export const metadata: Metadata = { title: "Gitbacker", - description: "Self-hosted git repository backup tool - Gitbacker", + description: "Self-hosted git repository backup tool — Gitbacker", icons: { icon: "/gitbacker-logo-filled.svg", }, diff --git a/frontend/src/app/login/page.tsx b/frontend/src/app/login/page.tsx index 726dd41..99f85f0 100644 --- a/frontend/src/app/login/page.tsx +++ b/frontend/src/app/login/page.tsx @@ -1,81 +1,177 @@ "use client"; -import { useState } from "react"; +import { useEffect, useState } from "react"; import { useRouter } from "next/navigation"; import { useAuth } from "@/lib/auth"; +import { getHealth } from "@/lib/api"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { PasswordInput } from "@/components/password-input"; -import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from "@/components/ui/card"; +import { Checkbox } from "@/components/ui/checkbox"; +import { GitbackerLogo } from "@/components/gitbacker-logo"; +import { ArrowRightIcon, AlertCircleIcon } from "lucide-react"; export default function LoginPage() { const { login } = useAuth(); const router = useRouter(); const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); + const [remember, setRemember] = useState(true); const [error, setError] = useState(""); const [isSubmitting, setIsSubmitting] = useState(false); + const [version, setVersion] = useState(null); + + useEffect(() => { + getHealth() + .then((h) => setVersion(h.version)) + .catch(() => {}); + }, []); async function handleSubmit(e: React.FormEvent) { e.preventDefault(); setError(""); setIsSubmitting(true); try { - await login(email, password); + await login(email, password, remember); router.push("/dashboard"); } catch { - setError("Invalid email or password"); + setError("Invalid email or password."); } finally { setIsSubmitting(false); } } return ( -
- - -
- - Gitbacker +
+ {/* Ambient mint glow, centered */} +
+ {/* Subtle grid */} +
+ +
+
+
+ + + Gitbacker + +
+ +
+
+

+ Welcome back +

+

+ Sign in to your Gitbacker instance. +

+ +
+
+ + setEmail(e.target.value)} + required + autoFocus + autoComplete="email" + /> +
+ +
+ + setPassword(e.target.value)} + required + autoComplete="current-password" + /> +
+ + + + {error && ( +
+ + {error} +
+ )} + + +
+
+ +
+ {version && ( + <> + + + v{version} + + + + )} + + Apache 2.0 + + + Self-hosted
- Sign in to your account - - -
-
- - setEmail(e.target.value)} - required - autoFocus - /> -
-
- - setPassword(e.target.value)} - required - /> -
- {error &&

{error}

} - -
-
- +
+
); } diff --git a/frontend/src/app/repos/page.tsx b/frontend/src/app/repos/page.tsx index 620402b..8b184eb 100644 --- a/frontend/src/app/repos/page.tsx +++ b/frontend/src/app/repos/page.tsx @@ -1,12 +1,24 @@ "use client"; -import { useMemo, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { useRouter } from "next/navigation"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; -import { ArrowDown, ArrowUp, ArrowUpDown, Search } from "lucide-react"; +import { + ArrowDown, + ArrowUp, + ArrowUpDown, + Search, + Plus, + MoreHorizontal, + Lock, + GitBranchIcon, + PlayIcon, + Trash2Icon, + XIcon, + AlertTriangleIcon, +} from "lucide-react"; import { useAuth } from "@/lib/auth"; -import { formatCron } from "@/lib/cron"; import { formatDateTime } from "@/lib/utils"; import { AppShell } from "@/components/app-shell"; import { @@ -55,9 +67,14 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, + DropdownMenuSeparator, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; +/* ------------------------------------------------------------------ */ +/* Filters + sorting */ +/* ------------------------------------------------------------------ */ + const STATUS_OPTIONS = [ { value: "all", label: "All statuses" }, { value: "backed_up", label: "Backed up" }, @@ -70,13 +87,22 @@ const STATUS_OPTIONS = [ const PAGE_SIZES = [10, 25, 50]; -type SortKey = "name" | "status" | "last_backup_at" | "next_backup_at" | "cron_expression"; +type SortKey = + | "name" + | "status" + | "last_backup_at" + | "next_backup_at" + | "cron_expression"; type SortDir = "asc" | "desc"; -function compareRepos(a: Repository, b: Repository, key: SortKey, dir: SortDir): number { +function compareRepos( + a: Repository, + b: Repository, + key: SortKey, + dir: SortDir, +): number { let av: string | number | null; let bv: string | number | null; - switch (key) { case "name": av = a.name.toLowerCase(); @@ -101,7 +127,6 @@ function compareRepos(a: Repository, b: Repository, key: SortKey, dir: SortDir): default: return 0; } - if (av < bv) return dir === "asc" ? -1 : 1; if (av > bv) return dir === "asc" ? 1 : -1; return 0; @@ -126,30 +151,51 @@ function SortableHead({ ); } +/* ------------------------------------------------------------------ */ +/* Row URL prettifier */ +/* ------------------------------------------------------------------ */ + +function prettyUrl(url: string): string { + return url.replace(/^https?:\/\//, "").replace(/\.git$/, ""); +} + +/* ------------------------------------------------------------------ */ +/* Page */ +/* ------------------------------------------------------------------ */ + export default function ReposPage() { const { token } = useAuth(); const router = useRouter(); const queryClient = useQueryClient(); - // --- Add repos dialog --- + // --- Add dialog --- const [open, setOpen] = useState(false); + + useEffect(() => { + if (new URLSearchParams(window.location.search).get("add") === "1") { + setOpen(true); + router.replace("/repos", { scroll: false }); + } + }, [router]); const [urls, setUrls] = useState(""); const [destinationId, setDestinationId] = useState(""); const [cronExpression, setCronExpression] = useState(""); @@ -164,7 +210,7 @@ export default function ReposPage() { // --- Selection --- const [selected, setSelected] = useState>(new Set()); - // --- Search, filter, sort, pagination --- + // --- Search/filter/sort/page --- const [search, setSearch] = useState(""); const [statusFilter, setStatusFilter] = useState("all"); const [sortKey, setSortKey] = useState(null); @@ -190,7 +236,11 @@ export default function ReposPage() { const hasDefaultSchedule = !!settings?.default_cron_expression; - const { data: repos = [], isLoading, isError } = useQuery({ + const { + data: repos = [], + isLoading, + isError, + } = useQuery({ queryKey: ["repositories"], queryFn: () => listRepositories(token!), enabled: !!token, @@ -209,7 +259,6 @@ export default function ReposPage() { enabled: !!token, }); - // --- Client-side filter + search + sort --- const filtered = useMemo(() => { let result = repos; if (statusFilter !== "all") { @@ -233,15 +282,16 @@ export default function ReposPage() { const safePage = Math.min(page, totalPages - 1); const paged = filtered.slice(safePage * pageSize, (safePage + 1) * pageSize); - // --- Selection helpers --- const pagedIds = paged.map((r) => r.id); - const allPageSelected = pagedIds.length > 0 && pagedIds.every((id) => selected.has(id)); + const allPageSelected = + pagedIds.length > 0 && pagedIds.every((id) => selected.has(id)); const somePageSelected = pagedIds.some((id) => selected.has(id)); function toggleOne(id: string) { setSelected((prev) => { const next = new Set(prev); - if (next.has(id)) next.delete(id); else next.add(id); + if (next.has(id)) next.delete(id); + else next.add(id); return next; }); } @@ -273,7 +323,9 @@ export default function ReposPage() { try { await triggerBackup(token!, id); ok++; - } catch { /* continue */ } + } catch { + /* continue */ + } } queryClient.invalidateQueries({ queryKey: ["repositories"] }); setSelected(new Set()); @@ -282,7 +334,12 @@ export default function ReposPage() { } async function batchDelete() { - if (!confirm(`Delete ${selected.size} repository(s)? This cannot be undone.`)) return; + if ( + !confirm( + `Delete ${selected.size} repository(s)? This cannot be undone.`, + ) + ) + return; setBatchRunning(true); const ids = [...selected]; let ok = 0; @@ -290,7 +347,9 @@ export default function ReposPage() { try { await deleteRepository(token!, id); ok++; - } catch { /* continue */ } + } catch { + /* continue */ + } } queryClient.invalidateQueries({ queryKey: ["repositories"] }); setSelected(new Set()); @@ -298,10 +357,18 @@ export default function ReposPage() { toast.success(`${ok} repository(s) deleted`); } - // Reset to first page when filters change - const setSearchAndReset = (v: string) => { setSearch(v); setPage(0); }; - const setStatusAndReset = (v: string) => { setStatusFilter(v); setPage(0); }; - const setPageSizeAndReset = (v: number) => { setPageSize(v); setPage(0); }; + const setSearchAndReset = (v: string) => { + setSearch(v); + setPage(0); + }; + const setStatusAndReset = (v: string) => { + setStatusFilter(v); + setPage(0); + }; + const setPageSizeAndReset = (v: number) => { + setPageSize(v); + setPage(0); + }; const createMutation = useMutation({ mutationFn: () => { @@ -312,13 +379,16 @@ export default function ReposPage() { const effectiveCron = useDefaultSchedule ? settings?.default_cron_expression ?? undefined : cronExpression || undefined; - const effectiveEncrypt = encryptionKeys.length > 0 && (encrypt ?? settings?.default_encrypt ?? false); + const effectiveEncrypt = + encryptionKeys.length > 0 && + (encrypt ?? settings?.default_encrypt ?? false); return createRepositories(token!, { urls: urlList, destination_id: destinationId || undefined, cron_expression: effectiveCron, encrypt: effectiveEncrypt, - encryption_key_id: effectiveEncrypt && encryptionKeyId ? encryptionKeyId : undefined, + encryption_key_id: + effectiveEncrypt && encryptionKeyId ? encryptionKeyId : undefined, }); }, onSuccess: (created) => { @@ -355,20 +425,63 @@ export default function ReposPage() { const defaultDest = destinations.find((d) => d.is_default); + const totalRepos = repos.length; + const backedUp = repos.filter((r) => r.status === "backed_up").length; + const running = repos.filter( + (r) => r.status === "running" || r.status === "verifying", + ).length; + const failed = repos.filter( + (r) => + r.status === "failed" || + r.status === "access_error" || + r.status === "unreachable", + ).length; + return ( -
-
-

Repositories

+
+ {/* Header */} +
+
+

+ Sources +

+

+ Repositories +

+

+ {totalRepos} total · {backedUp} backed up + {running > 0 && ( + <> + {" · "} + + {running} running + + + )} + {failed > 0 && ( + <> + {" · "} + + {failed} failing + + + )} +

+
- + Add repositories - Paste one or more git URLs, one per line. + Paste one or more git URLs, one per line. They'll be + queued for their first backup once added.
-
+