Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { SearchBar } from './components/SearchBar';
import { RepositoryList } from './components/RepositoryList';
import { CategorySidebar } from './components/CategorySidebar';
import { ReleaseTimeline } from './components/ReleaseTimeline';
import { ForkTimeline } from './components/ForkTimeline';
import { SettingsPanel } from './components/SettingsPanel';
import { DiscoveryView } from './components/DiscoveryView';
import { BackToTop } from './components/BackToTop';
Expand Down Expand Up @@ -47,6 +48,9 @@ RepositoriesView.displayName = 'RepositoriesView';
const ReleasesView = React.memo(() => <ReleaseTimeline />);
ReleasesView.displayName = 'ReleasesView';

const ForksView = React.memo(() => <ForkTimeline />);
ForksView.displayName = 'ForksView';

const SettingsView = React.memo(() => <SettingsPanel />);
SettingsView.displayName = 'SettingsView';

Expand Down Expand Up @@ -117,6 +121,8 @@ function App() {
);
case 'releases':
return <ReleasesView />;
case 'forks':
return <ForksView />;
case 'subscription':
return (
<ErrorBoundary>
Expand Down
240 changes: 240 additions & 0 deletions src/components/ForkCard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,240 @@
import React, { memo, useState, useCallback } from 'react';
import { ExternalLink, GitFork, RefreshCw, ChevronDown, ChevronUp, FolderOpen, Folder, Play, Loader2 } from 'lucide-react';
import { ForkRepo, WorkflowRun } from '../types';
import { formatDistanceToNow } from 'date-fns';

interface ForkCardProps {
fork: ForkRepo;
isUnread: boolean;
isWorkflowsExpanded: boolean;
onToggleWorkflows: () => void;
onSyncUpstream: () => void;
onMarkAsRead: () => void;
onRunWorkflow: (workflowId: string, workflowName: string, branch: string) => void;
workflows: WorkflowRun[];
isLoadingWorkflows: boolean;
isSyncing: boolean;
language: 'zh' | 'en';
}

const ForkCard: React.FC<ForkCardProps> = memo(({
fork,
isUnread,
isWorkflowsExpanded,
onToggleWorkflows,
onSyncUpstream,
onMarkAsRead,
onRunWorkflow,
workflows,
isLoadingWorkflows,
isSyncing,
language,
}) => {
const t = useCallback((zh: string, en: string) => language === 'zh' ? zh : en, [language]);

const sourceFullName = fork.source?.full_name || fork.parent?.full_name || '';
const sourceName = fork.source?.name || fork.parent?.name || '';

return (
<div
onClick={onMarkAsRead}
className={`bg-white dark:bg-[#121314] rounded-xl border transition-all duration-300 ease-in-out cursor-pointer ${
isWorkflowsExpanded
? 'border-brand-indigo/20 shadow-lg ring-1 ring-brand-indigo/30'
: 'border-black/[0.06] dark:border-white/[0.04] hover:shadow-md hover:border-black/10 dark:hover:border-white/10'
}`}
>
{/* Header */}
<div className="p-3 sm:p-4">
<div className="flex items-stretch justify-between gap-3">
<div className="flex items-center min-w-0 flex-1">
{isUnread && (
<div className="w-1.5 h-1.5 bg-brand-violet rounded-full flex-shrink-0 animate-pulse mr-2"></div>
)}
<div className="flex items-center justify-center w-8 h-8 bg-gray-100 dark:bg-white/[0.04] rounded-lg flex-shrink-0 border border-transparent dark:border-white/[0.04]">
<GitFork className="w-4 h-4 text-gray-500 dark:text-text-tertiary" />
</div>
<div className="min-w-0 flex-1 ml-3">
<div className="flex items-center gap-2 min-w-0 flex-wrap">
<h4 className="font-semibold text-gray-900 dark:text-text-primary text-sm truncate">
{fork.name}
</h4>
{fork.language && (
<span className="px-1.5 py-0.5 bg-gray-100 dark:bg-white/[0.06] text-gray-700 dark:text-text-secondary text-xs font-medium rounded-md border border-black/[0.06] dark:border-white/[0.04] shrink-0">
{fork.language}
</span>
)}
</div>
<p className="text-xs text-gray-500 dark:text-text-quaternary truncate mt-1">
{fork.full_name}
</p>
{sourceFullName && (
<p className="text-xs text-gray-400 dark:text-text-quaternary truncate mt-0.5 flex items-center gap-1">
<span>↑</span>
<span>{sourceFullName}</span>
</p>
)}
</div>
</div>

<div className="flex items-center gap-4 flex-shrink-0 self-stretch">
<div className="hidden md:flex min-w-[140px] flex-col justify-center gap-2 text-xs text-gray-500 dark:text-text-tertiary">
<div className="flex items-center gap-1.5">
<RefreshCw className="w-3.5 h-3.5" />
<span>
{fork.updated_at
? formatDistanceToNow(new Date(fork.updated_at), { addSuffix: true })
: '-'}
</span>
</div>
{fork.source?.updated_at && (
<div className="flex items-center gap-1.5">
<GitFork className="w-3.5 h-3.5" />
<span>
{formatDistanceToNow(new Date(fork.source.updated_at), { addSuffix: true })}
</span>
</div>
)}
</div>
<div className="flex items-center space-x-1 flex-shrink-0">
{/* Workflows dropdown */}
<button
onClick={(e) => {
e.stopPropagation();
onToggleWorkflows();
}}
className={`flex items-center space-x-0.5 px-1.5 py-1 rounded transition-all duration-200 whitespace-nowrap ${
isWorkflowsExpanded
? 'bg-brand-indigo/15 text-brand-indigo dark:bg-brand-indigo/20 dark:text-white'
: 'bg-light-surface text-gray-700 dark:bg-white/[0.04] dark:text-text-tertiary hover:bg-gray-200 dark:hover:bg-white/[0.08]'
}`}
title={isWorkflowsExpanded ? t('隐藏工作流', 'Hide Workflows') : t('显示工作流', 'Show Workflows')}
aria-label={isWorkflowsExpanded ? t('隐藏工作流', 'Hide Workflows') : t('显示工作流', 'Show Workflows')}
aria-expanded={isWorkflowsExpanded}
>
{isWorkflowsExpanded ? <FolderOpen className="w-3.5 h-3.5" /> : <Folder className="w-3.5 h-3.5" />}
<span className="text-xs font-medium">{isWorkflowsExpanded ? t('隐藏', 'Hide') : t('工作流', 'Workflows')}</span>
{isWorkflowsExpanded ? <ChevronUp className="w-3 h-3" /> : <ChevronDown className="w-3 h-3" />}
</button>
Comment thread
coderabbitai[bot] marked this conversation as resolved.

{/* Sync Upstream button */}
<button
onClick={(e) => {
e.stopPropagation();
onSyncUpstream();
}}
disabled={isSyncing}
className="p-1 rounded bg-light-surface text-gray-700 dark:bg-white/[0.04] dark:text-text-secondary hover:bg-gray-200 hover:text-gray-900 dark:hover:bg-white/[0.08] dark:hover:text-text-primary transition-colors disabled:opacity-50"
title={t('同步上游仓库', 'Sync upstream repository')}
aria-label={t('同步上游仓库', 'Sync upstream repository')}
>
{isSyncing ? (
<Loader2 className="w-3.5 h-3.5 animate-spin" />
) : (
<RefreshCw className="w-3.5 h-3.5" />
)}
</button>

{/* View on GitHub link */}
<a
href={fork.html_url}
target="_blank"
rel="noopener noreferrer"
className="p-1 rounded bg-light-surface text-gray-700 dark:bg-white/[0.04] dark:text-text-secondary hover:bg-gray-200 hover:text-gray-900 dark:hover:bg-white/[0.08] dark:hover:text-text-primary transition-colors"
title={t('在GitHub上查看', 'View on GitHub')}
aria-label={t('在GitHub上查看', 'View on GitHub')}
onClick={(e) => {
e.stopPropagation();
onMarkAsRead();
}}
>
<ExternalLink className="w-3.5 h-3.5" />
</a>
</div>
</div>
</div>
</div>

{/* Expandable Workflows section */}
<div
className="grid transition-[grid-template-rows] duration-300 ease-in-out"
style={{ gridTemplateRows: isWorkflowsExpanded ? '1fr' : '0fr' }}
>
<div className="overflow-hidden min-h-0">
<div className="px-3 sm:px-4 pb-3 sm:pb-4 pt-3 sm:pt-4 border-t border-black/[0.06] dark:border-white/[0.04]">
{isLoadingWorkflows ? (
<div className="flex items-center justify-center py-6">
<Loader2 className="w-5 h-5 animate-spin text-gray-400 dark:text-text-tertiary" />
<span className="ml-2 text-sm text-gray-500 dark:text-text-tertiary">
{t('加载工作流中...', 'Loading workflows...')}
</span>
</div>
) : workflows.length === 0 ? (
<div className="py-4 text-center text-sm text-gray-500 dark:text-text-tertiary">
{t('暂无工作流', 'No workflows')}
</div>
) : (
<div className="py-2">
<div className="flex items-center space-x-2 mb-3">
<Folder className="w-3.5 h-3.5 text-gray-700 dark:text-text-secondary" />
<span className="text-xs font-medium text-gray-900 dark:text-text-secondary">
{t('工作流', 'Workflows')}
</span>
<span className="text-xs text-gray-500 dark:text-text-tertiary">
({workflows.length})
</span>
</div>

<div className="bg-gray-50 dark:bg-[#121314] rounded border border-black/[0.06] dark:border-white/[0.04] max-h-72 overflow-y-auto">
{workflows.map((workflow) => (
<div
key={workflow.id}
className="flex items-center justify-between px-4 py-3 hover:bg-light-surface dark:hover:bg-white/[0.06] transition-colors border-b border-black/[0.04] dark:border-white/[0.04] last:border-b-0"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center space-x-2 min-w-0 flex-1">
<span className={`w-2 h-2 rounded-full flex-shrink-0 ${
workflow.conclusion === 'success' ? 'bg-green-500' :
workflow.conclusion === 'failure' ? 'bg-red-500' :
workflow.status === 'in_progress' ? 'bg-yellow-500 animate-pulse' :
'bg-gray-400'
}`} />
<div className="min-w-0 flex-1">
<p className="text-sm truncate text-gray-900 dark:text-text-secondary">
{workflow.name}
</p>
<p className="text-xs text-gray-500 dark:text-text-tertiary truncate">
#{workflow.run_number} • {workflow.head_branch} • {formatDistanceToNow(new Date(workflow.created_at), { addSuffix: true })}
</p>
</div>
</div>
<button
onClick={(e) => {
e.stopPropagation();
onRunWorkflow(String(workflow.id), workflow.name, workflow.head_branch);
}}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
disabled={workflow.status === 'in_progress'}
className="ml-2 p-1.5 rounded bg-brand-indigo text-white hover:bg-brand-hover transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex-shrink-0"
title={t('运行工作流', 'Run workflow')}
>
Comment thread
coderabbitai[bot] marked this conversation as resolved.
{workflow.status === 'in_progress' ? (
<Loader2 className="w-3.5 h-3.5 animate-spin" />
) : (
<Play className="w-3.5 h-3.5" />
)}
</button>
</div>
))}
</div>
</div>
)}
</div>
</div>
</div>
</div>
);
});

ForkCard.displayName = 'ForkCard';

export default ForkCard;
Loading
Loading