mirror of
https://github.com/Dvorinka/Trackeep.git
synced 2026-07-29 05:53:50 +00:00
1030 lines
35 KiB
TypeScript
1030 lines
35 KiB
TypeScript
import { createSignal, onMount } from 'solid-js';
|
|
import { Card } from '@/components/ui/Card';
|
|
import { Button } from '@/components/ui/Button';
|
|
import { GitHubActivity } from '@/components/ui/GitHubActivity';
|
|
import { getApiV1BaseUrl } from '@/lib/api-url';
|
|
import { startGitHubSignIn } from '@/lib/oauth';
|
|
import {
|
|
IconBrandGithub,
|
|
IconTrendingUp,
|
|
IconFolder,
|
|
IconStar,
|
|
IconGitFork,
|
|
IconEye,
|
|
IconExternalLink,
|
|
IconRefresh,
|
|
IconActivity
|
|
} from '@tabler/icons-solidjs';
|
|
import { useHaptics } from '@/lib/haptics';
|
|
|
|
interface GitHubRepo {
|
|
id: number;
|
|
name: string;
|
|
full_name: string;
|
|
description: string;
|
|
html_url: string;
|
|
stargazers_count: number;
|
|
forks_count: number;
|
|
watchers_count: number;
|
|
language: string;
|
|
updated_at: string;
|
|
created_at: string;
|
|
size: number;
|
|
open_issues_count: number;
|
|
default_branch: string;
|
|
}
|
|
|
|
interface GitHubAppInstallation {
|
|
installation_id: number;
|
|
account_login: string;
|
|
account_type: string;
|
|
}
|
|
|
|
interface GitHubAppStatus {
|
|
app_slug: string;
|
|
install_enabled: boolean;
|
|
sign_in_configured: boolean;
|
|
credentials_configured: boolean;
|
|
installed: boolean;
|
|
installation?: GitHubAppInstallation;
|
|
}
|
|
|
|
interface GitHubBackupRecord {
|
|
id: number;
|
|
repository_full_name: string;
|
|
local_path: string;
|
|
source: string;
|
|
last_backup_status: string;
|
|
last_backup_error?: string;
|
|
last_backup_at?: string;
|
|
last_backup_size: number;
|
|
}
|
|
|
|
interface GitHubBackupResult {
|
|
repository: string;
|
|
status: string;
|
|
local_path?: string;
|
|
source: string;
|
|
size_bytes?: number;
|
|
error?: string;
|
|
}
|
|
|
|
interface GitHubActivityResponse {
|
|
contributions: Array<{
|
|
date: string;
|
|
count: number;
|
|
level: number;
|
|
}>;
|
|
weekly_data: number[];
|
|
total_count: number;
|
|
}
|
|
|
|
interface GitHubStats {
|
|
totalRepos: number;
|
|
totalStars: number;
|
|
totalForks: number;
|
|
totalWatchers: number;
|
|
languages: Array<{
|
|
name: string;
|
|
count: number;
|
|
color: string;
|
|
}>;
|
|
recentActivity: Array<{
|
|
type: string;
|
|
repo: string;
|
|
date: string;
|
|
message: string;
|
|
}>;
|
|
repos: GitHubRepo[];
|
|
}
|
|
|
|
const API_BASE_URL = getApiV1BaseUrl();
|
|
|
|
export const GitHub = () => {
|
|
const haptics = useHaptics();
|
|
const [githubStats, setGithubStats] = createSignal<GitHubStats>({
|
|
totalRepos: 0,
|
|
totalStars: 0,
|
|
totalForks: 0,
|
|
totalWatchers: 0,
|
|
languages: [],
|
|
recentActivity: [],
|
|
repos: []
|
|
});
|
|
|
|
const [weeklyActivity, setWeeklyActivity] = createSignal([0, 0, 0, 0, 0, 0, 0]);
|
|
const [username, setUsername] = createSignal('');
|
|
const [isConnected, setIsConnected] = createSignal(false);
|
|
const [appStatus, setAppStatus] = createSignal<GitHubAppStatus>({
|
|
app_slug: '',
|
|
install_enabled: false,
|
|
sign_in_configured: false,
|
|
credentials_configured: false,
|
|
installed: false
|
|
});
|
|
const [backups, setBackups] = createSignal<GitHubBackupRecord[]>([]);
|
|
const [backupRoot, setBackupRoot] = createSignal('');
|
|
const [selectedRepos, setSelectedRepos] = createSignal<string[]>([]);
|
|
const [isBackingUp, setIsBackingUp] = createSignal(false);
|
|
const [isInstallingApp, setIsInstallingApp] = createSignal(false);
|
|
const [backupMessage, setBackupMessage] = createSignal('');
|
|
const [backupError, setBackupError] = createSignal('');
|
|
const [selectedLanguage, setSelectedLanguage] = createSignal<string>('');
|
|
const [searchTerm, setSearchTerm] = createSignal<string>('');
|
|
|
|
const weeklyTotal = () => weeklyActivity().reduce((a, b) => a + b, 0);
|
|
const selectedCount = () => selectedRepos().length;
|
|
|
|
const filteredRepos = () => {
|
|
let repos = githubStats().repos;
|
|
|
|
// Filter by language
|
|
if (selectedLanguage()) {
|
|
repos = repos.filter(repo => repo.language === selectedLanguage());
|
|
}
|
|
|
|
// Filter by search term
|
|
if (searchTerm()) {
|
|
const term = searchTerm().toLowerCase();
|
|
repos = repos.filter(repo =>
|
|
repo.name.toLowerCase().includes(term) ||
|
|
repo.description?.toLowerCase().includes(term) ||
|
|
repo.language?.toLowerCase().includes(term)
|
|
);
|
|
}
|
|
|
|
return repos;
|
|
};
|
|
|
|
const uniqueLanguages = () => {
|
|
const languages = new Set(githubStats().repos.map(repo => repo.language).filter(Boolean));
|
|
return Array.from(languages).sort();
|
|
};
|
|
|
|
const getAuthToken = () => {
|
|
return localStorage.getItem('trackeep_token') || localStorage.getItem('token') || '';
|
|
};
|
|
|
|
onMount(() => {
|
|
const params = new URLSearchParams(window.location.search);
|
|
if (params.get('github_app_installed') === '1') {
|
|
setBackupMessage('GitHub App installation completed successfully.');
|
|
window.history.replaceState({}, '', window.location.pathname);
|
|
}
|
|
const callbackError = params.get('github_app_error');
|
|
if (callbackError) {
|
|
setBackupError(`GitHub App setup failed: ${callbackError}`);
|
|
window.history.replaceState({}, '', window.location.pathname);
|
|
}
|
|
checkGitHubConnection();
|
|
});
|
|
|
|
const resetGitHubData = () => {
|
|
setWeeklyActivity([0, 0, 0, 0, 0, 0, 0]);
|
|
setGithubStats({
|
|
totalRepos: 0,
|
|
totalStars: 0,
|
|
totalForks: 0,
|
|
totalWatchers: 0,
|
|
languages: [],
|
|
recentActivity: [],
|
|
repos: []
|
|
});
|
|
setSelectedRepos([]);
|
|
};
|
|
|
|
const processLanguages = (repos: GitHubRepo[]) => {
|
|
const languageMap = new Map<string, number>();
|
|
|
|
repos.forEach(repo => {
|
|
if (repo.language) {
|
|
languageMap.set(repo.language, (languageMap.get(repo.language) || 0) + 1);
|
|
}
|
|
});
|
|
|
|
return Array.from(languageMap.entries()).map(([name, count]) => ({
|
|
name,
|
|
count,
|
|
color: getLanguageColor()
|
|
}));
|
|
};
|
|
|
|
const generateRecentActivity = (repos: GitHubRepo[]) => {
|
|
const sortedRepos = [...repos]
|
|
.sort((a, b) => new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime())
|
|
.slice(0, 5);
|
|
|
|
return sortedRepos.map(repo => ({
|
|
type: 'push',
|
|
repo: repo.name,
|
|
date: formatDate(repo.updated_at),
|
|
message: `Updated ${repo.name}`
|
|
}));
|
|
};
|
|
|
|
const updateStatsFromRepos = (repos: GitHubRepo[]) => {
|
|
const languages = processLanguages(repos);
|
|
const recentActivity = generateRecentActivity(repos);
|
|
const totalStars = repos.reduce((sum: number, repo: GitHubRepo) => sum + repo.stargazers_count, 0);
|
|
const totalForks = repos.reduce((sum: number, repo: GitHubRepo) => sum + repo.forks_count, 0);
|
|
const totalWatchers = repos.reduce((sum: number, repo: GitHubRepo) => sum + repo.watchers_count, 0);
|
|
|
|
setGithubStats({
|
|
totalRepos: repos.length,
|
|
totalStars,
|
|
totalForks,
|
|
totalWatchers,
|
|
languages,
|
|
recentActivity,
|
|
repos
|
|
});
|
|
setSelectedRepos(prev => prev.filter(repoName => repos.some(repo => repo.full_name === repoName)));
|
|
};
|
|
|
|
const extractRepos = (payload: unknown): GitHubRepo[] => {
|
|
if (Array.isArray(payload)) {
|
|
return payload as GitHubRepo[];
|
|
}
|
|
if (payload && typeof payload === 'object' && Array.isArray((payload as { repos?: unknown[] }).repos)) {
|
|
return (payload as { repos: GitHubRepo[] }).repos;
|
|
}
|
|
return [];
|
|
};
|
|
|
|
const fetchGitHubAppStatus = async (): Promise<GitHubAppStatus | null> => {
|
|
try {
|
|
const token = getAuthToken();
|
|
if (!token) {
|
|
return null;
|
|
}
|
|
|
|
const response = await fetch(`${API_BASE_URL}/github/app/status`, {
|
|
headers: {
|
|
'Authorization': `Bearer ${token}`
|
|
}
|
|
});
|
|
if (!response.ok) {
|
|
return null;
|
|
}
|
|
|
|
const data = (await response.json()) as GitHubAppStatus;
|
|
setAppStatus({
|
|
app_slug: data.app_slug || '',
|
|
install_enabled: Boolean(data.install_enabled),
|
|
sign_in_configured: Boolean(data.sign_in_configured),
|
|
credentials_configured: Boolean(data.credentials_configured),
|
|
installed: Boolean(data.installed),
|
|
installation: data.installation
|
|
});
|
|
return data;
|
|
} catch (error) {
|
|
console.error('Failed to fetch GitHub App status:', error);
|
|
return null;
|
|
}
|
|
};
|
|
|
|
const fetchGitHubBackups = async () => {
|
|
try {
|
|
const token = getAuthToken();
|
|
if (!token) {
|
|
return;
|
|
}
|
|
|
|
const response = await fetch(`${API_BASE_URL}/github/backups`, {
|
|
headers: {
|
|
'Authorization': `Bearer ${token}`
|
|
}
|
|
});
|
|
if (!response.ok) {
|
|
return;
|
|
}
|
|
|
|
const data = await response.json();
|
|
const backupList = Array.isArray(data?.backups) ? (data.backups as GitHubBackupRecord[]) : [];
|
|
setBackups(backupList);
|
|
setBackupRoot(typeof data?.backup_root === 'string' ? data.backup_root : '');
|
|
} catch (error) {
|
|
console.error('Failed to fetch GitHub backups:', error);
|
|
}
|
|
};
|
|
|
|
const fetchRepos = async (endpoint: string): Promise<boolean> => {
|
|
try {
|
|
const token = getAuthToken();
|
|
if (!token) {
|
|
throw new Error('No authentication token');
|
|
}
|
|
|
|
const response = await fetch(`${API_BASE_URL}${endpoint}`, {
|
|
headers: {
|
|
'Authorization': `Bearer ${token}`
|
|
}
|
|
});
|
|
if (!response.ok) {
|
|
throw new Error('Failed to fetch GitHub repositories');
|
|
}
|
|
|
|
const data = await response.json();
|
|
const repos = extractRepos(data);
|
|
updateStatsFromRepos(repos);
|
|
return true;
|
|
} catch (error) {
|
|
console.error('Failed to fetch GitHub repositories:', error);
|
|
return false;
|
|
}
|
|
};
|
|
|
|
const fetchGitHubActivity = async () => {
|
|
try {
|
|
const token = getAuthToken();
|
|
if (!token) {
|
|
return;
|
|
}
|
|
|
|
const response = await fetch(`${API_BASE_URL}/github/activity`, {
|
|
headers: {
|
|
'Authorization': `Bearer ${token}`
|
|
}
|
|
});
|
|
if (!response.ok) {
|
|
return;
|
|
}
|
|
|
|
const data = await response.json() as GitHubActivityResponse;
|
|
setWeeklyActivity(data.weekly_data);
|
|
|
|
// Update GitHubActivity component with real data
|
|
updateGitHubActivityComponent(data);
|
|
} catch (error) {
|
|
console.error('Failed to fetch GitHub activity:', error);
|
|
}
|
|
};
|
|
|
|
const updateGitHubActivityComponent = (data: GitHubActivityResponse) => {
|
|
// This will be used to update the GitHubActivity component
|
|
// For now, we'll store the data and let the component pick it up
|
|
const event = new CustomEvent('githubActivityData', { detail: data });
|
|
window.dispatchEvent(event);
|
|
};
|
|
|
|
const fetchGitHubStats = async () => {
|
|
const loaded = await fetchRepos('/github/repos');
|
|
if (!loaded) {
|
|
resetGitHubData();
|
|
}
|
|
};
|
|
|
|
const fetchGitHubAppRepos = async () => {
|
|
const loaded = await fetchRepos('/github/app/repos');
|
|
if (!loaded) {
|
|
resetGitHubData();
|
|
}
|
|
};
|
|
|
|
const checkGitHubConnection = async () => {
|
|
try {
|
|
const token = getAuthToken();
|
|
if (!token) {
|
|
setIsConnected(false);
|
|
setUsername('');
|
|
setAppStatus({
|
|
app_slug: '',
|
|
install_enabled: false,
|
|
sign_in_configured: false,
|
|
credentials_configured: false,
|
|
installed: false
|
|
});
|
|
setBackups([]);
|
|
setBackupRoot('');
|
|
setBackupMessage('');
|
|
setBackupError('');
|
|
resetGitHubData();
|
|
return;
|
|
}
|
|
|
|
const response = await fetch(`${API_BASE_URL}/auth/me`, {
|
|
headers: {
|
|
'Authorization': `Bearer ${token}`
|
|
}
|
|
});
|
|
|
|
const appInfo = await fetchGitHubAppStatus();
|
|
await fetchGitHubBackups();
|
|
if (!response.ok) {
|
|
resetGitHubData();
|
|
return;
|
|
}
|
|
|
|
const userData = await response.json();
|
|
const hasGitHubSignIn = Boolean(userData?.user?.github_id);
|
|
setUsername(typeof userData?.user?.username === 'string' ? userData.user.username : '');
|
|
setIsConnected(hasGitHubSignIn);
|
|
|
|
if (hasGitHubSignIn) {
|
|
await fetchGitHubStats();
|
|
await fetchGitHubActivity();
|
|
return;
|
|
}
|
|
|
|
if (appInfo?.installed && appInfo.credentials_configured) {
|
|
await fetchGitHubAppRepos();
|
|
await fetchGitHubActivity();
|
|
return;
|
|
}
|
|
|
|
resetGitHubData();
|
|
} catch (error) {
|
|
console.error('Failed to check GitHub connection:', error);
|
|
resetGitHubData();
|
|
}
|
|
};
|
|
|
|
const connectGitHub = () => {
|
|
startGitHubSignIn();
|
|
};
|
|
|
|
const installGitHubApp = async () => {
|
|
try {
|
|
setIsInstallingApp(true);
|
|
setBackupError('');
|
|
|
|
const token = getAuthToken();
|
|
if (!token) {
|
|
throw new Error('No authentication token');
|
|
}
|
|
|
|
const response = await fetch(`${API_BASE_URL}/github/app/install-url`, {
|
|
headers: {
|
|
'Authorization': `Bearer ${token}`
|
|
}
|
|
});
|
|
const data = await response.json();
|
|
if (!response.ok || !data?.install_url) {
|
|
const message = typeof data?.error === 'string' ? data.error : 'Failed to create install URL';
|
|
throw new Error(message);
|
|
}
|
|
|
|
window.location.href = data.install_url as string;
|
|
} catch (error) {
|
|
console.error('Failed to start GitHub App installation:', error);
|
|
setBackupError(error instanceof Error ? error.message : 'Failed to start GitHub App installation');
|
|
haptics.warning();
|
|
} finally {
|
|
setIsInstallingApp(false);
|
|
}
|
|
};
|
|
|
|
const toggleRepoSelection = (repoFullName: string) => {
|
|
setSelectedRepos(prev => (
|
|
prev.includes(repoFullName)
|
|
? prev.filter(name => name !== repoFullName)
|
|
: [...prev, repoFullName]
|
|
));
|
|
};
|
|
|
|
const selectAllRepos = () => {
|
|
setSelectedRepos(githubStats().repos.map(repo => repo.full_name));
|
|
};
|
|
|
|
const clearRepoSelection = () => {
|
|
setSelectedRepos([]);
|
|
};
|
|
|
|
const backupSelectedRepositories = async () => {
|
|
try {
|
|
const token = getAuthToken();
|
|
if (!token) {
|
|
throw new Error('No authentication token');
|
|
}
|
|
if (selectedRepos().length === 0) {
|
|
return;
|
|
}
|
|
|
|
setIsBackingUp(true);
|
|
setBackupMessage('');
|
|
setBackupError('');
|
|
|
|
const source = appStatus().installed ? 'github_app' : 'github_user';
|
|
const response = await fetch(`${API_BASE_URL}/github/backups`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Authorization': `Bearer ${token}`,
|
|
'Content-Type': 'application/json'
|
|
},
|
|
body: JSON.stringify({
|
|
repositories: selectedRepos(),
|
|
source
|
|
})
|
|
});
|
|
const data = await response.json();
|
|
if (!response.ok) {
|
|
const errorMessage = typeof data?.error === 'string' ? data.error : 'Backup failed';
|
|
throw new Error(errorMessage);
|
|
}
|
|
|
|
const results = Array.isArray(data?.results) ? (data.results as GitHubBackupResult[]) : [];
|
|
const failed = Number(data?.failed ?? 0);
|
|
const backedUp = Number(data?.backed_up ?? 0);
|
|
const firstFailure = results.find(result => result.status !== 'success');
|
|
if (failed > 0 && firstFailure?.error) {
|
|
setBackupError(firstFailure.error);
|
|
}
|
|
|
|
setBackupMessage(`Backups completed: ${backedUp} succeeded, ${failed} failed.`);
|
|
await fetchGitHubBackups();
|
|
haptics.impact();
|
|
} catch (error) {
|
|
console.error('Failed to backup repositories:', error);
|
|
setBackupError(error instanceof Error ? error.message : 'Failed to backup repositories');
|
|
haptics.warning();
|
|
} finally {
|
|
setIsBackingUp(false);
|
|
}
|
|
};
|
|
|
|
const disconnectGitHub = async () => {
|
|
try {
|
|
setIsConnected(false);
|
|
setUsername('');
|
|
resetGitHubData();
|
|
} catch (error) {
|
|
console.error('Failed to disconnect GitHub:', error);
|
|
}
|
|
};
|
|
|
|
const formatDate = (dateString: string) => {
|
|
return new Date(dateString).toLocaleDateString();
|
|
};
|
|
|
|
const getLanguageColor = () => {
|
|
// Use primary color for all languages instead of language-specific colors
|
|
return 'hsl(var(--primary))';
|
|
};
|
|
|
|
const getLanguageIcon = (language: string) => {
|
|
const iconMap: Record<string, string> = {
|
|
'Go': '🐹',
|
|
'TypeScript': '🔷',
|
|
'JavaScript': '🟨',
|
|
'CSS': '🎨',
|
|
'HTML': '🌐',
|
|
'Python': '🐍',
|
|
'Dart': '🎯',
|
|
'C++': '⚙️',
|
|
'MDX': '📝',
|
|
'Shell': '🐚',
|
|
'Rust': '🦀',
|
|
'Java': '☕',
|
|
'Ruby': '💎',
|
|
'PHP': '🐘',
|
|
'Swift': '🍎',
|
|
'Kotlin': '🎯',
|
|
'Vue': '💚',
|
|
'React': '⚛️',
|
|
'Angular': '🔺',
|
|
'Svelte': '🔥',
|
|
'Docker': '🐳',
|
|
'YAML': '📄',
|
|
'JSON': '📋',
|
|
'Markdown': '📝',
|
|
'SQL': '🗃️',
|
|
'GraphQL': '◈'
|
|
};
|
|
return iconMap[language] || '📄';
|
|
};
|
|
|
|
return (
|
|
<div class="p-6 space-y-6 overflow-x-hidden max-w-full">
|
|
{/* Header */}
|
|
<div class="flex justify-between items-center">
|
|
<div>
|
|
<h1 class="text-3xl font-bold text-foreground">GitHub Integration</h1>
|
|
<p class="text-muted-foreground mt-2">Track your GitHub repositories and activity</p>
|
|
</div>
|
|
<div class="flex gap-2 flex-shrink-0">
|
|
<Button variant="outline" size="sm" onClick={() => {
|
|
checkGitHubConnection();
|
|
haptics.selection();
|
|
}}>
|
|
<IconRefresh class="size-4 mr-2" />
|
|
Refresh
|
|
</Button>
|
|
{isConnected() ? (
|
|
<Button variant="outline" size="sm" onClick={() => {
|
|
disconnectGitHub();
|
|
haptics.warning();
|
|
}}>
|
|
Disconnect
|
|
</Button>
|
|
) : (
|
|
<Button onClick={() => {
|
|
connectGitHub();
|
|
haptics.impact();
|
|
}} disabled={!appStatus().sign_in_configured}>
|
|
<IconBrandGithub class="size-4 mr-2" />
|
|
Connect GitHub Sign-In
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Connection Status */}
|
|
{(isConnected() || (appStatus().installed && appStatus().credentials_configured)) && (
|
|
<Card class="p-4">
|
|
<div class="flex items-center gap-3">
|
|
<div class="bg-primary/10 flex items-center justify-center p-2 rounded-lg">
|
|
<IconBrandGithub class="size-5 text-primary" />
|
|
</div>
|
|
<div>
|
|
<p class="text-sm font-medium text-foreground">
|
|
{isConnected() ? `Connected via GitHub App sign-in as @${username()}` : `Connected via GitHub App installation as @${username()}`}
|
|
</p>
|
|
<p class="text-xs text-muted-foreground">
|
|
{isConnected() ? 'Syncing user data with your GitHub App sign-in token' : 'Syncing repositories from the GitHub App installation'}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
)}
|
|
|
|
{/* GitHub App Status - Only show if not installed */}
|
|
{!appStatus().installed && appStatus().install_enabled && (
|
|
<Card class="p-4">
|
|
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
|
<div>
|
|
<p class="text-sm font-medium text-foreground">GitHub App Backup Access</p>
|
|
<p class="text-xs text-muted-foreground mt-1">
|
|
Not installed yet
|
|
</p>
|
|
{!appStatus().sign_in_configured && (
|
|
<p class="text-xs text-amber-500 mt-1">
|
|
GitHub sign-in is not available from the unified Trackeep control service.
|
|
</p>
|
|
)}
|
|
{appStatus().install_enabled && !appStatus().credentials_configured && (
|
|
<p class="text-xs text-amber-500 mt-1">
|
|
Unified GitHub App credentials are not configured on `hq.trackeep.org`.
|
|
</p>
|
|
)}
|
|
</div>
|
|
<div class="flex gap-2 flex-wrap">
|
|
<Button variant="outline" size="sm" onClick={() => fetchGitHubAppStatus()}>
|
|
Refresh App Status
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
disabled={!appStatus().install_enabled || isInstallingApp() || !isConnected()}
|
|
onClick={() => installGitHubApp()}
|
|
>
|
|
{isInstallingApp() ? 'Opening...' : 'Install GitHub App'}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
)}
|
|
|
|
{/* Stats Overview - 2-column layout with larger left column */}
|
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
|
{/* Left Column - Main Stats */}
|
|
<div class="space-y-4">
|
|
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
|
<Card class="p-6">
|
|
<div class="flex items-center gap-3">
|
|
<div class="bg-primary/10 flex items-center justify-center p-3 rounded-lg">
|
|
<IconFolder class="size-6 text-primary" />
|
|
</div>
|
|
<div>
|
|
<p class="text-2xl font-bold text-foreground">{githubStats().totalRepos}</p>
|
|
<p class="text-sm text-muted-foreground">Repositories</p>
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
|
|
<Card class="p-6">
|
|
<div class="flex items-center gap-3">
|
|
<div class="bg-primary/10 flex items-center justify-center p-3 rounded-lg">
|
|
<IconStar class="size-6 text-primary" />
|
|
</div>
|
|
<div>
|
|
<p class="text-2xl font-bold text-foreground">{githubStats().totalStars}</p>
|
|
<p class="text-sm text-muted-foreground">Total Stars</p>
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
|
|
<Card class="p-6">
|
|
<div class="flex items-center gap-3">
|
|
<div class="bg-primary/10 flex items-center justify-center p-3 rounded-lg">
|
|
<IconGitFork class="size-6 text-primary" />
|
|
</div>
|
|
<div>
|
|
<p class="text-2xl font-bold text-foreground">{githubStats().totalForks}</p>
|
|
<p class="text-sm text-muted-foreground">Total Forks</p>
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
|
|
<Card class="p-6">
|
|
<div class="flex items-center gap-3">
|
|
<div class="bg-primary/10 flex items-center justify-center p-3 rounded-lg">
|
|
<IconEye class="size-6 text-primary" />
|
|
</div>
|
|
<div>
|
|
<p class="text-2xl font-bold text-foreground">{githubStats().totalWatchers}</p>
|
|
<p class="text-sm text-muted-foreground">Watchers</p>
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Right Column - Additional Stats */}
|
|
<div class="space-y-4">
|
|
{/* Additional GitHub stats can go here */}
|
|
<Card class="p-6">
|
|
<div class="flex items-center gap-3">
|
|
<div class="bg-primary/10 flex items-center justify-center p-3 rounded-lg">
|
|
<IconActivity class="size-6 text-primary" />
|
|
</div>
|
|
<div>
|
|
<p class="text-2xl font-bold text-foreground">{weeklyActivity().reduce((a, b) => a + b, 0)}</p>
|
|
<p class="text-sm text-muted-foreground">Weekly Activity</p>
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Two-way Grid: Contribution Graph and Languages - Responsive */}
|
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
|
{/* Contribution Graph - Left Column (larger) */}
|
|
<div class="lg:col-span-1">
|
|
<GitHubActivity
|
|
title="Contribution Activity"
|
|
showStats={false}
|
|
showContributionGraph={true}
|
|
showRecentActivity={false}
|
|
compact={true}
|
|
period="year"
|
|
fullWidth={true}
|
|
hideHeader={false}
|
|
/>
|
|
</div>
|
|
|
|
{/* Languages - Right Column (smaller) */}
|
|
<Card class="p-6 lg:col-span-1">
|
|
<h3 class="text-lg font-semibold text-foreground mb-4">Languages</h3>
|
|
{githubStats().languages.length === 0 ? (
|
|
<p class="text-sm text-muted-foreground">No language data yet.</p>
|
|
) : (
|
|
<div class="space-y-3">
|
|
{githubStats().languages.map((language) => (
|
|
<div class="flex items-center justify-between p-2 rounded-lg hover:bg-muted/50 transition-colors cursor-pointer">
|
|
<div class="flex items-center gap-3">
|
|
<span class="text-lg">{getLanguageIcon(language.name)}</span>
|
|
<div
|
|
class="w-3 h-3 rounded-full flex-shrink-0"
|
|
style={`background-color: ${language.color}`}
|
|
></div>
|
|
<span class="text-sm text-foreground truncate">{language.name}</span>
|
|
</div>
|
|
<span class="text-sm text-muted-foreground flex-shrink-0">{language.count} repos</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</Card>
|
|
</div>
|
|
|
|
{/* Weekly Activity Chart */}
|
|
<Card class="p-6">
|
|
<div class="flex items-center gap-2 mb-4">
|
|
<IconActivity class="size-5 text-primary" />
|
|
<h3 class="text-lg font-semibold text-foreground">Weekly Activity</h3>
|
|
</div>
|
|
<div class="space-y-4">
|
|
{weeklyTotal() === 0 ? (
|
|
<div class="h-32 md:h-36 border border-dashed border-border rounded-lg flex items-center justify-center">
|
|
<p class="text-sm text-muted-foreground">No weekly GitHub activity yet.</p>
|
|
</div>
|
|
) : (
|
|
<div class="relative h-32 md:h-36 px-6 weekly-activity-chart">
|
|
<div class="absolute inset-x-0 inset-y-2 pointer-events-none flex flex-col justify-between">
|
|
<div class="border-t border-border/60"></div>
|
|
<div class="border-t border-border/40"></div>
|
|
<div class="border-t border-border/30"></div>
|
|
<div class="border-t border-border/20"></div>
|
|
</div>
|
|
<div class="relative flex items-end justify-between h-full gap-3 md:gap-4">
|
|
{['M', 'T', 'W', 'T', 'F', 'S', 'S'].map((day, index) => {
|
|
const weeklyActivityData = weeklyActivity();
|
|
const activity = weeklyActivityData[index];
|
|
const maxActivity = Math.max(...weeklyActivityData, 1);
|
|
const heightPercent = (activity / maxActivity) * 85;
|
|
const finalHeightPercent = activity > 0 ? Math.max(heightPercent, 6) : 0;
|
|
|
|
return (
|
|
<div class="flex flex-col items-center flex-1 gap-2 group min-w-0 max-w-8 h-full">
|
|
<div class="relative w-full max-w-4 md:max-w-5 flex flex-col items-center justify-end h-full">
|
|
<span class="text-xs font-medium text-primary mb-1 opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap absolute -top-5 z-10">
|
|
{activity}
|
|
</span>
|
|
<div
|
|
class="w-full max-w-4 md:max-w-5 bg-primary rounded-t transition-all duration-500 hover:opacity-80 cursor-pointer hover:scale-105 weekly-bar"
|
|
style={`height: ${finalHeightPercent}%; background-color: hsl(199, 89%, 67%);`}
|
|
title={`${day}: ${activity} contributions`}
|
|
></div>
|
|
</div>
|
|
<span class="text-xs text-muted-foreground font-medium mt-1">{day}</span>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<div class="flex justify-between text-xs text-muted-foreground pt-2 border-t border-border">
|
|
<span>Total: {weeklyTotal()} contributions</span>
|
|
<span>Avg: {Math.round(weeklyTotal() / 7)} per day</span>
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
|
|
|
|
{/* Recent Activity */}
|
|
<Card class="p-6">
|
|
<h3 class="text-lg font-semibold text-foreground mb-4">Recent Activity</h3>
|
|
{githubStats().recentActivity.length === 0 ? (
|
|
<p class="text-sm text-muted-foreground">No recent GitHub events yet.</p>
|
|
) : (
|
|
<div class="space-y-3">
|
|
{githubStats().recentActivity.map((activity) => (
|
|
<div class="flex items-center justify-between p-3 bg-muted rounded-lg">
|
|
<div class="flex items-center gap-3">
|
|
<div class="bg-primary/10 p-2 rounded-lg">
|
|
<IconTrendingUp class="size-4 text-primary" />
|
|
</div>
|
|
<div>
|
|
<p class="text-sm text-foreground">{activity.message}</p>
|
|
<p class="text-xs text-muted-foreground">{activity.repo} • {activity.date}</p>
|
|
</div>
|
|
</div>
|
|
<span class="text-xs text-muted-foreground capitalize">{activity.type.replace('_', ' ')}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</Card>
|
|
|
|
{/* Repositories */}
|
|
<Card class="p-6">
|
|
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-3 mb-4">
|
|
<h3 class="text-lg font-semibold text-foreground">Repositories</h3>
|
|
{githubStats().repos.length > 0 && (
|
|
<div class="flex gap-2 flex-wrap">
|
|
<Button variant="outline" size="sm" onClick={() => selectAllRepos()}>
|
|
Select All
|
|
</Button>
|
|
<Button variant="outline" size="sm" onClick={() => clearRepoSelection()} disabled={selectedCount() === 0}>
|
|
Clear
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
onClick={() => backupSelectedRepositories()}
|
|
disabled={selectedCount() === 0 || isBackingUp()}
|
|
>
|
|
{isBackingUp() ? 'Backing Up...' : `Backup Selected (${selectedCount()})`}
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Filters */}
|
|
{githubStats().repos.length > 0 && (
|
|
<div class="flex flex-col md:flex-row gap-3 mb-4 p-3 bg-muted/30 rounded-lg">
|
|
<div class="flex-1">
|
|
<input
|
|
type="text"
|
|
placeholder="Search repositories..."
|
|
class="w-full px-3 py-2 border border-border rounded-md bg-background text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary"
|
|
value={searchTerm()}
|
|
onInput={(e) => setSearchTerm(e.currentTarget.value)}
|
|
/>
|
|
</div>
|
|
<div class="flex gap-2">
|
|
<select
|
|
class="px-3 py-2 border border-border rounded-md bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-primary"
|
|
value={selectedLanguage()}
|
|
onChange={(e) => setSelectedLanguage(e.currentTarget.value)}
|
|
>
|
|
<option value="">All Languages</option>
|
|
{uniqueLanguages().map(lang => (
|
|
<option value={lang}>{lang}</option>
|
|
))}
|
|
</select>
|
|
{(selectedLanguage() || searchTerm()) && (
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => {
|
|
setSelectedLanguage('');
|
|
setSearchTerm('');
|
|
}}
|
|
>
|
|
Clear Filters
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{backupMessage() && (
|
|
<p class="text-sm text-emerald-500 mb-3">{backupMessage()}</p>
|
|
)}
|
|
{backupError() && (
|
|
<p class="text-sm text-red-500 mb-3">{backupError()}</p>
|
|
)}
|
|
{backupRoot() && (
|
|
<p class="text-xs text-muted-foreground mb-3">
|
|
Backup root: <span class="font-mono break-all">{backupRoot()}</span>
|
|
</p>
|
|
)}
|
|
{backups().length > 0 && (
|
|
<div class="mb-4 p-3 border border-border rounded-lg bg-muted/30 space-y-2">
|
|
<p class="text-xs font-medium text-foreground">Recent Local Backups</p>
|
|
{backups().slice(0, 5).map((backup) => (
|
|
<div class="flex items-center justify-between text-xs text-muted-foreground">
|
|
<span class="truncate pr-2">{backup.repository_full_name}</span>
|
|
<span class={backup.last_backup_status === 'success' ? 'text-emerald-500' : 'text-red-500'}>
|
|
{backup.last_backup_status}
|
|
</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{githubStats().repos.length === 0 ? (
|
|
<p class="text-sm text-muted-foreground">No repositories available yet.</p>
|
|
) : (
|
|
<div class="space-y-4">
|
|
{filteredRepos().map((repo) => (
|
|
<div class="border border-border rounded-lg p-4 hover:bg-muted/30 transition-colors cursor-pointer group">
|
|
<div class="flex items-start gap-3">
|
|
<input
|
|
type="checkbox"
|
|
class="mt-1 h-4 w-4 rounded border-border accent-primary"
|
|
checked={selectedRepos().includes(repo.full_name)}
|
|
onChange={() => toggleRepoSelection(repo.full_name)}
|
|
onClick={(e) => e.stopPropagation()}
|
|
/>
|
|
<div class="flex-1 min-w-0">
|
|
<div class="flex items-center gap-2 mb-2">
|
|
<h4 class="text-lg font-medium text-foreground group-hover:text-primary transition-colors">{repo.name}</h4>
|
|
{repo.language && (
|
|
<span
|
|
class="text-xs px-2 py-1 rounded-full flex items-center gap-1"
|
|
style={`background-color: ${getLanguageColor()}20; color: ${getLanguageColor()}`}
|
|
>
|
|
<span>{getLanguageIcon(repo.language)}</span>
|
|
{repo.language}
|
|
</span>
|
|
)}
|
|
</div>
|
|
<p class="text-sm text-muted-foreground mb-3">{repo.description}</p>
|
|
<div class="flex items-center gap-4 text-xs text-muted-foreground">
|
|
<div class="flex items-center gap-1">
|
|
<IconStar class="size-3" />
|
|
<span>{repo.stargazers_count}</span>
|
|
</div>
|
|
<div class="flex items-center gap-1">
|
|
<IconGitFork class="size-3" />
|
|
<span>{repo.forks_count}</span>
|
|
</div>
|
|
<div class="flex items-center gap-1">
|
|
<IconEye class="size-3" />
|
|
<span>{repo.watchers_count}</span>
|
|
</div>
|
|
<span>Updated {formatDate(repo.updated_at)}</span>
|
|
</div>
|
|
</div>
|
|
<div class="flex items-center gap-2">
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => window.open(repo.html_url, '_blank', 'noopener,noreferrer')}
|
|
aria-label={`Open ${repo.full_name}`}
|
|
class="opacity-0 group-hover:opacity-100 transition-opacity"
|
|
>
|
|
<IconExternalLink class="size-4" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</Card>
|
|
</div>
|
|
);
|
|
};
|