- {d.alias}
+
+
+
+
+
+ {d.alias}
+
-
+
{formatBytes(d.used_bytes)}
{cap != null && (
-
- {" "}/ {formatBytes(cap)}
+
+ {" / "}
+ {formatBytes(cap)}
)}
{pct != null && (
-
+
)}
@@ -123,20 +279,135 @@ 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" ||
+ a.status === "unreachable";
+ const bFailed =
+ b.status === "failed" ||
+ b.status === "access_error" ||
+ b.status === "unreachable";
+ 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 (
+
+
+
+ {recent.length === 0 ? (
+
+
+
+ No repositories yet.
- )}
-
-
+
+
+
+ Add your first repo
+
+
+
+ ) : (
+
+ {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 +431,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=attention",
},
{
- 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 ? (
+ "—"
+ ) : (
+
+ {encryptedCount} of {totalRepos} repos
+
+ ),
+ icon: HardDriveIcon,
+ // Encrypted is a feature label, not a health metric — always mint,
+ // regardless of current coverage. Sub-text already conveys coverage.
+ tone: "ok",
+ href: "/settings/encryption",
},
];
+ const displayName =
+ user?.name?.trim() ||
+ (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 */}
+
+
+
+
+ {new Date().getFullYear()}
+
+
+
+
+
+
+ {/* 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..73ddc0a 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,26 @@ export default function DestinationsPage() {
enabled: !!token,
});
+ const { data: repos = [] } = useQuery({
+ queryKey: ["repositories"],
+ queryFn: () => listRepositories(token!),
+ enabled: !!token,
+ });
+
+ // Only aggregate destinations with known capacity. Treating null
+ // available_bytes as zero would inflate the "% used" figure for remote or
+ // unmounted destinations. Matches the guard StorageBar already uses per-row.
+ const withCapacity = destinations.filter(
+ (d) => d.available_bytes != null && d.available_bytes > 0,
+ );
+ const totalUsed = withCapacity.reduce((s, d) => s + d.used_bytes, 0);
+ const totalCapacity = withCapacity.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 +157,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 +192,7 @@ export default function DestinationsPage() {
value={alias}
onChange={(e) => setAlias(e.target.value)}
placeholder="e.g. External SSD"
+ maxLength={64}
required
/>
@@ -161,7 +205,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 +234,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 +292,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
- ) : (
- setDefaultMutation.mutate(dest.id)}
- >
- Set default
-
- )}
-
-
- {!dest.is_default && (
- {
- if (
- confirm(
- `Delete "${dest.alias}"? Repos using this destination will need to be reassigned.`,
- )
- ) {
- deleteMutation.mutate(dest.id);
- }
- }}
- >
- Delete
-
- )}
-
+
+
+
+
+
+ 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 && (
+
+ setDefaultMutation.mutate(dest.id)
+ }
+ >
+ Set default
+
+ )}
+ {!dest.is_default && (
+ {
+ if (
+ confirm(
+ `Delete "${dest.alias}"? Repos using this destination will need to be reassigned.`,
+ )
+ ) {
+ deleteMutation.mutate(dest.id);
+ }
+ }}
+ >
+
+
+ )}
+
+
+ );
+ })}
+
+
+
+ )}
+
+ {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 718d6fe..0000000
Binary files a/frontend/src/app/favicon.ico and /dev/null differ
diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css
index 64b55d9..19c521a 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,209 @@
--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);
}
-.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);
+/* Light — single source of truth. next-themes removes .dark on when
+ * the user toggles light; `html:not(.dark)` (specificity 0,1,1) overrides the
+ * :root dark defaults above. */
+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-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);
}
@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 +269,4 @@
.logo-stale:hover img {
filter: grayscale(0%);
opacity: 1;
-}
\ No newline at end of file
+}
diff --git a/frontend/src/app/icon.svg b/frontend/src/app/icon.svg
new file mode 100644
index 0000000..f81699f
--- /dev/null
+++ b/frontend/src/app/icon.svg
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/app/layout.tsx b/frontend/src/app/layout.tsx
index 4c6be84..1da89cd 100644
--- a/frontend/src/app/layout.tsx
+++ b/frontend/src/app/layout.tsx
@@ -16,10 +16,7 @@ const geistMono = Geist_Mono({
export const metadata: Metadata = {
title: "Gitbacker",
- description: "Self-hosted git repository backup tool - Gitbacker",
- icons: {
- icon: "/gitbacker-logo-filled.svg",
- },
+ description: "Self-hosted git repository backup tool — Gitbacker",
};
export default function RootLayout({
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.
+
+
+
+
+
+
+ {version && (
+ <>
+
+
+ v{version}
+
+
•
+ >
+ )}
+
+ Apache 2.0
+
+
•
+
Self-hosted
-
Sign in to your account
-
-
-
-
-
+
+
);
}
diff --git a/frontend/src/app/repos/page.tsx b/frontend/src/app/repos/page.tsx
index 620402b..76ff106 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,65 @@ function SortableHead({
onSort(sortKey)}
- className="inline-flex items-center gap-1 hover:text-foreground transition-colors"
+ className={`inline-flex items-center gap-1 transition-colors ${
+ active ? "text-foreground" : "hover:text-foreground"
+ }`}
>
{label}
{active ? (
currentDir === "asc" ? (
-
+
) : (
-
+
)
) : (
-
+
)}
);
}
+/* ------------------------------------------------------------------ */
+/* 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(() => {
+ const params = new URLSearchParams(window.location.search);
+ let scrub = false;
+ if (params.get("add") === "1") {
+ setOpen(true);
+ scrub = true;
+ }
+ const status = params.get("status");
+ if (status) {
+ const allowed = new Set([
+ ...STATUS_OPTIONS.map((o) => o.value),
+ "attention",
+ ]);
+ if (allowed.has(status)) {
+ setStatusFilter(status);
+ }
+ scrub = true;
+ }
+ if (scrub) router.replace("/repos", { scroll: false });
+ }, [router]);
const [urls, setUrls] = useState("");
const [destinationId, setDestinationId] = useState
("");
const [cronExpression, setCronExpression] = useState("");
@@ -164,7 +224,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 +250,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,10 +273,16 @@ export default function ReposPage() {
enabled: !!token,
});
- // --- Client-side filter + search + sort ---
const filtered = useMemo(() => {
let result = repos;
- if (statusFilter !== "all") {
+ if (statusFilter === "attention") {
+ result = result.filter(
+ (r) =>
+ r.status === "failed" ||
+ r.status === "access_error" ||
+ r.status === "unreachable",
+ );
+ } else if (statusFilter !== "all") {
result = result.filter((r) => r.status === statusFilter);
}
if (search.trim()) {
@@ -233,15 +303,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 +344,9 @@ export default function ReposPage() {
try {
await triggerBackup(token!, id);
ok++;
- } catch { /* continue */ }
+ } catch {
+ /* continue */
+ }
}
queryClient.invalidateQueries({ queryKey: ["repositories"] });
setSelected(new Set());
@@ -282,7 +355,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 +368,9 @@ export default function ReposPage() {
try {
await deleteRepository(token!, id);
ok++;
- } catch { /* continue */ }
+ } catch {
+ /* continue */
+ }
}
queryClient.invalidateQueries({ queryKey: ["repositories"] });
setSelected(new Set());
@@ -298,10 +378,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 +400,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 +446,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 (
-