mirror of
https://github.com/RayLabsHQ/gitea-mirror.git
synced 2025-12-08 20:46:44 +03:00
feat: enhance ActivityLog and ActivityList components with key generation and filtering improvements
This commit is contained in:
@@ -1,16 +1,18 @@
|
||||
import { useMemo, useRef, useState, useEffect } from "react";
|
||||
import { useVirtualizer } from "@tanstack/react-virtual";
|
||||
import type { MirrorJob } from "@/lib/db/schema";
|
||||
import Fuse from "fuse.js";
|
||||
import { Button } from "../ui/button";
|
||||
import { RefreshCw } from "lucide-react";
|
||||
import { Card } from "../ui/card";
|
||||
import { formatDate, getStatusColor } from "@/lib/utils";
|
||||
import { Skeleton } from "../ui/skeleton";
|
||||
import type { FilterParams } from "@/types/filter";
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
import type { MirrorJob } from '@/lib/db/schema';
|
||||
import Fuse from 'fuse.js';
|
||||
import { Button } from '../ui/button';
|
||||
import { RefreshCw } from 'lucide-react';
|
||||
import { Card } from '../ui/card';
|
||||
import { formatDate, getStatusColor } from '@/lib/utils';
|
||||
import { Skeleton } from '../ui/skeleton';
|
||||
import type { FilterParams } from '@/types/filter';
|
||||
|
||||
type MirrorJobWithKey = MirrorJob & { _rowKey: string };
|
||||
|
||||
interface ActivityListProps {
|
||||
activities: MirrorJob[];
|
||||
activities: MirrorJobWithKey[];
|
||||
isLoading: boolean;
|
||||
filter: FilterParams;
|
||||
setFilter: (filter: FilterParams) => void;
|
||||
@@ -22,38 +24,44 @@ export default function ActivityList({
|
||||
filter,
|
||||
setFilter,
|
||||
}: ActivityListProps) {
|
||||
const [expandedItems, setExpandedItems] = useState<Set<string>>(new Set());
|
||||
const [expandedItems, setExpandedItems] = useState<Set<string>>(
|
||||
() => new Set(),
|
||||
);
|
||||
|
||||
const parentRef = useRef<HTMLDivElement>(null);
|
||||
const rowRefs = useRef<Map<string, HTMLDivElement | null>>(new Map());
|
||||
// We keep the ref only for possible future scroll-to-row logic.
|
||||
const rowRefs = useRef<Map<string, HTMLDivElement | null>>(new Map()); // eslint-disable-line @typescript-eslint/no-unused-vars
|
||||
|
||||
const filteredActivities = useMemo(() => {
|
||||
let result = activities;
|
||||
|
||||
if (filter.status) {
|
||||
result = result.filter((activity) => activity.status === filter.status);
|
||||
result = result.filter((a) => a.status === filter.status);
|
||||
}
|
||||
|
||||
if (filter.type) {
|
||||
if (filter.type === 'repository') {
|
||||
result = result.filter((activity) => !!activity.repositoryId);
|
||||
} else if (filter.type === 'organization') {
|
||||
result = result.filter((activity) => !!activity.organizationId);
|
||||
}
|
||||
result =
|
||||
filter.type === 'repository'
|
||||
? result.filter((a) => !!a.repositoryId)
|
||||
: filter.type === 'organization'
|
||||
? result.filter((a) => !!a.organizationId)
|
||||
: result;
|
||||
}
|
||||
|
||||
if (filter.name) {
|
||||
result = result.filter((activity) =>
|
||||
activity.repositoryName === filter.name ||
|
||||
activity.organizationName === filter.name
|
||||
result = result.filter(
|
||||
(a) =>
|
||||
a.repositoryName === filter.name ||
|
||||
a.organizationName === filter.name,
|
||||
);
|
||||
}
|
||||
|
||||
if (filter.searchTerm) {
|
||||
const fuse = new Fuse(result, {
|
||||
keys: ["message", "details", "organizationName", "repositoryName"],
|
||||
keys: ['message', 'details', 'organizationName', 'repositoryName'],
|
||||
threshold: 0.3,
|
||||
});
|
||||
result = fuse.search(filter.searchTerm).map((res) => res.item);
|
||||
result = fuse.search(filter.searchTerm).map((r) => r.item);
|
||||
}
|
||||
|
||||
return result;
|
||||
@@ -62,10 +70,8 @@ export default function ActivityList({
|
||||
const virtualizer = useVirtualizer({
|
||||
count: filteredActivities.length,
|
||||
getScrollElement: () => parentRef.current,
|
||||
estimateSize: (index) => {
|
||||
const activity = filteredActivities[index];
|
||||
return expandedItems.has(activity.id || "") ? 217 : 120;
|
||||
},
|
||||
estimateSize: (idx) =>
|
||||
expandedItems.has(filteredActivities[idx]._rowKey) ? 217 : 120,
|
||||
overscan: 5,
|
||||
measureElement: (el) => el.getBoundingClientRect().height + 8,
|
||||
});
|
||||
@@ -74,118 +80,132 @@ export default function ActivityList({
|
||||
virtualizer.measure();
|
||||
}, [expandedItems, virtualizer]);
|
||||
|
||||
return isLoading ? (
|
||||
<div className="flex flex-col gap-y-4">
|
||||
{Array.from({ length: 5 }, (_, index) => (
|
||||
<Skeleton key={index} className="h-28 w-full rounded-md" />
|
||||
))}
|
||||
</div>
|
||||
) : filteredActivities.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<RefreshCw className="h-12 w-12 text-muted-foreground mb-4" />
|
||||
<h3 className="text-lg font-medium">No activities found</h3>
|
||||
<p className="text-sm text-muted-foreground mt-1 mb-4 max-w-md">
|
||||
{filter.searchTerm || filter.status || filter.type || filter.name
|
||||
? "Try adjusting your search or filter criteria."
|
||||
: "No mirroring activities have been recorded yet."}
|
||||
</p>
|
||||
{filter.searchTerm || filter.status || filter.type || filter.name ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setFilter({ searchTerm: "", status: "", type: "", name: "" });
|
||||
}}
|
||||
>
|
||||
Clear Filters
|
||||
</Button>
|
||||
) : (
|
||||
<Button>
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
Refresh
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
/* ------------------------------ render ------------------------------ */
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className='flex flex-col gap-y-4'>
|
||||
{Array.from({ length: 5 }, (_, i) => (
|
||||
<Skeleton key={i} className='h-28 w-full rounded-md' />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (filteredActivities.length === 0) {
|
||||
const hasFilter =
|
||||
filter.searchTerm || filter.status || filter.type || filter.name;
|
||||
|
||||
return (
|
||||
<div className='flex flex-col items-center justify-center py-12 text-center'>
|
||||
<RefreshCw className='mb-4 h-12 w-12 text-muted-foreground' />
|
||||
<h3 className='text-lg font-medium'>No activities found</h3>
|
||||
<p className='mt-1 mb-4 max-w-md text-sm text-muted-foreground'>
|
||||
{hasFilter
|
||||
? 'Try adjusting your search or filter criteria.'
|
||||
: 'No mirroring activities have been recorded yet.'}
|
||||
</p>
|
||||
{hasFilter ? (
|
||||
<Button
|
||||
variant='outline'
|
||||
onClick={() =>
|
||||
setFilter({ searchTerm: '', status: '', type: '', name: '' })
|
||||
}
|
||||
>
|
||||
Clear Filters
|
||||
</Button>
|
||||
) : (
|
||||
<Button>
|
||||
<RefreshCw className='mr-2 h-4 w-4' />
|
||||
Refresh
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card
|
||||
className="border rounded-md max-h-[calc(100dvh-191px)] overflow-y-auto relative"
|
||||
ref={parentRef}
|
||||
className='relative max-h-[calc(100dvh-191px)] overflow-y-auto rounded-md border'
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
height: virtualizer.getTotalSize(),
|
||||
position: "relative",
|
||||
width: "100%",
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
}}
|
||||
>
|
||||
{virtualizer.getVirtualItems().map((virtualRow) => {
|
||||
const activity = filteredActivities[virtualRow.index];
|
||||
const isExpanded = expandedItems.has(activity.id || "");
|
||||
const key = activity.id || String(virtualRow.index);
|
||||
{virtualizer.getVirtualItems().map((vRow) => {
|
||||
const activity = filteredActivities[vRow.index];
|
||||
const isExpanded = expandedItems.has(activity._rowKey);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
key={activity._rowKey}
|
||||
ref={(node) => {
|
||||
if (node) {
|
||||
rowRefs.current.set(key, node);
|
||||
virtualizer.measureElement(node);
|
||||
}
|
||||
rowRefs.current.set(activity._rowKey, node);
|
||||
if (node) virtualizer.measureElement(node);
|
||||
}}
|
||||
style={{
|
||||
position: "absolute",
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: "100%",
|
||||
transform: `translateY(${virtualRow.start}px)`,
|
||||
paddingBottom: "8px",
|
||||
width: '100%',
|
||||
transform: `translateY(${vRow.start}px)`,
|
||||
paddingBottom: '8px',
|
||||
}}
|
||||
className="border-b px-4 pt-4"
|
||||
className='border-b px-4 pt-4'
|
||||
>
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="relative mt-2">
|
||||
<div className='flex items-start gap-4'>
|
||||
<div className='relative mt-2'>
|
||||
<div
|
||||
className={`h-2 w-2 rounded-full ${getStatusColor(
|
||||
activity.status
|
||||
activity.status,
|
||||
)}`}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between mb-1">
|
||||
<p className="font-medium">{activity.message}</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
|
||||
<div className='flex-1'>
|
||||
<div className='mb-1 flex flex-col sm:flex-row sm:items-center sm:justify-between'>
|
||||
<p className='font-medium'>{activity.message}</p>
|
||||
<p className='text-sm text-muted-foreground'>
|
||||
{formatDate(activity.timestamp)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{activity.repositoryName && (
|
||||
<p className="text-sm text-muted-foreground mb-2">
|
||||
<p className='mb-2 text-sm text-muted-foreground'>
|
||||
Repository: {activity.repositoryName}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{activity.organizationName && (
|
||||
<p className="text-sm text-muted-foreground mb-2">
|
||||
<p className='mb-2 text-sm text-muted-foreground'>
|
||||
Organization: {activity.organizationName}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{activity.details && (
|
||||
<div className="mt-2">
|
||||
<div className='mt-2'>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
const newSet = new Set(expandedItems);
|
||||
const id = activity.id || "";
|
||||
newSet.has(id) ? newSet.delete(id) : newSet.add(id);
|
||||
setExpandedItems(newSet);
|
||||
}}
|
||||
className="text-xs h-7 px-2"
|
||||
variant='ghost'
|
||||
className='h-7 px-2 text-xs'
|
||||
onClick={() =>
|
||||
setExpandedItems((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.has(activity._rowKey)
|
||||
? next.delete(activity._rowKey)
|
||||
: next.add(activity._rowKey);
|
||||
return next;
|
||||
})
|
||||
}
|
||||
>
|
||||
{isExpanded ? "Hide Details" : "Show Details"}
|
||||
{isExpanded ? 'Hide Details' : 'Show Details'}
|
||||
</Button>
|
||||
|
||||
{isExpanded && (
|
||||
<pre className="mt-2 p-3 bg-muted rounded-md text-xs overflow-auto whitespace-pre-wrap min-h-[100px]">
|
||||
<pre className='mt-2 min-h-[100px] whitespace-pre-wrap overflow-auto rounded-md bg-muted p-3 text-xs'>
|
||||
{activity.details}
|
||||
</pre>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user