diff --git a/backend/api/app/routers/users.py b/backend/api/app/routers/users.py index c15eec4..1b2763e 100644 --- a/backend/api/app/routers/users.py +++ b/backend/api/app/routers/users.py @@ -5,7 +5,13 @@ from app.db import get_db from app.services import user_service from shared.models import User -from shared.schemas import PasswordChange, UserCreate, UserRead, UserUpdate +from shared.schemas import ( + PasswordChange, + UserCreate, + UserRead, + UserSelfUpdate, + UserUpdate, +) router = APIRouter() @@ -24,6 +30,15 @@ async def get_me(user: User = Depends(get_current_user)) -> User: return user +@router.patch("/me", response_model=UserRead) +async def update_me( + body: UserSelfUpdate, + db: AsyncSession = Depends(get_db), + user: User = Depends(get_current_user), +) -> User: + return await user_service.update_me(db, user, body) + + @router.post("/me/password", status_code=status.HTTP_204_NO_CONTENT) async def change_password( body: PasswordChange, diff --git a/backend/api/app/services/user_service.py b/backend/api/app/services/user_service.py index 50c6814..7b2d2c9 100644 --- a/backend/api/app/services/user_service.py +++ b/backend/api/app/services/user_service.py @@ -7,7 +7,7 @@ from app.repositories import user_repo from shared.enums import IdentityProvider from shared.models import User, UserIdentity -from shared.schemas import PasswordChange, UserCreate, UserUpdate +from shared.schemas import PasswordChange, UserCreate, UserSelfUpdate, UserUpdate async def create_user(db: AsyncSession, body: UserCreate) -> User: @@ -34,6 +34,16 @@ async def list_users(db: AsyncSession) -> list[User]: return await user_repo.list_all(db) +async def update_me( + db: AsyncSession, user: User, body: UserSelfUpdate +) -> User: + """Update the current user's own profile fields (name only).""" + await user_repo.update(db, user, body.model_dump(exclude_unset=True)) + await db.commit() + await db.refresh(user) + return user + + async def update_user( db: AsyncSession, user_id: str, body: UserUpdate, current_user: User ) -> User: @@ -43,7 +53,12 @@ async def update_user( status_code=status.HTTP_400_BAD_REQUEST, detail="Cannot deactivate your own account", ) - if body.role is not None and body.role != current_user.role: + # Compare by value to avoid enum-vs-string mismatch across the + # Pydantic/SQLAlchemy boundary. + if body.role is not None and ( + getattr(body.role, "value", body.role) + != getattr(current_user.role, "value", current_user.role) + ): raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Cannot change your own role", diff --git a/backend/shared/shared/schemas.py b/backend/shared/shared/schemas.py index 88cb042..0c759bc 100644 --- a/backend/shared/shared/schemas.py +++ b/backend/shared/shared/schemas.py @@ -77,6 +77,12 @@ class UserUpdate(BaseModel): is_active: bool | None = None +class UserSelfUpdate(BaseModel): + """Fields a user may change on their own account. Excludes role / is_active.""" + + name: str | None = None + + class PasswordChange(BaseModel): current_password: str new_password: str diff --git a/frontend/src/app/dashboard/page.tsx b/frontend/src/app/dashboard/page.tsx index d477710..7472c46 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,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 ( +

+
+
+ +

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 +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 */} +
+
+
+ +

Backup activity

+
+ + {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 - ) : ( - - )} - - - {!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 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. +

+ +
+
+ + 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..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({ ); } +/* ------------------------------------------------------------------ */ +/* 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 ( -
-
-

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.
-
+