table polish
This commit is contained in:
parent
fb9d4a907f
commit
bd153b7a87
|
|
@ -4,6 +4,7 @@ export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import { Folder, Plus } from "lucide-react";
|
||||||
|
|
||||||
import { useAuth } from "@/auth/clerk";
|
import { useAuth } from "@/auth/clerk";
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
|
|
@ -120,7 +121,18 @@ export default function BoardsPage() {
|
||||||
title="Boards"
|
title="Boards"
|
||||||
description={`Manage boards and task workflows. ${boards.length} board${boards.length === 1 ? "" : "s"} total.`}
|
description={`Manage boards and task workflows. ${boards.length} board${boards.length === 1 ? "" : "s"} total.`}
|
||||||
headerActions={
|
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
|
<Link
|
||||||
href="/boards/new"
|
href="/boards/new"
|
||||||
className={buttonVariants({
|
className={buttonVariants({
|
||||||
|
|
@ -128,9 +140,11 @@ export default function BoardsPage() {
|
||||||
variant: "primary",
|
variant: "primary",
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
Create board
|
Create board
|
||||||
</Link>
|
</Link>
|
||||||
) : null
|
) : null}
|
||||||
|
</div>
|
||||||
}
|
}
|
||||||
stickyHeader
|
stickyHeader
|
||||||
>
|
>
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -73,7 +73,7 @@ export default function ForgejoRepositoriesEditPage({
|
||||||
|
|
||||||
const handleSubmit = async (values: ForgejoRepositoryUpdate) => {
|
const handleSubmit = async (values: ForgejoRepositoryUpdate) => {
|
||||||
await updateForgejoRepository(params.repositoryId, values);
|
await updateForgejoRepository(params.repositoryId, values);
|
||||||
router.push("/git-projects/repositories");
|
router.push("/git-projects");
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDelete = async () => {
|
const handleDelete = async () => {
|
||||||
|
|
@ -81,7 +81,7 @@ export default function ForgejoRepositoriesEditPage({
|
||||||
setDeleteError(null);
|
setDeleteError(null);
|
||||||
try {
|
try {
|
||||||
await deleteForgejoRepository(params.repositoryId);
|
await deleteForgejoRepository(params.repositoryId);
|
||||||
router.push("/git-projects/repositories");
|
router.push("/git-projects");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setDeleteError(
|
setDeleteError(
|
||||||
err instanceof Error ? err.message : "Failed to delete repository",
|
err instanceof Error ? err.message : "Failed to delete repository",
|
||||||
|
|
@ -96,8 +96,8 @@ export default function ForgejoRepositoriesEditPage({
|
||||||
<DashboardPageLayout
|
<DashboardPageLayout
|
||||||
signedOut={{
|
signedOut={{
|
||||||
message: "Sign in to edit a tracked repository.",
|
message: "Sign in to edit a tracked repository.",
|
||||||
forceRedirectUrl: "/git-projects/repositories",
|
forceRedirectUrl: "/git-projects",
|
||||||
signUpForceRedirectUrl: "/git-projects/repositories",
|
signUpForceRedirectUrl: "/git-projects",
|
||||||
}}
|
}}
|
||||||
title="Loading…"
|
title="Loading…"
|
||||||
stickyHeader
|
stickyHeader
|
||||||
|
|
@ -112,8 +112,8 @@ export default function ForgejoRepositoriesEditPage({
|
||||||
<DashboardPageLayout
|
<DashboardPageLayout
|
||||||
signedOut={{
|
signedOut={{
|
||||||
message: "Sign in to edit a tracked repository.",
|
message: "Sign in to edit a tracked repository.",
|
||||||
forceRedirectUrl: "/git-projects/repositories",
|
forceRedirectUrl: "/git-projects",
|
||||||
signUpForceRedirectUrl: "/git-projects/repositories",
|
signUpForceRedirectUrl: "/git-projects",
|
||||||
}}
|
}}
|
||||||
title="Error"
|
title="Error"
|
||||||
stickyHeader
|
stickyHeader
|
||||||
|
|
@ -138,8 +138,8 @@ export default function ForgejoRepositoriesEditPage({
|
||||||
<DashboardPageLayout
|
<DashboardPageLayout
|
||||||
signedOut={{
|
signedOut={{
|
||||||
message: "Sign in to edit a tracked repository.",
|
message: "Sign in to edit a tracked repository.",
|
||||||
forceRedirectUrl: "/git-projects/repositories",
|
forceRedirectUrl: "/git-projects",
|
||||||
signUpForceRedirectUrl: "/git-projects/repositories",
|
signUpForceRedirectUrl: "/git-projects",
|
||||||
}}
|
}}
|
||||||
title={`Edit Git Project Repository: ${repository.display_name || repository.repo}`}
|
title={`Edit Git Project Repository: ${repository.display_name || repository.repo}`}
|
||||||
description="Update the repository settings Pipeline uses for Git Projects."
|
description="Update the repository settings Pipeline uses for Git Projects."
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ export default function ForgejoRepositoriesNewPage() {
|
||||||
|
|
||||||
const handleSubmit = async (values: ForgejoRepositoryCreate) => {
|
const handleSubmit = async (values: ForgejoRepositoryCreate) => {
|
||||||
await createForgejoRepository(values);
|
await createForgejoRepository(values);
|
||||||
router.push("/git-projects/repositories");
|
router.push("/git-projects");
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -1,819 +1,5 @@
|
||||||
"use client";
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
import {
|
export default function GitProjectRepositoriesRedirectPage() {
|
||||||
type ReactNode,
|
redirect("/git-projects");
|
||||||
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"
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1340,7 +1340,7 @@ export default function GitProjectSettingsPage() {
|
||||||
, and paste a secret. Store the same secret on the repository
|
, and paste a secret. Store the same secret on the repository
|
||||||
record in Pipeline via{" "}
|
record in Pipeline via{" "}
|
||||||
<Link
|
<Link
|
||||||
href="/git-projects/repositories"
|
href="/git-projects"
|
||||||
className="text-[color:var(--accent)] underline underline-offset-2 hover:opacity-80"
|
className="text-[color:var(--accent)] underline underline-offset-2 hover:opacity-80"
|
||||||
>
|
>
|
||||||
Edit Repository
|
Edit Repository
|
||||||
|
|
|
||||||
|
|
@ -11,14 +11,21 @@ import { useQueryClient } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
ArrowRight,
|
ArrowRight,
|
||||||
Bot,
|
Bot,
|
||||||
|
Building2,
|
||||||
|
Folder,
|
||||||
GitBranch,
|
GitBranch,
|
||||||
Globe,
|
Globe,
|
||||||
|
KeyRound,
|
||||||
Mail,
|
Mail,
|
||||||
|
Network,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
RotateCcw,
|
RotateCcw,
|
||||||
Save,
|
Save,
|
||||||
|
SlidersHorizontal,
|
||||||
|
Tags,
|
||||||
Trash2,
|
Trash2,
|
||||||
User,
|
User,
|
||||||
|
type LucideIcon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
|
@ -35,16 +42,71 @@ import { ConfirmActionDialog } from "@/components/ui/confirm-action-dialog";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import SearchableSelect from "@/components/ui/searchable-select";
|
import SearchableSelect from "@/components/ui/searchable-select";
|
||||||
import { getSupportedTimezones } from "@/lib/timezones";
|
import { getSupportedTimezones } from "@/lib/timezones";
|
||||||
|
import { useOrganizationMembership } from "@/lib/use-organization-membership";
|
||||||
|
|
||||||
type ClerkGlobal = {
|
type ClerkGlobal = {
|
||||||
signOut?: (options?: { redirectUrl?: string }) => Promise<void> | void;
|
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() {
|
export default function SettingsPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const { isSignedIn } = useAuth();
|
const { isSignedIn } = useAuth();
|
||||||
const { user } = useUser();
|
const { user } = useUser();
|
||||||
|
const { isAdmin } = useOrganizationMembership(isSignedIn);
|
||||||
|
|
||||||
const [name, setName] = useState("");
|
const [name, setName] = useState("");
|
||||||
const [timezone, setTimezone] = useState<string | null>(null);
|
const [timezone, setTimezone] = useState<string | null>(null);
|
||||||
|
|
@ -56,7 +118,9 @@ export default function SettingsPage() {
|
||||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||||
const [forgejoSyncing, setForgejoSyncing] = useState(false);
|
const [forgejoSyncing, setForgejoSyncing] = useState(false);
|
||||||
const [forgejoSyncError, setForgejoSyncError] = useState<string | null>(null);
|
const [forgejoSyncError, setForgejoSyncError] = useState<string | null>(null);
|
||||||
const [forgejoSyncSuccess, setForgejoSyncSuccess] = useState<string | null>(null);
|
const [forgejoSyncSuccess, setForgejoSyncSuccess] = useState<string | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
|
||||||
const meQuery = useGetMeApiV1UsersMeGet<
|
const meQuery = useGetMeApiV1UsersMeGet<
|
||||||
getMeApiV1UsersMeGetResponse,
|
getMeApiV1UsersMeGetResponse,
|
||||||
|
|
@ -141,7 +205,9 @@ export default function SettingsPage() {
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDisableForgejoProfile = async () => {
|
const handleDisableForgejoProfile = async () => {
|
||||||
await updateMeMutation.mutateAsync({ data: { use_forgejo_profile: false } });
|
await updateMeMutation.mutateAsync({
|
||||||
|
data: { use_forgejo_profile: false },
|
||||||
|
});
|
||||||
setForgejoSyncSuccess(null);
|
setForgejoSyncSuccess(null);
|
||||||
setForgejoSyncError(null);
|
setForgejoSyncError(null);
|
||||||
};
|
};
|
||||||
|
|
@ -175,6 +241,80 @@ export default function SettingsPage() {
|
||||||
|
|
||||||
const isSaving = updateMeMutation.isPending;
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<DashboardPageLayout
|
<DashboardPageLayout
|
||||||
|
|
@ -184,7 +324,7 @@ export default function SettingsPage() {
|
||||||
signUpForceRedirectUrl: "/settings",
|
signUpForceRedirectUrl: "/settings",
|
||||||
}}
|
}}
|
||||||
title="Settings"
|
title="Settings"
|
||||||
description="Update your profile and account preferences."
|
description="Manage your profile, workspace configuration, and integrations."
|
||||||
>
|
>
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<section className="rounded-xl border border-border bg-card p-6 shadow-sm">
|
<section className="rounded-xl border border-border bg-card p-6 shadow-sm">
|
||||||
|
|
@ -351,48 +491,23 @@ export default function SettingsPage() {
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="rounded-xl border border-border bg-card p-6 shadow-sm">
|
<SettingsLinkSection
|
||||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
title="Workspace"
|
||||||
<div className="min-w-0">
|
description="Set up the organization structure behind your boards."
|
||||||
<h2 className="flex items-center gap-2 text-base font-semibold text-foreground">
|
items={workspaceItems}
|
||||||
<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>
|
|
||||||
|
|
||||||
<section className="rounded-xl border border-border bg-card p-6 shadow-sm">
|
<SettingsLinkSection
|
||||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
title="Integrations"
|
||||||
<div className="min-w-0">
|
description="Connect external systems and configure model providers."
|
||||||
<h2 className="flex items-center gap-2 text-base font-semibold text-foreground">
|
items={integrationItems}
|
||||||
<GitBranch className="h-4 w-4 text-muted-foreground" />
|
/>
|
||||||
Git Projects
|
|
||||||
</h2>
|
<SettingsLinkSection
|
||||||
<p className="mt-1 text-sm text-muted-foreground">
|
title="Operations"
|
||||||
Manage Forgejo connections, tracked repositories, and issue
|
description="Admin tools for runtime infrastructure and agent access."
|
||||||
sync.
|
items={operationItems}
|
||||||
</p>
|
/>
|
||||||
</div>
|
|
||||||
<Link href="/git-projects/connections">
|
|
||||||
<Button type="button" variant="outline">
|
|
||||||
Manage Connections
|
|
||||||
<ArrowRight className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="rounded-xl border border-border bg-card p-6 shadow-sm">
|
<section className="rounded-xl border border-border bg-card p-6 shadow-sm">
|
||||||
<h2 className="text-base font-semibold text-foreground">
|
<h2 className="text-base font-semibold text-foreground">
|
||||||
|
|
|
||||||
|
|
@ -330,7 +330,7 @@ export function AssignIssueAgentDialog({
|
||||||
{isLinkingRepository ? "Linking..." : "Link and continue"}
|
{isLinkingRepository ? "Linking..." : "Link and continue"}
|
||||||
</Button>
|
</Button>
|
||||||
<Link
|
<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)]"
|
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
|
Manage Git repositories
|
||||||
|
|
|
||||||
|
|
@ -92,22 +92,32 @@ const repositoryLabel = (repository: ForgejoRepository): string =>
|
||||||
|
|
||||||
// ── Tone → icon badge (background + text color) ────────────────────────────
|
// ── Tone → icon badge (background + text color) ────────────────────────────
|
||||||
const toneIconClasses: Record<MetricTone, string> = {
|
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]",
|
amber:
|
||||||
success: "border-[color:rgba(16,185,129,0.35)] bg-[color:rgba(16,185,129,0.14)] text-[color:#10B981]",
|
"border-[color:rgba(245,158,11,0.35)] bg-[color:rgba(245,158,11,0.14)] text-[color:#F59E0B]",
|
||||||
danger: "border-[color:rgba(248,113,113,0.35)] bg-[color:rgba(248,113,113,0.14)] text-[color:var(--danger)]",
|
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]",
|
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]",
|
slate:
|
||||||
muted: "border-[color:var(--border)] bg-[color:var(--surface-muted)] text-muted",
|
"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) ───────────────────
|
// ── Tone → card (full background + border + hover glow) ───────────────────
|
||||||
const toneCardClasses: Record<MetricTone, string> = {
|
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)]",
|
amber:
|
||||||
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)]",
|
"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)]",
|
||||||
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)]",
|
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)]",
|
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)]",
|
slate:
|
||||||
muted: "border-[color:var(--border)] bg-[color:var(--surface-muted)] hover:border-[color:var(--border-strong)] hover:shadow-sm",
|
"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 ────────────────────────────────────────────────
|
// ── Tone → value text color ────────────────────────────────────────────────
|
||||||
|
|
@ -132,25 +142,28 @@ function buildSyncHealthCard(
|
||||||
const syncErrorCount = Object.values(metrics?.sync_error_counts ?? {}).filter(
|
const syncErrorCount = Object.values(metrics?.sync_error_counts ?? {}).filter(
|
||||||
(count) => Number(count) > 0,
|
(count) => Number(count) > 0,
|
||||||
).length;
|
).length;
|
||||||
const metricSyncDates = Object.values(metrics?.last_sync_timestamps ?? {}).flatMap(
|
const metricSyncDates = Object.values(
|
||||||
(v) => { const d = parseDate(v); return d ? [d] : []; },
|
metrics?.last_sync_timestamps ?? {},
|
||||||
);
|
).flatMap((v) => {
|
||||||
const repositorySyncDates = repositories.flatMap(
|
const d = parseDate(v);
|
||||||
(r) => { const d = parseDate(r.last_sync_at); return d ? [d] : []; },
|
return d ? [d] : [];
|
||||||
);
|
});
|
||||||
|
const repositorySyncDates = repositories.flatMap((r) => {
|
||||||
|
const d = parseDate(r.last_sync_at);
|
||||||
|
return d ? [d] : [];
|
||||||
|
});
|
||||||
const latestSync = newestDate(
|
const latestSync = newestDate(
|
||||||
metricSyncDates.length > 0 ? metricSyncDates : repositorySyncDates,
|
metricSyncDates.length > 0 ? metricSyncDates : repositorySyncDates,
|
||||||
);
|
);
|
||||||
const latestSyncAge = latestSync && nowMs != null
|
const latestSyncAge =
|
||||||
? Math.max(0, nowMs - latestSync.getTime())
|
latestSync && nowMs != null ? Math.max(0, nowMs - latestSync.getTime()) : 0;
|
||||||
: 0;
|
|
||||||
|
|
||||||
if (repositoryCount === 0) {
|
if (repositoryCount === 0) {
|
||||||
return {
|
return {
|
||||||
title: "Last Sync",
|
title: "Last Sync",
|
||||||
value: "No repos",
|
value: "No repos",
|
||||||
caption: "Add repositories to track sync status.",
|
caption: "Add repositories to track sync status.",
|
||||||
href: "/git-projects/repositories",
|
href: "/git-projects",
|
||||||
tone: "muted",
|
tone: "muted",
|
||||||
icon: RefreshCw,
|
icon: RefreshCw,
|
||||||
};
|
};
|
||||||
|
|
@ -160,7 +173,7 @@ function buildSyncHealthCard(
|
||||||
title: "Last Sync",
|
title: "Last Sync",
|
||||||
value: `${formatCount(syncErrorCount)} repo${syncErrorCount === 1 ? "" : "s"}`,
|
value: `${formatCount(syncErrorCount)} repo${syncErrorCount === 1 ? "" : "s"}`,
|
||||||
caption: "Repository sync needs attention.",
|
caption: "Repository sync needs attention.",
|
||||||
href: "/git-projects/repositories",
|
href: "/git-projects",
|
||||||
tone: "danger",
|
tone: "danger",
|
||||||
icon: ShieldAlert,
|
icon: ShieldAlert,
|
||||||
action: {
|
action: {
|
||||||
|
|
@ -175,7 +188,7 @@ function buildSyncHealthCard(
|
||||||
title: "Last Sync",
|
title: "Last Sync",
|
||||||
value: "Waiting",
|
value: "Waiting",
|
||||||
caption: "Repositories have not synced yet.",
|
caption: "Repositories have not synced yet.",
|
||||||
href: "/git-projects/repositories",
|
href: "/git-projects",
|
||||||
tone: "slate",
|
tone: "slate",
|
||||||
icon: Clock3,
|
icon: Clock3,
|
||||||
action: {
|
action: {
|
||||||
|
|
@ -190,7 +203,7 @@ function buildSyncHealthCard(
|
||||||
title: "Last Sync",
|
title: "Last Sync",
|
||||||
value: "Stale",
|
value: "Stale",
|
||||||
caption: `Updated ${formatRelativeTimestampLive(latestSync, nowMs ?? latestSync.getTime())}.`,
|
caption: `Updated ${formatRelativeTimestampLive(latestSync, nowMs ?? latestSync.getTime())}.`,
|
||||||
href: "/git-projects/repositories",
|
href: "/git-projects",
|
||||||
tone: "amber",
|
tone: "amber",
|
||||||
icon: Clock3,
|
icon: Clock3,
|
||||||
action: {
|
action: {
|
||||||
|
|
@ -204,7 +217,7 @@ function buildSyncHealthCard(
|
||||||
title: "Last Sync",
|
title: "Last Sync",
|
||||||
value: "Healthy",
|
value: "Healthy",
|
||||||
caption: `Updated ${formatRelativeTimestampLive(latestSync, nowMs ?? latestSync.getTime())}.`,
|
caption: `Updated ${formatRelativeTimestampLive(latestSync, nowMs ?? latestSync.getTime())}.`,
|
||||||
href: "/git-projects/repositories",
|
href: "/git-projects",
|
||||||
tone: "cyan",
|
tone: "cyan",
|
||||||
icon: ShieldCheck,
|
icon: ShieldCheck,
|
||||||
action: {
|
action: {
|
||||||
|
|
@ -292,7 +305,10 @@ function MetricCardLink({ card }: { card: MetricCard }) {
|
||||||
aria-label={card.action.label}
|
aria-label={card.action.label}
|
||||||
>
|
>
|
||||||
<RefreshCw
|
<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>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
|
|
@ -331,13 +347,12 @@ export function ForgejoIssueMetricCards({
|
||||||
const openIssues = metrics?.open_issues ?? 0;
|
const openIssues = metrics?.open_issues ?? 0;
|
||||||
const recentlyClosed = metrics?.closed_last_7_days ?? 0;
|
const recentlyClosed = metrics?.closed_last_7_days ?? 0;
|
||||||
const staleOpen = metrics?.stale_open_issues ?? 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, 1–5 → amber, >5 → danger
|
// Stale: 0 → slate, 1–5 → amber, >5 → danger
|
||||||
const staleTone: MetricTone =
|
const staleTone: MetricTone =
|
||||||
staleOpen === 0 ? "slate" :
|
staleOpen === 0 ? "slate" : staleOpen <= 5 ? "amber" : "danger";
|
||||||
staleOpen <= 5 ? "amber" :
|
|
||||||
"danger";
|
|
||||||
const issueHref = (params: string): string => {
|
const issueHref = (params: string): string => {
|
||||||
const repositoryParam =
|
const repositoryParam =
|
||||||
selectedRepositoryId === ALL_REPOSITORIES_VALUE
|
selectedRepositoryId === ALL_REPOSITORIES_VALUE
|
||||||
|
|
@ -394,7 +409,8 @@ export function ForgejoIssueMetricCards({
|
||||||
Git Project Issue Tracking
|
Git Project Issue Tracking
|
||||||
</h3>
|
</h3>
|
||||||
<p className="mt-1 text-sm text-muted">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex w-full flex-col gap-2 sm:w-auto sm:flex-row sm:items-center">
|
<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" />
|
<SelectValue placeholder="All projects" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent align="end">
|
<SelectContent align="end">
|
||||||
<SelectItem value={ALL_REPOSITORIES_VALUE}>All projects</SelectItem>
|
<SelectItem value={ALL_REPOSITORIES_VALUE}>
|
||||||
|
All projects
|
||||||
|
</SelectItem>
|
||||||
{repositories.map((repository) => (
|
{repositories.map((repository) => (
|
||||||
<SelectItem key={repository.id} value={repository.id}>
|
<SelectItem key={repository.id} value={repository.id}>
|
||||||
{repositoryLabel(repository)}
|
{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">
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
||||||
{isLoading
|
{isLoading
|
||||||
? Array.from({ length: 4 }).map((_, i) => <MetricSkeleton key={i} />)
|
? 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>
|
</div>
|
||||||
|
|
||||||
{!isLoading && !error && repositories.length === 0 ? (
|
{!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">
|
<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>
|
</div>
|
||||||
) : !isLoading && !error && repositoriesSynced === 0 ? (
|
) : !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">
|
<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>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</section>
|
</section>
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,12 @@ import {
|
||||||
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipProvider,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from "@/components/ui/tooltip";
|
||||||
import {
|
import {
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
Archive,
|
Archive,
|
||||||
|
|
@ -25,7 +31,10 @@ import {
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { DataTable } from "@/components/tables/DataTable";
|
import {
|
||||||
|
DataTable,
|
||||||
|
type DataTableRowAction,
|
||||||
|
} from "@/components/tables/DataTable";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
|
|
@ -45,14 +54,14 @@ const repositoryTone = (repo: ForgejoRepository) => {
|
||||||
|
|
||||||
const toneClasses = {
|
const toneClasses = {
|
||||||
success: {
|
success: {
|
||||||
rail: "border-l-[color:var(--success)]",
|
rail: "border-l-transparent",
|
||||||
row: "bg-[color:rgba(52,211,153,0.025)] hover:bg-[color:rgba(52,211,153,0.07)]",
|
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)]",
|
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)]",
|
dot: "bg-[color:var(--success)]",
|
||||||
},
|
},
|
||||||
warning: {
|
warning: {
|
||||||
rail: "border-l-[color:var(--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)]",
|
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)]",
|
dot: "bg-[color:var(--warning)]",
|
||||||
},
|
},
|
||||||
|
|
@ -63,7 +72,7 @@ const toneClasses = {
|
||||||
dot: "bg-[color:var(--danger)]",
|
dot: "bg-[color:var(--danger)]",
|
||||||
},
|
},
|
||||||
muted: {
|
muted: {
|
||||||
rail: "border-l-[color:var(--border-strong)]",
|
rail: "border-l-transparent",
|
||||||
row: "hover:bg-[color:var(--surface-muted)]",
|
row: "hover:bg-[color:var(--surface-muted)]",
|
||||||
icon: "border-[color:var(--border)] bg-[color:var(--surface-muted)] text-muted",
|
icon: "border-[color:var(--border)] bg-[color:var(--surface-muted)] text-muted",
|
||||||
dot: "bg-[color:var(--text-quiet)]",
|
dot: "bg-[color:var(--text-quiet)]",
|
||||||
|
|
@ -74,8 +83,25 @@ const formatSyncTime = (value: string | null) => {
|
||||||
if (!value) return null;
|
if (!value) return null;
|
||||||
const date = new Date(value);
|
const date = new Date(value);
|
||||||
if (Number.isNaN(date.getTime())) return null;
|
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 {
|
return {
|
||||||
date: date.toLocaleDateString(),
|
relative,
|
||||||
|
full: date.toLocaleString(),
|
||||||
time: date.toLocaleTimeString([], {
|
time: date.toLocaleTimeString([], {
|
||||||
hour: "2-digit",
|
hour: "2-digit",
|
||||||
minute: "2-digit",
|
minute: "2-digit",
|
||||||
|
|
@ -114,6 +140,27 @@ export function ForgejoRepositoriesTable({
|
||||||
}: RepositoriesTableProps) {
|
}: RepositoriesTableProps) {
|
||||||
// onEdit available for future use
|
// onEdit available for future use
|
||||||
const _ = onEdit;
|
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({
|
const table = useReactTable({
|
||||||
data: repositories,
|
data: repositories,
|
||||||
columns: columns(onSync, onValidate, onViewDetails),
|
columns: columns(onSync, onValidate, onViewDetails),
|
||||||
|
|
@ -133,18 +180,18 @@ export function ForgejoRepositoriesTable({
|
||||||
actionLabel: "Add repository",
|
actionLabel: "Add repository",
|
||||||
}}
|
}}
|
||||||
rowActions={{
|
rowActions={{
|
||||||
getEditHref: (row) => `/git-projects/repositories/${row.id}/edit`,
|
header: "Manage",
|
||||||
onDelete: onDelete ?? undefined,
|
actions: rowActions,
|
||||||
cellClassName: "px-3 py-3 align-middle md:px-5",
|
cellClassName: "px-3 py-3 align-middle md:px-5",
|
||||||
}}
|
}}
|
||||||
tableClassName="min-w-[900px] w-full text-left text-sm"
|
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"
|
headerCellClassName="px-3 py-3 md:px-5"
|
||||||
cellClassName="px-3 py-4 align-middle md:px-5"
|
cellClassName="px-3 py-4 align-middle md:px-5"
|
||||||
rowClassName={(row) => {
|
rowClassName={(row) => {
|
||||||
const tone = repositoryTone(row.original);
|
const tone = repositoryTone(row.original);
|
||||||
return cn(
|
return cn(
|
||||||
"border-l-4 transition-colors",
|
"border-l-4 transition-colors duration-150",
|
||||||
toneClasses[tone].rail,
|
toneClasses[tone].rail,
|
||||||
toneClasses[tone].row,
|
toneClasses[tone].row,
|
||||||
);
|
);
|
||||||
|
|
@ -205,11 +252,6 @@ const columns = (
|
||||||
<span className="truncate">
|
<span className="truncate">
|
||||||
{repo.owner}/{repo.repo}
|
{repo.owner}/{repo.repo}
|
||||||
</span>
|
</span>
|
||||||
{repo.connection?.name ? (
|
|
||||||
<span className="truncate text-[color:var(--text-quiet)]">
|
|
||||||
/ {repo.connection.name}
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
</span>
|
</span>
|
||||||
{repo.description ? (
|
{repo.description ? (
|
||||||
<span className="mt-1 block max-w-[360px] truncate text-xs text-muted">
|
<span className="mt-1 block max-w-[360px] truncate text-xs text-muted">
|
||||||
|
|
@ -290,7 +332,7 @@ const columns = (
|
||||||
Webhook
|
Webhook
|
||||||
</Badge>
|
</Badge>
|
||||||
) : (
|
) : (
|
||||||
<Badge variant="outline" className="gap-1">
|
<Badge variant="warning" className="gap-1">
|
||||||
<KeyRound className="h-3 w-3" />
|
<KeyRound className="h-3 w-3" />
|
||||||
No secret
|
No secret
|
||||||
</Badge>
|
</Badge>
|
||||||
|
|
@ -323,11 +365,11 @@ const columns = (
|
||||||
className={cn(
|
className={cn(
|
||||||
"inline-flex min-w-[94px] flex-col rounded-xl border px-3 py-2",
|
"inline-flex min-w-[94px] flex-col rounded-xl border px-3 py-2",
|
||||||
row.original.open_issues_count > 0
|
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(251,191,36,0.24)] bg-[color:rgba(251,191,36,0.08)]"
|
||||||
: "border-[color:rgba(52,211,153,0.24)] bg-[color:rgba(52,211,153,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}
|
{row.original.open_issues_count}
|
||||||
</span>
|
</span>
|
||||||
<span className="mt-1 text-[11px] uppercase tracking-wide text-muted">
|
<span className="mt-1 text-[11px] uppercase tracking-wide text-muted">
|
||||||
|
|
@ -357,13 +399,16 @@ const columns = (
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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
|
<span
|
||||||
className={cn("mt-1.5 h-2 w-2 rounded-full", toneClasses[tone].dot)}
|
className={cn("mt-1.5 h-2 w-2 rounded-full", toneClasses[tone].dot)}
|
||||||
/>
|
/>
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<span className="text-sm font-medium text-strong">
|
<span className="text-sm font-medium text-strong">
|
||||||
{syncTime.date}
|
{syncTime.relative}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs text-muted">{syncTime.time}</span>
|
<span className="text-xs text-muted">{syncTime.time}</span>
|
||||||
{lastSyncError && (
|
{lastSyncError && (
|
||||||
|
|
@ -378,6 +423,7 @@ const columns = (
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "actions",
|
id: "actions",
|
||||||
|
header: "Actions",
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<ActionsCell
|
<ActionsCell
|
||||||
repository={row.original}
|
repository={row.original}
|
||||||
|
|
@ -444,14 +490,16 @@ function ActionsCell({
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
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 && (
|
{onSync && (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
onClick={handleSync}
|
onClick={handleSync}
|
||||||
disabled={isSyncLoading}
|
disabled={isSyncLoading}
|
||||||
className="h-8 w-8 p-0"
|
className="h-7 w-7 rounded-lg p-0 text-muted hover:bg-[color:var(--surface-muted)] hover:text-strong"
|
||||||
title="Sync issues"
|
|
||||||
aria-label={`Sync issues for ${repository.display_name || `${repository.owner}/${repository.repo}`}`}
|
aria-label={`Sync issues for ${repository.display_name || `${repository.owner}/${repository.repo}`}`}
|
||||||
>
|
>
|
||||||
{isSyncLoading ? (
|
{isSyncLoading ? (
|
||||||
|
|
@ -462,14 +510,18 @@ function ActionsCell({
|
||||||
<RefreshCw className="h-4 w-4" />
|
<RefreshCw className="h-4 w-4" />
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>Sync issues</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
{onValidate && (
|
{onValidate && (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
onClick={handleValidate}
|
onClick={handleValidate}
|
||||||
disabled={isValidateLoading}
|
disabled={isValidateLoading}
|
||||||
className="h-8 w-8 p-0"
|
className="h-7 w-7 rounded-lg p-0 text-muted hover:bg-[color:var(--surface-muted)] hover:text-strong"
|
||||||
title="Validate repository"
|
|
||||||
aria-label={`Validate ${repository.display_name || `${repository.owner}/${repository.repo}`}`}
|
aria-label={`Validate ${repository.display_name || `${repository.owner}/${repository.repo}`}`}
|
||||||
>
|
>
|
||||||
{isValidateLoading ? (
|
{isValidateLoading ? (
|
||||||
|
|
@ -480,19 +532,27 @@ function ActionsCell({
|
||||||
<CheckCircle2 className="h-4 w-4" />
|
<CheckCircle2 className="h-4 w-4" />
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>Validate repository</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
{onViewDetails && (
|
{onViewDetails && (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
onClick={() => onViewDetails(repository)}
|
onClick={() => onViewDetails(repository)}
|
||||||
className="h-8 w-8 p-0"
|
className="h-7 w-7 rounded-lg p-0 text-muted hover:bg-[color:var(--surface-muted)] hover:text-strong"
|
||||||
title="Repository details"
|
|
||||||
aria-label={`View details for ${repository.display_name || `${repository.owner}/${repository.repo}`}`}
|
aria-label={`View details for ${repository.display_name || `${repository.owner}/${repository.repo}`}`}
|
||||||
>
|
>
|
||||||
<Eye className="h-4 w-4" />
|
<Eye className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>Repository details</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</TooltipProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,14 +11,11 @@ import {
|
||||||
Building2,
|
Building2,
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
CircleDot,
|
CircleDot,
|
||||||
Folder,
|
|
||||||
FolderGit,
|
FolderGit,
|
||||||
KeyRound,
|
|
||||||
LayoutGrid,
|
LayoutGrid,
|
||||||
Network,
|
Network,
|
||||||
Settings,
|
Settings,
|
||||||
Store,
|
Store,
|
||||||
Tags,
|
|
||||||
TerminalSquare,
|
TerminalSquare,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
|
|
@ -65,6 +62,14 @@ const iconClass = (active: boolean, tone: NavTone) =>
|
||||||
);
|
);
|
||||||
|
|
||||||
function isNavActive(pathname: string, href: string) {
|
function isNavActive(pathname: string, href: string) {
|
||||||
|
if (href === "/boards") {
|
||||||
|
return (
|
||||||
|
pathname === href ||
|
||||||
|
pathname.startsWith("/boards/") ||
|
||||||
|
pathname.startsWith("/board-groups")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (href === "/git-projects") {
|
if (href === "/git-projects") {
|
||||||
return (
|
return (
|
||||||
pathname === href ||
|
pathname === href ||
|
||||||
|
|
@ -76,8 +81,9 @@ function isNavActive(pathname: string, href: string) {
|
||||||
if (href === "/settings") {
|
if (href === "/settings") {
|
||||||
return (
|
return (
|
||||||
pathname === href ||
|
pathname === href ||
|
||||||
(pathname.startsWith("/settings/") &&
|
pathname.startsWith("/settings/") ||
|
||||||
!pathname.startsWith("/settings/ai-providers"))
|
pathname.startsWith("/tags") ||
|
||||||
|
pathname.startsWith("/custom-fields")
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -166,15 +172,8 @@ export function DashboardSidebar() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<p className={sectionHeaderClass}>Boards</p>
|
<p className={sectionHeaderClass}>Work</p>
|
||||||
<div className="mt-2 space-y-1.5">
|
<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
|
<NavItem
|
||||||
href="/boards"
|
href="/boards"
|
||||||
label="Boards"
|
label="Boards"
|
||||||
|
|
@ -182,6 +181,13 @@ export function DashboardSidebar() {
|
||||||
tone="blue"
|
tone="blue"
|
||||||
active={isActive("/boards")}
|
active={isActive("/boards")}
|
||||||
/>
|
/>
|
||||||
|
<NavItem
|
||||||
|
href="/approvals"
|
||||||
|
label="Approvals"
|
||||||
|
icon={<CheckCircle2 className="h-4 w-4" />}
|
||||||
|
tone="emerald"
|
||||||
|
active={isActive("/approvals")}
|
||||||
|
/>
|
||||||
<NavItem
|
<NavItem
|
||||||
href="/git-projects"
|
href="/git-projects"
|
||||||
label="Git projects"
|
label="Git projects"
|
||||||
|
|
@ -196,29 +202,6 @@ export function DashboardSidebar() {
|
||||||
tone="amber"
|
tone="amber"
|
||||||
active={isActive("/git-projects/issues")}
|
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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -250,13 +233,6 @@ export function DashboardSidebar() {
|
||||||
active={isActive("/gateways")}
|
active={isActive("/gateways")}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : 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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue