初始化 GitHub Trending 项目收藏器

功能:每日抓取 GitHub Top 10 趋势项目,展示中文描述、功能摘要与安装命令,
支持用户收藏,未收藏项目 7 天后自动删除。

技术栈:Node.js + Express + SQLite / React + Vite + Tailwind CSS

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
MikiVL
2026-05-03 23:44:14 +08:00
co-authored by Claude Sonnet 4.6
commit 34ce3a3cf9
33 changed files with 6036 additions and 0 deletions
+13
View File
@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>GitHub Trending</title>
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🔥</text></svg>" />
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>
+2764
View File
File diff suppressed because it is too large Load Diff
+22
View File
@@ -0,0 +1,22 @@
{
"name": "github-trending-frontend",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite --port 5173",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@vitejs/plugin-react": "^4.3.4",
"autoprefixer": "^10.4.20",
"postcss": "^8.5.1",
"tailwindcss": "^3.4.17",
"vite": "^6.0.7"
}
}
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
+127
View File
@@ -0,0 +1,127 @@
import { useState, useEffect } from 'react';
import { FilterBar } from './components/FilterBar.jsx';
import { RepoCard } from './components/RepoCard.jsx';
import { useRepos } from './hooks/useRepos.js';
import { fetchStats, triggerScrape } from './api.js';
export default function App() {
const [filter, setFilter] = useState('all');
const [stats, setStats] = useState(null);
const [scraping, setScraping] = useState(false);
const { repos, loading, error, reload, toggleFavorite } = useRepos(filter);
const loadStats = async () => {
try {
const s = await fetchStats();
setStats(s);
} catch {}
};
useEffect(() => { loadStats(); }, [repos]);
const handleToggleFavorite = async (id, current) => {
await toggleFavorite(id, current);
loadStats();
};
const handleScrape = async () => {
setScraping(true);
try {
await triggerScrape();
await reload();
} finally {
setScraping(false);
}
};
return (
<div className="min-h-screen" style={{ background: 'var(--surface-0)' }}>
{/* Ambient background glow */}
<div className="fixed inset-0 pointer-events-none overflow-hidden" aria-hidden>
<div className="absolute top-[-20%] left-[10%] w-[600px] h-[600px] rounded-full opacity-[0.03]"
style={{ background: 'radial-gradient(circle, #00e5a0 0%, transparent 70%)' }} />
<div className="absolute top-[30%] right-[-5%] w-[400px] h-[400px] rounded-full opacity-[0.025]"
style={{ background: 'radial-gradient(circle, #4f8ef7 0%, transparent 70%)' }} />
</div>
<header className="sticky top-0 z-20 backdrop-blur-xl" style={{ borderBottom: '1px solid var(--border)', background: 'rgba(6,8,16,0.85)' }}>
<div className="max-w-5xl mx-auto px-5 py-3.5 flex items-center justify-between gap-4">
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-xl flex items-center justify-center text-base flex-shrink-0"
style={{ background: 'linear-gradient(135deg, rgba(0,229,160,0.2), rgba(0,229,160,0.05))', border: '1px solid rgba(0,229,160,0.25)' }}>
🔥
</div>
<div>
<h1 className="text-sm font-bold tracking-tight" style={{ color: 'var(--text-1)' }}>
github<span style={{ color: 'var(--accent)' }}>.</span>趋势
</h1>
<p className="text-xs" style={{ color: 'var(--text-3)' }}>每日 Top 10 · 自动更新</p>
</div>
</div>
<button
onClick={handleScrape}
disabled={scraping}
className="btn disabled:opacity-40 disabled:cursor-not-allowed"
style={{
background: scraping ? 'transparent' : 'var(--accent-dim)',
borderColor: 'var(--accent-border)',
color: scraping ? 'var(--text-2)' : 'var(--accent)',
}}
>
<span className={scraping ? 'animate-spin' : ''} style={{ display: 'inline-block' }}>↻</span>
{scraping ? 'scraping…' : '刷新'}
</button>
</div>
</header>
<main className="max-w-5xl mx-auto px-5 py-7">
<div className="mb-7">
<FilterBar active={filter} onChange={setFilter} stats={stats} />
</div>
{loading && (
<div className="flex flex-col items-center justify-center py-24 gap-3">
<div className="flex gap-1">
{[0,1,2].map(i => (
<span key={i} className="w-1.5 h-1.5 rounded-full animate-bounce"
style={{ background: 'var(--accent)', animationDelay: `${i * 150}ms` }} />
))}
</div>
<span className="text-xs" style={{ color: 'var(--text-3)' }}>正在获取项目…</span>
</div>
)}
{error && (
<div className="flex flex-col items-center justify-center py-24 gap-3">
<span className="text-2xl">⚠</span>
<p className="text-sm" style={{ color: '#f87171' }}>加载失败:{error}</p>
<button onClick={reload} className="text-xs" style={{ color: 'var(--accent)' }}>重试 →</button>
</div>
)}
{!loading && !error && repos.length === 0 && (
<div className="flex flex-col items-center justify-center py-24 gap-3">
<span className="text-3xl opacity-30">{filter === 'favorites' ? '☆' : '⊙'}</span>
<p className="text-sm text-center max-w-xs" style={{ color: 'var(--text-3)' }}>
{filter === 'favorites'
? '还没有收藏的项目,点击星标收藏吧'
: '暂无数据,点击刷新抓取今日趋势'}
</p>
</div>
)}
{!loading && !error && repos.length > 0 && (
<div className="grid gap-4 lg:grid-cols-2">
{repos.map(repo => (
<RepoCard
key={repo.id}
repo={repo}
onToggleFavorite={handleToggleFavorite}
/>
))}
</div>
)}
</main>
</div>
);
}
+26
View File
@@ -0,0 +1,26 @@
const BASE = '/api';
export async function fetchRepos(filter = 'all') {
const res = await fetch(`${BASE}/repos?filter=${filter}`);
if (!res.ok) throw new Error('Failed to fetch repos');
return res.json();
}
export async function fetchStats() {
const res = await fetch(`${BASE}/stats`);
if (!res.ok) throw new Error('Failed to fetch stats');
return res.json();
}
export async function setFavorite(id, value) {
const method = value ? 'POST' : 'DELETE';
const res = await fetch(`${BASE}/repos/${id}/favorite`, { method });
if (!res.ok) throw new Error('Failed to update favorite');
return res.json();
}
export async function triggerScrape() {
const res = await fetch(`${BASE}/scrape`, { method: 'POST' });
if (!res.ok) throw new Error('Scrape failed');
return res.json();
}
+45
View File
@@ -0,0 +1,45 @@
import { useState } from 'react';
export function CodeBlock({ code }) {
const [copied, setCopied] = useState(false);
if (!code) return null;
const copy = () => {
navigator.clipboard.writeText(code).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
});
};
return (
<div className="rounded-xl overflow-hidden" style={{ border: '1px solid var(--border)' }}>
<div className="flex items-center justify-between px-3 py-2"
style={{ background: 'var(--surface-3)', borderBottom: '1px solid var(--border)' }}>
<div className="flex items-center gap-1.5">
<span className="w-2 h-2 rounded-full" style={{ background: '#ff5f57' }} />
<span className="w-2 h-2 rounded-full" style={{ background: '#febc2e' }} />
<span className="w-2 h-2 rounded-full" style={{ background: '#28c840' }} />
<span className="text-xs ml-2 font-medium" style={{ color: 'var(--text-3)' }}>安装命令</span>
</div>
<button
onClick={copy}
className="text-xs font-medium transition-colors duration-150 px-2 py-0.5 rounded"
style={copied ? {
color: 'var(--accent)',
background: 'var(--accent-dim)',
} : {
color: 'var(--text-3)',
}}
onMouseEnter={e => { if (!copied) e.currentTarget.style.color = 'var(--text-1)'; }}
onMouseLeave={e => { if (!copied) e.currentTarget.style.color = 'var(--text-3)'; }}
>
{copied ? '✓ 已复制' : '复制'}
</button>
</div>
<pre className="p-4 text-xs overflow-x-auto leading-relaxed" style={{ background: '#070b12', color: '#00e5a0', fontFamily: 'inherit' }}>
<code>{code}</code>
</pre>
</div>
);
}
+25
View File
@@ -0,0 +1,25 @@
export function DeletionBadge({ daysUntilDeletion }) {
if (daysUntilDeletion === null || daysUntilDeletion === undefined) return null;
const urgent = daysUntilDeletion <= 2;
const label = daysUntilDeletion === 0
? '今日删除'
: `${daysUntilDeletion} 天后删除`;
return (
<span
className="badge"
style={urgent ? {
background: 'rgba(239,68,68,0.08)',
border: '1px solid rgba(239,68,68,0.25)',
color: '#f87171',
} : {
background: 'rgba(251,191,36,0.06)',
border: '1px solid rgba(251,191,36,0.18)',
color: '#d97706',
}}
>
⏱ {label}
</span>
);
}
@@ -0,0 +1,33 @@
export function FavoriteButton({ isFavorited, onClick }) {
return (
<button
onClick={onClick}
title={isFavorited ? '取消收藏' : '收藏此项目'}
className="btn flex-shrink-0 transition-all duration-200"
style={isFavorited ? {
background: 'rgba(0,229,160,0.08)',
borderColor: 'rgba(0,229,160,0.3)',
color: 'var(--accent)',
} : {
background: 'var(--surface-3)',
borderColor: 'var(--border)',
color: 'var(--text-2)',
}}
onMouseEnter={e => {
if (!isFavorited) {
e.currentTarget.style.borderColor = 'rgba(0,229,160,0.25)';
e.currentTarget.style.color = 'var(--accent)';
}
}}
onMouseLeave={e => {
if (!isFavorited) {
e.currentTarget.style.borderColor = 'var(--border)';
e.currentTarget.style.color = 'var(--text-2)';
}
}}
>
<span>{isFavorited ? '★' : '☆'}</span>
{isFavorited ? '已收藏' : '收藏'}
</button>
);
}
+27
View File
@@ -0,0 +1,27 @@
import { useState } from 'react';
export function FeaturesPanel({ summary }) {
const [expanded, setExpanded] = useState(false);
if (!summary) return null;
const isLong = summary.length > 150;
const displayed = !isLong || expanded ? summary : summary.slice(0, 150) + '…';
return (
<div className="pl-3 py-1" style={{ borderLeft: '2px solid rgba(0,229,160,0.2)' }}>
<p className="text-xs leading-relaxed" style={{ color: 'var(--text-2)' }}>{displayed}</p>
{isLong && (
<button
onClick={() => setExpanded(e => !e)}
className="text-xs mt-1 font-medium transition-colors duration-150"
style={{ color: 'var(--accent)', opacity: 0.7 }}
onMouseEnter={e => e.currentTarget.style.opacity = '1'}
onMouseLeave={e => e.currentTarget.style.opacity = '0.7'}
>
{expanded ? '↑ 收起' : '↓ 展开'}
</button>
)}
</div>
);
}
+45
View File
@@ -0,0 +1,45 @@
export function FilterBar({ active, onChange, stats }) {
const tabs = [
{ key: 'all', label: '全部项目', icon: '⊞' },
{ key: 'today', label: '今日趋势', icon: '▲' },
{ key: 'favorites', label: '已收藏', icon: '★' },
];
return (
<div className="flex items-center gap-2 flex-wrap">
<div className="flex items-center gap-1 p-1 rounded-xl" style={{ background: 'var(--surface-2)', border: '1px solid var(--border)' }}>
{tabs.map(tab => (
<button
key={tab.key}
onClick={() => onChange(tab.key)}
className="tab flex items-center gap-1.5"
style={active === tab.key ? {
background: 'var(--accent-dim)',
border: '1px solid var(--accent-border)',
color: 'var(--accent)',
} : {
background: 'transparent',
border: '1px solid transparent',
color: 'var(--text-2)',
}}
>
<span className="text-xs opacity-70">{tab.icon}</span>
{tab.label}
{tab.key === 'favorites' && stats?.favorited > 0 && (
<span className="text-xs px-1.5 py-0 rounded-md font-bold"
style={{ background: 'rgba(0,229,160,0.15)', color: 'var(--accent)' }}>
{stats.favorited}
</span>
)}
</button>
))}
</div>
{stats?.pendingDeletion > 0 && (
<span className="text-xs px-2 py-1 rounded-lg"
style={{ background: 'rgba(251,191,36,0.08)', border: '1px solid rgba(251,191,36,0.2)', color: '#fbbf24' }}>
⚠ {stats.pendingDeletion} 个即将过期
</span>
)}
</div>
);
}
+120
View File
@@ -0,0 +1,120 @@
import { FavoriteButton } from './FavoriteButton.jsx';
import { DeletionBadge } from './DeletionBadge.jsx';
import { CodeBlock } from './CodeBlock.jsx';
import { FeaturesPanel } from './FeaturesPanel.jsx';
const LANG_COLORS = {
JavaScript: '#f0db4f',
TypeScript: '#3178c6',
Python: '#3572a5',
Go: '#00acd7',
Rust: '#dea584',
Java: '#b07219',
'C++': '#f34b7d',
C: '#555555',
Ruby: '#701516',
Swift: '#f05138',
Kotlin: '#a97bff',
PHP: '#4f5d95',
Shell: '#89e051',
Nix: '#7e7eff',
};
function formatStars(n) {
if (n >= 1000) return (n / 1000).toFixed(1) + 'k';
return String(n);
}
export function RepoCard({ repo, onToggleFavorite }) {
const langColor = LANG_COLORS[repo.language] || '#4a5568';
return (
<div className="card group">
{/* Top row: avatar + name + favorite */}
<div className="flex items-start justify-between gap-3">
<div className="flex items-center gap-3 min-w-0">
{repo.avatar_url ? (
<img
src={repo.avatar_url}
alt={repo.owner}
className="w-10 h-10 rounded-xl flex-shrink-0 ring-1"
style={{ ringColor: 'var(--border)' }}
/>
) : (
<div className="w-10 h-10 rounded-xl flex-shrink-0 flex items-center justify-center text-lg"
style={{ background: 'var(--surface-3)', border: '1px solid var(--border)' }}>
⬡
</div>
)}
<div className="min-w-0 flex-1">
<a
href={repo.html_url}
target="_blank"
rel="noopener noreferrer"
className="font-bold text-sm tracking-tight block truncate transition-colors duration-150"
style={{ color: 'var(--text-1)' }}
onMouseEnter={e => e.currentTarget.style.color = 'var(--accent)'}
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-1)'}
>
<span style={{ color: 'var(--text-2)', fontWeight: 400 }}>{repo.owner}</span>
<span style={{ color: 'var(--text-3)' }}>/</span>
<span>{repo.name}</span>
</a>
{repo.description && (
<p className="text-xs mt-0.5 line-clamp-2 leading-relaxed" style={{ color: 'var(--text-2)' }}>
{repo.description}
</p>
)}
</div>
</div>
<FavoriteButton
isFavorited={!!repo.is_favorited}
onClick={() => onToggleFavorite(repo.id, !!repo.is_favorited)}
/>
</div>
{/* Meta row: language + stars + badges */}
<div className="flex items-center gap-3 flex-wrap" style={{ borderTop: '1px solid var(--border)', paddingTop: '12px' }}>
{repo.language && (
<span className="flex items-center gap-1.5 text-xs" style={{ color: 'var(--text-2)' }}>
<span className="w-2 h-2 rounded-full flex-shrink-0" style={{ background: langColor, boxShadow: `0 0 6px ${langColor}66` }} />
{repo.language}
</span>
)}
<span className="flex items-center gap-1 text-xs" style={{ color: 'var(--text-2)' }}>
<span style={{ color: '#fbbf24' }}>★</span>
<span className="font-medium">{formatStars(repo.stars)}</span>
</span>
{!repo.is_favorited && (
<DeletionBadge daysUntilDeletion={repo.days_until_deletion} />
)}
{!!repo.is_favorited && (
<span className="badge" style={{ background: 'rgba(0,229,160,0.08)', border: '1px solid rgba(0,229,160,0.2)', color: 'var(--accent)' }}>
★ 已收藏
</span>
)}
</div>
<FeaturesPanel summary={repo.features_summary} />
<CodeBlock code={repo.install_code} />
{/* 项目地址 */}
<div className="flex items-center justify-between pt-1" style={{ borderTop: '1px solid var(--border)' }}>
<span className="text-xs" style={{ color: 'var(--text-3)' }}>项目地址</span>
<a
href={repo.html_url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1.5 text-xs font-medium transition-colors duration-150"
style={{ color: 'var(--text-2)' }}
onMouseEnter={e => e.currentTarget.style.color = 'var(--accent)'}
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-2)'}
>
<span style={{ opacity: 0.5 }}>github.com/</span>
<span>{repo.full_name}</span>
<span style={{ fontSize: '10px' }}>↗</span>
</a>
</div>
</div>
);
}
+39
View File
@@ -0,0 +1,39 @@
import { useState, useEffect, useCallback } from 'react';
import { fetchRepos, setFavorite } from '../api.js';
export function useRepos(filter) {
const [repos, setRepos] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const load = useCallback(async () => {
setLoading(true);
setError(null);
try {
const data = await fetchRepos(filter);
setRepos(data);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
}, [filter]);
useEffect(() => { load(); }, [load]);
const toggleFavorite = useCallback(async (id, currentValue) => {
const newValue = !currentValue;
setRepos(prev =>
prev.map(r => r.id === id ? { ...r, is_favorited: newValue ? 1 : 0 } : r)
);
try {
await setFavorite(id, newValue);
} catch {
setRepos(prev =>
prev.map(r => r.id === id ? { ...r, is_favorited: currentValue ? 1 : 0 } : r)
);
}
}, []);
return { repos, loading, error, reload: load, toggleFavorite };
}
+10
View File
@@ -0,0 +1,10 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App.jsx';
import './styles/index.css';
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<App />
</React.StrictMode>
);
+59
View File
@@ -0,0 +1,59 @@
@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@300;400;500;600;700&display=swap');
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
--accent: #00e5a0;
--accent-dim: rgba(0, 229, 160, 0.1);
--accent-border: rgba(0, 229, 160, 0.25);
--surface-0: #060810;
--surface-1: #0b0f1a;
--surface-2: #10151f;
--surface-3: #161c28;
--border: rgba(255, 255, 255, 0.07);
--border-hover: rgba(255, 255, 255, 0.13);
--text-1: #e2e8f5;
--text-2: #7d8fa8;
--text-3: #3d4a5c;
}
@layer base {
body {
font-family: 'JetBrains Mono', 'Fira Code', ui-monospace, monospace;
background-color: var(--surface-0);
color: var(--text-1);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
* {
scrollbar-width: thin;
scrollbar-color: rgba(255,255,255,0.08) transparent;
}
::-webkit-scrollbar { width: 4px; height: 4px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.08); border-radius: 2px; }
}
@layer components {
.card {
@apply relative flex flex-col gap-4 p-5 rounded-2xl transition-all duration-300 cursor-default;
background: linear-gradient(145deg, var(--surface-1) 0%, var(--surface-2) 100%);
border: 1px solid var(--border);
}
.card:hover {
border-color: var(--border-hover);
box-shadow: 0 0 0 1px rgba(0, 229, 160, 0.06), 0 12px 40px rgba(0, 0, 0, 0.5);
transform: translateY(-2px);
}
.badge {
@apply inline-flex items-center px-2 py-0.5 rounded-md text-xs font-medium;
}
.btn {
@apply inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition-all duration-200 cursor-pointer border;
}
.tab {
@apply px-4 py-2 rounded-lg text-xs font-medium transition-all duration-200 cursor-pointer;
}
}
+8
View File
@@ -0,0 +1,8 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ['./index.html', './src/**/*.{js,jsx}'],
theme: {
extend: {},
},
plugins: [],
};
+11
View File
@@ -0,0 +1,11 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
server: {
proxy: {
'/api': 'http://localhost:3001',
},
},
});