初始化 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:
commit
34ce3a3cf9
2
.env.example
Normal file
2
.env.example
Normal file
@ -0,0 +1,2 @@
|
||||
GITHUB_TOKEN=
|
||||
PORT=3001
|
||||
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
@ -0,0 +1,4 @@
|
||||
node_modules/
|
||||
data/
|
||||
.env
|
||||
frontend/dist/
|
||||
76
README.md
Normal file
76
README.md
Normal 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(热更新)
|
||||
- 后端 API:http://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
1747
backend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
19
backend/package.json
Normal file
19
backend/package.json
Normal 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
15
backend/src/cleanup.js
Normal 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
37
backend/src/db.js
Normal 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;
|
||||
14
backend/src/routes/admin.js
Normal file
14
backend/src/routes/admin.js
Normal 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 };
|
||||
27
backend/src/routes/favorites.js
Normal file
27
backend/src/routes/favorites.js
Normal 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 };
|
||||
59
backend/src/routes/repos.js
Normal file
59
backend/src/routes/repos.js
Normal 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
23
backend/src/scheduler.js
Normal 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
178
backend/src/scraper.js
Normal 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
33
backend/src/server.js
Normal 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
36
backend/src/translate.js
Normal 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
13
frontend/index.html
Normal 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
2764
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
22
frontend/package.json
Normal file
22
frontend/package.json
Normal 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
frontend/postcss.config.js
Normal file
6
frontend/postcss.config.js
Normal file
@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
127
frontend/src/App.jsx
Normal file
127
frontend/src/App.jsx
Normal 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
26
frontend/src/api.js
Normal 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
frontend/src/components/CodeBlock.jsx
Normal file
45
frontend/src/components/CodeBlock.jsx
Normal 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
frontend/src/components/DeletionBadge.jsx
Normal file
25
frontend/src/components/DeletionBadge.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
33
frontend/src/components/FavoriteButton.jsx
Normal file
33
frontend/src/components/FavoriteButton.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
27
frontend/src/components/FeaturesPanel.jsx
Normal file
27
frontend/src/components/FeaturesPanel.jsx
Normal 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
frontend/src/components/FilterBar.jsx
Normal file
45
frontend/src/components/FilterBar.jsx
Normal 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
frontend/src/components/RepoCard.jsx
Normal file
120
frontend/src/components/RepoCard.jsx
Normal 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
frontend/src/hooks/useRepos.js
Normal file
39
frontend/src/hooks/useRepos.js
Normal 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
10
frontend/src/main.jsx
Normal 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
frontend/src/styles/index.css
Normal file
59
frontend/src/styles/index.css
Normal 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
frontend/tailwind.config.js
Normal file
8
frontend/tailwind.config.js
Normal 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
11
frontend/vite.config.js
Normal 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
372
package-lock.json
generated
Normal 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
14
package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user