table polish

This commit is contained in:
null 2026-05-25 14:18:32 -05:00
parent fb9d4a907f
commit bd153b7a87
11 changed files with 1183 additions and 1245 deletions

View File

@ -4,6 +4,7 @@ export const dynamic = "force-dynamic";
import { useMemo, useState } from "react";
import Link from "next/link";
import { Folder, Plus } from "lucide-react";
import { useAuth } from "@/auth/clerk";
import { useQueryClient } from "@tanstack/react-query";
@ -120,7 +121,18 @@ export default function BoardsPage() {
title="Boards"
description={`Manage boards and task workflows. ${boards.length} board${boards.length === 1 ? "" : "s"} total.`}
headerActions={
boards.length > 0 && isAdmin ? (
<div className="flex flex-wrap items-center gap-3">
<Link
href="/board-groups"
className={buttonVariants({
size: "md",
variant: "outline",
})}
>
<Folder className="h-4 w-4" />
Groups
</Link>
{boards.length > 0 && isAdmin ? (
<Link
href="/boards/new"
className={buttonVariants({
@ -128,9 +140,11 @@ export default function BoardsPage() {
variant: "primary",
})}
>
<Plus className="h-4 w-4" />
Create board
</Link>
) : null
) : null}
</div>
}
stickyHeader
>

File diff suppressed because it is too large Load Diff

View File

@ -73,7 +73,7 @@ export default function ForgejoRepositoriesEditPage({
const handleSubmit = async (values: ForgejoRepositoryUpdate) => {
await updateForgejoRepository(params.repositoryId, values);
router.push("/git-projects/repositories");
router.push("/git-projects");
};
const handleDelete = async () => {
@ -81,7 +81,7 @@ export default function ForgejoRepositoriesEditPage({
setDeleteError(null);
try {
await deleteForgejoRepository(params.repositoryId);
router.push("/git-projects/repositories");
router.push("/git-projects");
} catch (err) {
setDeleteError(
err instanceof Error ? err.message : "Failed to delete repository",
@ -96,8 +96,8 @@ export default function ForgejoRepositoriesEditPage({
<DashboardPageLayout
signedOut={{
message: "Sign in to edit a tracked repository.",
forceRedirectUrl: "/git-projects/repositories",
signUpForceRedirectUrl: "/git-projects/repositories",
forceRedirectUrl: "/git-projects",
signUpForceRedirectUrl: "/git-projects",
}}
title="Loading…"
stickyHeader
@ -112,8 +112,8 @@ export default function ForgejoRepositoriesEditPage({
<DashboardPageLayout
signedOut={{
message: "Sign in to edit a tracked repository.",
forceRedirectUrl: "/git-projects/repositories",
signUpForceRedirectUrl: "/git-projects/repositories",
forceRedirectUrl: "/git-projects",
signUpForceRedirectUrl: "/git-projects",
}}
title="Error"
stickyHeader
@ -138,8 +138,8 @@ export default function ForgejoRepositoriesEditPage({
<DashboardPageLayout
signedOut={{
message: "Sign in to edit a tracked repository.",
forceRedirectUrl: "/git-projects/repositories",
signUpForceRedirectUrl: "/git-projects/repositories",
forceRedirectUrl: "/git-projects",
signUpForceRedirectUrl: "/git-projects",
}}
title={`Edit Git Project Repository: ${repository.display_name || repository.repo}`}
description="Update the repository settings Pipeline uses for Git Projects."

View File

@ -14,7 +14,7 @@ export default function ForgejoRepositoriesNewPage() {
const handleSubmit = async (values: ForgejoRepositoryCreate) => {
await createForgejoRepository(values);
router.push("/git-projects/repositories");
router.push("/git-projects");
};
return (

View File

@ -1,819 +1,5 @@
"use client";
import { redirect } from "next/navigation";
import {
type ReactNode,
useCallback,
useEffect,
useMemo,
useState,
} from "react";
import Link from "next/link";
import {
AlertCircle,
CheckCircle2,
CircleDot,
Clock,
Copy,
ExternalLink,
GitBranch,
Loader2,
RefreshCw,
Search,
Settings,
Tags,
Webhook,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { useAuth } from "@/auth/clerk";
import { DashboardPageLayout } from "@/components/templates/DashboardPageLayout";
import { ForgejoRepositoriesTable } from "@/components/git/ForgejoRepositoriesTable";
import { ConfirmActionDialog } from "@/components/ui/confirm-action-dialog";
import { Badge } from "@/components/ui/badge";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { getApiBaseUrl } from "@/lib/api-base";
import {
getForgejoRepositories,
deleteForgejoRepository,
syncRepository,
validateRepository,
type ForgejoRepository,
} from "@/lib/api-forgejo";
type Notice = {
tone: "success" | "error";
message: string;
};
type RepositoryFilter =
| "all"
| "attention"
| "active"
| "webhooks"
| "archived";
type StatTone = "blue" | "green" | "amber" | "rose" | "violet";
const statToneClasses: Record<
StatTone,
{ card: string; icon: string; glow: string }
> = {
blue: {
card: "border-[color:rgba(96,165,250,0.34)] bg-[linear-gradient(145deg,rgba(96,165,250,0.16),rgba(15,23,36,0.94)_42%,var(--surface))]",
icon: "border-[color:rgba(96,165,250,0.3)] bg-[color:rgba(96,165,250,0.14)] text-[color:var(--accent-strong)]",
glow: "bg-[color:rgba(96,165,250,0.22)]",
},
green: {
card: "border-[color:rgba(52,211,153,0.34)] bg-[linear-gradient(145deg,rgba(52,211,153,0.15),rgba(15,23,36,0.94)_42%,var(--surface))]",
icon: "border-[color:rgba(52,211,153,0.3)] bg-[color:rgba(52,211,153,0.14)] text-[color:var(--success)]",
glow: "bg-[color:rgba(52,211,153,0.22)]",
},
amber: {
card: "border-[color:rgba(251,191,36,0.36)] bg-[linear-gradient(145deg,rgba(251,191,36,0.14),rgba(15,23,36,0.94)_42%,var(--surface))]",
icon: "border-[color:rgba(251,191,36,0.32)] bg-[color:rgba(251,191,36,0.13)] text-[color:var(--warning)]",
glow: "bg-[color:rgba(251,191,36,0.2)]",
},
rose: {
card: "border-[color:rgba(248,113,113,0.34)] bg-[linear-gradient(145deg,rgba(248,113,113,0.14),rgba(15,23,36,0.94)_42%,var(--surface))]",
icon: "border-[color:rgba(248,113,113,0.3)] bg-[color:rgba(248,113,113,0.13)] text-[color:var(--danger)]",
glow: "bg-[color:rgba(248,113,113,0.2)]",
},
violet: {
card: "border-[color:rgba(168,85,247,0.32)] bg-[linear-gradient(145deg,rgba(168,85,247,0.14),rgba(15,23,36,0.94)_42%,var(--surface))]",
icon: "border-[color:rgba(168,85,247,0.28)] bg-[color:rgba(168,85,247,0.13)] text-[color:rgb(196,181,253)]",
glow: "bg-[color:rgba(168,85,247,0.2)]",
},
};
const repositoryName = (repository: ForgejoRepository) =>
repository.display_name || `${repository.owner}/${repository.repo}`;
const formatTimestamp = (value: string | null) => {
if (!value) return "Never";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return date.toLocaleString();
};
const formatCompactNumber = (value: number) =>
new Intl.NumberFormat(undefined, { notation: "compact" }).format(value);
const labelColor = (color: string) =>
color.startsWith("#") ? color : `#${color}`;
function NoticeBanner({ notice }: { notice: Notice }) {
return (
<div
className={`flex items-start gap-3 rounded-xl border p-3 text-sm ${
notice.tone === "success"
? "border-[color:rgba(52,211,153,0.35)] bg-[color:rgba(52,211,153,0.08)] text-[color:var(--success)]"
: "border-[color:rgba(248,113,113,0.35)] bg-[color:rgba(248,113,113,0.08)] text-[color:var(--danger)]"
}`}
>
{notice.tone === "success" ? (
<CheckCircle2 className="mt-0.5 h-4 w-4 shrink-0" />
) : (
<AlertCircle className="mt-0.5 h-4 w-4 shrink-0" />
)}
<span>{notice.message}</span>
</div>
);
}
function StatCard({
icon,
label,
value,
caption,
tone = "blue",
}: {
icon: ReactNode;
label: string;
value: string;
caption: string;
tone?: StatTone;
}) {
const colors = statToneClasses[tone];
return (
<div
className={`relative overflow-hidden rounded-xl border p-4 shadow-lush ${colors.card}`}
>
<span
className={`pointer-events-none absolute left-4 right-4 top-0 h-px ${colors.glow}`}
/>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<p className="text-xs font-semibold uppercase tracking-wide text-muted">
{label}
</p>
<p className="mt-2 text-2xl font-semibold text-strong">{value}</p>
</div>
<div className={`rounded-lg border p-2 ${colors.icon}`}>{icon}</div>
</div>
<p className="mt-3 text-sm text-muted">{caption}</p>
</div>
);
}
function RepositoryDetailsDialog({
repository,
webhookBaseUrl,
onOpenChange,
}: {
repository: ForgejoRepository | null;
webhookBaseUrl: string;
onOpenChange: (open: boolean) => void;
}) {
const [copied, setCopied] = useState(false);
const webhookUrl = repository
? webhookBaseUrl
? `${webhookBaseUrl}/api/v1/forgejo/webhooks/${repository.id}`
: `.../api/v1/forgejo/webhooks/${repository.id}`
: "";
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(webhookUrl);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch {
// Clipboard support is optional.
}
};
return (
<Dialog open={Boolean(repository)} onOpenChange={onOpenChange}>
<DialogContent className="max-w-3xl">
<DialogHeader>
<DialogTitle>
{repository ? repositoryName(repository) : "Repository details"}
</DialogTitle>
</DialogHeader>
{repository ? (
<div className="space-y-5">
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
<StatCard
icon={<CircleDot className="h-4 w-4" />}
label="Open Issues"
value={String(repository.open_issues_count)}
caption="Reported upstream."
tone="amber"
/>
<StatCard
icon={<GitBranch className="h-4 w-4" />}
label="Branch"
value={repository.default_branch || "Unknown"}
caption="Default branch."
tone="blue"
/>
<StatCard
icon={<Webhook className="h-4 w-4" />}
label="Webhook"
value={repository.has_webhook_secret ? "Ready" : "Missing"}
caption="Stored secret status."
tone={repository.has_webhook_secret ? "green" : "rose"}
/>
<StatCard
icon={<Clock className="h-4 w-4" />}
label="Synced"
value={formatTimestamp(repository.last_sync_at)}
caption="Last sync timestamp."
tone={repository.last_sync_error ? "rose" : "violet"}
/>
</div>
{repository.last_sync_error ? (
<div className="flex items-start gap-3 rounded-lg border border-[color:rgba(248,113,113,0.35)] bg-[color:rgba(248,113,113,0.08)] p-3 text-sm text-[color:var(--danger)]">
<AlertCircle className="mt-0.5 h-4 w-4 shrink-0" />
<span>{repository.last_sync_error}</span>
</div>
) : null}
{repository.description ? (
<p className="rounded-lg border border-[color:var(--border)] bg-[color:var(--surface-muted)] p-3 text-sm text-muted">
{repository.description}
</p>
) : null}
<div className="grid gap-4 lg:grid-cols-2">
<div>
<h3 className="text-sm font-semibold text-strong">
Linked Boards
</h3>
<div className="mt-2 flex flex-wrap gap-2">
{repository.linked_boards.length ? (
repository.linked_boards.map((board) => (
<Badge
key={board.id}
variant="outline"
className="normal-case"
>
{board.name}
</Badge>
))
) : (
<span className="text-sm text-muted">
No linked boards.
</span>
)}
</div>
</div>
<div>
<h3 className="text-sm font-semibold text-strong">Topics</h3>
<div className="mt-2 flex flex-wrap gap-2">
{repository.topics.length ? (
repository.topics.map((topic) => (
<Badge
key={topic}
variant="accent"
className="gap-1 normal-case"
>
<Tags className="h-3 w-3" />
{topic}
</Badge>
))
) : (
<span className="text-sm text-muted">No topics.</span>
)}
</div>
</div>
</div>
<div>
<h3 className="text-sm font-semibold text-strong">Labels</h3>
<div className="mt-2 flex flex-wrap gap-2">
{repository.labels.length ? (
repository.labels.map((label) => (
<span
key={`${label.id ?? label.name}-${label.name}`}
className="inline-flex items-center gap-1 rounded-full border border-[color:var(--border)] px-3 py-1 text-xs text-strong"
title={label.description || label.name}
>
<span
className="h-2 w-2 rounded-full"
style={{ backgroundColor: labelColor(label.color) }}
/>
{label.name}
</span>
))
) : (
<span className="text-sm text-muted">No labels synced.</span>
)}
</div>
</div>
<div className="rounded-lg border border-[color:var(--border)] bg-[color:var(--surface-muted)] p-3">
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div className="min-w-0">
<p className="text-sm font-semibold text-strong">
Webhook URL
</p>
<code className="mt-1 block truncate font-mono text-xs text-muted">
{webhookUrl}
</code>
</div>
<Button
type="button"
variant="ghost"
size="sm"
onClick={handleCopy}
className="h-8"
>
{copied ? (
<CheckCircle2 className="h-4 w-4 text-[color:var(--success)]" />
) : (
<Copy className="h-4 w-4" />
)}
{copied ? "Copied" : "Copy"}
</Button>
</div>
</div>
</div>
) : null}
</DialogContent>
</Dialog>
);
}
export default function ForgejoRepositoriesPage() {
const auth = useAuth();
const [repositories, setRepositories] = useState<ForgejoRepository[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [isBulkSyncing, setIsBulkSyncing] = useState(false);
const [error, setError] = useState<string | null>(null);
const [notice, setNotice] = useState<Notice | null>(null);
const [search, setSearch] = useState("");
const [filter, setFilter] = useState<RepositoryFilter>("all");
const [selectedRepository, setSelectedRepository] =
useState<ForgejoRepository | null>(null);
const [deleteTarget, setDeleteTarget] = useState<ForgejoRepository | null>(
null,
);
const [deleteError, setDeleteError] = useState<string | null>(null);
const [isDeleting, setIsDeleting] = useState(false);
const webhookBaseUrl = useMemo(() => {
try {
return getApiBaseUrl();
} catch {
return "";
}
}, []);
const fetchRepositories = useCallback(async () => {
try {
setIsLoading(true);
const data = await getForgejoRepositories();
setRepositories(data);
setError(null);
} catch (err) {
setError(
err instanceof Error ? err.message : "Failed to load repositories",
);
} finally {
setIsLoading(false);
}
}, []);
useEffect(() => {
if (auth.isSignedIn) {
fetchRepositories();
}
}, [auth.isSignedIn, fetchRepositories]);
useEffect(() => {
if (!notice) return;
const id = setTimeout(() => setNotice(null), 8000);
return () => clearTimeout(id);
}, [notice]);
const activeRepositories = useMemo(
() => repositories.filter((repo) => repo.active).length,
[repositories],
);
const totalOpenIssues = useMemo(
() =>
repositories.reduce((total, repo) => total + repo.open_issues_count, 0),
[repositories],
);
const webhookReady = useMemo(
() =>
repositories.filter((repo) => repo.active && repo.has_webhook_secret)
.length,
[repositories],
);
const syncErrors = useMemo(
() => repositories.filter((repo) => repo.last_sync_error).length,
[repositories],
);
const attentionRepositories = useMemo(
() =>
repositories.filter(
(repo) =>
repo.last_sync_error ||
(repo.active && !repo.has_webhook_secret) ||
(repo.active && !repo.last_sync_at),
),
[repositories],
);
const archivedRepositories = useMemo(
() => repositories.filter((repo) => repo.is_archived).length,
[repositories],
);
const linkedBoardCount = useMemo(
() =>
repositories.reduce(
(total, repo) => total + repo.linked_boards.length,
0,
),
[repositories],
);
const latestSync = useMemo(() => {
const dates = repositories
.map((repo) => repo.last_sync_at)
.filter((value): value is string => Boolean(value))
.map((value) => new Date(value))
.filter((date) => !Number.isNaN(date.getTime()));
if (!dates.length) return null;
return dates.reduce((latest, date) =>
date.getTime() > latest.getTime() ? date : latest,
);
}, [repositories]);
const filteredRepositories = useMemo(() => {
const query = search.trim().toLowerCase();
return repositories.filter((repo) => {
const matchesQuery =
!query ||
[
repositoryName(repo),
repo.owner,
repo.repo,
repo.connection?.name ?? "",
repo.connection?.base_url ?? "",
repo.default_branch,
...repo.topics,
...repo.linked_boards.map((board) => board.name),
]
.join(" ")
.toLowerCase()
.includes(query);
if (!matchesQuery) return false;
if (filter === "active") return repo.active;
if (filter === "webhooks") return repo.active && !repo.has_webhook_secret;
if (filter === "archived") return repo.is_archived;
if (filter === "attention") return attentionRepositories.includes(repo);
return true;
});
}, [attentionRepositories, filter, repositories, search]);
const filterOptions = useMemo(
() => [
{ key: "all" as const, label: "All", count: repositories.length },
{
key: "attention" as const,
label: "Attention",
count: attentionRepositories.length,
},
{ key: "active" as const, label: "Active", count: activeRepositories },
{
key: "webhooks" as const,
label: "Missing Webhooks",
count: repositories.filter(
(repo) => repo.active && !repo.has_webhook_secret,
).length,
},
{
key: "archived" as const,
label: "Archived",
count: archivedRepositories,
},
],
[
activeRepositories,
archivedRepositories,
attentionRepositories.length,
repositories,
],
);
const handleDelete = (repository: ForgejoRepository) => {
setDeleteError(null);
setDeleteTarget(repository);
};
const confirmDelete = async () => {
if (!deleteTarget) return;
setIsDeleting(true);
setDeleteError(null);
try {
await deleteForgejoRepository(deleteTarget.id);
setRepositories((prev) => prev.filter((r) => r.id !== deleteTarget.id));
setNotice({
tone: "success",
message: `Deleted "${repositoryName(deleteTarget)}".`,
});
setDeleteTarget(null);
} catch (err) {
setDeleteError(
err instanceof Error ? err.message : "Failed to delete repository",
);
} finally {
setIsDeleting(false);
}
};
const handleSync = async (repository: ForgejoRepository) => {
try {
const result = await syncRepository(repository.id);
setNotice({
tone: "success",
message: `${repositoryName(repository)} synced: ${result.created} created, ${result.updated} updated, ${result.open} open, ${result.closed} closed.`,
});
// Refetch to update last_sync_at
const data = await getForgejoRepositories();
setRepositories(data);
return result;
} catch (err) {
setNotice({
tone: "error",
message:
err instanceof Error ? err.message : "Failed to sync repository",
});
throw err;
}
};
const handleSyncAttention = async () => {
const targets = attentionRepositories.filter((repo) => repo.active);
if (!targets.length) return;
setIsBulkSyncing(true);
let succeeded = 0;
let failed = 0;
await Promise.allSettled(
targets.map(async (repo) => {
try {
await syncRepository(repo.id);
succeeded++;
} catch {
failed++;
}
}),
);
const data = await getForgejoRepositories();
setRepositories(data);
setIsBulkSyncing(false);
setNotice(
failed === 0
? {
tone: "success",
message: `${succeeded} repositor${succeeded === 1 ? "y" : "ies"} synced successfully.`,
}
: {
tone: "error",
message: `Sync completed: ${succeeded} succeeded, ${failed} failed.`,
},
);
};
const handleValidateRepository = async (repository: ForgejoRepository) => {
try {
const result = await validateRepository(repository.id);
if (result.status.ok) {
setNotice({
tone: "success",
message: `${repositoryName(repository)} is reachable from Pipeline.`,
});
} else {
setNotice({
tone: "error",
message: `Repository validation failed: ${result.status.error_message || "Unknown error"}`,
});
}
return result;
} catch (err) {
setNotice({
tone: "error",
message:
err instanceof Error ? err.message : "Failed to validate repository",
});
throw err;
}
};
return (
<>
<DashboardPageLayout
signedOut={{
message: "Sign in to manage Git Project repositories.",
forceRedirectUrl: "/git-projects/repositories",
signUpForceRedirectUrl: "/git-projects/repositories",
}}
title="Git Project Repositories"
description={`${repositories.length} repositor${repositories.length === 1 ? "y" : "ies"} tracked by Pipeline.`}
stickyHeader
headerActions={
<div className="flex flex-wrap gap-2">
<Link href="/settings/git-projects">
<Button variant="outline" size="sm">
<Settings className="h-4 w-4" />
Settings
</Button>
</Link>
<Button
type="button"
variant="outline"
size="sm"
onClick={fetchRepositories}
disabled={isLoading}
>
<RefreshCw
className={`h-4 w-4 ${isLoading ? "animate-spin" : ""}`}
/>
Refresh
</Button>
<Link href="/git-projects/repositories/new">
<Button size="sm">
<GitBranch className="h-4 w-4" />
Add Repository
</Button>
</Link>
</div>
}
>
<div className="space-y-6">
{notice ? <NoticeBanner notice={notice} /> : null}
<section className="relative overflow-hidden rounded-xl border border-[color:rgba(96,165,250,0.28)] bg-[linear-gradient(135deg,rgba(96,165,250,0.16),rgba(52,211,153,0.09)_34%,rgba(251,191,36,0.08)_68%,var(--surface)_100%)] p-4 shadow-lush md:p-5">
<div className="pointer-events-none absolute inset-x-0 top-0 h-px bg-[linear-gradient(90deg,rgba(96,165,250,0),rgba(96,165,250,0.85),rgba(52,211,153,0.85),rgba(251,191,36,0))]" />
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div className="min-w-0">
<Badge
variant={attentionRepositories.length ? "warning" : "success"}
className="w-fit shadow-[0_0_24px_rgba(96,165,250,0.16)]"
>
{attentionRepositories.length
? `${attentionRepositories.length} need review`
: "Healthy"}
</Badge>
<h2 className="mt-3 font-heading text-2xl font-semibold text-strong">
Repository Operations
</h2>
<p className="mt-2 max-w-3xl text-sm text-muted">
Track provider reachability, webhook coverage, sync freshness,
and board usage from one management surface.
</p>
</div>
<div className="flex flex-wrap gap-2">
<Button
type="button"
variant="outline"
onClick={handleSyncAttention}
disabled={
isBulkSyncing ||
attentionRepositories.filter((repo) => repo.active)
.length === 0
}
>
{isBulkSyncing ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<RefreshCw className="h-4 w-4" />
)}
Sync Attention
</Button>
<Link href="/git-projects">
<Button type="button" variant="outline">
<ExternalLink className="h-4 w-4" />
Git Projects
</Button>
</Link>
</div>
</div>
<div className="mt-5 grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
<StatCard
icon={<GitBranch className="h-4 w-4" />}
label="Repositories"
value={`${activeRepositories}/${repositories.length}`}
caption="Active tracked repositories."
tone="blue"
/>
<StatCard
icon={<CircleDot className="h-4 w-4" />}
label="Open Issues"
value={formatCompactNumber(totalOpenIssues)}
caption="Reported by Forgejo."
tone="amber"
/>
<StatCard
icon={<Webhook className="h-4 w-4" />}
label="Webhooks"
value={`${webhookReady}/${activeRepositories}`}
caption="Active repositories with secrets."
tone={webhookReady === activeRepositories ? "green" : "rose"}
/>
<StatCard
icon={<Clock className="h-4 w-4" />}
label="Latest Sync"
value={formatTimestamp(latestSync?.toISOString() ?? null)}
caption={`${syncErrors} errors, ${archivedRepositories} archived, ${linkedBoardCount} board links.`}
tone={syncErrors ? "rose" : "violet"}
/>
</div>
</section>
<section className="rounded-xl border border-[color:var(--border)] bg-[linear-gradient(180deg,rgba(255,255,255,0.035),rgba(255,255,255,0)_72%),var(--surface)] p-4 shadow-lush md:p-5">
<div className="flex flex-col gap-3 xl:flex-row xl:items-center xl:justify-between">
<div className="relative min-w-0 flex-1">
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted" />
<Input
value={search}
onChange={(event) => setSearch(event.target.value)}
placeholder="Search repositories, connections, topics, boards..."
className="pl-9"
/>
</div>
<div className="flex flex-wrap gap-2">
{filterOptions.map((option) => (
<Button
key={option.key}
type="button"
variant={filter === option.key ? "primary" : "outline"}
size="sm"
onClick={() => setFilter(option.key)}
className={`gap-1.5 ${
filter === option.key
? "shadow-[0_0_24px_rgba(96,165,250,0.2)]"
: ""
}`}
>
{option.label}
<span
className={`rounded-full px-1.5 text-xs ${
filter === option.key
? "bg-[color:rgba(7,11,18,0.18)] text-inherit"
: "bg-[color:var(--surface-muted)] text-muted"
}`}
>
{option.count}
</span>
</Button>
))}
</div>
</div>
</section>
<div className="overflow-hidden rounded-xl border border-[color:rgba(96,165,250,0.18)] bg-[linear-gradient(180deg,rgba(96,165,250,0.07),rgba(15,23,36,0)_160px),var(--surface)] shadow-lush">
{error ? (
<div className="p-8 text-center">
<p className="text-sm text-[color:var(--danger)]">{error}</p>
</div>
) : (
<ForgejoRepositoriesTable
repositories={filteredRepositories}
isLoading={isLoading}
onDelete={handleDelete}
onViewDetails={setSelectedRepository}
onSync={handleSync}
onValidate={handleValidateRepository}
/>
)}
</div>
</div>
</DashboardPageLayout>
<RepositoryDetailsDialog
repository={selectedRepository}
webhookBaseUrl={webhookBaseUrl}
onOpenChange={(open) => {
if (!open) setSelectedRepository(null);
}}
/>
<ConfirmActionDialog
open={Boolean(deleteTarget)}
onOpenChange={(open) => {
if (!open) setDeleteTarget(null);
}}
title="Delete Git Project repository"
description={
deleteTarget
? `Delete "${repositoryName(deleteTarget)}" from Pipeline? Synced issue records for this repository will be removed.`
: ""
}
onConfirm={confirmDelete}
isConfirming={isDeleting}
errorMessage={deleteError}
confirmLabel="Delete Repository"
confirmingLabel="Deleting…"
confirmClassName="bg-destructive text-destructive-foreground hover:bg-destructive/90"
cancelLabel="Keep Repository"
/>
</>
);
export default function GitProjectRepositoriesRedirectPage() {
redirect("/git-projects");
}

View File

@ -1340,7 +1340,7 @@ export default function GitProjectSettingsPage() {
, and paste a secret. Store the same secret on the repository
record in Pipeline via{" "}
<Link
href="/git-projects/repositories"
href="/git-projects"
className="text-[color:var(--accent)] underline underline-offset-2 hover:opacity-80"
>
Edit Repository

View File

@ -11,14 +11,21 @@ import { useQueryClient } from "@tanstack/react-query";
import {
ArrowRight,
Bot,
Building2,
Folder,
GitBranch,
Globe,
KeyRound,
Mail,
Network,
RefreshCw,
RotateCcw,
Save,
SlidersHorizontal,
Tags,
Trash2,
User,
type LucideIcon,
} from "lucide-react";
import {
@ -35,16 +42,71 @@ import { ConfirmActionDialog } from "@/components/ui/confirm-action-dialog";
import { Input } from "@/components/ui/input";
import SearchableSelect from "@/components/ui/searchable-select";
import { getSupportedTimezones } from "@/lib/timezones";
import { useOrganizationMembership } from "@/lib/use-organization-membership";
type ClerkGlobal = {
signOut?: (options?: { redirectUrl?: string }) => Promise<void> | void;
};
type SettingsLink = {
href: string;
label: string;
description: string;
icon: LucideIcon;
};
function SettingsLinkSection({
title,
description,
items,
}: {
title: string;
description: string;
items: SettingsLink[];
}) {
if (items.length === 0) return null;
return (
<section className="rounded-xl border border-border bg-card p-6 shadow-sm">
<div className="mb-4">
<h2 className="text-base font-semibold text-foreground">{title}</h2>
<p className="mt-1 text-sm text-muted-foreground">{description}</p>
</div>
<div className="divide-y divide-border">
{items.map((item) => {
const Icon = item.icon;
return (
<Link
key={item.href}
href={item.href}
className="group flex items-center gap-4 py-4 first:pt-0 last:pb-0 focus-visible:outline-none"
>
<span className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg border border-border bg-muted text-muted-foreground transition group-hover:border-[color:var(--accent)] group-hover:text-[color:var(--accent)] group-focus-visible:ring-2 group-focus-visible:ring-ring">
<Icon className="h-4 w-4" />
</span>
<span className="min-w-0 flex-1">
<span className="block text-sm font-semibold text-foreground">
{item.label}
</span>
<span className="mt-1 block text-sm text-muted-foreground">
{item.description}
</span>
</span>
<ArrowRight className="h-4 w-4 shrink-0 text-muted-foreground transition group-hover:translate-x-0.5 group-hover:text-[color:var(--accent)]" />
</Link>
);
})}
</div>
</section>
);
}
export default function SettingsPage() {
const router = useRouter();
const queryClient = useQueryClient();
const { isSignedIn } = useAuth();
const { user } = useUser();
const { isAdmin } = useOrganizationMembership(isSignedIn);
const [name, setName] = useState("");
const [timezone, setTimezone] = useState<string | null>(null);
@ -56,7 +118,9 @@ export default function SettingsPage() {
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [forgejoSyncing, setForgejoSyncing] = useState(false);
const [forgejoSyncError, setForgejoSyncError] = useState<string | null>(null);
const [forgejoSyncSuccess, setForgejoSyncSuccess] = useState<string | null>(null);
const [forgejoSyncSuccess, setForgejoSyncSuccess] = useState<string | null>(
null,
);
const meQuery = useGetMeApiV1UsersMeGet<
getMeApiV1UsersMeGetResponse,
@ -141,7 +205,9 @@ export default function SettingsPage() {
};
const handleDisableForgejoProfile = async () => {
await updateMeMutation.mutateAsync({ data: { use_forgejo_profile: false } });
await updateMeMutation.mutateAsync({
data: { use_forgejo_profile: false },
});
setForgejoSyncSuccess(null);
setForgejoSyncError(null);
};
@ -175,6 +241,80 @@ export default function SettingsPage() {
const isSaving = updateMeMutation.isPending;
const workspaceItems: SettingsLink[] = [
{
href: "/organization",
label: "Organization",
description: "Manage members, invites, access, and organization details.",
icon: Building2,
},
{
href: "/board-groups",
label: "Board groups",
description:
"Group related boards so agents and operators share context.",
icon: Folder,
},
...(isAdmin
? [
{
href: "/tags",
label: "Tags",
description: "Maintain reusable task labels used across boards.",
icon: Tags,
},
{
href: "/custom-fields",
label: "Custom fields",
description:
"Configure organization-level task metadata and board bindings.",
icon: SlidersHorizontal,
},
]
: []),
];
const integrationItems: SettingsLink[] = [
{
href: "/settings/ai-providers",
label: "AI providers",
description:
"Manage model credentials, endpoints, and provider usage tracking.",
icon: KeyRound,
},
{
href: "/settings/git-projects",
label: "Git project settings",
description:
"Review Forgejo sync health and Git Project automation settings.",
icon: GitBranch,
},
{
href: "/git-projects/connections",
label: "Git connections",
description: "Connect Git providers used for repository and issue sync.",
icon: GitBranch,
},
];
const operationItems: SettingsLink[] = isAdmin
? [
{
href: "/agents",
label: "Agents",
description:
"Provision and monitor the agents available to this organization.",
icon: Bot,
},
{
href: "/gateways",
label: "Gateways",
description: "Manage gateway connections used by boards and agents.",
icon: Network,
},
]
: [];
return (
<>
<DashboardPageLayout
@ -184,7 +324,7 @@ export default function SettingsPage() {
signUpForceRedirectUrl: "/settings",
}}
title="Settings"
description="Update your profile and account preferences."
description="Manage your profile, workspace configuration, and integrations."
>
<div className="space-y-6">
<section className="rounded-xl border border-border bg-card p-6 shadow-sm">
@ -351,48 +491,23 @@ export default function SettingsPage() {
</div>
</section>
<section className="rounded-xl border border-border bg-card p-6 shadow-sm">
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div className="min-w-0">
<h2 className="flex items-center gap-2 text-base font-semibold text-foreground">
<Bot className="h-4 w-4 text-muted-foreground" />
AI Providers
</h2>
<p className="mt-1 text-sm text-muted-foreground">
API keys and endpoints for Claude, Codex/OpenAI, and Ollama
(local, on-prem, or cloud). Add multiple accounts to track
usage separately.
</p>
</div>
<Link href="/settings/ai-providers">
<Button type="button" variant="outline">
Manage Providers
<ArrowRight className="h-4 w-4" />
</Button>
</Link>
</div>
</section>
<SettingsLinkSection
title="Workspace"
description="Set up the organization structure behind your boards."
items={workspaceItems}
/>
<section className="rounded-xl border border-border bg-card p-6 shadow-sm">
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div className="min-w-0">
<h2 className="flex items-center gap-2 text-base font-semibold text-foreground">
<GitBranch className="h-4 w-4 text-muted-foreground" />
Git Projects
</h2>
<p className="mt-1 text-sm text-muted-foreground">
Manage Forgejo connections, tracked repositories, and issue
sync.
</p>
</div>
<Link href="/git-projects/connections">
<Button type="button" variant="outline">
Manage Connections
<ArrowRight className="h-4 w-4" />
</Button>
</Link>
</div>
</section>
<SettingsLinkSection
title="Integrations"
description="Connect external systems and configure model providers."
items={integrationItems}
/>
<SettingsLinkSection
title="Operations"
description="Admin tools for runtime infrastructure and agent access."
items={operationItems}
/>
<section className="rounded-xl border border-border bg-card p-6 shadow-sm">
<h2 className="text-base font-semibold text-foreground">

View File

@ -330,7 +330,7 @@ export function AssignIssueAgentDialog({
{isLinkingRepository ? "Linking..." : "Link and continue"}
</Button>
<Link
href="/git-projects/repositories"
href="/git-projects"
className="inline-flex h-9 items-center gap-2 rounded-xl border border-[color:var(--border)] bg-[color:var(--surface)] px-3 text-xs font-semibold text-strong transition hover:border-[color:var(--accent)] hover:text-[color:var(--accent)]"
>
Manage Git repositories

View File

@ -92,22 +92,32 @@ const repositoryLabel = (repository: ForgejoRepository): string =>
// ── Tone → icon badge (background + text color) ────────────────────────────
const toneIconClasses: Record<MetricTone, string> = {
amber: "border-[color:rgba(245,158,11,0.35)] bg-[color:rgba(245,158,11,0.14)] text-[color:#F59E0B]",
success: "border-[color:rgba(16,185,129,0.35)] bg-[color:rgba(16,185,129,0.14)] text-[color:#10B981]",
danger: "border-[color:rgba(248,113,113,0.35)] bg-[color:rgba(248,113,113,0.14)] text-[color:var(--danger)]",
amber:
"border-[color:rgba(245,158,11,0.35)] bg-[color:rgba(245,158,11,0.14)] text-[color:#F59E0B]",
success:
"border-[color:rgba(16,185,129,0.35)] bg-[color:rgba(16,185,129,0.14)] text-[color:#10B981]",
danger:
"border-[color:rgba(248,113,113,0.35)] bg-[color:rgba(248,113,113,0.14)] text-[color:var(--danger)]",
cyan: "border-[color:rgba(6,182,212,0.35)] bg-[color:rgba(6,182,212,0.14)] text-[color:#06B6D4]",
slate: "border-[color:rgba(100,116,139,0.35)] bg-[color:rgba(100,116,139,0.12)] text-[color:#64748B]",
muted: "border-[color:var(--border)] bg-[color:var(--surface-muted)] text-muted",
slate:
"border-[color:rgba(100,116,139,0.35)] bg-[color:rgba(100,116,139,0.12)] text-[color:#64748B]",
muted:
"border-[color:var(--border)] bg-[color:var(--surface-muted)] text-muted",
};
// ── Tone → card (full background + border + hover glow) ───────────────────
const toneCardClasses: Record<MetricTone, string> = {
amber: "border-[color:rgba(245,158,11,0.28)] bg-[color:rgba(245,158,11,0.06)] hover:border-[color:rgba(245,158,11,0.55)] hover:shadow-[0_4px_24px_rgba(245,158,11,0.18)]",
success: "border-[color:rgba(16,185,129,0.28)] bg-[color:rgba(16,185,129,0.06)] hover:border-[color:rgba(16,185,129,0.55)] hover:shadow-[0_4px_24px_rgba(16,185,129,0.18)]",
danger: "border-[color:rgba(248,113,113,0.28)] bg-[color:rgba(248,113,113,0.06)] hover:border-[color:rgba(248,113,113,0.55)] hover:shadow-[0_4px_24px_rgba(248,113,113,0.18)]",
amber:
"border-[color:rgba(245,158,11,0.28)] bg-[color:rgba(245,158,11,0.06)] hover:border-[color:rgba(245,158,11,0.55)] hover:shadow-[0_4px_24px_rgba(245,158,11,0.18)]",
success:
"border-[color:rgba(16,185,129,0.28)] bg-[color:rgba(16,185,129,0.06)] hover:border-[color:rgba(16,185,129,0.55)] hover:shadow-[0_4px_24px_rgba(16,185,129,0.18)]",
danger:
"border-[color:rgba(248,113,113,0.28)] bg-[color:rgba(248,113,113,0.06)] hover:border-[color:rgba(248,113,113,0.55)] hover:shadow-[0_4px_24px_rgba(248,113,113,0.18)]",
cyan: "border-[color:rgba(6,182,212,0.28)] bg-[color:rgba(6,182,212,0.06)] hover:border-[color:rgba(6,182,212,0.55)] hover:shadow-[0_4px_24px_rgba(6,182,212,0.18)]",
slate: "border-[color:rgba(100,116,139,0.22)] bg-[color:rgba(100,116,139,0.05)] hover:border-[color:rgba(100,116,139,0.40)] hover:shadow-[0_4px_16px_rgba(100,116,139,0.12)]",
muted: "border-[color:var(--border)] bg-[color:var(--surface-muted)] hover:border-[color:var(--border-strong)] hover:shadow-sm",
slate:
"border-[color:rgba(100,116,139,0.22)] bg-[color:rgba(100,116,139,0.05)] hover:border-[color:rgba(100,116,139,0.40)] hover:shadow-[0_4px_16px_rgba(100,116,139,0.12)]",
muted:
"border-[color:var(--border)] bg-[color:var(--surface-muted)] hover:border-[color:var(--border-strong)] hover:shadow-sm",
};
// ── Tone → value text color ────────────────────────────────────────────────
@ -132,25 +142,28 @@ function buildSyncHealthCard(
const syncErrorCount = Object.values(metrics?.sync_error_counts ?? {}).filter(
(count) => Number(count) > 0,
).length;
const metricSyncDates = Object.values(metrics?.last_sync_timestamps ?? {}).flatMap(
(v) => { const d = parseDate(v); return d ? [d] : []; },
);
const repositorySyncDates = repositories.flatMap(
(r) => { const d = parseDate(r.last_sync_at); return d ? [d] : []; },
);
const metricSyncDates = Object.values(
metrics?.last_sync_timestamps ?? {},
).flatMap((v) => {
const d = parseDate(v);
return d ? [d] : [];
});
const repositorySyncDates = repositories.flatMap((r) => {
const d = parseDate(r.last_sync_at);
return d ? [d] : [];
});
const latestSync = newestDate(
metricSyncDates.length > 0 ? metricSyncDates : repositorySyncDates,
);
const latestSyncAge = latestSync && nowMs != null
? Math.max(0, nowMs - latestSync.getTime())
: 0;
const latestSyncAge =
latestSync && nowMs != null ? Math.max(0, nowMs - latestSync.getTime()) : 0;
if (repositoryCount === 0) {
return {
title: "Last Sync",
value: "No repos",
caption: "Add repositories to track sync status.",
href: "/git-projects/repositories",
href: "/git-projects",
tone: "muted",
icon: RefreshCw,
};
@ -160,7 +173,7 @@ function buildSyncHealthCard(
title: "Last Sync",
value: `${formatCount(syncErrorCount)} repo${syncErrorCount === 1 ? "" : "s"}`,
caption: "Repository sync needs attention.",
href: "/git-projects/repositories",
href: "/git-projects",
tone: "danger",
icon: ShieldAlert,
action: {
@ -175,7 +188,7 @@ function buildSyncHealthCard(
title: "Last Sync",
value: "Waiting",
caption: "Repositories have not synced yet.",
href: "/git-projects/repositories",
href: "/git-projects",
tone: "slate",
icon: Clock3,
action: {
@ -190,7 +203,7 @@ function buildSyncHealthCard(
title: "Last Sync",
value: "Stale",
caption: `Updated ${formatRelativeTimestampLive(latestSync, nowMs ?? latestSync.getTime())}.`,
href: "/git-projects/repositories",
href: "/git-projects",
tone: "amber",
icon: Clock3,
action: {
@ -204,7 +217,7 @@ function buildSyncHealthCard(
title: "Last Sync",
value: "Healthy",
caption: `Updated ${formatRelativeTimestampLive(latestSync, nowMs ?? latestSync.getTime())}.`,
href: "/git-projects/repositories",
href: "/git-projects",
tone: "cyan",
icon: ShieldCheck,
action: {
@ -292,7 +305,10 @@ function MetricCardLink({ card }: { card: MetricCard }) {
aria-label={card.action.label}
>
<RefreshCw
className={cn("h-4 w-4 shrink-0", card.action.isLoading && "animate-spin")}
className={cn(
"h-4 w-4 shrink-0",
card.action.isLoading && "animate-spin",
)}
/>
</button>
) : (
@ -331,13 +347,12 @@ export function ForgejoIssueMetricCards({
const openIssues = metrics?.open_issues ?? 0;
const recentlyClosed = metrics?.closed_last_7_days ?? 0;
const staleOpen = metrics?.stale_open_issues ?? 0;
const repositoriesSynced = metrics?.repositories_synced ?? activeRepositories.length;
const repositoriesSynced =
metrics?.repositories_synced ?? activeRepositories.length;
// Stale: 0 → slate, 15 → amber, >5 → danger
const staleTone: MetricTone =
staleOpen === 0 ? "slate" :
staleOpen <= 5 ? "amber" :
"danger";
staleOpen === 0 ? "slate" : staleOpen <= 5 ? "amber" : "danger";
const issueHref = (params: string): string => {
const repositoryParam =
selectedRepositoryId === ALL_REPOSITORIES_VALUE
@ -394,7 +409,8 @@ export function ForgejoIssueMetricCards({
Git Project Issue Tracking
</h3>
<p className="mt-1 text-sm text-muted">
High-level Forgejo issue health across repositories synced into Pipeline.
High-level Forgejo issue health across repositories synced into
Pipeline.
</p>
</div>
<div className="flex w-full flex-col gap-2 sm:w-auto sm:flex-row sm:items-center">
@ -407,7 +423,9 @@ export function ForgejoIssueMetricCards({
<SelectValue placeholder="All projects" />
</SelectTrigger>
<SelectContent align="end">
<SelectItem value={ALL_REPOSITORIES_VALUE}>All projects</SelectItem>
<SelectItem value={ALL_REPOSITORIES_VALUE}>
All projects
</SelectItem>
{repositories.map((repository) => (
<SelectItem key={repository.id} value={repository.id}>
{repositoryLabel(repository)}
@ -438,16 +456,20 @@ export function ForgejoIssueMetricCards({
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-4">
{isLoading
? Array.from({ length: 4 }).map((_, i) => <MetricSkeleton key={i} />)
: cards.map((card) => <MetricCardLink key={card.title} card={card} />)}
: cards.map((card) => (
<MetricCardLink key={card.title} card={card} />
))}
</div>
{!isLoading && !error && repositories.length === 0 ? (
<div className="mt-4 rounded-lg border border-dashed border-[color:var(--border)] bg-[color:var(--surface-muted)] p-3 text-sm text-muted">
No Git Project repositories are configured yet. Metrics will populate after repositories are added and synced.
No Git Project repositories are configured yet. Metrics will populate
after repositories are added and synced.
</div>
) : !isLoading && !error && repositoriesSynced === 0 ? (
<div className="mt-4 rounded-lg border border-dashed border-[color:var(--border)] bg-[color:var(--surface-muted)] p-3 text-sm text-muted">
Git Project repositories are configured, but Pipeline has not synced issue metrics yet.
Git Project repositories are configured, but Pipeline has not synced
issue metrics yet.
</div>
) : null}
</section>

View File

@ -11,6 +11,12 @@ import {
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import {
AlertCircle,
Archive,
@ -25,7 +31,10 @@ import {
RefreshCw,
ShieldCheck,
} from "lucide-react";
import { DataTable } from "@/components/tables/DataTable";
import {
DataTable,
type DataTableRowAction,
} from "@/components/tables/DataTable";
import { cn } from "@/lib/utils";
import type {
@ -45,14 +54,14 @@ const repositoryTone = (repo: ForgejoRepository) => {
const toneClasses = {
success: {
rail: "border-l-[color:var(--success)]",
row: "bg-[color:rgba(52,211,153,0.025)] hover:bg-[color:rgba(52,211,153,0.07)]",
rail: "border-l-transparent",
row: "hover:bg-[color:rgba(96,165,250,0.045)]",
icon: "border-[color:rgba(52,211,153,0.28)] bg-[color:rgba(52,211,153,0.13)] text-[color:var(--success)]",
dot: "bg-[color:var(--success)]",
},
warning: {
rail: "border-l-[color:var(--warning)]",
row: "bg-[color:rgba(251,191,36,0.025)] hover:bg-[color:rgba(251,191,36,0.075)]",
row: "bg-[color:rgba(251,191,36,0.035)] hover:bg-[color:rgba(251,191,36,0.085)]",
icon: "border-[color:rgba(251,191,36,0.3)] bg-[color:rgba(251,191,36,0.13)] text-[color:var(--warning)]",
dot: "bg-[color:var(--warning)]",
},
@ -63,7 +72,7 @@ const toneClasses = {
dot: "bg-[color:var(--danger)]",
},
muted: {
rail: "border-l-[color:var(--border-strong)]",
rail: "border-l-transparent",
row: "hover:bg-[color:var(--surface-muted)]",
icon: "border-[color:var(--border)] bg-[color:var(--surface-muted)] text-muted",
dot: "bg-[color:var(--text-quiet)]",
@ -74,8 +83,25 @@ const formatSyncTime = (value: string | null) => {
if (!value) return null;
const date = new Date(value);
if (Number.isNaN(date.getTime())) return null;
const diffMinutes = Math.max(
0,
Math.round((Date.now() - date.getTime()) / 60_000),
);
const relative =
diffMinutes < 1
? "Just now"
: diffMinutes < 60
? `${diffMinutes} min ago`
: diffMinutes < 60 * 24
? `${Math.round(diffMinutes / 60)} hr ago`
: diffMinutes < 60 * 24 * 7
? `${Math.round(diffMinutes / (60 * 24))} days ago`
: date.toLocaleDateString();
return {
date: date.toLocaleDateString(),
relative,
full: date.toLocaleString(),
time: date.toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
@ -114,6 +140,27 @@ export function ForgejoRepositoriesTable({
}: RepositoriesTableProps) {
// onEdit available for future use
const _ = onEdit;
const rowActions: DataTableRowAction<ForgejoRepository>[] = [
{
key: "edit",
label: "Edit",
href: (row) => `/git-projects/repositories/${row.id}/edit`,
className:
"inline-flex h-8 items-center justify-center rounded-lg px-3 text-xs font-semibold text-muted transition hover:bg-[color:var(--surface-muted)] hover:text-strong focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--accent)]",
},
...(onDelete
? [
{
key: "delete",
label: "Delete",
onClick: onDelete,
className:
"h-8 rounded-lg px-3 text-xs font-semibold text-muted transition hover:bg-[color:rgba(248,113,113,0.1)] hover:text-[color:var(--danger)]",
} satisfies DataTableRowAction<ForgejoRepository>,
]
: []),
];
const table = useReactTable({
data: repositories,
columns: columns(onSync, onValidate, onViewDetails),
@ -133,18 +180,18 @@ export function ForgejoRepositoriesTable({
actionLabel: "Add repository",
}}
rowActions={{
getEditHref: (row) => `/git-projects/repositories/${row.id}/edit`,
onDelete: onDelete ?? undefined,
header: "Manage",
actions: rowActions,
cellClassName: "px-3 py-3 align-middle md:px-5",
}}
tableClassName="min-w-[900px] w-full text-left text-sm"
headerClassName="bg-[linear-gradient(90deg,rgba(96,165,250,0.16),rgba(52,211,153,0.1),rgba(251,191,36,0.08))] text-xs font-semibold uppercase tracking-wider text-[color:var(--text-muted)]"
headerClassName="border-b border-[color:var(--border)] bg-[color:var(--surface-muted)]/80 text-xs font-semibold uppercase tracking-wider text-[color:var(--text-muted)]"
headerCellClassName="px-3 py-3 md:px-5"
cellClassName="px-3 py-4 align-middle md:px-5"
rowClassName={(row) => {
const tone = repositoryTone(row.original);
return cn(
"border-l-4 transition-colors",
"border-l-4 transition-colors duration-150",
toneClasses[tone].rail,
toneClasses[tone].row,
);
@ -205,11 +252,6 @@ const columns = (
<span className="truncate">
{repo.owner}/{repo.repo}
</span>
{repo.connection?.name ? (
<span className="truncate text-[color:var(--text-quiet)]">
/ {repo.connection.name}
</span>
) : null}
</span>
{repo.description ? (
<span className="mt-1 block max-w-[360px] truncate text-xs text-muted">
@ -290,7 +332,7 @@ const columns = (
Webhook
</Badge>
) : (
<Badge variant="outline" className="gap-1">
<Badge variant="warning" className="gap-1">
<KeyRound className="h-3 w-3" />
No secret
</Badge>
@ -323,11 +365,11 @@ const columns = (
className={cn(
"inline-flex min-w-[94px] flex-col rounded-xl border px-3 py-2",
row.original.open_issues_count > 0
? "border-[color:rgba(251,191,36,0.28)] bg-[color:rgba(251,191,36,0.1)]"
: "border-[color:rgba(52,211,153,0.24)] bg-[color:rgba(52,211,153,0.08)]",
? "border-[color:rgba(251,191,36,0.24)] bg-[color:rgba(251,191,36,0.08)]"
: "border-[color:rgba(52,211,153,0.18)] bg-[color:rgba(52,211,153,0.055)]",
)}
>
<span className="text-lg font-semibold leading-none text-strong">
<span className="text-base font-semibold leading-none text-strong">
{row.original.open_issues_count}
</span>
<span className="mt-1 text-[11px] uppercase tracking-wide text-muted">
@ -357,13 +399,16 @@ const columns = (
}
return (
<div className="flex min-w-[150px] items-start gap-2">
<div
className="flex min-w-[150px] items-start gap-2"
title={syncTime.full}
>
<span
className={cn("mt-1.5 h-2 w-2 rounded-full", toneClasses[tone].dot)}
/>
<div className="flex flex-col">
<span className="text-sm font-medium text-strong">
{syncTime.date}
{syncTime.relative}
</span>
<span className="text-xs text-muted">{syncTime.time}</span>
{lastSyncError && (
@ -378,6 +423,7 @@ const columns = (
},
{
id: "actions",
header: "Actions",
cell: ({ row }) => (
<ActionsCell
repository={row.original}
@ -444,14 +490,16 @@ function ActionsCell({
};
return (
<div className="flex items-center gap-2">
<TooltipProvider delayDuration={180}>
<div className="inline-flex items-center rounded-xl border border-[color:var(--border)] bg-[color:var(--surface)] p-1 shadow-sm">
{onSync && (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
onClick={handleSync}
disabled={isSyncLoading}
className="h-8 w-8 p-0"
title="Sync issues"
className="h-7 w-7 rounded-lg p-0 text-muted hover:bg-[color:var(--surface-muted)] hover:text-strong"
aria-label={`Sync issues for ${repository.display_name || `${repository.owner}/${repository.repo}`}`}
>
{isSyncLoading ? (
@ -462,14 +510,18 @@ function ActionsCell({
<RefreshCw className="h-4 w-4" />
)}
</Button>
</TooltipTrigger>
<TooltipContent>Sync issues</TooltipContent>
</Tooltip>
)}
{onValidate && (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
onClick={handleValidate}
disabled={isValidateLoading}
className="h-8 w-8 p-0"
title="Validate repository"
className="h-7 w-7 rounded-lg p-0 text-muted hover:bg-[color:var(--surface-muted)] hover:text-strong"
aria-label={`Validate ${repository.display_name || `${repository.owner}/${repository.repo}`}`}
>
{isValidateLoading ? (
@ -480,19 +532,27 @@ function ActionsCell({
<CheckCircle2 className="h-4 w-4" />
)}
</Button>
</TooltipTrigger>
<TooltipContent>Validate repository</TooltipContent>
</Tooltip>
)}
{onViewDetails && (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
onClick={() => onViewDetails(repository)}
className="h-8 w-8 p-0"
title="Repository details"
className="h-7 w-7 rounded-lg p-0 text-muted hover:bg-[color:var(--surface-muted)] hover:text-strong"
aria-label={`View details for ${repository.display_name || `${repository.owner}/${repository.repo}`}`}
>
<Eye className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Repository details</TooltipContent>
</Tooltip>
)}
</div>
</TooltipProvider>
);
}

View File

@ -11,14 +11,11 @@ import {
Building2,
CheckCircle2,
CircleDot,
Folder,
FolderGit,
KeyRound,
LayoutGrid,
Network,
Settings,
Store,
Tags,
TerminalSquare,
} from "lucide-react";
@ -65,6 +62,14 @@ const iconClass = (active: boolean, tone: NavTone) =>
);
function isNavActive(pathname: string, href: string) {
if (href === "/boards") {
return (
pathname === href ||
pathname.startsWith("/boards/") ||
pathname.startsWith("/board-groups")
);
}
if (href === "/git-projects") {
return (
pathname === href ||
@ -76,8 +81,9 @@ function isNavActive(pathname: string, href: string) {
if (href === "/settings") {
return (
pathname === href ||
(pathname.startsWith("/settings/") &&
!pathname.startsWith("/settings/ai-providers"))
pathname.startsWith("/settings/") ||
pathname.startsWith("/tags") ||
pathname.startsWith("/custom-fields")
);
}
@ -166,15 +172,8 @@ export function DashboardSidebar() {
</div>
<div>
<p className={sectionHeaderClass}>Boards</p>
<p className={sectionHeaderClass}>Work</p>
<div className="mt-2 space-y-1.5">
<NavItem
href="/board-groups"
label="Board groups"
icon={<Folder className="h-4 w-4" />}
tone="emerald"
active={isActive("/board-groups")}
/>
<NavItem
href="/boards"
label="Boards"
@ -182,6 +181,13 @@ export function DashboardSidebar() {
tone="blue"
active={isActive("/boards")}
/>
<NavItem
href="/approvals"
label="Approvals"
icon={<CheckCircle2 className="h-4 w-4" />}
tone="emerald"
active={isActive("/approvals")}
/>
<NavItem
href="/git-projects"
label="Git projects"
@ -196,29 +202,6 @@ export function DashboardSidebar() {
tone="amber"
active={isActive("/git-projects/issues")}
/>
<NavItem
href="/tags"
label="Tags"
icon={<Tags className="h-4 w-4" />}
tone="cyan"
active={isActive("/tags")}
/>
<NavItem
href="/approvals"
label="Approvals"
icon={<CheckCircle2 className="h-4 w-4" />}
tone="emerald"
active={isActive("/approvals")}
/>
{isAdmin ? (
<NavItem
href="/custom-fields"
label="Custom fields"
icon={<Settings className="h-4 w-4" />}
tone="rose"
active={isActive("/custom-fields")}
/>
) : null}
</div>
</div>
@ -250,13 +233,6 @@ export function DashboardSidebar() {
active={isActive("/gateways")}
/>
) : null}
<NavItem
href="/settings/ai-providers"
label="AI Providers"
icon={<KeyRound className="h-4 w-4" />}
tone="amber"
active={isActive("/settings/ai-providers")}
/>
</div>
</div>