From a23d51ac3cb4fac3bc6246f7a1444b97dd8790c1 Mon Sep 17 00:00:00 2001 From: dot Date: Mon, 30 Mar 2026 11:49:03 +0200 Subject: [PATCH 1/2] fix: bury something else resets form instead of retrying same URL Co-Authored-By: Claude Sonnet 4.6 --- ROADMAP.md | 3 +++ src/app/page.tsx | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/ROADMAP.md b/ROADMAP.md index ccc9fa1..c8c8d01 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -43,6 +43,9 @@ Living document. Completed work at the top, upcoming at the bottom. Add new entr - OG image aligned with live hero (light theme, correct fonts) - Custom roast line for commitmentissues repo itself +### Bug fixes +- Fixed "bury something else →" on error screen — was retrying same broken URL instead of resetting to the form + ### Data integrity - Audited all 28 Famous Casualties graveyard entries against live GitHub API - Fixed `angularjs/angular.js` → `angular/angular.js` (org transferred, old path 404) diff --git a/src/app/page.tsx b/src/app/page.tsx index 8ba33db..d564af0 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -118,7 +118,7 @@ export default function Page() { )} {loading && } - {error && !loading && analyze(url)} />} + {error && !loading && } From 040579967ece4416ecf8c8466f3eef5b3dc50fbf Mon Sep 17 00:00:00 2001 From: dot Date: Tue, 7 Apr 2026 11:47:27 +0200 Subject: [PATCH 2/2] feat: full UI/UX consistency pass, open source polish, and certificate improvements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Warm parchment palette (#FAF6EF) across all pages and components - Certificate stamp repositioned to bottom center, straight, with "Issued by commitmentissues.dev" - Last words styled to match cause of death (large, red, bold italic) - Add to README section redesigned to match site theme with clipboard icons - Footer simplified to About · GitHub - Search button hover fixed, input background unified - Recently Buried and about cards warmed to #EDE8E1 - README updated with embed examples, badge preview, and full roadmap - 6 GitHub issues created for community contributions - npm prune: 72 extraneous packages removed - Lint clean, unused vars removed from certificate-image route and CertificateCard Co-Authored-By: Claude Sonnet 4.6 --- README.md | 189 +++++++++------- next.config.mjs | 12 + package-lock.json | 30 +-- src/app/about/page.tsx | 10 +- src/app/api/badge/[owner]/[repo]/route.ts | 130 +++++++++++ .../[owner]/[repo]/route.tsx | 197 +++++++++++++++++ src/app/api/repo/route.ts | 14 +- src/app/globals.css | 20 +- src/app/layout.tsx | 34 ++- src/components/CertificateCard.tsx | 207 ++++++++++++++---- src/components/CertificateFixed.tsx | 64 ++++-- src/components/RecentlyBuried.tsx | 16 +- src/components/SearchForm.tsx | 6 +- src/components/SiteFooter.tsx | 82 +++---- src/hooks/useRepoAnalysis.ts | 16 +- src/lib/rateLimit.ts | 34 ++- 16 files changed, 816 insertions(+), 245 deletions(-) create mode 100644 src/app/api/badge/[owner]/[repo]/route.ts create mode 100644 src/app/api/certificate-image/[owner]/[repo]/route.tsx diff --git a/README.md b/README.md index 6ed377a..67b8746 100644 --- a/README.md +++ b/README.md @@ -2,49 +2,72 @@ Your abandoned repos deserve a proper funeral. -**Live:** [commitmentissues.dev](https://commitmentissues.dev) +**Live:** [commitmentissues.dev](https://commitmentissues.dev)  ·  Built by [Dot Systems](https://github.com/dotsystemsdevs) -![MIT License](https://img.shields.io/github/license/dotsystemsdevs/commitmentissues?style=flat-square) -![Vercel Deploy](https://img.shields.io/badge/deployed%20on-Vercel-black?style=flat-square&logo=vercel) +[![MIT License](https://img.shields.io/github/license/dotsystemsdevs/commitmentissues?style=flat-square)](LICENSE) +[![Deployed on Vercel](https://img.shields.io/badge/deployed%20on-Vercel-black?style=flat-square&logo=vercel)](https://commitmentissues.dev) +[![Next.js](https://img.shields.io/badge/Next.js-14-black?style=flat-square&logo=next.js)](https://nextjs.org) -Paste a public GitHub URL. Get a shareable **Certificate of Death** — cause of death, last words, repo age, exportable graphics. No account required. +--- -## Screenshots +Paste a public GitHub URL. Get a shareable **Certificate of Death** — algorithmic cause of death, last commit as last words, repo age, and exportable graphics. No signup. No account. Completely free. + +## Embed your dead repo -Home: +Got a dead repo? Add the badge to your README: -![Homepage screenshot](docs/screenshots/homepage.png) +[![commitmentissues](https://img.shields.io/badge/%E2%9A%B0%20commitmentissues-declared%20dead-cc0000?style=for-the-badge&labelColor=1a0f06)](https://commitmentissues.dev) -Certificate: +```markdown +[![commitmentissues](https://img.shields.io/badge/%E2%9A%B0%20commitmentissues-declared%20dead-cc0000?style=for-the-badge&labelColor=1a0f06)](https://commitmentissues.dev/?repo=YOUR_OWNER/YOUR_REPO) +``` -![Certificate screenshot](docs/screenshots/certificate.png) +Or embed the full certificate image: -About: +```markdown +[![Certificate of Death](https://commitmentissues.dev/api/certificate-image/YOUR_OWNER/YOUR_REPO)](https://commitmentissues.dev/?repo=YOUR_OWNER/YOUR_REPO) +``` + +Both are generated automatically on the certificate page — just hit **Copy** after analyzing your repo. + +## Screenshots -![About page screenshot](docs/screenshots/about.png) +![Homepage](docs/screenshots/homepage.png) + +![Certificate of Death](docs/screenshots/certificate.png) + +![The Mortician — About page](docs/screenshots/about.png) ## Features -- **Certificate of Death** — A4-style layout with cause, last words, repo age, and derived stats -- **Exports** — Multiple aspect ratios (feed, square, story-style) for Instagram, X, Facebook -- **Mobile share** — Native share sheet on mobile with story-friendly format -- **Hall of Shame** — Curated leaderboard of famously abandoned repos -- **Recently Buried** — Live feed of the latest public burials -- **Chrome extension** — Tombstone badge injected on any GitHub repo page (MVP) +- **Certificate of Death** — A4 layout with cause of death, last words, repo age, stars, forks, and language +- **Algorithmic scoring** — `src/lib/scoring.ts` computes a death index from commit activity, archive status, issue count, and time since last push +- **Export** — PNG downloads in multiple aspect ratios: A4, Instagram (4:5 and 1:1), X/Twitter (16:9), Facebook feed, and Stories (9:16) +- **Mobile share** — Native share sheet on iOS/Android with a story-formatted image +- **README badge** — Embed a `⚰ DECLARED DEAD` shields.io badge linking back to the certificate +- **Certificate embed** — Full certificate image via `/api/certificate-image/[owner]/[repo]` for GitHub READMEs +- **Recently Buried** — Live scrolling feed of the latest public burials +- **Famous Casualties** — Curated graveyard of famously abandoned repos +- **Rate limiting** — Redis-backed per-IP limiting with graceful fallback +- **Timeout + race condition handling** — AbortController on every GitHub API call ## Tech stack | | | |---|---| | Framework | Next.js 14 (App Router) | +| Styling | Tailwind CSS + inline styles | | Fonts | UnifrakturMaguntia, Courier Prime, Inter | +| Export | `html-to-image`, Canvas API | +| Certificate image | `next/og` (Satori, Node.js runtime) | | Hosting | Vercel | -| Storage | Upstash Redis (counters + recent burials) | +| Storage | Upstash Redis (rate limiting + recent burials + stats) | | Data | GitHub public API | +| Analytics | Vercel Analytics + Plausible | ## Getting started -Prerequisites: Node 18+ +**Prerequisites:** Node 18+ ```bash git clone https://github.com/dotsystemsdevs/commitmentissues.git @@ -55,92 +78,110 @@ npm run dev Open [http://localhost:3000](http://localhost:3000). -### Environment +### Environment variables -Add a GitHub token to raise API rate limits (optional but recommended): +Create a `.env.local` in the project root: ```env +# GitHub — optional but strongly recommended (raises API rate limits from 60 to 5000 req/hr) GITHUB_TOKEN=ghp_yourtoken + +# Upstash Redis — optional (enables Recently Buried feed, rate limiting, and buried counter) +KV_REST_API_URL=https://your-instance.upstash.io +KV_REST_API_TOKEN=your_token ``` -Generate one at **GitHub → Settings → Developer settings → Personal access tokens**. Fine-grained or classic tokens both work. +**Without any env vars** the app still works fully — you just get GitHub's unauthenticated rate limits (60 req/hr) and the Recently Buried feed is hidden. -> **Note:** The Recently Buried feed requires Upstash Redis (`KV_REST_API_URL` + `KV_REST_API_TOKEN`). Without it, the feed is hidden and the buried counter falls back to the historical baseline. +Generate a GitHub token at **Settings → Developer settings → Personal access tokens**. Fine-grained or classic both work; no special scopes needed for public repo access. ## How we pronounce repos dead | Step | What happens | -|------|----------------| -| Input | You submit a public GitHub URL | -| Data | The app fetches public metadata via the GitHub API | -| Score | A death index and narrative are computed in `src/lib/scoring.ts` | -| Output | Certificate rendered on-screen, exportable as PNG | - -Hall of Shame entries are hand-curated; Recently Buried reflects real usage. - -## Testing - -```bash -npm test -``` +|------|-------------| +| Input | User submits a public GitHub URL | +| Fetch | App fetches repo metadata + latest commit via GitHub API | +| Score | `computeDeathIndex()` in `src/lib/scoring.ts` produces a 0–10 death index | +| Narrative | `determineCauseOfDeath()` picks a cause based on the index and repo signals | +| Output | Certificate rendered client-side, exportable as high-res PNG | -## Contributing - -- Read `.github/CONTRIBUTING.md` before opening a PR -- Use the issue and PR templates -- CI runs lint, tests, and build on pull requests to `master` +The scoring algorithm weighs: time since last commit, archive status, open issues, fork ratio, star count, and whether the last commit message reads like a final sigh. ## Project structure -```text +``` src/ ├── app/ -│ ├── page.tsx -│ ├── about/ +│ ├── page.tsx ← homepage +│ ├── about/page.tsx ← /about +│ ├── layout.tsx ← root layout, fonts, analytics, JSON-LD │ └── api/ -│ ├── repo/ -│ ├── stats/ -│ └── recent/ +│ ├── repo/route.ts ← main analysis endpoint +│ ├── recent/route.ts ← recently buried feed +│ ├── random/route.ts ← random dead repo picker +│ ├── stats/route.ts ← buried counter +│ ├── badge/[owner]/[repo]/ ← shields.io-compatible badge SVG +│ └── certificate-image/[owner]/[repo]/ ← OG image for README embeds ├── components/ -│ ├── CertificateCard.tsx -│ ├── Leaderboard.tsx -│ ├── SearchForm.tsx -│ └── LoadingState.tsx +│ ├── CertificateCard.tsx ← certificate view + all export/share logic +│ ├── CertificateFixed.tsx ← the actual certificate layout (A4) +│ ├── SearchForm.tsx ← URL input + random button +│ ├── RecentlyBuried.tsx ← scrolling marquee feed +│ ├── Leaderboard.tsx ← Famous Casualties graveyard +│ ├── LoadingState.tsx ← loading skeleton +│ ├── ErrorDisplay.tsx ← error + retry UI +│ ├── PageHero.tsx ← shared hero (emoji, title, subtitle) +│ ├── SubpageShell.tsx ← shell for /about and future subpages +│ └── SiteFooter.tsx ← footer └── lib/ - ├── scoring.ts - ├── rateLimit.ts - ├── recentStore.ts - └── types.ts -extension/ ← Chrome extension (MV3, load unpacked) + ├── scoring.ts ← death index + cause of death logic + ├── scoring.test.ts ← scoring unit tests + ├── rateLimit.ts ← Redis-backed rate limiting + ├── recentStore.ts ← recently buried Redis store + └── types.ts ← shared TypeScript types ``` -## Chrome extension (MVP) +## Running tests -A MV3 extension lives under `extension/`. It injects a tombstone badge on GitHub repo pages and links to the full certificate. +```bash +npm test +``` + +Tests cover the scoring algorithm in `src/lib/scoring.test.ts`. -### Load unpacked in Chrome +## Contributing + +Contributions are welcome. Please read [`.github/CONTRIBUTING.md`](.github/CONTRIBUTING.md) before opening a PR. -1. Open `chrome://extensions/` -2. Enable **Developer mode** -3. Click **Load unpacked** and select the `extension/` subfolder +- Use the issue templates for bugs and feature requests +- CI runs lint, tests, and build on every pull request to `master` +- Keep PRs focused — one thing at a time -### Test flow +## Roadmap -1. Open a GitHub repo page (e.g. `https://github.com/vercel/next.js`) -2. Verify a tombstone badge appears near the repo header -3. Click the badge to open the full certificate on `commitmentissues.dev` -4. Navigate to another repo without a full reload; verify no duplicate badge appears +Items are loosely prioritized. Community PRs welcome on anything marked **good first issue**. -If the API is rate-limited or unavailable, the badge falls back to `Reaper busy`. +### Near-term +- [ ] Upgrade to Next.js 16 (planned within one month of launch) +- [ ] Dark mode +- [ ] `/api/certificate-image` caching layer (currently no Redis cache) +- [ ] Repo comparison — bury two repos side by side -## Docs +### Longer-term +- [ ] Chrome extension — tombstone badge injected on GitHub repo pages +- [ ] Language-specific causes of death ("Died of PHP fatigue", "Last seen in CoffeeScript") +- [ ] Death anniversary emails — opt-in reminders on the date of last commit +- [ ] API for third-party integrations -- Release notes: `docs/releases/` -- Screenshots: `docs/screenshots/` -- Repository conventions: `docs/repository-conventions.md` +### Won't do (by design) +- Private repo support — we don't break into houses +- Accounts / login — the funeral is free and anonymous +- Monetization — coffee button stays, paywalls don't ## License -MIT — see repository license file. +MIT — see [`LICENSE`](LICENSE). + +--- -Built by [Dot Systems](https://github.com/dotsystemsdevs). +Built with too much free time by [Dot Systems](https://github.com/dotsystemsdevs). If it made you laugh, [keep us alive](https://buymeacoffee.com/commitmentissues). diff --git a/next.config.mjs b/next.config.mjs index 08c806e..96dc693 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -18,6 +18,18 @@ const nextConfig = { { key: 'X-XSS-Protection', value: '1; mode=block' }, { key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' }, { key: 'Permissions-Policy', value: 'camera=(), microphone=(), geolocation=()' }, + { + key: 'Content-Security-Policy', + value: [ + "default-src 'self'", + "script-src 'self' 'unsafe-inline' 'unsafe-eval' https://va.vercel-scripts.com", + "style-src 'self' 'unsafe-inline' https://fonts.googleapis.com", + "font-src 'self' https://fonts.gstatic.com", + "img-src 'self' data: blob: https://img.shields.io https://avatars.githubusercontent.com", + "connect-src 'self' https://vitals.vercel-insights.com https://va.vercel-scripts.com", + "frame-ancestors 'none'", + ].join('; '), + }, ], }, ] diff --git a/package-lock.json b/package-lock.json index 6b0d10e..c4a9a2a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1222,9 +1222,9 @@ } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz", - "integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==", + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", "dev": true, "license": "MIT", "dependencies": { @@ -2028,9 +2028,9 @@ } }, "node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", + "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", "dev": true, "license": "MIT", "dependencies": { @@ -3490,9 +3490,9 @@ } }, "node_modules/glob/node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz", + "integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==", "dev": true, "license": "MIT", "dependencies": { @@ -4945,9 +4945,9 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", "dev": true, "license": "MIT", "engines": { @@ -6102,9 +6102,9 @@ } }, "node_modules/tinyglobby/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", "engines": { diff --git a/src/app/about/page.tsx b/src/app/about/page.tsx index c627587..dc6e853 100644 --- a/src/app/about/page.tsx +++ b/src/app/about/page.tsx @@ -2,12 +2,12 @@ import type { Metadata } from 'next' import SubpageShell from '@/components/SubpageShell' export const metadata: Metadata = { - title: 'About — commitmentissues', - description: 'How commitmentissues works: real GitHub data, a death score algorithm, and a healthy dose of dark humor for your abandoned repos.', + title: 'About — Commitment Issues | How It Works', + description: 'How Commitment Issues works: we analyze your GitHub repo\'s commit history, activity decay, and open issues to assign a cause of death and generate a printable death certificate.', alternates: { canonical: 'https://commitmentissues.dev/about' }, openGraph: { - title: 'About — commitmentissues', - description: 'How commitmentissues works: real GitHub data, a death score algorithm, and a healthy dose of dark humor for your abandoned repos.', + title: 'About — Commitment Issues | How It Works', + description: 'How Commitment Issues works: we analyze your GitHub repo\'s commit history, activity decay, and open issues to assign a cause of death and generate a printable death certificate.', url: 'https://commitmentissues.dev/about', }, } @@ -62,7 +62,7 @@ export default function AboutPage() { padding: '20px 18px', border: '2px solid #1a1a1a', borderRadius: '0', - background: '#f2f2f2', + background: '#EDE8E1', }} >

diff --git a/src/app/api/badge/[owner]/[repo]/route.ts b/src/app/api/badge/[owner]/[repo]/route.ts new file mode 100644 index 0000000..4592325 --- /dev/null +++ b/src/app/api/badge/[owner]/[repo]/route.ts @@ -0,0 +1,130 @@ +import { NextRequest, NextResponse } from 'next/server' +import { + computeDeathIndex, + getDeathLabel, + determineCauseOfDeath, +} from '@/lib/scoring' +import { RepoData } from '@/lib/types' + +const VALID_SEGMENT = /^[a-zA-Z0-9_.-]+$/ + +// Approximate character width for Verdana 11px (standard badge font) +function textWidth(text: string): number { + const widths: Record = { + f: 5, i: 4, j: 4, l: 4, r: 5, t: 5, ' ': 4, + m: 11, w: 10, W: 10, M: 11, + } + return text.split('').reduce((sum, ch) => sum + (widths[ch] ?? 7), 0) +} + +function buildSvg(label: string, value: string, color: string): string { + const lw = textWidth(label) + 20 + const rw = textWidth(value) + 20 + const total = lw + rw + + return ` + + + + + + + + + + + + + + + ${label} + + ${value} + +` +} + +export async function GET( + request: NextRequest, + { params }: { params: { owner: string; repo: string } } +) { + const { owner, repo } = params + + if (!VALID_SEGMENT.test(owner) || !VALID_SEGMENT.test(repo)) { + return new NextResponse('Invalid repo', { status: 400 }) + } + + const headers: HeadersInit = { + Accept: 'application/vnd.github.v3+json', + 'User-Agent': 'commitmentissues.dev', + ...(process.env.GITHUB_TOKEN + ? { Authorization: `Bearer ${process.env.GITHUB_TOKEN}` } + : {}), + } + + let repoData: RepoData + try { + const [repoRes, commitsRes] = await Promise.all([ + fetch(`https://api.github.com/repos/${owner}/${repo}`, { + headers, + signal: AbortSignal.timeout(8000), + next: { revalidate: 86400 }, + }), + fetch(`https://api.github.com/repos/${owner}/${repo}/commits?per_page=1`, { + headers, + signal: AbortSignal.timeout(8000), + next: { revalidate: 86400 }, + }), + ]) + + if (!repoRes.ok) { + const svg = buildSvg('⚰ commitmentissues', 'unknown', '#555') + return new NextResponse(svg, { + headers: { 'Content-Type': 'image/svg+xml', 'Cache-Control': 'no-cache' }, + }) + } + + const repoJson = await repoRes.json() + const commitsJson = commitsRes.ok ? await commitsRes.json() : [] + + repoData = { + name: repoJson.name, + fullName: repoJson.full_name, + description: repoJson.description ?? null, + createdAt: repoJson.created_at, + pushedAt: repoJson.pushed_at, + isArchived: repoJson.archived ?? false, + stargazersCount: repoJson.stargazers_count ?? 0, + forksCount: repoJson.forks_count ?? 0, + openIssuesCount: repoJson.open_issues_count ?? 0, + language: repoJson.language ?? null, + topics: repoJson.topics ?? [], + isFork: repoJson.fork ?? false, + commitCount: Array.isArray(commitsJson) ? commitsJson.length : 0, + lastCommitMessage: commitsJson[0]?.commit?.message ?? 'No commits found', + lastCommitDate: commitsJson[0]?.commit?.author?.date ?? repoJson.pushed_at, + } + } catch { + const svg = buildSvg('⚰ commitmentissues', 'unknown', '#555') + return new NextResponse(svg, { + headers: { 'Content-Type': 'image/svg+xml', 'Cache-Control': 'no-cache' }, + }) + } + + const index = computeDeathIndex(repoData) + const label = getDeathLabel(index) + const cause = determineCauseOfDeath(repoData) + + // Color based on death index + const color = index >= 9 ? '#5c0000' : index >= 6 ? '#7a1a00' : index >= 3 ? '#6b4400' : '#2d5a00' + + const valueText = label === 'dead dead' ? `☠ ${cause.slice(0, 28)}` : `${label} (${index}/10)` + const svg = buildSvg('⚰ commitmentissues', valueText, color) + + return new NextResponse(svg, { + headers: { + 'Content-Type': 'image/svg+xml', + 'Cache-Control': 'public, max-age=86400, stale-while-revalidate=3600', + }, + }) +} diff --git a/src/app/api/certificate-image/[owner]/[repo]/route.tsx b/src/app/api/certificate-image/[owner]/[repo]/route.tsx new file mode 100644 index 0000000..f477eda --- /dev/null +++ b/src/app/api/certificate-image/[owner]/[repo]/route.tsx @@ -0,0 +1,197 @@ +import { NextRequest } from 'next/server' +import { ImageResponse } from 'next/og' +import { + determineCauseOfDeath, + generateLastWords, + computeAge, + formatDate, +} from '@/lib/scoring' +import { RepoData } from '@/lib/types' + +export const runtime = 'nodejs' + +const VALID_SEGMENT = /^[a-zA-Z0-9_.-]+$/ +const W = 794 +const H = 1123 +const INK = '#1A0F06' +const WARM = '#8B6B4A' +const RED = '#8B0000' +const LINE = '#C4A882' +const BG = '#FAF6EF' + +async function loadFont(family: string, weight: number): Promise { + try { + const css = await fetch( + `https://fonts.googleapis.com/css2?family=${family.replace(/\s+/g, '+')}:wght@${weight}&display=swap`, + { headers: { 'User-Agent': 'Mozilla/5.0 Chrome/120' } } + ).then(r => r.text()) + const m = css.match(/url\((https:\/\/fonts\.gstatic\.com[^)]+)\)\s+format\(['"]woff2['"]\)/) + if (!m) return null + return fetch(m[1]).then(r => r.arrayBuffer()) + } catch { + return null + } +} + +function Row({ label, value, border }: { label: string; value: string; border?: boolean }) { + return ( +

+
{label}
+
{value}
+
+ ) +} + +export async function GET( + _request: NextRequest, + { params }: { params: { owner: string; repo: string } } +) { + const { owner, repo } = params + + if (!VALID_SEGMENT.test(owner) || !VALID_SEGMENT.test(repo)) { + return new Response('Invalid repo', { status: 400 }) + } + + const ghHeaders: HeadersInit = { + Accept: 'application/vnd.github.v3+json', + 'User-Agent': 'commitmentissues.dev', + ...(process.env.GITHUB_TOKEN ? { Authorization: `Bearer ${process.env.GITHUB_TOKEN}` } : {}), + } + + let repoData: RepoData + try { + const [repoRes, commitsRes] = await Promise.all([ + fetch(`https://api.github.com/repos/${owner}/${repo}`, { headers: ghHeaders, signal: AbortSignal.timeout(8000) }), + fetch(`https://api.github.com/repos/${owner}/${repo}/commits?per_page=1`, { headers: ghHeaders, signal: AbortSignal.timeout(8000) }), + ]) + if (!repoRes.ok) return new Response('Repo not found', { status: 404 }) + const rj = await repoRes.json() + const cj = commitsRes.ok ? await commitsRes.json() : [] + repoData = { + name: rj.name, fullName: rj.full_name, description: rj.description ?? null, + createdAt: rj.created_at, pushedAt: rj.pushed_at, isArchived: rj.archived ?? false, + stargazersCount: rj.stargazers_count ?? 0, forksCount: rj.forks_count ?? 0, + openIssuesCount: rj.open_issues_count ?? 0, language: rj.language ?? null, + topics: rj.topics ?? [], isFork: rj.fork ?? false, + commitCount: Array.isArray(cj) ? cj.length : 0, + lastCommitMessage: cj[0]?.commit?.message ?? 'No commits found', + lastCommitDate: cj[0]?.commit?.author?.date ?? rj.pushed_at, + } + } catch { + return new Response('Failed to fetch repo', { status: 502 }) + } + + const causeOfDeath = determineCauseOfDeath(repoData) + const lastWords = generateLastWords(repoData) + const deathDate = formatDate(repoData.lastCommitDate) + const age = computeAge(repoData.createdAt, repoData.lastCommitDate) + const ownerName = repoData.fullName.split('/')[0] + + const [gothicFont, monoFont] = await Promise.all([ + loadFont('Unifraktur Maguntia', 400), + loadFont('Courier Prime', 400), + ]) + const fonts: { name: string; data: ArrayBuffer; weight: 400; style: 'normal' }[] = [] + if (gothicFont) fonts.push({ name: 'Gothic', data: gothicFont, weight: 400, style: 'normal' }) + if (monoFont) fonts.push({ name: 'Mono', data: monoFont, weight: 400, style: 'normal' }) + + const gothic = fonts.find(f => f.name === 'Gothic') ? 'Gothic' : 'Georgia, serif' + const mono = fonts.find(f => f.name === 'Mono') ? 'Mono' : 'Courier New, monospace' + + return new ImageResponse( + ( +
+
+ + {/* Header */} +
+
+ Certificate of Death +
+
+ official record of abandonment +
+
+ + {/* Repo */} +
+
THIS IS TO CERTIFY THE DEATH OF
+
{ownerName} /
+
{repoData.name}
+ {repoData.description && ( +
+ {repoData.description.slice(0, 120)}{repoData.description.length > 120 ? '...' : ''} +
+ )} +
+ + {/* Cause */} +
+
CAUSE OF DEATH
+
+ {causeOfDeath} +
+ {/* Stamp — no transform, Satori doesn't support it */} +
+
+ REST IN PRODUCTION +
+
+
+ + {/* Dates */} +
+ + +
+ + {/* Stats */} +
+
+
{repoData.stargazersCount.toLocaleString()}
+
STARS
+
+
+
+
{repoData.forksCount.toLocaleString()}
+
FORKS
+
+ {repoData.language && ( + <> +
+
+
{repoData.language}
+
LANGUAGE
+
+ + )} +
+ + {/* Last words */} +
+
LAST WORDS
+
+ {'\u201C'}{lastWords}{'\u201D'} +
+
+ + {/* Footer */} +
+
ISSUED BY COMMITMENTISSUES.DEV
+
+ +
+
+ ), + { + width: W, + height: H, + fonts: fonts.length ? fonts : undefined, + headers: { 'Cache-Control': 'public, max-age=86400, stale-while-revalidate=3600' }, + } + ) +} diff --git a/src/app/api/repo/route.ts b/src/app/api/repo/route.ts index 9759fb1..52a5ee5 100644 --- a/src/app/api/repo/route.ts +++ b/src/app/api/repo/route.ts @@ -19,7 +19,7 @@ export async function GET(request: NextRequest) { const ip = request.headers.get('x-forwarded-for')?.split(',')[0].trim() ?? '127.0.0.1' - const { allowed, retryAfter } = checkRateLimit(ip) + const { allowed, retryAfter } = await checkRateLimit(ip) if (!allowed) { return NextResponse.json( { error: `Slow down. Try again in ${retryAfter}s.`, retryAfter }, @@ -78,16 +78,18 @@ export async function GET(request: NextRequest) { ;[repoRes, commitsRes] = await Promise.all([ fetch(`https://api.github.com/repos/${owner}/${cleanRepo}`, { headers, + signal: AbortSignal.timeout(8000), next: { revalidate: 86400 }, }), fetch( `https://api.github.com/repos/${owner}/${cleanRepo}/commits?per_page=1`, - { headers, next: { revalidate: 86400 } } + { headers, signal: AbortSignal.timeout(8000), next: { revalidate: 86400 } } ), ]) - } catch { + } catch (err) { + const isTimeout = err instanceof DOMException && err.name === 'TimeoutError' return NextResponse.json( - { error: 'The reaper is busy. Try again in a moment.' }, + { error: isTimeout ? 'GitHub took too long to respond. Try again.' : 'The reaper is busy. Try again in a moment.' }, { status: 502 } ) } @@ -173,7 +175,9 @@ export async function GET(request: NextRequest) { cause: causeOfDeath, score: deathIndex, analyzedAt: new Date().toISOString(), - }).catch(() => {}) + }).catch((err) => { + console.error('[addRecent] Redis write failed:', err) + }) return NextResponse.json(certificate) } diff --git a/src/app/globals.css b/src/app/globals.css index c6c2a2b..6c274dc 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -20,7 +20,7 @@ .page-shell-main { min-height: 100vh; min-height: 100dvh; - background: #e8e8e8; + background: #FAF6EF; display: flex; flex-direction: column; align-items: center; @@ -115,7 +115,7 @@ .page-shell-rule { height: 1px; - background: #bfbfbf; + background: #d8cfc4; width: 100%; } @@ -296,10 +296,10 @@ button { /* ── Stamp: rubber-stamp impact animation ── */ @keyframes stamp-in { - 0% { transform: rotate(-12deg) scale(3); opacity: 0; } - 60% { transform: rotate(-12deg) scale(0.95); opacity: 1; } - 80% { transform: rotate(-12deg) scale(1.05); opacity: 1; } - 100% { transform: rotate(-12deg) scale(1); opacity: 1; } + 0% { transform: scale(3); opacity: 0; } + 60% { transform: scale(0.95); opacity: 1; } + 80% { transform: scale(1.05); opacity: 1; } + 100% { transform: scale(1); opacity: 1; } } .stamp-animate { @@ -403,7 +403,7 @@ button { } @media (hover: hover) and (pointer: fine) { .cert-btn-secondary:hover { - background: #f0f0f0 !important; + background: #EDE8E1 !important; } } .cert-btn-secondary:active { @@ -455,7 +455,7 @@ a.subpage-inline-mail { gap: 6px; width: 100%; position: relative; - background: #e8e8e8; + background: #FAF6EF; } .site-footer--compact { @@ -653,7 +653,7 @@ a.subpage-inline-mail { font-family: var(--font-dm), -apple-system, sans-serif; font-size: 14px; color: #160A06; - background-color: #e8e8e8; + background-color: #FAF6EF; } html, body, @@ -665,7 +665,7 @@ a.subpage-inline-mail { input:-webkit-autofill, input:-webkit-autofill:focus { -webkit-text-fill-color: #160A06; - -webkit-box-shadow: 0 0 0 1000px #f5f5f5 inset; + -webkit-box-shadow: 0 0 0 1000px #FAF6EF inset; transition: background-color 5000s ease-in-out 0s; } } diff --git a/src/app/layout.tsx b/src/app/layout.tsx index f20117b..55cced9 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -33,23 +33,29 @@ export const viewport: Viewport = { export const metadata: Metadata = { metadataBase: new URL('https://commitmentissues.dev'), - title: 'commitmentissues — Death Certificates for Abandoned GitHub Repos', - description: 'Paste any GitHub URL. Get an official death certificate for your abandoned repo. Cause of death, last words, and more.', - keywords: ['github', 'dead repo', 'abandoned project', 'death certificate', 'side project', 'open source', 'abandoned github repository', 'repo graveyard', 'unmaintained project', 'stale repository', 'github stats', 'commit activity'], + title: 'Commitment Issues — Official Death Certificates for Dead GitHub Repos', + description: 'Paste any public GitHub repo and get an official death certificate. See the cause of death, last commit as "last words," and repo stats. Free tool for developers with too many abandoned side projects.', + keywords: [ + 'abandoned github repo', 'dead github project', 'github repo death certificate', + 'unmaintained open source', 'stale repository checker', 'github graveyard', + 'side project syndrome', 'dead project', 'abandoned side project', + 'github repo stats', 'last commit checker', 'repo activity', + 'commitmentissues', 'developer humor', 'github tools', + ], authors: [{ name: 'Dot Systems', url: 'https://github.com/dotsystemsdevs' }], alternates: { canonical: 'https://commitmentissues.dev' }, openGraph: { - title: 'commitmentissues - Death Certificates for Abandoned GitHub Repos', - description: 'Paste any GitHub URL. Get an official death certificate for your abandoned repo. Cause of death, last words, and more.', + title: 'Commitment Issues — Official Death Certificates for Dead GitHub Repos', + description: 'Paste any public GitHub repo and get an official death certificate. Cause of death, last words, repo stats. Free for developers.', url: 'https://commitmentissues.dev', - siteName: 'commitmentissues', + siteName: 'Commitment Issues', type: 'website', images: [{ url: '/opengraph-image', width: 1200, height: 630 }], }, twitter: { card: 'summary_large_image', - title: 'commitmentissues - Death Certificates for Abandoned GitHub Repos', - description: 'Paste any GitHub URL. Get an official death certificate for your abandoned repo.', + title: 'Commitment Issues — Official Death Certificates for Dead GitHub Repos', + description: 'Paste any public GitHub repo and get an official death certificate. Cause of death, last words, repo stats.', images: ['/opengraph-image'], }, } @@ -59,11 +65,19 @@ const jsonLd = { '@graph': [ { '@type': 'WebApplication', - name: 'commitmentissues', - description: 'Death certificates for abandoned GitHub repos', + name: 'Commitment Issues', + alternateName: 'commitmentissues.dev', + description: 'Free tool that generates official death certificates for abandoned GitHub repos. Paste any public repo URL to see the cause of death, last commit as last words, and full repo stats.', url: 'https://commitmentissues.dev', applicationCategory: 'DeveloperApplication', operatingSystem: 'Web', + featureList: [ + 'Death certificate generation for GitHub repos', + 'Cause of death analysis based on commit activity', + 'Last commit as last words', + 'Shareable certificate image', + 'README badge embed', + ], offers: { '@type': 'Offer', price: '0', priceCurrency: 'USD' }, creator: { '@type': 'Organization', name: 'Dot Systems', url: 'https://github.com/dotsystemsdevs' }, }, diff --git a/src/components/CertificateCard.tsx b/src/components/CertificateCard.tsx index ecd56d4..e59a502 100644 --- a/src/components/CertificateCard.tsx +++ b/src/components/CertificateCard.tsx @@ -1,6 +1,7 @@ 'use client' import { useRef, useState, useEffect } from 'react' +import type React from 'react' import { track } from '@vercel/analytics' import { toBlob } from 'html-to-image' import { DeathCertificate } from '@/lib/types' @@ -29,7 +30,7 @@ function getCertificateUiScale(viewportWidth: number) { return clamp((viewportWidth * 0.72) / CERT_RENDER_WIDTH, 0.42, DESKTOP_CERT_UI_SCALE) } -const SOCIAL_BG = '#E8E8E8' +const SOCIAL_BG = '#FAF6EF' const SOCIAL_EXPORT_FORMATS = { instagramPortrait: { width: 1080, height: 1350, padding: 64, filename: 'instagram-portrait' }, instagramSquare: { width: 1080, height: 1080, padding: 48, filename: 'instagram-square' }, @@ -87,8 +88,21 @@ export default function CertificateCard({ cert, onReset }: Props) { const [uiScale, setUiScale] = useState(DESKTOP_CERT_UI_SCALE) const [isMobileViewport, setIsMobileViewport] = useState(false) const [isGeneratingShare, setIsGeneratingShare] = useState(false) + const [isDownloading, setIsDownloading] = useState(false) + const [exportError, setExportError] = useState(null) const [showInlineShare, setShowInlineShare] = useState(false) const [copied, setCopied] = useState(false) + const [badgeCopied, setBadgeCopied] = useState(false) + const [embedCopied, setEmbedCopied] = useState(false) + + function getPixelRatio(highQuality = false): number { + if (typeof navigator === 'undefined') return 2 + const memory = (navigator as Navigator & { deviceMemory?: number }).deviceMemory + const isLowEnd = memory !== undefined && memory <= 2 + if (isLowEnd) return 1.5 + if (isMobileViewport) return highQuality ? 2 : 1.5 + return highQuality ? 3 : 2.5 + } useEffect(() => { const t = setTimeout(() => setVisible(true), 50) @@ -188,7 +202,7 @@ export default function CertificateCard({ cert, onReset }: Props) { const shareText = buildShareCopy(cert, shareUrl) async function generateSocialBlob(formatKey: SocialFormatKey) { - const masterBlob = await exportBlob(2.5, true) + const masterBlob = await exportBlob(getPixelRatio(), true) if (!masterBlob) return null return composeSocialBlob(masterBlob, SOCIAL_EXPORT_FORMATS[formatKey]) } @@ -200,10 +214,14 @@ export default function CertificateCard({ cert, onReset }: Props) { async function handleShare() { track('share_clicked') setIsGeneratingShare(true) + setExportError(null) try { const shareFormat: SocialFormatKey = isMobileViewport ? 'story' : 'instagramPortrait' const blob = await generateSocialBlob(shareFormat) - if (!blob) return + if (!blob) { + setExportError('Could not generate image. Try downloading instead.') + return + } const file = new File([blob], `${cert.repoData.name}-${SOCIAL_EXPORT_FORMATS[shareFormat].filename}.png`, { type: 'image/png' }) const hasNativeShare = typeof navigator !== 'undefined' && 'share' in navigator @@ -230,6 +248,8 @@ export default function CertificateCard({ cert, onReset }: Props) { // Desktop: show inline options setShowInlineShare(true) + } catch { + setExportError('Something went wrong generating the image. Try again.') } finally { setIsGeneratingShare(false) } @@ -253,29 +273,52 @@ export default function CertificateCard({ cert, onReset }: Props) { } async function handleDownloadShareImage() { - const blob = await generateShareBlob() - if (!blob) return - triggerDownload(blob, `${cert.repoData.name}-${SOCIAL_EXPORT_FORMATS.instagramPortrait.filename}.png`) - stat('downloaded') - setShowInlineShare(false) + setIsDownloading(true) + setExportError(null) + try { + const blob = await generateShareBlob() + if (!blob) { setExportError('Could not generate image. Try again.'); return } + triggerDownload(blob, `${cert.repoData.name}-${SOCIAL_EXPORT_FORMATS.instagramPortrait.filename}.png`) + stat('downloaded') + setShowInlineShare(false) + } catch { + setExportError('Download failed. Try again.') + } finally { + setIsDownloading(false) + } } async function handleDownloadFormat(formatKey: SocialFormatKey) { - const masterBlob = await exportBlob(2.5, true) - if (!masterBlob) return - const format = SOCIAL_EXPORT_FORMATS[formatKey] - const blob = await composeSocialBlob(masterBlob, format) - if (!blob) return - triggerDownload(blob, `${cert.repoData.name}-${format.filename}.png`) - stat('downloaded') + setIsDownloading(true) + setExportError(null) + try { + const masterBlob = await exportBlob(getPixelRatio(), true) + if (!masterBlob) { setExportError('Could not generate image. Try again.'); return } + const format = SOCIAL_EXPORT_FORMATS[formatKey] + const blob = await composeSocialBlob(masterBlob, format) + if (!blob) { setExportError('Could not generate image. Try again.'); return } + triggerDownload(blob, `${cert.repoData.name}-${format.filename}.png`) + stat('downloaded') + } catch { + setExportError('Download failed. Try again.') + } finally { + setIsDownloading(false) + } } - async function handleDownloadA4() { - const blob = await exportBlob(3, true) - if (!blob) return - triggerDownload(blob, `${cert.repoData.name}-certificate.png`) - stat('downloaded') + setIsDownloading(true) + setExportError(null) + try { + const blob = await exportBlob(getPixelRatio(true), true) + if (!blob) { setExportError('Could not generate image. Try again.'); return } + triggerDownload(blob, `${cert.repoData.name}-certificate.png`) + stat('downloaded') + } catch { + setExportError('Download failed. Try again.') + } finally { + setIsDownloading(false) + } } const UI = `var(--font-dm), -apple-system, sans-serif` @@ -384,7 +427,7 @@ export default function CertificateCard({ cert, onReset }: Props) { gap: '8px', }} > - {isGeneratingShare ? : (isMobileViewport ? 'Share Story (9:16) →' : 'Share →')} + {isGeneratingShare || isDownloading ? : (isMobileViewport ? 'Share Story (9:16) →' : 'Share →')} )} @@ -410,7 +453,7 @@ export default function CertificateCard({ cert, onReset }: Props) { className="cert-btn-secondary" style={{ fontFamily: UI, fontSize: '14px', fontWeight: 600, - width: '100%', height: '52px', background: '#fff', color: '#0a0a0a', + width: '100%', height: '52px', background: '#FAF6EF', color: '#0a0a0a', border: '2px solid #0a0a0a', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center', }} @@ -420,67 +463,72 @@ export default function CertificateCard({ cert, onReset }: Props) { )} + {/* Export error */} + {exportError && ( +

+ {exportError} +

+ )} + + {/* README embed */} + {(() => { + const fullName = cert.repoData.fullName + const repoUrl = `https://commitmentissues.dev/?repo=${encodeURIComponent(fullName)}` + const shieldsUrl = `https://img.shields.io/badge/${encodeURIComponent('⚰ commitmentissues')}-${encodeURIComponent('declared dead')}-cc0000?style=for-the-badge&labelColor=1a0f06` + const badgeMd = `[![commitmentissues](${shieldsUrl})](${repoUrl})` + const certImageUrl = `https://commitmentissues.dev/api/certificate-image/${fullName}` + const certMd = `[![${cert.repoData.name} — Certificate of Death](${certImageUrl})](${repoUrl})` + + const shieldsPreviewUrl = `https://img.shields.io/badge/${encodeURIComponent('⚰ commitmentissues')}-${encodeURIComponent('declared dead')}-cc0000?style=for-the-badge&labelColor=1a0f06` + + const CopyIcon = ({ done }: { done: boolean }) => done + ? + : + + const copyBtn = (copied: boolean, onCopy: () => void) => ( + + ) + + return ( +
+
+ + + + + Add to README + +
+
+ {/* Badge row — preview + copy icon */} +
+
+ {/* eslint-disable-next-line @next/next/no-img-element */} + commitmentissues badge + links to certificate on commitmentissues.dev +
+ {copyBtn(badgeCopied, async () => { try { await navigator.clipboard.writeText(badgeMd); setBadgeCopied(true); setTimeout(() => setBadgeCopied(false), 2000) } catch { /* ignore */ } })} +
+ {/* Full certificate row */} +
+
+ Full certificate + embeds certificate image in README +
+ {copyBtn(embedCopied, async () => { try { await navigator.clipboard.writeText(certMd); setEmbedCopied(true); setTimeout(() => setEmbedCopied(false), 2000) } catch { /* ignore */ } })} +
+
+
+ ) + })()} + {/* Bury another — text link */}
diff --git a/src/components/SiteFooter.tsx b/src/components/SiteFooter.tsx index e48218c..fd49c09 100644 --- a/src/components/SiteFooter.tsx +++ b/src/components/SiteFooter.tsx @@ -1,17 +1,11 @@ 'use client' -import { useState } from 'react' import Link from 'next/link' const FONT = `var(--font-dm), -apple-system, sans-serif` -const MONO = `var(--font-courier), 'Courier New', monospace` - -const LINKS = [ - { href: '/about', label: 'About' }, -] as const const GitHubIcon = () => ( - + ) @@ -21,53 +15,41 @@ interface SiteFooterProps { } export default function SiteFooter({ compact = false }: SiteFooterProps) { - const [aliveFlash, setAliveFlash] = useState(false) - - function handleRestClick() { - setAliveFlash(true) - window.setTimeout(() => setAliveFlash(false), 850) + const linkStyle = { + fontFamily: FONT, + fontSize: '13px', + color: '#8a8a8a', + textDecoration: 'none', + display: 'inline-flex', + alignItems: 'center', + gap: '4px', + transition: 'color 0.15s', } return (
+ (e.currentTarget.style.color = '#1f1f1f')} + onMouseLeave={e => (e.currentTarget.style.color = '#8a8a8a')} + > + About + + · + (e.currentTarget.style.color = '#1f1f1f')} + onMouseLeave={e => (e.currentTarget.style.color = '#8a8a8a')} + > + + GitHub + +
) } diff --git a/src/hooks/useRepoAnalysis.ts b/src/hooks/useRepoAnalysis.ts index cf4364f..d1a3989 100644 --- a/src/hooks/useRepoAnalysis.ts +++ b/src/hooks/useRepoAnalysis.ts @@ -1,6 +1,6 @@ 'use client' -import { useState } from 'react' +import { useState, useRef } from 'react' import { DeathCertificate } from '@/lib/types' export interface AnalysisError { @@ -13,15 +13,22 @@ export function useRepoAnalysis() { const [certificate, setCertificate] = useState(null) const [error, setError] = useState(null) const [loading, setLoading] = useState(false) + const abortRef = useRef(null) async function analyze(inputUrl: string) { if (!inputUrl.trim()) return + + // Cancel any in-flight request + abortRef.current?.abort() + const controller = new AbortController() + abortRef.current = controller + setLoading(true) setError(null) setCertificate(null) try { - const res = await fetch(`/api/repo?url=${encodeURIComponent(inputUrl.trim())}`) + const res = await fetch(`/api/repo?url=${encodeURIComponent(inputUrl.trim())}`, { signal: controller.signal }) const data = await res.json() if (!res.ok) { setError({ message: data.error, retryAfter: data.retryAfter }) @@ -49,10 +56,11 @@ export function useRepoAnalysis() { localStorage.setItem('ci_recent', JSON.stringify(filtered.slice(0, 20))) } catch { /* localStorage unavailable */ } } - } catch { + } catch (err) { + if (err instanceof DOMException && err.name === 'AbortError') return setError({ message: 'Network error. Check your connection.' }) } finally { - setLoading(false) + if (!controller.signal.aborted) setLoading(false) } } diff --git a/src/lib/rateLimit.ts b/src/lib/rateLimit.ts index a2f253b..f219dd5 100644 --- a/src/lib/rateLimit.ts +++ b/src/lib/rateLimit.ts @@ -1,20 +1,38 @@ -const requests = new Map() const WINDOW_MS = 60_000 const MAX_REQUESTS = 10 -export function checkRateLimit(ip: string): { allowed: boolean; retryAfter?: number } { +async function getRedis() { + if (!process.env.KV_REST_API_URL || !process.env.KV_REST_API_TOKEN) return null + try { + const { Redis } = await import('@upstash/redis') + return new Redis({ url: process.env.KV_REST_API_URL, token: process.env.KV_REST_API_TOKEN }) + } catch { + return null + } +} + +export async function checkRateLimit(ip: string): Promise<{ allowed: boolean; retryAfter?: number }> { + const redis = await getRedis() + + if (!redis) { + // No Redis configured — allow all requests + return { allowed: true } + } + + const key = `ratelimit:${ip}` const now = Date.now() - const entry = requests.get(ip) - if (!entry || now > entry.resetAt) { - requests.set(ip, { count: 1, resetAt: now + WINDOW_MS }) + const raw = await redis.get<{ count: number; resetAt: number }>(key) + + if (!raw || now > raw.resetAt) { + await redis.set(key, { count: 1, resetAt: now + WINDOW_MS }, { px: WINDOW_MS }) return { allowed: true } } - if (entry.count >= MAX_REQUESTS) { - return { allowed: false, retryAfter: Math.ceil((entry.resetAt - now) / 1000) } + if (raw.count >= MAX_REQUESTS) { + return { allowed: false, retryAfter: Math.ceil((raw.resetAt - now) / 1000) } } - entry.count++ + await redis.set(key, { count: raw.count + 1, resetAt: raw.resetAt }, { px: raw.resetAt - now }) return { allowed: true } }