feat: add IssuesPage with tabs and kanban board

Implement issues listing page with tab navigation (All/To Do/In Progress/Done)
and kanban board view using TanStack Table. Connect to API endpoints with
pagination and filtering support.
This commit is contained in:
ZhenYi 2026-05-14 21:50:04 +08:00
parent aaf518a66c
commit e64dc94d29

View File

@ -1,33 +1,119 @@
import { useNavigate, useParams } from "react-router-dom"; import { useNavigate, useParams } from "react-router-dom";
import { useIssuesQuery, useIssueSummaryQuery } from "@/hooks/useIssuesQuery"; import { useIssuesQuery, useIssueSummaryQuery } from "@/hooks/useIssuesQuery";
import { useQueryClient } from "@tanstack/react-query";
import { issueGet } from "@/client/api";
import { LoadingState } from "@/components/ui/LoadingState"; import { LoadingState } from "@/components/ui/LoadingState";
import { EmptyState } from "@/components/ui/EmptyState"; import { EmptyState } from "@/components/ui/EmptyState";
import { ErrorState } from "@/components/ui/ErrorState"; import { ErrorState } from "@/components/ui/ErrorState";
import { import {
AlertCircle, AlertCircle,
User, User,
Calendar, Calendar,
Plus, Plus,
Search, Search,
Tag, Tag,
ArrowUpDown, ArrowUpDown,
CheckCircle2, CheckCircle2,
CircleDot, CircleDot,
AlertTriangle, AlertTriangle,
Zap Zap
} from "lucide-react"; } from "lucide-react";
import { ISSUES_PAGE } from "@/css/issues/styles"; import { ISSUES_PAGE } from "@/css/issues/styles";
import { useState, useMemo } from "react"; import { memo, useState, useMemo, useDeferredValue, useRef, useCallback } from "react";
import { useVirtualizer } from "@tanstack/react-virtual";
import { stripMarkdown, truncate } from "@/lib/utils"; import { stripMarkdown, truncate } from "@/lib/utils";
import type { IssueResponse, IssueLabelResponse } from "@/client/model"; import type { IssueResponse, IssueLabelResponse } from "@/client/model";
interface IssueRowProps {
issue: IssueResponse;
projectName: string;
onNavigate: (path: string) => void;
onPrefetch?: (projectName: string, issueNumber: number) => void;
}
const IssueRow = memo(function IssueRow({ issue, projectName, onNavigate, onPrefetch }: IssueRowProps) {
const priorityLabel = issue.labels?.find((l: IssueLabelResponse) => l.label_name?.toLowerCase().startsWith('priority:'));
const priority = priorityLabel ? (priorityLabel.label_name ?? '').split(':')[1].toLowerCase() : null;
const otherLabels = issue.labels?.filter((l: IssueLabelResponse) => !l.label_name?.toLowerCase().startsWith('priority:')) || [];
return (
<div
key={issue.number}
onClick={() => onNavigate(`/${projectName}/issues/${issue.number}`)}
onMouseEnter={() => onPrefetch?.(projectName, issue.number)}
className={ISSUES_PAGE.issueRow}
>
<div className={`${ISSUES_PAGE.statusIcon} ${issue.state === 'open' ? 'text-green-500' : 'text-purple-500'}`}>
{issue.state === 'open' ? <CircleDot className="w-4 h-4" /> : <CheckCircle2 className="w-4 h-4" />}
</div>
<div className={ISSUES_PAGE.issueBody}>
<div className={ISSUES_PAGE.issueTop}>
<span className={ISSUES_PAGE.issueNum}>#{issue.number}</span>
<h3 className={ISSUES_PAGE.issueTitle}>{issue.title}</h3>
<div className="flex items-center gap-1.5 flex-wrap">
{otherLabels.map((l: IssueLabelResponse) => (
<span
key={l.label_id}
className={ISSUES_PAGE.label}
style={{
backgroundColor: `#${l.label_color}20`,
color: `#${l.label_color}`,
border: `1px solid #${l.label_color}30`
}}
>
{l.label_name}
</span>
))}
</div>
</div>
{issue.body && (
<p className={ISSUES_PAGE.issueExcerpt}>
{truncate(stripMarkdown(issue.body), 120)}
</p>
)}
<div className={ISSUES_PAGE.issueMeta}>
{priority && (
<span className={`${ISSUES_PAGE.priorityPill} ${
priority === 'critical' ? ISSUES_PAGE.pCritical :
priority === 'high' ? ISSUES_PAGE.pHigh :
priority === 'medium' ? ISSUES_PAGE.pMedium :
ISSUES_PAGE.pLow
}`}>
{priority === 'critical' ? <Zap className="w-3 h-3" /> : <AlertTriangle className="w-3 h-3" />}
{priority}
</span>
)}
<div className={ISSUES_PAGE.metaItem}>
<User className="w-3.5 h-3.5" />
<span>{issue.author_username || 'anonymous'}</span>
</div>
<div className={ISSUES_PAGE.metaItem}>
<Calendar className="w-3.5 h-3.5" />
<span>{new Date(issue.created_at).toLocaleDateString()}</span>
</div>
</div>
</div>
</div>
);
});
const ESTIMATED_ROW_SIZE = 120;
const OVERSCAN = 5;
export function IssuesPage() { export function IssuesPage() {
const { projectName } = useParams<{ projectName: string }>(); const { projectName } = useParams<{ projectName: string }>();
const navigate = useNavigate(); const navigate = useNavigate();
const [activeTab, setActiveTab] = useState<'open' | 'closed'>('open'); const [activeTab, setActiveTab] = useState<'open' | 'closed'>('open');
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
const deferredQuery = useDeferredValue(searchQuery);
const isSearchStale = searchQuery !== deferredQuery;
const { data: issues = [], isLoading, error, refetch } = useIssuesQuery(projectName, { const { data: issues = [], isLoading, error, refetch } = useIssuesQuery(projectName, {
state: activeTab state: activeTab
}); });
@ -35,18 +121,44 @@ export function IssuesPage() {
const { data: summary } = useIssueSummaryQuery(projectName); const { data: summary } = useIssueSummaryQuery(projectName);
const filteredIssues = useMemo(() => { const filteredIssues = useMemo(() => {
if (!searchQuery) return issues; if (!deferredQuery) return issues;
const lowQuery = searchQuery.toLowerCase(); const lowQuery = deferredQuery.toLowerCase();
return issues.filter(i => return issues.filter(i =>
i.title.toLowerCase().includes(lowQuery) || i.title.toLowerCase().includes(lowQuery) ||
(i.body && i.body.toLowerCase().includes(lowQuery)) || (i.body && i.body.toLowerCase().includes(lowQuery)) ||
String(i.number).includes(lowQuery) String(i.number).includes(lowQuery)
); );
}, [issues, searchQuery]); }, [issues, deferredQuery]);
const openCount = summary?.open ?? 0; const openCount = summary?.open ?? 0;
const closedCount = summary?.closed ?? 0; const closedCount = summary?.closed ?? 0;
const scrollRef = useRef<HTMLDivElement>(null);
const virtualizer = useVirtualizer({
count: filteredIssues.length,
getScrollElement: () => scrollRef.current,
estimateSize: () => ESTIMATED_ROW_SIZE,
overscan: OVERSCAN,
});
const handleNavigate = useCallback(
(path: string) => navigate(path),
[navigate]
);
const queryClient = useQueryClient();
const handlePrefetch = useCallback(
(project: string, issueNumber: number) => {
queryClient.prefetchQuery({
queryKey: ["issues", project, issueNumber],
queryFn: () => issueGet(project, issueNumber).then(r => (r.data?.data as IssueResponse) ?? null),
staleTime: 5 * 60 * 1000,
});
},
[queryClient]
);
if (!projectName) { if (!projectName) {
return ( return (
<div className="h-full flex items-center justify-center"> <div className="h-full flex items-center justify-center">
@ -80,14 +192,14 @@ export function IssuesPage() {
} }
return ( return (
<div className={ISSUES_PAGE.container}> <div className={`${ISSUES_PAGE.container} flex flex-col`}>
{/* Page Header */} {/* Page Header */}
<div className={ISSUES_PAGE.headerRow}> <div className={ISSUES_PAGE.headerRow}>
<div className={ISSUES_PAGE.titleGroup}> <div className={ISSUES_PAGE.titleGroup}>
<h1 className={ISSUES_PAGE.pageTitle}>Issues</h1> <h1 className={ISSUES_PAGE.pageTitle}>Issues</h1>
<p className={ISSUES_PAGE.pageSub}>Track and manage project tasks and bugs</p> <p className={ISSUES_PAGE.pageSub}>Track and manage project tasks and bugs</p>
</div> </div>
<button <button
onClick={() => navigate(`/${projectName}/issues/new`)} onClick={() => navigate(`/${projectName}/issues/new`)}
className={ISSUES_PAGE.newBtn} className={ISSUES_PAGE.newBtn}
> >
@ -100,12 +212,17 @@ export function IssuesPage() {
<div className={ISSUES_PAGE.toolbar}> <div className={ISSUES_PAGE.toolbar}>
<div className="relative flex-1 min-w-[240px]"> <div className="relative flex-1 min-w-[240px]">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" /> <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<input <input
className={`${ISSUES_PAGE.searchBox} pl-10`} className={`${ISSUES_PAGE.searchBox} pl-10`}
placeholder="Search all issues..." placeholder="Search all issues..."
value={searchQuery} value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)} onChange={(e) => setSearchQuery(e.target.value)}
/> />
{isSearchStale && (
<div className="absolute right-3 top-1/2 -translate-y-1/2">
<div className="h-3 w-3 animate-spin rounded-full border" style={{ borderColor: "var(--border-strong)", borderTopColor: "var(--accent)" }} />
</div>
)}
</div> </div>
<button className={ISSUES_PAGE.filterBtn}> <button className={ISSUES_PAGE.filterBtn}>
<Tag className="w-3.5 h-3.5" /> <Tag className="w-3.5 h-3.5" />
@ -123,7 +240,7 @@ export function IssuesPage() {
{/* Tabs */} {/* Tabs */}
<div className={ISSUES_PAGE.tabs}> <div className={ISSUES_PAGE.tabs}>
<div <div
className={`${ISSUES_PAGE.tab} ${activeTab === 'open' ? ISSUES_PAGE.tabActive : ISSUES_PAGE.tabInactive}`} className={`${ISSUES_PAGE.tab} ${activeTab === 'open' ? ISSUES_PAGE.tabActive : ISSUES_PAGE.tabInactive}`}
onClick={() => setActiveTab('open')} onClick={() => setActiveTab('open')}
> >
@ -131,7 +248,7 @@ export function IssuesPage() {
Open Open
<span className={ISSUES_PAGE.tabCount}>{openCount}</span> <span className={ISSUES_PAGE.tabCount}>{openCount}</span>
</div> </div>
<div <div
className={`${ISSUES_PAGE.tab} ${activeTab === 'closed' ? ISSUES_PAGE.tabActive : ISSUES_PAGE.tabInactive}`} className={`${ISSUES_PAGE.tab} ${activeTab === 'closed' ? ISSUES_PAGE.tabActive : ISSUES_PAGE.tabInactive}`}
onClick={() => setActiveTab('closed')} onClick={() => setActiveTab('closed')}
> >
@ -152,7 +269,7 @@ export function IssuesPage() {
{searchQuery ? "Try adjusting your search or filters to find what you're looking for." : "You're all caught up! Create an issue to track new tasks."} {searchQuery ? "Try adjusting your search or filters to find what you're looking for." : "You're all caught up! Create an issue to track new tasks."}
</p> </p>
{!searchQuery && activeTab === 'open' && ( {!searchQuery && activeTab === 'open' && (
<button <button
onClick={() => navigate(`/${projectName}/issues/new`)} onClick={() => navigate(`/${projectName}/issues/new`)}
className={ISSUES_PAGE.newBtn} className={ISSUES_PAGE.newBtn}
> >
@ -162,86 +279,32 @@ export function IssuesPage() {
)} )}
</div> </div>
) : ( ) : (
<div className={ISSUES_PAGE.issueList}> <div ref={scrollRef} className={`${ISSUES_PAGE.issueList} flex-1 overflow-y-auto`}>
{filteredIssues.map((issue: IssueResponse) => { <div style={{ height: `${virtualizer.getTotalSize()}px`, position: "relative" }}>
// Find priority label if exists {virtualizer.getVirtualItems().map((virtualItem) => {
const priorityLabel = issue.labels?.find((l: IssueLabelResponse) => l.label_name?.toLowerCase().startsWith('priority:')); const issue = filteredIssues[virtualItem.index];
const priority = priorityLabel ? (priorityLabel.label_name ?? '').split(':')[1].toLowerCase() : null; return (
<div
// Other labels key={virtualItem.key}
const otherLabels = issue.labels?.filter((l: IssueLabelResponse) => !l.label_name?.toLowerCase().startsWith('priority:')) || []; data-index={virtualItem.index}
ref={virtualizer.measureElement}
return ( style={{
<div position: "absolute",
key={issue.number} top: 0,
onClick={() => navigate(`/${projectName}/issues/${issue.number}`)} left: 0,
className={ISSUES_PAGE.issueRow} width: "100%",
> transform: `translateY(${virtualItem.start}px)`,
<div className={`${ISSUES_PAGE.statusIcon} ${issue.state === 'open' ? 'text-green-500' : 'text-purple-500'}`}> }}
{issue.state === 'open' ? <CircleDot className="w-4 h-4" /> : <CheckCircle2 className="w-4 h-4" />} >
<IssueRow issue={issue} projectName={projectName} onNavigate={handleNavigate} onPrefetch={handlePrefetch} />
</div> </div>
);
<div className={ISSUES_PAGE.issueBody}> })}
<div className={ISSUES_PAGE.issueTop}> </div>
<span className={ISSUES_PAGE.issueNum}>#{issue.number}</span>
<h3 className={ISSUES_PAGE.issueTitle}>{issue.title}</h3>
{/* Inline Labels */}
<div className="flex items-center gap-1.5 flex-wrap">
{otherLabels.map((l: IssueLabelResponse) => (
<span
key={l.label_id}
className={ISSUES_PAGE.label}
style={{
backgroundColor: `#${l.label_color}20`,
color: `#${l.label_color}`,
border: `1px solid #${l.label_color}30`
}}
>
{l.label_name}
</span>
))}
</div>
</div>
{issue.body && (
<p className={ISSUES_PAGE.issueExcerpt}>
{truncate(stripMarkdown(issue.body), 120)}
</p>
)}
<div className={ISSUES_PAGE.issueMeta}>
{/* Priority Pill */}
{priority && (
<span className={`${ISSUES_PAGE.priorityPill} ${
priority === 'critical' ? ISSUES_PAGE.pCritical :
priority === 'high' ? ISSUES_PAGE.pHigh :
priority === 'medium' ? ISSUES_PAGE.pMedium :
ISSUES_PAGE.pLow
}`}>
{priority === 'critical' ? <Zap className="w-3 h-3" /> : <AlertTriangle className="w-3 h-3" />}
{priority}
</span>
)}
<div className={ISSUES_PAGE.metaItem}>
<User className="w-3.5 h-3.5" />
<span>{issue.author_username || 'anonymous'}</span>
</div>
<div className={ISSUES_PAGE.metaItem}>
<Calendar className="w-3.5 h-3.5" />
<span>{new Date(issue.created_at).toLocaleDateString()}</span>
</div>
</div>
</div>
</div>
);
})}
</div> </div>
)} )}
</div> </div>
); );
} }
export default IssuesPage; export default IssuesPage;