feat(forgejo): batch 3 UI + Georgia numbers font (#28)

This commit is contained in:
null 2026-05-19 20:14:16 -05:00
parent 2de481460f
commit 8e012a2197
30 changed files with 1551 additions and 1103 deletions

View File

@ -2,16 +2,13 @@
from __future__ import annotations
import time
from typing import TYPE_CHECKING
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, status
from sqlmodel import select
from sqlalchemy.orm import selectinload
from app.api.deps import require_org_admin
from app.core.auth import AuthContext, get_auth_context
from app.api.deps import require_org_member
from app.db import crud
from app.db.session import get_session
from app.models.forgejo_connections import ForgejoConnection
@ -22,7 +19,6 @@ from app.schemas.forgejo_connections import (
ForgejoConnectionUpdate,
)
from app.schemas.forgejo_validation import ForgejoConnectionValidationResponse
from app.services.forgejo_client import get_forgejo_client
from app.services.organizations import OrganizationContext
if TYPE_CHECKING:
@ -31,8 +27,7 @@ if TYPE_CHECKING:
router = APIRouter(prefix="/forgejo/connections", tags=["forgejo-connections"])
SESSION_DEP = Depends(get_session)
AUTH_DEP = Depends(get_auth_context)
ORG_ADMIN_DEP = Depends(require_org_admin)
ORG_MEMBER_DEP = Depends(require_org_member)
def _extract_token_last_eight(token: str | None) -> str | None:
@ -60,7 +55,7 @@ def _mask_connection(connection: ForgejoConnection) -> dict[str, object]:
@router.get("", response_model=list[ForgejoConnectionRead])
async def list_connections(
session: AsyncSession = SESSION_DEP,
ctx: OrganizationContext = ORG_ADMIN_DEP,
ctx: OrganizationContext = ORG_MEMBER_DEP,
) -> list[ForgejoConnectionRead]:
"""List Forgejo connections for the caller's organization."""
statement = (
@ -76,8 +71,7 @@ async def list_connections(
async def create_connection(
payload: ForgejoConnectionCreate,
session: AsyncSession = SESSION_DEP,
auth: AuthContext = AUTH_DEP,
ctx: OrganizationContext = ORG_ADMIN_DEP,
ctx: OrganizationContext = ORG_MEMBER_DEP,
) -> ForgejoConnectionRead:
"""Create a Forgejo connection for the caller's organization."""
data = payload.model_dump()
@ -96,7 +90,7 @@ async def create_connection(
async def get_connection(
connection_id: UUID,
session: AsyncSession = SESSION_DEP,
ctx: OrganizationContext = ORG_ADMIN_DEP,
ctx: OrganizationContext = ORG_MEMBER_DEP,
) -> ForgejoConnectionRead:
"""Return one Forgejo connection by id for the caller's organization."""
connection = await crud.get_by_id(session, ForgejoConnection, connection_id)
@ -112,8 +106,7 @@ async def update_connection(
connection_id: UUID,
payload: ForgejoConnectionUpdate,
session: AsyncSession = SESSION_DEP,
auth: AuthContext = AUTH_DEP,
ctx: OrganizationContext = ORG_ADMIN_DEP,
ctx: OrganizationContext = ORG_MEMBER_DEP,
) -> ForgejoConnectionRead:
"""Patch a Forgejo connection for the caller's organization."""
connection = await crud.get_by_id(session, ForgejoConnection, connection_id)
@ -165,7 +158,7 @@ async def update_connection(
async def delete_connection(
connection_id: UUID,
session: AsyncSession = SESSION_DEP,
ctx: OrganizationContext = ORG_ADMIN_DEP,
ctx: OrganizationContext = ORG_MEMBER_DEP,
) -> OkResponse:
"""Delete a Forgejo connection for the caller's organization."""
connection = await crud.get_by_id(session, ForgejoConnection, connection_id)
@ -188,7 +181,7 @@ async def delete_connection(
async def validate_connection(
connection_id: UUID,
session: AsyncSession = SESSION_DEP,
ctx: OrganizationContext = ORG_ADMIN_DEP,
ctx: OrganizationContext = ORG_MEMBER_DEP,
) -> ForgejoConnectionValidationResponse:
"""Validate a Forgejo connection by testing authenticated API access."""
connection = await crud.get_by_id(session, ForgejoConnection, connection_id)

View File

@ -8,9 +8,8 @@ from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlmodel import select, func
from app.api.deps import get_board_for_user_write, require_org_admin
from app.core.agent_auth import get_agent_auth_context
from app.core.auth import get_auth_context
from app.api.deps import get_board_for_user_write, require_org_member
from app.core.auth import AuthContext, get_auth_context
from app.db import crud
from app.db.session import get_session
from app.models.board_repository_links import BoardRepositoryLink
@ -26,14 +25,13 @@ if TYPE_CHECKING:
router = APIRouter(prefix="/forgejo/issues", tags=["forgejo-issues"])
SESSION_DEP = Depends(get_session)
AUTH_DEP = Depends(get_auth_context)
ORG_ADMIN_DEP = Depends(require_org_admin)
BOARD_WRITE_DEP = Depends(get_board_for_user_write)
ORG_MEMBER_DEP = Depends(require_org_member)
@router.get("", response_model=ForgejoIssueListResponse)
async def list_issues(
session: AsyncSession = SESSION_DEP,
ctx: OrganizationContext = ORG_ADMIN_DEP,
ctx: OrganizationContext = ORG_MEMBER_DEP,
repository_id: str | None = Query(None, description="Filter by repository ID"),
state: str | None = Query(None, description="Filter by state (open, closed)"),
label: str | None = Query(None, description="Filter by label name"),
@ -99,7 +97,7 @@ async def list_issues(
async def get_issue(
issue_id: str,
session: AsyncSession = SESSION_DEP,
ctx: OrganizationContext = ORG_ADMIN_DEP,
ctx: OrganizationContext = ORG_MEMBER_DEP,
) -> ForgejoIssueRead:
"""Get one cached issue by ID."""
try:
@ -157,7 +155,8 @@ async def get_issue(
async def close_issue(
issue_id: str,
session: AsyncSession = SESSION_DEP,
ctx: OrganizationContext = ORG_ADMIN_DEP,
auth: AuthContext = AUTH_DEP,
ctx: OrganizationContext = ORG_MEMBER_DEP,
) -> CloseIssueResponse:
"""Close a Forgejo issue as an authenticated user.
@ -185,18 +184,20 @@ async def close_issue(
)
# Verify the user has write access to the board
board = await get_board_for_user_write(
await get_board_for_user_write(
board_id=str(link.board_id),
session=session,
auth=ctx,
auth=auth,
)
if auth.user is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED)
# Close the issue using the service
try:
result = await close_issue_by_id(
session=session,
issue_id=uuid,
actor_user_id=ctx.user.id,
actor_user_id=auth.user.id,
)
except CloseIssueNotFoundError as e:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e))

View File

@ -2,15 +2,13 @@
from __future__ import annotations
import time
from typing import TYPE_CHECKING
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, status
from sqlmodel import select
from app.api.deps import require_org_admin
from app.core.auth import AuthContext, get_auth_context
from app.api.deps import require_org_member
from app.db import crud
from app.db.session import get_session
from app.models.forgejo_connections import ForgejoConnection
@ -31,8 +29,7 @@ if TYPE_CHECKING:
router = APIRouter(prefix="/forgejo/repositories", tags=["forgejo-repositories"])
SESSION_DEP = Depends(get_session)
AUTH_DEP = Depends(get_auth_context)
ORG_ADMIN_DEP = Depends(require_org_admin)
ORG_MEMBER_DEP = Depends(require_org_member)
def _create_connection_info(connection: ForgejoConnection) -> dict[str, object]:
@ -51,7 +48,7 @@ def _create_connection_info(connection: ForgejoConnection) -> dict[str, object]:
@router.get("", response_model=list[ForgejoRepositoryRead])
async def list_repositories(
session: AsyncSession = SESSION_DEP,
ctx: OrganizationContext = ORG_ADMIN_DEP,
ctx: OrganizationContext = ORG_MEMBER_DEP,
) -> list[ForgejoRepositoryRead]:
"""List Forgejo repositories for the caller's organization."""
statement = (
@ -77,8 +74,7 @@ async def list_repositories(
async def create_repository(
payload: ForgejoRepositoryCreate,
session: AsyncSession = SESSION_DEP,
auth: AuthContext = AUTH_DEP,
ctx: OrganizationContext = ORG_ADMIN_DEP,
ctx: OrganizationContext = ORG_MEMBER_DEP,
) -> ForgejoRepositoryRead:
"""Create a Forgejo repository tracked for the caller's organization."""
# Validate connection belongs to caller's org
@ -123,7 +119,7 @@ async def create_repository(
async def get_repository(
repository_id: UUID,
session: AsyncSession = SESSION_DEP,
ctx: OrganizationContext = ORG_ADMIN_DEP,
ctx: OrganizationContext = ORG_MEMBER_DEP,
) -> ForgejoRepositoryRead:
"""Return one Forgejo repository by id for the caller's organization."""
statement = (
@ -143,8 +139,7 @@ async def update_repository(
repository_id: UUID,
payload: ForgejoRepositoryUpdate,
session: AsyncSession = SESSION_DEP,
auth: AuthContext = AUTH_DEP,
ctx: OrganizationContext = ORG_ADMIN_DEP,
ctx: OrganizationContext = ORG_MEMBER_DEP,
) -> ForgejoRepositoryRead:
"""Patch a Forgejo repository for the caller's organization."""
# Get repository
@ -218,7 +213,7 @@ async def update_repository(
async def delete_repository(
repository_id: UUID,
session: AsyncSession = SESSION_DEP,
ctx: OrganizationContext = ORG_ADMIN_DEP,
ctx: OrganizationContext = ORG_MEMBER_DEP,
) -> OkResponse:
"""Delete a Forgejo repository for the caller's organization."""
repository = await crud.get_by_id(session, ForgejoRepository, repository_id)
@ -241,7 +236,7 @@ async def delete_repository(
async def validate_repository(
repository_id: UUID,
session: AsyncSession = SESSION_DEP,
ctx: OrganizationContext = ORG_ADMIN_DEP,
ctx: OrganizationContext = ORG_MEMBER_DEP,
) -> ForgejoRepositoryValidationResponse:
"""Validate a Forgejo repository by testing API access."""
repository = await crud.get_by_id(session, ForgejoRepository, repository_id)
@ -296,12 +291,12 @@ async def validate_repository(
@router.post(
"/{repository_id}/sync",
summary="Sync Issues from Repository",
description="Sync issues from a Forgejo repository. Admin-only endpoint.",
description="Sync issues from a Forgejo repository.",
)
async def sync_repository_issues(
repository_id: UUID,
session: AsyncSession = SESSION_DEP,
ctx: OrganizationContext = ORG_ADMIN_DEP,
ctx: OrganizationContext = ORG_MEMBER_DEP,
) -> dict[str, int]:
"""Sync issues from a Forgejo repository."""
repository = await crud.get_by_id(session, ForgejoRepository, repository_id)

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -7,6 +7,7 @@ import { Button } from "@/components/ui/button";
import { useAuth } from "@/auth/clerk";
import { DashboardPageLayout } from "@/components/templates/DashboardPageLayout";
import { ForgejoConnectionForm } from "@/components/git/ForgejoConnectionForm";
import { ConfirmActionDialog } from "@/components/ui/confirm-action-dialog";
import {
getForgejoConnection,
updateForgejoConnection,
@ -27,13 +28,20 @@ interface ConnectionData {
id: string;
}
export default function ForgejoConnectionsEditPage({ params }: { params: RouteParams }) {
export default function ForgejoConnectionsEditPage({
params,
}: {
params: RouteParams;
}) {
const router = useRouter();
const auth = useAuth();
const [connection, setConnection] = useState<ConnectionData | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [deleteOpen, setDeleteOpen] = useState(false);
const [deleteError, setDeleteError] = useState<string | null>(null);
const [isDeleting, setIsDeleting] = useState(false);
useEffect(() => {
const fetchConnection = async () => {
@ -43,7 +51,9 @@ export default function ForgejoConnectionsEditPage({ params }: { params: RoutePa
setConnection(data);
setError(null);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to load connection");
setError(
err instanceof Error ? err.message : "Failed to load connection",
);
} finally {
setIsLoading(false);
}
@ -55,26 +65,22 @@ export default function ForgejoConnectionsEditPage({ params }: { params: RoutePa
}, [params.connectionId, auth.isSignedIn]);
const handleSubmit = async (values: ForgejoConnectionUpdate) => {
try {
const connection = await updateForgejoConnection(params.connectionId, values);
console.log("Connection updated:", connection);
router.push("/git-projects/connections");
} catch (err) {
alert(err instanceof Error ? err.message : "Failed to update connection");
}
await updateForgejoConnection(params.connectionId, values);
router.push("/git-projects/connections");
};
const handleDelete = async () => {
if (
confirm(`Are you sure you want to delete "${connection?.name}"? This action cannot be undone.`)
) {
try {
await deleteForgejoConnection(params.connectionId);
console.log("Connection deleted");
router.push("/git-projects/connections");
} catch (err) {
alert(err instanceof Error ? err.message : "Failed to delete connection");
}
setIsDeleting(true);
setDeleteError(null);
try {
await deleteForgejoConnection(params.connectionId);
router.push("/git-projects/connections");
} catch (err) {
setDeleteError(
err instanceof Error ? err.message : "Failed to delete connection",
);
} finally {
setIsDeleting(false);
}
};
@ -82,14 +88,14 @@ export default function ForgejoConnectionsEditPage({ params }: { params: RoutePa
return (
<DashboardPageLayout
signedOut={{
message: "Sign in to edit a Forgejo connection.",
message: "Sign in to edit a Git Project connection.",
forceRedirectUrl: "/git-projects/connections",
signUpForceRedirectUrl: "/git-projects/connections",
}}
title="Loading..."
title="Loading"
stickyHeader
>
<p className="text-slate-500">Loading connection...</p>
<p className="text-muted">Loading connection</p>
</DashboardPageLayout>
);
}
@ -98,14 +104,16 @@ export default function ForgejoConnectionsEditPage({ params }: { params: RoutePa
return (
<DashboardPageLayout
signedOut={{
message: "Sign in to edit a Forgejo connection.",
message: "Sign in to edit a Git Project connection.",
forceRedirectUrl: "/git-projects/connections",
signUpForceRedirectUrl: "/git-projects/connections",
}}
title="Error"
stickyHeader
>
<p className="text-red-600">{error || "Connection not found"}</p>
<p className="text-[color:var(--danger)]">
{error || "Connection not found"}
</p>
</DashboardPageLayout>
);
}
@ -113,40 +121,58 @@ export default function ForgejoConnectionsEditPage({ params }: { params: RoutePa
const defaultValues = {
name: connection.name,
base_url: connection.base_url,
token: connection.has_token ? "••••" + (connection.token_last_eight || "") : "",
token: "",
};
return (
<DashboardPageLayout
signedOut={{
message: "Sign in to edit a Forgejo connection.",
message: "Sign in to edit a Git Project connection.",
forceRedirectUrl: "/git-projects/connections",
signUpForceRedirectUrl: "/git-projects/connections",
}}
title={`Edit Connection: ${connection.name}`}
description="Update connection settings and credentials."
title={`Edit Git Project Connection: ${connection.name}`}
description="Update the Git provider settings Pipeline uses for Git Projects."
stickyHeader
>
<div className="max-w-2xl">
<div className="w-full max-w-2xl">
<ForgejoConnectionForm
defaultValues={defaultValues}
onSubmit={handleSubmit}
isTokenRequired={!connection.has_token}
existingTokenLastEight={connection.token_last_eight}
submitLabel="Save Changes"
/>
<div className="mt-8 border-t pt-6">
<h3 className="text-lg font-semibold text-red-900 dark:text-red-100">Danger Zone</h3>
<p className="mt-2 text-sm text-slate-500">
Deleting a connection will remove all associated repositories and data.
<div className="surface-muted mt-6 rounded-2xl p-4 sm:p-5">
<h3 className="text-base font-semibold text-[color:var(--danger)]">
Delete Connection
</h3>
<p className="mt-2 text-sm text-muted">
Remove this connection from Pipeline. Repositories that use it will
stop syncing.
</p>
<Button
variant="outline"
className="text-red-600 hover:bg-red-50 hover:text-red-700 dark:text-red-400 dark:hover:bg-red-950 dark:hover:text-red-300"
onClick={handleDelete}
className="mt-4 border-[color:rgba(248,113,113,0.45)] text-[color:var(--danger)] hover:bg-[color:rgba(248,113,113,0.08)]"
onClick={() => setDeleteOpen(true)}
>
Delete Connection
</Button>
</div>
</div>
<ConfirmActionDialog
open={deleteOpen}
onOpenChange={setDeleteOpen}
title="Delete Git Project connection"
description={`Delete "${connection.name}" from Pipeline? Repositories that use this connection will stop syncing.`}
onConfirm={handleDelete}
isConfirming={isDeleting}
errorMessage={deleteError}
confirmLabel="Delete Connection"
confirmingLabel="Deleting…"
confirmClassName="bg-[color:var(--danger)] text-white hover:bg-[color:var(--danger)]/90"
cancelLabel="Keep Connection"
/>
</DashboardPageLayout>
);
}

View File

@ -4,33 +4,31 @@ import { useRouter } from "next/navigation";
import { DashboardPageLayout } from "@/components/templates/DashboardPageLayout";
import { ForgejoConnectionForm } from "@/components/git/ForgejoConnectionForm";
import { createForgejoConnection, type ForgejoConnectionCreate } from "@/lib/api-forgejo";
import {
createForgejoConnection,
type ForgejoConnectionCreate,
} from "@/lib/api-forgejo";
export default function ForgejoConnectionsNewPage() {
const router = useRouter();
const handleSubmit = async (values: ForgejoConnectionCreate) => {
try {
const connection = await createForgejoConnection(values);
alert(`Connection "${connection.name}" created successfully`);
router.push("/git-projects/connections");
} catch (err) {
alert(err instanceof Error ? err.message : "Failed to create connection");
}
await createForgejoConnection(values);
router.push("/git-projects/connections");
};
return (
<DashboardPageLayout
signedOut={{
message: "Sign in to create a Forgejo connection.",
message: "Sign in to create a Git Project connection.",
forceRedirectUrl: "/git-projects/connections/new",
signUpForceRedirectUrl: "/git-projects/connections/new",
}}
title="New Forgejo Connection"
description="Add a new Forgejo instance to track issues and pull requests."
title="New Git Project Connection"
description="Connect a Git provider so Pipeline can track repository issues."
stickyHeader
>
<div className="max-w-2xl">
<div className="w-full max-w-2xl">
<ForgejoConnectionForm onSubmit={handleSubmit} />
</div>
</DashboardPageLayout>

View File

@ -2,11 +2,13 @@
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { AlertCircle, CheckCircle2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useAuth } from "@/auth/clerk";
import { DashboardPageLayout } from "@/components/templates/DashboardPageLayout";
import { ForgejoConnectionsTable } from "@/components/git/ForgejoConnectionsTable";
import { ConfirmActionDialog } from "@/components/ui/confirm-action-dialog";
import {
getForgejoConnections,
deleteForgejoConnection,
@ -21,6 +23,15 @@ export default function ForgejoConnectionsPage() {
const [connections, setConnections] = useState<ForgejoConnection[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [notice, setNotice] = useState<{
tone: "success" | "error";
message: string;
} | null>(null);
const [deleteTarget, setDeleteTarget] = useState<ForgejoConnection | null>(
null,
);
const [deleteError, setDeleteError] = useState<string | null>(null);
const [isDeleting, setIsDeleting] = useState(false);
useEffect(() => {
const fetchConnections = async () => {
@ -30,7 +41,9 @@ export default function ForgejoConnectionsPage() {
setConnections(data);
setError(null);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to load connections");
setError(
err instanceof Error ? err.message : "Failed to load connections",
);
} finally {
setIsLoading(false);
}
@ -41,77 +54,130 @@ export default function ForgejoConnectionsPage() {
}
}, [auth.isSignedIn, auth.getToken]);
const handleDelete = async (connection: ForgejoConnection) => {
if (
confirm(`Are you sure you want to delete "${connection.name}"? This action cannot be undone.`)
) {
try {
await deleteForgejoConnection(connection.id);
setConnections((prev) => prev.filter((c) => c.id !== connection.id));
alert("Connection deleted successfully");
} catch (err) {
alert(err instanceof Error ? err.message : "Failed to delete connection");
}
const handleDelete = (connection: ForgejoConnection) => {
setDeleteError(null);
setDeleteTarget(connection);
};
const confirmDelete = async () => {
if (!deleteTarget) return;
setIsDeleting(true);
setDeleteError(null);
try {
await deleteForgejoConnection(deleteTarget.id);
setConnections((prev) => prev.filter((c) => c.id !== deleteTarget.id));
setNotice({
tone: "success",
message: `Deleted "${deleteTarget.name}".`,
});
setDeleteTarget(null);
} catch (err) {
setDeleteError(
err instanceof Error ? err.message : "Failed to delete connection",
);
} finally {
setIsDeleting(false);
}
};
const handleValidateConnection = async (connection: ForgejoConnection) => {
try {
const result = await validateConnection(connection.id);
if (result.ok) {
alert(
`Connection validated successfully!\n\n` +
`Response time: ${result.response_time_ms}ms`
);
if (result.status.ok) {
setNotice({
tone: "success",
message: `"${connection.name}" validated in ${Math.round(result.response_time_ms)}ms.`,
});
} else {
alert(
`Connection validation failed: ${result.error_message || "Unknown error"}`
);
setNotice({
tone: "error",
message: `Connection validation failed: ${result.status.error_message || "Unknown error"}`,
});
}
return result;
} catch (err) {
alert(err instanceof Error ? err.message : "Failed to validate connection");
setNotice({
tone: "error",
message:
err instanceof Error ? err.message : "Failed to validate connection",
});
throw err;
}
};
return (
<DashboardPageLayout
signedOut={{
message: "Sign in to manage Forgejo connections.",
forceRedirectUrl: "/git-projects/connections",
signUpForceRedirectUrl: "/git-projects/connections",
}}
title="Forgejo Connections"
description={`${connections.length} connection${connections.length === 1 ? "" : "s"} configured.`}
stickyHeader
isAdmin={false}
adminOnlyMessage="Admin access required to manage Forgejo connections."
>
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between">
<h2 className="text-sm font-medium text-slate-500">Connections</h2>
<Button
onClick={() => router.push("/git-projects/connections/new")}
>
Add Connection
</Button>
</div>
<div className="overflow-hidden rounded-xl border border-slate-200 bg-white shadow-sm">
{error ? (
<div className="p-8 text-center">
<p className="text-red-600">{error}</p>
<>
<DashboardPageLayout
signedOut={{
message: "Sign in to manage Git Project connections.",
forceRedirectUrl: "/git-projects/connections",
signUpForceRedirectUrl: "/git-projects/connections",
}}
title="Git Project Connections"
description={`${connections.length} connection${connections.length === 1 ? "" : "s"} configured for Pipeline.`}
stickyHeader
>
<div className="flex flex-col gap-4">
{notice ? (
<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>
) : (
<ForgejoConnectionsTable
connections={connections}
isLoading={isLoading}
onDelete={handleDelete}
onValidate={handleValidateConnection}
/>
)}
) : null}
<div className="flex flex-wrap items-center justify-between gap-3">
<h2 className="text-sm font-medium text-muted">Connections</h2>
<Button
onClick={() => router.push("/git-projects/connections/new")}
>
Add Connection
</Button>
</div>
<div className="overflow-hidden rounded-xl border border-[color:var(--border)] bg-[color:var(--surface)] shadow-lush">
{error ? (
<div className="p-8 text-center">
<p className="text-sm text-[color:var(--danger)]">{error}</p>
</div>
) : (
<ForgejoConnectionsTable
connections={connections}
isLoading={isLoading}
onDelete={handleDelete}
onValidate={handleValidateConnection}
/>
)}
</div>
</div>
</div>
</DashboardPageLayout>
</DashboardPageLayout>
<ConfirmActionDialog
open={Boolean(deleteTarget)}
onOpenChange={(open) => {
if (!open) setDeleteTarget(null);
}}
title="Delete Git Project connection"
description={
deleteTarget
? `Delete "${deleteTarget.name}" from Pipeline? Repositories that use this connection will stop syncing.`
: ""
}
onConfirm={confirmDelete}
isConfirming={isDeleting}
errorMessage={deleteError}
confirmLabel="Delete Connection"
confirmingLabel="Deleting…"
confirmClassName="bg-[color:var(--danger)] text-white hover:bg-[color:var(--danger)]/90"
cancelLabel="Keep Connection"
/>
</>
);
}

View File

@ -2,14 +2,11 @@
export const dynamic = "force-dynamic";
import { useMemo, useState, useEffect } from "react";
import {
type ColumnDef,
} from "@tanstack/react-table";
import { useState, useEffect } from "react";
import { AlertCircle } from "lucide-react";
import { DashboardPageLayout } from "@/components/templates/DashboardPageLayout";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
getForgejoIssues,
getForgejoRepositories,
@ -23,6 +20,8 @@ export default function GitIssuesPage() {
const [issues, setIssues] = useState<ForgejoIssue[]>([]);
const [repos, setRepos] = useState<ForgejoRepository[]>([]);
const [total, setTotal] = useState(0);
const [isLoadingIssues, setIsLoadingIssues] = useState(true);
const [error, setError] = useState<string | null>(null);
const [stateFilter, setStateFilter] = useState<string>("open");
const [repoFilter, setRepoFilter] = useState<string>("all");
const [search, setSearch] = useState("");
@ -44,8 +43,9 @@ export default function GitIssuesPage() {
const controller = new AbortController();
(async () => {
try {
setIsLoadingIssues(true);
const result = await getForgejoIssues({
state: stateFilter || undefined,
state: stateFilter !== "all" ? stateFilter : undefined,
repository_id: repoFilter !== "all" ? repoFilter : undefined,
search: search || undefined,
page,
@ -53,9 +53,16 @@ export default function GitIssuesPage() {
});
setIssues(result.items);
setTotal(result.total);
setError(null);
} catch (err) {
if (err instanceof Error && err.name === "AbortError") return;
console.error("Failed to fetch issues:", err);
setError(
err instanceof Error
? err.message
: "Pipeline could not load Git Project issues.",
);
} finally {
setIsLoadingIssues(false);
}
})();
return () => controller.abort();
@ -63,8 +70,9 @@ export default function GitIssuesPage() {
const handleRefresh = async () => {
try {
setIsLoadingIssues(true);
const result = await getForgejoIssues({
state: stateFilter || undefined,
state: stateFilter !== "all" ? stateFilter : undefined,
repository_id: repoFilter !== "all" ? repoFilter : undefined,
search: search || undefined,
page,
@ -72,152 +80,85 @@ export default function GitIssuesPage() {
});
setIssues(result.items);
setTotal(result.total);
setError(null);
} catch (err) {
console.error("Failed to fetch issues:", err);
setError(
err instanceof Error
? err.message
: "Pipeline could not refresh Git Project issues.",
);
} finally {
setIsLoadingIssues(false);
}
};
const columns: ColumnDef<ForgejoIssue>[] = useMemo(
() => [
{
accessorKey: "forgejo_issue_number",
header: "#",
cell: ({ row }) => (
<a
href={row.original.html_url}
target="_blank"
rel="noopener noreferrer"
className="font-mono text-sm text-blue-600 hover:underline dark:text-blue-400"
>
#{row.original.forgejo_issue_number}
</a>
),
},
{
accessorKey: "title",
header: "Title",
cell: ({ row }) => (
<div className="max-w-md truncate">{row.original.title}</div>
),
},
{
accessorKey: "body_preview",
header: "Description",
cell: ({ row }) => {
const body = row.original.body_preview;
if (!body) return null;
const truncated = body.length > 120 ? body.slice(0, 120) + "…" : body;
return <div className="max-w-xs truncate text-sm text-slate-500">{truncated}</div>;
},
},
{
accessorKey: "state",
header: "State",
cell: ({ row }) => {
const state = row.original.state;
return (
<Badge
variant={state === "open" ? "success" : "default"}
className={state === "open" ? "" : ""}
>
{state}
</Badge>
);
},
},
{
accessorKey: "author",
header: "Author",
},
{
accessorKey: "labels",
header: "Labels",
cell: ({ row }) => {
const labels = row.original.labels;
if (!labels || labels.length === 0) return null;
return (
<div className="flex flex-wrap gap-1">
{labels.slice(0, 3).map((label: Record<string, unknown>, i: number) => (
<Badge
key={i}
variant="outline"
className="text-xs"
style={
label.color
? { backgroundColor: `#${label.color}`, color: "#fff" }
: undefined
}
>
{String(label.name || "")}
</Badge>
))}
{labels.length > 3 && (
<span className="text-xs text-slate-500">+{labels.length - 3}</span>
)}
</div>
);
},
},
{
accessorKey: "forgejo_updated_at",
header: "Updated",
cell: ({ row }) => {
try {
return new Date(row.original.forgejo_updated_at).toLocaleDateString();
} catch {
return row.original.forgejo_updated_at;
}
},
},
],
[],
);
const totalPages = Math.ceil(total / limit);
return (
<DashboardPageLayout
signedOut={{
message: "Sign in to view issues.",
message: "Sign in to view Git Project issues.",
forceRedirectUrl: "/git-projects/issues",
signUpForceRedirectUrl: "/git-projects/issues",
}}
title="Issues"
description={`${total} issue${total === 1 ? "" : "s"} from tracked repositories.`}
title="Git Project Issues"
description={`${total} issue${total === 1 ? "" : "s"} from repositories tracked by Pipeline.`}
stickyHeader
>
<ForgejoIssueFilters
stateFilter={stateFilter}
onStateChange={(v) => { setStateFilter(v); setPage(1); }}
onStateChange={(v) => {
setStateFilter(v);
setPage(1);
}}
repoFilter={repoFilter}
onRepoChange={(v) => { setRepoFilter(v); setPage(1); }}
onRepoChange={(v) => {
setRepoFilter(v);
setPage(1);
}}
search={search}
onSearchChange={(v) => { setSearch(v); setPage(1); }}
onSearchChange={(v) => {
setSearch(v);
setPage(1);
}}
repos={repos}
/>
<ForgejoIssuesTable issues={issues} onRefresh={handleRefresh} />
{error ? (
<div className="mb-4 flex items-start gap-3 rounded-xl 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>{error}</span>
</div>
) : null}
<ForgejoIssuesTable
issues={issues}
isLoading={isLoadingIssues}
onRefresh={handleRefresh}
/>
{totalPages > 1 && (
<div className="mt-4 flex items-center justify-between text-sm text-slate-600 dark:text-slate-400">
<span>
<div className="mt-4 flex flex-col gap-3 text-sm text-muted sm:flex-row sm:items-center sm:justify-between">
<span className="break-words">
Page {page} of {totalPages} ({total} total)
</span>
<div className="flex gap-2">
<button
className="rounded border px-3 py-1 disabled:opacity-50"
<Button
variant="outline"
size="sm"
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page <= 1}
>
Previous
</button>
<button
className="rounded border px-3 py-1 disabled:opacity-50"
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
disabled={page >= totalPages}
>
Next
</button>
</Button>
</div>
</div>
)}

View File

@ -9,12 +9,13 @@ import {
getCoreRowModel,
useReactTable,
} from "@tanstack/react-table";
import { AlertCircle, CheckCircle2, GitBranch, RefreshCw } from "lucide-react";
import Link from "next/link";
import { useAuth } from "@/auth/clerk";
import { DashboardPageLayout } from "@/components/templates/DashboardPageLayout";
import { DataTable } from "@/components/tables/DataTable";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
getForgejoRepositories,
@ -23,9 +24,9 @@ import {
} from "@/lib/api-forgejo";
export default function GitProjectsPage() {
const _useAuth = useAuth();
const [repositories, setRepositories] = useState<ForgejoRepository[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [syncingId, setSyncingId] = useState<string | null>(null);
const [syncResult, setSyncResult] = useState<{
repoName: string;
@ -33,14 +34,20 @@ export default function GitProjectsPage() {
updated: number;
open: number;
closed: number;
error?: string;
} | null>(null);
const fetchRepos = useCallback(async () => {
try {
const repos = await getForgejoRepositories();
setRepositories(repos);
setError(null);
} catch (err) {
console.error("Failed to fetch repositories:", err);
setError(
err instanceof Error
? err.message
: "Pipeline could not load Git Projects.",
);
} finally {
setLoading(false);
}
@ -65,7 +72,14 @@ export default function GitProjectsPage() {
});
await fetchRepos();
} catch (err) {
console.error("Sync failed:", err);
setSyncResult({
repoName: `${repo.owner}/${repo.repo}`,
created: 0,
updated: 0,
open: 0,
closed: 0,
error: err instanceof Error ? err.message : "Sync failed.",
});
} finally {
setSyncingId(null);
}
@ -80,14 +94,13 @@ export default function GitProjectsPage() {
header: "Repository",
cell: ({ row }) => {
const repo = row.original;
const name =
repo.display_name || `${repo.owner}/${repo.repo}`;
const name = repo.display_name || `${repo.owner}/${repo.repo}`;
return (
<div>
<div className="font-medium text-slate-900 dark:text-slate-100">
<div className="min-w-[180px]">
<div className="truncate font-medium text-strong" title={name}>
{name}
</div>
<div className="text-xs text-slate-500 dark:text-slate-400">
<div className="truncate font-mono text-xs text-muted">
{repo.owner}/{repo.repo}
</div>
</div>
@ -99,22 +112,20 @@ export default function GitProjectsPage() {
header: "Connection",
cell: ({ row }) => {
const conn = row.original.connection;
return conn ? conn.name : "—";
return (
<span className="block max-w-[180px] truncate text-muted">
{conn ? conn.name : "Unassigned"}
</span>
);
},
},
{
accessorKey: "active",
header: "Status",
cell: ({ row }) => (
<span
className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${
row.original.active
? "bg-emerald-50 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400"
: "bg-slate-100 text-slate-600 dark:bg-slate-800 dark:text-slate-400"
}`}
>
<Badge variant={row.original.active ? "success" : "outline"}>
{row.original.active ? "Active" : "Inactive"}
</span>
</Badge>
),
},
{
@ -122,11 +133,15 @@ export default function GitProjectsPage() {
header: "Last Synced",
cell: ({ row }) => {
const val = row.original.last_sync_at;
if (!val) return "—";
if (!val) return <span className="text-muted">Never</span>;
try {
return new Date(val).toLocaleString();
return (
<span className="whitespace-nowrap text-muted">
{new Date(val).toLocaleString()}
</span>
);
} catch {
return val;
return <span className="text-muted">{val}</span>;
}
},
},
@ -138,12 +153,16 @@ export default function GitProjectsPage() {
const isSyncing = syncingId === repo.id;
return (
<Button
variant="outline"
variant="ghost"
size="sm"
className="whitespace-nowrap"
onClick={() => handleSync(repo)}
disabled={!!syncingId}
>
{isSyncing ? "Syncing…" : "Sync Issues"}
<RefreshCw
className={`h-4 w-4 ${isSyncing ? "animate-spin" : ""}`}
/>
{isSyncing ? "Syncing" : "Sync"}
</Button>
);
},
@ -161,7 +180,7 @@ export default function GitProjectsPage() {
return (
<DashboardPageLayout
signedOut={{
message: "Sign in to view Git projects.",
message: "Sign in to view Git Projects.",
forceRedirectUrl: "/git-projects",
signUpForceRedirectUrl: "/git-projects",
}}
@ -170,14 +189,33 @@ export default function GitProjectsPage() {
stickyHeader
>
{syncResult && (
<div className="mb-4 rounded-lg border border-emerald-200 bg-emerald-50 p-3 text-sm text-emerald-800 dark:border-emerald-800 dark:bg-emerald-900/20 dark:text-emerald-300">
<strong>{syncResult.repoName}</strong> synced:{" "}
{syncResult.created} created, {syncResult.updated} updated,{" "}
{syncResult.open} open, {syncResult.closed} closed
<div
className={`mb-4 flex items-start gap-3 rounded-xl border p-3 text-sm ${
syncResult.error
? "border-[color:rgba(248,113,113,0.35)] bg-[color:rgba(248,113,113,0.08)] text-[color:var(--danger)]"
: "border-[color:rgba(52,211,153,0.35)] bg-[color:rgba(52,211,153,0.08)] text-[color:var(--success)]"
}`}
>
{syncResult.error ? (
<AlertCircle className="mt-0.5 h-4 w-4 shrink-0" />
) : (
<CheckCircle2 className="mt-0.5 h-4 w-4 shrink-0" />
)}
<div className="min-w-0">
<strong className="break-words">{syncResult.repoName}</strong>{" "}
{syncResult.error ? (
<span>{syncResult.error}</span>
) : (
<span>
synced: {syncResult.created} created, {syncResult.updated}{" "}
updated, {syncResult.open} open, {syncResult.closed} closed
</span>
)}
</div>
</div>
)}
<div className="mb-4 flex gap-3">
<div className="mb-4 flex flex-wrap gap-3">
<Link href="/git-projects/connections">
<Button variant="outline" size="sm">
Connections
@ -190,28 +228,21 @@ export default function GitProjectsPage() {
</Link>
</div>
<div className="overflow-hidden rounded-xl border border-slate-200 bg-white shadow-sm dark:border-slate-700 dark:bg-slate-900">
{error ? (
<div className="surface-muted mb-4 rounded-xl p-4 text-sm text-[color:var(--danger)]">
{error}
</div>
) : null}
<div className="overflow-hidden rounded-xl border border-[color:var(--border)] bg-[color:var(--surface)] shadow-lush">
<DataTable
table={table}
isLoading={loading}
emptyState={{
icon: (
<svg
className="h-16 w-16 text-slate-300"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M9 11l3 3L22 4" />
<path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11" />
</svg>
),
icon: <GitBranch className="h-12 w-12" />,
title: "No repositories tracked yet",
description:
"Connect a Forgejo instance and add repositories to start tracking issues.",
"Connect a Git provider and add repositories so Pipeline can track issues for Git Projects.",
actionHref: "/git-projects/connections",
actionLabel: "Set up connection",
}}
@ -219,4 +250,4 @@ export default function GitProjectsPage() {
</div>
</DashboardPageLayout>
);
}
}

View File

@ -7,6 +7,7 @@ import { Button } from "@/components/ui/button";
import { useAuth } from "@/auth/clerk";
import { DashboardPageLayout } from "@/components/templates/DashboardPageLayout";
import { ForgejoRepositoryForm } from "@/components/git/ForgejoRepositoryForm";
import { ConfirmActionDialog } from "@/components/ui/confirm-action-dialog";
import {
getForgejoRepository,
updateForgejoRepository,
@ -33,13 +34,20 @@ interface RepositoryData {
};
}
export default function ForgejoRepositoriesEditPage({ params }: { params: RouteParams }) {
export default function ForgejoRepositoriesEditPage({
params,
}: {
params: RouteParams;
}) {
const router = useRouter();
const auth = useAuth();
const [repository, setRepository] = useState<RepositoryData | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [deleteOpen, setDeleteOpen] = useState(false);
const [deleteError, setDeleteError] = useState<string | null>(null);
const [isDeleting, setIsDeleting] = useState(false);
useEffect(() => {
const fetchRepository = async () => {
@ -49,7 +57,9 @@ export default function ForgejoRepositoriesEditPage({ params }: { params: RouteP
setRepository(data);
setError(null);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to load repository");
setError(
err instanceof Error ? err.message : "Failed to load repository",
);
} finally {
setIsLoading(false);
}
@ -61,26 +71,22 @@ export default function ForgejoRepositoriesEditPage({ params }: { params: RouteP
}, [params.repositoryId, auth.isSignedIn]);
const handleSubmit = async (values: ForgejoRepositoryUpdate) => {
try {
const repository = await updateForgejoRepository(params.repositoryId, values);
console.log("Repository updated:", repository);
router.push("/git-projects/repositories");
} catch (err) {
alert(err instanceof Error ? err.message : "Failed to update repository");
}
await updateForgejoRepository(params.repositoryId, values);
router.push("/git-projects/repositories");
};
const handleDelete = async () => {
if (
confirm(`Are you sure you want to delete "${repository?.display_name || repository?.repo}"? This action cannot be undone.`)
) {
try {
await deleteForgejoRepository(params.repositoryId);
console.log("Repository deleted");
router.push("/git-projects/repositories");
} catch (err) {
alert(err instanceof Error ? err.message : "Failed to delete repository");
}
setIsDeleting(true);
setDeleteError(null);
try {
await deleteForgejoRepository(params.repositoryId);
router.push("/git-projects/repositories");
} catch (err) {
setDeleteError(
err instanceof Error ? err.message : "Failed to delete repository",
);
} finally {
setIsDeleting(false);
}
};
@ -92,10 +98,10 @@ export default function ForgejoRepositoriesEditPage({ params }: { params: RouteP
forceRedirectUrl: "/git-projects/repositories",
signUpForceRedirectUrl: "/git-projects/repositories",
}}
title="Loading..."
title="Loading"
stickyHeader
>
<p className="text-slate-500">Loading repository...</p>
<p className="text-muted">Loading repository</p>
</DashboardPageLayout>
);
}
@ -111,7 +117,9 @@ export default function ForgejoRepositoriesEditPage({ params }: { params: RouteP
title="Error"
stickyHeader
>
<p className="text-red-600">{error || "Repository not found"}</p>
<p className="text-[color:var(--danger)]">
{error || "Repository not found"}
</p>
</DashboardPageLayout>
);
}
@ -131,30 +139,46 @@ export default function ForgejoRepositoriesEditPage({ params }: { params: RouteP
forceRedirectUrl: "/git-projects/repositories",
signUpForceRedirectUrl: "/git-projects/repositories",
}}
title={`Edit Repository: ${repository.display_name || repository.repo}`}
description="Update repository settings and tracking options."
title={`Edit Git Project Repository: ${repository.display_name || repository.repo}`}
description="Update the repository settings Pipeline uses for Git Projects."
stickyHeader
>
<div className="max-w-2xl">
<div className="w-full max-w-2xl">
<ForgejoRepositoryForm
defaultValues={defaultValues}
onSubmit={handleSubmit}
submitLabel="Save Changes"
/>
<div className="mt-8 border-t pt-6">
<h3 className="text-lg font-semibold text-red-900 dark:text-red-100">Danger Zone</h3>
<p className="mt-2 text-sm text-slate-500">
Deleting a repository will remove all associated data including issues and pull requests.
<div className="surface-muted mt-6 rounded-2xl p-4 sm:p-5">
<h3 className="text-base font-semibold text-[color:var(--danger)]">
Delete Repository
</h3>
<p className="mt-2 text-sm text-muted">
Remove this repository from Pipeline. Synced issue records for this
repository will be removed.
</p>
<Button
variant="outline"
className="text-red-600 hover:bg-red-50 hover:text-red-700 dark:text-red-400 dark:hover:bg-red-950 dark:hover:text-red-300"
onClick={handleDelete}
className="mt-4 border-[color:rgba(248,113,113,0.45)] text-[color:var(--danger)] hover:bg-[color:rgba(248,113,113,0.08)]"
onClick={() => setDeleteOpen(true)}
>
Delete Repository
</Button>
</div>
</div>
<ConfirmActionDialog
open={deleteOpen}
onOpenChange={setDeleteOpen}
title="Delete Git Project repository"
description={`Delete "${repository.display_name || repository.repo}" from Pipeline? Synced issue records for this repository will be removed.`}
onConfirm={handleDelete}
isConfirming={isDeleting}
errorMessage={deleteError}
confirmLabel="Delete Repository"
confirmingLabel="Deleting…"
confirmClassName="bg-[color:var(--danger)] text-white hover:bg-[color:var(--danger)]/90"
cancelLabel="Keep Repository"
/>
</DashboardPageLayout>
);
}

View File

@ -4,33 +4,31 @@ import { useRouter } from "next/navigation";
import { DashboardPageLayout } from "@/components/templates/DashboardPageLayout";
import { ForgejoRepositoryForm } from "@/components/git/ForgejoRepositoryForm";
import { createForgejoRepository, type ForgejoRepositoryCreate } from "@/lib/api-forgejo";
import {
createForgejoRepository,
type ForgejoRepositoryCreate,
} from "@/lib/api-forgejo";
export default function ForgejoRepositoriesNewPage() {
const router = useRouter();
const handleSubmit = async (values: ForgejoRepositoryCreate) => {
try {
const repository = await createForgejoRepository(values);
alert(`Repository "${repository.display_name || repository.repo}" added successfully`);
router.push("/git-projects/repositories");
} catch (err) {
alert(err instanceof Error ? err.message : "Failed to add repository");
}
await createForgejoRepository(values);
router.push("/git-projects/repositories");
};
return (
<DashboardPageLayout
signedOut={{
message: "Sign in to add a tracked repository.",
message: "Sign in to add a Git Project repository.",
forceRedirectUrl: "/git-projects/repositories/new",
signUpForceRedirectUrl: "/git-projects/repositories/new",
}}
title="Add Tracked Repository"
description="Add a repository to track issues and pull requests from your Forgejo instance."
title="Add Git Project Repository"
description="Add a repository for Pipeline to sync and display in Git Projects."
stickyHeader
>
<div className="max-w-2xl">
<div className="w-full max-w-2xl">
<ForgejoRepositoryForm onSubmit={handleSubmit} />
</div>
</DashboardPageLayout>

View File

@ -2,11 +2,13 @@
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { AlertCircle, CheckCircle2 } 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 {
getForgejoRepositories,
deleteForgejoRepository,
@ -22,6 +24,15 @@ export default function ForgejoRepositoriesPage() {
const [repositories, setRepositories] = useState<ForgejoRepository[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [notice, setNotice] = useState<{
tone: "success" | "error";
message: string;
} | null>(null);
const [deleteTarget, setDeleteTarget] = useState<ForgejoRepository | null>(
null,
);
const [deleteError, setDeleteError] = useState<string | null>(null);
const [isDeleting, setIsDeleting] = useState(false);
useEffect(() => {
const fetchRepositories = async () => {
@ -31,7 +42,9 @@ export default function ForgejoRepositoriesPage() {
setRepositories(data);
setError(null);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to load repositories");
setError(
err instanceof Error ? err.message : "Failed to load repositories",
);
} finally {
setIsLoading(false);
}
@ -42,37 +55,52 @@ export default function ForgejoRepositoriesPage() {
}
}, [auth.isSignedIn, auth.getToken]);
const handleDelete = async (repository: ForgejoRepository) => {
if (
confirm(`Are you sure you want to delete "${repository.display_name || repository.repo}"? This action cannot be undone.`)
) {
try {
await deleteForgejoRepository(repository.id);
setRepositories((prev) => prev.filter((r) => r.id !== repository.id));
alert("Repository deleted successfully");
} catch (err) {
alert(err instanceof Error ? err.message : "Failed to delete repository");
}
const repositoryName = (repository: ForgejoRepository) =>
repository.display_name || `${repository.owner}/${repository.repo}`;
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);
alert(
`Sync completed!\n\n` +
`Created: ${result.created}\n` +
`Updated: ${result.updated}\n` +
`Open: ${result.open}\n` +
`Closed: ${result.closed}\n` +
`Total: ${result.total}`
);
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) {
alert(err instanceof Error ? err.message : "Failed to sync repository");
setNotice({
tone: "error",
message:
err instanceof Error ? err.message : "Failed to sync repository",
});
throw err;
}
};
@ -80,59 +108,102 @@ export default function ForgejoRepositoriesPage() {
const handleValidateRepository = async (repository: ForgejoRepository) => {
try {
const result = await validateRepository(repository.id);
if (result.ok) {
alert(
`Repository is valid!\n\n` +
`Repository exists: ${result.repo_exists ? "Yes" : "No"}`
);
if (result.status.ok) {
setNotice({
tone: "success",
message: `${repositoryName(repository)} is reachable from Pipeline.`,
});
} else {
alert(
`Repository validation failed: ${result.error_message || "Unknown error"}`
);
setNotice({
tone: "error",
message: `Repository validation failed: ${result.status.error_message || "Unknown error"}`,
});
}
return result;
} catch (err) {
alert(err instanceof Error ? err.message : "Failed to validate repository");
setNotice({
tone: "error",
message:
err instanceof Error ? err.message : "Failed to validate repository",
});
throw err;
}
};
return (
<DashboardPageLayout
signedOut={{
message: "Sign in to manage tracked repositories.",
forceRedirectUrl: "/git-projects/repositories",
signUpForceRedirectUrl: "/git-projects/repositories",
}}
title="Tracked Repositories"
description={`${repositories.length} repository${repositories.length === 1 ? "" : "s"} being tracked.`}
stickyHeader
>
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between">
<h2 className="text-sm font-medium text-slate-500">Repositories</h2>
<Button
onClick={() => router.push("/git-projects/repositories/new")}
>
Add Repository
</Button>
</div>
<div className="overflow-hidden rounded-xl border border-slate-200 bg-white shadow-sm">
{error ? (
<div className="p-8 text-center">
<p className="text-red-600">{error}</p>
<>
<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
>
<div className="flex flex-col gap-4">
{notice ? (
<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>
) : (
<ForgejoRepositoriesTable
repositories={repositories}
isLoading={isLoading}
onDelete={handleDelete}
onSync={handleSync}
onValidate={handleValidateRepository}
/>
)}
) : null}
<div className="flex flex-wrap items-center justify-between gap-3">
<h2 className="text-sm font-medium text-muted">Repositories</h2>
<Button
onClick={() => router.push("/git-projects/repositories/new")}
>
Add Repository
</Button>
</div>
<div className="overflow-hidden rounded-xl border border-[color:var(--border)] bg-[color:var(--surface)] shadow-lush">
{error ? (
<div className="p-8 text-center">
<p className="text-sm text-[color:var(--danger)]">{error}</p>
</div>
) : (
<ForgejoRepositoriesTable
repositories={repositories}
isLoading={isLoading}
onDelete={handleDelete}
onSync={handleSync}
onValidate={handleValidateRepository}
/>
)}
</div>
</div>
</div>
</DashboardPageLayout>
</DashboardPageLayout>
<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-[color:var(--danger)] text-white hover:bg-[color:var(--danger)]/90"
cancelLabel="Keep Repository"
/>
</>
);
}

View File

@ -2,6 +2,100 @@
@tailwind components;
@tailwind utilities;
/* Georgia font for numbers only - subset to digits and common number symbols */
@font-face {
font-family: "Georgia Numbers";
font-style: normal;
font-weight: 400;
font-display: swap;
src:
local("Georgia"),
local("Georgia-Regular"),
url("/fonts/georgia-regular.woff2") format("woff2");
unicode-range:
U+0030-0039,
U+00B9,
U+00B2,
U+00B3,
U+2030,
U+2070,
U+2074-2079,
U+2080-2089,
U+2150-215F,
U+2160-2188,
U+2189-2189;
}
@font-face {
font-family: "Georgia Numbers";
font-style: italic;
font-weight: 400;
font-display: swap;
src:
local("Georgia Italic"),
local("Georgia-Italic"),
url("/fonts/georgia-italic.woff2") format("woff2");
unicode-range:
U+0030-0039,
U+00B9,
U+00B2,
U+00B3,
U+2030,
U+2070,
U+2074-2079,
U+2080-2089,
U+2150-215F,
U+2160-2188,
U+2189-2189;
}
@font-face {
font-family: "Georgia Numbers";
font-style: normal;
font-weight: 700;
font-display: swap;
src:
local("Georgia Bold"),
local("Georgia-Bold"),
url("/fonts/georgia-bold.woff2") format("woff2");
unicode-range:
U+0030-0039,
U+00B9,
U+00B2,
U+00B3,
U+2030,
U+2070,
U+2074-2079,
U+2080-2089,
U+2150-215F,
U+2160-2188,
U+2189-2189;
}
@font-face {
font-family: "Georgia Numbers";
font-style: italic;
font-weight: 700;
font-display: swap;
src:
local("Georgia Bold Italic"),
local("Georgia-Bold-Italic"),
url("/fonts/georgia-bold-italic.woff2") format("woff2");
unicode-range:
U+0030-0039,
U+00B9,
U+00B2,
U+00B3,
U+2030,
U+2070,
U+2074-2079,
U+2080-2089,
U+2150-215F,
U+2160-2188,
U+2189-2189;
}
:root {
color-scheme: dark;
--bg: #070b12;
@ -160,6 +254,10 @@ body {
);
background-size: 120px 120px;
}
/* Numbers-only Georgia font utility */
.font-numeric {
font-family: "Georgia Numbers", "Georgia", "Times New Roman", serif;
}
}
.landing-page {

View File

@ -1,261 +1,393 @@
"use client";
import { useMemo, useState, useEffect, useCallback } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { AlertCircle, GitBranch, Loader2, X } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { ConfirmActionDialog } from "@/components/ui/confirm-action-dialog";
import { type ForgejoRepository, getForgejoRepositories, linkBoardForgejoRepository, unlinkBoardForgejoRepository, getBoardForgejoRepositories } from "@/lib/api-forgejo";
import { Input } from "@/components/ui/input";
import {
type BoardForgejoRepositoriesResponse,
type BoardForgejoRepositoryLink,
type ForgejoRepository,
getBoardForgejoRepositories,
getForgejoRepositories,
linkBoardForgejoRepository,
unlinkBoardForgejoRepository,
} from "@/lib/api-forgejo";
type BoardForgejoRepositoryLink = {
id: string;
board_id: string;
repository_id: string;
organization_id: string;
created_at: string;
interface BoardForgejoRepositoryLinksProps {
boardId: string;
canWrite?: boolean;
}
type LinkedRepository = BoardForgejoRepositoryLink & {
repository: ForgejoRepository;
};
type BoardForgejoRepositoryLinksProps = {
boardId: string;
canWrite: boolean;
};
const normalizeBoardLinks = (
result: BoardForgejoRepositoriesResponse,
): BoardForgejoRepositoryLink[] =>
Array.isArray(result) ? result : (result.repositories ?? []);
const repositoryDisplayName = (repository: ForgejoRepository): string =>
repository.display_name || `${repository.owner}/${repository.repo}`;
export function BoardForgejoRepositoryLinks({
boardId,
canWrite,
canWrite = false,
}: BoardForgejoRepositoryLinksProps) {
const [linkedRepos, setLinkedRepos] = useState<BoardForgejoRepositoryLink[]>([]);
const [linkedLinks, setLinkedLinks] = useState<BoardForgejoRepositoryLink[]>(
[],
);
const [allRepos, setAllRepos] = useState<ForgejoRepository[]>([]);
const [searchQuery, setSearchQuery] = useState("");
const [loading, setLoading] = useState(true);
const [isLinking, setIsLinking] = useState(false);
const [unlinkRepo, setUnlinkRepo] = useState<string | null>(null);
const [searchTerm, setSearchTerm] = useState("");
const [isLoading, setIsLoading] = useState(true);
const [linkError, setLinkError] = useState<string | null>(null);
const [unlinkError, setUnlinkError] = useState<string | null>(null);
const [isDialogOpen, setIsDialogOpen] = useState(false);
const [isLinking, setIsLinking] = useState(false);
const [isUnlinking, setIsUnlinking] = useState(false);
const [unlinkTarget, setUnlinkTarget] = useState<LinkedRepository | null>(
null,
);
const fetchLinkedRepos = useCallback(async () => {
try {
const result = await getBoardForgejoRepositories(boardId);
setLinkedRepos(result.repositories || []);
setLinkedLinks(normalizeBoardLinks(result));
setLinkError(null);
} catch (err) {
console.error("Failed to fetch linked repositories:", err);
const message =
err instanceof Error
? err.message
: "Unable to load linked Git Project repositories.";
setLinkError(message);
}
}, [boardId]);
const fetchAllRepositories = useCallback(async () => {
try {
const repos = await getForgejoRepositories();
setAllRepos(repos);
setLinkError(null);
} catch (err) {
console.error("Failed to fetch repositories:", err);
const message =
err instanceof Error
? err.message
: "Unable to load available Git Project repositories.";
setLinkError(message);
}
}, []);
useEffect(() => {
setLoading(true);
Promise.all([fetchLinkedRepos(), fetchAllRepositories()]).finally(() => {
setLoading(false);
});
}, [boardId, fetchLinkedRepos, fetchAllRepositories]);
let isMounted = true;
const loadRepositories = async () => {
setIsLoading(true);
await Promise.all([fetchLinkedRepos(), fetchAllRepositories()]);
if (isMounted) {
setIsLoading(false);
}
};
const filteredRepos = useMemo(() => {
if (!searchQuery) return allRepos;
const query = searchQuery.toLowerCase();
return allRepos.filter(
(r) =>
(r.display_name && r.display_name.toLowerCase().includes(query)) ||
r.owner.toLowerCase().includes(query) ||
r.repo.toLowerCase().includes(query),
);
}, [allRepos, searchQuery]);
loadRepositories();
return () => {
isMounted = false;
};
}, [fetchAllRepositories, fetchLinkedRepos]);
const linkedRepoIds = useMemo(
() => new Set(linkedLinks.map((link) => link.repository_id)),
[linkedLinks],
);
const repoById = useMemo(
() => new Map(allRepos.map((repository) => [repository.id, repository])),
[allRepos],
);
const linkedRepos = useMemo(
() =>
linkedLinks
.map((link) => ({
...link,
repository: link.repository ?? repoById.get(link.repository_id),
}))
.filter(
(link): link is LinkedRepository => link.repository !== undefined,
),
[linkedLinks, repoById],
);
const availableRepos = useMemo(() => {
const query = searchTerm.toLowerCase().trim();
return allRepos
.filter((repository) => !linkedRepoIds.has(repository.id))
.filter((repository) => {
if (!query) {
return true;
}
const haystack = [
repositoryDisplayName(repository),
repository.owner,
repository.repo,
]
.join(" ")
.toLowerCase();
return haystack.includes(query);
});
}, [allRepos, linkedRepoIds, searchTerm]);
const handleLinkRepo = async (repositoryId: string) => {
if (!canWrite) return;
if (!canWrite) {
return;
}
setIsLinking(true);
setLinkError(null);
try {
await linkBoardForgejoRepository(boardId, repositoryId);
await fetchLinkedRepos();
setSearchQuery("");
} catch (err) {
console.error("Failed to link repository:", err);
const message =
err instanceof Error
? err.message
: "Unable to link this repository to the board.";
setLinkError(message);
} finally {
setIsLinking(false);
}
};
const handleUnlinkRepo = async () => {
if (!unlinkRepo) return;
setIsDialogOpen(false);
if (!unlinkTarget || !canWrite) {
return;
}
setIsUnlinking(true);
setUnlinkError(null);
try {
await unlinkBoardForgejoRepository(boardId, unlinkRepo);
await unlinkBoardForgejoRepository(boardId, unlinkTarget.repository_id);
await fetchLinkedRepos();
setUnlinkTarget(null);
} catch (err) {
const message = err instanceof Error ? err.message : "Failed to unlink repository";
const message =
err instanceof Error
? err.message
: "Unable to unlink this repository from the board.";
setUnlinkError(message);
} finally {
setIsUnlinking(false);
}
};
const linkedRepoIds = useMemo(() => new Set(linkedRepos.map((l) => l.repository_id)), [linkedRepos]);
return (
<div className="rounded-xl border border-slate-200 bg-white p-6 dark:border-slate-700 dark:bg-slate-900">
<div className="mb-6">
<h3 className="text-lg font-semibold text-slate-900 dark:text-slate-100">
Linked Repositories
</h3>
<p className="mt-1 text-sm text-slate-500">
{linkedRepos.length} repository{linkedRepos.length === 1 ? "" : "s"} linked to this board
</p>
</div>
<>
<section className="rounded-xl border border-[color:var(--border)] bg-[color:var(--surface)] p-4 shadow-lush sm:p-5">
<div className="mb-4 flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div className="min-w-0">
<h3 className="text-sm font-semibold text-strong">
Linked Git Project Repositories
</h3>
<p className="mt-1 text-sm text-muted">
Choose which synced repositories appear on this Pipeline board.
</p>
</div>
<Badge variant="outline" className="w-fit">
{linkedRepos.length} linked
</Badge>
</div>
<div className="mb-6 flex flex-wrap items-center gap-3">
<Input
placeholder="Search repositories…"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-[240px]"
/>
{canWrite && (
<Button
variant="outline"
size="sm"
onClick={() => setIsDialogOpen(true)}
disabled={isLinking}
>
Link Repository
</Button>
{linkError && (
<div className="mb-4 flex items-start gap-2 rounded-lg border border-[color:var(--danger)]/35 bg-[color:var(--danger-soft)] px-3 py-2 text-sm text-[color:var(--danger)]">
<AlertCircle className="mt-0.5 h-4 w-4 shrink-0" />
<span>{linkError}</span>
</div>
)}
</div>
{loading ? (
<div className="py-8 text-center text-sm text-slate-500">Loading</div>
) : linkedRepos.length === 0 && allRepos.length === 0 ? (
<div className="py-8 text-center">
<p className="text-sm text-slate-500">
No repositories found. Configure Forgejo connections in Git Projects to start tracking repositories.
</p>
</div>
) : linkedRepos.length === 0 ? (
<div className="py-8 text-center">
<p className="text-sm text-slate-500">
No repositories linked to this board. Link a repository to track its issues.
</p>
</div>
) : (
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{linkedRepos.map((link) => (
<div
key={link.id}
className="rounded-lg border border-slate-200 bg-slate-50 p-4 dark:border-slate-700 dark:bg-slate-800/50"
>
<div className="flex items-start justify-between">
<div>
<div className="font-medium text-slate-900 dark:text-slate-100">
{link.repository.display_name || `${link.repository.owner}/${link.repository.repo}`}
</div>
<div className="mt-1 text-xs text-slate-500">
Last sync: {link.repository.last_sync_at ? new Date(link.repository.last_sync_at).toLocaleDateString() : "Never"}
{isLoading ? (
<div className="rounded-lg border border-[color:var(--border)] bg-[color:var(--surface-muted)] px-4 py-6 text-center text-sm text-muted">
<Loader2 className="mx-auto mb-2 h-4 w-4 animate-spin" />
Loading Git Project repositories...
</div>
) : (
<div className="space-y-5">
<div>
<div className="mb-2 text-xs font-semibold uppercase tracking-[0.14em] text-muted">
On This Board
</div>
{linkedRepos.length === 0 ? (
<div className="rounded-lg border border-dashed border-[color:var(--border)] bg-[color:var(--surface-muted)] px-4 py-5 text-center">
<div className="mx-auto mb-3 flex h-10 w-10 items-center justify-center rounded-full border border-[color:var(--border)] bg-[color:var(--surface)] text-muted">
<GitBranch className="h-5 w-5" />
</div>
<p className="text-sm font-medium text-strong">
No repositories linked yet
</p>
<p className="mt-1 text-sm text-muted">
{canWrite
? "Link a Git Project repository below to bring its issues onto this board."
: "No Git Project repositories are linked to this board yet."}
</p>
</div>
{canWrite && (
<Button
variant="ghost"
size="sm"
onClick={() => {
setUnlinkRepo(link.repository_id);
setIsDialogOpen(true);
}}
className="h-6 w-6 p-0 text-rose-500 hover:bg-rose-50 hover:text-rose-600"
title="Unlink repository"
>
<svg
className="h-4 w-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M18 6L6 18M6 6l12 12" />
</svg>
</Button>
) : (
<div className="grid gap-2 sm:grid-cols-2">
{linkedRepos.map((link) => {
const repository = link.repository;
return (
<div
key={link.id}
className="flex min-w-0 items-center justify-between gap-3 rounded-lg border border-[color:var(--border)] bg-[color:var(--surface-muted)] px-3 py-2"
>
<div className="min-w-0">
<p
className="truncate text-sm font-medium text-strong"
title={repositoryDisplayName(repository)}
>
{repositoryDisplayName(repository)}
</p>
<p
className="truncate text-xs text-muted"
title={`${repository.owner}/${repository.repo}`}
>
{repository.owner}/{repository.repo}
</p>
</div>
{canWrite ? (
<Button
type="button"
variant="ghost"
size="sm"
className="h-8 w-8 shrink-0 p-0 text-[color:var(--danger)] hover:bg-[color:var(--danger-soft)]"
onClick={() => {
setUnlinkError(null);
setUnlinkTarget(link);
}}
aria-label={`Unlink ${repositoryDisplayName(repository)}`}
>
<X className="h-4 w-4" />
</Button>
) : null}
</div>
);
})}
</div>
)}
</div>
{canWrite ? (
<div>
<div className="mb-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div className="min-w-0">
<div className="text-xs font-semibold uppercase tracking-[0.14em] text-muted">
Available Repositories
</div>
<p className="mt-1 text-sm text-muted">
Link repositories that are already configured in Git
Projects.
</p>
</div>
<Input
value={searchTerm}
onChange={(event) => setSearchTerm(event.target.value)}
placeholder="Search repositories..."
className="w-full sm:w-72"
/>
</div>
{availableRepos.length === 0 ? (
<div className="rounded-lg border border-dashed border-[color:var(--border)] bg-[color:var(--surface-muted)] px-4 py-5 text-center">
<p className="text-sm font-medium text-strong">
{allRepos.length === 0
? "No Git Project repositories configured"
: "No matching repositories"}
</p>
<p className="mt-1 text-sm text-muted">
{allRepos.length === 0
? "Add repositories in Git Projects before linking them to boards."
: "Adjust the search or unlink a repository from this board."}
</p>
</div>
) : (
<div className="grid gap-2 lg:grid-cols-2">
{availableRepos.slice(0, 9).map((repository) => (
<div
key={repository.id}
className="flex min-w-0 flex-col gap-3 rounded-lg border border-[color:var(--border)] bg-[color:var(--surface-muted)] px-3 py-3 transition-colors hover:bg-[color:var(--accent-soft)] sm:flex-row sm:items-center sm:justify-between"
>
<div className="min-w-0">
<div className="flex min-w-0 flex-wrap items-center gap-2">
<p
className="truncate text-sm font-medium text-strong"
title={repositoryDisplayName(repository)}
>
{repositoryDisplayName(repository)}
</p>
<Badge
variant={
repository.active ? "success" : "outline"
}
>
{repository.active ? "Active" : "Paused"}
</Badge>
</div>
<p
className="mt-1 truncate text-xs text-muted"
title={`${repository.owner}/${repository.repo}`}
>
{repository.owner}/{repository.repo}
</p>
</div>
<Button
type="button"
variant="outline"
size="sm"
className="w-full shrink-0 sm:w-auto"
onClick={() => handleLinkRepo(repository.id)}
disabled={isLinking}
>
Link
</Button>
</div>
))}
</div>
)}
</div>
</div>
))}
</div>
)}
{canWrite && (
<>
<div className="mt-6 border-t border-slate-200 pt-6 dark:border-slate-700">
<h4 className="mb-3 text-sm font-medium text-slate-900 dark:text-slate-100">
Available Repositories
</h4>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{filteredRepos
.filter((r) => !linkedRepoIds.has(r.id))
.slice(0, 9)
.map((repo) => (
<div
key={repo.id}
className="rounded-lg border border-slate-200 bg-slate-50 p-4 hover:border-blue-300 hover:bg-blue-50/50 dark:border-slate-700 dark:bg-slate-800/50 dark:hover:border-blue-700 dark:hover:bg-blue-900/20"
>
<div className="font-medium text-slate-900 dark:text-slate-100">
{repo.display_name || `${repo.owner}/${repo.repo}`}
</div>
<div className="mt-2 flex items-center gap-2">
<Badge variant="outline" className="text-xs">
{repo.active ? "Active" : "Inactive"}
</Badge>
{repo.last_sync_at && (
<span className="text-xs text-slate-500">
Synced {new Date(repo.last_sync_at).toLocaleDateString()}
</span>
)}
</div>
<Button
variant="outline"
size="sm"
className="mt-3 w-full text-xs"
onClick={() => handleLinkRepo(repo.id)}
disabled={isLinking}
>
Link
</Button>
</div>
))}
</div>
) : null}
</div>
</>
)}
)}
</section>
<ConfirmActionDialog
open={isDialogOpen}
onOpenChange={setIsDialogOpen}
title={
unlinkRepo
? "Unlink Repository"
: "Link Repository"
}
open={unlinkTarget !== null}
onOpenChange={(open) => {
if (!open && !isUnlinking) {
setUnlinkTarget(null);
setUnlinkError(null);
}
}}
title="Unlink Git Project repository"
description={
unlinkRepo
? unlinkError
? `Error: ${unlinkError}`
: "Are you sure you want to unlink this repository from the board? Issues from this repository will no longer appear on this board."
: "Select a repository to link to this board."
unlinkTarget
? `Remove "${repositoryDisplayName(unlinkTarget.repository)}" from this board? Issues from this repository will no longer appear on the board.`
: "Remove this repository from the board?"
}
onConfirm={
unlinkRepo ? handleUnlinkRepo : () => setIsDialogOpen(false)
}
isConfirming={isLinking || (!!unlinkRepo && unlinkError !== null)}
cancelLabel={unlinkRepo ? "Keep Linked" : "Cancel"}
confirmLabel={unlinkRepo ? "Unlink" : undefined}
errorStyle="panel"
onConfirm={handleUnlinkRepo}
isConfirming={isUnlinking}
errorMessage={unlinkError}
confirmLabel="Unlink Repository"
confirmingLabel="Unlinking..."
confirmClassName="bg-[color:var(--danger)] text-white hover:bg-[color:var(--danger)]/90"
cancelLabel="Keep Linked"
/>
</div>
</>
);
}

View File

@ -2,7 +2,14 @@
import { useState } from "react";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import type { ForgejoIssue } from "@/lib/api-forgejo";
import { closeForgejoIssue } from "@/lib/api-forgejo";
@ -33,7 +40,8 @@ export function CloseForgejoIssueDialog({
onCloseSuccess();
onOpenChange(false);
} catch (err) {
const message = err instanceof Error ? err.message : "Failed to close issue";
const message =
err instanceof Error ? err.message : "Failed to close issue";
setError(message);
} finally {
setIsClosing(false);
@ -42,25 +50,47 @@ export function CloseForgejoIssueDialog({
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>Close Issue</DialogTitle>
<DialogTitle>Close Git Project issue</DialogTitle>
<DialogDescription>
Are you sure you want to close issue{" "}
<span className="font-mono font-semibold">#{issue.forgejo_issue_number}</span> in{" "}
<span className="font-mono font-semibold">{issue.repository_id}</span>?
Pipeline will mark issue{" "}
<span className="font-mono font-semibold text-strong">
#{issue.forgejo_issue_number}
</span>{" "}
as closed in the connected Git provider and refresh the local issue
cache.
</DialogDescription>
</DialogHeader>
<div className="rounded-xl border border-[color:var(--border)] bg-[color:var(--surface-muted)] p-3">
<p className="break-words text-sm font-medium text-strong">
{issue.title}
</p>
{issue.body_preview ? (
<p className="mt-1 line-clamp-3 break-words text-xs text-muted">
{issue.body_preview}
</p>
) : null}
</div>
{error && (
<div className="rounded-lg border border-[color:var(--border)] bg-[color:var(--surface-muted)] p-3 text-xs text-muted">
<div className="rounded-lg border border-[color:rgba(248,113,113,0.35)] bg-[color:rgba(248,113,113,0.08)] p-3 text-xs text-[color:var(--danger)]">
{error}
</div>
)}
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
<Button
variant="outline"
onClick={() => onOpenChange(false)}
disabled={isClosing}
>
Cancel
</Button>
<Button onClick={handleClose} disabled={isClosing}>
<Button
variant="outline"
className="border-[color:rgba(248,113,113,0.45)] text-[color:var(--danger)] hover:bg-[color:rgba(248,113,113,0.08)]"
onClick={handleClose}
disabled={isClosing}
>
{isClosing ? "Closing…" : "Close Issue"}
</Button>
</DialogFooter>

View File

@ -12,6 +12,8 @@ interface ForgejoConnectionFormProps {
defaultValues?: Partial<ForgejoConnectionFormValues>;
onSubmit: (values: ForgejoConnectionCreate) => Promise<void>;
isSubmitting?: boolean;
isTokenRequired?: boolean;
existingTokenLastEight?: string | null;
title?: string;
description?: string;
submitLabel?: string;
@ -27,20 +29,30 @@ export function ForgejoConnectionForm({
defaultValues = {},
onSubmit,
isSubmitting = false,
title = "Forgejo Connection",
description = "Connect a Forgejo instance to track issues and pull requests.",
isTokenRequired = true,
existingTokenLastEight,
title = "Git Project Connection",
description = "Connect a Git provider so Pipeline can track issues.",
submitLabel = "Save Connection",
}: ForgejoConnectionFormProps) {
const [error, setError] = useState<string | null>(null);
const [isSaving, setIsSaving] = useState(false);
const [name, setName] = useState(defaultValues.name || "");
const [baseUrl, setBaseUrl] = useState(defaultValues.base_url || "");
const [token, setToken] = useState(defaultValues.token || "");
const isBusy = isSubmitting || isSaving;
const tokenHelpText = isTokenRequired
? "Paste your Git provider personal access token. Use read:issue; add write:issue if Pipeline should close issues. The token is stored server-side and never displayed."
: existingTokenLastEight
? `Leave blank to keep the saved token ending in ${existingTokenLastEight}. Paste a new token to replace it.`
: "Paste a new Git provider personal access token to replace the saved token.";
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setError(null);
try {
setIsSaving(true);
await onSubmit({
name,
base_url: baseUrl,
@ -48,17 +60,26 @@ export function ForgejoConnectionForm({
});
} catch (err) {
setError(err instanceof Error ? err.message : "An error occurred");
} finally {
setIsSaving(false);
}
}
return (
<form onSubmit={handleSubmit} className="space-y-8">
<form
onSubmit={handleSubmit}
className="surface-panel w-full max-w-2xl space-y-6 rounded-2xl p-4 sm:p-6"
>
<div className="space-y-4">
<h3 className="text-lg font-semibold">{title}</h3>
{description && <p className="text-sm text-slate-500">{description}</p>}
<div>
<h3 className="text-lg font-semibold text-strong">{title}</h3>
{description && (
<p className="mt-1 text-sm text-muted">{description}</p>
)}
</div>
{error && (
<div className="rounded-lg bg-red-50 p-4 text-red-700">
<div className="rounded-xl border border-[color:rgba(248,113,113,0.35)] bg-[color:rgba(248,113,113,0.08)] p-4 text-[color:var(--danger)]">
<p className="font-medium">Configuration Error</p>
<p className="text-sm">{error}</p>
</div>
@ -72,12 +93,12 @@ export function ForgejoConnectionForm({
id="name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="e.g., Dream Forgejo"
disabled={isSubmitting}
placeholder="Team Git"
disabled={isBusy}
required
/>
<p className="text-xs text-slate-500">
A memorable name for this Forgejo connection.
<p className="text-xs text-muted">
A memorable name for this Git Projects connection.
</p>
</div>
@ -89,12 +110,13 @@ export function ForgejoConnectionForm({
id="base_url"
value={baseUrl}
onChange={(e) => setBaseUrl(e.target.value)}
placeholder="https://dream.scheller.ltd"
disabled={isSubmitting}
placeholder="https://git.example.com"
disabled={isBusy}
required
/>
<p className="text-xs text-slate-500">
The base URL of your Forgejo instance (without trailing slash).
<p className="text-xs text-muted">
The base URL of your Git provider instance, without a trailing
slash.
</p>
</div>
@ -107,25 +129,34 @@ export function ForgejoConnectionForm({
type="password"
value={token}
onChange={(e) => setToken(e.target.value)}
placeholder="••••••••"
disabled={isSubmitting}
required
placeholder={
isTokenRequired
? "Paste token"
: existingTokenLastEight
? `Current token ends in ${existingTokenLastEight}`
: "Paste token"
}
disabled={isBusy}
required={isTokenRequired}
/>
<p className="text-xs text-slate-500">
Forgejo personal access token with repo permissions. Token is stored securely and never displayed.
</p>
<p className="text-xs text-muted">{tokenHelpText}</p>
</div>
</div>
<div className="flex justify-end gap-4 pt-4 border-t">
<Button type="button" variant="outline" onClick={() => window.history.back()} disabled={isSubmitting}>
<div className="flex flex-col-reverse gap-3 border-t border-[color:var(--border)] pt-4 sm:flex-row sm:justify-end">
<Button
type="button"
variant="outline"
onClick={() => window.history.back()}
disabled={isBusy}
>
Cancel
</Button>
<Button type="submit" disabled={isSubmitting}>
{isSubmitting ? (
<Button type="submit" disabled={isBusy}>
{isBusy ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Saving...
Saving
</>
) : (
submitLabel

View File

@ -11,20 +11,24 @@ import {
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Loader2, CheckCircle2 } from "lucide-react";
import { CheckCircle2, GitBranch, Loader2 } from "lucide-react";
import { DataTable } from "@/components/tables/DataTable";
import DropdownSelect from "@/components/ui/dropdown-select";
import { Input } from "@/components/ui/input";
import { cn } from "@/lib/utils";
import type { ForgejoConnection } from "@/lib/api-forgejo";
import type {
ForgejoConnection,
ForgejoConnectionValidationResponse,
} from "@/lib/api-forgejo";
interface ConnectionsTableProps {
connections: ForgejoConnection[];
isLoading: boolean;
onEdit?: (connection: ForgejoConnection) => void;
onDelete?: (connection: ForgejoConnection) => void;
onValidate?: (connection: ForgejoConnection) => void;
onValidate?: (
connection: ForgejoConnection,
) => Promise<ForgejoConnectionValidationResponse>;
}
export function ForgejoConnectionsTable({
@ -47,23 +51,10 @@ export function ForgejoConnectionsTable({
table={table}
isLoading={isLoading}
emptyState={{
icon: (
<svg
className="h-16 w-16 text-slate-300"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M9 11l3 3L22 4" />
<path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11" />
</svg>
),
title: "No Forgejo connections yet",
icon: <GitBranch className="h-12 w-12" />,
title: "No Git Project connections yet",
description:
"Connect a Forgejo instance to start tracking issues and pull requests from your Git projects.",
"Connect a Git provider so Pipeline can track issues for Git Projects.",
actionHref: "/git-projects/connections/new",
actionLabel: "Add connection",
}}
@ -71,12 +62,15 @@ export function ForgejoConnectionsTable({
getEditHref: (row) => `/git-projects/connections/${row.id}/edit`,
onDelete: onDelete ?? undefined,
}}
tableClassName="min-w-[680px] w-full text-left text-sm"
/>
);
}
const columns = (
onValidate?: (connection: ForgejoConnection) => void
onValidate?: (
connection: ForgejoConnection,
) => Promise<ForgejoConnectionValidationResponse>,
): ColumnDef<ForgejoConnection>[] => [
{
accessorKey: "name",
@ -85,7 +79,7 @@ const columns = (
<Button
variant="ghost"
onClick={() => column.toggleSorting(false)}
className="px-0 hover:bg-transparent hover:text-slate-900"
className="h-auto px-0 py-0 hover:bg-transparent hover:text-[color:var(--accent)]"
>
Name
{column.getIsSorted() === "asc" && "↑"}
@ -94,9 +88,13 @@ const columns = (
);
},
cell: ({ row }) => (
<div className="flex flex-col">
<span className="font-medium text-slate-900">{row.original.name}</span>
<span className="text-xs text-slate-500">{row.original.base_url}</span>
<div className="min-w-0">
<span className="block truncate font-medium text-strong">
{row.original.name}
</span>
<span className="block truncate text-xs text-muted">
{row.original.base_url}
</span>
</div>
),
},
@ -124,7 +122,9 @@ const columns = (
{hasToken ? "Configured" : "Missing"}
</Badge>
{tokenLastEight && hasToken && (
<span className="text-xs text-slate-500 font-mono">{tokenLastEight}</span>
<span className="font-mono text-xs text-muted">
{tokenLastEight}
</span>
)}
</div>
);
@ -132,7 +132,9 @@ const columns = (
},
{
id: "actions",
cell: ({ row }) => <ActionsCell connection={row.original} onValidate={onValidate} />,
cell: ({ row }) => (
<ActionsCell connection={row.original} onValidate={onValidate} />
),
},
];
@ -141,10 +143,11 @@ function ActionsCell({
onValidate,
}: {
connection: ForgejoConnection;
onValidate?: (connection: ForgejoConnection) => void;
onValidate?: (
connection: ForgejoConnection,
) => Promise<ForgejoConnectionValidationResponse>;
}) {
const [isValidateLoading, setIsValidateLoading] = useState(false);
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const [validateResult, setValidateResult] = useState<{
ok: boolean;
error_message?: string;
@ -155,41 +158,17 @@ function ActionsCell({
if (!onValidate) return;
setIsValidateLoading(true);
try {
await onValidate(connection);
const result = await onValidate(connection);
setValidateResult({
ok: result.status.ok,
error_message: result.status.error_message ?? undefined,
response_time_ms: result.response_time_ms,
});
} finally {
setIsValidateLoading(false);
}
};
const options = [
{ value: "edit", label: "Edit" },
{ value: "delete", label: "Delete", icon: (props: { className?: string }) => (
<svg
className={cn("h-4 w-4", props.className)}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M3 6h18" />
<path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6" />
<path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2" />
</svg>
)},
];
const handleSelect = (value: string) => {
if (value === "edit") {
// Navigate to edit page
} else if (value === "delete") {
if (confirm(`Are you sure you want to delete "${connection.name}"?`)) {
// Trigger delete via parent
}
}
};
return (
<div className="flex items-center gap-2">
{onValidate && (
@ -203,18 +182,12 @@ function ActionsCell({
{isValidateLoading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : validateResult?.ok ? (
<CheckCircle2 className="h-4 w-4 text-green-600" />
<CheckCircle2 className="h-4 w-4 text-[color:var(--success)]" />
) : (
<CheckCircle2 className="h-4 w-4" />
)}
</Button>
)}
<DropdownSelect
ariaLabel="Connection actions"
options={options}
onValueChange={handleSelect}
triggerClassName="h-8 w-8 p-0"
/>
</div>
);
}
@ -246,7 +219,7 @@ export function ConnectionsTableToggle({
return (
<div className="flex items-center gap-2">
<div className="flex items-center gap-2">
<span className="text-xs text-slate-500">Show:</span>
<span className="text-xs text-muted">Show:</span>
<Button
variant="ghost"
size="sm"
@ -277,7 +250,9 @@ export function ConnectionsTableToggle({
onClick={() => column.toggleVisibility(!column.getIsVisible())}
className={cn(
"h-8 px-2 py-1 text-xs",
column.getIsVisible() ? "bg-slate-100 text-slate-900" : "text-slate-500",
column.getIsVisible()
? "bg-[color:var(--surface-strong)] text-strong"
: "text-muted",
)}
>
{column.id}

View File

@ -1,8 +1,12 @@
"use client";
import { useState, useEffect } from "react";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Input } from "@/components/ui/input";
import type { ForgejoRepository } from "@/lib/api-forgejo";
@ -26,9 +30,9 @@ export function ForgejoIssueFilters({
repos,
}: ForgejoIssueFiltersProps) {
return (
<div className="mb-4 flex flex-wrap gap-3">
<Select value={stateFilter} onValueChange={(v) => { onStateChange(v); }}>
<SelectTrigger className="w-[120px]">
<div className="mb-4 grid gap-3 rounded-xl border border-[color:var(--border)] bg-[color:var(--surface)] p-3 shadow-lush sm:grid-cols-[140px_minmax(200px,280px)_minmax(220px,1fr)]">
<Select value={stateFilter} onValueChange={onStateChange}>
<SelectTrigger>
<SelectValue placeholder="State" />
</SelectTrigger>
<SelectContent>
@ -38,8 +42,8 @@ export function ForgejoIssueFilters({
</SelectContent>
</Select>
<Select value={repoFilter} onValueChange={(v) => { onRepoChange(v); }}>
<SelectTrigger className="w-[200px]">
<Select value={repoFilter} onValueChange={onRepoChange}>
<SelectTrigger>
<SelectValue placeholder="Repository" />
</SelectTrigger>
<SelectContent>
@ -53,10 +57,10 @@ export function ForgejoIssueFilters({
</Select>
<Input
placeholder="Search issues…"
placeholder="Search Git Project issues…"
value={search}
onChange={(e) => onSearchChange(e.target.value)}
className="w-[240px]"
className="min-w-0"
/>
</div>
);

View File

@ -1,9 +1,13 @@
"use client";
import { useMemo, useState } from "react";
import { useCallback, useMemo, useState } from "react";
import { type ColumnDef, getCoreRowModel, useReactTable } from "@tanstack/react-table";
import { XCircle } from "lucide-react";
import {
type ColumnDef,
getCoreRowModel,
useReactTable,
} from "@tanstack/react-table";
import { CircleDot, ExternalLink, XCircle } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
@ -13,17 +17,22 @@ import type { ForgejoIssue } from "@/lib/api-forgejo";
export type ForgejoIssuesTableProps = {
issues: ForgejoIssue[];
isLoading?: boolean;
onRefresh: () => void;
};
export function ForgejoIssuesTable({ issues, onRefresh }: ForgejoIssuesTableProps) {
export function ForgejoIssuesTable({
issues,
isLoading = false,
onRefresh,
}: ForgejoIssuesTableProps) {
const [closeIssueDialogOpen, setCloseIssueDialogOpen] = useState(false);
const [issueToClose, setIssueToClose] = useState<ForgejoIssue | null>(null);
const handleCloseClick = (issue: ForgejoIssue) => {
const handleCloseClick = useCallback((issue: ForgejoIssue) => {
setIssueToClose(issue);
setCloseIssueDialogOpen(true);
};
}, []);
const handleCloseSuccess = () => {
onRefresh();
@ -39,9 +48,10 @@ export function ForgejoIssuesTable({ issues, onRefresh }: ForgejoIssuesTableProp
href={row.original.html_url}
target="_blank"
rel="noopener noreferrer"
className="font-mono text-sm text-blue-600 hover:underline dark:text-blue-400"
className="inline-flex items-center gap-1 font-mono text-sm text-[color:var(--accent)] hover:underline"
>
#{row.original.forgejo_issue_number}
<ExternalLink className="h-3 w-3" />
</a>
),
},
@ -49,7 +59,12 @@ export function ForgejoIssuesTable({ issues, onRefresh }: ForgejoIssuesTableProp
accessorKey: "title",
header: "Title",
cell: ({ row }) => (
<div className="max-w-md truncate">{row.original.title}</div>
<div
className="max-w-[28rem] truncate font-medium text-strong"
title={row.original.title}
>
{row.original.title}
</div>
),
},
{
@ -59,7 +74,11 @@ export function ForgejoIssuesTable({ issues, onRefresh }: ForgejoIssuesTableProp
const body = row.original.body_preview;
if (!body) return null;
const truncated = body.length > 120 ? body.slice(0, 120) + "…" : body;
return <div className="max-w-xs truncate text-sm text-slate-500">{truncated}</div>;
return (
<div className="max-w-xs truncate text-sm text-muted" title={body}>
{truncated}
</div>
);
},
},
{
@ -68,10 +87,7 @@ export function ForgejoIssuesTable({ issues, onRefresh }: ForgejoIssuesTableProp
cell: ({ row }) => {
const state = row.original.state;
return (
<Badge
variant={state === "open" ? "success" : "default"}
className={state === "open" ? "" : ""}
>
<Badge variant={state === "open" ? "success" : "default"}>
{state}
</Badge>
);
@ -80,6 +96,11 @@ export function ForgejoIssuesTable({ issues, onRefresh }: ForgejoIssuesTableProp
{
accessorKey: "author",
header: "Author",
cell: ({ row }) => (
<span className="block max-w-[160px] truncate text-muted">
{row.original.author || "Unknown"}
</span>
),
},
{
accessorKey: "labels",
@ -88,23 +109,25 @@ export function ForgejoIssuesTable({ issues, onRefresh }: ForgejoIssuesTableProp
const labels = row.original.labels;
if (!labels || labels.length === 0) return null;
return (
<div className="flex flex-wrap gap-1">
{labels.slice(0, 3).map((label: Record<string, unknown>, i: number) => (
<Badge
key={i}
variant="outline"
className="text-xs"
style={
label.color
? { backgroundColor: `#${label.color}`, color: "#fff" }
: undefined
}
>
{String(label.name || "")}
</Badge>
))}
<div className="flex max-w-[220px] flex-wrap gap-1">
{labels
.slice(0, 3)
.map((label: Record<string, unknown>, i: number) => (
<Badge
key={i}
variant="outline"
className="text-xs"
style={
label.color
? { backgroundColor: `#${label.color}`, color: "#fff" }
: undefined
}
>
{String(label.name || "")}
</Badge>
))}
{labels.length > 3 && (
<span className="text-xs text-slate-500">+{labels.length - 3}</span>
<span className="text-xs text-muted">+{labels.length - 3}</span>
)}
</div>
);
@ -115,9 +138,17 @@ export function ForgejoIssuesTable({ issues, onRefresh }: ForgejoIssuesTableProp
header: "Updated",
cell: ({ row }) => {
try {
return new Date(row.original.forgejo_updated_at).toLocaleDateString();
return (
<span className="whitespace-nowrap text-muted">
{new Date(row.original.forgejo_updated_at).toLocaleDateString()}
</span>
);
} catch {
return row.original.forgejo_updated_at;
return (
<span className="text-muted">
{row.original.forgejo_updated_at}
</span>
);
}
},
},
@ -131,47 +162,40 @@ export function ForgejoIssuesTable({ issues, onRefresh }: ForgejoIssuesTableProp
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0 text-[color:var(--danger)] hover:bg-[color:rgba(248,113,113,0.08)]"
onClick={() => handleCloseClick(issue)}
title="Close issue"
>
<XCircle className="h-4 w-4 text-rose-500" />
<XCircle className="h-4 w-4" />
</Button>
);
},
},
],
[],
[handleCloseClick],
);
const table = useReactTable({
data: issues,
columns,
getCoreRowModel: getCoreRowModel(),
});
return (
<>
<DataTable
table={useReactTable({
data: issues,
columns,
getCoreRowModel: getCoreRowModel(),
})}
isLoading={false}
emptyState={{
icon: (
<svg
className="h-16 w-16 text-slate-300"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={1.5}
strokeLinecap="round"
strokeLinejoin="round"
>
<circle cx="12" cy="12" r="10" />
<path d="M12 8v4" />
<path d="M12 16h.01" />
</svg>
),
title: "No issues found",
description: "Sync a repository to pull in issues, or adjust your filters.",
}}
/>
<div className="overflow-hidden rounded-xl border border-[color:var(--border)] bg-[color:var(--surface)] shadow-lush">
<DataTable
table={table}
isLoading={isLoading}
loadingLabel="Loading Git Project issues…"
tableClassName="min-w-[960px] w-full text-left text-sm"
emptyState={{
icon: <CircleDot className="h-12 w-12" />,
title: "No Git Project issues found",
description:
"Sync a repository to pull issues into Pipeline, or adjust your filters.",
}}
/>
</div>
<CloseForgejoIssueDialog
issue={issueToClose}
open={closeIssueDialogOpen}

View File

@ -11,20 +11,32 @@ import {
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Loader2, CheckCircle2 } from "lucide-react";
import { CheckCircle2, GitBranch, Loader2, RefreshCw } from "lucide-react";
import { DataTable } from "@/components/tables/DataTable";
import DropdownSelect from "@/components/ui/dropdown-select";
import { cn } from "@/lib/utils";
import type { ForgejoRepository } from "@/lib/api-forgejo";
import type {
ForgejoRepository,
ForgejoRepositoryValidationResponse,
} from "@/lib/api-forgejo";
type RepositorySyncResult = {
created: number;
updated: number;
open: number;
closed: number;
total: number;
};
interface RepositoriesTableProps {
repositories: ForgejoRepository[];
isLoading: boolean;
onEdit?: (repository: ForgejoRepository) => void;
onDelete?: (repository: ForgejoRepository) => void;
onSync?: (repository: ForgejoRepository) => void;
onValidate?: (repository: ForgejoRepository) => void;
onSync?: (repository: ForgejoRepository) => Promise<RepositorySyncResult>;
onValidate?: (
repository: ForgejoRepository,
) => Promise<ForgejoRepositoryValidationResponse>;
}
export function ForgejoRepositoriesTable({
@ -48,23 +60,10 @@ export function ForgejoRepositoriesTable({
table={table}
isLoading={isLoading}
emptyState={{
icon: (
<svg
className="h-16 w-16 text-slate-300"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M9 11l3 3L22 4" />
<path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11" />
</svg>
),
title: "No repositories tracked yet",
icon: <GitBranch className="h-12 w-12" />,
title: "No Git Project repositories yet",
description:
"Add repositories to start tracking issues and pull requests from your Git projects.",
"Add repositories so Pipeline can sync issues into Git Projects.",
actionHref: "/git-projects/repositories/new",
actionLabel: "Add repository",
}}
@ -72,13 +71,16 @@ export function ForgejoRepositoriesTable({
getEditHref: (row) => `/git-projects/repositories/${row.id}/edit`,
onDelete: onDelete ?? undefined,
}}
tableClassName="min-w-[860px] w-full text-left text-sm"
/>
);
}
const columns = (
onSync?: (repository: ForgejoRepository) => void,
onValidate?: (repository: ForgejoRepository) => void
onSync?: (repository: ForgejoRepository) => Promise<RepositorySyncResult>,
onValidate?: (
repository: ForgejoRepository,
) => Promise<ForgejoRepositoryValidationResponse>,
): ColumnDef<ForgejoRepository>[] => [
{
accessorKey: "displayName",
@ -87,7 +89,7 @@ const columns = (
<Button
variant="ghost"
onClick={() => column.toggleSorting(false)}
className="px-0 hover:bg-transparent hover:text-slate-900"
className="h-auto px-0 py-0 hover:bg-transparent hover:text-[color:var(--accent)]"
>
Repository
{column.getIsSorted() === "asc" && "↑"}
@ -98,9 +100,11 @@ const columns = (
cell: ({ row }) => {
const repo = row.original;
return (
<div className="flex flex-col">
<span className="font-medium text-slate-900">{repo.display_name || `${repo.owner}/${repo.repo}`}</span>
<span className="text-xs text-slate-500">
<div className="min-w-0">
<span className="block truncate font-medium text-strong">
{repo.display_name || `${repo.owner}/${repo.repo}`}
</span>
<span className="block truncate font-mono text-xs text-muted">
{repo.owner}/{repo.repo} {repo.connection?.name}
</span>
</div>
@ -113,9 +117,13 @@ const columns = (
cell: ({ row }) => {
const connection = row.original.connection;
return (
<div className="flex flex-col">
<span className="text-sm text-slate-700">{connection?.name}</span>
<span className="text-xs text-slate-500">{connection?.base_url}</span>
<div className="min-w-0">
<span className="block truncate text-sm text-strong">
{connection?.name}
</span>
<span className="block truncate text-xs text-muted">
{connection?.base_url}
</span>
</div>
);
},
@ -140,20 +148,23 @@ const columns = (
const lastSyncError = row.original.last_sync_error;
if (!lastSyncAt) {
return <span className="text-sm text-slate-400">Never</span>;
return <span className="text-sm text-muted">Never</span>;
}
const date = new Date(lastSyncAt);
const isRecent = new Date().getTime() - date.getTime() < 24 * 60 * 60 * 1000; // Within 24 hours
return (
<div className="flex flex-col">
<span className={`text-sm ${isRecent ? "text-green-600" : "text-slate-700"}`}>
<span className="text-sm text-strong">
{date.toLocaleDateString()}
</span>
<span className="text-xs text-slate-500">{date.toLocaleTimeString()}</span>
<span className="text-xs text-muted">
{date.toLocaleTimeString()}
</span>
{lastSyncError && (
<span className="text-xs text-red-500">Error: {lastSyncError.substring(0, 50)}...</span>
<span className="max-w-[220px] truncate text-xs text-[color:var(--danger)]">
Error: {lastSyncError.substring(0, 50)}...
</span>
)}
</div>
);
@ -161,7 +172,13 @@ const columns = (
},
{
id: "actions",
cell: ({ row }) => <ActionsCell repository={row.original} onSync={onSync} onValidate={onValidate} />,
cell: ({ row }) => (
<ActionsCell
repository={row.original}
onSync={onSync}
onValidate={onValidate}
/>
),
},
];
@ -171,12 +188,13 @@ function ActionsCell({
onValidate,
}: {
repository: ForgejoRepository;
onSync?: (repository: ForgejoRepository) => void;
onValidate?: (repository: ForgejoRepository) => void;
onSync?: (repository: ForgejoRepository) => Promise<RepositorySyncResult>;
onValidate?: (
repository: ForgejoRepository,
) => Promise<ForgejoRepositoryValidationResponse>;
}) {
const [isSyncLoading, setIsSyncLoading] = useState(false);
const [isValidateLoading, setIsValidateLoading] = useState(false);
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const [syncResult, setSyncResult] = useState<{
created: number;
updated: number;
@ -184,7 +202,6 @@ function ActionsCell({
closed: number;
total: number;
} | null>(null);
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const [validateResult, setValidateResult] = useState<{
ok: boolean;
repo_exists?: boolean;
@ -195,7 +212,8 @@ function ActionsCell({
if (!onSync) return;
setIsSyncLoading(true);
try {
await onSync(repository);
const result = await onSync(repository);
setSyncResult(result);
} finally {
setIsSyncLoading(false);
}
@ -205,41 +223,17 @@ function ActionsCell({
if (!onValidate) return;
setIsValidateLoading(true);
try {
await onValidate(repository);
const result = await onValidate(repository);
setValidateResult({
ok: result.status.ok,
repo_exists: result.repo_exists ?? undefined,
error_message: result.status.error_message ?? undefined,
});
} finally {
setIsValidateLoading(false);
}
};
const options = [
{ value: "edit", label: "Edit" },
{ value: "delete", label: "Delete", icon: (props: { className?: string }) => (
<svg
className={cn("h-4 w-4", props.className)}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M3 6h18" />
<path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6" />
<path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2" />
</svg>
)},
];
const handleSelect = (value: string) => {
if (value === "edit") {
// Navigate to edit page
} else if (value === "delete") {
if (confirm(`Are you sure you want to delete "${repository.display_name || repository.repo}"?`)) {
// Trigger delete via parent
}
}
};
return (
<div className="flex items-center gap-2">
{onSync && (
@ -253,35 +247,9 @@ function ActionsCell({
{isSyncLoading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : syncResult ? (
<svg
className="h-4 w-4 text-green-600"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M21 12a9 9 0 0 0-9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" />
<path d="M3 3v5h5" />
<path d="M3 12a9 9 0 0 0 9 9 9.75 9.75 0 0 0 6.74-2.74L21 16" />
<path d="M16 21h5v-5" />
</svg>
<CheckCircle2 className="h-4 w-4 text-[color:var(--success)]" />
) : (
<svg
className="h-4 w-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M21 12a9 9 0 0 0-9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" />
<path d="M3 3v5h5" />
<path d="M3 12a9 9 0 0 0 9 9 9.75 9.75 0 0 0 6.74-2.74L21 16" />
<path d="M16 21h5v-5" />
</svg>
<RefreshCw className="h-4 w-4" />
)}
</Button>
)}
@ -296,18 +264,12 @@ function ActionsCell({
{isValidateLoading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : validateResult?.ok ? (
<CheckCircle2 className="h-4 w-4 text-green-600" />
<CheckCircle2 className="h-4 w-4 text-[color:var(--success)]" />
) : (
<CheckCircle2 className="h-4 w-4" />
)}
</Button>
)}
<DropdownSelect
ariaLabel="Repository actions"
options={options}
onValueChange={handleSelect}
triggerClassName="h-8 w-8 p-0"
/>
</div>
);
}
@ -323,10 +285,10 @@ export function RepositoriesTableFilter({
return (
<input
type="text"
placeholder="Filter repositories..."
placeholder="Filter repositories"
value={value ?? ""}
onChange={(e) => onChange(e.target.value)}
className="h-8 w-[150px] rounded-md border border-slate-200 px-3 py-1 text-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 lg:w-[250px]"
className="h-9 w-[150px] rounded-xl border border-[color:var(--border)] bg-[color:var(--surface)] px-3 py-1 text-sm text-strong focus:border-[color:var(--accent)] focus:outline-none focus:ring-2 focus:ring-[color:var(--accent)] lg:w-[250px]"
/>
);
}
@ -340,7 +302,7 @@ export function RepositoriesTableToggle({
return (
<div className="flex items-center gap-2">
<div className="flex items-center gap-2">
<span className="text-xs text-slate-500">Show:</span>
<span className="text-xs text-muted">Show:</span>
<Button
variant="ghost"
size="sm"
@ -371,7 +333,9 @@ export function RepositoriesTableToggle({
onClick={() => column.toggleVisibility(!column.getIsVisible())}
className={cn(
"h-8 px-2 py-1 text-xs",
column.getIsVisible() ? "bg-slate-100 text-slate-900" : "text-slate-500",
column.getIsVisible()
? "bg-[color:var(--surface-strong)] text-strong"
: "text-muted",
)}
>
{column.id}

View File

@ -5,8 +5,19 @@ import { useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Loader2 } from "lucide-react";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import type { ForgejoRepositoryCreate } from "@/lib/api-forgejo";
import {
getForgejoConnections,
type ForgejoConnection,
type ForgejoRepositoryCreate,
} from "@/lib/api-forgejo";
interface ForgejoRepositoryFormProps {
defaultValues?: Partial<ForgejoRepositoryFormValues>;
@ -34,27 +45,32 @@ export function ForgejoRepositoryForm({
submitLabel = "Save Repository",
}: ForgejoRepositoryFormProps) {
const [error, setError] = useState<string | null>(null);
const [connectionId, setConnectionId] = useState(defaultValues.connection_id || "");
const [isSaving, setIsSaving] = useState(false);
const [connectionId, setConnectionId] = useState(
defaultValues.connection_id || "",
);
const [owner, setOwner] = useState(defaultValues.owner || "");
const [repo, setRepo] = useState(defaultValues.repo || "");
const [displayName, setDisplayName] = useState(defaultValues.display_name || "");
const [defaultBranch, setDefaultBranch] = useState(defaultValues.default_branch || "main");
const [displayName, setDisplayName] = useState(
defaultValues.display_name || "",
);
const [defaultBranch, setDefaultBranch] = useState(
defaultValues.default_branch || "main",
);
// Get connections for dropdown
const [connections, setConnections] = useState<{id: string; name: string; base_url: string; active: boolean}[]>([]);
const [connections, setConnections] = useState<ForgejoConnection[]>([]);
const [isLoadingConnections, setIsLoadingConnections] = useState(false);
const isBusy = isSubmitting || isSaving;
useEffect(() => {
const fetchConnections = async () => {
try {
setIsLoadingConnections(true);
const response = await fetch("/api/v1/forgejo/connections");
if (response.ok) {
const data = await response.json();
setConnections(data.filter((c: { active: boolean }) => c.active));
}
} catch (_: unknown) {
const data = await getForgejoConnections();
setConnections(data.filter((connection) => connection.active));
} catch {
setError("Failed to load connections");
} finally {
setIsLoadingConnections(false);
@ -66,8 +82,15 @@ export function ForgejoRepositoryForm({
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setError(null);
if (!connectionId) {
setError(
"Choose a Git Projects connection before saving this repository.",
);
return;
}
try {
setIsSaving(true);
await onSubmit({
connection_id: connectionId,
owner,
@ -77,17 +100,26 @@ export function ForgejoRepositoryForm({
});
} catch (err) {
setError(err instanceof Error ? err.message : "An error occurred");
} finally {
setIsSaving(false);
}
}
return (
<form onSubmit={handleSubmit} className="space-y-8">
<form
onSubmit={handleSubmit}
className="surface-panel w-full max-w-2xl space-y-6 rounded-2xl p-4 sm:p-6"
>
<div className="space-y-4">
<h3 className="text-lg font-semibold">{title}</h3>
{description && <p className="text-sm text-slate-500">{description}</p>}
<div>
<h3 className="text-lg font-semibold text-strong">{title}</h3>
{description && (
<p className="mt-1 text-sm text-muted">{description}</p>
)}
</div>
{error && (
<div className="rounded-lg bg-red-50 p-4 text-red-700">
<div className="rounded-xl border border-[color:rgba(248,113,113,0.35)] bg-[color:rgba(248,113,113,0.08)] p-4 text-[color:var(--danger)]">
<p className="font-medium">Configuration Error</p>
<p className="text-sm">{error}</p>
</div>
@ -97,27 +129,37 @@ export function ForgejoRepositoryForm({
<label htmlFor="connection_id" className="text-sm font-medium">
Connection
</label>
<select
id="connection_id"
<Select
value={connectionId}
onChange={(e) => setConnectionId(e.target.value)}
disabled={isSubmitting || isLoadingConnections}
className="flex h-10 w-full rounded-md border border-slate-200 bg-white px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent disabled:cursor-not-allowed disabled:opacity-50"
required
onValueChange={setConnectionId}
disabled={
isBusy || isLoadingConnections || connections.length === 0
}
>
<option value="">Select a connection</option>
{connections.map((conn) => (
<option key={conn.id} value={conn.id}>
{conn.name} - {conn.base_url}
</option>
))}
</select>
<p className="text-xs text-slate-500">
The Forgejo connection to use for this repository.
<SelectTrigger id="connection_id">
<SelectValue
placeholder={
isLoadingConnections
? "Loading connections…"
: "Select a connection"
}
/>
</SelectTrigger>
<SelectContent>
{connections.map((conn) => (
<SelectItem key={conn.id} value={conn.id}>
{conn.name} - {conn.base_url}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-muted">
The Git Projects connection to use for this repository. Add a
connection first if none are available.
</p>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<label htmlFor="owner" className="text-sm font-medium">
Owner
@ -126,11 +168,11 @@ export function ForgejoRepositoryForm({
id="owner"
value={owner}
onChange={(e) => setOwner(e.target.value)}
placeholder="e.g., null"
disabled={isSubmitting}
placeholder="openclaw"
disabled={isBusy}
required
/>
<p className="text-xs text-slate-500">
<p className="text-xs text-muted">
The owner of the repository (username or organization).
</p>
</div>
@ -143,13 +185,11 @@ export function ForgejoRepositoryForm({
id="repo"
value={repo}
onChange={(e) => setRepo(e.target.value)}
placeholder="e.g., Pipeline"
disabled={isSubmitting}
placeholder="pipeline"
disabled={isBusy}
required
/>
<p className="text-xs text-slate-500">
The name of the repository.
</p>
<p className="text-xs text-muted">The name of the repository.</p>
</div>
</div>
@ -161,11 +201,12 @@ export function ForgejoRepositoryForm({
id="display_name"
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
placeholder="e.g., Pipeline"
disabled={isSubmitting}
placeholder="Pipeline"
disabled={isBusy}
/>
<p className="text-xs text-slate-500">
A friendly name for this repository. If not provided, the owner/repo will be used.
<p className="text-xs text-muted">
A friendly name for this repository. If not provided, the owner/repo
will be used.
</p>
</div>
@ -178,24 +219,29 @@ export function ForgejoRepositoryForm({
value={defaultBranch}
onChange={(e) => setDefaultBranch(e.target.value)}
placeholder="main"
disabled={isSubmitting}
disabled={isBusy}
required
/>
<p className="text-xs text-slate-500">
<p className="text-xs text-muted">
The default branch for this repository (e.g., main, dev, master).
</p>
</div>
</div>
<div className="flex justify-end gap-4 pt-4 border-t">
<Button type="button" variant="outline" onClick={() => window.history.back()} disabled={isSubmitting}>
<div className="flex flex-col-reverse gap-3 border-t border-[color:var(--border)] pt-4 sm:flex-row sm:justify-end">
<Button
type="button"
variant="outline"
onClick={() => window.history.back()}
disabled={isBusy}
>
Cancel
</Button>
<Button type="submit" disabled={isSubmitting}>
{isSubmitting ? (
<Button type="submit" disabled={isBusy}>
{isBusy ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Saving...
Saving
</>
) : (
submitLabel

View File

@ -60,9 +60,9 @@ export function DataTable<TData>({
tableClassName = "w-full text-left text-sm",
headerClassName,
headerCellClassName = "px-3 py-2 md:px-6 md:py-3",
bodyClassName = "divide-y divide-slate-100",
rowClassName = "hover:bg-slate-50",
cellClassName = "px-3 py-3 md:px-6 md:py-4",
bodyClassName = "divide-y divide-[color:var(--border)]",
rowClassName = "transition-colors hover:bg-[color:var(--surface-muted)]",
cellClassName = "px-3 py-3 align-middle md:px-6",
}: DataTableProps<TData>) {
const resolvedRowActions = rowActions
? (rowActions.actions ??
@ -93,7 +93,7 @@ export function DataTable<TData>({
<thead
className={
headerClassName ??
`${stickyHeader ? "sticky top-0 z-10 " : ""}bg-slate-50 text-xs font-semibold uppercase tracking-wider text-slate-500`
`${stickyHeader ? "sticky top-0 z-10 " : ""}bg-[color:var(--surface-muted)] text-xs font-semibold uppercase tracking-wider text-[color:var(--text-muted)]`
}
>
{table.getHeaderGroups().map((headerGroup) => (
@ -117,7 +117,9 @@ export function DataTable<TData>({
) : header.column.getIsSorted() === "desc" ? (
"↓"
) : (
<span className="text-slate-300"></span>
<span className="text-[color:var(--text-quiet)]">
</span>
)}
</button>
) : (
@ -204,10 +206,7 @@ export function DataTable<TData>({
/>
) : (
<tr>
<td
colSpan={colSpan}
className="px-6 py-8 text-sm text-slate-500"
>
<td colSpan={colSpan} className="px-6 py-8 text-sm text-muted">
{emptyMessage}
</td>
</tr>

View File

@ -40,14 +40,14 @@ export function linkifyCell({
<Link href={href} title={title} className={cn("group block", className)}>
<p
className={cn(
"text-sm font-medium text-slate-900 group-hover:text-blue-600",
"text-sm font-medium text-strong group-hover:text-[color:var(--accent)]",
labelClassName,
)}
>
{label}
</p>
{subtitle != null ? (
<p className={cn("text-xs text-slate-500", subtitleClassName)}>
<p className={cn("text-xs text-muted", subtitleClassName)}>
{subtitle}
</p>
) : null}
@ -60,7 +60,7 @@ export function linkifyCell({
href={href}
title={title}
className={cn(
"text-sm font-medium text-slate-700 hover:text-blue-600",
"text-sm font-medium text-strong hover:text-[color:var(--accent)]",
className,
)}
>
@ -82,7 +82,7 @@ export function dateCell(
) {
const display = relative ? formatRelative(value) : formatTimestamp(value);
return (
<span className={cn("text-sm text-slate-700", className)}>
<span className={cn("text-sm text-muted", className)}>
{display ?? fallback}
</span>
);

View File

@ -9,6 +9,7 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { cn } from "@/lib/utils";
type ConfirmActionDialogProps = {
open: boolean;
@ -20,6 +21,8 @@ type ConfirmActionDialogProps = {
errorMessage?: string | null;
confirmLabel?: string;
confirmingLabel?: string;
confirmVariant?: NonNullable<ButtonProps["variant"]>;
confirmClassName?: string;
cancelLabel?: string;
cancelVariant?: NonNullable<ButtonProps["variant"]>;
errorStyle?: "text" | "panel";
@ -36,6 +39,8 @@ export function ConfirmActionDialog({
errorMessage,
confirmLabel = "Delete",
confirmingLabel = "Deleting…",
confirmVariant = "primary",
confirmClassName,
cancelLabel = "Cancel",
cancelVariant = "outline",
errorStyle = "panel",
@ -50,7 +55,7 @@ export function ConfirmActionDialog({
</DialogHeader>
{errorMessage ? (
errorStyle === "text" ? (
<p className="text-sm text-red-500">{errorMessage}</p>
<p className="text-sm text-[color:var(--danger)]">{errorMessage}</p>
) : (
<div className="rounded-lg border border-[color:var(--border)] bg-[color:var(--surface-muted)] p-3 text-xs text-muted">
{errorMessage}
@ -58,10 +63,19 @@ export function ConfirmActionDialog({
)
) : null}
<DialogFooter>
<Button variant={cancelVariant} onClick={() => onOpenChange(false)}>
<Button
variant={cancelVariant}
onClick={() => onOpenChange(false)}
disabled={isConfirming}
>
{cancelLabel}
</Button>
<Button onClick={onConfirm} disabled={isConfirming}>
<Button
variant={confirmVariant}
className={cn(confirmClassName)}
onClick={onConfirm}
disabled={isConfirming}
>
{isConfirming ? confirmingLabel : confirmLabel}
</Button>
</DialogFooter>

View File

@ -1,5 +1,6 @@
import type { ReactNode } from "react";
import Link from "next/link";
import { Loader2 } from "lucide-react";
import { buttonVariants } from "@/components/ui/button";
@ -15,7 +16,10 @@ export function TableLoadingRow({
return (
<tr>
<td colSpan={colSpan} className="px-6 py-8">
<span className="text-sm text-slate-500">{label}</span>
<div className="flex items-center gap-2 text-sm text-muted">
<Loader2 className="h-4 w-4 animate-spin text-[color:var(--accent)]" />
<span>{label}</span>
</div>
</td>
</tr>
);
@ -42,9 +46,11 @@ export function TableEmptyStateRow({
<tr>
<td colSpan={colSpan} className="px-6 py-16">
<div className="flex flex-col items-center justify-center text-center">
<div className="mb-4 rounded-full bg-slate-50 p-4">{icon}</div>
<h3 className="mb-2 text-lg font-semibold text-slate-900">{title}</h3>
<p className="mb-6 max-w-md text-sm text-slate-500">{description}</p>
<div className="mb-4 rounded-2xl border border-[color:var(--border)] bg-[color:var(--surface-muted)] p-4 text-[color:var(--text-muted)]">
{icon}
</div>
<h3 className="mb-2 text-lg font-semibold text-strong">{title}</h3>
<p className="mb-6 max-w-md text-sm text-muted">{description}</p>
{actionHref && actionLabel ? (
<Link
href={actionHref}

View File

@ -1,4 +1,4 @@
import { getApiBaseUrl } from "./api-base";
import { customFetch } from "@/api/mutator";
// Forgejo Connection types
export interface ForgejoConnection {
@ -56,48 +56,36 @@ export interface ForgejoRepositoryUpdate {
default_branch?: string;
}
// API client
const API_BASE_URL = getApiBaseUrl();
type ApiResponse<T> = {
data: T;
status: number;
headers: Headers;
};
async function fetchJson<T>(url: string, init?: RequestInit): Promise<T> {
const response = await fetch(url, {
...init,
headers: {
"Content-Type": "application/json",
...(init?.headers || {}),
},
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.detail || `API error: ${response.statusText}`);
}
return response.json();
async function fetchJson<T>(path: string, init?: RequestInit): Promise<T> {
const response = await customFetch<ApiResponse<T>>(path, init ?? {});
return response.data;
}
// Forgejo Connection API
export async function getForgejoConnections(): Promise<ForgejoConnection[]> {
return fetchJson<ForgejoConnection[]>(`${API_BASE_URL}/api/v1/forgejo/connections`);
return fetchJson<ForgejoConnection[]>("/api/v1/forgejo/connections");
}
export async function createForgejoConnection(
data: ForgejoConnectionCreate,
): Promise<ForgejoConnection> {
return fetchJson<ForgejoConnection>(
`${API_BASE_URL}/api/v1/forgejo/connections`,
{
method: "POST",
body: JSON.stringify(data),
},
);
return fetchJson<ForgejoConnection>("/api/v1/forgejo/connections", {
method: "POST",
body: JSON.stringify(data),
});
}
export async function getForgejoConnection(
connectionId: string,
): Promise<ForgejoConnection> {
return fetchJson<ForgejoConnection>(
`${API_BASE_URL}/api/v1/forgejo/connections/${connectionId}`,
`/api/v1/forgejo/connections/${connectionId}`,
);
}
@ -106,7 +94,7 @@ export async function updateForgejoConnection(
data: ForgejoConnectionUpdate,
): Promise<ForgejoConnection> {
return fetchJson<ForgejoConnection>(
`${API_BASE_URL}/api/v1/forgejo/connections/${connectionId}`,
`/api/v1/forgejo/connections/${connectionId}`,
{
method: "PATCH",
body: JSON.stringify(data),
@ -114,36 +102,36 @@ export async function updateForgejoConnection(
);
}
export async function deleteForgejoConnection(connectionId: string): Promise<void> {
await fetch(`${API_BASE_URL}/api/v1/forgejo/connections/${connectionId}`, {
method: "DELETE",
});
export async function deleteForgejoConnection(
connectionId: string,
): Promise<void> {
await customFetch<ApiResponse<unknown>>(
`/api/v1/forgejo/connections/${connectionId}`,
{
method: "DELETE",
},
);
}
// Forgejo Repository API
export async function getForgejoRepositories(): Promise<ForgejoRepository[]> {
return fetchJson<ForgejoRepository[]>(
`${API_BASE_URL}/api/v1/forgejo/repositories`,
);
return fetchJson<ForgejoRepository[]>("/api/v1/forgejo/repositories");
}
export async function createForgejoRepository(
data: ForgejoRepositoryCreate,
): Promise<ForgejoRepository> {
return fetchJson<ForgejoRepository>(
`${API_BASE_URL}/api/v1/forgejo/repositories`,
{
method: "POST",
body: JSON.stringify(data),
},
);
return fetchJson<ForgejoRepository>("/api/v1/forgejo/repositories", {
method: "POST",
body: JSON.stringify(data),
});
}
export async function getForgejoRepository(
repositoryId: string,
): Promise<ForgejoRepository> {
return fetchJson<ForgejoRepository>(
`${API_BASE_URL}/api/v1/forgejo/repositories/${repositoryId}`,
`/api/v1/forgejo/repositories/${repositoryId}`,
);
}
@ -152,7 +140,7 @@ export async function updateForgejoRepository(
data: ForgejoRepositoryUpdate,
): Promise<ForgejoRepository> {
return fetchJson<ForgejoRepository>(
`${API_BASE_URL}/api/v1/forgejo/repositories/${repositoryId}`,
`/api/v1/forgejo/repositories/${repositoryId}`,
{
method: "PATCH",
body: JSON.stringify(data),
@ -160,43 +148,62 @@ export async function updateForgejoRepository(
);
}
export async function deleteForgejoRepository(repositoryId: string): Promise<void> {
await fetch(`${API_BASE_URL}/api/v1/forgejo/repositories/${repositoryId}`, {
method: "DELETE",
});
export async function deleteForgejoRepository(
repositoryId: string,
): Promise<void> {
await customFetch<ApiResponse<unknown>>(
`/api/v1/forgejo/repositories/${repositoryId}`,
{
method: "DELETE",
},
);
}
// Forgejo Sync & Validation API
export async function syncRepository(
repositoryId: string,
): Promise<{
export async function syncRepository(repositoryId: string): Promise<{
created: number;
updated: number;
open: number;
closed: number;
total: number;
}> {
return fetchJson<{ created: number; updated: number; open: number; closed: number; total: number }>(
`${API_BASE_URL}/api/v1/forgejo/repositories/${repositoryId}/sync`,
{
method: "POST",
},
);
return fetchJson<{
created: number;
updated: number;
open: number;
closed: number;
total: number;
}>(`/api/v1/forgejo/repositories/${repositoryId}/sync`, {
method: "POST",
});
}
export interface ForgejoValidationStatus {
ok: boolean;
status: string;
error_message?: string | null;
}
export interface ForgejoConnectionValidationResponse {
connection_id: string;
status: ForgejoValidationStatus;
response_time_ms: number;
validated_at: string;
}
export interface ForgejoRepositoryValidationResponse {
repository_id: string;
status: ForgejoValidationStatus;
response_time_ms: number;
validated_at: string;
repo_exists?: boolean | null;
}
export async function validateConnection(
connectionId: string,
): Promise<{
ok: boolean;
error_message?: string;
response_time_ms: number;
}> {
return fetchJson<{
ok: boolean;
error_message?: string;
response_time_ms: number;
}>(
`${API_BASE_URL}/api/v1/forgejo/connections/${connectionId}/validate`,
): Promise<ForgejoConnectionValidationResponse> {
return fetchJson<ForgejoConnectionValidationResponse>(
`/api/v1/forgejo/connections/${connectionId}/validate`,
{
method: "POST",
},
@ -205,17 +212,9 @@ export async function validateConnection(
export async function validateRepository(
repositoryId: string,
): Promise<{
ok: boolean;
repo_exists: boolean;
error_message?: string;
}> {
return fetchJson<{
ok: boolean;
repo_exists: boolean;
error_message?: string;
}>(
`${API_BASE_URL}/api/v1/forgejo/repositories/${repositoryId}/validate`,
): Promise<ForgejoRepositoryValidationResponse> {
return fetchJson<ForgejoRepositoryValidationResponse>(
`/api/v1/forgejo/repositories/${repositoryId}/validate`,
{
method: "POST",
},
@ -260,7 +259,8 @@ export async function getForgejoIssues(params?: {
limit?: number;
}): Promise<ForgejoIssueListResponse> {
const searchParams = new URLSearchParams();
if (params?.repository_id) searchParams.set("repository_id", params.repository_id);
if (params?.repository_id)
searchParams.set("repository_id", params.repository_id);
if (params?.state) searchParams.set("state", params.state);
if (params?.search) searchParams.set("search", params.search);
if (params?.page) searchParams.set("page", params.page.toString());
@ -268,83 +268,62 @@ export async function getForgejoIssues(params?: {
const qs = searchParams.toString();
return fetchJson<ForgejoIssueListResponse>(
`${API_BASE_URL}/api/v1/forgejo/issues${qs ? `?${qs}` : ""}`,
`/api/v1/forgejo/issues${qs ? `?${qs}` : ""}`,
);
}
export async function getForgejoIssue(issueId: string): Promise<ForgejoIssue> {
return fetchJson<ForgejoIssue>(
`${API_BASE_URL}/api/v1/forgejo/issues/${issueId}`,
);
return fetchJson<ForgejoIssue>(`/api/v1/forgejo/issues/${issueId}`);
}
export async function closeForgejoIssue(issueId: string): Promise<ForgejoIssue> {
return fetchJson<ForgejoIssue>(
`${API_BASE_URL}/api/v1/forgejo/issues/${issueId}/close`,
{
method: "POST",
},
);
export async function closeForgejoIssue(
issueId: string,
): Promise<ForgejoIssue> {
return fetchJson<ForgejoIssue>(`/api/v1/forgejo/issues/${issueId}/close`, {
method: "POST",
});
}
// Board Repository Linking API
export async function getBoardForgejoRepositories(boardId: string): Promise<{
repositories: Array<{
id: string;
board_id: string;
repository_id: string;
organization_id: string;
created_at: string;
repository: ForgejoRepository;
}>;
}> {
return fetchJson<{
repositories: Array<{
id: string;
board_id: string;
repository_id: string;
organization_id: string;
created_at: string;
repository: ForgejoRepository;
}>;
}>(
`${API_BASE_URL}/api/v1/boards/${boardId}/forgejo/repositories`,
export interface BoardForgejoRepositoryLink {
id: string;
board_id: string;
repository_id: string;
organization_id: string;
created_at: string;
repository?: ForgejoRepository;
}
export type BoardForgejoRepositoriesResponse =
| BoardForgejoRepositoryLink[]
| { repositories: BoardForgejoRepositoryLink[] };
export async function getBoardForgejoRepositories(
boardId: string,
): Promise<BoardForgejoRepositoriesResponse> {
return fetchJson<BoardForgejoRepositoriesResponse>(
`/api/v1/boards/${boardId}/forgejo/repositories`,
);
}
export async function linkBoardForgejoRepository(
boardId: string,
repositoryId: string,
): Promise<{
id: string;
board_id: string;
repository_id: string;
organization_id: string;
created_at: string;
repository: ForgejoRepository;
}> {
return fetchJson<{
id: string;
board_id: string;
repository_id: string;
organization_id: string;
created_at: string;
repository: ForgejoRepository;
}>(
`${API_BASE_URL}/api/v1/boards/${boardId}/forgejo/repositories`,
{
method: "POST",
body: JSON.stringify({ repository_id: repositoryId }),
},
);
): Promise<BoardForgejoRepositoryLink | { link?: BoardForgejoRepositoryLink }> {
return fetchJson<
BoardForgejoRepositoryLink | { link?: BoardForgejoRepositoryLink }
>(`/api/v1/boards/${boardId}/forgejo/repositories`, {
method: "POST",
body: JSON.stringify({ repository_id: repositoryId }),
});
}
export async function unlinkBoardForgejoRepository(
boardId: string,
repositoryId: string,
): Promise<void> {
await fetch(
`${API_BASE_URL}/api/v1/boards/${boardId}/forgejo/repositories/${repositoryId}`,
await customFetch<ApiResponse<unknown>>(
`/api/v1/boards/${boardId}/forgejo/repositories/${repositoryId}`,
{
method: "DELETE",
},
@ -378,9 +357,10 @@ export async function getForgejoMetrics(params?: {
}): Promise<ForgejoIssueMetrics> {
const searchParams = new URLSearchParams();
if (params?.board_id) searchParams.set("board_id", params.board_id);
if (params?.repository_id) searchParams.set("repository_id", params.repository_id);
if (params?.repository_id)
searchParams.set("repository_id", params.repository_id);
const qs = searchParams.toString();
return fetchJson<ForgejoIssueMetrics>(
`${API_BASE_URL}/api/v1/forgejo/metrics${qs ? `?${qs}` : ""}`,
`/api/v1/forgejo/metrics${qs ? `?${qs}` : ""}`,
);
}

View File

@ -8,6 +8,7 @@ module.exports = {
heading: ["var(--font-heading)", "sans-serif"],
body: ["var(--font-body)", "sans-serif"],
display: ["var(--font-display)", "serif"],
numeric: ["Georgia Numbers", "Georgia", "Times New Roman", "serif"],
},
},
},