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 from __future__ import annotations
import time
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from uuid import UUID from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status
from sqlmodel import select from sqlmodel import select
from sqlalchemy.orm import selectinload
from app.api.deps import require_org_admin from app.api.deps import require_org_member
from app.core.auth import AuthContext, get_auth_context
from app.db import crud from app.db import crud
from app.db.session import get_session from app.db.session import get_session
from app.models.forgejo_connections import ForgejoConnection from app.models.forgejo_connections import ForgejoConnection
@ -22,7 +19,6 @@ from app.schemas.forgejo_connections import (
ForgejoConnectionUpdate, ForgejoConnectionUpdate,
) )
from app.schemas.forgejo_validation import ForgejoConnectionValidationResponse from app.schemas.forgejo_validation import ForgejoConnectionValidationResponse
from app.services.forgejo_client import get_forgejo_client
from app.services.organizations import OrganizationContext from app.services.organizations import OrganizationContext
if TYPE_CHECKING: if TYPE_CHECKING:
@ -31,8 +27,7 @@ if TYPE_CHECKING:
router = APIRouter(prefix="/forgejo/connections", tags=["forgejo-connections"]) router = APIRouter(prefix="/forgejo/connections", tags=["forgejo-connections"])
SESSION_DEP = Depends(get_session) SESSION_DEP = Depends(get_session)
AUTH_DEP = Depends(get_auth_context) ORG_MEMBER_DEP = Depends(require_org_member)
ORG_ADMIN_DEP = Depends(require_org_admin)
def _extract_token_last_eight(token: str | None) -> str | None: 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]) @router.get("", response_model=list[ForgejoConnectionRead])
async def list_connections( async def list_connections(
session: AsyncSession = SESSION_DEP, session: AsyncSession = SESSION_DEP,
ctx: OrganizationContext = ORG_ADMIN_DEP, ctx: OrganizationContext = ORG_MEMBER_DEP,
) -> list[ForgejoConnectionRead]: ) -> list[ForgejoConnectionRead]:
"""List Forgejo connections for the caller's organization.""" """List Forgejo connections for the caller's organization."""
statement = ( statement = (
@ -76,8 +71,7 @@ async def list_connections(
async def create_connection( async def create_connection(
payload: ForgejoConnectionCreate, payload: ForgejoConnectionCreate,
session: AsyncSession = SESSION_DEP, session: AsyncSession = SESSION_DEP,
auth: AuthContext = AUTH_DEP, ctx: OrganizationContext = ORG_MEMBER_DEP,
ctx: OrganizationContext = ORG_ADMIN_DEP,
) -> ForgejoConnectionRead: ) -> ForgejoConnectionRead:
"""Create a Forgejo connection for the caller's organization.""" """Create a Forgejo connection for the caller's organization."""
data = payload.model_dump() data = payload.model_dump()
@ -96,7 +90,7 @@ async def create_connection(
async def get_connection( async def get_connection(
connection_id: UUID, connection_id: UUID,
session: AsyncSession = SESSION_DEP, session: AsyncSession = SESSION_DEP,
ctx: OrganizationContext = ORG_ADMIN_DEP, ctx: OrganizationContext = ORG_MEMBER_DEP,
) -> ForgejoConnectionRead: ) -> ForgejoConnectionRead:
"""Return one Forgejo connection by id for the caller's organization.""" """Return one Forgejo connection by id for the caller's organization."""
connection = await crud.get_by_id(session, ForgejoConnection, connection_id) connection = await crud.get_by_id(session, ForgejoConnection, connection_id)
@ -112,8 +106,7 @@ async def update_connection(
connection_id: UUID, connection_id: UUID,
payload: ForgejoConnectionUpdate, payload: ForgejoConnectionUpdate,
session: AsyncSession = SESSION_DEP, session: AsyncSession = SESSION_DEP,
auth: AuthContext = AUTH_DEP, ctx: OrganizationContext = ORG_MEMBER_DEP,
ctx: OrganizationContext = ORG_ADMIN_DEP,
) -> ForgejoConnectionRead: ) -> ForgejoConnectionRead:
"""Patch a Forgejo connection for the caller's organization.""" """Patch a Forgejo connection for the caller's organization."""
connection = await crud.get_by_id(session, ForgejoConnection, connection_id) connection = await crud.get_by_id(session, ForgejoConnection, connection_id)
@ -165,7 +158,7 @@ async def update_connection(
async def delete_connection( async def delete_connection(
connection_id: UUID, connection_id: UUID,
session: AsyncSession = SESSION_DEP, session: AsyncSession = SESSION_DEP,
ctx: OrganizationContext = ORG_ADMIN_DEP, ctx: OrganizationContext = ORG_MEMBER_DEP,
) -> OkResponse: ) -> OkResponse:
"""Delete a Forgejo connection for the caller's organization.""" """Delete a Forgejo connection for the caller's organization."""
connection = await crud.get_by_id(session, ForgejoConnection, connection_id) connection = await crud.get_by_id(session, ForgejoConnection, connection_id)
@ -188,7 +181,7 @@ async def delete_connection(
async def validate_connection( async def validate_connection(
connection_id: UUID, connection_id: UUID,
session: AsyncSession = SESSION_DEP, session: AsyncSession = SESSION_DEP,
ctx: OrganizationContext = ORG_ADMIN_DEP, ctx: OrganizationContext = ORG_MEMBER_DEP,
) -> ForgejoConnectionValidationResponse: ) -> ForgejoConnectionValidationResponse:
"""Validate a Forgejo connection by testing authenticated API access.""" """Validate a Forgejo connection by testing authenticated API access."""
connection = await crud.get_by_id(session, ForgejoConnection, connection_id) 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 fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlmodel import select, func from sqlmodel import select, func
from app.api.deps import get_board_for_user_write, require_org_admin from app.api.deps import get_board_for_user_write, require_org_member
from app.core.agent_auth import get_agent_auth_context from app.core.auth import AuthContext, get_auth_context
from app.core.auth import get_auth_context
from app.db import crud from app.db import crud
from app.db.session import get_session from app.db.session import get_session
from app.models.board_repository_links import BoardRepositoryLink from app.models.board_repository_links import BoardRepositoryLink
@ -26,14 +25,13 @@ if TYPE_CHECKING:
router = APIRouter(prefix="/forgejo/issues", tags=["forgejo-issues"]) router = APIRouter(prefix="/forgejo/issues", tags=["forgejo-issues"])
SESSION_DEP = Depends(get_session) SESSION_DEP = Depends(get_session)
AUTH_DEP = Depends(get_auth_context) AUTH_DEP = Depends(get_auth_context)
ORG_ADMIN_DEP = Depends(require_org_admin) ORG_MEMBER_DEP = Depends(require_org_member)
BOARD_WRITE_DEP = Depends(get_board_for_user_write)
@router.get("", response_model=ForgejoIssueListResponse) @router.get("", response_model=ForgejoIssueListResponse)
async def list_issues( async def list_issues(
session: AsyncSession = SESSION_DEP, 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"), repository_id: str | None = Query(None, description="Filter by repository ID"),
state: str | None = Query(None, description="Filter by state (open, closed)"), state: str | None = Query(None, description="Filter by state (open, closed)"),
label: str | None = Query(None, description="Filter by label name"), label: str | None = Query(None, description="Filter by label name"),
@ -99,7 +97,7 @@ async def list_issues(
async def get_issue( async def get_issue(
issue_id: str, issue_id: str,
session: AsyncSession = SESSION_DEP, session: AsyncSession = SESSION_DEP,
ctx: OrganizationContext = ORG_ADMIN_DEP, ctx: OrganizationContext = ORG_MEMBER_DEP,
) -> ForgejoIssueRead: ) -> ForgejoIssueRead:
"""Get one cached issue by ID.""" """Get one cached issue by ID."""
try: try:
@ -157,7 +155,8 @@ async def get_issue(
async def close_issue( async def close_issue(
issue_id: str, issue_id: str,
session: AsyncSession = SESSION_DEP, session: AsyncSession = SESSION_DEP,
ctx: OrganizationContext = ORG_ADMIN_DEP, auth: AuthContext = AUTH_DEP,
ctx: OrganizationContext = ORG_MEMBER_DEP,
) -> CloseIssueResponse: ) -> CloseIssueResponse:
"""Close a Forgejo issue as an authenticated user. """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 # 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), board_id=str(link.board_id),
session=session, 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 # Close the issue using the service
try: try:
result = await close_issue_by_id( result = await close_issue_by_id(
session=session, session=session,
issue_id=uuid, issue_id=uuid,
actor_user_id=ctx.user.id, actor_user_id=auth.user.id,
) )
except CloseIssueNotFoundError as e: except CloseIssueNotFoundError as e:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e)) raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e))

View File

@ -2,15 +2,13 @@
from __future__ import annotations from __future__ import annotations
import time
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from uuid import UUID from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status
from sqlmodel import select from sqlmodel import select
from app.api.deps import require_org_admin from app.api.deps import require_org_member
from app.core.auth import AuthContext, get_auth_context
from app.db import crud from app.db import crud
from app.db.session import get_session from app.db.session import get_session
from app.models.forgejo_connections import ForgejoConnection from app.models.forgejo_connections import ForgejoConnection
@ -31,8 +29,7 @@ if TYPE_CHECKING:
router = APIRouter(prefix="/forgejo/repositories", tags=["forgejo-repositories"]) router = APIRouter(prefix="/forgejo/repositories", tags=["forgejo-repositories"])
SESSION_DEP = Depends(get_session) SESSION_DEP = Depends(get_session)
AUTH_DEP = Depends(get_auth_context) ORG_MEMBER_DEP = Depends(require_org_member)
ORG_ADMIN_DEP = Depends(require_org_admin)
def _create_connection_info(connection: ForgejoConnection) -> dict[str, object]: 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]) @router.get("", response_model=list[ForgejoRepositoryRead])
async def list_repositories( async def list_repositories(
session: AsyncSession = SESSION_DEP, session: AsyncSession = SESSION_DEP,
ctx: OrganizationContext = ORG_ADMIN_DEP, ctx: OrganizationContext = ORG_MEMBER_DEP,
) -> list[ForgejoRepositoryRead]: ) -> list[ForgejoRepositoryRead]:
"""List Forgejo repositories for the caller's organization.""" """List Forgejo repositories for the caller's organization."""
statement = ( statement = (
@ -77,8 +74,7 @@ async def list_repositories(
async def create_repository( async def create_repository(
payload: ForgejoRepositoryCreate, payload: ForgejoRepositoryCreate,
session: AsyncSession = SESSION_DEP, session: AsyncSession = SESSION_DEP,
auth: AuthContext = AUTH_DEP, ctx: OrganizationContext = ORG_MEMBER_DEP,
ctx: OrganizationContext = ORG_ADMIN_DEP,
) -> ForgejoRepositoryRead: ) -> ForgejoRepositoryRead:
"""Create a Forgejo repository tracked for the caller's organization.""" """Create a Forgejo repository tracked for the caller's organization."""
# Validate connection belongs to caller's org # Validate connection belongs to caller's org
@ -123,7 +119,7 @@ async def create_repository(
async def get_repository( async def get_repository(
repository_id: UUID, repository_id: UUID,
session: AsyncSession = SESSION_DEP, session: AsyncSession = SESSION_DEP,
ctx: OrganizationContext = ORG_ADMIN_DEP, ctx: OrganizationContext = ORG_MEMBER_DEP,
) -> ForgejoRepositoryRead: ) -> ForgejoRepositoryRead:
"""Return one Forgejo repository by id for the caller's organization.""" """Return one Forgejo repository by id for the caller's organization."""
statement = ( statement = (
@ -143,8 +139,7 @@ async def update_repository(
repository_id: UUID, repository_id: UUID,
payload: ForgejoRepositoryUpdate, payload: ForgejoRepositoryUpdate,
session: AsyncSession = SESSION_DEP, session: AsyncSession = SESSION_DEP,
auth: AuthContext = AUTH_DEP, ctx: OrganizationContext = ORG_MEMBER_DEP,
ctx: OrganizationContext = ORG_ADMIN_DEP,
) -> ForgejoRepositoryRead: ) -> ForgejoRepositoryRead:
"""Patch a Forgejo repository for the caller's organization.""" """Patch a Forgejo repository for the caller's organization."""
# Get repository # Get repository
@ -218,7 +213,7 @@ async def update_repository(
async def delete_repository( async def delete_repository(
repository_id: UUID, repository_id: UUID,
session: AsyncSession = SESSION_DEP, session: AsyncSession = SESSION_DEP,
ctx: OrganizationContext = ORG_ADMIN_DEP, ctx: OrganizationContext = ORG_MEMBER_DEP,
) -> OkResponse: ) -> OkResponse:
"""Delete a Forgejo repository for the caller's organization.""" """Delete a Forgejo repository for the caller's organization."""
repository = await crud.get_by_id(session, ForgejoRepository, repository_id) repository = await crud.get_by_id(session, ForgejoRepository, repository_id)
@ -241,7 +236,7 @@ async def delete_repository(
async def validate_repository( async def validate_repository(
repository_id: UUID, repository_id: UUID,
session: AsyncSession = SESSION_DEP, session: AsyncSession = SESSION_DEP,
ctx: OrganizationContext = ORG_ADMIN_DEP, ctx: OrganizationContext = ORG_MEMBER_DEP,
) -> ForgejoRepositoryValidationResponse: ) -> ForgejoRepositoryValidationResponse:
"""Validate a Forgejo repository by testing API access.""" """Validate a Forgejo repository by testing API access."""
repository = await crud.get_by_id(session, ForgejoRepository, repository_id) repository = await crud.get_by_id(session, ForgejoRepository, repository_id)
@ -296,12 +291,12 @@ async def validate_repository(
@router.post( @router.post(
"/{repository_id}/sync", "/{repository_id}/sync",
summary="Sync Issues from Repository", 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( async def sync_repository_issues(
repository_id: UUID, repository_id: UUID,
session: AsyncSession = SESSION_DEP, session: AsyncSession = SESSION_DEP,
ctx: OrganizationContext = ORG_ADMIN_DEP, ctx: OrganizationContext = ORG_MEMBER_DEP,
) -> dict[str, int]: ) -> dict[str, int]:
"""Sync issues from a Forgejo repository.""" """Sync issues from a Forgejo repository."""
repository = await crud.get_by_id(session, ForgejoRepository, repository_id) 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 { useAuth } from "@/auth/clerk";
import { DashboardPageLayout } from "@/components/templates/DashboardPageLayout"; import { DashboardPageLayout } from "@/components/templates/DashboardPageLayout";
import { ForgejoConnectionForm } from "@/components/git/ForgejoConnectionForm"; import { ForgejoConnectionForm } from "@/components/git/ForgejoConnectionForm";
import { ConfirmActionDialog } from "@/components/ui/confirm-action-dialog";
import { import {
getForgejoConnection, getForgejoConnection,
updateForgejoConnection, updateForgejoConnection,
@ -27,13 +28,20 @@ interface ConnectionData {
id: string; id: string;
} }
export default function ForgejoConnectionsEditPage({ params }: { params: RouteParams }) { export default function ForgejoConnectionsEditPage({
params,
}: {
params: RouteParams;
}) {
const router = useRouter(); const router = useRouter();
const auth = useAuth(); const auth = useAuth();
const [connection, setConnection] = useState<ConnectionData | null>(null); const [connection, setConnection] = useState<ConnectionData | null>(null);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null); 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(() => { useEffect(() => {
const fetchConnection = async () => { const fetchConnection = async () => {
@ -43,7 +51,9 @@ export default function ForgejoConnectionsEditPage({ params }: { params: RoutePa
setConnection(data); setConnection(data);
setError(null); setError(null);
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : "Failed to load connection"); setError(
err instanceof Error ? err.message : "Failed to load connection",
);
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }
@ -55,26 +65,22 @@ export default function ForgejoConnectionsEditPage({ params }: { params: RoutePa
}, [params.connectionId, auth.isSignedIn]); }, [params.connectionId, auth.isSignedIn]);
const handleSubmit = async (values: ForgejoConnectionUpdate) => { const handleSubmit = async (values: ForgejoConnectionUpdate) => {
try { await updateForgejoConnection(params.connectionId, values);
const connection = await updateForgejoConnection(params.connectionId, values);
console.log("Connection updated:", connection);
router.push("/git-projects/connections"); router.push("/git-projects/connections");
} catch (err) {
alert(err instanceof Error ? err.message : "Failed to update connection");
}
}; };
const handleDelete = async () => { const handleDelete = async () => {
if ( setIsDeleting(true);
confirm(`Are you sure you want to delete "${connection?.name}"? This action cannot be undone.`) setDeleteError(null);
) {
try { try {
await deleteForgejoConnection(params.connectionId); await deleteForgejoConnection(params.connectionId);
console.log("Connection deleted");
router.push("/git-projects/connections"); router.push("/git-projects/connections");
} catch (err) { } catch (err) {
alert(err instanceof Error ? err.message : "Failed to delete connection"); 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 ( return (
<DashboardPageLayout <DashboardPageLayout
signedOut={{ signedOut={{
message: "Sign in to edit a Forgejo connection.", message: "Sign in to edit a Git Project connection.",
forceRedirectUrl: "/git-projects/connections", forceRedirectUrl: "/git-projects/connections",
signUpForceRedirectUrl: "/git-projects/connections", signUpForceRedirectUrl: "/git-projects/connections",
}} }}
title="Loading..." title="Loading"
stickyHeader stickyHeader
> >
<p className="text-slate-500">Loading connection...</p> <p className="text-muted">Loading connection</p>
</DashboardPageLayout> </DashboardPageLayout>
); );
} }
@ -98,14 +104,16 @@ export default function ForgejoConnectionsEditPage({ params }: { params: RoutePa
return ( return (
<DashboardPageLayout <DashboardPageLayout
signedOut={{ signedOut={{
message: "Sign in to edit a Forgejo connection.", message: "Sign in to edit a Git Project connection.",
forceRedirectUrl: "/git-projects/connections", forceRedirectUrl: "/git-projects/connections",
signUpForceRedirectUrl: "/git-projects/connections", signUpForceRedirectUrl: "/git-projects/connections",
}} }}
title="Error" title="Error"
stickyHeader stickyHeader
> >
<p className="text-red-600">{error || "Connection not found"}</p> <p className="text-[color:var(--danger)]">
{error || "Connection not found"}
</p>
</DashboardPageLayout> </DashboardPageLayout>
); );
} }
@ -113,40 +121,58 @@ export default function ForgejoConnectionsEditPage({ params }: { params: RoutePa
const defaultValues = { const defaultValues = {
name: connection.name, name: connection.name,
base_url: connection.base_url, base_url: connection.base_url,
token: connection.has_token ? "••••" + (connection.token_last_eight || "") : "", token: "",
}; };
return ( return (
<DashboardPageLayout <DashboardPageLayout
signedOut={{ signedOut={{
message: "Sign in to edit a Forgejo connection.", message: "Sign in to edit a Git Project connection.",
forceRedirectUrl: "/git-projects/connections", forceRedirectUrl: "/git-projects/connections",
signUpForceRedirectUrl: "/git-projects/connections", signUpForceRedirectUrl: "/git-projects/connections",
}} }}
title={`Edit Connection: ${connection.name}`} title={`Edit Git Project Connection: ${connection.name}`}
description="Update connection settings and credentials." description="Update the Git provider settings Pipeline uses for Git Projects."
stickyHeader stickyHeader
> >
<div className="max-w-2xl"> <div className="w-full max-w-2xl">
<ForgejoConnectionForm <ForgejoConnectionForm
defaultValues={defaultValues} defaultValues={defaultValues}
onSubmit={handleSubmit} onSubmit={handleSubmit}
isTokenRequired={!connection.has_token}
existingTokenLastEight={connection.token_last_eight}
submitLabel="Save Changes" submitLabel="Save Changes"
/> />
<div className="mt-8 border-t pt-6"> <div className="surface-muted mt-6 rounded-2xl p-4 sm:p-5">
<h3 className="text-lg font-semibold text-red-900 dark:text-red-100">Danger Zone</h3> <h3 className="text-base font-semibold text-[color:var(--danger)]">
<p className="mt-2 text-sm text-slate-500"> Delete Connection
Deleting a connection will remove all associated repositories and data. </h3>
<p className="mt-2 text-sm text-muted">
Remove this connection from Pipeline. Repositories that use it will
stop syncing.
</p> </p>
<Button <Button
variant="outline" 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" 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={handleDelete} onClick={() => setDeleteOpen(true)}
> >
Delete Connection Delete Connection
</Button> </Button>
</div> </div>
</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> </DashboardPageLayout>
); );
} }

View File

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

View File

@ -2,11 +2,13 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { AlertCircle, CheckCircle2 } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { useAuth } from "@/auth/clerk"; import { useAuth } from "@/auth/clerk";
import { DashboardPageLayout } from "@/components/templates/DashboardPageLayout"; import { DashboardPageLayout } from "@/components/templates/DashboardPageLayout";
import { ForgejoConnectionsTable } from "@/components/git/ForgejoConnectionsTable"; import { ForgejoConnectionsTable } from "@/components/git/ForgejoConnectionsTable";
import { ConfirmActionDialog } from "@/components/ui/confirm-action-dialog";
import { import {
getForgejoConnections, getForgejoConnections,
deleteForgejoConnection, deleteForgejoConnection,
@ -21,6 +23,15 @@ export default function ForgejoConnectionsPage() {
const [connections, setConnections] = useState<ForgejoConnection[]>([]); const [connections, setConnections] = useState<ForgejoConnection[]>([]);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null); 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(() => { useEffect(() => {
const fetchConnections = async () => { const fetchConnections = async () => {
@ -30,7 +41,9 @@ export default function ForgejoConnectionsPage() {
setConnections(data); setConnections(data);
setError(null); setError(null);
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : "Failed to load connections"); setError(
err instanceof Error ? err.message : "Failed to load connections",
);
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }
@ -41,66 +54,99 @@ export default function ForgejoConnectionsPage() {
} }
}, [auth.isSignedIn, auth.getToken]); }, [auth.isSignedIn, auth.getToken]);
const handleDelete = async (connection: ForgejoConnection) => { const handleDelete = (connection: ForgejoConnection) => {
if ( setDeleteError(null);
confirm(`Are you sure you want to delete "${connection.name}"? This action cannot be undone.`) setDeleteTarget(connection);
) { };
const confirmDelete = async () => {
if (!deleteTarget) return;
setIsDeleting(true);
setDeleteError(null);
try { try {
await deleteForgejoConnection(connection.id); await deleteForgejoConnection(deleteTarget.id);
setConnections((prev) => prev.filter((c) => c.id !== connection.id)); setConnections((prev) => prev.filter((c) => c.id !== deleteTarget.id));
alert("Connection deleted successfully"); setNotice({
tone: "success",
message: `Deleted "${deleteTarget.name}".`,
});
setDeleteTarget(null);
} catch (err) { } catch (err) {
alert(err instanceof Error ? err.message : "Failed to delete connection"); setDeleteError(
} err instanceof Error ? err.message : "Failed to delete connection",
);
} finally {
setIsDeleting(false);
} }
}; };
const handleValidateConnection = async (connection: ForgejoConnection) => { const handleValidateConnection = async (connection: ForgejoConnection) => {
try { try {
const result = await validateConnection(connection.id); const result = await validateConnection(connection.id);
if (result.ok) { if (result.status.ok) {
alert( setNotice({
`Connection validated successfully!\n\n` + tone: "success",
`Response time: ${result.response_time_ms}ms` message: `"${connection.name}" validated in ${Math.round(result.response_time_ms)}ms.`,
); });
} else { } else {
alert( setNotice({
`Connection validation failed: ${result.error_message || "Unknown error"}` tone: "error",
); message: `Connection validation failed: ${result.status.error_message || "Unknown error"}`,
});
} }
return result; return result;
} catch (err) { } 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; throw err;
} }
}; };
return ( return (
<>
<DashboardPageLayout <DashboardPageLayout
signedOut={{ signedOut={{
message: "Sign in to manage Forgejo connections.", message: "Sign in to manage Git Project connections.",
forceRedirectUrl: "/git-projects/connections", forceRedirectUrl: "/git-projects/connections",
signUpForceRedirectUrl: "/git-projects/connections", signUpForceRedirectUrl: "/git-projects/connections",
}} }}
title="Forgejo Connections" title="Git Project Connections"
description={`${connections.length} connection${connections.length === 1 ? "" : "s"} configured.`} description={`${connections.length} connection${connections.length === 1 ? "" : "s"} configured for Pipeline.`}
stickyHeader stickyHeader
isAdmin={false}
adminOnlyMessage="Admin access required to manage Forgejo connections."
> >
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<div className="flex items-center justify-between"> {notice ? (
<h2 className="text-sm font-medium text-slate-500">Connections</h2> <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>
) : null}
<div className="flex flex-wrap items-center justify-between gap-3">
<h2 className="text-sm font-medium text-muted">Connections</h2>
<Button <Button
onClick={() => router.push("/git-projects/connections/new")} onClick={() => router.push("/git-projects/connections/new")}
> >
Add Connection Add Connection
</Button> </Button>
</div> </div>
<div className="overflow-hidden rounded-xl border border-slate-200 bg-white shadow-sm"> <div className="overflow-hidden rounded-xl border border-[color:var(--border)] bg-[color:var(--surface)] shadow-lush">
{error ? ( {error ? (
<div className="p-8 text-center"> <div className="p-8 text-center">
<p className="text-red-600">{error}</p> <p className="text-sm text-[color:var(--danger)]">{error}</p>
</div> </div>
) : ( ) : (
<ForgejoConnectionsTable <ForgejoConnectionsTable
@ -113,5 +159,25 @@ export default function ForgejoConnectionsPage() {
</div> </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"; export const dynamic = "force-dynamic";
import { useMemo, useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { AlertCircle } from "lucide-react";
import {
type ColumnDef,
} from "@tanstack/react-table";
import { DashboardPageLayout } from "@/components/templates/DashboardPageLayout"; import { DashboardPageLayout } from "@/components/templates/DashboardPageLayout";
import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button";
import { import {
getForgejoIssues, getForgejoIssues,
getForgejoRepositories, getForgejoRepositories,
@ -23,6 +20,8 @@ export default function GitIssuesPage() {
const [issues, setIssues] = useState<ForgejoIssue[]>([]); const [issues, setIssues] = useState<ForgejoIssue[]>([]);
const [repos, setRepos] = useState<ForgejoRepository[]>([]); const [repos, setRepos] = useState<ForgejoRepository[]>([]);
const [total, setTotal] = useState(0); const [total, setTotal] = useState(0);
const [isLoadingIssues, setIsLoadingIssues] = useState(true);
const [error, setError] = useState<string | null>(null);
const [stateFilter, setStateFilter] = useState<string>("open"); const [stateFilter, setStateFilter] = useState<string>("open");
const [repoFilter, setRepoFilter] = useState<string>("all"); const [repoFilter, setRepoFilter] = useState<string>("all");
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
@ -44,8 +43,9 @@ export default function GitIssuesPage() {
const controller = new AbortController(); const controller = new AbortController();
(async () => { (async () => {
try { try {
setIsLoadingIssues(true);
const result = await getForgejoIssues({ const result = await getForgejoIssues({
state: stateFilter || undefined, state: stateFilter !== "all" ? stateFilter : undefined,
repository_id: repoFilter !== "all" ? repoFilter : undefined, repository_id: repoFilter !== "all" ? repoFilter : undefined,
search: search || undefined, search: search || undefined,
page, page,
@ -53,9 +53,16 @@ export default function GitIssuesPage() {
}); });
setIssues(result.items); setIssues(result.items);
setTotal(result.total); setTotal(result.total);
setError(null);
} catch (err) { } catch (err) {
if (err instanceof Error && err.name === "AbortError") return; 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(); return () => controller.abort();
@ -63,8 +70,9 @@ export default function GitIssuesPage() {
const handleRefresh = async () => { const handleRefresh = async () => {
try { try {
setIsLoadingIssues(true);
const result = await getForgejoIssues({ const result = await getForgejoIssues({
state: stateFilter || undefined, state: stateFilter !== "all" ? stateFilter : undefined,
repository_id: repoFilter !== "all" ? repoFilter : undefined, repository_id: repoFilter !== "all" ? repoFilter : undefined,
search: search || undefined, search: search || undefined,
page, page,
@ -72,152 +80,85 @@ export default function GitIssuesPage() {
}); });
setIssues(result.items); setIssues(result.items);
setTotal(result.total); setTotal(result.total);
setError(null);
} catch (err) { } 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); const totalPages = Math.ceil(total / limit);
return ( return (
<DashboardPageLayout <DashboardPageLayout
signedOut={{ signedOut={{
message: "Sign in to view issues.", message: "Sign in to view Git Project issues.",
forceRedirectUrl: "/git-projects/issues", forceRedirectUrl: "/git-projects/issues",
signUpForceRedirectUrl: "/git-projects/issues", signUpForceRedirectUrl: "/git-projects/issues",
}} }}
title="Issues" title="Git Project Issues"
description={`${total} issue${total === 1 ? "" : "s"} from tracked repositories.`} description={`${total} issue${total === 1 ? "" : "s"} from repositories tracked by Pipeline.`}
stickyHeader stickyHeader
> >
<ForgejoIssueFilters <ForgejoIssueFilters
stateFilter={stateFilter} stateFilter={stateFilter}
onStateChange={(v) => { setStateFilter(v); setPage(1); }} onStateChange={(v) => {
setStateFilter(v);
setPage(1);
}}
repoFilter={repoFilter} repoFilter={repoFilter}
onRepoChange={(v) => { setRepoFilter(v); setPage(1); }} onRepoChange={(v) => {
setRepoFilter(v);
setPage(1);
}}
search={search} search={search}
onSearchChange={(v) => { setSearch(v); setPage(1); }} onSearchChange={(v) => {
setSearch(v);
setPage(1);
}}
repos={repos} 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 && ( {totalPages > 1 && (
<div className="mt-4 flex items-center justify-between text-sm text-slate-600 dark:text-slate-400"> <div className="mt-4 flex flex-col gap-3 text-sm text-muted sm:flex-row sm:items-center sm:justify-between">
<span> <span className="break-words">
Page {page} of {totalPages} ({total} total) Page {page} of {totalPages} ({total} total)
</span> </span>
<div className="flex gap-2"> <div className="flex gap-2">
<button <Button
className="rounded border px-3 py-1 disabled:opacity-50" variant="outline"
size="sm"
onClick={() => setPage((p) => Math.max(1, p - 1))} onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page <= 1} disabled={page <= 1}
> >
Previous Previous
</button> </Button>
<button <Button
className="rounded border px-3 py-1 disabled:opacity-50" variant="outline"
size="sm"
onClick={() => setPage((p) => Math.min(totalPages, p + 1))} onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
disabled={page >= totalPages} disabled={page >= totalPages}
> >
Next Next
</button> </Button>
</div> </div>
</div> </div>
)} )}

View File

@ -9,12 +9,13 @@ import {
getCoreRowModel, getCoreRowModel,
useReactTable, useReactTable,
} from "@tanstack/react-table"; } from "@tanstack/react-table";
import { AlertCircle, CheckCircle2, GitBranch, RefreshCw } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { useAuth } from "@/auth/clerk";
import { DashboardPageLayout } from "@/components/templates/DashboardPageLayout"; import { DashboardPageLayout } from "@/components/templates/DashboardPageLayout";
import { DataTable } from "@/components/tables/DataTable"; import { DataTable } from "@/components/tables/DataTable";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
getForgejoRepositories, getForgejoRepositories,
@ -23,9 +24,9 @@ import {
} from "@/lib/api-forgejo"; } from "@/lib/api-forgejo";
export default function GitProjectsPage() { export default function GitProjectsPage() {
const _useAuth = useAuth();
const [repositories, setRepositories] = useState<ForgejoRepository[]>([]); const [repositories, setRepositories] = useState<ForgejoRepository[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [syncingId, setSyncingId] = useState<string | null>(null); const [syncingId, setSyncingId] = useState<string | null>(null);
const [syncResult, setSyncResult] = useState<{ const [syncResult, setSyncResult] = useState<{
repoName: string; repoName: string;
@ -33,14 +34,20 @@ export default function GitProjectsPage() {
updated: number; updated: number;
open: number; open: number;
closed: number; closed: number;
error?: string;
} | null>(null); } | null>(null);
const fetchRepos = useCallback(async () => { const fetchRepos = useCallback(async () => {
try { try {
const repos = await getForgejoRepositories(); const repos = await getForgejoRepositories();
setRepositories(repos); setRepositories(repos);
setError(null);
} catch (err) { } catch (err) {
console.error("Failed to fetch repositories:", err); setError(
err instanceof Error
? err.message
: "Pipeline could not load Git Projects.",
);
} finally { } finally {
setLoading(false); setLoading(false);
} }
@ -65,7 +72,14 @@ export default function GitProjectsPage() {
}); });
await fetchRepos(); await fetchRepos();
} catch (err) { } 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 { } finally {
setSyncingId(null); setSyncingId(null);
} }
@ -80,14 +94,13 @@ export default function GitProjectsPage() {
header: "Repository", header: "Repository",
cell: ({ row }) => { cell: ({ row }) => {
const repo = row.original; const repo = row.original;
const name = const name = repo.display_name || `${repo.owner}/${repo.repo}`;
repo.display_name || `${repo.owner}/${repo.repo}`;
return ( return (
<div> <div className="min-w-[180px]">
<div className="font-medium text-slate-900 dark:text-slate-100"> <div className="truncate font-medium text-strong" title={name}>
{name} {name}
</div> </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} {repo.owner}/{repo.repo}
</div> </div>
</div> </div>
@ -99,22 +112,20 @@ export default function GitProjectsPage() {
header: "Connection", header: "Connection",
cell: ({ row }) => { cell: ({ row }) => {
const conn = row.original.connection; 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", accessorKey: "active",
header: "Status", header: "Status",
cell: ({ row }) => ( cell: ({ row }) => (
<span <Badge variant={row.original.active ? "success" : "outline"}>
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"
}`}
>
{row.original.active ? "Active" : "Inactive"} {row.original.active ? "Active" : "Inactive"}
</span> </Badge>
), ),
}, },
{ {
@ -122,11 +133,15 @@ export default function GitProjectsPage() {
header: "Last Synced", header: "Last Synced",
cell: ({ row }) => { cell: ({ row }) => {
const val = row.original.last_sync_at; const val = row.original.last_sync_at;
if (!val) return "—"; if (!val) return <span className="text-muted">Never</span>;
try { try {
return new Date(val).toLocaleString(); return (
<span className="whitespace-nowrap text-muted">
{new Date(val).toLocaleString()}
</span>
);
} catch { } catch {
return val; return <span className="text-muted">{val}</span>;
} }
}, },
}, },
@ -138,12 +153,16 @@ export default function GitProjectsPage() {
const isSyncing = syncingId === repo.id; const isSyncing = syncingId === repo.id;
return ( return (
<Button <Button
variant="outline" variant="ghost"
size="sm" size="sm"
className="whitespace-nowrap"
onClick={() => handleSync(repo)} onClick={() => handleSync(repo)}
disabled={!!syncingId} disabled={!!syncingId}
> >
{isSyncing ? "Syncing…" : "Sync Issues"} <RefreshCw
className={`h-4 w-4 ${isSyncing ? "animate-spin" : ""}`}
/>
{isSyncing ? "Syncing" : "Sync"}
</Button> </Button>
); );
}, },
@ -161,7 +180,7 @@ export default function GitProjectsPage() {
return ( return (
<DashboardPageLayout <DashboardPageLayout
signedOut={{ signedOut={{
message: "Sign in to view Git projects.", message: "Sign in to view Git Projects.",
forceRedirectUrl: "/git-projects", forceRedirectUrl: "/git-projects",
signUpForceRedirectUrl: "/git-projects", signUpForceRedirectUrl: "/git-projects",
}} }}
@ -170,14 +189,33 @@ export default function GitProjectsPage() {
stickyHeader stickyHeader
> >
{syncResult && ( {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"> <div
<strong>{syncResult.repoName}</strong> synced:{" "} className={`mb-4 flex items-start gap-3 rounded-xl border p-3 text-sm ${
{syncResult.created} created, {syncResult.updated} updated,{" "} syncResult.error
{syncResult.open} open, {syncResult.closed} closed ? "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>
)} )}
<div className="mb-4 flex gap-3"> <div className="mb-4 flex flex-wrap gap-3">
<Link href="/git-projects/connections"> <Link href="/git-projects/connections">
<Button variant="outline" size="sm"> <Button variant="outline" size="sm">
Connections Connections
@ -190,28 +228,21 @@ export default function GitProjectsPage() {
</Link> </Link>
</div> </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 <DataTable
table={table} table={table}
isLoading={loading} isLoading={loading}
emptyState={{ emptyState={{
icon: ( icon: <GitBranch className="h-12 w-12" />,
<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", title: "No repositories tracked yet",
description: 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", actionHref: "/git-projects/connections",
actionLabel: "Set up connection", actionLabel: "Set up connection",
}} }}

View File

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

View File

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

View File

@ -2,11 +2,13 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { AlertCircle, CheckCircle2 } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { useAuth } from "@/auth/clerk"; import { useAuth } from "@/auth/clerk";
import { DashboardPageLayout } from "@/components/templates/DashboardPageLayout"; import { DashboardPageLayout } from "@/components/templates/DashboardPageLayout";
import { ForgejoRepositoriesTable } from "@/components/git/ForgejoRepositoriesTable"; import { ForgejoRepositoriesTable } from "@/components/git/ForgejoRepositoriesTable";
import { ConfirmActionDialog } from "@/components/ui/confirm-action-dialog";
import { import {
getForgejoRepositories, getForgejoRepositories,
deleteForgejoRepository, deleteForgejoRepository,
@ -22,6 +24,15 @@ export default function ForgejoRepositoriesPage() {
const [repositories, setRepositories] = useState<ForgejoRepository[]>([]); const [repositories, setRepositories] = useState<ForgejoRepository[]>([]);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null); 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(() => { useEffect(() => {
const fetchRepositories = async () => { const fetchRepositories = async () => {
@ -31,7 +42,9 @@ export default function ForgejoRepositoriesPage() {
setRepositories(data); setRepositories(data);
setError(null); setError(null);
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : "Failed to load repositories"); setError(
err instanceof Error ? err.message : "Failed to load repositories",
);
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }
@ -42,37 +55,52 @@ export default function ForgejoRepositoriesPage() {
} }
}, [auth.isSignedIn, auth.getToken]); }, [auth.isSignedIn, auth.getToken]);
const handleDelete = async (repository: ForgejoRepository) => { const repositoryName = (repository: ForgejoRepository) =>
if ( repository.display_name || `${repository.owner}/${repository.repo}`;
confirm(`Are you sure you want to delete "${repository.display_name || repository.repo}"? This action cannot be undone.`)
) { const handleDelete = (repository: ForgejoRepository) => {
setDeleteError(null);
setDeleteTarget(repository);
};
const confirmDelete = async () => {
if (!deleteTarget) return;
setIsDeleting(true);
setDeleteError(null);
try { try {
await deleteForgejoRepository(repository.id); await deleteForgejoRepository(deleteTarget.id);
setRepositories((prev) => prev.filter((r) => r.id !== repository.id)); setRepositories((prev) => prev.filter((r) => r.id !== deleteTarget.id));
alert("Repository deleted successfully"); setNotice({
tone: "success",
message: `Deleted "${repositoryName(deleteTarget)}".`,
});
setDeleteTarget(null);
} catch (err) { } catch (err) {
alert(err instanceof Error ? err.message : "Failed to delete repository"); setDeleteError(
} err instanceof Error ? err.message : "Failed to delete repository",
);
} finally {
setIsDeleting(false);
} }
}; };
const handleSync = async (repository: ForgejoRepository) => { const handleSync = async (repository: ForgejoRepository) => {
try { try {
const result = await syncRepository(repository.id); const result = await syncRepository(repository.id);
alert( setNotice({
`Sync completed!\n\n` + tone: "success",
`Created: ${result.created}\n` + message: `${repositoryName(repository)} synced: ${result.created} created, ${result.updated} updated, ${result.open} open, ${result.closed} closed.`,
`Updated: ${result.updated}\n` + });
`Open: ${result.open}\n` +
`Closed: ${result.closed}\n` +
`Total: ${result.total}`
);
// Refetch to update last_sync_at // Refetch to update last_sync_at
const data = await getForgejoRepositories(); const data = await getForgejoRepositories();
setRepositories(data); setRepositories(data);
return result; return result;
} catch (err) { } 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; throw err;
} }
}; };
@ -80,47 +108,70 @@ export default function ForgejoRepositoriesPage() {
const handleValidateRepository = async (repository: ForgejoRepository) => { const handleValidateRepository = async (repository: ForgejoRepository) => {
try { try {
const result = await validateRepository(repository.id); const result = await validateRepository(repository.id);
if (result.ok) { if (result.status.ok) {
alert( setNotice({
`Repository is valid!\n\n` + tone: "success",
`Repository exists: ${result.repo_exists ? "Yes" : "No"}` message: `${repositoryName(repository)} is reachable from Pipeline.`,
); });
} else { } else {
alert( setNotice({
`Repository validation failed: ${result.error_message || "Unknown error"}` tone: "error",
); message: `Repository validation failed: ${result.status.error_message || "Unknown error"}`,
});
} }
return result; return result;
} catch (err) { } 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; throw err;
} }
}; };
return ( return (
<>
<DashboardPageLayout <DashboardPageLayout
signedOut={{ signedOut={{
message: "Sign in to manage tracked repositories.", message: "Sign in to manage Git Project repositories.",
forceRedirectUrl: "/git-projects/repositories", forceRedirectUrl: "/git-projects/repositories",
signUpForceRedirectUrl: "/git-projects/repositories", signUpForceRedirectUrl: "/git-projects/repositories",
}} }}
title="Tracked Repositories" title="Git Project Repositories"
description={`${repositories.length} repository${repositories.length === 1 ? "" : "s"} being tracked.`} description={`${repositories.length} repositor${repositories.length === 1 ? "y" : "ies"} tracked by Pipeline.`}
stickyHeader stickyHeader
> >
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<div className="flex items-center justify-between"> {notice ? (
<h2 className="text-sm font-medium text-slate-500">Repositories</h2> <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>
) : null}
<div className="flex flex-wrap items-center justify-between gap-3">
<h2 className="text-sm font-medium text-muted">Repositories</h2>
<Button <Button
onClick={() => router.push("/git-projects/repositories/new")} onClick={() => router.push("/git-projects/repositories/new")}
> >
Add Repository Add Repository
</Button> </Button>
</div> </div>
<div className="overflow-hidden rounded-xl border border-slate-200 bg-white shadow-sm"> <div className="overflow-hidden rounded-xl border border-[color:var(--border)] bg-[color:var(--surface)] shadow-lush">
{error ? ( {error ? (
<div className="p-8 text-center"> <div className="p-8 text-center">
<p className="text-red-600">{error}</p> <p className="text-sm text-[color:var(--danger)]">{error}</p>
</div> </div>
) : ( ) : (
<ForgejoRepositoriesTable <ForgejoRepositoriesTable
@ -134,5 +185,25 @@ export default function ForgejoRepositoriesPage() {
</div> </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 components;
@tailwind utilities; @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 { :root {
color-scheme: dark; color-scheme: dark;
--bg: #070b12; --bg: #070b12;
@ -160,6 +254,10 @@ body {
); );
background-size: 120px 120px; background-size: 120px 120px;
} }
/* Numbers-only Georgia font utility */
.font-numeric {
font-family: "Georgia Numbers", "Georgia", "Times New Roman", serif;
}
} }
.landing-page { .landing-page {

View File

@ -1,227 +1,357 @@
"use client"; "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 { 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 { 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 = { interface BoardForgejoRepositoryLinksProps {
id: string; boardId: string;
board_id: string; canWrite?: boolean;
repository_id: string; }
organization_id: string;
created_at: string; type LinkedRepository = BoardForgejoRepositoryLink & {
repository: ForgejoRepository; repository: ForgejoRepository;
}; };
type BoardForgejoRepositoryLinksProps = { const normalizeBoardLinks = (
boardId: string; result: BoardForgejoRepositoriesResponse,
canWrite: boolean; ): BoardForgejoRepositoryLink[] =>
}; Array.isArray(result) ? result : (result.repositories ?? []);
const repositoryDisplayName = (repository: ForgejoRepository): string =>
repository.display_name || `${repository.owner}/${repository.repo}`;
export function BoardForgejoRepositoryLinks({ export function BoardForgejoRepositoryLinks({
boardId, boardId,
canWrite, canWrite = false,
}: BoardForgejoRepositoryLinksProps) { }: BoardForgejoRepositoryLinksProps) {
const [linkedRepos, setLinkedRepos] = useState<BoardForgejoRepositoryLink[]>([]); const [linkedLinks, setLinkedLinks] = useState<BoardForgejoRepositoryLink[]>(
[],
);
const [allRepos, setAllRepos] = useState<ForgejoRepository[]>([]); const [allRepos, setAllRepos] = useState<ForgejoRepository[]>([]);
const [searchQuery, setSearchQuery] = useState(""); const [searchTerm, setSearchTerm] = useState("");
const [loading, setLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [isLinking, setIsLinking] = useState(false); const [linkError, setLinkError] = useState<string | null>(null);
const [unlinkRepo, setUnlinkRepo] = useState<string | null>(null);
const [unlinkError, setUnlinkError] = 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 () => { const fetchLinkedRepos = useCallback(async () => {
try { try {
const result = await getBoardForgejoRepositories(boardId); const result = await getBoardForgejoRepositories(boardId);
setLinkedRepos(result.repositories || []); setLinkedLinks(normalizeBoardLinks(result));
setLinkError(null);
} catch (err) { } 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]); }, [boardId]);
const fetchAllRepositories = useCallback(async () => { const fetchAllRepositories = useCallback(async () => {
try { try {
const repos = await getForgejoRepositories(); const repos = await getForgejoRepositories();
setAllRepos(repos); setAllRepos(repos);
setLinkError(null);
} catch (err) { } 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(() => { useEffect(() => {
setLoading(true); let isMounted = true;
Promise.all([fetchLinkedRepos(), fetchAllRepositories()]).finally(() => {
setLoading(false);
});
}, [boardId, fetchLinkedRepos, fetchAllRepositories]);
const loadRepositories = async () => {
setIsLoading(true);
await Promise.all([fetchLinkedRepos(), fetchAllRepositories()]);
if (isMounted) {
setIsLoading(false);
}
};
const filteredRepos = useMemo(() => { loadRepositories();
if (!searchQuery) return allRepos;
const query = searchQuery.toLowerCase(); return () => {
return allRepos.filter( isMounted = false;
(r) => };
(r.display_name && r.display_name.toLowerCase().includes(query)) || }, [fetchAllRepositories, fetchLinkedRepos]);
r.owner.toLowerCase().includes(query) ||
r.repo.toLowerCase().includes(query), const linkedRepoIds = useMemo(
() => new Set(linkedLinks.map((link) => link.repository_id)),
[linkedLinks],
); );
}, [allRepos, searchQuery]);
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) => { const handleLinkRepo = async (repositoryId: string) => {
if (!canWrite) return; if (!canWrite) {
return;
}
setIsLinking(true); setIsLinking(true);
setLinkError(null);
try { try {
await linkBoardForgejoRepository(boardId, repositoryId); await linkBoardForgejoRepository(boardId, repositoryId);
await fetchLinkedRepos(); await fetchLinkedRepos();
setSearchQuery("");
} catch (err) { } 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 { } finally {
setIsLinking(false); setIsLinking(false);
} }
}; };
const handleUnlinkRepo = async () => { const handleUnlinkRepo = async () => {
if (!unlinkRepo) return; if (!unlinkTarget || !canWrite) {
setIsDialogOpen(false); return;
}
setIsUnlinking(true);
setUnlinkError(null); setUnlinkError(null);
try { try {
await unlinkBoardForgejoRepository(boardId, unlinkRepo); await unlinkBoardForgejoRepository(boardId, unlinkTarget.repository_id);
await fetchLinkedRepos(); await fetchLinkedRepos();
setUnlinkTarget(null);
} catch (err) { } 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); setUnlinkError(message);
} finally {
setIsUnlinking(false);
} }
}; };
const linkedRepoIds = useMemo(() => new Set(linkedRepos.map((l) => l.repository_id)), [linkedRepos]);
return ( 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"> <section className="rounded-xl border border-[color:var(--border)] bg-[color:var(--surface)] p-4 shadow-lush sm:p-5">
<h3 className="text-lg font-semibold text-slate-900 dark:text-slate-100"> <div className="mb-4 flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
Linked Repositories <div className="min-w-0">
<h3 className="text-sm font-semibold text-strong">
Linked Git Project Repositories
</h3> </h3>
<p className="mt-1 text-sm text-slate-500"> <p className="mt-1 text-sm text-muted">
{linkedRepos.length} repository{linkedRepos.length === 1 ? "" : "s"} linked to this board Choose which synced repositories appear on this Pipeline board.
</p> </p>
</div> </div>
<Badge variant="outline" className="w-fit">
{linkedRepos.length} linked
</Badge>
</div>
<div className="mb-6 flex flex-wrap items-center gap-3"> {linkError && (
<Input <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)]">
placeholder="Search repositories…" <AlertCircle className="mt-0.5 h-4 w-4 shrink-0" />
value={searchQuery} <span>{linkError}</span>
onChange={(e) => setSearchQuery(e.target.value)} </div>
className="w-[240px]"
/>
{canWrite && (
<Button
variant="outline"
size="sm"
onClick={() => setIsDialogOpen(true)}
disabled={isLinking}
>
Link Repository
</Button>
)} )}
</div>
{loading ? ( {isLoading ? (
<div className="py-8 text-center text-sm text-slate-500">Loading</div> <div className="rounded-lg border border-[color:var(--border)] bg-[color:var(--surface-muted)] px-4 py-6 text-center text-sm text-muted">
) : linkedRepos.length === 0 && allRepos.length === 0 ? ( <Loader2 className="mx-auto mb-2 h-4 w-4 animate-spin" />
<div className="py-8 text-center"> Loading Git Project repositories...
<p className="text-sm text-slate-500">
No repositories found. Configure Forgejo connections in Git Projects to start tracking repositories.
</p>
</div> </div>
) : linkedRepos.length === 0 ? ( ) : (
<div className="py-8 text-center"> <div className="space-y-5">
<p className="text-sm text-slate-500"> <div>
No repositories linked to this board. Link a repository to track its issues. <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> </p>
</div> </div>
) : ( ) : (
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3"> <div className="grid gap-2 sm:grid-cols-2">
{linkedRepos.map((link) => ( {linkedRepos.map((link) => {
const repository = link.repository;
return (
<div <div
key={link.id} key={link.id}
className="rounded-lg border border-slate-200 bg-slate-50 p-4 dark:border-slate-700 dark:bg-slate-800/50" 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="flex items-start justify-between"> <div className="min-w-0">
<div> <p
<div className="font-medium text-slate-900 dark:text-slate-100"> className="truncate text-sm font-medium text-strong"
{link.repository.display_name || `${link.repository.owner}/${link.repository.repo}`} title={repositoryDisplayName(repository)}
>
{repositoryDisplayName(repository)}
</p>
<p
className="truncate text-xs text-muted"
title={`${repository.owner}/${repository.repo}`}
>
{repository.owner}/{repository.repo}
</p>
</div> </div>
<div className="mt-1 text-xs text-slate-500"> {canWrite ? (
Last sync: {link.repository.last_sync_at ? new Date(link.repository.last_sync_at).toLocaleDateString() : "Never"}
</div>
</div>
{canWrite && (
<Button <Button
type="button"
variant="ghost" variant="ghost"
size="sm" size="sm"
className="h-8 w-8 shrink-0 p-0 text-[color:var(--danger)] hover:bg-[color:var(--danger-soft)]"
onClick={() => { onClick={() => {
setUnlinkRepo(link.repository_id); setUnlinkError(null);
setIsDialogOpen(true); setUnlinkTarget(link);
}} }}
className="h-6 w-6 p-0 text-rose-500 hover:bg-rose-50 hover:text-rose-600" aria-label={`Unlink ${repositoryDisplayName(repository)}`}
title="Unlink repository"
> >
<svg <X className="h-4 w-4" />
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> </Button>
)} ) : null}
</div> </div>
</div> );
))} })}
</div> </div>
)} )}
</div>
{canWrite && ( {canWrite ? (
<> <div>
<div className="mt-6 border-t border-slate-200 pt-6 dark:border-slate-700"> <div className="mb-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<h4 className="mb-3 text-sm font-medium text-slate-900 dark:text-slate-100"> <div className="min-w-0">
<div className="text-xs font-semibold uppercase tracking-[0.14em] text-muted">
Available Repositories 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>
<div className="mt-2 flex items-center gap-2"> <p className="mt-1 text-sm text-muted">
<Badge variant="outline" className="text-xs"> Link repositories that are already configured in Git
{repo.active ? "Active" : "Inactive"} 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> </Badge>
{repo.last_sync_at && ( </div>
<span className="text-xs text-slate-500"> <p
Synced {new Date(repo.last_sync_at).toLocaleDateString()} className="mt-1 truncate text-xs text-muted"
</span> title={`${repository.owner}/${repository.repo}`}
)} >
{repository.owner}/{repository.repo}
</p>
</div> </div>
<Button <Button
type="button"
variant="outline" variant="outline"
size="sm" size="sm"
className="mt-3 w-full text-xs" className="w-full shrink-0 sm:w-auto"
onClick={() => handleLinkRepo(repo.id)} onClick={() => handleLinkRepo(repository.id)}
disabled={isLinking} disabled={isLinking}
> >
Link Link
@ -229,33 +359,35 @@ export function BoardForgejoRepositoryLinks({
</div> </div>
))} ))}
</div> </div>
</div>
</>
)} )}
</div>
) : null}
</div>
)}
</section>
<ConfirmActionDialog <ConfirmActionDialog
open={isDialogOpen} open={unlinkTarget !== null}
onOpenChange={setIsDialogOpen} onOpenChange={(open) => {
title={ if (!open && !isUnlinking) {
unlinkRepo setUnlinkTarget(null);
? "Unlink Repository" setUnlinkError(null);
: "Link Repository"
} }
}}
title="Unlink Git Project repository"
description={ description={
unlinkRepo unlinkTarget
? unlinkError ? `Remove "${repositoryDisplayName(unlinkTarget.repository)}" from this board? Issues from this repository will no longer appear on the board.`
? `Error: ${unlinkError}` : "Remove this repository from the board?"
: "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."
} }
onConfirm={ onConfirm={handleUnlinkRepo}
unlinkRepo ? handleUnlinkRepo : () => setIsDialogOpen(false) isConfirming={isUnlinking}
} errorMessage={unlinkError}
isConfirming={isLinking || (!!unlinkRepo && unlinkError !== null)} confirmLabel="Unlink Repository"
cancelLabel={unlinkRepo ? "Keep Linked" : "Cancel"} confirmingLabel="Unlinking..."
confirmLabel={unlinkRepo ? "Unlink" : undefined} confirmClassName="bg-[color:var(--danger)] text-white hover:bg-[color:var(--danger)]/90"
errorStyle="panel" cancelLabel="Keep Linked"
/> />
</div> </>
); );
} }

View File

@ -2,7 +2,14 @@
import { useState } from "react"; 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 { Button } from "@/components/ui/button";
import type { ForgejoIssue } from "@/lib/api-forgejo"; import type { ForgejoIssue } from "@/lib/api-forgejo";
import { closeForgejoIssue } from "@/lib/api-forgejo"; import { closeForgejoIssue } from "@/lib/api-forgejo";
@ -33,7 +40,8 @@ export function CloseForgejoIssueDialog({
onCloseSuccess(); onCloseSuccess();
onOpenChange(false); onOpenChange(false);
} catch (err) { } 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); setError(message);
} finally { } finally {
setIsClosing(false); setIsClosing(false);
@ -42,25 +50,47 @@ export function CloseForgejoIssueDialog({
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent> <DialogContent className="max-w-lg">
<DialogHeader> <DialogHeader>
<DialogTitle>Close Issue</DialogTitle> <DialogTitle>Close Git Project issue</DialogTitle>
<DialogDescription> <DialogDescription>
Are you sure you want to close issue{" "} Pipeline will mark issue{" "}
<span className="font-mono font-semibold">#{issue.forgejo_issue_number}</span> in{" "} <span className="font-mono font-semibold text-strong">
<span className="font-mono font-semibold">{issue.repository_id}</span>? #{issue.forgejo_issue_number}
</span>{" "}
as closed in the connected Git provider and refresh the local issue
cache.
</DialogDescription> </DialogDescription>
</DialogHeader> </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 && ( {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} {error}
</div> </div>
)} )}
<DialogFooter> <DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}> <Button
variant="outline"
onClick={() => onOpenChange(false)}
disabled={isClosing}
>
Cancel Cancel
</Button> </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"} {isClosing ? "Closing…" : "Close Issue"}
</Button> </Button>
</DialogFooter> </DialogFooter>

View File

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

View File

@ -11,20 +11,24 @@ import {
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Loader2, CheckCircle2 } from "lucide-react"; import { CheckCircle2, GitBranch, Loader2 } from "lucide-react";
import { DataTable } from "@/components/tables/DataTable"; import { DataTable } from "@/components/tables/DataTable";
import DropdownSelect from "@/components/ui/dropdown-select";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import type { ForgejoConnection } from "@/lib/api-forgejo"; import type {
ForgejoConnection,
ForgejoConnectionValidationResponse,
} from "@/lib/api-forgejo";
interface ConnectionsTableProps { interface ConnectionsTableProps {
connections: ForgejoConnection[]; connections: ForgejoConnection[];
isLoading: boolean; isLoading: boolean;
onEdit?: (connection: ForgejoConnection) => void; onEdit?: (connection: ForgejoConnection) => void;
onDelete?: (connection: ForgejoConnection) => void; onDelete?: (connection: ForgejoConnection) => void;
onValidate?: (connection: ForgejoConnection) => void; onValidate?: (
connection: ForgejoConnection,
) => Promise<ForgejoConnectionValidationResponse>;
} }
export function ForgejoConnectionsTable({ export function ForgejoConnectionsTable({
@ -47,23 +51,10 @@ export function ForgejoConnectionsTable({
table={table} table={table}
isLoading={isLoading} isLoading={isLoading}
emptyState={{ emptyState={{
icon: ( icon: <GitBranch className="h-12 w-12" />,
<svg title: "No Git Project connections yet",
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",
description: 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", actionHref: "/git-projects/connections/new",
actionLabel: "Add connection", actionLabel: "Add connection",
}} }}
@ -71,12 +62,15 @@ export function ForgejoConnectionsTable({
getEditHref: (row) => `/git-projects/connections/${row.id}/edit`, getEditHref: (row) => `/git-projects/connections/${row.id}/edit`,
onDelete: onDelete ?? undefined, onDelete: onDelete ?? undefined,
}} }}
tableClassName="min-w-[680px] w-full text-left text-sm"
/> />
); );
} }
const columns = ( const columns = (
onValidate?: (connection: ForgejoConnection) => void onValidate?: (
connection: ForgejoConnection,
) => Promise<ForgejoConnectionValidationResponse>,
): ColumnDef<ForgejoConnection>[] => [ ): ColumnDef<ForgejoConnection>[] => [
{ {
accessorKey: "name", accessorKey: "name",
@ -85,7 +79,7 @@ const columns = (
<Button <Button
variant="ghost" variant="ghost"
onClick={() => column.toggleSorting(false)} 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 Name
{column.getIsSorted() === "asc" && "↑"} {column.getIsSorted() === "asc" && "↑"}
@ -94,9 +88,13 @@ const columns = (
); );
}, },
cell: ({ row }) => ( cell: ({ row }) => (
<div className="flex flex-col"> <div className="min-w-0">
<span className="font-medium text-slate-900">{row.original.name}</span> <span className="block truncate font-medium text-strong">
<span className="text-xs text-slate-500">{row.original.base_url}</span> {row.original.name}
</span>
<span className="block truncate text-xs text-muted">
{row.original.base_url}
</span>
</div> </div>
), ),
}, },
@ -124,7 +122,9 @@ const columns = (
{hasToken ? "Configured" : "Missing"} {hasToken ? "Configured" : "Missing"}
</Badge> </Badge>
{tokenLastEight && hasToken && ( {tokenLastEight && hasToken && (
<span className="text-xs text-slate-500 font-mono">{tokenLastEight}</span> <span className="font-mono text-xs text-muted">
{tokenLastEight}
</span>
)} )}
</div> </div>
); );
@ -132,7 +132,9 @@ const columns = (
}, },
{ {
id: "actions", 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, onValidate,
}: { }: {
connection: ForgejoConnection; connection: ForgejoConnection;
onValidate?: (connection: ForgejoConnection) => void; onValidate?: (
connection: ForgejoConnection,
) => Promise<ForgejoConnectionValidationResponse>;
}) { }) {
const [isValidateLoading, setIsValidateLoading] = useState(false); const [isValidateLoading, setIsValidateLoading] = useState(false);
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const [validateResult, setValidateResult] = useState<{ const [validateResult, setValidateResult] = useState<{
ok: boolean; ok: boolean;
error_message?: string; error_message?: string;
@ -155,41 +158,17 @@ function ActionsCell({
if (!onValidate) return; if (!onValidate) return;
setIsValidateLoading(true); setIsValidateLoading(true);
try { 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 { } finally {
setIsValidateLoading(false); 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 ( return (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{onValidate && ( {onValidate && (
@ -203,18 +182,12 @@ function ActionsCell({
{isValidateLoading ? ( {isValidateLoading ? (
<Loader2 className="h-4 w-4 animate-spin" /> <Loader2 className="h-4 w-4 animate-spin" />
) : validateResult?.ok ? ( ) : 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" /> <CheckCircle2 className="h-4 w-4" />
)} )}
</Button> </Button>
)} )}
<DropdownSelect
ariaLabel="Connection actions"
options={options}
onValueChange={handleSelect}
triggerClassName="h-8 w-8 p-0"
/>
</div> </div>
); );
} }
@ -246,7 +219,7 @@ export function ConnectionsTableToggle({
return ( return (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<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 <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
@ -277,7 +250,9 @@ export function ConnectionsTableToggle({
onClick={() => column.toggleVisibility(!column.getIsVisible())} onClick={() => column.toggleVisibility(!column.getIsVisible())}
className={cn( className={cn(
"h-8 px-2 py-1 text-xs", "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} {column.id}

View File

@ -1,8 +1,12 @@
"use client"; "use client";
import { useState, useEffect } from "react"; import {
Select,
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import type { ForgejoRepository } from "@/lib/api-forgejo"; import type { ForgejoRepository } from "@/lib/api-forgejo";
@ -26,9 +30,9 @@ export function ForgejoIssueFilters({
repos, repos,
}: ForgejoIssueFiltersProps) { }: ForgejoIssueFiltersProps) {
return ( return (
<div className="mb-4 flex flex-wrap gap-3"> <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={(v) => { onStateChange(v); }}> <Select value={stateFilter} onValueChange={onStateChange}>
<SelectTrigger className="w-[120px]"> <SelectTrigger>
<SelectValue placeholder="State" /> <SelectValue placeholder="State" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
@ -38,8 +42,8 @@ export function ForgejoIssueFilters({
</SelectContent> </SelectContent>
</Select> </Select>
<Select value={repoFilter} onValueChange={(v) => { onRepoChange(v); }}> <Select value={repoFilter} onValueChange={onRepoChange}>
<SelectTrigger className="w-[200px]"> <SelectTrigger>
<SelectValue placeholder="Repository" /> <SelectValue placeholder="Repository" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
@ -53,10 +57,10 @@ export function ForgejoIssueFilters({
</Select> </Select>
<Input <Input
placeholder="Search issues…" placeholder="Search Git Project issues…"
value={search} value={search}
onChange={(e) => onSearchChange(e.target.value)} onChange={(e) => onSearchChange(e.target.value)}
className="w-[240px]" className="min-w-0"
/> />
</div> </div>
); );

View File

@ -1,9 +1,13 @@
"use client"; "use client";
import { useMemo, useState } from "react"; import { useCallback, useMemo, useState } from "react";
import { type ColumnDef, getCoreRowModel, useReactTable } from "@tanstack/react-table"; import {
import { XCircle } from "lucide-react"; type ColumnDef,
getCoreRowModel,
useReactTable,
} from "@tanstack/react-table";
import { CircleDot, ExternalLink, XCircle } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
@ -13,17 +17,22 @@ import type { ForgejoIssue } from "@/lib/api-forgejo";
export type ForgejoIssuesTableProps = { export type ForgejoIssuesTableProps = {
issues: ForgejoIssue[]; issues: ForgejoIssue[];
isLoading?: boolean;
onRefresh: () => void; onRefresh: () => void;
}; };
export function ForgejoIssuesTable({ issues, onRefresh }: ForgejoIssuesTableProps) { export function ForgejoIssuesTable({
issues,
isLoading = false,
onRefresh,
}: ForgejoIssuesTableProps) {
const [closeIssueDialogOpen, setCloseIssueDialogOpen] = useState(false); const [closeIssueDialogOpen, setCloseIssueDialogOpen] = useState(false);
const [issueToClose, setIssueToClose] = useState<ForgejoIssue | null>(null); const [issueToClose, setIssueToClose] = useState<ForgejoIssue | null>(null);
const handleCloseClick = (issue: ForgejoIssue) => { const handleCloseClick = useCallback((issue: ForgejoIssue) => {
setIssueToClose(issue); setIssueToClose(issue);
setCloseIssueDialogOpen(true); setCloseIssueDialogOpen(true);
}; }, []);
const handleCloseSuccess = () => { const handleCloseSuccess = () => {
onRefresh(); onRefresh();
@ -39,9 +48,10 @@ export function ForgejoIssuesTable({ issues, onRefresh }: ForgejoIssuesTableProp
href={row.original.html_url} href={row.original.html_url}
target="_blank" target="_blank"
rel="noopener noreferrer" 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} #{row.original.forgejo_issue_number}
<ExternalLink className="h-3 w-3" />
</a> </a>
), ),
}, },
@ -49,7 +59,12 @@ export function ForgejoIssuesTable({ issues, onRefresh }: ForgejoIssuesTableProp
accessorKey: "title", accessorKey: "title",
header: "Title", header: "Title",
cell: ({ row }) => ( 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; const body = row.original.body_preview;
if (!body) return null; if (!body) return null;
const truncated = body.length > 120 ? body.slice(0, 120) + "…" : body; 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 }) => { cell: ({ row }) => {
const state = row.original.state; const state = row.original.state;
return ( return (
<Badge <Badge variant={state === "open" ? "success" : "default"}>
variant={state === "open" ? "success" : "default"}
className={state === "open" ? "" : ""}
>
{state} {state}
</Badge> </Badge>
); );
@ -80,6 +96,11 @@ export function ForgejoIssuesTable({ issues, onRefresh }: ForgejoIssuesTableProp
{ {
accessorKey: "author", accessorKey: "author",
header: "Author", header: "Author",
cell: ({ row }) => (
<span className="block max-w-[160px] truncate text-muted">
{row.original.author || "Unknown"}
</span>
),
}, },
{ {
accessorKey: "labels", accessorKey: "labels",
@ -88,8 +109,10 @@ export function ForgejoIssuesTable({ issues, onRefresh }: ForgejoIssuesTableProp
const labels = row.original.labels; const labels = row.original.labels;
if (!labels || labels.length === 0) return null; if (!labels || labels.length === 0) return null;
return ( return (
<div className="flex flex-wrap gap-1"> <div className="flex max-w-[220px] flex-wrap gap-1">
{labels.slice(0, 3).map((label: Record<string, unknown>, i: number) => ( {labels
.slice(0, 3)
.map((label: Record<string, unknown>, i: number) => (
<Badge <Badge
key={i} key={i}
variant="outline" variant="outline"
@ -104,7 +127,7 @@ export function ForgejoIssuesTable({ issues, onRefresh }: ForgejoIssuesTableProp
</Badge> </Badge>
))} ))}
{labels.length > 3 && ( {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> </div>
); );
@ -115,9 +138,17 @@ export function ForgejoIssuesTable({ issues, onRefresh }: ForgejoIssuesTableProp
header: "Updated", header: "Updated",
cell: ({ row }) => { cell: ({ row }) => {
try { 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 { } 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 <Button
variant="ghost" variant="ghost"
size="sm" 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)} onClick={() => handleCloseClick(issue)}
title="Close issue" title="Close issue"
> >
<XCircle className="h-4 w-4 text-rose-500" /> <XCircle className="h-4 w-4" />
</Button> </Button>
); );
}, },
}, },
], ],
[], [handleCloseClick],
); );
const table = useReactTable({
return (
<>
<DataTable
table={useReactTable({
data: issues, data: issues,
columns, columns,
getCoreRowModel: getCoreRowModel(), getCoreRowModel: getCoreRowModel(),
})} });
isLoading={false}
return (
<>
<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={{ emptyState={{
icon: ( icon: <CircleDot className="h-12 w-12" />,
<svg title: "No Git Project issues found",
className="h-16 w-16 text-slate-300" description:
viewBox="0 0 24 24" "Sync a repository to pull issues into Pipeline, or adjust your filters.",
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>
<CloseForgejoIssueDialog <CloseForgejoIssueDialog
issue={issueToClose} issue={issueToClose}
open={closeIssueDialogOpen} open={closeIssueDialogOpen}

View File

@ -11,20 +11,32 @@ import {
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Loader2, CheckCircle2 } from "lucide-react"; import { CheckCircle2, GitBranch, Loader2, RefreshCw } from "lucide-react";
import { DataTable } from "@/components/tables/DataTable"; import { DataTable } from "@/components/tables/DataTable";
import DropdownSelect from "@/components/ui/dropdown-select";
import { cn } from "@/lib/utils"; 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 { interface RepositoriesTableProps {
repositories: ForgejoRepository[]; repositories: ForgejoRepository[];
isLoading: boolean; isLoading: boolean;
onEdit?: (repository: ForgejoRepository) => void; onEdit?: (repository: ForgejoRepository) => void;
onDelete?: (repository: ForgejoRepository) => void; onDelete?: (repository: ForgejoRepository) => void;
onSync?: (repository: ForgejoRepository) => void; onSync?: (repository: ForgejoRepository) => Promise<RepositorySyncResult>;
onValidate?: (repository: ForgejoRepository) => void; onValidate?: (
repository: ForgejoRepository,
) => Promise<ForgejoRepositoryValidationResponse>;
} }
export function ForgejoRepositoriesTable({ export function ForgejoRepositoriesTable({
@ -48,23 +60,10 @@ export function ForgejoRepositoriesTable({
table={table} table={table}
isLoading={isLoading} isLoading={isLoading}
emptyState={{ emptyState={{
icon: ( icon: <GitBranch className="h-12 w-12" />,
<svg title: "No Git Project repositories yet",
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",
description: 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", actionHref: "/git-projects/repositories/new",
actionLabel: "Add repository", actionLabel: "Add repository",
}} }}
@ -72,13 +71,16 @@ export function ForgejoRepositoriesTable({
getEditHref: (row) => `/git-projects/repositories/${row.id}/edit`, getEditHref: (row) => `/git-projects/repositories/${row.id}/edit`,
onDelete: onDelete ?? undefined, onDelete: onDelete ?? undefined,
}} }}
tableClassName="min-w-[860px] w-full text-left text-sm"
/> />
); );
} }
const columns = ( const columns = (
onSync?: (repository: ForgejoRepository) => void, onSync?: (repository: ForgejoRepository) => Promise<RepositorySyncResult>,
onValidate?: (repository: ForgejoRepository) => void onValidate?: (
repository: ForgejoRepository,
) => Promise<ForgejoRepositoryValidationResponse>,
): ColumnDef<ForgejoRepository>[] => [ ): ColumnDef<ForgejoRepository>[] => [
{ {
accessorKey: "displayName", accessorKey: "displayName",
@ -87,7 +89,7 @@ const columns = (
<Button <Button
variant="ghost" variant="ghost"
onClick={() => column.toggleSorting(false)} 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 Repository
{column.getIsSorted() === "asc" && "↑"} {column.getIsSorted() === "asc" && "↑"}
@ -98,9 +100,11 @@ const columns = (
cell: ({ row }) => { cell: ({ row }) => {
const repo = row.original; const repo = row.original;
return ( return (
<div className="flex flex-col"> <div className="min-w-0">
<span className="font-medium text-slate-900">{repo.display_name || `${repo.owner}/${repo.repo}`}</span> <span className="block truncate font-medium text-strong">
<span className="text-xs text-slate-500"> {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} {repo.owner}/{repo.repo} {repo.connection?.name}
</span> </span>
</div> </div>
@ -113,9 +117,13 @@ const columns = (
cell: ({ row }) => { cell: ({ row }) => {
const connection = row.original.connection; const connection = row.original.connection;
return ( return (
<div className="flex flex-col"> <div className="min-w-0">
<span className="text-sm text-slate-700">{connection?.name}</span> <span className="block truncate text-sm text-strong">
<span className="text-xs text-slate-500">{connection?.base_url}</span> {connection?.name}
</span>
<span className="block truncate text-xs text-muted">
{connection?.base_url}
</span>
</div> </div>
); );
}, },
@ -140,20 +148,23 @@ const columns = (
const lastSyncError = row.original.last_sync_error; const lastSyncError = row.original.last_sync_error;
if (!lastSyncAt) { 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 date = new Date(lastSyncAt);
const isRecent = new Date().getTime() - date.getTime() < 24 * 60 * 60 * 1000; // Within 24 hours
return ( return (
<div className="flex flex-col"> <div className="flex flex-col">
<span className={`text-sm ${isRecent ? "text-green-600" : "text-slate-700"}`}> <span className="text-sm text-strong">
{date.toLocaleDateString()} {date.toLocaleDateString()}
</span> </span>
<span className="text-xs text-slate-500">{date.toLocaleTimeString()}</span> <span className="text-xs text-muted">
{date.toLocaleTimeString()}
</span>
{lastSyncError && ( {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> </div>
); );
@ -161,7 +172,13 @@ const columns = (
}, },
{ {
id: "actions", 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, onValidate,
}: { }: {
repository: ForgejoRepository; repository: ForgejoRepository;
onSync?: (repository: ForgejoRepository) => void; onSync?: (repository: ForgejoRepository) => Promise<RepositorySyncResult>;
onValidate?: (repository: ForgejoRepository) => void; onValidate?: (
repository: ForgejoRepository,
) => Promise<ForgejoRepositoryValidationResponse>;
}) { }) {
const [isSyncLoading, setIsSyncLoading] = useState(false); const [isSyncLoading, setIsSyncLoading] = useState(false);
const [isValidateLoading, setIsValidateLoading] = useState(false); const [isValidateLoading, setIsValidateLoading] = useState(false);
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const [syncResult, setSyncResult] = useState<{ const [syncResult, setSyncResult] = useState<{
created: number; created: number;
updated: number; updated: number;
@ -184,7 +202,6 @@ function ActionsCell({
closed: number; closed: number;
total: number; total: number;
} | null>(null); } | null>(null);
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const [validateResult, setValidateResult] = useState<{ const [validateResult, setValidateResult] = useState<{
ok: boolean; ok: boolean;
repo_exists?: boolean; repo_exists?: boolean;
@ -195,7 +212,8 @@ function ActionsCell({
if (!onSync) return; if (!onSync) return;
setIsSyncLoading(true); setIsSyncLoading(true);
try { try {
await onSync(repository); const result = await onSync(repository);
setSyncResult(result);
} finally { } finally {
setIsSyncLoading(false); setIsSyncLoading(false);
} }
@ -205,41 +223,17 @@ function ActionsCell({
if (!onValidate) return; if (!onValidate) return;
setIsValidateLoading(true); setIsValidateLoading(true);
try { 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 { } finally {
setIsValidateLoading(false); 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 ( return (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{onSync && ( {onSync && (
@ -253,35 +247,9 @@ function ActionsCell({
{isSyncLoading ? ( {isSyncLoading ? (
<Loader2 className="h-4 w-4 animate-spin" /> <Loader2 className="h-4 w-4 animate-spin" />
) : syncResult ? ( ) : syncResult ? (
<svg <CheckCircle2 className="h-4 w-4 text-[color:var(--success)]" />
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>
) : ( ) : (
<svg <RefreshCw className="h-4 w-4" />
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>
)} )}
</Button> </Button>
)} )}
@ -296,18 +264,12 @@ function ActionsCell({
{isValidateLoading ? ( {isValidateLoading ? (
<Loader2 className="h-4 w-4 animate-spin" /> <Loader2 className="h-4 w-4 animate-spin" />
) : validateResult?.ok ? ( ) : 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" /> <CheckCircle2 className="h-4 w-4" />
)} )}
</Button> </Button>
)} )}
<DropdownSelect
ariaLabel="Repository actions"
options={options}
onValueChange={handleSelect}
triggerClassName="h-8 w-8 p-0"
/>
</div> </div>
); );
} }
@ -323,10 +285,10 @@ export function RepositoriesTableFilter({
return ( return (
<input <input
type="text" type="text"
placeholder="Filter repositories..." placeholder="Filter repositories"
value={value ?? ""} value={value ?? ""}
onChange={(e) => onChange(e.target.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 ( return (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<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 <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
@ -371,7 +333,9 @@ export function RepositoriesTableToggle({
onClick={() => column.toggleVisibility(!column.getIsVisible())} onClick={() => column.toggleVisibility(!column.getIsVisible())}
className={cn( className={cn(
"h-8 px-2 py-1 text-xs", "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} {column.id}

View File

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

View File

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

View File

@ -40,14 +40,14 @@ export function linkifyCell({
<Link href={href} title={title} className={cn("group block", className)}> <Link href={href} title={title} className={cn("group block", className)}>
<p <p
className={cn( 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, labelClassName,
)} )}
> >
{label} {label}
</p> </p>
{subtitle != null ? ( {subtitle != null ? (
<p className={cn("text-xs text-slate-500", subtitleClassName)}> <p className={cn("text-xs text-muted", subtitleClassName)}>
{subtitle} {subtitle}
</p> </p>
) : null} ) : null}
@ -60,7 +60,7 @@ export function linkifyCell({
href={href} href={href}
title={title} title={title}
className={cn( 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, className,
)} )}
> >
@ -82,7 +82,7 @@ export function dateCell(
) { ) {
const display = relative ? formatRelative(value) : formatTimestamp(value); const display = relative ? formatRelative(value) : formatTimestamp(value);
return ( return (
<span className={cn("text-sm text-slate-700", className)}> <span className={cn("text-sm text-muted", className)}>
{display ?? fallback} {display ?? fallback}
</span> </span>
); );

View File

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

View File

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

View File

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