初始化 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:
@@ -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>
|
||||
Generated
+2764
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
export function DeletionBadge({ daysUntilDeletion }) {
|
||||
if (daysUntilDeletion === null || daysUntilDeletion === undefined) return null;
|
||||
|
||||
const urgent = daysUntilDeletion <= 2;
|
||||
const label = daysUntilDeletion === 0
|
||||
? '今日删除'
|
||||
: `${daysUntilDeletion} 天后删除`;
|
||||
|
||||
return (
|
||||
<span
|
||||
className="badge"
|
||||
style={urgent ? {
|
||||
background: 'rgba(239,68,68,0.08)',
|
||||
border: '1px solid rgba(239,68,68,0.25)',
|
||||
color: '#f87171',
|
||||
} : {
|
||||
background: 'rgba(251,191,36,0.06)',
|
||||
border: '1px solid rgba(251,191,36,0.18)',
|
||||
color: '#d97706',
|
||||
}}
|
||||
>
|
||||
⏱ {label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
export function FavoriteButton({ isFavorited, onClick }) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
title={isFavorited ? '取消收藏' : '收藏此项目'}
|
||||
className="btn flex-shrink-0 transition-all duration-200"
|
||||
style={isFavorited ? {
|
||||
background: 'rgba(0,229,160,0.08)',
|
||||
borderColor: 'rgba(0,229,160,0.3)',
|
||||
color: 'var(--accent)',
|
||||
} : {
|
||||
background: 'var(--surface-3)',
|
||||
borderColor: 'var(--border)',
|
||||
color: 'var(--text-2)',
|
||||
}}
|
||||
onMouseEnter={e => {
|
||||
if (!isFavorited) {
|
||||
e.currentTarget.style.borderColor = 'rgba(0,229,160,0.25)';
|
||||
e.currentTarget.style.color = 'var(--accent)';
|
||||
}
|
||||
}}
|
||||
onMouseLeave={e => {
|
||||
if (!isFavorited) {
|
||||
e.currentTarget.style.borderColor = 'var(--border)';
|
||||
e.currentTarget.style.color = 'var(--text-2)';
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span>{isFavorited ? '★' : '☆'}</span>
|
||||
{isFavorited ? '已收藏' : '收藏'}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: ['./index.html', './src/**/*.{js,jsx}'],
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
plugins: [],
|
||||
};
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user