mirror of
https://github.com/RayLabsHQ/gitea-mirror.git
synced 2026-03-28 17:42:38 +01:00
repositories: migrate table to tanstack
This commit is contained in:
5
bun.lock
5
bun.lock
@@ -31,6 +31,7 @@
|
|||||||
"@radix-ui/react-tabs": "^1.1.13",
|
"@radix-ui/react-tabs": "^1.1.13",
|
||||||
"@radix-ui/react-tooltip": "^1.2.8",
|
"@radix-ui/react-tooltip": "^1.2.8",
|
||||||
"@tailwindcss/vite": "^4.2.1",
|
"@tailwindcss/vite": "^4.2.1",
|
||||||
|
"@tanstack/react-table": "^8.21.3",
|
||||||
"@tanstack/react-virtual": "^3.13.22",
|
"@tanstack/react-virtual": "^3.13.22",
|
||||||
"@types/canvas-confetti": "^1.9.0",
|
"@types/canvas-confetti": "^1.9.0",
|
||||||
"@types/react": "^19.2.14",
|
"@types/react": "^19.2.14",
|
||||||
@@ -576,8 +577,12 @@
|
|||||||
|
|
||||||
"@tailwindcss/vite": ["@tailwindcss/vite@4.2.1", "", { "dependencies": { "@tailwindcss/node": "4.2.1", "@tailwindcss/oxide": "4.2.1", "tailwindcss": "4.2.1" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7" } }, "sha512-TBf2sJjYeb28jD2U/OhwdW0bbOsxkWPwQ7SrqGf9sVcoYwZj7rkXljroBO9wKBut9XnmQLXanuDUeqQK0lGg/w=="],
|
"@tailwindcss/vite": ["@tailwindcss/vite@4.2.1", "", { "dependencies": { "@tailwindcss/node": "4.2.1", "@tailwindcss/oxide": "4.2.1", "tailwindcss": "4.2.1" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7" } }, "sha512-TBf2sJjYeb28jD2U/OhwdW0bbOsxkWPwQ7SrqGf9sVcoYwZj7rkXljroBO9wKBut9XnmQLXanuDUeqQK0lGg/w=="],
|
||||||
|
|
||||||
|
"@tanstack/react-table": ["@tanstack/react-table@8.21.3", "", { "dependencies": { "@tanstack/table-core": "8.21.3" }, "peerDependencies": { "react": ">=16.8", "react-dom": ">=16.8" } }, "sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww=="],
|
||||||
|
|
||||||
"@tanstack/react-virtual": ["@tanstack/react-virtual@3.13.22", "", { "dependencies": { "@tanstack/virtual-core": "3.13.22" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-EaOrBBJLi3M0bTMQRjGkxLXRw7Gizwntoy5E2Q2UnSbML7Mo2a1P/Hfkw5tw9FLzK62bj34Jl6VNbQfRV6eJcA=="],
|
"@tanstack/react-virtual": ["@tanstack/react-virtual@3.13.22", "", { "dependencies": { "@tanstack/virtual-core": "3.13.22" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-EaOrBBJLi3M0bTMQRjGkxLXRw7Gizwntoy5E2Q2UnSbML7Mo2a1P/Hfkw5tw9FLzK62bj34Jl6VNbQfRV6eJcA=="],
|
||||||
|
|
||||||
|
"@tanstack/table-core": ["@tanstack/table-core@8.21.3", "", {}, "sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg=="],
|
||||||
|
|
||||||
"@tanstack/virtual-core": ["@tanstack/virtual-core@3.13.22", "", {}, "sha512-isuUGKsc5TAPDoHSbWTbl1SCil54zOS2MiWz/9GCWHPUQOvNTQx8qJEWC7UWR0lShhbK0Lmkcf0SZYxvch7G3g=="],
|
"@tanstack/virtual-core": ["@tanstack/virtual-core@3.13.22", "", {}, "sha512-isuUGKsc5TAPDoHSbWTbl1SCil54zOS2MiWz/9GCWHPUQOvNTQx8qJEWC7UWR0lShhbK0Lmkcf0SZYxvch7G3g=="],
|
||||||
|
|
||||||
"@testing-library/dom": ["@testing-library/dom@10.4.0", "", { "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", "@types/aria-query": "^5.0.1", "aria-query": "5.3.0", "chalk": "^4.1.0", "dom-accessibility-api": "^0.5.9", "lz-string": "^1.5.0", "pretty-format": "^27.0.2" } }, "sha512-pemlzrSESWbdAloYml3bAJMEfNh1Z7EduzqPKprCH5S341frlpYnUEW0H72dLxa6IsYr+mPno20GiSm+h9dEdQ=="],
|
"@testing-library/dom": ["@testing-library/dom@10.4.0", "", { "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", "@types/aria-query": "^5.0.1", "aria-query": "5.3.0", "chalk": "^4.1.0", "dom-accessibility-api": "^0.5.9", "lz-string": "^1.5.0", "pretty-format": "^27.0.2" } }, "sha512-pemlzrSESWbdAloYml3bAJMEfNh1Z7EduzqPKprCH5S341frlpYnUEW0H72dLxa6IsYr+mPno20GiSm+h9dEdQ=="],
|
||||||
|
|||||||
@@ -77,6 +77,7 @@
|
|||||||
"@radix-ui/react-tabs": "^1.1.13",
|
"@radix-ui/react-tabs": "^1.1.13",
|
||||||
"@radix-ui/react-tooltip": "^1.2.8",
|
"@radix-ui/react-tooltip": "^1.2.8",
|
||||||
"@tailwindcss/vite": "^4.2.1",
|
"@tailwindcss/vite": "^4.2.1",
|
||||||
|
"@tanstack/react-table": "^8.21.3",
|
||||||
"@tanstack/react-virtual": "^3.13.22",
|
"@tanstack/react-virtual": "^3.13.22",
|
||||||
"@types/canvas-confetti": "^1.9.0",
|
"@types/canvas-confetti": "^1.9.0",
|
||||||
"@types/react": "^19.2.14",
|
"@types/react": "^19.2.14",
|
||||||
|
|||||||
@@ -1,11 +1,19 @@
|
|||||||
import { useMemo, useRef } from "react";
|
import { useMemo, useRef, useState } from "react";
|
||||||
import Fuse from "fuse.js";
|
import {
|
||||||
|
getCoreRowModel,
|
||||||
|
getFilteredRowModel,
|
||||||
|
getSortedRowModel,
|
||||||
|
useReactTable,
|
||||||
|
type ColumnDef,
|
||||||
|
type ColumnFiltersState,
|
||||||
|
type SortingState,
|
||||||
|
} from "@tanstack/react-table";
|
||||||
import { useVirtualizer } from "@tanstack/react-virtual";
|
import { useVirtualizer } from "@tanstack/react-virtual";
|
||||||
import { FlipHorizontal, GitFork, RefreshCw, RotateCcw, Star, Lock, Ban, Check, ChevronDown, Trash2, X } from "lucide-react";
|
import { FlipHorizontal, GitFork, RefreshCw, RotateCcw, Star, Lock, Ban, Check, ChevronDown, Trash2, X } from "lucide-react";
|
||||||
import { SiGithub, SiGitea } from "react-icons/si";
|
import { SiGithub, SiGitea } from "react-icons/si";
|
||||||
import type { Repository } from "@/lib/db/schema";
|
import type { Repository } from "@/lib/db/schema";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { formatDate, formatLastSyncTime, getStatusColor } from "@/lib/utils";
|
import { formatLastSyncTime } from "@/lib/utils";
|
||||||
import type { FilterParams } from "@/types/filter";
|
import type { FilterParams } from "@/types/filter";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import { useGiteaConfig } from "@/hooks/useGiteaConfig";
|
import { useGiteaConfig } from "@/hooks/useGiteaConfig";
|
||||||
@@ -66,6 +74,7 @@ export default function RepositoryTable({
|
|||||||
}: RepositoryTableProps) {
|
}: RepositoryTableProps) {
|
||||||
const tableParentRef = useRef<HTMLDivElement>(null);
|
const tableParentRef = useRef<HTMLDivElement>(null);
|
||||||
const { giteaConfig } = useGiteaConfig();
|
const { giteaConfig } = useGiteaConfig();
|
||||||
|
const [sorting, setSorting] = useState<SortingState>([]);
|
||||||
|
|
||||||
const handleUpdateDestination = async (repoId: string, newDestination: string | null) => {
|
const handleUpdateDestination = async (repoId: string, newDestination: string | null) => {
|
||||||
// Call API to update repository destination
|
// Call API to update repository destination
|
||||||
@@ -120,40 +129,90 @@ export default function RepositoryTable({
|
|||||||
return `${baseUrl}/${repoPath}`;
|
return `${baseUrl}/${repoPath}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
const hasAnyFilter = Object.values(filter).some(
|
const hasAnyFilter = [filter.searchTerm, filter.status, filter.organization, filter.owner].some(
|
||||||
(val) => val?.toString().trim() !== ""
|
(val) => val?.toString().trim() !== ""
|
||||||
);
|
);
|
||||||
|
|
||||||
const filteredRepositories = useMemo(() => {
|
const columnFilters = useMemo<ColumnFiltersState>(() => {
|
||||||
let result = repositories;
|
const next: ColumnFiltersState = [];
|
||||||
|
|
||||||
if (filter.status) {
|
if (filter.status) {
|
||||||
result = result.filter((repo) => repo.status === filter.status);
|
next.push({ id: "status", value: filter.status });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (filter.owner) {
|
if (filter.owner) {
|
||||||
result = result.filter((repo) => repo.owner === filter.owner);
|
next.push({ id: "owner", value: filter.owner });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (filter.organization) {
|
if (filter.organization) {
|
||||||
result = result.filter(
|
next.push({ id: "organization", value: filter.organization });
|
||||||
(repo) => repo.organization === filter.organization
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
return next;
|
||||||
|
}, [filter.status, filter.owner, filter.organization]);
|
||||||
|
|
||||||
if (filter.searchTerm) {
|
const columns = useMemo<ColumnDef<Repository>[]>(
|
||||||
const fuse = new Fuse(result, {
|
() => [
|
||||||
keys: ["name", "fullName", "owner", "organization"],
|
{
|
||||||
threshold: 0.3,
|
id: "fullName",
|
||||||
});
|
accessorFn: (row) => row.fullName,
|
||||||
result = fuse.search(filter.searchTerm).map((res) => res.item);
|
},
|
||||||
}
|
{
|
||||||
|
id: "owner",
|
||||||
|
accessorFn: (row) => row.owner,
|
||||||
|
filterFn: "equalsString",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "organization",
|
||||||
|
accessorFn: (row) => row.organization ?? "",
|
||||||
|
filterFn: "equalsString",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "status",
|
||||||
|
accessorFn: (row) => row.status,
|
||||||
|
filterFn: "equalsString",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "lastMirrored",
|
||||||
|
accessorFn: (row) =>
|
||||||
|
row.lastMirrored ? new Date(row.lastMirrored).getTime() : 0,
|
||||||
|
enableGlobalFilter: false,
|
||||||
|
enableColumnFilter: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
return result;
|
const table = useReactTable({
|
||||||
}, [repositories, filter]);
|
data: repositories,
|
||||||
|
columns,
|
||||||
|
state: {
|
||||||
|
globalFilter: filter.searchTerm ?? "",
|
||||||
|
columnFilters,
|
||||||
|
sorting,
|
||||||
|
},
|
||||||
|
onSortingChange: setSorting,
|
||||||
|
getCoreRowModel: getCoreRowModel(),
|
||||||
|
getFilteredRowModel: getFilteredRowModel(),
|
||||||
|
getSortedRowModel: getSortedRowModel(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const visibleRepositories = table
|
||||||
|
.getRowModel()
|
||||||
|
.rows.map((row) => row.original);
|
||||||
|
|
||||||
|
const getSortDirection = (columnId: string) =>
|
||||||
|
table.getColumn(columnId)?.getIsSorted() ?? false;
|
||||||
|
|
||||||
|
const getSortLabel = (columnId: string) => {
|
||||||
|
const direction = getSortDirection(columnId);
|
||||||
|
if (direction === "asc") return " ↑";
|
||||||
|
if (direction === "desc") return " ↓";
|
||||||
|
return "";
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleSort = (columnId: string) => {
|
||||||
|
table.getColumn(columnId)?.toggleSorting();
|
||||||
|
};
|
||||||
|
|
||||||
const rowVirtualizer = useVirtualizer({
|
const rowVirtualizer = useVirtualizer({
|
||||||
count: filteredRepositories.length,
|
count: visibleRepositories.length,
|
||||||
getScrollElement: () => tableParentRef.current,
|
getScrollElement: () => tableParentRef.current,
|
||||||
estimateSize: () => 65,
|
estimateSize: () => 65,
|
||||||
overscan: 5,
|
overscan: 5,
|
||||||
@@ -162,7 +221,11 @@ export default function RepositoryTable({
|
|||||||
// Selection handlers
|
// Selection handlers
|
||||||
const handleSelectAll = (checked: boolean) => {
|
const handleSelectAll = (checked: boolean) => {
|
||||||
if (checked) {
|
if (checked) {
|
||||||
const allIds = new Set(filteredRepositories.map(repo => repo.id).filter((id): id is string => !!id));
|
const allIds = new Set(
|
||||||
|
visibleRepositories
|
||||||
|
.map((repo) => repo.id)
|
||||||
|
.filter((id): id is string => !!id)
|
||||||
|
);
|
||||||
onSelectionChange(allIds);
|
onSelectionChange(allIds);
|
||||||
} else {
|
} else {
|
||||||
onSelectionChange(new Set());
|
onSelectionChange(new Set());
|
||||||
@@ -179,8 +242,9 @@ export default function RepositoryTable({
|
|||||||
onSelectionChange(newSelection);
|
onSelectionChange(newSelection);
|
||||||
};
|
};
|
||||||
|
|
||||||
const isAllSelected = filteredRepositories.length > 0 &&
|
const isAllSelected =
|
||||||
filteredRepositories.every(repo => repo.id && selectedRepoIds.has(repo.id));
|
visibleRepositories.length > 0 &&
|
||||||
|
visibleRepositories.every((repo) => repo.id && selectedRepoIds.has(repo.id));
|
||||||
const isPartiallySelected = selectedRepoIds.size > 0 && !isAllSelected;
|
const isPartiallySelected = selectedRepoIds.size > 0 && !isAllSelected;
|
||||||
|
|
||||||
// Mobile card layout for repository
|
// Mobile card layout for repository
|
||||||
@@ -510,7 +574,7 @@ export default function RepositoryTable({
|
|||||||
{hasAnyFilter && (
|
{hasAnyFilter && (
|
||||||
<div className="mb-4 flex items-center gap-2">
|
<div className="mb-4 flex items-center gap-2">
|
||||||
<span className="text-sm text-muted-foreground">
|
<span className="text-sm text-muted-foreground">
|
||||||
Showing {filteredRepositories.length} of {repositories.length} repositories
|
Showing {visibleRepositories.length} of {repositories.length} repositories
|
||||||
</span>
|
</span>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -529,7 +593,7 @@ export default function RepositoryTable({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{filteredRepositories.length === 0 ? (
|
{visibleRepositories.length === 0 ? (
|
||||||
<div className="text-center py-8">
|
<div className="text-center py-8">
|
||||||
<p className="text-muted-foreground">
|
<p className="text-muted-foreground">
|
||||||
{hasAnyFilter
|
{hasAnyFilter
|
||||||
@@ -550,12 +614,12 @@ export default function RepositoryTable({
|
|||||||
className="h-5 w-5"
|
className="h-5 w-5"
|
||||||
/>
|
/>
|
||||||
<span className="text-sm font-medium">
|
<span className="text-sm font-medium">
|
||||||
Select All ({filteredRepositories.length})
|
Select All ({visibleRepositories.length})
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Repository cards */}
|
{/* Repository cards */}
|
||||||
{filteredRepositories.map((repo) => (
|
{visibleRepositories.map((repo) => (
|
||||||
<RepositoryCard key={repo.id} repo={repo} />
|
<RepositoryCard key={repo.id} repo={repo} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -572,16 +636,55 @@ export default function RepositoryTable({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="h-full py-3 text-sm font-medium flex-[2.3]">
|
<div className="h-full py-3 text-sm font-medium flex-[2.3]">
|
||||||
Repository
|
<button
|
||||||
</div>
|
type="button"
|
||||||
<div className="h-full p-3 text-sm font-medium flex-[1]">Owner</div>
|
className="hover:text-foreground text-left"
|
||||||
<div className="h-full p-3 text-sm font-medium flex-[1]">
|
onClick={() => toggleSort("fullName")}
|
||||||
Organization
|
title="Sort by repository"
|
||||||
|
>
|
||||||
|
Repository{getSortLabel("fullName")}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="h-full p-3 text-sm font-medium flex-[1]">
|
<div className="h-full p-3 text-sm font-medium flex-[1]">
|
||||||
Last Mirrored
|
<button
|
||||||
|
type="button"
|
||||||
|
className="hover:text-foreground text-left"
|
||||||
|
onClick={() => toggleSort("owner")}
|
||||||
|
title="Sort by owner"
|
||||||
|
>
|
||||||
|
Owner{getSortLabel("owner")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="h-full p-3 text-sm font-medium flex-[1]">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="hover:text-foreground text-left"
|
||||||
|
onClick={() => toggleSort("organization")}
|
||||||
|
title="Sort by organization"
|
||||||
|
>
|
||||||
|
Organization{getSortLabel("organization")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="h-full p-3 text-sm font-medium flex-[1]">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="hover:text-foreground text-left"
|
||||||
|
onClick={() => toggleSort("lastMirrored")}
|
||||||
|
title="Sort by last mirrored"
|
||||||
|
>
|
||||||
|
Last Mirrored{getSortLabel("lastMirrored")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="h-full p-3 text-sm font-medium flex-[1]">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="hover:text-foreground text-left"
|
||||||
|
onClick={() => toggleSort("status")}
|
||||||
|
title="Sort by status"
|
||||||
|
>
|
||||||
|
Status{getSortLabel("status")}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="h-full p-3 text-sm font-medium flex-[1]">Status</div>
|
|
||||||
<div className="h-full p-3 text-sm font-medium flex-[1]">
|
<div className="h-full p-3 text-sm font-medium flex-[1]">
|
||||||
Actions
|
Actions
|
||||||
</div>
|
</div>
|
||||||
@@ -601,13 +704,14 @@ export default function RepositoryTable({
|
|||||||
position: "relative",
|
position: "relative",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{rowVirtualizer.getVirtualItems().map((virtualRow, index) => {
|
{rowVirtualizer.getVirtualItems().map((virtualRow) => {
|
||||||
const repo = filteredRepositories[virtualRow.index];
|
const repo = visibleRepositories[virtualRow.index];
|
||||||
|
if (!repo) return null;
|
||||||
const isLoading = loadingRepoIds.has(repo.id ?? "");
|
const isLoading = loadingRepoIds.has(repo.id ?? "");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={index}
|
key={virtualRow.key}
|
||||||
ref={rowVirtualizer.measureElement}
|
ref={rowVirtualizer.measureElement}
|
||||||
style={{
|
style={{
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
@@ -784,7 +888,7 @@ export default function RepositoryTable({
|
|||||||
<div className={`h-1.5 w-1.5 rounded-full ${isLiveActive ? 'bg-emerald-500' : 'bg-primary'}`} />
|
<div className={`h-1.5 w-1.5 rounded-full ${isLiveActive ? 'bg-emerald-500' : 'bg-primary'}`} />
|
||||||
<span className="text-sm font-medium text-foreground">
|
<span className="text-sm font-medium text-foreground">
|
||||||
{hasAnyFilter
|
{hasAnyFilter
|
||||||
? `Showing ${filteredRepositories.length} of ${repositories.length} repositories`
|
? `Showing ${visibleRepositories.length} of ${repositories.length} repositories`
|
||||||
: `${repositories.length} ${repositories.length === 1 ? 'repository' : 'repositories'} total`}
|
: `${repositories.length} ${repositories.length === 1 ? 'repository' : 'repositories'} total`}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user