初始化 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
commit 34ce3a3cf9
33 changed files with 6036 additions and 0 deletions

2
.env.example Normal file
View File

@ -0,0 +1,2 @@
GITHUB_TOKEN=
PORT=3001

4
.gitignore vendored Normal file
View File

@ -0,0 +1,4 @@
node_modules/
data/
.env
frontend/dist/

76
README.md Normal file
View File

@ -0,0 +1,76 @@
# GitHub Trending 项目收藏器
每日自动抓取 GitHub 热度最高的 10 个项目,展示功能介绍与安装代码,支持收藏保存,未收藏项目 7 天后自动删除。
## 快速启动
### 方式一:生产模式(单端口,推荐 demo
```bash
# 1. 安装依赖
npm run install:all
# 2. 构建前端
npm run build
# 3. 启动服务
npm start
```
访问 → **http://localhost:3001**
---
### 方式二:开发模式(热更新)
```bash
npm run install:all
npm run dev
```
- 前端http://localhost:5173热更新
- 后端 APIhttp://localhost:3001
---
## 可选配置
添加 GitHub Token 可将 API 速率限制从 60次/小时 提升至 5000次/小时:
```bash
cp .env.example .env
# 编辑 .env填入 GITHUB_TOKEN=ghp_xxx
```
---
## 功能说明
| 功能 | 说明 |
|------|------|
| 自动抓取 | 每天凌晨 2:00 自动抓取 GitHub 当日 Top 10 趋势项目 |
| 手动刷新 | 点击页面右上角「↻ 刷新」按钮立即触发抓取 |
| 功能摘要 | 自动从 README 提取功能介绍段落 |
| 安装代码 | 自动从 README 提取安装/快速开始代码块 |
| 收藏保存 | 点击「☆ Save」收藏项目收藏后永久保留 |
| 自动删除 | 未收藏的项目在首次抓取 7 天后自动删除 |
| 删除倒计时 | 卡片上显示「⏱ Auto-deletes in Nd」徽章 |
| 筛选标签 | All Repos / Today's Trending / Favorites 三个视图 |
## API 接口
```
GET /api/repos?filter=all|today|favorites # 获取项目列表
GET /api/repos/:id # 获取单个项目
POST /api/repos/:id/favorite # 收藏
DELETE /api/repos/:id/favorite # 取消收藏
GET /api/stats # 统计数据
POST /api/scrape # 手动触发抓取
```
## 技术栈
- **后端**Node.js + Express + SQLite (better-sqlite3)
- **前端**React + Vite + Tailwind CSS
- **抓取**cheerio 解析 github.com/trending + GitHub REST API
- **调度**node-cron 每日定时任务

1747
backend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

19
backend/package.json Normal file
View File

@ -0,0 +1,19 @@
{
"name": "github-trending-backend",
"version": "1.0.0",
"private": true,
"main": "src/server.js",
"scripts": {
"dev": "node --watch src/server.js",
"start": "node src/server.js"
},
"dependencies": {
"axios": "^1.7.9",
"better-sqlite3": "^11.7.0",
"cheerio": "^1.0.0",
"cors": "^2.8.5",
"dotenv": "^16.4.7",
"express": "^4.21.2",
"node-cron": "^3.0.3"
}
}

15
backend/src/cleanup.js Normal file
View File

@ -0,0 +1,15 @@
const db = require('./db');
function cleanup() {
const result = db.prepare(`
DELETE FROM repositories
WHERE is_favorited = 0
AND date(first_fetched_at) <= date('now', '-7 days')
`).run();
if (result.changes > 0) {
console.log(`[cleanup] Deleted ${result.changes} unfavorited repo(s) older than 7 days`);
}
}
module.exports = { cleanup };

37
backend/src/db.js Normal file
View File

@ -0,0 +1,37 @@
const Database = require('better-sqlite3');
const path = require('path');
const fs = require('fs');
const dataDir = path.join(__dirname, '../../data');
if (!fs.existsSync(dataDir)) fs.mkdirSync(dataDir, { recursive: true });
const db = new Database(path.join(dataDir, 'github_trending.db'));
db.pragma('journal_mode = WAL');
db.pragma('foreign_keys = ON');
db.exec(`
CREATE TABLE IF NOT EXISTS repositories (
id INTEGER PRIMARY KEY AUTOINCREMENT,
full_name TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
owner TEXT NOT NULL,
description TEXT,
language TEXT,
stars INTEGER NOT NULL DEFAULT 0,
html_url TEXT NOT NULL,
avatar_url TEXT,
features_summary TEXT,
install_code TEXT,
first_fetched_at TEXT NOT NULL,
last_fetched_at TEXT NOT NULL,
is_favorited INTEGER NOT NULL DEFAULT 0,
favorited_at TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_repos_favorited ON repositories(is_favorited);
CREATE INDEX IF NOT EXISTS idx_repos_fetched_at ON repositories(first_fetched_at);
`);
module.exports = db;

View File

@ -0,0 +1,14 @@
const { scrape } = require('../scraper');
const { cleanup } = require('../cleanup');
async function triggerScrape(req, res) {
try {
await scrape();
cleanup();
res.json({ success: true, message: 'Scrape completed' });
} catch (err) {
res.status(500).json({ error: err.message });
}
}
module.exports = { triggerScrape };

View File

@ -0,0 +1,27 @@
const db = require('../db');
function favorite(req, res) {
const row = db.prepare('SELECT id FROM repositories WHERE id = ?').get(req.params.id);
if (!row) return res.status(404).json({ error: 'Not found' });
db.prepare(`
UPDATE repositories SET is_favorited = 1, favorited_at = datetime('now')
WHERE id = ?
`).run(req.params.id);
res.json({ success: true });
}
function unfavorite(req, res) {
const row = db.prepare('SELECT id FROM repositories WHERE id = ?').get(req.params.id);
if (!row) return res.status(404).json({ error: 'Not found' });
db.prepare(`
UPDATE repositories SET is_favorited = 0, favorited_at = NULL
WHERE id = ?
`).run(req.params.id);
res.json({ success: true });
}
module.exports = { favorite, unfavorite };

View File

@ -0,0 +1,59 @@
const db = require('../db');
function computeDaysUntilDeletion(row) {
if (row.is_favorited) return null;
const first = new Date(row.first_fetched_at);
const now = new Date();
const daysSince = Math.floor((now - first) / (1000 * 60 * 60 * 24));
return Math.max(0, 7 - daysSince);
}
function formatRepo(row) {
return { ...row, days_until_deletion: computeDaysUntilDeletion(row) };
}
function getRepos(req, res) {
const filter = req.query.filter || 'all';
let rows;
if (filter === 'today') {
rows = db.prepare(`
SELECT * FROM repositories
WHERE date(last_fetched_at) = date('now')
ORDER BY stars DESC
`).all();
} else if (filter === 'favorites') {
rows = db.prepare(`
SELECT * FROM repositories
WHERE is_favorited = 1
ORDER BY favorited_at DESC
`).all();
} else {
rows = db.prepare(`
SELECT * FROM repositories
ORDER BY stars DESC
`).all();
}
res.json(rows.map(formatRepo));
}
function getRepo(req, res) {
const row = db.prepare('SELECT * FROM repositories WHERE id = ?').get(req.params.id);
if (!row) return res.status(404).json({ error: 'Not found' });
res.json(formatRepo(row));
}
function getStats(req, res) {
const total = db.prepare('SELECT COUNT(*) as count FROM repositories').get().count;
const favorited = db.prepare('SELECT COUNT(*) as count FROM repositories WHERE is_favorited = 1').get().count;
const pendingDeletion = db.prepare(`
SELECT COUNT(*) as count FROM repositories
WHERE is_favorited = 0
AND date(first_fetched_at) <= date('now', '-6 days')
`).get().count;
res.json({ total, favorited, pendingDeletion });
}
module.exports = { getRepos, getRepo, getStats };

23
backend/src/scheduler.js Normal file
View File

@ -0,0 +1,23 @@
const cron = require('node-cron');
const { scrape } = require('./scraper');
const { cleanup } = require('./cleanup');
async function runJob() {
try {
await scrape();
cleanup();
} catch (err) {
console.error('[scheduler] Job failed:', err.message);
}
}
function initScheduler() {
// Run daily at 2:00 AM
cron.schedule('0 2 * * *', runJob);
console.log('[scheduler] Daily scrape scheduled at 02:00');
// Run once immediately on startup
runJob();
}
module.exports = { initScheduler };

178
backend/src/scraper.js Normal file
View File

@ -0,0 +1,178 @@
const axios = require('axios');
const cheerio = require('cheerio');
const db = require('./db');
const { translate } = require('./translate');
const TRENDING_URL = 'https://github.com/trending?since=daily';
const README_URL = (owner, repo) => `https://api.github.com/repos/${owner}/${repo}/readme`;
function buildHeaders() {
const headers = { 'User-Agent': 'github-trending-app/1.0' };
if (process.env.GITHUB_TOKEN) {
headers['Authorization'] = `token ${process.env.GITHUB_TOKEN}`;
}
return headers;
}
async function fetchTrending() {
const res = await axios.get(TRENDING_URL, {
headers: {
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36',
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
'Accept-Language': 'en-US,en;q=0.5',
},
timeout: 30000,
});
const $ = cheerio.load(res.data);
const repos = [];
$('article.Box-row').slice(0, 10).each((_, el) => {
const $el = $(el);
const href = $el.find('h2 a').attr('href') || '';
const parts = href.replace(/^\//, '').split('/').filter(Boolean);
const [owner, name] = parts;
if (!owner || !name) return;
const descEl = $el.find('p').first();
const description = descEl.text().trim() || null;
const starsText = $el.find('a[href$="/stargazers"]').first().text().replace(/,/g, '').trim();
const stars = parseInt(starsText, 10) || 0;
const language = $el.find('span[itemprop="programmingLanguage"]').text().trim() || null;
repos.push({ owner, name, full_name: `${owner}/${name}`, description, stars, language });
});
return repos;
}
async function fetchReadme(owner, repo) {
try {
const res = await axios.get(README_URL(owner, repo), {
headers: buildHeaders(),
timeout: 10000,
});
const content = Buffer.from(res.data.content, 'base64').toString('utf-8');
return content;
} catch {
return null;
}
}
// 候选标题关键词:英文 + 中文 + emoji前缀容忍
const FEATURES_RE = /^#{1,3}[^#\n]*(?:features?|highlights?|overview|about|what is|why|introduction|capabilities|framework|skills?|development|功能|特性|特点|介绍|亮点)[^#\n]*/im;
function extractSection(readme, headingMatch) {
const start = readme.indexOf(headingMatch) + headingMatch.length;
const nextHeading = readme.slice(start).search(/^#{1,3}\s/m);
const section = nextHeading === -1
? readme.slice(start)
: readme.slice(start, start + nextHeading);
return section
.replace(/```[\s\S]*?```/g, '')
.replace(/!\[.*?\]\(.*?\)/g, '')
.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1')
.replace(/[*_`#>\-|]/g, '')
.replace(/\s+/g, ' ')
.trim()
.slice(0, 500) || null;
}
function extractFeaturesSection(readme, description) {
if (!readme) return description || null;
// 优先匹配功能相关标题
const match = readme.match(FEATURES_RE);
if (match) {
const result = extractSection(readme, match[0]);
if (result && result.length > 20) return result;
}
// 兜底取README正文第一段有意义的文字跳过徽章行
const lines = readme.split('\n');
const paragraphs = [];
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#') || trimmed.startsWith('!') || trimmed.startsWith('[!') || trimmed.startsWith('<')) continue;
if (trimmed.startsWith('[![') || trimmed.match(/^\[!\[/)) continue;
if (trimmed.length > 40) paragraphs.push(trimmed);
if (paragraphs.length >= 3) break;
}
const fallback = paragraphs.join(' ').replace(/\s+/g, ' ').trim().slice(0, 500);
return fallback.length > 20 ? fallback : (description || null);
}
function extractInstallCode(readme) {
if (!readme) return null;
const installMatch = readme.match(
/#{1,3}\s*(?:install(?:ation)?|setup|getting[- ]started|quick[- ]start)\b[^\n]*/i
);
const searchFrom = installMatch
? readme.indexOf(installMatch[0])
: 0;
const slice = readme.slice(searchFrom);
const codeMatch = slice.match(/```[^\n]*\n([\s\S]*?)```/);
return codeMatch ? codeMatch[1].trim() : null;
}
async function fetchOwnerAvatar(owner) {
try {
const res = await axios.get(`https://api.github.com/users/${owner}`, {
headers: buildHeaders(),
timeout: 5000,
});
return res.data.avatar_url || null;
} catch {
return null;
}
}
const upsert = db.prepare(`
INSERT INTO repositories
(full_name, name, owner, description, language, stars, html_url, avatar_url,
features_summary, install_code, first_fetched_at, last_fetched_at)
VALUES
(@full_name, @name, @owner, @description, @language, @stars, @html_url, @avatar_url,
@features_summary, @install_code, date('now'), date('now'))
ON CONFLICT(full_name) DO UPDATE SET
stars = excluded.stars,
description = excluded.description,
language = excluded.language,
avatar_url = excluded.avatar_url,
features_summary = excluded.features_summary,
install_code = excluded.install_code,
last_fetched_at = excluded.last_fetched_at
`);
async function scrape() {
console.log('[scraper] Starting scrape...');
const repos = await fetchTrending();
console.log(`[scraper] Found ${repos.length} trending repos`);
for (const repo of repos) {
const readme = await fetchReadme(repo.owner, repo.name);
const avatar_url = await fetchOwnerAvatar(repo.owner);
const features_summary_raw = extractFeaturesSection(readme, repo.description);
const install_code = extractInstallCode(readme);
const [description_zh, features_summary] = await Promise.all([
translate(repo.description),
translate(features_summary_raw),
]);
upsert.run({
...repo,
description: description_zh,
html_url: `https://github.com/${repo.full_name}`,
avatar_url,
features_summary,
install_code,
});
console.log(`[scraper] Saved ${repo.full_name}`);
}
console.log('[scraper] Done.');
}
module.exports = { scrape };

33
backend/src/server.js Normal file
View File

@ -0,0 +1,33 @@
require('dotenv').config({ path: require('path').join(__dirname, '../../.env') });
const express = require('express');
const cors = require('cors');
const path = require('path');
const { getRepos, getRepo, getStats } = require('./routes/repos');
const { favorite, unfavorite } = require('./routes/favorites');
const { triggerScrape } = require('./routes/admin');
const { initScheduler } = require('./scheduler');
const app = express();
const PORT = process.env.PORT || 3001;
app.use(cors());
app.use(express.json());
app.get('/api/repos', getRepos);
app.get('/api/stats', getStats);
app.get('/api/repos/:id', getRepo);
app.post('/api/repos/:id/favorite', favorite);
app.delete('/api/repos/:id/favorite', unfavorite);
app.post('/api/scrape', triggerScrape);
const frontendDist = path.join(__dirname, '../../frontend/dist');
app.use(express.static(frontendDist));
app.get('*', (req, res) => {
res.sendFile(path.join(frontendDist, 'index.html'));
});
app.listen(PORT, () => {
console.log(`[server] Running on http://localhost:${PORT}`);
initScheduler();
});

36
backend/src/translate.js Normal file
View File

@ -0,0 +1,36 @@
const axios = require('axios');
const CJK_RE = /[一-鿿㐀-䶿]/;
const HTML_TAG_RE = /<[^>]{1,100}>/g;
function cleanText(text) {
if (!text) return text;
return text
.replace(HTML_TAG_RE, ' ') // 去除 HTML 标签
.replace(/&[a-z]+;/gi, ' ') // 去除 HTML 实体
.replace(/\s+/g, ' ')
.trim();
}
async function translate(text) {
const cleaned = cleanText(text);
if (!cleaned || cleaned.length < 5) return cleaned || text;
if (CJK_RE.test(cleaned)) return cleaned; // 已含中文,跳过
try {
const res = await axios.get('https://api.mymemory.translated.net/get', {
params: { q: cleaned.slice(0, 500), langpair: 'en|zh-CN' },
headers: { 'User-Agent': 'github-trending-app/1.0' },
timeout: 10000,
});
const translated = res.data?.responseData?.translatedText;
if (!translated || translated === cleaned || translated.startsWith('PLEASE SELECT')) {
return cleaned;
}
return translated.trim();
} catch {
return cleaned; // 失败静默降级,返回清理后原文
}
}
module.exports = { translate };

13
frontend/index.html Normal file
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
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

22
frontend/package.json Normal file
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"
}
}

View File

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

127
frontend/src/App.jsx Normal file
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
frontend/src/api.js Normal file
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();
}

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>
);
}

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>
);
}

View File

@ -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>
);
}

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>
);
}

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>
);
}

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>
);
}

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
frontend/src/main.jsx Normal file
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>
);

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;
}
}

View File

@ -0,0 +1,8 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ['./index.html', './src/**/*.{js,jsx}'],
theme: {
extend: {},
},
plugins: [],
};

11
frontend/vite.config.js Normal file
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',
},
},
});

372
package-lock.json generated Normal file
View File

@ -0,0 +1,372 @@
{
"name": "github-trending",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "github-trending",
"version": "1.0.0",
"devDependencies": {
"concurrently": "^8.2.2"
}
},
"node_modules/@babel/runtime": {
"version": "7.29.2",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz",
"integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/ansi-regex": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/ansi-styles": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
"dev": true,
"license": "MIT",
"dependencies": {
"color-convert": "^2.0.1"
},
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/chalk": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
"integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-styles": "^4.1.0",
"supports-color": "^7.1.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/chalk/chalk?sponsor=1"
}
},
"node_modules/chalk/node_modules/supports-color": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
"integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
"dev": true,
"license": "MIT",
"dependencies": {
"has-flag": "^4.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/cliui": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz",
"integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==",
"dev": true,
"license": "ISC",
"dependencies": {
"string-width": "^4.2.0",
"strip-ansi": "^6.0.1",
"wrap-ansi": "^7.0.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"color-name": "~1.1.4"
},
"engines": {
"node": ">=7.0.0"
}
},
"node_modules/color-name": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"dev": true,
"license": "MIT"
},
"node_modules/concurrently": {
"version": "8.2.2",
"resolved": "https://registry.npmjs.org/concurrently/-/concurrently-8.2.2.tgz",
"integrity": "sha512-1dP4gpXFhei8IOtlXRE/T/4H88ElHgTiUzh71YUmtjTEHMSRS2Z/fgOxHSxxusGHogsRfxNq1vyAwxSC+EVyDg==",
"dev": true,
"license": "MIT",
"dependencies": {
"chalk": "^4.1.2",
"date-fns": "^2.30.0",
"lodash": "^4.17.21",
"rxjs": "^7.8.1",
"shell-quote": "^1.8.1",
"spawn-command": "0.0.2",
"supports-color": "^8.1.1",
"tree-kill": "^1.2.2",
"yargs": "^17.7.2"
},
"bin": {
"conc": "dist/bin/concurrently.js",
"concurrently": "dist/bin/concurrently.js"
},
"engines": {
"node": "^14.13.0 || >=16.0.0"
},
"funding": {
"url": "https://github.com/open-cli-tools/concurrently?sponsor=1"
}
},
"node_modules/date-fns": {
"version": "2.30.0",
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.30.0.tgz",
"integrity": "sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.21.0"
},
"engines": {
"node": ">=0.11"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/date-fns"
}
},
"node_modules/emoji-regex": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"dev": true,
"license": "MIT"
},
"node_modules/escalade": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
"integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/get-caller-file": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
"dev": true,
"license": "ISC",
"engines": {
"node": "6.* || 8.* || >= 10.*"
}
},
"node_modules/has-flag": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
"integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/is-fullwidth-code-point": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/lodash": {
"version": "4.18.1",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
"integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
"dev": true,
"license": "MIT"
},
"node_modules/require-directory": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/rxjs": {
"version": "7.8.2",
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz",
"integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"tslib": "^2.1.0"
}
},
"node_modules/shell-quote": {
"version": "1.8.3",
"resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz",
"integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/spawn-command": {
"version": "0.0.2",
"resolved": "https://registry.npmjs.org/spawn-command/-/spawn-command-0.0.2.tgz",
"integrity": "sha512-zC8zGoGkmc8J9ndvml8Xksr1Amk9qBujgbF0JAIWO7kXr43w0h/0GJNM/Vustixu+YE8N/MTrQ7N31FvHUACxQ==",
"dev": true
},
"node_modules/string-width": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"dev": true,
"license": "MIT",
"dependencies": {
"emoji-regex": "^8.0.0",
"is-fullwidth-code-point": "^3.0.0",
"strip-ansi": "^6.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/strip-ansi": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-regex": "^5.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/supports-color": {
"version": "8.1.1",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz",
"integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"has-flag": "^4.0.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/chalk/supports-color?sponsor=1"
}
},
"node_modules/tree-kill": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz",
"integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==",
"dev": true,
"license": "MIT",
"bin": {
"tree-kill": "cli.js"
}
},
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"dev": true,
"license": "0BSD"
},
"node_modules/wrap-ansi": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
"integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-styles": "^4.0.0",
"string-width": "^4.1.0",
"strip-ansi": "^6.0.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
}
},
"node_modules/y18n": {
"version": "5.0.8",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
"integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==",
"dev": true,
"license": "ISC",
"engines": {
"node": ">=10"
}
},
"node_modules/yargs": {
"version": "17.7.2",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz",
"integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==",
"dev": true,
"license": "MIT",
"dependencies": {
"cliui": "^8.0.1",
"escalade": "^3.1.1",
"get-caller-file": "^2.0.5",
"require-directory": "^2.1.1",
"string-width": "^4.2.3",
"y18n": "^5.0.5",
"yargs-parser": "^21.1.1"
},
"engines": {
"node": ">=12"
}
},
"node_modules/yargs-parser": {
"version": "21.1.1",
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz",
"integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==",
"dev": true,
"license": "ISC",
"engines": {
"node": ">=12"
}
}
}
}

14
package.json Normal file
View File

@ -0,0 +1,14 @@
{
"name": "github-trending",
"version": "1.0.0",
"private": true,
"scripts": {
"install:all": "npm install && npm install --prefix backend && npm install --prefix frontend",
"dev": "concurrently \"npm run dev --prefix backend\" \"npm run dev --prefix frontend\"",
"build": "npm run build --prefix frontend",
"start": "npm run start --prefix backend"
},
"devDependencies": {
"concurrently": "^8.2.2"
}
}