Create the website

Start website

Remove dead code

fix
This commit is contained in:
Pavlenex 2026-03-12 15:15:37 +01:00
parent 7d416b57fc
commit 8a7e66acda
47 changed files with 8989 additions and 0 deletions

40
.github/workflows/deploy.yml vendored Normal file
View File

@ -0,0 +1,40 @@
name: Deploy to GitHub Pages
on:
push:
branches: [main]
workflow_dispatch:
permissions:
contents: read
pages: write
id-token: write
concurrency:
group: pages
cancel-in-progress: true
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- run: npm ci
- run: npm run build
- uses: actions/upload-pages-artifact@v3
with:
path: dist
deploy:
needs: build
runs-on: ubuntu-latest
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- uses: actions/deploy-pages@v4
id: deployment

20
.gitignore vendored Normal file
View File

@ -0,0 +1,20 @@
# Dependencies
node_modules/
# Build output (deploy via CI, not committed)
dist/
# Local env files
.env
.env.local
.env.*.local
# macOS
.DS_Store
# Editor
.vscode/
.idea/
# TypeScript cache
*.tsbuildinfo

72
CONTRIBUTING.md Normal file
View File

@ -0,0 +1,72 @@
# Contributing to contribute.btcpayserver.org
Welcome! This is a simple static React site. Contributing is straightforward.
## Quick start
```bash
git clone https://github.com/btcpayserver/contribute.btcpayserver.org
cd contribute.btcpayserver.org
npm install
npm run dev # starts at http://localhost:5173
```
The site loads `public/data/issues.json` (seed data) so you don't need a GitHub token to develop locally.
## Running with live data
```bash
ORG_GITHUB_TOKEN=ghp_your_token node scripts/fetch-issues.js
npm run dev
```
The token needs `repo:read` scope. Get one at https://github.com/settings/tokens.
## Before submitting a PR
```bash
npm run typecheck # must pass with zero errors
npm run build # must produce a clean dist/
```
The PR check workflow runs both automatically.
## File conventions
| Pattern | Use for |
|---|---|
| `PascalCase.tsx` | React components |
| `useCamelCase.ts` | React hooks |
| `kebab-case.ts` | Utility/lib files |
| One default export per file | Always |
## Adding a new skill or sub-tag
Skills and tags are defined in **two places** — keep them in sync:
1. **`src/lib/skill-map.ts`** — frontend taxonomy (displayed in UI)
2. **`scripts/skill-mapper.js`** — server-side mapping (applied at build time)
Add the new skill/tag to both files, then run `node scripts/fetch-issues.js` to verify the mapping produces expected output.
## Design system
The site mirrors [directory.btcpayserver.org](https://directory.btcpayserver.org). Core design tokens are in `src/index.css`. The `.glass` utility class is the primary card style.
Do not add new CSS frameworks or UI libraries — use the existing Tailwind utilities and Radix UI primitives.
## Project structure
```
src/
components/ui/ # Atomic primitives (Button, Badge, Dialog)
components/ # Feature components
hooks/ # useFilters, useIssues, useTheme
lib/ # Pure utilities — no side effects
types/ # TypeScript interfaces
scripts/
fetch-issues.js # GitHub API fetcher (runs in CI)
skill-mapper.js # Issue → skill mapping logic
public/data/
issues.json # Auto-generated by workflow; seed data committed
```

49
README.md Normal file
View File

@ -0,0 +1,49 @@
# contribute.btcpayserver.org
A unified landing page that surfaces all **good first issues** across the BTCPay Server GitHub organization, filtered by contributor skill — developer, writer, designer, or marketer.
**Live:** https://contribute.btcpayserver.org
---
## How it works
1. A GitHub Actions workflow runs every 6 hours (and on manual trigger)
2. It fetches all `good first issue` labeled issues from every public repo in the `btcpayserver` org
3. Issues are mapped to skill categories and sub-tags based on repo language and labels
4. The data is written to `public/data/issues.json` and the site is rebuilt and deployed to GitHub Pages
The site is fully static — no backend, no API calls from the browser.
## Local development
```bash
git clone https://github.com/btcpayserver/contribute.btcpayserver.org
cd contribute.btcpayserver.org
npm install
npm run dev
```
The dev server uses the seed data in `public/data/issues.json`. To fetch live data:
```bash
ORG_GITHUB_TOKEN=ghp_your_token node scripts/fetch-issues.js
```
## Contributing
See [CONTRIBUTING.md](./CONTRIBUTING.md).
## Stack
- React 19 + TypeScript (strict)
- Vite 6 + Tailwind CSS 4
- Radix UI (Dialog), Lucide React
- GitHub Actions for data pipeline and deployment
## Deployment
GitHub Pages via `actions/deploy-pages`. Source: `main` branch.
Pages source must be set to **GitHub Actions** in repo Settings → Pages.
Required secret: `ORG_GITHUB_TOKEN` — a PAT with `repo:read` scope to query cross-repo issues.

23
eslint.config.js Normal file
View File

@ -0,0 +1,23 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
},
])

43
index.html Normal file
View File

@ -0,0 +1,43 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>BTCPay Contribute — Find your first issue</title>
<meta name="description" content="Discover good first issues across all BTCPay Server projects. Filter by skill — developer, writer, designer, or marketer — and start contributing today." />
<!-- Open Graph -->
<meta property="og:title" content="BTCPay Contribute" />
<meta property="og:description" content="Find good first issues across all BTCPay Server projects, filtered by your skills." />
<meta property="og:type" content="website" />
<meta property="og:url" content="https://contribute.btcpayserver.org" />
<!-- Twitter Card -->
<meta name="twitter:card" content="summary" />
<meta name="twitter:title" content="BTCPay Contribute" />
<meta name="twitter:description" content="Find good first issues across all BTCPay Server projects." />
<!-- Favicon -->
<meta name="theme-color" content="#51b13e" />
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
<link rel="shortcut icon" href="/favicon.ico" />
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<link rel="mask-icon" href="/safari-pinned-tab.svg" color="#51b13e" />
<link rel="manifest" href="/site.webmanifest" />
<!-- Theme flash prevention — runs before React hydrates -->
<script>
(function() {
var t = localStorage.getItem('theme');
if (t === 'dark' || (!t && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
document.documentElement.classList.add('dark');
}
})();
</script>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

6097
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

44
package.json Normal file
View File

@ -0,0 +1,44 @@
{
"name": "btcpay-contribute",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview",
"typecheck": "tsc -b --noEmit",
"lint": "eslint ."
},
"dependencies": {
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-tooltip": "^1.2.8",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.577.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-markdown": "^10.1.0",
"tailwind-merge": "^3.5.0"
},
"devDependencies": {
"@eslint/js": "^9.39.1",
"@octokit/rest": "^22.0.1",
"@tailwindcss/typography": "^0.5.19",
"@tailwindcss/vite": "^4.2.1",
"@types/node": "^24.10.1",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1",
"eslint": "^9.39.1",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
"tailwindcss": "^4.2.1",
"tailwindcss-animate": "^1.0.7",
"typescript": "~5.9.3",
"typescript-eslint": "^8.48.0",
"vite": "^7.3.1"
}
}

7
public/_headers Normal file
View File

@ -0,0 +1,7 @@
/*
Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data: https://avatars.githubusercontent.com https://img.youtube.com; connect-src 'self'; frame-src https://www.youtube.com; frame-ancestors 'none'
X-Frame-Options: DENY
X-Content-Type-Options: nosniff
Referrer-Policy: strict-origin-when-cross-origin
Permissions-Policy: camera=(), microphone=(), geolocation=()
Strict-Transport-Security: max-age=31536000; includeSubDomains; preload

BIN
public/apple-touch-icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

521
public/data/issues.json Normal file
View File

@ -0,0 +1,521 @@
{
"lastUpdated": "2026-03-11T11:20:00.000Z",
"totalIssues": 12,
"repoCount": 5,
"repos": [
{
"id": 0,
"name": "directory.btcpayserver.org",
"fullName": "btcpayserver/directory.btcpayserver.org",
"description": null,
"url": "https://github.com/btcpayserver/directory.btcpayserver.org",
"language": null,
"topics": [],
"stars": 0
},
{
"id": 0,
"name": "btcpayserver-plugin-builder",
"fullName": "btcpayserver/btcpayserver-plugin-builder",
"description": null,
"url": "https://github.com/btcpayserver/btcpayserver-plugin-builder",
"language": null,
"topics": [],
"stars": 0
},
{
"id": 0,
"name": "btcpayserver",
"fullName": "btcpayserver/btcpayserver",
"description": null,
"url": "https://github.com/btcpayserver/btcpayserver",
"language": null,
"topics": [],
"stars": 0
},
{
"id": 0,
"name": "btcpayserver-doc",
"fullName": "btcpayserver/btcpayserver-doc",
"description": null,
"url": "https://github.com/btcpayserver/btcpayserver-doc",
"language": null,
"topics": [],
"stars": 0
},
{
"id": 0,
"name": "app",
"fullName": "btcpayserver/app",
"description": null,
"url": "https://github.com/btcpayserver/app",
"language": null,
"topics": [],
"stars": 0
}
],
"issues": [
{
"id": 4054026700,
"number": 542,
"title": "New entry submission - MAGIC Grants",
"body": "New submission:\n\nName: MAGIC Grants\nUrl: https://donate.magicgrants.org\nTwitter: @MagicGrants\nType: non-profits\nDescription: MAGIC Grants provides scholarships for students interested in cryptocurrencies and privacy, supports public cryptocurrency infrastructure, and promotes privacy.",
"url": "https://github.com/btcpayserver/directory.btcpayserver.org/issues/542",
"createdAt": "2026-03-10T20:20:35Z",
"updatedAt": "2026-03-11T08:33:49Z",
"commentsCount": 0,
"reactionCount": 0,
"labels": [
{
"name": "good first issue",
"color": "7057ff"
},
{
"name": "Submission-approved",
"color": "4ddd60"
}
],
"repo": {
"name": "directory.btcpayserver.org",
"fullName": "btcpayserver/directory.btcpayserver.org",
"language": null,
"url": "https://github.com/btcpayserver/directory.btcpayserver.org"
},
"assignees": [],
"author": {
"login": "SamsungGalaxyPlayer",
"avatarUrl": "https://avatars.githubusercontent.com/u/12520755?v=4",
"url": "https://github.com/SamsungGalaxyPlayer"
},
"skills": [
"developer"
],
"tags": []
},
{
"id": 4046354134,
"number": 163,
"title": "[Feature] Add health check endpoint to Plugin Builder",
"body": "Plugin Builder currently has no dedicated health check endpoint.\n\nSince startup depends on Postgres, Docker, and Azure Storage, it would be useful to expose a simple probe endpoint for deployments and monitoring.\n\nSuggested scope:\n- add `/health`\n- return `200 OK` when the app is ready to serve requests\n- return `503` when startup is incomplete or a critical dependency is unavailable\n- keep the check lightweight",
"url": "https://github.com/btcpayserver/btcpayserver-plugin-builder/issues/163",
"createdAt": "2026-03-09T15:44:59Z",
"updatedAt": "2026-03-09T15:46:24Z",
"commentsCount": 0,
"reactionCount": 0,
"labels": [
{
"name": "good first issue",
"color": "7057ff"
}
],
"repo": {
"name": "btcpayserver-plugin-builder",
"fullName": "btcpayserver/btcpayserver-plugin-builder",
"language": null,
"url": "https://github.com/btcpayserver/btcpayserver-plugin-builder"
},
"assignees": [],
"author": {
"login": "thgO-O",
"avatarUrl": "https://avatars.githubusercontent.com/u/107907441?v=4",
"url": "https://github.com/thgO-O"
},
"skills": [
"developer"
],
"tags": []
},
{
"id": 4018455059,
"number": 160,
"title": "Require Video URL filled out in order to request listing in the directory",
"body": "Now that @teamssUTXO has [added support for a video URL](https://github.com/btcpayserver/btcpayserver-plugin-builder/pull/150), we should require all newly listed plugins to include an explainer video.\n\nIt does not need to be high production quality - even a basic screen recording of the plugin in action would go a long way in demonstrating that it deserves to be listed.\n\nThe change should be straightforward, likely just an update to this page:\n\n<img width=\"2013\" height=\"642\" alt=\"Image\" src=\"https://github.com/user-attachments/assets/43febf2a-b6a4-481d-b8fc-446021f0f40d\" />",
"url": "https://github.com/btcpayserver/btcpayserver-plugin-builder/issues/160",
"createdAt": "2026-03-03T20:01:10Z",
"updatedAt": "2026-03-03T20:01:43Z",
"commentsCount": 1,
"reactionCount": 1,
"labels": [
{
"name": "good first issue",
"color": "7057ff"
}
],
"repo": {
"name": "btcpayserver-plugin-builder",
"fullName": "btcpayserver/btcpayserver-plugin-builder",
"language": null,
"url": "https://github.com/btcpayserver/btcpayserver-plugin-builder"
},
"assignees": [],
"author": {
"login": "rockstardev",
"avatarUrl": "https://avatars.githubusercontent.com/u/5191402?v=4",
"url": "https://github.com/rockstardev"
},
"skills": [
"developer"
],
"tags": []
},
{
"id": 3974150655,
"number": 158,
"title": "Port and unify error page system from BTCPayServer (404 and 500 for start)",
"body": "It would be great to have a default 404 error page instead of this one :\n\n<img width=\"2877\" height=\"1464\" alt=\"Image\" src=\"https://github.com/user-attachments/assets/9a35e86f-74f4-458b-9a90-a8487c8055ec\" />",
"url": "https://github.com/btcpayserver/btcpayserver-plugin-builder/issues/158",
"createdAt": "2026-02-22T10:30:20Z",
"updatedAt": "2026-02-23T22:34:38Z",
"commentsCount": 1,
"reactionCount": 1,
"labels": [
{
"name": "good first issue",
"color": "7057ff"
}
],
"repo": {
"name": "btcpayserver-plugin-builder",
"fullName": "btcpayserver/btcpayserver-plugin-builder",
"language": null,
"url": "https://github.com/btcpayserver/btcpayserver-plugin-builder"
},
"assignees": [],
"author": {
"login": "teamssUTXO",
"avatarUrl": "https://avatars.githubusercontent.com/u/183613235?v=4",
"url": "https://github.com/teamssUTXO"
},
"skills": [
"developer"
],
"tags": []
},
{
"id": 3820273373,
"number": 7109,
"title": "[Feature]: Improve label system to work when we have 10+ labels",
"body": "Now that we are integrating the label system into Payment Requests as well (https://github.com/btcpayserver/btcpayserver/pull/7050), we should improve label UX so it scales to 10, 100, or 1,000 labels.\n\nThe current wallet UI that dumps all labels into the filter dropdown will not scale:\n\nhttps://github.com/user-attachments/assets/a9de89de-b877-472e-a776-921a46da87c9\n\nFrom the video, it is clear the labels filter dropdown should never exceed page height. A good approach would be to reuse the dropdown we already use in transaction rows for applying labels:\n\n<img width=\"453\" height=\"275\" alt=\"Ima",
"url": "https://github.com/btcpayserver/btcpayserver/issues/7109",
"createdAt": "2026-01-16T04:21:31Z",
"updatedAt": "2026-02-24T20:03:07Z",
"commentsCount": 8,
"reactionCount": 2,
"labels": [
{
"name": "good first issue",
"color": "3de570"
},
{
"name": "Label",
"color": "4566a6"
}
],
"repo": {
"name": "btcpayserver",
"fullName": "btcpayserver/btcpayserver",
"language": null,
"url": "https://github.com/btcpayserver/btcpayserver"
},
"assignees": [],
"author": {
"login": "rockstardev",
"avatarUrl": "https://avatars.githubusercontent.com/u/5191402?v=4",
"url": "https://github.com/rockstardev"
},
"skills": [
"developer"
],
"tags": []
},
{
"id": 3799539831,
"number": 7092,
"title": "Standardize Wallet View: Add Date Filter for Transactions",
"body": "**Context:**\nAs mentioned in [Issue #3954](https://github.com/btcpayserver/btcpayserver/issues/3954), there is a need to standardize the Wallet view so that it offers feature parity with other views in BTCPay Server.\n\n<img width=\"2297\" height=\"262\" alt=\"Image\" src=\"https://github.com/user-attachments/assets/2e36b1fd-c0ec-42dc-b64f-48f0eabf8ed3\" />\n\n\n**Request:**\nEnhance the Wallet transaction view to allow users to filter transactions by date in the same way as the Payment Requests and Invoices views. This would help users more easily locate relevant transactions and improve consistency across",
"url": "https://github.com/btcpayserver/btcpayserver/issues/7092",
"createdAt": "2026-01-10T11:16:33Z",
"updatedAt": "2026-02-14T16:29:31Z",
"commentsCount": 2,
"reactionCount": 0,
"labels": [
{
"name": "good first issue",
"color": "3de570"
},
{
"name": "Wallet",
"color": "97f4c1"
}
],
"repo": {
"name": "btcpayserver",
"fullName": "btcpayserver/btcpayserver",
"language": null,
"url": "https://github.com/btcpayserver/btcpayserver"
},
"assignees": [],
"author": {
"login": "pavlenex",
"avatarUrl": "https://avatars.githubusercontent.com/u/36959754?v=4",
"url": "https://github.com/pavlenex"
},
"skills": [
"developer"
],
"tags": []
},
{
"id": 3799531574,
"number": 7091,
"title": "Add support for search, specifically for TX id inside the wallet view",
"body": "As mentioned in [issue #3954](https://github.com/btcpayserver/btcpayserver/issues/3954), the wallet transactions view should be standardized line invoice or payment requests views and provide a search functionality, allowing users to immediately find a transaction based on criteria such as the transaction ID.\n\n- Implement a search bar in the wallet transactions view so users can filter or rapidly locate transactions using their transaction ID.\n",
"url": "https://github.com/btcpayserver/btcpayserver/issues/7091",
"createdAt": "2026-01-10T11:07:58Z",
"updatedAt": "2026-03-03T21:54:15Z",
"commentsCount": 3,
"reactionCount": 0,
"labels": [
{
"name": "good first issue",
"color": "3de570"
},
{
"name": "Wallet",
"color": "97f4c1"
}
],
"repo": {
"name": "btcpayserver",
"fullName": "btcpayserver/btcpayserver",
"language": null,
"url": "https://github.com/btcpayserver/btcpayserver"
},
"assignees": [],
"author": {
"login": "pavlenex",
"avatarUrl": "https://avatars.githubusercontent.com/u/36959754?v=4",
"url": "https://github.com/pavlenex"
},
"skills": [
"developer"
],
"tags": []
},
{
"id": 3579096842,
"number": 1531,
"title": "Remove Wabisabi Plugin documentation",
"body": "This page refers to a plugin not available anymore: https://docs.btcpayserver.org/Wabisabi\n\nAre there any plans to bring the plugin back or should this page better be removed?",
"url": "https://github.com/btcpayserver/btcpayserver-doc/issues/1531",
"createdAt": "2025-11-02T07:59:22Z",
"updatedAt": "2026-03-11T09:03:28Z",
"commentsCount": 1,
"reactionCount": 0,
"labels": [
{
"name": "good first issue",
"color": "19b542"
}
],
"repo": {
"name": "btcpayserver-doc",
"fullName": "btcpayserver/btcpayserver-doc",
"language": null,
"url": "https://github.com/btcpayserver/btcpayserver-doc"
},
"assignees": [],
"author": {
"login": "petzsch",
"avatarUrl": "https://avatars.githubusercontent.com/u/1374810?v=4",
"url": "https://github.com/petzsch"
},
"skills": [
"writer"
],
"tags": [
"docs"
]
},
{
"id": 3062219572,
"number": 6727,
"title": "Greenfield API - Improvements tracker",
"body": "This is a tracker issue aggregating several Greenfield API improvements, all validated as useful by @NicolasDorier. These changes would further extend the flexibility and usability of the API:\n\n- [x] Calling all invoices at once [#2394](https://github.com/btcpayserver/btcpayserver/discussions/2394)\n- [x] Get an amount paid, not the invoice amount [#2525](https://github.com/btcpayserver/btcpayserver/discussions/2525)\n- [ ] Show time last used for API key [#3169](https://github.com/btcpayserver/btcpayserver/discussions/3196)\n- [x] List all users [#3166](https://github.com/btcpayserver/btcpayserv",
"url": "https://github.com/btcpayserver/btcpayserver/issues/6727",
"createdAt": "2025-05-14T08:06:31Z",
"updatedAt": "2026-03-05T15:25:48Z",
"commentsCount": 7,
"reactionCount": 0,
"labels": [
{
"name": "Enhancement",
"color": "267588"
},
{
"name": "API",
"color": "c64f51"
},
{
"name": "good first issue",
"color": "3de570"
}
],
"repo": {
"name": "btcpayserver",
"fullName": "btcpayserver/btcpayserver",
"language": null,
"url": "https://github.com/btcpayserver/btcpayserver"
},
"assignees": [
{
"login": "TChukwuleta",
"avatarUrl": "https://avatars.githubusercontent.com/u/47084273?v=4",
"url": "https://github.com/TChukwuleta"
}
],
"author": {
"login": "pavlenex",
"avatarUrl": "https://avatars.githubusercontent.com/u/36959754?v=4",
"url": "https://github.com/pavlenex"
},
"skills": [
"developer"
],
"tags": []
},
{
"id": 3051345641,
"number": 202,
"title": "Poor `Pending` status contrast in Light mode > Withdraw > Pending status",
"body": "Good first issue for new devs wanting to contribute, pretty straightforward.\n\n<img width=\"1043\" alt=\"Image\" src=\"https://github.com/user-attachments/assets/eda85646-eef7-4ab6-a04d-fcb5c7c56906\" />\n",
"url": "https://github.com/btcpayserver/app/issues/202",
"createdAt": "2025-05-09T07:59:10Z",
"updatedAt": "2025-05-16T19:47:29Z",
"commentsCount": 4,
"reactionCount": 0,
"labels": [
{
"name": "enhancement",
"color": "a2eeef"
},
{
"name": "good first issue",
"color": "7057ff"
}
],
"repo": {
"name": "app",
"fullName": "btcpayserver/app",
"language": null,
"url": "https://github.com/btcpayserver/app"
},
"assignees": [],
"author": {
"login": "pavlenex",
"avatarUrl": "https://avatars.githubusercontent.com/u/36959754?v=4",
"url": "https://github.com/pavlenex"
},
"skills": [
"developer"
],
"tags": []
},
{
"id": 2950017622,
"number": 169,
"title": "Startup Icon Appears Low Resolution and Pixelated",
"body": "The app's startup/splash screen icon appears in low resolution and pixelated when launching the app. \n\nIt may not be visible from the screenshot, but if you deploy on the device, I am sure you can clearly tell.\n\n![Image](https://github.com/user-attachments/assets/105abca3-28fe-4076-928a-cb1e121ab688)",
"url": "https://github.com/btcpayserver/app/issues/169",
"createdAt": "2025-03-26T15:27:38Z",
"updatedAt": "2025-06-20T09:25:24Z",
"commentsCount": 1,
"reactionCount": 0,
"labels": [
{
"name": "enhancement",
"color": "a2eeef"
},
{
"name": "good first issue",
"color": "7057ff"
},
{
"name": "design",
"color": "bfdadc"
}
],
"repo": {
"name": "app",
"fullName": "btcpayserver/app",
"language": null,
"url": "https://github.com/btcpayserver/app"
},
"assignees": [
{
"login": "Ghander",
"avatarUrl": "https://avatars.githubusercontent.com/u/12990724?v=4",
"url": "https://github.com/Ghander"
}
],
"author": {
"login": "pavlenex",
"avatarUrl": "https://avatars.githubusercontent.com/u/36959754?v=4",
"url": "https://github.com/pavlenex"
},
"skills": [
"design"
],
"tags": [
"design"
]
},
{
"id": 389532105,
"number": 444,
"title": "RTL Language Support",
"body": "Right-to-Left languages have display issues in the checkout page. Arabic translation is available for anyone who wants to work on this issue. \r\n\r\nOpen RTL Issue to close outdated #305 \r\n",
"url": "https://github.com/btcpayserver/btcpayserver/issues/444",
"createdAt": "2018-12-10T23:29:17Z",
"updatedAt": "2024-05-11T19:08:51Z",
"commentsCount": 10,
"reactionCount": 1,
"labels": [
{
"name": "Enhancement",
"color": "267588"
},
{
"name": "Translation",
"color": "b3e1f9"
},
{
"name": "good first issue",
"color": "3de570"
},
{
"name": "Invoice",
"color": "68d2e8"
}
],
"repo": {
"name": "btcpayserver",
"fullName": "btcpayserver/btcpayserver",
"language": null,
"url": "https://github.com/btcpayserver/btcpayserver"
},
"assignees": [],
"author": {
"login": "britttttk",
"avatarUrl": "https://avatars.githubusercontent.com/u/39231115?v=4",
"url": "https://github.com/britttttk"
},
"skills": [
"writer"
],
"tags": [
"docs"
]
}
]
}

BIN
public/favicon-16x16.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1007 B

BIN
public/favicon-32x32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@ -0,0 +1,138 @@
<?xml version="1.0" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
"http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
<svg version="1.0" xmlns="http://www.w3.org/2000/svg"
width="1504.000000pt" height="1504.000000pt" viewBox="0 0 1504.000000 1504.000000"
preserveAspectRatio="xMidYMid meet">
<g transform="translate(0.000000,1504.000000) scale(0.100000,-0.100000)"
fill="#000000" stroke="none">
<path d="M3873 15007 c-158 -65 -289 -162 -380 -284 -15 -21 -30 -40 -33 -43
-9 -9 -33 -53 -57 -105 -13 -27 -28 -58 -33 -68 -6 -9 -20 -54 -33 -100 l-22
-82 0 -6755 c0 -3715 4 -6780 8 -6810 35 -251 174 -470 388 -613 42 -27 79
-48 82 -46 3 2 13 -3 23 -12 26 -22 84 -42 219 -76 11 -3 74 -7 140 -9 90 -2
140 1 200 15 91 20 157 42 166 54 3 5 9 8 13 7 6 -1 44 16 250 117 50 23 96
43 103 43 7 0 13 5 13 10 0 6 9 10 20 10 11 0 20 4 20 9 0 9 48 26 63 23 4 -1
7 1 7 6 0 4 15 13 33 21 17 8 91 42 162 76 72 34 166 79 210 100 44 21 139 66
212 101 72 35 136 64 142 64 6 0 11 5 11 12 0 6 3 9 6 5 4 -3 27 5 53 18 25
13 96 47 156 75 61 29 144 68 185 87 41 19 82 39 90 44 8 5 28 14 44 20 15 7
48 22 73 35 24 13 51 24 59 24 7 0 14 5 14 10 0 6 4 10 9 10 5 0 63 25 128 57
65 31 120 57 123 58 3 0 28 11 55 24 28 13 53 23 58 23 4 -1 7 3 7 8 0 6 5 10
10 10 10 0 78 31 290 134 47 22 88 40 92 39 3 -1 12 4 18 12 7 8 17 15 23 15
6 0 47 18 91 39 187 91 261 125 269 123 4 -1 7 3 7 8 0 5 3 9 8 8 4 0 46 18
95 40 48 23 88 41 90 40 1 -1 21 11 45 25 23 15 42 25 42 22 0 -3 26 8 58 25
70 36 295 144 302 145 3 0 43 19 90 42 47 23 132 63 190 91 196 92 283 134
343 163 33 16 66 29 74 29 7 0 13 4 13 9 0 5 10 12 22 16 31 10 20 5 143 64
61 30 116 54 123 54 6 0 12 4 12 7 0 4 21 17 48 28 51 22 220 102 582 275 63
30 156 74 205 97 50 23 135 64 190 90 55 26 138 66 185 88 47 22 142 67 212
101 210 100 283 134 290 135 4 1 13 7 20 14 6 6 17 12 23 12 30 0 291 141 342
185 55 48 122 120 158 170 46 65 108 175 98 175 -3 0 -1 6 4 13 5 6 18 39 27
72 10 33 21 72 26 86 13 43 9 295 -5 344 -8 24 -11 46 -8 49 4 3 2 6 -3 6 -5
0 -10 8 -10 18 -1 27 -6 39 -51 131 -41 82 -62 120 -73 131 -3 3 -23 28 -45
55 -22 28 -46 55 -55 61 -8 7 -26 22 -40 34 -14 13 -68 54 -120 93 -52 38 -99
75 -105 81 -5 6 -22 17 -36 25 -15 7 -44 28 -65 47 -22 18 -42 34 -46 34 -4 0
-13 6 -20 13 -7 7 -71 55 -143 107 -71 52 -151 110 -176 130 -25 19 -56 42
-69 50 -12 8 -32 23 -44 32 -11 10 -109 82 -216 160 -107 78 -199 146 -205
150 -5 5 -66 50 -135 100 -178 130 -322 237 -342 253 -9 8 -29 22 -45 32 -15
9 -35 25 -44 35 -9 10 -19 18 -23 18 -6 0 -72 46 -86 60 -3 3 -27 21 -55 40
-48 33 -61 43 -107 83 -12 9 -25 17 -29 17 -4 0 -14 6 -21 13 -7 7 -69 54
-138 104 -69 51 -129 96 -135 100 -5 5 -26 19 -45 33 -19 13 -37 27 -40 30 -3
3 -45 34 -95 70 -49 35 -94 68 -100 73 -5 4 -53 39 -105 77 -52 39 -104 77
-115 86 -11 9 -38 28 -60 42 -22 15 -44 32 -50 38 -5 6 -35 28 -65 50 -30 21
-56 42 -58 46 -2 5 -8 8 -13 8 -6 0 -19 9 -31 20 -12 11 -28 20 -35 20 -7 0
-13 4 -13 8 0 5 -22 24 -50 43 -27 19 -50 37 -50 42 0 4 -5 7 -11 7 -6 0 -23
10 -38 23 -15 12 -40 31 -55 42 -15 11 -75 55 -132 98 -57 42 -106 77 -109 77
-3 0 -14 8 -24 18 -28 24 -41 35 -84 64 -44 30 -48 23 59 101 201 148 322 237
383 282 37 28 80 59 95 70 15 11 46 34 69 51 23 17 87 64 142 104 139 101 168
123 180 133 10 8 219 163 260 192 37 26 210 155 230 171 11 8 32 23 48 32 15
9 27 20 27 24 0 4 6 8 14 8 8 0 16 3 18 8 5 12 127 102 138 102 6 0 10 3 10 8
1 4 24 23 52 42 28 19 59 43 69 53 11 9 21 17 24 17 5 0 64 44 120 88 6 4 87
64 180 132 186 136 252 185 275 203 8 7 83 61 165 121 83 61 155 114 160 118
6 5 60 45 120 89 61 44 117 85 125 92 8 7 47 35 85 62 39 27 76 55 83 62 7 7
26 20 42 30 17 9 30 21 30 25 0 4 4 8 10 8 5 0 18 7 27 15 10 8 61 46 113 84
52 39 100 74 105 79 6 4 69 51 140 102 197 143 250 192 332 308 38 52 73 119
66 126 -4 3 -1 6 5 6 7 0 11 3 10 8 -1 4 3 18 9 32 6 14 18 52 27 85 10 33 21
72 25 86 16 52 4 299 -17 374 -27 91 -45 139 -59 156 -6 8 -13 21 -15 29 -6
27 -97 154 -146 205 -70 71 -151 135 -171 135 -5 0 -11 4 -13 8 -4 10 -171 92
-187 92 -6 0 -11 5 -11 10 0 6 -5 10 -12 10 -7 0 -53 20 -103 44 -49 24 -117
56 -150 72 -33 15 -62 31 -65 34 -3 3 -10 6 -17 7 -7 2 -59 25 -116 53 -57 27
-107 50 -111 50 -4 0 -20 9 -34 19 -15 10 -31 19 -35 19 -4 0 -31 11 -60 25
-29 14 -104 50 -167 80 -63 30 -155 74 -205 97 -49 24 -135 64 -190 90 -55 26
-139 66 -187 89 -48 22 -92 41 -98 41 -5 0 -10 5 -10 10 0 6 -5 10 -11 10 -6
0 -48 18 -92 39 -79 37 -204 97 -387 183 -208 99 -291 138 -375 179 -49 24
-93 42 -97 41 -5 -1 -8 3 -8 8 0 6 -5 10 -11 10 -5 0 -54 21 -107 46 -53 25
-139 66 -189 90 -51 24 -137 65 -190 90 -54 26 -134 64 -178 84 -137 65 -267
126 -354 169 -46 22 -88 41 -93 41 -6 0 -22 9 -38 20 -15 11 -30 17 -34 13 -3
-3 -6 -1 -6 5 0 7 -3 11 -7 10 -5 -1 -43 15 -86 35 -42 20 -82 37 -87 37 -6 0
-10 5 -10 10 0 6 -4 10 -10 10 -10 0 -66 25 -255 115 -66 31 -154 72 -195 92
-41 19 -83 39 -92 44 -10 5 -26 13 -35 18 -10 5 -47 23 -83 39 -36 17 -119 56
-185 87 -66 32 -147 70 -180 85 -33 15 -136 64 -229 109 -92 45 -172 81 -177
81 -5 0 -9 4 -9 10 0 5 -7 7 -15 4 -8 -4 -15 -1 -15 5 0 6 -4 11 -9 11 -10 0
-238 105 -261 120 -8 6 -22 13 -30 17 -46 19 -129 57 -235 108 -352 168 -410
197 -462 233 -60 41 -154 91 -182 97 -9 3 -34 11 -56 19 -31 12 -91 15 -275
15 l-235 1 -82 -33z m1274 -2276 c37 -19 188 -91 348 -166 170 -80 294 -139
385 -182 232 -111 280 -134 360 -171 80 -37 202 -95 387 -183 171 -81 421
-200 583 -276 41 -20 105 -51 143 -69 37 -18 67 -31 67 -27 0 3 8 -1 18 -9 9
-8 51 -29 92 -48 41 -18 79 -37 84 -41 6 -5 18 -9 28 -9 10 0 18 -4 18 -10 0
-5 4 -10 9 -10 9 0 99 -41 301 -137 253 -121 300 -143 308 -143 4 0 14 -6 21
-12 6 -7 15 -13 19 -14 12 -1 51 -19 192 -87 74 -36 176 -84 225 -107 50 -23
128 -61 175 -83 47 -22 87 -41 90 -42 3 0 21 -9 40 -20 19 -11 37 -20 40 -20
20 -4 50 -18 50 -23 0 -4 -15 -17 -33 -30 -17 -13 -73 -53 -123 -91 -49 -37
-112 -82 -139 -101 -26 -18 -56 -40 -67 -49 -10 -9 -27 -24 -38 -33 -11 -10
-20 -16 -20 -13 0 3 -49 -32 -109 -77 -60 -46 -119 -90 -131 -98 -13 -8 -35
-24 -49 -36 -59 -46 -371 -274 -376 -274 -3 0 -13 -8 -23 -17 -10 -10 -81 -63
-157 -118 -76 -55 -146 -107 -155 -116 -9 -8 -23 -19 -31 -22 -8 -4 -34 -23
-59 -42 -25 -19 -56 -43 -70 -52 -14 -10 -43 -32 -66 -50 -23 -18 -46 -33 -53
-33 -6 0 -11 -4 -11 -9 0 -5 -17 -20 -37 -32 -41 -25 -65 -44 -95 -71 -10 -10
-19 -17 -21 -15 -1 1 -9 -4 -17 -10 -8 -7 -44 -34 -80 -60 -36 -26 -69 -51
-75 -56 -5 -4 -71 -52 -145 -106 -186 -136 -224 -165 -230 -171 -3 -3 -18 -14
-35 -24 -16 -11 -34 -23 -40 -28 -5 -5 -62 -47 -125 -94 -63 -47 -126 -94
-140 -104 -14 -10 -78 -57 -143 -105 l-118 -86 -62 45 c-34 25 -73 54 -86 63
-13 10 -99 73 -190 141 -91 67 -211 155 -266 194 -55 40 -102 75 -105 78 -3 3
-30 23 -60 45 -30 22 -64 48 -74 57 -11 10 -23 18 -27 18 -4 0 -14 6 -21 13
-7 7 -43 35 -80 62 -66 48 -75 63 -70 116 1 6 2 67 2 154 0 28 1 56 1 63 1 83
0 125 -3 165 -2 17 0 42 4 54 4 12 3 25 -3 28 -5 4 -7 10 -5 14 5 7 5 29 6
156 0 28 1 56 1 63 1 83 0 125 -3 165 -2 17 0 42 4 54 4 12 3 25 -3 28 -5 4
-7 10 -5 14 5 7 5 29 6 156 0 28 1 56 1 63 1 83 0 125 -3 165 -2 17 0 42 4 54
4 12 3 25 -3 28 -5 4 -7 10 -5 14 5 7 5 29 6 156 0 28 1 56 1 63 1 83 0 125
-3 165 -2 17 0 42 4 54 4 12 3 25 -3 28 -5 4 -7 10 -5 14 5 7 5 29 6 156 0 28
1 56 1 63 1 83 0 125 -3 165 -2 17 0 42 4 54 4 12 3 25 -3 28 -5 4 -7 10 -5
14 5 7 5 29 6 156 0 28 1 56 1 63 1 83 0 125 -3 165 -2 17 0 42 4 54 4 12 3
25 -3 28 -5 4 -7 10 -5 14 5 7 5 29 6 156 0 28 1 56 1 63 0 67 0 121 -2 152
-3 58 6 62 68 31z m997 -6327 c9 -8 16 -12 16 -7 0 4 5 0 11 -9 5 -10 17 -18
24 -18 8 0 15 -5 15 -10 0 -6 7 -13 16 -17 9 -3 28 -17 42 -29 14 -13 29 -24
33 -24 5 0 17 -8 27 -17 11 -10 32 -27 47 -38 61 -44 241 -177 258 -190 9 -8
21 -15 25 -15 4 0 14 -9 22 -20 8 -11 18 -20 23 -20 10 0 90 -61 95 -72 2 -5
8 -8 13 -8 7 0 81 -52 164 -117 6 -5 29 -21 53 -37 23 -16 42 -33 42 -37 0 -5
5 -9 10 -9 8 0 190 -130 210 -150 3 -3 28 -21 55 -39 28 -19 52 -38 55 -41 3
-3 28 -22 55 -41 28 -18 52 -36 55 -39 3 -3 48 -37 100 -75 52 -38 101 -75
108 -82 7 -7 16 -13 20 -13 4 0 20 -12 37 -26 16 -14 44 -34 62 -45 18 -11 33
-26 33 -31 0 -6 3 -9 6 -5 4 3 12 1 20 -6 21 -19 34 -29 114 -87 41 -29 79
-58 85 -62 5 -5 35 -26 65 -48 30 -22 60 -43 65 -48 6 -4 26 -19 45 -32 19
-14 37 -27 40 -30 3 -3 21 -17 40 -30 19 -13 37 -27 40 -30 3 -3 24 -18 48
-34 23 -16 42 -33 42 -37 0 -5 6 -9 13 -9 6 0 26 -11 42 -25 17 -13 84 -63
150 -112 66 -48 125 -91 130 -95 81 -64 228 -168 235 -168 6 0 10 -4 10 -9 0
-5 16 -19 36 -32 l36 -22 -23 -18 c-13 -10 -27 -19 -31 -19 -7 0 -58 -24 -318
-148 -52 -25 -97 -46 -100 -47 -3 0 -45 -21 -95 -45 -49 -24 -97 -45 -105 -46
-8 -2 -16 -8 -18 -13 -2 -6 -10 -11 -17 -11 -7 0 -49 -18 -94 -40 -44 -22 -85
-40 -90 -40 -5 0 -14 -7 -21 -15 -7 -9 -15 -13 -18 -11 -3 3 -17 -1 -31 -9
-30 -17 -449 -217 -461 -220 -19 -5 -66 -31 -72 -40 -4 -5 -8 -7 -8 -3 0 4
-35 -10 -77 -31 -43 -22 -107 -52 -143 -69 -36 -16 -132 -62 -213 -101 -82
-39 -153 -71 -158 -71 -5 0 -9 -5 -9 -12 0 -6 -3 -9 -6 -5 -4 3 -30 -6 -58
-20 -28 -15 -53 -27 -56 -28 -3 0 -48 -21 -100 -47 -243 -118 -356 -171 -367
-171 -7 0 -13 -4 -13 -9 0 -4 -4 -8 -9 -8 -5 0 -78 -33 -163 -74 -146 -70
-255 -121 -343 -162 -22 -10 -43 -22 -46 -27 -3 -5 -10 -9 -15 -9 -17 -1 -180
-76 -386 -178 -31 -16 -59 -26 -62 -22 -3 3 -6 0 -6 -6 0 -7 -5 -12 -11 -12
-6 0 -54 -21 -107 -47 -54 -26 -99 -48 -102 -48 -3 -1 -24 -11 -48 -23 -44
-24 -49 -21 -51 23 -2 43 -3 154 -2 240 0 50 0 122 0 160 0 39 0 111 0 160 0
50 0 122 0 160 0 39 0 111 0 160 0 50 0 122 0 160 0 39 0 111 0 160 0 50 0
122 0 160 0 39 0 111 0 160 0 50 0 122 0 160 0 39 0 111 0 160 0 50 0 122 0
160 0 39 0 111 0 160 0 50 0 122 0 160 -1 39 -1 102 0 140 0 39 0 90 -2 115
-1 25 1 57 5 72 3 15 3 30 -1 32 -8 5 -5 81 4 95 3 5 1 12 -5 16 -6 4 -8 10
-6 14 5 7 5 29 6 156 0 28 1 56 1 63 0 28 0 103 -1 120 0 9 -1 36 -1 60 -1 24
1 46 4 49 3 3 2 15 -3 27 -5 13 -3 30 4 43 9 18 134 116 216 170 14 9 30 21
35 27 6 6 36 29 68 50 31 22 57 43 57 48 0 4 5 8 11 8 7 0 31 16 55 35 23 19
45 35 49 35 3 0 13 7 22 15 8 9 41 34 72 55 31 22 76 56 101 75 25 18 63 46
85 62 22 15 46 34 54 41 8 7 26 21 40 30 42 26 146 106 149 114 6 13 30 9 46
-8z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 9.7 KiB

19
public/site.webmanifest Normal file
View File

@ -0,0 +1,19 @@
{
"name": "BTCPay Contribute",
"short_name": "Contribute",
"icons": [
{
"src": "/android-chrome-192x192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/android-chrome-512x512.png",
"sizes": "512x512",
"type": "image/png"
}
],
"theme_color": "#51b13e",
"background_color": "#0D1117",
"display": "standalone"
}

161
scripts/fetch-issues.js Normal file
View File

@ -0,0 +1,161 @@
// @ts-check
/**
* Fetches all open "good first issue" issues across the btcpayserver GitHub org.
* Writes output to public/data/issues.json.
* Exits with code 1 (no-op signal) if data is unchanged avoids redundant deploys.
*
* Required env (CI): ORG_GITHUB_TOKEN PAT with repo:read scope.
* The org has 70+ repos. Fetching all of them requires ~70 API requests, which
* exceeds the unauthenticated rate limit of 60 req/hr. A token is mandatory for
* the cron workflow. Set it as a repo secret named ORG_GITHUB_TOKEN.
*
* Local dev: run `ORG_GITHUB_TOKEN=ghp_xxx node scripts/fetch-issues.js`
* You can create a token at https://github.com/settings/tokens (no scopes needed
* for public repos just generate a classic token with no checkboxes ticked).
*/
import { Octokit } from '@octokit/rest'
import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'fs'
import { resolve, dirname } from 'path'
import { fileURLToPath } from 'url'
import { mapSkills } from './skill-mapper.js'
const __dirname = dirname(fileURLToPath(import.meta.url))
const ORG = 'btcpayserver'
const LABEL = 'good first issue'
const OUT = resolve(__dirname, '../public/data/issues.json')
const BODY_MAX = 600
async function main() {
const token = process.env.ORG_GITHUB_TOKEN
if (!token) {
console.error('ORG_GITHUB_TOKEN is required. The org has 70+ repos and will exceed')
console.error('the unauthenticated rate limit of 60 req/hr.')
console.error('Create a token at https://github.com/settings/tokens (no scopes needed for public repos)')
console.error('then run: ORG_GITHUB_TOKEN=ghp_xxx node scripts/fetch-issues.js')
process.exit(2)
}
const octokit = new Octokit({ auth: token })
// ── 1. Fetch all org repos ─────────────────────────────────────────────────
console.log(`Fetching repos for org: ${ORG}`)
const repos = await octokit.paginate(octokit.rest.repos.listForOrg, {
org: ORG,
type: 'public',
per_page: 100,
})
console.log(`Found ${repos.length} public repos`)
// ── 2. Fetch good-first-issues from each repo ──────────────────────────────
/** @type {any[]} */
const issues = []
for (const repo of repos) {
let page = 1
while (true) {
const { data } = await octokit.rest.issues.listForRepo({
owner: ORG,
repo: repo.name,
labels: LABEL,
state: 'open',
per_page: 100,
page,
})
if (data.length === 0) break
for (const raw of data) {
// Skip pull requests (GitHub returns PRs in issue list)
if (raw.pull_request) continue
const { skills, tags } = mapSkills(
{ labels: raw.labels.map((l) => (typeof l === 'string' ? { name: l } : l)) },
{ name: repo.name, language: repo.language },
)
issues.push({
id: raw.id,
number: raw.number,
title: raw.title,
body: (raw.body ?? '').slice(0, BODY_MAX),
url: raw.html_url,
createdAt: raw.created_at,
updatedAt: raw.updated_at ?? raw.created_at,
commentsCount: raw.comments,
reactionCount: raw.reactions?.total_count ?? 0,
labels: raw.labels
.filter((l) => typeof l === 'object')
.map((l) => ({ name: l.name ?? '', color: l.color ?? '888888' })),
repo: {
name: repo.name,
fullName: repo.full_name,
language: repo.language ?? null,
url: repo.html_url,
},
assignees: (raw.assignees ?? []).map((a) => ({
login: a.login,
avatarUrl: a.avatar_url,
url: a.html_url,
})),
author: {
login: raw.user?.login ?? 'unknown',
avatarUrl: raw.user?.avatar_url ?? '',
url: raw.user?.html_url ?? '',
},
skills,
tags,
})
}
if (data.length < 100) break
page++
}
}
// ── 3. Sort by creation date desc ─────────────────────────────────────────
issues.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
// ── 4. Build repo list ─────────────────────────────────────────────────────
const reposWithIssues = repos
.filter((r) => issues.some((i) => i.repo.name === r.name))
.map((r) => ({
id: r.id,
name: r.name,
fullName: r.full_name,
description: r.description ?? null,
url: r.html_url,
language: r.language ?? null,
topics: r.topics ?? [],
stars: r.stargazers_count,
}))
const output = {
lastUpdated: new Date().toISOString(),
totalIssues: issues.length,
repoCount: reposWithIssues.length,
repos: reposWithIssues,
issues,
}
// ── 5. Diff check — skip write if unchanged ────────────────────────────────
const json = JSON.stringify(output, null, 2)
if (existsSync(OUT)) {
const existing = readFileSync(OUT, 'utf8')
const strip = (s) => s.replace(/"lastUpdated":\s*"[^"]+"/g, '"lastUpdated":""')
if (strip(existing) === strip(json)) {
console.log('No changes detected — skipping deploy')
process.exit(1) // signals workflow to skip build step
}
}
mkdirSync(dirname(OUT), { recursive: true })
writeFileSync(OUT, json, 'utf8')
console.log(`✓ Wrote ${issues.length} issues from ${reposWithIssues.length} repos to ${OUT}`)
}
main().catch((err) => {
console.error(err)
process.exit(2)
})

48
scripts/skill-mapper.js Normal file
View File

@ -0,0 +1,48 @@
// @ts-check
/**
* Maps a GitHub issue + repo to skill categories.
*
* Rules (an issue can match multiple skills):
* writer repo is btcpayserver-doc OR label is docs/writing/translation
* design label is design/ui/ux
* marketing label is marketing/community/growth/seo
* developer default when none of the above match
*
* Mirrors src/lib/skill-map.ts keep both in sync.
*/
const WRITER_REPOS = new Set(['btcpayserver-doc'])
const WRITER_LABELS = new Set(['writing', 'documentation', 'docs', 'translation', 'content writing'])
const DESIGN_LABELS = new Set(['design', 'ui', 'ux', 'ui/ux'])
const MARKETING_LABELS = new Set(['marketing', 'community', 'growth', 'seo'])
/**
* @param {{ labels: { name: string }[] }} issue
* @param {{ name: string, language: string | null }} repo
* @returns {{ skills: string[], tags: string[] }}
*/
export function mapSkills(issue, repo) {
const labelNames = issue.labels.map((l) => l.name.toLowerCase())
const isWriter = WRITER_REPOS.has(repo.name) || labelNames.some((l) => WRITER_LABELS.has(l))
const isDesign = labelNames.some((l) => DESIGN_LABELS.has(l))
const isMarketing = labelNames.some((l) => MARKETING_LABELS.has(l))
/** @type {string[]} */
const skills = []
if (isWriter) skills.push('writer')
if (isDesign) skills.push('design')
if (isMarketing) skills.push('marketing')
if (skills.length === 0) skills.push('developer') // default
// Tags: repo language for developer issues
/** @type {string[]} */
const tags = []
if (skills.includes('developer') && repo.language) tags.push(repo.language)
if (isWriter) tags.push('docs')
if (isDesign) tags.push('design')
if (isMarketing) tags.push('marketing')
return { skills, tags }
}

60
src/App.tsx Normal file
View File

@ -0,0 +1,60 @@
import { useState } from 'react'
import Navbar from '@/components/Navbar'
import Hero from '@/components/Hero'
import FilterBar from '@/components/FilterBar'
import IssueGrid from '@/components/IssueGrid'
import IssueModal from '@/components/IssueModal'
import ResourcesSection from '@/components/ResourcesSection'
import Footer from '@/components/Footer'
import { useFilters } from '@/hooks/useFilters'
import { useIssues } from '@/hooks/useIssues'
import type { Issue } from '@/types'
export default function App() {
const { filters, setSkill, setQuery, clearAll } = useFilters()
const { filtered, status } = useIssues(filters)
const [selectedIssue, setSelectedIssue] = useState<Issue | null>(null)
return (
<>
<Navbar />
<main className="max-w-7xl mx-auto px-4 sm:px-6 pt-[4.5rem] pb-8">
<Hero />
<div id="issues" className="border-t border-border/60 pt-20 sm:pt-28 pb-10 text-center">
<h2 className="font-display font-bold text-3xl sm:text-4xl lg:text-5xl text-foreground">
Pick an issue
</h2>
<p className="mt-4 text-muted-foreground max-w-sm mx-auto text-sm sm:text-base">
Filter by skill and find something that excites you.
</p>
</div>
<div className="sticky top-14 z-40 py-3 -mx-4 sm:-mx-6 px-4 sm:px-6 bg-background/85 backdrop-blur-xl">
<FilterBar
filters={filters}
setSkill={setSkill}
setQuery={setQuery}
clearAll={clearAll}
/>
</div>
<IssueGrid
issues={filtered}
loading={status === 'loading'}
onIssueClick={setSelectedIssue}
/>
<ResourcesSection />
</main>
<Footer />
<IssueModal
issue={selectedIssue}
onClose={() => setSelectedIssue(null)}
/>
</>
)
}

View File

@ -0,0 +1,93 @@
import { Search, X } from 'lucide-react'
import { useCallback, useRef } from 'react'
import { cn } from '@/lib/utils'
import { ALL_SKILLS, SKILL_META } from '@/lib/skill-map'
import type { FilterState, SkillCategory } from '@/types'
import { hasActiveFilters } from '@/lib/filter-engine'
interface FilterBarProps {
filters: FilterState
setSkill: (s: SkillCategory | null) => void
setQuery: (q: string) => void
clearAll: () => void
}
export default function FilterBar({ filters, setSkill, setQuery, clearAll }: FilterBarProps) {
const searchRef = useRef<HTMLInputElement>(null)
const debounceRef = useRef<number | undefined>(undefined)
const handleSearch = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
clearTimeout(debounceRef.current)
debounceRef.current = window.setTimeout(() => setQuery(e.target.value), 200)
},
[setQuery],
)
const active = hasActiveFilters(filters)
return (
<div className="mb-4">
<div className="flex flex-col sm:flex-row sm:items-center gap-2">
<div className="flex flex-wrap items-center gap-1.5">
{ALL_SKILLS.map((skill) => {
const meta = SKILL_META[skill]
const on = filters.skill === skill
return (
<button
key={skill}
type="button"
onClick={() => setSkill(on ? null : skill)}
aria-pressed={on}
className={cn(
'flex items-center gap-1.5 px-3.5 py-1.5 rounded-full text-sm font-medium',
'transition-all duration-150 cursor-pointer',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
on
? 'bg-primary text-white shadow-sm shadow-primary/20'
: 'bg-muted/70 text-muted-foreground hover:bg-muted hover:text-foreground',
)}
>
<span aria-hidden="true">{meta.icon}</span>
{meta.label}
</button>
)
})}
</div>
<div className="flex items-center gap-2 sm:ml-auto">
<div className="relative flex-1 sm:flex-none">
<Search size={13} className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground pointer-events-none" aria-hidden="true" />
<input
ref={searchRef}
type="search"
defaultValue={filters.query}
onChange={handleSearch}
placeholder="Search issues…"
aria-label="Search issues"
className={cn(
'h-8 pl-8 pr-3 rounded-full text-sm',
'bg-muted/70 placeholder:text-muted-foreground',
'border border-transparent focus:border-border focus:bg-card',
'focus:outline-none transition-all duration-200',
'w-full sm:w-40 sm:focus:w-60',
)}
/>
</div>
{active && (
<button
type="button"
onClick={clearAll}
aria-label="Clear all filters"
className="flex items-center gap-1 h-8 px-3 rounded-full text-xs bg-muted/70 text-muted-foreground hover:text-foreground hover:bg-muted transition-all duration-150 cursor-pointer shrink-0"
>
<X size={11} aria-hidden="true" /> Clear
</button>
)}
</div>
</div>
</div>
)
}

35
src/components/Footer.tsx Normal file
View File

@ -0,0 +1,35 @@
import SupporterSprite from '@/components/SupporterSprite'
import { supporters } from '@/data/supporters'
export default function Footer() {
return (
<footer className="mt-20 border-t border-border">
<div className="max-w-7xl mx-auto px-4 sm:px-6 py-8 sm:py-10 relative">
<SupporterSprite />
<div className="grid grid-cols-3 sm:grid-cols-5 lg:grid-cols-9 gap-4 sm:gap-6 items-center justify-items-center">
{supporters.map((s) => (
<a
key={s.svgId}
href={s.url}
target="_blank"
rel="noopener noreferrer"
title={s.name}
className="flex items-center justify-center w-full h-8 sm:h-10 opacity-50 hover:opacity-100 transition-opacity duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<svg
role="img"
aria-label={s.name}
width={s.width}
height={s.height}
className="max-h-6 sm:max-h-8 w-auto max-w-full shrink-0"
style={s.fillCurrentColor ? { fill: 'currentColor' } : undefined}
>
<use href={`#${s.svgId}`} />
</svg>
</a>
))}
</div>
</div>
</footer>
)
}

41
src/components/Hero.tsx Normal file
View File

@ -0,0 +1,41 @@
export default function Hero() {
return (
<section aria-label="Overview" className="relative py-16 sm:py-24">
<div
className="absolute inset-0 pointer-events-none"
style={{ background: 'radial-gradient(ellipse at 50% 0%, hsl(var(--primary) / 0.08) 0%, transparent 60%)' }}
aria-hidden="true"
/>
<div className="relative text-center max-w-2xl mx-auto space-y-6">
<h1 className="text-5xl sm:text-6xl xl:text-7xl font-display font-bold tracking-tight leading-[1.05] text-foreground">
Start contributing<br />to{' '}
<span className="bg-gradient-to-r from-primary to-emerald-600 bg-clip-text text-transparent">
itcoin.
</span>
</h1>
<p className="text-lg sm:text-xl leading-relaxed text-muted-foreground max-w-xl mx-auto">
Find your first issue across the entire BTCPay ecosystem.
Developer, writer, designer, or marketer. There's a spot for you.
</p>
<div className="flex flex-wrap items-center justify-center gap-3 pt-2">
<a
href="#issues"
className="inline-flex items-center gap-2 rounded-full px-8 h-11 sm:h-12 text-sm sm:text-base font-semibold text-primary-foreground bg-primary shadow-lg shadow-primary/20 hover:shadow-primary/40 hover:bg-primary/90 transition-all duration-300 hover:scale-105 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
>
Pick an issue
</a>
<a
href="#how-it-works"
className="inline-flex items-center gap-2 rounded-full px-8 h-11 sm:h-12 text-sm sm:text-base font-semibold border border-border/60 bg-background/50 backdrop-blur-sm hover:bg-background/80 transition-all duration-300 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
>
How it works
</a>
</div>
</div>
</section>
)
}

View File

@ -0,0 +1,64 @@
import { GitBranch, Clock } from 'lucide-react'
import { Badge } from '@/components/ui/badge'
import IssueLabel from '@/components/IssueLabel'
import type { Issue } from '@/types'
import { cn, timeAgo } from '@/lib/utils'
import { SKILL_META } from '@/lib/skill-map'
interface IssueCardProps {
issue: Issue
onClick: (issue: Issue) => void
}
export default function IssueCard({ issue, onClick }: IssueCardProps) {
const skill = issue.skills[0]
const meta = skill ? SKILL_META[skill] : null
return (
<button
type="button"
onClick={() => onClick(issue)}
className={cn(
'glass rounded-2xl text-left w-full h-full flex flex-col p-5 group transition-all duration-300',
'hover:-translate-y-1 hover:shadow-xl hover:shadow-primary/10',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
)}
>
<div className="flex items-center justify-between gap-2 mb-2.5">
<span className="flex items-center gap-1.5 text-muted-foreground text-xs font-medium">
<GitBranch size={11} aria-hidden="true" />
{issue.repo.name}
</span>
{meta && (
<Badge variant="skill" className="text-[10px]">
<span aria-hidden="true">{meta.icon}</span> {meta.label}
</Badge>
)}
</div>
<h3 className="font-display font-bold text-base leading-snug line-clamp-2 group-hover:text-primary transition-colors duration-200">
{issue.title}
</h3>
{issue.body && (
<p className="text-muted-foreground text-sm leading-relaxed mt-2 line-clamp-2">
{issue.body}
</p>
)}
{issue.labels.length > 0 && (
<div className="flex flex-wrap gap-1.5 mt-3">
{issue.labels.slice(0, 3).map((label) => (
<IssueLabel key={label.name} label={label} />
))}
</div>
)}
<div className="flex items-center gap-3 mt-3 text-muted-foreground text-xs">
<span className="flex items-center gap-1 ml-auto">
<Clock size={11} aria-hidden="true" /> {timeAgo(issue.createdAt)}
</span>
</div>
</button>
)
}

View File

@ -0,0 +1,68 @@
import { useEffect, useState } from 'react'
import { Loader2 } from 'lucide-react'
import { Button } from '@/components/ui/button'
import IssueCard from '@/components/IssueCard'
import type { Issue } from '@/types'
const PAGE_SIZE = 20
interface IssueGridProps {
issues: Issue[]
loading?: boolean
onIssueClick: (issue: Issue) => void
}
export default function IssueGrid({ issues, loading, onIssueClick }: IssueGridProps) {
const [page, setPage] = useState(1)
// Reset to page 1 whenever the filtered issue list changes
useEffect(() => { setPage(1) }, [issues])
const visible = issues.slice(0, page * PAGE_SIZE)
const hasMore = visible.length < issues.length
if (loading) {
return (
<div className="flex items-center justify-center py-20 text-muted-foreground">
<Loader2 size={24} className="animate-spin mr-2" />
Loading issues
</div>
)
}
if (issues.length === 0) {
return (
<div className="flex flex-col items-center justify-center py-20 text-center">
<p className="text-4xl mb-3">🔍</p>
<h3 className="font-display font-semibold text-lg">No issues found</h3>
<p className="text-muted-foreground text-sm mt-1">
Try adjusting your filters or search query.
</p>
</div>
)
}
return (
<div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{visible.map((issue, i) => (
<div key={issue.id} className={`card-enter card-enter-${Math.min(i + 1, 6)}`}>
<IssueCard issue={issue} onClick={onIssueClick} />
</div>
))}
</div>
{hasMore && (
<div className="flex justify-center mt-8">
<Button variant="outline" onClick={() => setPage((p) => p + 1)}>
Load more ({issues.length - visible.length} remaining)
</Button>
</div>
)}
<p className="text-center text-xs text-muted-foreground mt-4">
Showing {visible.length} of {issues.length} issues
</p>
</div>
)
}

View File

@ -0,0 +1,19 @@
import type { IssueLabel as IssueLabelType } from '@/types'
/** Shared GitHub-style label chip used in IssueCard and IssueModal. */
export default function IssueLabel({ label }: { label: IssueLabelType }) {
// Validate hex color from GitHub API before using in inline styles
const hex = /^[0-9a-f]{6}$/i.test(label.color) ? label.color : '888888'
return (
<span
className="inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-medium"
style={{
backgroundColor: `#${hex}22`,
color: `#${hex}`,
border: `1px solid #${hex}44`,
}}
>
{label.name}
</span>
)
}

View File

@ -0,0 +1,115 @@
import { ExternalLink, MessageCircle, Clock, GitBranch } from 'lucide-react'
import ReactMarkdown from 'react-markdown'
import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import IssueLabel from '@/components/IssueLabel'
import type { Issue } from '@/types'
import { timeAgo } from '@/lib/utils'
import { SKILL_META } from '@/lib/skill-map'
interface IssueModalProps {
issue: Issue | null
onClose: () => void
}
export default function IssueModal({ issue, onClose }: IssueModalProps) {
return (
<Dialog open={!!issue} onOpenChange={(open) => { if (!open) onClose() }}>
{issue && (
<DialogContent className="max-h-[90vh] flex flex-col overflow-hidden">
<div className="flex items-center gap-2 text-muted-foreground text-xs pr-8">
<GitBranch size={12} aria-hidden="true" />
<a
href={issue.repo.url}
target="_blank"
rel="noopener noreferrer"
className="hover:text-primary transition-colors"
>
{issue.repo.fullName}
</a>
<span aria-hidden="true">·</span>
<span>#{issue.number}</span>
</div>
<DialogTitle className="font-display font-semibold text-xl leading-snug mt-2 pr-8">
{issue.title}
</DialogTitle>
{issue.labels.length > 0 && (
<div className="flex flex-wrap gap-1.5 mt-2">
{issue.labels.map((label) => (
<IssueLabel key={label.name} label={label} />
))}
</div>
)}
<div className="flex items-center gap-3 text-xs text-muted-foreground mt-1">
<img
src={issue.author.avatarUrl}
alt={issue.author.login}
className="w-5 h-5 rounded-full"
referrerPolicy="no-referrer"
loading="lazy"
/>
<a
href={issue.author.url}
target="_blank"
rel="noopener noreferrer"
className="font-medium hover:text-primary transition-colors"
>
{issue.author.login}
</a>
<span className="flex items-center gap-1">
<Clock size={11} aria-hidden="true" /> {timeAgo(issue.createdAt)}
</span>
<span className="flex items-center gap-1 ml-auto">
<MessageCircle size={11} aria-hidden="true" /> {issue.commentsCount} comments
</span>
</div>
<div className="flex-1 overflow-y-auto mt-4 pr-1 min-h-0">
<div className="prose prose-sm max-w-none dark:prose-invert text-foreground">
<ReactMarkdown
skipHtml
allowedElements={['p', 'h1', 'h2', 'h3', 'ul', 'ol', 'li', 'code', 'pre', 'blockquote', 'a', 'strong', 'em', 'br']}
unwrapDisallowed
>
{issue.body || '_No description provided._'}
</ReactMarkdown>
</div>
</div>
{issue.skills.length > 0 && (
<div className="flex flex-wrap gap-1.5 pt-3 border-t border-border">
{issue.skills.map((s) => {
const meta = SKILL_META[s]
return (
<Badge key={s} variant="skill">
<span aria-hidden="true">{meta.icon}</span> {meta.label}
</Badge>
)
})}
{issue.tags.map((tag) => (
<Badge key={tag} variant="default" className="text-xs">
{tag}
</Badge>
))}
</div>
)}
<div className="mt-4 pt-4 border-t border-border">
<Button size="lg" className="w-full" asChild>
<a href={issue.url} target="_blank" rel="noopener noreferrer">
Open Issue on GitHub
<ExternalLink size={14} aria-hidden="true" />
</a>
</Button>
</div>
</DialogContent>
)}
</Dialog>
)
}

76
src/components/Navbar.tsx Normal file
View File

@ -0,0 +1,76 @@
import ThemeToggle from '@/components/ThemeToggle'
/** BTCPay logo mark — paths extracted from directory.btcpayserver.org logo SVG */
function BTCPayMark({ className }: { className?: string }) {
return (
<svg
viewBox="3.5 0 62 107.758"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
className={className}
>
<path d="M 9.525 104.7 C 6.228 104.682 3.565 102.004 3.565 98.707 L 3.565 8.573 C 3.5 6.39 4.627 4.344 6.507 3.233 C 8.387 2.122 10.723 2.122 12.603 3.233 C 14.484 4.344 15.611 6.39 15.545 8.573 L 15.545 98.707 C 15.545 100.301 14.91 101.83 13.78 102.954 C 12.651 104.079 11.119 104.707 9.525 104.7 Z" fill="rgb(206,220,33)" />
<path d="M 9.531 104.7 C 6.751 104.675 4.353 102.741 3.739 100.03 C 3.126 97.318 4.458 94.54 6.957 93.321 L 43.076 76.178 L 5.976 48.826 C 3.313 46.864 2.744 43.115 4.706 40.452 C 6.667 37.788 10.417 37.22 13.08 39.181 L 58.242 72.455 C 59.953 73.696 60.879 75.749 60.676 77.852 C 60.473 79.956 59.172 81.794 57.256 82.685 L 12.105 104.12 C 11.301 104.504 10.421 104.702 9.531 104.7 Z" fill="rgb(81,177,62)" />
<path d="M 9.531 69.269 C 6.927 69.288 4.61 67.622 3.799 65.148 C 2.987 62.674 3.868 59.96 5.976 58.433 L 43.076 31.097 L 6.957 13.98 C 3.965 12.562 2.689 8.986 4.107 5.994 C 5.526 3.001 9.101 1.725 12.094 3.144 L 57.256 24.579 C 59.161 25.48 60.452 27.318 60.654 29.416 C 60.857 31.515 59.94 33.565 58.242 34.814 L 13.08 68.104 C 12.052 68.863 10.808 69.271 9.531 69.269 Z" fill="rgb(206,220,33)" />
<path d="M 15.518 40.975 L 15.518 66.305 L 32.7 53.648 Z" fill="rgb(30,122,68)" />
<rect x="3.538" y="30.355" width="11.98" height="29.199" fill="white" />
<path d="M 15.518 8.573 C 15.584 6.39 14.457 4.344 12.576 3.233 C 10.696 2.122 8.36 2.122 6.48 3.233 C 4.6 4.344 3.472 6.39 3.538 8.573 L 3.538 83.937 L 15.518 83.937 Z" fill="rgb(206,220,33)" />
</svg>
)
}
export default function Navbar() {
return (
<header className="fixed top-0 left-0 right-0 z-50 bg-background/80 backdrop-blur-xl border-b border-border/40 transition-all duration-300">
<div className="max-w-7xl mx-auto px-4 sm:px-6 h-16 flex items-center gap-6">
{/* Logo — same mark as directory.btcpayserver.org, "Contribute" label */}
<a
href="/"
aria-label="BTCPay Contribute home"
className="flex items-center gap-2 shrink-0 group"
>
<BTCPayMark className="h-9 w-auto transition-transform duration-300 group-hover:scale-105" />
<span className="font-display leading-[1.1]">
<span className="block font-bold text-sm tracking-tight text-foreground">
BTCPay
</span>
<span className="block text-[10px] font-bold tracking-[0.14em] uppercase text-primary">
Contribute
</span>
</span>
</a>
<nav className="hidden sm:flex items-center gap-1">
<a
href="#issues"
className="px-3 py-1.5 rounded-lg text-sm text-muted-foreground hover:text-foreground hover:bg-muted transition-all duration-150"
>
Pick an issue
</a>
<a
href="#how-it-works"
className="px-3 py-1.5 rounded-lg text-sm text-muted-foreground hover:text-foreground hover:bg-muted transition-all duration-150"
>
How it works
</a>
</nav>
<div className="flex items-center gap-1.5 ml-auto">
<a
href="https://github.com/btcpayserver"
target="_blank"
rel="noopener noreferrer"
aria-label="BTCPay Server on GitHub"
className="flex items-center justify-center w-8 h-8 rounded-lg text-muted-foreground hover:text-foreground hover:bg-muted transition-all duration-150"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path fillRule="evenodd" clipRule="evenodd" d="M12 0C5.37 0 0 5.37 0 12c0 5.31 3.435 9.795 8.205 11.385.6.105.825-.255.825-.57 0-.285-.015-1.23-.015-2.235-3.015.555-3.795-.735-4.035-1.41-.135-.345-.72-1.41-1.23-1.695-.42-.225-1.02-.78-.015-.795.945-.015 1.62.87 1.845 1.23 1.08 1.815 2.805 1.305 3.495.99.105-.78.42-1.305.765-1.605-2.67-.3-5.46-1.335-5.46-5.925 0-1.305.465-2.385 1.23-3.225-.12-.3-.54-1.53.12-3.18 0 0 1.005-.315 3.3 1.23.96-.27 1.98-.405 3-.405s2.04.135 3 .405c2.295-1.56 3.3-1.23 3.3-1.23.66 1.65.24 2.88.12 3.18.765.84 1.23 1.905 1.23 3.225 0 4.605-2.805 5.625-5.475 5.925.435.375.81 1.095.81 2.22 0 1.605-.015 2.895-.015 3.3 0 .315.225.69.825.57A12.02 12.02 0 0024 12c0-6.63-5.37-12-12-12z" />
</svg>
</a>
<ThemeToggle />
</div>
</div>
</header>
)
}

View File

@ -0,0 +1,355 @@
import { useEffect, useRef, useState } from 'react'
import { Play, ArrowUpRight, Code2, Terminal, MonitorPlay, FlaskConical, PenLine, BookOpen } from 'lucide-react'
import { cn } from '@/lib/utils'
function useScrollReveal() {
const ref = useRef<HTMLDivElement>(null)
const [visible, setVisible] = useState(false)
useEffect(() => {
const el = ref.current
if (!el) return
const obs = new IntersectionObserver(
([entry]) => { if (entry.isIntersecting) { setVisible(true); obs.disconnect() } },
{ threshold: 0.1 }
)
obs.observe(el)
return () => obs.disconnect()
}, [])
return { ref, visible }
}
function TelegramIcon({ className }: { className?: string }) {
return (
<svg viewBox="0 0 24 24" fill="currentColor" className={className} aria-hidden="true">
<path d="M12 0C5.37 0 0 5.37 0 12s5.37 12 12 12 12-5.37 12-12S18.63 0 12 0zm5.89 8.25-1.97 9.27c-.14.66-.54.82-1.08.51l-3-2.21-1.45 1.39c-.16.16-.3.29-.61.29l.21-3.05 5.56-5.02c.24-.21-.05-.33-.37-.12L6.3 13.66l-2.96-.92c-.64-.2-.65-.64.14-.95l11.57-4.46c.53-.19 1 .13.84.92z" />
</svg>
)
}
function MattermostIcon({ className }: { className?: string }) {
return (
<svg viewBox="0 0 24 24" fill="currentColor" className={className} aria-hidden="true">
<path d="M11.977 0C5.358 0 .008 5.35.008 11.97S5.358 23.94 11.978 23.94c6.618 0 11.969-5.35 11.969-11.97S18.596 0 11.977 0zm5.882 17.29c-.114.099-.276.131-.419.082l-5.47-1.764-3.674 2.672a.387.387 0 01-.606-.385l.553-5.784-5.214-2.858a.387.387 0 01.072-.71l14.476-4.365a.385.385 0 01.487.463l-2.878 12.175a.387.387 0 01-.307.474z" />
</svg>
)
}
function GitHubIcon({ className }: { className?: string }) {
return (
<svg viewBox="0 0 24 24" fill="currentColor" className={className} aria-hidden="true">
<path fillRule="evenodd" clipRule="evenodd" d="M12 0C5.37 0 0 5.37 0 12c0 5.31 3.435 9.795 8.205 11.385.6.105.825-.255.825-.57 0-.285-.015-1.23-.015-2.235-3.015.555-3.795-.735-4.035-1.41-.135-.345-.72-1.41-1.23-1.695-.42-.225-1.02-.78-.015-.795.945-.015 1.62.87 1.845 1.23 1.08 1.815 2.805 1.305 3.495.99.105-.78.42-1.305.765-1.605-2.67-.3-5.46-1.335-5.46-5.925 0-1.305.465-2.385 1.23-3.225-.12-.3-.54-1.53.12-3.18 0 0 1.005-.315 3.3 1.23.96-.27 1.98-.405 3-.405s2.04.135 3 .405c2.295-1.56 3.3-1.23 3.3-1.23.66 1.65.24 2.88.12 3.18.765.84 1.23 1.905 1.23 3.225 0 4.605-2.805 5.625-5.475 5.925.435.375.81 1.095.81 2.22 0 1.605-.015 2.895-.015 3.3 0 .315.225.69.825.57A12.02 12.02 0 0024 12c0-6.63-5.37-12-12-12z" />
</svg>
)
}
function XIcon({ className }: { className?: string }) {
return (
<svg viewBox="0 0 24 24" fill="currentColor" className={className} aria-hidden="true">
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-4.714-6.231-5.401 6.231H2.744l7.737-8.835L1.254 2.25H8.08l4.253 5.622 5.911-5.622Zm-1.161 17.52h1.833L7.084 4.126H5.117L17.083 19.77Z" />
</svg>
)
}
function DockerIcon({ className }: { className?: string }) {
return (
<svg viewBox="0 0 24 24" fill="currentColor" className={className} aria-hidden="true">
<path d="M13.983 11.078h2.119a.186.186 0 00.186-.185V9.006a.186.186 0 00-.186-.186h-2.119a.185.185 0 00-.185.185v1.888c0 .102.083.185.185.185m-2.954-5.43h2.118a.186.186 0 00.186-.186V3.574a.186.186 0 00-.186-.185h-2.118a.185.185 0 00-.185.185v1.888c0 .102.082.185.185.185m0 2.716h2.118a.187.187 0 00.186-.186V6.29a.186.186 0 00-.186-.185h-2.118a.185.185 0 00-.185.185v1.887c0 .102.082.185.185.186m-2.93 0h2.12a.186.186 0 00.184-.186V6.29a.185.185 0 00-.185-.185H8.1a.185.185 0 00-.185.185v1.887c0 .102.083.185.185.186m-2.964 0h2.119a.186.186 0 00.185-.186V6.29a.185.185 0 00-.185-.185H5.136a.186.186 0 00-.186.185v1.887c0 .102.084.185.186.186m5.893 2.715h2.118a.186.186 0 00.186-.185V9.006a.186.186 0 00-.186-.186h-2.118a.185.185 0 00-.185.185v1.888c0 .102.082.185.185.185m-2.93 0h2.12a.185.185 0 00.184-.185V9.006a.185.185 0 00-.184-.186h-2.12a.185.185 0 00-.184.185v1.888c0 .102.083.185.185.185m-2.964 0h2.119a.185.185 0 00.185-.185V9.006a.185.185 0 00-.184-.186h-2.12a.186.186 0 00-.186.186v1.887c0 .102.084.185.186.185m-2.92 0h2.12a.186.186 0 00.184-.185V9.006a.185.185 0 00-.184-.186h-2.12a.185.185 0 00-.184.186v1.887c0 .102.082.185.185.185M23.763 9.89c-.065-.051-.672-.51-1.954-.51-.338.001-.676.03-1.01.087-.248-1.7-1.653-2.53-1.716-2.566l-.344-.199-.226.327c-.284.438-.49.922-.612 1.43-.23.97-.09 1.882.403 2.661-.595.332-1.55.413-1.744.42H.751a.751.751 0 00-.75.748 11.376 11.376 0 00.692 4.062c.545 1.428 1.355 2.48 2.41 3.124 1.18.723 3.1 1.137 5.275 1.137.983.003 1.963-.086 2.93-.266a12.248 12.248 0 003.823-1.389c.98-.567 1.86-1.288 2.61-2.136 1.252-1.418 1.998-2.997 2.553-4.4h.221c1.372 0 2.215-.549 2.68-1.009.309-.293.55-.65.707-1.046l.098-.288Z" />
</svg>
)
}
interface VideoMeta {
id: string
meta: string
title: string
ariaLabel: string
start?: number
}
function YoutubeThumbnail({ video }: { video: VideoMeta }) {
const [playing, setPlaying] = useState(false)
if (playing) {
return (
<div className="relative aspect-video w-full rounded-2xl overflow-hidden bg-black">
<iframe
src={`https://www.youtube.com/embed/${video.id}?autoplay=1&rel=0&modestbranding=1${video.start ? `&start=${video.start}` : ''}`}
title={video.ariaLabel}
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
allowFullScreen
className="absolute inset-0 w-full h-full border-0"
/>
</div>
)
}
return (
<button
type="button"
onClick={() => setPlaying(true)}
aria-label={`Play: ${video.ariaLabel}`}
className="group relative w-full aspect-video rounded-2xl overflow-hidden bg-black cursor-pointer"
>
<img
src={`https://img.youtube.com/vi/${video.id}/maxresdefault.jpg`}
alt=""
className="w-full h-full object-cover transition-transform duration-500 group-hover:scale-[1.03]"
/>
<div className="absolute inset-0 bg-gradient-to-t from-black/65 via-transparent to-transparent" aria-hidden="true" />
<div className="absolute inset-0 flex items-center justify-center">
<div
className="w-16 h-16 rounded-full flex items-center justify-center transition-transform duration-300 group-hover:scale-110"
style={{ backgroundColor: 'hsl(var(--primary))', boxShadow: '0 0 0 16px hsl(var(--primary) / 0.15)' }}
>
<Play size={20} fill="white" className="ml-1" aria-hidden="true" />
</div>
</div>
<div className="absolute bottom-0 left-0 right-0 p-5">
<p className="text-white/60 text-xs font-medium">{video.meta}</p>
<p className="text-white font-semibold text-sm mt-0.5 leading-snug">{video.title}</p>
</div>
</button>
)
}
const DOC_VIDEO: VideoMeta = {
id: 'Z78ZbPcsc3g',
meta: 'Documentary · 42 min',
title: 'My Trust in You Is Broken',
ariaLabel: 'My Trust in You Is Broken — BTCPay Server documentary',
}
const DEV_VIDEO: VideoMeta = {
id: 'dW9eSgA_dUg',
meta: 'Dev setup walkthrough',
title: 'BTCPay Server Development Setup',
ariaLabel: 'BTCPay Server development environment setup tutorial',
start: 408,
}
function ToolRow({ href, icon, label, meta }: {
href?: string
icon: React.ReactNode
label: React.ReactNode
meta?: React.ReactNode
}) {
const base = 'flex items-center gap-3 rounded-2xl border border-border/60 bg-card/50 p-3'
const inner = (
<>
<div className="w-8 h-8 rounded-full bg-muted flex items-center justify-center shrink-0">{icon}</div>
<span className="font-semibold text-sm text-foreground flex-1 min-w-0">{label}</span>
{meta ?? (href && <ArrowUpRight size={13} className="text-muted-foreground group-hover:text-foreground transition-colors shrink-0" aria-hidden="true" />)}
</>
)
if (href) {
return (
<a href={href} target="_blank" rel="noopener noreferrer"
className={`${base} group hover:border-border transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring`}
>{inner}</a>
)
}
return <div className={base}>{inner}</div>
}
function InlineLinks({ links }: { links: { href: string; label: string }[] }) {
return (
<div className="flex items-center gap-2 shrink-0">
{links.map((link, i) => (
<span key={link.href} className="flex items-center gap-2">
{i > 0 && <span className="text-border">·</span>}
<a href={link.href} target="_blank" rel="noopener noreferrer" className="text-[11px] font-medium text-primary hover:underline">{link.label}</a>
</span>
))}
</div>
)
}
function DevToolRows() {
return (
<div className="flex flex-col gap-1.5">
<ToolRow
icon={<GitHubIcon className="w-4 h-4 text-foreground" />}
label="Git Client"
meta={<InlineLinks links={[{ href: 'https://desktop.github.com', label: 'GitHub Desktop' }, { href: 'https://www.sourcetreeapp.com', label: 'SourceTree' }]} />}
/>
<ToolRow
href="https://dotnet.microsoft.com/download/dotnet/10.0"
icon={<Terminal size={15} className="text-foreground" />}
label=".NET 10.0 SDK"
/>
<ToolRow
href="https://www.docker.com/get-started/"
icon={<DockerIcon className="w-4 h-4 text-foreground" />}
label="Docker Desktop"
/>
<ToolRow
href="https://www.jetbrains.com/rider/"
icon={<Code2 size={15} className="text-foreground" />}
label={<>JetBrains Rider <span className="text-xs font-normal text-muted-foreground">community edition</span></>}
/>
<ToolRow
icon={<BookOpen size={15} className="text-foreground" />}
label={<span className="text-xs font-semibold uppercase tracking-widest text-muted-foreground">Optional</span>}
meta={<InlineLinks links={[
{ href: 'https://docs.btcpayserver.org/Development/LocalDevelopment/', label: 'Dev Docs' },
{ href: 'https://www.youtube.com/watch?v=GWR_CcMsEV0&list=PLrIppt9ulOJ3fuAhsYkbubN_vI0nhvDUC', label: 'Playlist' },
]} />}
/>
</div>
)
}
const STEPS = [
{
label: 'Documentary',
title: 'Watch the documentary',
description: 'Understand the mission and meet the contributors who built BTCPay Server. A 42-minute film that shows why this project matters.',
},
{
label: 'Dev Setup',
title: 'Deploy a local dev environment',
description: '',
},
{
label: 'Community',
title: 'Join the community',
description: 'Introduce yourself, ask questions, and connect with contributors who have shipped real features. Everyone started exactly where you are.',
},
{
label: 'Find Issue',
title: 'Pick an issue and ship it',
description: 'Filter by your skill and grab a good-first-issue that fits your experience level.',
},
]
function StepVisual({ index }: { index: number }) {
if (index === 0) return <YoutubeThumbnail video={DOC_VIDEO} />
if (index === 1) return <YoutubeThumbnail video={DEV_VIDEO} />
if (index === 2) {
return (
<div className="flex flex-col gap-1.5">
<ToolRow href="https://t.me/btcpayserver" icon={<TelegramIcon className="w-4 h-4 text-foreground" />} label="Telegram" />
<ToolRow href="https://chat.btcpayserver.org" icon={<MattermostIcon className="w-4 h-4 text-foreground" />} label="Mattermost" />
<ToolRow href="https://x.com/BtcpayServer" icon={<XIcon className="w-4 h-4 text-foreground" />} label="Follow on X" />
</div>
)
}
return (
<div className="flex flex-col gap-1.5">
<p className="text-xs font-semibold uppercase tracking-widest text-muted-foreground mb-1">
When submitting a PR
</p>
<ToolRow icon={<MonitorPlay size={15} className="text-foreground" />} label="Include a screen recording in description" />
<ToolRow icon={<FlaskConical size={15} className="text-foreground" />} label="Test locally before requesting review" />
<ToolRow icon={<PenLine size={15} className="text-foreground" />} label="Write a human-readable description" />
</div>
)
}
function StepRow({ step, index }: { step: typeof STEPS[number]; index: number }) {
const { ref, visible } = useScrollReveal()
const flip = index % 2 !== 0
if (index === 1) {
return (
<div
ref={ref}
className={cn(
'py-14 sm:py-20 space-y-6 transition-all duration-700 ease-out',
visible ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-10',
)}
>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8 lg:gap-12">
<div className="hidden lg:block" />
<div className="space-y-1">
<span
className="font-display font-bold leading-none select-none block text-foreground/[0.055]"
style={{ fontSize: 'clamp(5rem, 12vw, 9rem)' }}
aria-hidden="true"
>
02
</span>
<h3 className="font-display font-bold text-2xl sm:text-3xl text-foreground leading-tight -mt-3 sm:-mt-5">
{step.title}
</h3>
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8 lg:gap-12 items-center">
<YoutubeThumbnail video={DEV_VIDEO} />
<DevToolRows />
</div>
</div>
)
}
return (
<div
ref={ref}
className={cn(
'grid grid-cols-1 lg:grid-cols-2 gap-10 lg:gap-20 py-14 sm:py-20 items-start transition-all duration-700 ease-out',
visible ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-10',
)}
>
<div className={cn('space-y-1', flip && 'lg:order-2')}>
<span
className="font-display font-bold leading-none select-none block text-foreground/[0.055]"
style={{ fontSize: 'clamp(5rem, 12vw, 9rem)' }}
aria-hidden="true"
>
{String(index + 1).padStart(2, '0')}
</span>
<div className="-mt-3 sm:-mt-5 space-y-3">
<h3 className="font-display font-bold text-2xl sm:text-3xl text-foreground leading-tight">
{step.title}
</h3>
{step.description && (
<p className="text-muted-foreground leading-relaxed max-w-sm">{step.description}</p>
)}
{index === 3 && (
<div className="pt-1">
<a
href="#issues"
className="inline-flex items-center gap-2 rounded-full px-8 h-12 text-sm font-semibold text-primary-foreground bg-primary shadow-lg shadow-primary/20 hover:shadow-primary/40 hover:bg-primary/90 transition-all duration-300 hover:scale-105 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
>
Pick an issue
</a>
</div>
)}
</div>
</div>
<div className={cn(flip && 'lg:order-1')}>
<StepVisual index={index} />
</div>
</div>
)
}
export default function ResourcesSection() {
return (
<section id="how-it-works" aria-label="Getting started" className="border-t border-border/60">
<div className="text-center pt-20 sm:pt-28 pb-6">
<p className="text-xs font-semibold uppercase tracking-widest text-muted-foreground mb-4">
How it works
</p>
<h2 className="font-display font-bold text-3xl sm:text-4xl lg:text-5xl text-foreground">
Your path to first contribution
</h2>
<p className="mt-4 text-muted-foreground max-w-sm mx-auto text-sm sm:text-base">
Four steps from curious to your first merged PR.
</p>
</div>
<div className="divide-y divide-border/60">
{STEPS.map((step, i) => (
<StepRow key={step.label} step={step} index={i} />
))}
</div>
</section>
)
}

View File

@ -0,0 +1,155 @@
export default function SupporterSprite() {
return (
<svg width="0" height="0" xmlns="http://www.w3.org/2000/svg" className="absolute">
<defs>
<linearGradient
id="spiral-gradient"
x1="81.36"
y1="311.35"
x2="541.35"
y2="311.35"
gradientUnits="userSpaceOnUse"
>
<stop offset=".18" stopColor="#00f" />
<stop offset="1" stopColor="#f0f" />
</linearGradient>
</defs>
<symbol id="supporter-spiral" viewBox="0 0 629 629">
<path
d="M326.4 572.09C201.2 572.09 141 503 112.48 445c-28.22-57.53-30.59-114.56-30.79-122.69-4.85-77 41-231.78 249.58-271.2a28.05 28.05 0 0 1 10.41 55.13c-213.12 40.28-204.44 206-204 213 0 .53.06 1.06.07 1.6.15 7.9 5.1 195.16 188.65 195.16 68.34 0 116.6-29.4 143.6-87.37 24.48-52.74 19.29-112.45-13.52-155.83-22.89-30.27-52.46-45-90.38-45-34.46 0-63.47 9.88-86.21 29.37A91.5 91.5 0 0 0 248 322.3c-1.41 25.4 7.14 49.36 24.07 67.49C287.27 406 305 413.9 326.4 413.9c27.46 0 45.52-9 53.66-26.81 8.38-18.3 3.61-38.93-.19-43.33-9.11-10-18.69-13.68-22.48-13-2.53.43-5.78 4.61-8.48 10.92a28 28 0 0 1-51.58-22c14.28-33.44 37.94-42 50.76-44.2 24.78-4.18 52.17 7.3 73.34 30.65s25.51 68.55 10.15 103.22C421.54 432 394.52 470 326.4 470c-36.72 0-69.67-14.49-95.29-41.92-27.47-29.4-41.34-68.08-39.11-108.89a149.1 149.1 0 0 1 51.31-104.6c33.19-28.45 74.48-42.87 122.71-42.87 55.12 0 101.85 23.25 135.12 67.23 45.36 60 52.9 141.71 19.66 213.3-25.35 54.67-79.68 119.84-194.4 119.84Z"
fill="url(#spiral-gradient)"
/>
</symbol>
<symbol id="supporter-opensats" viewBox="0 0 5220 720">
<path
d="M0 435.197L229.609 291.597V288.121L0 144.259V29.0508L334.901 245.894V333.824L0 550.798V435.197Z"
fill="#FF3300"
/>
<path
d="M486.969 623.844H902.627V719.643H486.969V623.844Z"
fill="#FF3300"
/>
<path
d="M993.879 291.2C993.879 106.422 1084.61 0 1214.37 0C1344.13 0 1434.86 106.422 1434.86 291.2C1434.86 479.061 1344.13 587.581 1214.37 587.581C1084.61 587.581 993.879 479.061 993.879 291.2ZM1345.12 291.2C1345.12 155.01 1293.16 75.9967 1214.37 75.9967C1135.58 75.9967 1083.62 155.01 1083.62 291.2C1083.62 430.473 1135.58 511.584 1214.37 511.584C1293.16 511.584 1344.85 430.473 1344.85 291.2H1345.12Z"
fill="#FF3300"
/>
<path
d="M1593.29 154.29H1663.81L1670.37 205.37H1673.13C1711.31 169.634 1764.71 144.258 1814.44 144.258C1925.96 144.258 1988.02 228.713 1988.02 359.855C1988.02 504.111 1897.95 587.911 1797.77 587.911C1759.13 587.911 1713.54 568.829 1677.39 535.454H1675.29L1679.43 612.237V749.936H1593.29V154.29ZM1899.65 359.855C1899.65 271.269 1867.44 215.599 1791.21 215.599C1756.57 215.599 1717.93 232.713 1679.69 272.121V472.112C1714.79 503.914 1754.61 515.455 1781.57 515.455C1848.75 515.717 1899.65 459.851 1899.65 359.855Z"
fill="#FF3300"
/>
<path
d="M2118.96 365.035C2118.96 227.336 2222.75 143.93 2335.98 143.93C2460.16 143.93 2530.82 225.434 2530.82 343.527C2530.67 359.209 2529.35 374.858 2526.88 390.345H2178.73V327.2H2473.22L2454.52 348.249C2454.52 256.449 2410.17 210.55 2338.47 210.55C2264.41 210.55 2203.66 265.17 2203.66 364.904C2203.66 468.833 2268.8 520.044 2359.79 520.044C2407.09 520.044 2445.08 505.75 2483.39 482.8L2513.56 537.29C2464.7 569.886 2407.32 587.378 2348.57 587.582C2220.39 587.582 2118.96 505.947 2118.96 365.035Z"
fill="#FF3300"
/>
<path
d="M2658.41 154.29H2729.07L2735.63 221.697H2739.04C2781.55 178.289 2829.83 144.258 2895.17 144.258C2994.1 144.258 3039.17 205.042 3039.17 315.201V577.026H2952.9V326.152C2952.9 252.319 2928.11 218.222 2865.39 218.222C2819.47 218.222 2788.31 240.844 2744.68 285.563V577.026H2658.41V154.29Z"
fill="#FF3300"
/>
<path
d="M3208.36 504.308L3259.46 444.376C3303 486.486 3360.93 510.468 3421.5 511.455C3493.27 511.455 3533.03 478.669 3533.03 432.77C3533.03 377.362 3491.63 361.953 3435.41 338.217L3355.57 303.333C3297.64 280.514 3234.21 238.614 3234.21 155.143C3234.21 66.8186 3313.65 0.001814 3425.64 0.001814C3492.01 -0.442962 3555.93 25.0654 3603.75 71.0807L3558.87 126.554C3521.22 93.051 3472.3 74.9951 3421.9 75.9985C3362.2 75.9985 3322.11 103.604 3322.11 150.028C3322.11 199.206 3371.05 217.173 3420.98 236.516L3497.93 270.416C3569.04 298.087 3622.18 339.528 3622.18 422.344C3622.18 513.356 3545.36 587.583 3416.78 587.583C3339.11 587.998 3264.34 558.123 3208.36 504.308V504.308Z"
fill="currentColor"
/>
<path
d="M3762.96 465.557C3762.96 370.741 3849.04 324.055 4062.7 308.186C4059.62 255.73 4031.54 213.896 3956.3 213.896C3905.13 213.896 3854.68 237.304 3813.28 261.5L3780.48 203.994C3827.98 174.684 3898.57 144.062 3971.78 144.062C4088.42 144.062 4148.91 210.945 4148.91 322.678V577.027H4077.73L4070.84 522.144H4068.08C4022.82 557.553 3963.97 587.715 3906.04 587.715C3825.16 587.584 3762.96 540.045 3762.96 465.557ZM4062.7 462.278V363.266C3896.79 375.134 3847.26 408.576 3847.26 459C3847.26 501.489 3885.38 519.063 3930.65 519.063C3975.91 519.063 4018.88 497.883 4062.7 462.278Z"
fill="currentColor"
/>
<path
d="M4388.81 409.884V222.941H4272.17V158.813L4392.16 154.289L4403.44 20.2617H4475.02V154.289H4674.64V222.941H4475.02V410.146C4475.02 482.864 4500.73 518.076 4577.29 518.076C4610.27 517.981 4642.96 511.847 4673.73 499.979L4691.24 562.992C4648.39 578.951 4603.07 587.274 4557.35 587.581C4430.86 587.581 4388.81 516.043 4388.81 409.884Z"
fill="currentColor"
/>
<path
d="M4818.71 521.815L4857.09 466.014C4909.9 502.948 4972.98 522.353 5037.43 521.487C5102.31 521.487 5133.34 495.259 5133.34 463.85C5133.34 433.95 5115.76 415 5009.22 393.034C4897.7 369.887 4845.21 331.2 4845.21 267.203C4845.21 196.518 4908.85 143.93 5025.95 143.93C5093.26 143.93 5157.55 170.158 5199.93 198.878L5159.45 252.646C5117.79 224.668 5068.79 209.592 5018.6 209.304C4955.62 209.304 4931.28 234.155 4931.28 263.138C4931.28 295.923 4965.07 309.037 5048.12 326.938C5185.89 357.101 5220.33 392.509 5220.33 458.736C5220.33 529.487 5151.71 587.582 5026.8 587.582C4952.47 586.641 4880.07 563.76 4818.71 521.815V521.815Z"
fill="currentColor"
/>
</symbol>
<symbol id="supporter-tether" viewBox="0 0 111 90">
<path
fillRule="evenodd"
clipRule="evenodd"
d="M24.4825 0.862305H88.0496C89.5663 0.862305 90.9675 1.64827 91.7239 2.92338L110.244 34.1419C111.204 35.7609 110.919 37.8043 109.549 39.1171L58.5729 87.9703C56.9216 89.5528 54.2652 89.5528 52.6139 87.9703L1.70699 39.1831C0.305262 37.8398 0.0427812 35.7367 1.07354 34.1077L20.8696 2.82322C21.6406 1.60483 23.0087 0.862305 24.4825 0.862305ZM79.8419 14.8003V23.5597H61.7343V29.6329C74.4518 30.2819 83.9934 32.9475 84.0642 36.1425L84.0638 42.803C83.993 45.998 74.4518 48.6635 61.7343 49.3125V64.2168H49.7105V49.3125C36.9929 48.6635 27.4513 45.998 27.3805 42.803L27.381 36.1425C27.4517 32.9475 36.9929 30.2819 49.7105 29.6329V23.5597H31.6028V14.8003H79.8419ZM55.7224 44.7367C69.2943 44.7367 80.6382 42.4827 83.4143 39.4727C81.0601 36.9202 72.5448 34.9114 61.7343 34.3597V40.7183C59.7966 40.8172 57.7852 40.8693 55.7224 40.8693C53.6595 40.8693 51.6481 40.8172 49.7105 40.7183V34.3597C38.8999 34.9114 30.3846 36.9202 28.0304 39.4727C30.8066 42.4827 42.1504 44.7367 55.7224 44.7367Z"
fill="#009393"
/>
</symbol>
<symbol id="supporter-hrf" viewBox="0 0 3000 987.6">
<path
d="M1137.09 103.9v773.45h-51.44V515.96h-953.6v361.38H80.62V103.9h51.44v361.2h953.6V103.9h51.43zm-102.77 0h-51.44v258.19H234.94V103.9H183.5v309.05h850.82V103.9zm-696.29 0h-50.87v205.84h50.87V103.9zm593.05 0h-51.44v205.84h51.44V103.9zM183.5 877.34h51.44V619.16h747.94v258.19h51.44V567.72H183.5v309.62zm695.72 0h51.44V670.93h-51.44v206.41zm-592.47 0h51.44V670.93h-51.44v206.41z"
fill="#e12991"
/>
<path
d="M1422.94 103.88V331.3h-44.51v-94.22h-92.2v94.22h-44.83V103.88h44.83v90.32h92.2v-90.32h44.51zM1605.81 168.85V331.3h-41.91v-18.19c-9.75 14.62-26.64 22.74-48.41 22.74-34.44 0-61.4-24.04-61.4-67.25v-99.74H1496v94.54c0 22.1 13.32 33.47 32.16 33.47 20.47 0 35.74-12.02 35.74-40.29v-87.72h41.91zM1885.19 231.23V331.3h-41.91v-97.14c0-19.17-9.75-30.86-27.29-30.86-18.52 0-30.22 12.35-30.22 36.71v91.29h-41.91v-97.14c0-19.17-9.75-30.86-27.29-30.86-17.87 0-30.54 12.35-30.54 36.71v91.29h-41.91V168.85h41.91v17.22c9.1-13.64 24.37-21.77 45.16-21.77 20.14 0 35.09 8.45 44.18 23.39 10.07-14.62 26.32-23.39 48.41-23.39 37.04.01 61.41 26.32 61.41 66.93zM2086.24 168.85V331.3h-41.91v-19.17c-11.7 14.62-29.24 23.72-52.96 23.72-43.21 0-78.95-37.36-78.95-85.77s35.74-85.77 78.95-85.77c23.72 0 41.26 9.1 52.96 23.72v-19.17h41.91zm-41.91 81.23c0-27.29-19.17-45.81-45.16-45.81-25.66 0-44.83 18.52-44.83 45.81 0 27.29 19.17 45.81 44.83 45.81 25.99 0 45.16-18.52 45.16-45.81zM2275.93 231.56v99.74h-41.91v-94.54c0-22.09-13.32-33.46-32.16-33.46-20.47 0-35.74 12.02-35.74 40.29v87.72h-41.91V168.85h41.91v18.19c9.75-14.62 26.64-22.74 48.41-22.74 34.44.01 61.4 24.05 61.4 67.26zM1316.47 525.36h-30.25v78.95h-44.83V376.89h94.84c41.91 0 75.7 33.79 75.7 75.37 0 28.59-17.87 54.26-43.86 66.28l50.36 85.77h-48.41l-53.55-78.95zm-30.25-39.31h50c16.89 0 30.86-14.95 30.86-33.79s-13.97-33.46-30.86-33.46h-50v67.25zM1437.88 396.71c0-13.97 15.57-25.99 29.54-25.99 14.29 0 22.12 12.02 22.12 25.99s-11.7 25.67-25.99 25.67c-13.97 0-25.67-11.7-25.67-25.67zm4.88 45.16h41.91v162.45h-41.91V441.87zM1681.86 441.87v154.65c0 53.28-41.91 77.33-84.8 77.33-34.77 0-62.7-13.32-77-39.64l35.74-20.47c6.82 12.67 17.54 22.74 42.56 22.74 26.31 0 42.56-14.29 42.56-39.96v-17.54c-11.37 15.27-28.92 24.69-51.98 24.69-46.14 0-80.9-37.36-80.9-83.17 0-45.48 34.76-83.17 80.9-83.17 23.07 0 40.61 9.42 51.98 24.69v-20.14h40.94zm-40.94 78.62c0-25.67-19.17-44.18-45.49-44.18-26.31 0-45.48 18.52-45.48 44.18 0 25.99 19.17 44.51 45.48 44.51 26.32 0 45.49-18.52 45.49-44.51zM1871.55 504.57v99.74h-41.91v-94.54c0-22.09-13.32-33.46-32.16-33.46-20.47 0-35.74 12.02-35.74 40.29v87.72h-41.91V376.89h41.91v83.17c9.75-14.62 26.64-22.74 48.41-22.74 34.44 0 61.4 24.04 61.4 67.25zM1963.46 482.15v67.58c0 17.54 12.67 17.87 36.71 16.57v38.01c-58.81 6.5-78.62-10.72-78.62-54.58v-67.58h-28.27v-40.29h28.27v-32.81l41.91-12.67v45.48h36.71v40.29h-36.71zM2148.63 556.88c0 35.09-30.54 51.98-65.31 51.98-32.49 0-56.53-13.64-68.22-38.66l36.39-20.47c4.55 13.32 15.6 21.12 31.84 21.12 13.32 0 22.42-4.55 22.42-13.97 0-23.72-83.82-10.72-83.82-67.9 0-33.14 28.27-51.66 61.73-51.66 26.32 0 49.06 12.02 61.73 34.44l-35.74 19.49c-4.88-10.4-13.97-16.57-25.99-16.57-10.4 0-18.84 4.55-18.84 13.32-.01 24.04 83.81 9.1 83.81 68.88zM1286.22 692.79v53.93h96.11v42.89h-96.11v87.72h-44.83V649.9h143.54v42.88h-98.71zM1400.82 796.1c0-48.41 38.01-85.77 85.77-85.77s86.1 37.36 86.1 85.77-38.34 85.77-86.1 85.77c-47.76 0-85.77-37.36-85.77-85.77zm129.96 0c0-26.31-19.17-44.83-44.19-44.83-24.69 0-43.86 18.52-43.86 44.83 0 26.32 19.17 44.83 43.86 44.83 25.02.01 44.19-18.51 44.19-44.83zM1744.84 714.88v162.45h-41.91v-18.19c-9.75 14.62-26.64 22.74-48.41 22.74-34.44 0-61.4-24.04-61.4-67.25v-99.74h41.91v94.54c0 22.1 13.32 33.47 32.16 33.47 20.47 0 35.74-12.02 35.74-40.29v-87.72h41.91zM1934.86 777.58v99.74h-41.91v-94.54c0-22.09-13.32-33.46-32.16-33.46-20.47 0-35.74 12.02-35.74 40.29v87.72h-41.91V714.88h41.91v18.19c9.75-14.62 26.64-22.74 48.41-22.74 34.44 0 61.4 24.04 61.4 67.25zM2135.61 649.9v227.42h-41.91v-19.17c-11.7 14.95-28.92 23.72-52.63 23.72-43.54 0-79.27-37.36-79.27-85.77s35.74-85.77 79.27-85.77c23.72 0 40.94 8.77 52.63 23.72V649.9h41.91zm-41.91 146.2c0-27.29-19.17-45.81-44.84-45.81-25.99 0-45.16 18.52-45.16 45.81 0 27.29 19.17 45.81 45.16 45.81 25.67.01 44.84-18.51 44.84-45.81zM2337.35 714.88v162.45h-41.91v-19.17c-11.7 14.62-29.24 23.72-52.96 23.72-43.21 0-78.95-37.36-78.95-85.77s35.74-85.77 78.95-85.77c23.72 0 41.26 9.1 52.96 23.72v-19.17h41.91zm-41.91 81.22c0-27.29-19.17-45.81-45.16-45.81-25.66 0-44.83 18.52-44.83 45.81 0 27.29 19.17 45.81 44.83 45.81 25.99.01 45.16-18.51 45.16-45.81zM2433.46 755.17v67.58c0 17.54 12.67 17.87 36.71 16.57v38.01c-58.81 6.5-78.62-10.72-78.62-54.58v-67.58h-28.26v-40.29h28.26v-32.81l41.91-12.67v45.48h36.71v40.29h-36.71zM2494.84 669.72c0-13.97 11.7-25.99 25.67-25.99 14.29 0 25.99 12.02 25.99 25.99s-11.7 25.66-25.99 25.66c-13.97.01-25.67-11.69-25.67-25.66zm4.87 45.16h41.91v162.45h-41.91V714.88zM2565 796.1c0-48.41 38.01-85.77 85.77-85.77s86.1 37.36 86.1 85.77-38.34 85.77-86.1 85.77c-47.76 0-85.77-37.36-85.77-85.77zm129.96 0c0-26.31-19.17-44.83-44.19-44.83-24.69 0-43.86 18.52-43.86 44.83 0 26.32 19.17 44.83 43.86 44.83 25.02.01 44.19-18.51 44.19-44.83zM2911.62 777.58v99.74h-41.91v-94.54c0-22.09-13.32-33.46-32.16-33.46-20.47 0-35.74 12.02-35.74 40.29v87.72h-41.91V714.88h41.91v18.19c9.75-14.62 26.64-22.74 48.41-22.74 34.44 0 61.4 24.04 61.4 67.25z"
fill="currentColor"
/>
</symbol>
<symbol id="supporter-lunanode" viewBox="0 0 194.219 193.977">
<path
d="M3185.89 2995.8c-1.77 21.49-2.76 43.2-2.76 65.16 0 411.03 319.09 747.36 723.13 774.95l-618.54-641.7c-54.62-56.68-88.55-126.08-101.83-198.41M3960 2284.09c-270.37 0-508.4 138.15-647.57 347.65l23.25-22.42c76.82-74.06 176.93-109.95 276.2-108.13 99 1.77 197.53 41.2 271.5 117.59l-177.95 171.52c-26.66-27.31-62.22-41.38-98.02-42.14-36.12-.65-72.43 12.41-100.16 39.15l-37.98 36.6c-27.69 26.66-42.04 62.45-42.7 98.57-.65 36.07 12.36 72.48 39.11 100.21l745.68 773.56c305.71-104.45 525.52-394.17 525.52-735.29 0-29.89-1.73-59.34-5.04-88.32-19.44 54.57-51.41 105.56-95.79 148.35l-37.93 36.58c-76.86 74.07-176.93 110.05-276.16 108.18-99.32-1.77-198.13-41.38-272.19-118.25l-290.74-301.59 177.95-171.53 290.74 301.61c26.71 27.73 62.64 42.04 98.72 42.74 36.12.69 72.38-12.35 100.16-39.1l37.89-36.59c27.69-26.66 42.09-62.45 42.74-98.58.61-36.03-12.4-72.48-39.1-100.21l-440.73-457.23c-22.23-1.9-44.69-2.93-67.4-2.93"
transform="matrix(.125 0 0 -.125 -397.891 479.489)"
fill="#004581"
/>
<path
d="M4376.22 2292.8h360.66v433.41c-17.35-55.88-47.59-108.64-90.81-153.48l-269.85-279.93"
transform="matrix(.125 0 0 -.125 -397.891 479.489)"
fill="#3384b9"
/>
</symbol>
<symbol id="supporter-walletofsatoshi" viewBox="0 0 313.1 76.32">
<path
d="M110.47 44.8H121c.84 0 1.22-.64.9-1.48l-17.6-42A2 2 0 0 0 102.22 0H87.63a2 2 0 0 0-2 1.34L66 48.11c-.32.84.06 1.48.83 1.48h13.7a1.42 1.42 0 0 1 1.32 1.93l-9.7 24.8 30.55-32.63A1 1 0 0 0 102 42H84.73a1.42 1.42 0 0 1-1.32-2l5.06-12.91 6.86-17.47 6.78 17.51h-7.54a1.42 1.42 0 0 0-1.32.9l-2.83 7.22a1.42 1.42 0 0 0 1.32 1.93H105a1.42 1.42 0 0 1 1.33.91l2.08 5.36a1.92 1.92 0 0 0 2.06 1.35Zm62.65 0h37.42a1.3 1.3 0 0 0 1.46-1.41V35.9a1.3 1.3 0 0 0-1.47-1.41h-26V1.41A1.35 1.35 0 0 0 183 0h-9.92a1.3 1.3 0 0 0-1.47 1.41v42a1.3 1.3 0 0 0 1.51 1.39Zm45.36 0h42a1.3 1.3 0 0 0 1.52-1.41V35.9a1.31 1.31 0 0 0-1.47-1.41h-30.59v-7.36h25.59a1.33 1.33 0 0 0 1.48-1.4v-7a1.33 1.33 0 0 0-1.48-1.41h-25.59v-7h30.59A1.3 1.3 0 0 0 262 8.89V1.41A1.3 1.3 0 0 0 260.53 0h-42A1.3 1.3 0 0 0 217 1.41v42a1.3 1.3 0 0 0 1.48 1.39ZM71.79 0H61.61a1.71 1.71 0 0 0-1.85 1.41L52.08 34.3 44.91 1.41A1.65 1.65 0 0 0 43.12 0H30.38a1.71 1.71 0 0 0-1.85 1.41L21.36 34.3 13.68 1.41A1.65 1.65 0 0 0 11.89 0H1.14C.24 0-.14.51.05 1.41l10.88 42a1.68 1.68 0 0 0 1.79 1.41H28.4a1.65 1.65 0 0 0 1.79-1.41l6.27-28.31 6.34 28.29a1.65 1.65 0 0 0 1.79 1.41H60.2a1.66 1.66 0 0 0 1.8-1.41l10.87-42C73.07.51 72.68 0 71.79 0Zm239.84 0h-43.52a1.3 1.3 0 0 0-1.47 1.41v7.48a1.3 1.3 0 0 0 1.47 1.41h15.29v33.09a1.3 1.3 0 0 0 1.48 1.41h10a1.33 1.33 0 0 0 1.47-1.41V10.3h15.3a1.3 1.3 0 0 0 1.47-1.41V1.41A1.3 1.3 0 0 0 311.63 0ZM127.76 44.8h37.42a1.3 1.3 0 0 0 1.47-1.41V35.9a1.3 1.3 0 0 0-1.47-1.41h-26V1.41a1.35 1.35 0 0 0-1.5-1.41h-9.92a1.3 1.3 0 0 0-1.47 1.41v42a1.3 1.3 0 0 0 1.47 1.39Zm-3.84 9.6h-11.53c-3.13 0-4.53 1.31-4.53 4.36v10.37c0 3.05 1.4 4.36 4.53 4.36h11.53c3.16 0 4.51-1.31 4.51-4.36V58.76c0-3.05-1.35-4.36-4.51-4.36Zm-1 12.95c0 1.48-.29 1.75-2.07 1.75h-5.51c-1.76 0-2.08-.27-2.08-1.75v-6.81c0-1.47.32-1.75 2.08-1.75h5.51c1.78 0 2.07.28 2.07 1.75Zm51.87-5.59h-8.75c-.89 0-1.16-.27-1.16-.95v-1.06c0-.68.27-1 1.16-1h6.7c.65 0 .89.28.89.85v.16a.55.55 0 0 0 .62.6h4a.55.55 0 0 0 .62-.6v-1.08c0-3.21-1.11-4.28-4.4-4.28H164c-3.19 0-4.51 1.31-4.51 4.36v2.84c0 3.06 1.32 4.36 4.51 4.36h8.74c.9 0 1.17.28 1.17 1v1.23c0 .68-.27.95-1.17.95h-7.34c-.62 0-.86-.27-.86-.85v-.16a.56.56 0 0 0-.62-.6h-4a.55.55 0 0 0-.62.6v1.12c0 3.22 1.08 4.28 4.4 4.28h11.2c3.19 0 4.51-1.31 4.51-4.36v-3c-.06-3.1-1.41-4.41-4.57-4.41Zm85.43 0h-8.75c-.89 0-1.16-.27-1.16-.95v-1.06c0-.68.27-1 1.16-1h6.7c.64 0 .89.28.89.85v.16a.55.55 0 0 0 .62.6h4a.55.55 0 0 0 .62-.6v-1.08c0-3.21-1.11-4.28-4.4-4.28h-10.48c-3.19 0-4.51 1.31-4.51 4.36v2.84c0 3.06 1.32 4.36 4.51 4.36h8.74c.89 0 1.16.28 1.16 1v1.23c0 .68-.27.95-1.16.95h-7.34c-.62 0-.86-.27-.86-.85v-.16a.57.57 0 0 0-.62-.6h-4.05a.55.55 0 0 0-.62.6v1.12c0 3.22 1.08 4.28 4.4 4.28h11.2c3.18 0 4.51-1.31 4.51-4.36v-3c0-3.1-1.33-4.41-4.51-4.41Zm26.65-7.36h-4.21a.56.56 0 0 0-.63.6v6.66h-9.2V55a.57.57 0 0 0-.65-.6H268a.55.55 0 0 0-.62.6v17.89a.55.55 0 0 0 .62.6h4.18a.57.57 0 0 0 .65-.6v-6.84h9.2v6.84a.56.56 0 0 0 .63.6h4.21a.55.55 0 0 0 .62-.6V55a.55.55 0 0 0-.57-.6Zm-137.62 0h-17.07a.55.55 0 0 0-.62.6v17.89a.55.55 0 0 0 .62.6h4.19a.58.58 0 0 0 .65-.6v-6.52h10.15a.57.57 0 0 0 .64-.6v-3.19a.57.57 0 0 0-.64-.6H137v-3.19h12.3a.55.55 0 0 0 .62-.6V55a.55.55 0 0 0-.62-.6Zm146.47 0h-4.18a.55.55 0 0 0-.62.6v17.89a.55.55 0 0 0 .62.6h4.18a.57.57 0 0 0 .65-.6V55a.57.57 0 0 0-.6-.6Zm-100.28.6a.83.83 0 0 0-.86-.57h-6.16a.83.83 0 0 0-.89.57l-7.42 17.89c-.14.36 0 .63.38.63h4.45a.8.8 0 0 0 .86-.57l1-2.68h9.1l1 2.68a.8.8 0 0 0 .87.57h4.69c.33 0 .49-.27.35-.63Zm-7 11 2.89-7.52 2.92 7.52Zm30.9-11.6H201a.55.55 0 0 0-.62.6v3.19a.55.55 0 0 0 .62.6h6.45v14.1a.55.55 0 0 0 .62.6h4.21a.56.56 0 0 0 .62-.6v-14.1h6.46a.55.55 0 0 0 .62-.6V55a.55.55 0 0 0-.64-.6Zm18.46 0h-11.52c-3.13 0-4.54 1.31-4.54 4.36v10.37c0 3.05 1.41 4.36 4.54 4.36h11.52c3.16 0 4.51-1.31 4.51-4.36V58.76c0-3.05-1.31-4.36-4.51-4.36Zm-.94 12.95c0 1.48-.3 1.75-2.08 1.75h-5.51c-1.75 0-2.07-.27-2.07-1.75v-6.81c0-1.47.32-1.75 2.07-1.75h5.51c1.78 0 2.08.28 2.08 1.75Z"
fill="#fad228"
stroke="#1e2127"
strokeWidth="2"
/>
</symbol>
<symbol id="supporter-coincards" viewBox="0 0 64 32">
<g fill="none">
<path
d="M32.7 5.9c-.2-1-1.3-1.7-2.3-1.4L7.7 9.9c-1 .2-1.7 1.3-1.4 2.3l3.1 12.9c.2 1 1.3 1.7 2.3 1.4l22.7-5.4c1-.2 1.7-1.3 1.4-2.3L32.7 5.9Z"
fill="#EF8022"
/>
<path
d="M12.6 30.3c-.2.2-.5.3-.7.3l.8.5c.9.6 2.1.4 2.7-.5l3.1-4.4-5.9 4.1ZM2.3 19.5l-1 1.4c-.6.9-.4 2.1.5 2.7L8.1 28l-5.8-8.5ZM12.9 8.1l7.2-5-2.7-1.9c-.9-.6-2.1-.4-2.7.5l-5 7.2 3.2-.8Z"
fill="#F9F185"
/>
<path
d="M9.7 29.4c.6.9 1.8 1.1 2.7.5l6.7-4.6-7.4 1.8c-1.3.3-2.6-.5-2.9-1.8L6 13.5l-3.3 2.3c-.9.6-1.1 1.8-.5 2.7l7.5 10.9Zm4.5-21.6L25.9 5l-1.3-2c-.6-.9-1.8-1.1-2.7-.5l-7.7 5.3Z"
fill="#FFC214"
/>
<path
d="M11.9 24.8c-.7 0-1.4-.5-1.7-1.1l-1.5-3.2 1.1 4.6c.2.6.7 1.1 1.4 1.1h.3l17-4.1-16.2 2.7h-.4Z"
fill="#FFC214"
/>
<path
d="M16 17.5s-1.1 1.2-2.5 1.2c-1.7 0-2.6-1.4-2.6-2.8 0-1.3.9-2.7 2.6-2.7 1.3 0 2.3 1 2.3 1l1.1-1.7s-.6-.7-1.9-1.1v-1.2h-1.1v1h-.6v-1h-1.1v1.1c-2.2.5-3.7 2.4-3.7 4.7 0 2.4 1.5 4.2 3.7 4.7v1.2h1.1v-1h.6V22H15v-1.3c1.4-.4 2.1-1.3 2.1-1.3L16 17.5ZM21 13.7c2.1 0 3.8 1.4 3.8 3.6 0 2.1-1.7 3.5-3.8 3.5-2.1 0-3.8-1.4-3.8-3.5s1.7-3.6 3.8-3.6Zm0 5.2c.8 0 1.5-.6 1.5-1.6s-.7-1.7-1.5-1.7-1.5.6-1.5 1.7c0 1 .7 1.6 1.5 1.6Zm4.3-5h2.3v6.7h-2.3v-6.7Zm0-2.6h2.2v1.8h-2.2v-1.8Zm3.2 2.6h2.2v1c.3-.5 1-1.2 2.1-1.2 1.4 0 2.4.6 2.4 2.5v4.4h-2.3v-4c0-.6-.2-.9-.7-.9-.7 0-1.1.4-1.3 1-.1.3-.1.6-.1.9v3h-2.3v-6.7Z"
fill="#FFF"
/>
<path
d="M39.3 13.9c1.7 0 2.5 1 2.5 1l-.6.9s-.7-.8-1.8-.8c-1.3 0-2.3 1-2.3 2.4 0 1.3 1 2.4 2.3 2.4 1.2 0 2-.9 2-.9l.5.9s-.9 1.1-2.6 1.1c-2.1 0-3.5-1.5-3.5-3.5-.1-2 1.4-3.5 3.5-3.5Zm6.8 2.6h.3v-.1c0-1.1-.6-1.5-1.5-1.5-1 0-1.8.6-1.8.6l-.5-.9s1-.8 2.5-.8c1.7 0 2.6.9 2.6 2.6v4.2h-1.2v-1.1s-.5 1.3-2.1 1.3c-1.1 0-2.3-.7-2.3-2 0-2.2 2.9-2.3 4-2.3Zm-1.4 3.3c1.1 0 1.8-1.1 1.8-2.1v-.2h-.3c-1 0-2.7.1-2.7 1.3-.1.5.3 1 1.2 1Zm3.8-5.8h1.2v1.7c.3-1 1.1-1.7 2.1-1.7h.3v1.3h-.4c-.8 0-1.6.6-1.9 1.6-.1.4-.2.8-.2 1.2v2.7h-1.3V14h.2Zm6.8-.1c1.5 0 2 1 2 1v-3.5h1.3v9.2h-1.2v-1s-.5 1.2-2.2 1.2c-1.8 0-2.9-1.4-2.9-3.5s1.3-3.4 3-3.4Zm.2 5.8c1 0 1.9-.7 1.9-2.4 0-1.2-.6-2.4-1.9-2.4-1 0-1.9.9-1.9 2.4s.8 2.4 1.9 2.4Zm4.2-.8s.7.8 1.9.8c.5 0 1.1-.3 1.1-.8 0-1.2-3.4-1-3.4-3.1 0-1.2 1.1-1.9 2.4-1.9 1.5 0 2.1.7 2.1.7l-.5 1s-.6-.6-1.6-.6c-.5 0-1.1.2-1.1.8 0 1.2 3.4.9 3.4 3.1 0 1.1-.9 1.9-2.4 1.9-1.6 0-2.5-1-2.5-1l.6-.9Z"
fill="#EF8022"
/>
</g>
</symbol>
<symbol id="supporter-ivpn" viewBox="0 0 84 29">
<path
fillRule="evenodd"
clipRule="evenodd"
d="M6.75 0h6.03c.07 0 .15.03.2.1.04.05.06.13.05.2L8.6 27.43a.26.26 0 0 1-.24.22l-6.63.38H1.7a.25.25 0 0 1-.19-.08.26.26 0 0 1-.06-.22L5.2 5.05C5.12 1.67 1 .85.49.73-.06.59 0 0 0 0h6.75Zm32.32.12a.25.25 0 0 0-.22-.12h-6.5c-.1 0-.18.05-.23.14l-8.98 17.4L20.08.2a.25.25 0 0 0-.24-.21h-6.55a.25.25 0 0 0-.2.1.26.26 0 0 0-.05.2l4.85 26.05a.25.25 0 0 0 .26.2l7.57-.43c.08 0 .16-.06.2-.14L39.08.38a.26.26 0 0 0-.01-.26Zm20.27 5.5a6.8 6.8 0 0 0-.53-2.08c-.27-.6-.61-1.1-1.01-1.5-.4-.41-.8-.75-1.23-1A8.23 8.23 0 0 0 52.9 0H40.47a.25.25 0 0 0-.25.2l-4.01 24.6c-.01.07.01.14.06.2.05.05.11.08.18.08h.02l6.25-.36c.11 0 .2-.1.22-.21l.75-4.63h6.08c1.39-.09 2.7-.43 3.89-1.03a9.75 9.75 0 0 0 2.99-2.46 9.9 9.9 0 0 0 2-4.76l.56-3.3c.17-1.02.21-1.93.13-2.71Zm-7.21 5.87a2.53 2.53 0 0 1-1.1 1.66c-.27.18-.64.27-1.1.27H44.7l1.1-7h5.3c.45 0 .78.09.97.27.22.2.38.41.47.65.1.27.13.6.1.95l-.52 3.2ZM83.75 0h-6.32c-.12 0-.23.1-.25.22l-2.25 14.34L70.04.17a.25.25 0 0 0-.23-.17H63.5c-.12 0-.23.1-.25.22l-3.86 24.56c-.01.07.01.16.06.22.05.05.12.08.19.08l6.43-.39c.12 0 .21-.1.23-.22l1.62-10.36 3.4 10.08c.04.1.14.17.25.17l8.58-.52c.11 0 .2-.1.23-.22L84 .3a.27.27 0 0 0-.06-.22.24.24 0 0 0-.19-.09Z"
fill="#F34"
/>
</symbol>
<symbol id="supporter-unbank" viewBox="0 0 766 132" fill="none">
<path
d="M133.125 66.24v41.145c-.03 10.77-7.051 19.926-17.376 22.792-2.053.571-4.156.831-6.279.831-27.55 0-55.09.03-82.641-.01-13.209-.02-23.625-10.429-23.645-23.623v-82.52c0-13.194 10.425-23.593 23.635-23.613h82.641c13.209.02 23.605 10.429 23.645 23.623l.01 41.385.01-.01zM68.38 33.16H31.987c-.361 0-.721-.02-1.082 0-2.694.17-4.657 2.044-4.717 4.508-.07 2.805 1.662 4.779 4.517 5.019.711.06.851.321.851.952v34.904c0 .661-.19.952-.871 1.162-2.183.661-2.694 3.206-1.042 4.769.751.711 1.632.731 2.574.731h72.316.721c1.472-.05 2.594-1.012 2.844-2.434a2.77 2.77 0 0 0-1.853-3.016c-.641-.21-.771-.501-.771-1.092V43.76c0-.741.19-.972.982-1.082 3.595-.491 5.347-4.348 3.385-7.363-1.062-1.633-2.704-2.154-4.557-2.154H68.41h-.03zm-.12 53.026H28.261c-.881 0-1.763-.04-2.644.01-3.245.19-5.208 3.446-3.715 6.171.701 1.272 1.933 1.853 3.175 2.435l27.37 12.824 12.478 5.88c1.703.822 3.325.972 5.037.081 1.352-.692 2.754-1.293 4.136-1.914l23.144-10.519 14.952-6.822c1.542-.701 2.564-1.783 2.684-3.516.2-2.755-1.672-4.638-4.577-4.638H68.26v.01zm-.13-65.329H26.218c-1.933 0-2.574.631-2.604 2.585-.02 1.122 0 2.234 0 3.356 0 2.615.561 3.176 3.155 3.176h82.741c.281 0 .561.01.841 0 1.432-.08 2.133-.761 2.153-2.174v-4.198c0-2.294-.45-2.755-2.713-2.755H68.12l.01.01zM593.247 49.67c-.27 1.703-.11 3.426-.1 5.129l.13 13.665.241 25.777.23 16.18c.18 5.169-4.006 8.155-7.321 8.145-4.696 0-7.911-3.156-7.971-7.995l-.15-17.261-.261-25.777-.22-22.18-.231-19.415c-.03-3.827 1.903-6.622 5.248-7.714s6.58.05 8.813 3.136l46.859 64.517c.29.401.5.892 1.131 1.262v-1.473-59.849c0-4.378 2.524-7.463 6.61-8.125 4.356-.711 8.542 2.755 8.693 7.183.01.321 0 .641 0 .962v84.554c0 3.897-1.933 6.792-5.248 7.864-3.355 1.082-6.64-.11-8.913-3.246l-46.708-64.327c-.251-.351-.421-.782-.832-.992-.04-.05-.08-.09-.12-.14 0 .03-.01.06-.02.1.05 0 .11.01.16.02h-.02zm-288.231-.21v12.383l.17 12.823.321 31.768c.02 1.753.22 3.526.03 5.269-.481 4.368-3.936 7.113-8.453 6.833-3.715-.231-6.739-3.637-6.79-7.704l-.14-15.589-.24-26.017-.24-22.18-.231-21.218c-.03-3.737 1.933-6.562 5.218-7.624 3.325-1.082 6.61.07 8.833 3.126l47.069 64.808c.25.351.51.691.971 1.292v-1.523-60.45c0-4.087 2.584-7.123 6.56-7.774 4.156-.671 8.182 2.394 8.622 6.572a12.05 12.05 0 0 1 .06 1.192v85.396c0 6.071-6.239 9.697-11.476 6.702-1.062-.611-1.863-1.483-2.574-2.465l-47.009-64.707-.681-.912h-.02zm97.402 22.421l-.02-37.178.02-8.626c.141-2.585.641-5.019 2.855-6.702 1.522-1.162 3.334-1.573 5.197-1.583l20.66.01c11.617.26 21.502 7.153 25.257 17.602 2.955 8.225 1.703 16.109-2.413 23.653-1.102 2.004-1.122 2.004.591 3.436 5.287 4.428 9.504 9.608 11.376 16.37 4.317 15.628-4.026 35.375-24.355 38.861-3.636.621-7.341.471-11.016.511-5.759.06-11.527 0-17.296-.05-1.442-.01-2.894 0-4.306-.21-3.946-.592-6.129-3.246-6.469-7.725a27.22 27.22 0 0 1-.061-2.033V71.871l-.02.01zm15.864 15.598v14.156c0 .491-.171 1.062.701 1.052 6.119-.1 12.258.32 18.366-.171 10.806-.871 17.206-11.921 12.619-21.729-2.163-4.628-5.718-7.634-11.026-8.135a86.36 86.36 0 0 0-9.104-.361l-10.675.17c-.801 0-.891.321-.891.982l.01 14.035zm0-42.537l-.02 10.78c0 .731.19.942.941.942l11.156-.03c7.461-.15 12.619-5.891 11.988-13.274-.431-5.009-4.006-8.796-8.903-9.537-4.767-.721-9.564-.571-14.351-.701-.751-.02-.831.311-.831.922v10.9h.02zM704.27 68.014c.581-.17.811-.571 1.111-.872l21.692-21.76 24.676-24.875c3.165-3.196 7.992-3.286 11.076-.21 3.015 3.005 2.915 7.814-.22 10.97l-24.886 25.016-6.259 6.301c-.601.581-.551.942-.071 1.563l31.026 40.153 1.462 1.903c2.604 3.456 2.073 8.095-1.212 10.72-3.315 2.644-8.072 2.123-10.796-1.323l-11.016-14.226-20.299-26.307c-.431-.561-.691-.822-1.332-.16l-14.281 14.406c-.541.541-.681 1.092-.681 1.813v18.954c-.02 3.166-1.492 5.53-4.347 6.893-2.764 1.312-5.468 1.002-7.921-.832-1.893-1.412-2.935-3.366-2.935-5.77V25.446c0-3.997 3.305-7.273 7.341-7.384 4.086-.12 7.631 3.026 7.812 7.033.1 2.194.05 4.398.05 6.592v36.336l.01-.01zM258.548 50.873l-.03 24.955c-.361 19.586-13.54 37.278-32.838 41.355-16.464 3.486-30.154-1.823-40.83-14.687-5.688-6.852-8.642-14.927-9.484-23.793-.17-1.803-.13-3.587-.13-5.38V24.745c0-3.807 2.063-6.682 5.488-7.674 4.978-1.443 9.765 2.254 9.765 7.584v33.591l.06 17.872c.33 12.232 8.843 23.423 20.66 26.228 10.045 2.374 20.64-1.803 26.879-10.86 3.275-4.749 5.068-10.028 5.078-15.839l.07-50.852c0-3.787 2.083-6.702 5.478-7.704 4.937-1.463 9.784 2.184 9.794 7.413v26.388l.04-.02zm292.788 67.683c-3.295 0-5.898-1.824-7.15-5.04l-7.131-18.413c-.35-.922-.811-1.152-1.742-1.152h-33.75c-.871 0-1.312.19-1.642 1.092l-7.01 18.584c-1.322 3.436-4.297 5.199-8.072 4.909-3.105-.241-5.859-2.786-6.56-6.102-.33-1.572-.07-3.075.501-4.568l11.657-30.906 20.37-53.968c1.272-3.386 3.996-5.4 7.301-5.41 3.254-.01 5.998 1.963 7.28 5.28l32.948 84.904c1.853 4.789-.45 9.447-5.207 10.639-.591.151-1.192.131-1.783.151h-.01zm-20.66-39.843l-12.438-32.068-.271.611-11.547 30.546c-.39 1.032.151.912.802.912h21.842 1.612z"
fill="#3cce49"
/>
<path
d="M68.38 33.16h36.874c1.863 0 3.495.511 4.557 2.154 1.962 3.005.21 6.873-3.385 7.363-.792.11-.982.341-.982 1.082v34.903c0 .591.13.892.771 1.092a2.76 2.76 0 0 1 1.853 3.016c-.24 1.423-1.372 2.384-2.844 2.434h-.721-72.316c-.931 0-1.823-.02-2.574-.731-1.652-1.563-1.142-4.118 1.042-4.769.681-.21.881-.501.871-1.162V43.639c0-.631-.14-.892-.851-.952-2.854-.24-4.587-2.214-4.517-5.019.06-2.455 2.023-4.338 4.717-4.508.361-.02.721 0 1.082 0H68.35h.03zM52.647 61.201l.02-17.271c0-.892-.16-1.252-1.172-1.232h-9.724c-.891-.01-1.152.23-1.152 1.132v34.543c0 .932.31 1.132 1.172 1.122h9.604c1.002.02 1.282-.27 1.272-1.272l-.02-17.031v.01zm42.973-.19l.02-17.141c0-.862-.18-1.192-1.122-1.172a249.45 249.45 0 0 1-9.484 0c-.921-.02-1.132.291-1.132 1.162v34.403c0 .902.18 1.262 1.182 1.242h9.364c.991.02 1.192-.321 1.192-1.232l-.02-17.261zm-21.561.08l.02-17.131c0-1.012-.29-1.282-1.282-1.262h-9.244c-.841-.01-1.082.24-1.082 1.082v34.633c0 .832.24 1.092 1.082 1.082h9.244c1.001.02 1.282-.281 1.272-1.272l-.02-17.131h.01zM68.26 86.187h42.041c2.905 0 4.777 1.883 4.577 4.638-.12 1.733-1.152 2.815-2.684 3.516l-14.952 6.823-23.144 10.519-4.136 1.914c-1.722.881-3.335.741-5.037-.081l-12.478-5.88-27.37-12.824c-1.242-.581-2.474-1.162-3.175-2.434-1.482-2.725.471-5.981 3.715-6.171.881-.05 1.762-.01 2.644-.01H68.26v-.01zm-.13-65.329h41.671c2.263 0 2.714.461 2.714 2.755v4.198c-.021 1.412-.722 2.094-2.154 2.174h-.841-82.741c-2.594 0-3.155-.561-3.155-3.176v-3.356c.03-1.954.661-2.585 2.604-2.585H68.14l-.01-.01z"
fill="#0e4160"
/>
<path
d="M52.647 61.201l.02 17.031c0 1.002-.27 1.292-1.272 1.272-3.205-.06-6.409-.05-9.604 0-.861.01-1.172-.18-1.172-1.122V43.84c0-.902.26-1.152 1.152-1.132h9.724c1.001-.02 1.172.341 1.172 1.232l-.02 17.271v-.01zm42.973-.19l.02 17.261c0 .912-.2 1.252-1.192 1.232a243.59 243.59 0 0 0-9.364 0c-1.002.02-1.182-.351-1.182-1.242V43.86c0-.871.21-1.182 1.132-1.162a249.45 249.45 0 0 0 9.484 0c.941-.02 1.122.311 1.122 1.172l-.02 17.141zm-21.562.08l.02 17.131c0 .992-.27 1.292-1.272 1.272-3.074-.06-6.159-.05-9.243 0-.841.01-1.081-.25-1.081-1.082V43.78c0-.842.24-1.092 1.081-1.082h9.243c.992-.02 1.292.25 1.282 1.262l-.02 17.131h-.01z"
fill="#3cce49"
/>
</symbol>
</svg>
);
}

View File

@ -0,0 +1,17 @@
import { Moon, Sun } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { useTheme } from '@/hooks/useTheme'
export default function ThemeToggle() {
const { theme, toggle } = useTheme()
return (
<Button
variant="ghost"
size="icon"
onClick={toggle}
aria-label={`Switch to ${theme === 'dark' ? 'light' : 'dark'} mode`}
>
{theme === 'dark' ? <Sun size={16} /> : <Moon size={16} />}
</Button>
)
}

View File

@ -0,0 +1,19 @@
import { cn } from '@/lib/utils'
interface BadgeProps extends React.HTMLAttributes<HTMLSpanElement> {
variant?: 'default' | 'skill'
}
export function Badge({ className, variant = 'default', ...props }: BadgeProps) {
return (
<span
className={cn(
'inline-flex items-center gap-1 rounded-full px-2.5 py-0.5 text-xs font-medium transition-colors',
variant === 'default' && 'bg-muted text-muted-foreground',
variant === 'skill' && 'bg-primary/12 text-accent-foreground border border-primary/30',
className,
)}
{...props}
/>
)
}

View File

@ -0,0 +1,34 @@
import { Slot } from '@radix-ui/react-slot'
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '@/lib/utils'
const buttonVariants = cva(
'inline-flex items-center justify-center gap-2 rounded-xl font-medium transition-all duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 cursor-pointer',
{
variants: {
variant: {
default: 'bg-primary text-white shadow-sm hover:opacity-90',
outline: 'border border-border bg-transparent hover:bg-muted',
ghost: 'hover:bg-muted',
},
size: {
sm: 'h-8 px-3 text-sm',
default: 'h-10 px-4 text-sm',
lg: 'h-11 px-6 text-base',
icon: 'h-9 w-9',
},
},
defaultVariants: { variant: 'default', size: 'default' },
},
)
interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean
}
export function Button({ className, variant, size, asChild, ...props }: ButtonProps) {
const Comp = asChild ? Slot : 'button'
return <Comp className={cn(buttonVariants({ variant, size }), className)} {...props} />
}

View File

@ -0,0 +1,41 @@
import * as RadixDialog from '@radix-ui/react-dialog'
import { X } from 'lucide-react'
import { cn } from '@/lib/utils'
export const Dialog = RadixDialog.Root
export function DialogContent({
children,
className,
}: {
children: React.ReactNode
className?: string
}) {
return (
<RadixDialog.Portal>
<RadixDialog.Overlay className="fixed inset-0 z-50 bg-black/50 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0" />
<RadixDialog.Content
className={cn(
'fixed left-1/2 top-1/2 z-50 w-full max-w-lg -translate-x-1/2 -translate-y-1/2',
'glass rounded-2xl shadow-2xl p-6',
'data-[state=open]:animate-in data-[state=closed]:animate-out',
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
'data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
'data-[state=closed]:slide-out-to-left-1/2 data-[state=open]:slide-in-from-left-1/2',
'data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-top-[48%]',
className,
)}
>
{children}
<RadixDialog.Close
aria-label="Close"
className="absolute right-4 top-4 rounded-lg p-1 opacity-60 hover:opacity-100 transition-opacity focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<X size={16} />
</RadixDialog.Close>
</RadixDialog.Content>
</RadixDialog.Portal>
)
}
export const DialogTitle = RadixDialog.Title

21
src/data/supporters.ts Normal file
View File

@ -0,0 +1,21 @@
export interface Supporter {
name: string;
url: string;
svgId: string;
width: number;
height: number;
fillCurrentColor?: boolean;
}
// width/height reflect actual viewBox aspect ratios so SVGs render at correct proportions
export const supporters: Supporter[] = [
{ name: "Spiral", url: "https://spiral.xyz/", svgId: "supporter-spiral", width: 100, height: 100 },
{ name: "OpenSats", url: "https://opensats.org/", svgId: "supporter-opensats", width: 200, height: 28 },
{ name: "Tether", url: "https://tether.to/", svgId: "supporter-tether", width: 111, height: 90, fillCurrentColor: true },
{ name: "HRF", url: "https://hrf.org/", svgId: "supporter-hrf", width: 180, height: 60, fillCurrentColor: true },
{ name: "LunaNode", url: "https://www.lunanode.com/", svgId: "supporter-lunanode", width: 100, height: 100 },
{ name: "Wallet of Satoshi", url: "https://walletofsatoshi.com/", svgId: "supporter-walletofsatoshi", width: 200, height: 38 },
{ name: "Coincards", url: "https://coincards.com/", svgId: "supporter-coincards", width: 128, height: 64 },
{ name: "IVPN", url: "https://www.ivpn.net/", svgId: "supporter-ivpn", width: 120, height: 42 },
{ name: "Unbank", url: "https://www.unbank.com/", svgId: "supporter-unbank", width: 180, height: 32, fillCurrentColor: true },
];

45
src/hooks/useFilters.ts Normal file
View File

@ -0,0 +1,45 @@
import { useCallback, useEffect, useState } from 'react'
import type { FilterState, SkillCategory } from '@/types'
import { sanitizeFilters } from '@/lib/filter-engine'
function readFromURL(): FilterState {
const params = new URLSearchParams(window.location.search)
return sanitizeFilters({
skill: params.get('skill') ?? undefined,
tags: params.get('tags')?.split(',').filter(Boolean) ?? [],
repos: params.get('repos')?.split(',').filter(Boolean) ?? [],
query: params.get('q') ?? '',
})
}
function writeToURL(filters: FilterState) {
const params = new URLSearchParams()
if (filters.skill) params.set('skill', filters.skill)
if (filters.tags.length) params.set('tags', filters.tags.join(','))
if (filters.repos.length) params.set('repos', filters.repos.join(','))
if (filters.query.trim()) params.set('q', filters.query.trim())
const search = params.toString()
const url = search ? `?${search}` : window.location.pathname
window.history.replaceState(null, '', url)
}
export function useFilters() {
const [filters, setFilters] = useState<FilterState>(readFromURL)
// Sync to URL whenever filters change
useEffect(() => { writeToURL(filters) }, [filters])
const setSkill = useCallback((skill: SkillCategory | null) => {
setFilters((prev) => ({ ...prev, skill, tags: [] })) // reset tags on skill change
}, [])
const setQuery = useCallback((query: string) => {
setFilters((prev) => ({ ...prev, query }))
}, [])
const clearAll = useCallback(() => {
setFilters({ skill: null, tags: [], repos: [], query: '' })
}, [])
return { filters, setSkill, setQuery, clearAll }
}

28
src/hooks/useIssues.ts Normal file
View File

@ -0,0 +1,28 @@
import { useEffect, useMemo, useState } from 'react'
import type { IssuesData, FilterState } from '@/types'
import { filterIssues } from '@/lib/filter-engine'
type Status = 'idle' | 'loading' | 'success' | 'error'
export function useIssues(filters: FilterState) {
const [data, setData] = useState<IssuesData | null>(null)
const [status, setStatus] = useState<Status>('idle')
useEffect(() => {
setStatus('loading')
fetch('/data/issues.json')
.then((r) => {
if (!r.ok) throw new Error(`HTTP ${r.status}`)
return r.json() as Promise<IssuesData>
})
.then((d) => { setData(d); setStatus('success') })
.catch((err) => { console.error('[useIssues] failed to load issues.json:', err); setStatus('error') })
}, [])
const filtered = useMemo(
() => (data ? filterIssues(data.issues, filters) : []),
[data, filters],
)
return { filtered, status }
}

24
src/hooks/useTheme.ts Normal file
View File

@ -0,0 +1,24 @@
import { useEffect, useState } from 'react'
type Theme = 'light' | 'dark'
function getInitialTheme(): Theme {
if (typeof window === 'undefined') return 'dark'
const stored = localStorage.getItem('theme')
if (stored === 'light' || stored === 'dark') return stored
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
}
export function useTheme() {
const [theme, setTheme] = useState<Theme>(getInitialTheme)
useEffect(() => {
const root = document.documentElement
root.classList.toggle('dark', theme === 'dark')
localStorage.setItem('theme', theme)
}, [theme])
const toggle = () => setTheme((t) => (t === 'dark' ? 'light' : 'dark'))
return { theme, toggle }
}

152
src/index.css Normal file
View File

@ -0,0 +1,152 @@
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=Space+Grotesk:wght@400;600;700&display=swap');
@import "tailwindcss";
@plugin "@tailwindcss/typography";
@plugin "tailwindcss-animate";
@custom-variant dark (&:is(.dark *));
@theme inline {
--font-sans: 'Inter', sans-serif;
--font-display: 'Space Grotesk', sans-serif;
--color-background: hsl(var(--background));
--color-foreground: hsl(var(--foreground));
--color-card: hsl(var(--card));
--color-card-foreground: hsl(var(--card-foreground));
--color-popover: hsl(var(--popover));
--color-popover-foreground: hsl(var(--popover-foreground));
--color-primary: hsl(var(--primary));
--color-primary-foreground: hsl(var(--primary-foreground));
--color-secondary: hsl(var(--secondary));
--color-secondary-foreground: hsl(var(--secondary-foreground));
--color-muted: hsl(var(--muted));
--color-muted-foreground: hsl(var(--muted-foreground));
--color-accent: hsl(var(--accent));
--color-accent-foreground: hsl(var(--accent-foreground));
--color-destructive: hsl(var(--destructive));
--color-destructive-foreground: hsl(var(--destructive-foreground));
--color-border: hsl(var(--border));
--color-input: hsl(var(--input));
--color-ring: hsl(var(--ring));
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
}
@layer base {
:root {
--background: 210 20% 98%;
--foreground: 0 0% 16%;
--card: 0 0% 100%;
--card-foreground: 0 0% 16%;
--popover: 0 0% 100%;
--popover-foreground: 0 0% 16%;
--primary: 110 48% 47%;
--primary-foreground: 0 0% 100%;
--secondary: 210 16% 93%;
--secondary-foreground: 0 0% 16%;
--muted: 210 16% 93%;
--muted-foreground: 210 10% 35%;
--accent: 110 48% 95%;
--accent-foreground: 110 55% 22%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 0 0% 98%;
--border: 210 14% 89%;
--input: 210 14% 89%;
--ring: 110 55% 30%;
--radius: 0.5rem;
}
.dark {
--background: 216 28% 7%;
--foreground: 210 40% 96%;
--card: 215 15% 15%;
--card-foreground: 210 40% 96%;
--popover: 215 15% 15%;
--popover-foreground: 210 40% 96%;
--primary: 110 48% 47%;
--primary-foreground: 0 0% 100%;
--secondary: 210 12% 21%;
--secondary-foreground: 210 40% 96%;
--muted: 210 12% 21%;
--muted-foreground: 215 14% 62%;
--accent: 213 10% 28%;
--accent-foreground: 210 40% 92%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 210 40% 96%;
--border: 210 12% 21%;
--input: 210 12% 21%;
--ring: 110 48% 47%;
}
* {
@apply border-border;
}
body {
@apply font-sans antialiased bg-background text-foreground;
transition: background-color 150ms ease, color 150ms ease;
}
h1, h2, h3, h4, h5, h6 {
@apply font-display;
}
::selection {
background-color: hsl(var(--primary) / 0.25);
}
}
/* ─── Utilities ───────────────────────────────────────────────────────────── */
@layer utilities {
.glass {
background-color: hsl(var(--card) / 0.6);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid hsl(var(--border) / 0.4);
}
.card-enter { animation: cardEnter 0.4s ease both; }
.card-enter-1 { animation-delay: 60ms; }
.card-enter-2 { animation-delay: 120ms; }
.card-enter-3 { animation-delay: 180ms; }
.card-enter-4 { animation-delay: 240ms; }
.card-enter-5 { animation-delay: 300ms; }
.card-enter-6 { animation-delay: 360ms; }
@keyframes cardEnter {
from { opacity: 0; transform: translateY(12px); }
to { opacity: 1; transform: translateY(0); }
}
}
/* ─── Prose (markdown in modal) ───────────────────────────────────────────── */
.prose {
--tw-prose-body: hsl(var(--foreground));
--tw-prose-headings: hsl(var(--foreground));
--tw-prose-code: hsl(var(--primary));
--tw-prose-links: hsl(var(--primary));
--tw-prose-bullets: hsl(var(--muted-foreground));
font-size: 0.875rem;
line-height: 1.65;
}
.prose pre {
background-color: hsl(var(--muted));
border-radius: 0.5rem;
padding: 0.75rem 1rem;
font-size: 0.8rem;
overflow-x: auto;
}
.prose code:not(pre code) {
background-color: hsl(var(--muted));
border-radius: 0.25rem;
padding: 0.1em 0.35em;
font-size: 0.85em;
}

55
src/lib/filter-engine.ts Normal file
View File

@ -0,0 +1,55 @@
import type { Issue, FilterState, SkillCategory } from '@/types'
import { ALL_SKILLS } from '@/lib/skill-map'
const ALLOWED_SKILLS = new Set<string>(ALL_SKILLS)
/** Loose input accepted by sanitizeFilters — raw strings from URL params before validation. */
interface RawFilterInput {
skill?: string | null
tags?: string[]
repos?: string[]
query?: string
}
/** Sanitize raw URL-param input — only valid, allowlisted values pass through. */
export function sanitizeFilters(raw: RawFilterInput): FilterState {
return {
skill: raw.skill && ALLOWED_SKILLS.has(raw.skill) ? (raw.skill as SkillCategory) : null,
tags: Array.isArray(raw.tags) ? raw.tags.filter((t) => /^[\w/. -]+$/.test(t)) : [],
repos: Array.isArray(raw.repos) ? raw.repos.filter((r) => /^[\w.-]+$/.test(r)) : [],
query: typeof raw.query === 'string' ? raw.query.slice(0, 200) : '',
}
}
/** Apply all active filters to the full issue list. Pure function — no side effects. */
export function filterIssues(issues: Issue[], filters: FilterState): Issue[] {
let result = issues.filter((i) => i.assignees.length === 0)
if (filters.skill) {
result = result.filter((i) => i.skills.includes(filters.skill!))
}
if (filters.tags.length > 0) {
result = result.filter((i) => filters.tags.every((tag) => i.tags.includes(tag)))
}
if (filters.repos.length > 0) {
result = result.filter((i) => filters.repos.includes(i.repo.name))
}
if (filters.query.trim()) {
const q = filters.query.trim().toLowerCase()
result = result.filter(
(i) =>
i.title.toLowerCase().includes(q) ||
i.body.toLowerCase().includes(q) ||
i.repo.name.toLowerCase().includes(q),
)
}
return result
}
export function hasActiveFilters(filters: FilterState): boolean {
return !!(filters.skill || filters.tags.length || filters.repos.length || filters.query.trim())
}

32
src/lib/skill-map.ts Normal file
View File

@ -0,0 +1,32 @@
import type { SkillCategory } from '@/types'
export interface SkillMeta {
label: string
icon: string
description: string
}
export const SKILL_META: Record<SkillCategory, SkillMeta> = {
developer: {
label: 'Developer',
icon: '⚡',
description: 'Code contributions: C#, TypeScript, APIs, Lightning',
},
writer: {
label: 'Writer',
icon: '✍️',
description: 'Docs, tutorials, translations',
},
design: {
label: 'Design',
icon: '🎨',
description: 'UI/UX, graphic design, CSS',
},
marketing: {
label: 'Marketing',
icon: '📣',
description: 'Content, community, social media, SEO',
},
}
export const ALL_SKILLS: SkillCategory[] = ['developer', 'writer', 'design', 'marketing']

18
src/lib/utils.ts Normal file
View File

@ -0,0 +1,18 @@
import { clsx, type ClassValue } from 'clsx'
import { twMerge } from 'tailwind-merge'
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
export function timeAgo(dateStr: string): string {
const diff = Date.now() - new Date(dateStr).getTime()
const mins = Math.floor(diff / 60000)
if (mins < 60) return `${mins}m ago`
const hrs = Math.floor(mins / 60)
if (hrs < 24) return `${hrs}h ago`
const days = Math.floor(hrs / 24)
if (days < 30) return `${days}d ago`
const months = Math.floor(days / 30)
return `${months}mo ago`
}

10
src/main.tsx Normal file
View File

@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)

56
src/types/index.ts Normal file
View File

@ -0,0 +1,56 @@
export type SkillCategory = 'developer' | 'writer' | 'design' | 'marketing'
export interface Repository {
id: number
name: string
fullName: string
description: string | null
url: string
language: string | null
topics: string[]
stars: number
}
export interface IssueLabel {
name: string
color: string // hex without #
}
export interface IssueAuthor {
login: string
avatarUrl: string
url: string
}
export interface Issue {
id: number
number: number
title: string
body: string // truncated to 600 chars
url: string
createdAt: string
updatedAt: string
commentsCount: number
reactionCount: number
labels: IssueLabel[]
repo: Pick<Repository, 'name' | 'fullName' | 'language' | 'url'>
assignees: IssueAuthor[]
author: IssueAuthor
skills: SkillCategory[]
tags: string[]
}
export interface IssuesData {
lastUpdated: string
totalIssues: number
repoCount: number
repos: Repository[]
issues: Issue[]
}
export interface FilterState {
skill: SkillCategory | null
tags: string[]
repos: string[]
query: string
}

30
tsconfig.app.json Normal file
View File

@ -0,0 +1,30 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"types": ["vite/client"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Path aliases */
"baseUrl": ".",
"paths": { "@/*": ["src/*"] },
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}

7
tsconfig.json Normal file
View File

@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

26
tsconfig.node.json Normal file
View File

@ -0,0 +1,26 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}

11
vite.config.ts Normal file
View File

@ -0,0 +1,11 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
import { resolve } from 'path'
export default defineConfig({
plugins: [react(), tailwindcss()],
resolve: {
alias: { '@': resolve(__dirname, 'src') },
},
})