diff --git a/git-paid/.env.example b/git-paid/.env.example new file mode 100644 index 00000000..a4e6e13c --- /dev/null +++ b/git-paid/.env.example @@ -0,0 +1,6 @@ +# GitPaid — Environment Variables +# Create a .env file and fill in your values + +# TinyFish API Key +# Get yours at: https://agent.tinyfish.ai/api-keys +TINYFISH_API_KEY=your_tinyfish_api_key_here \ No newline at end of file diff --git a/git-paid/.gitignore b/git-paid/.gitignore new file mode 100644 index 00000000..c780f7de --- /dev/null +++ b/git-paid/.gitignore @@ -0,0 +1,3 @@ +__pycache__/ +*.pyc +.env \ No newline at end of file diff --git a/git-paid/README.md b/git-paid/README.md new file mode 100644 index 00000000..c95ef6d4 --- /dev/null +++ b/git-paid/README.md @@ -0,0 +1,190 @@ +# GitPaid — OSS Bounty & Grant Finder + +> Find paid open-source work across bounty platforms, GitHub repos, and grant foundations — powered by a 3-tier TinyFish agent system. + +--- +**Live Link**: https://git-paid.onrender.com/ + +## What is GitPaid? + +GitPaid aggregates paid open-source opportunities into a single real-time feed. Instead of manually checking Algora, IssueHunt, GitHub issues, and foundation websites separately, GitPaid runs all of them concurrently using TinyFish web agents and streams results live into the UI. + +--- + +## Architecture + +GitPaid runs three tiers of agents simultaneously: + +``` +User selects: stack=Rust, keywords=async, min=$100 + +┌─────────────────────────────────────────────────────────────┐ +│ TIER 1 — Bounty Aggregator Platforms │ +│ Algora · IssueHunt · Gitcoin · Bountysource │ +│ Scrape platforms that already curate bounties │ +└─────────────────────────────────────────────────────────────┘ + concurrently ▼ +┌─────────────────────────────────────────────────────────────┐ +│ TIER 2 — Awesome List → Repo Fan-Out │ +│ Stage 1: scrape awesome-rust → discover top 25 repos │ +│ Stage 2: 20 parallel agents check bounty-labelled issues │ +└─────────────────────────────────────────────────────────────┘ + concurrently ▼ +┌─────────────────────────────────────────────────────────────┐ +│ TIER 3 — Grant Programs (always runs) │ +│ NLNet · Sovereign Tech Fund · Mozilla MOSS │ +│ LFX Mentorship · Google Summer of Code · Outreachy │ +└─────────────────────────────────────────────────────────────┘ + │ + ▼ + FastAPI SSE stream → Real-time UI +``` + +### Why 3 tiers? + +| Tier | Method | Signal Quality | Speed | +|------|--------|---------------|-------| +| T1 · Aggregators | Scrape platforms that already index bounties | Highest — curated | Fast | +| T2 · Repo Discovery | Awesome list → GitHub issues fan-out | High — top repos only | Medium | +| T3 · Grants | Fixed list of foundation open-call pages | Perfect — finite known list | Fast | + +--- + +## Tech Stack + +| Layer | Technology | +|-------|-----------| +| Backend | Python 3.11+ · FastAPI · Uvicorn | +| Agent API | TinyFish Python SDK (`pip install tinyfish`) | +| Streaming | Server-Sent Events (SSE) | +| Frontend | Vanilla HTML · CSS · JavaScript | +| Config | python-dotenv | + +--- + +## Supported Stacks + +Rust · Go · Python · TypeScript · JavaScript · C++ · Java · Zig · Elixir · Swift · Ruby · Haskell + +--- + +## Quick Start + +### 1. Install dependencies + +```bash +pip install -r requirements.txt +``` + +### 2. Set up your API key + +Get your TinyFish API key at [agent.tinyfish.ai/api-keys](https://agent.tinyfish.ai/api-keys) + +```bash +cp .env.example .env +# Edit .env and add your key +``` + +```env +TINYFISH_API_KEY=your_key_here +``` + +### 3. Run + +```bash +python main.py +``` + +Open **http://localhost:8000** + +--- + +## Project Structure + +``` +gitpaid/ +├── main.py # FastAPI server — serves frontend + /api/search SSE route +├── agents.py # 3-tier TinyFish agent orchestration +├── models.py # Pydantic models for requests and SSE events +├── requirements.txt +├── .env.example # Copy to .env and add your API key +├── .gitignore +├── README.md +└── static/ + └── index.html # Dark-themed frontend (single file, no build step) +``` + +--- + +## How It Works + +### Real-time Streaming + +Results appear in the UI as each agent completes — there is no waiting for all sources to finish. The frontend opens an SSE stream to `/api/search` and processes events as they arrive. + +### SSE Event Types + +| Event | Payload | When | +|-------|---------|------| +| `sources` | Array of all agent source metadata | Once at start | +| `agent_started` | `source_id` | T1/T3 agent begins | +| `agent_complete` | `source_id`, `count`, `opportunities[]` | T1/T3 agent finishes | +| `agent_error` | `source_id`, `error` | T1/T3 agent fails | +| `tier2_status` | `phase`, `total`, `lang` | T2 phase changes | +| `tier2_repo_done` | `repo`, `count`, `scanned`, `total`, `opportunities[]` | Each T2 repo checked | +| `done` | — | All agents finished | + +### Relevance Filtering + +Results are filtered at two levels: +1. **Prompt level** — TinyFish is instructed to only return results relevant to the selected stack +2. **Post-parse level** — A keyword filter drops any result that mentions an unrelated language/stack + +--- + +## Grant Programs + +| Program | Organisation | Max Funding | Focus | +|---------|-------------|-------------|-------| +| NLNet / NGI Zero | NLNet Foundation | €50,000 | Privacy, security, open internet | +| Sovereign Tech Fund | German Government | €250,000 | Digital infrastructure | +| Mozilla MOSS | Mozilla Foundation | ~$10,000 | Web, security, privacy | +| LFX Mentorship | Linux Foundation | ~$6,600 | Any open source | +| Google Summer of Code | Google | ~$6,000 | Any open source | +| Outreachy | Software Freedom Conservancy | $7,000 | Underrepresented contributors | + +--- + +## Environment Variables + +| Variable | Required | Description | +|----------|----------|-------------| +| `TINYFISH_API_KEY` | ✅ Yes | Your TinyFish API key | + +--- + +## Diagnostic Endpoints + +| Endpoint | Description | +|----------|-------------| +| `GET /health` | Check server status and API key configuration | +| `GET /api/test-agent` | Fire a single TinyFish agent to verify connectivity | + +--- + +## Extension Ideas + +- Add **Pot.app** and **Open Collective** as Tier 1 sources +- Cache awesome-list repo URLs for 24h (skip T2 discovery on repeat searches) +- Add bounty label aliases: `$$$`, `💰`, `paid`, `reward` +- **Slack/Discord webhook** — post new matches to a channel automatically +- **Resume parser** — paste your CV and auto-extract your stack +- **MCP integration** — expose as a TinyFish MCP tool for Claude/Cursor inline search + +--- + +## License + +MIT — see [LICENSE](LICENSE) + +--- diff --git a/git-paid/agents.py b/git-paid/agents.py new file mode 100644 index 00000000..2c85a8d1 --- /dev/null +++ b/git-paid/agents.py @@ -0,0 +1,508 @@ +""" +agents.py — GitPaid 3-tier TinyFish agent orchestration +Uses the official TinyFish Python SDK: pip install tinyfish +""" +from __future__ import annotations + +import asyncio +import json +import re +import time +from typing import Any + +from tinyfish import AsyncTinyFish, CompleteEvent, EventType, RunStatus, StreamingUrlEvent + +from models import ( + Opportunity, + SourceMeta, + AgentStartedEvent, + AgentCompleteEvent, + AgentErrorEvent, + Tier2StatusEvent, + Tier2RepoDoneEvent, +) + +# ── Awesome-list registry ────────────────────────────────────────────────────── +AWESOME_LISTS: dict[str, str] = { + "Rust": "https://github.com/rust-unofficial/awesome-rust", + "Go": "https://github.com/avelino/awesome-go", + "Python": "https://github.com/vinta/awesome-python", + "TypeScript": "https://github.com/dzharii/awesome-typescript", + "JavaScript": "https://github.com/sorrycc/awesome-javascript", + "C++": "https://github.com/fffaraz/awesome-cpp", + "Java": "https://github.com/akullpp/awesome-java", + "Zig": "https://github.com/catdevnull/awesome-zig", + "Elixir": "https://github.com/h4cc/awesome-elixir", + "Swift": "https://github.com/matteocrippa/awesome-swift", + "Ruby": "https://github.com/markets/awesome-ruby", + "Haskell": "https://github.com/krispo/awesome-haskell", +} + +# ── Tier 3 grant sources ─────────────────────────────────────────────────────── +GRANT_SOURCES = [ + SourceMeta(id="nlnet", label="NLNet / NGI Zero", tier=3, url="https://nlnet.nl/thema/"), + SourceMeta(id="stf", label="Sovereign Tech Fund", tier=3, url="https://www.sovereigntechfund.de/programs"), + SourceMeta(id="moss", label="Mozilla MOSS", tier=3, url="https://www.mozilla.org/en-US/moss/"), + SourceMeta(id="lfx", label="LFX Mentorship", tier=3, url="https://lfx.linuxfoundation.org/tools/mentorship/"), + SourceMeta(id="gsoc", label="Google Summer of Code", tier=3, url="https://summerofcode.withgoogle.com/"), + SourceMeta(id="outreachy", label="Outreachy", tier=3, url="https://www.outreachy.org/apply/"), +] + +# ── Core TinyFish SDK call ───────────────────────────────────────────────────── + +async def run_tinyfish_agent( + url: str, + goal: str, + source_id: str | None = None, + queue: asyncio.Queue | None = None, +) -> Any: + """ + Call TinyFish using the official Python SDK (AsyncTinyFish). + Reads TINYFISH_API_KEY automatically from the environment. + + If source_id and queue are provided, streaming_url events are forwarded + to the queue so the frontend can show a live browser preview iframe. + + Returns result_json on COMPLETE, raises on failure. + """ + print(f"[TinyFish] -> {url[:80]}") + + # AsyncTinyFish() reads TINYFISH_API_KEY from env automatically + async with AsyncTinyFish() as client: + async with client.agent.stream(url=url, goal=goal) as stream: + async for event in stream: + + # ── Live preview: forward streaming URL to frontend ────────── + if isinstance(event, StreamingUrlEvent) and source_id and queue: + print(f"[TinyFish] streaming_url for {source_id}: {event.streaming_url}") + await queue.put({ + "type": "streaming_url", + "source_id": source_id, + "url": event.streaming_url, + }) + + elif isinstance(event, CompleteEvent): + if event.status == RunStatus.COMPLETED: + print(f"[TinyFish] COMPLETE {url[:60]} | result type={type(event.result_json).__name__} | preview={str(event.result_json)[:200]}") + return event.result_json + else: + raise RuntimeError(f"Run ended with status: {event.status}") + + return None + + +# ── Amount normaliser ────────────────────────────────────────────────────────── + +def _normalise_amount(val: Any) -> float | None: + if val is None: + return None + try: + cleaned = re.sub(r"[^\d.]", "", str(val).replace(",", "").split()[0]) + return float(cleaned) if cleaned else None + except (ValueError, TypeError): + return None + + +# ── Opportunity parser ───────────────────────────────────────────────────────── + +ENVELOPE_KEYS = [ + "opportunities", "bounties", "grants", "issues", "items", + "results", "result", + "repos", "data", "listings", "programs", + "bounty_listings", "open_bounty_listings", + "open_bounties", "open_issues", "projects", +] + + +def parse_opportunities( + raw: Any, + source_id: str, + source_label: str, + tier: int, + opp_type: str = "bounty", +) -> list[Opportunity]: + """Normalise raw TinyFish result_json into typed Opportunity objects.""" + if raw is None: + print(f"[Parser] {source_id}: raw is None") + return [] + + print(f"[Parser] {source_id}: type={type(raw).__name__} | preview={str(raw)[:200]}") + + # Unwrap string-encoded JSON + if isinstance(raw, str): + try: + raw = json.loads(raw) + except json.JSONDecodeError: + return [] + + # Unwrap dict envelope + if isinstance(raw, dict): + for key in ENVELOPE_KEYS: + if key in raw and isinstance(raw[key], list): + print(f"[Parser] {source_id}: unwrapped key='{key}' -> {len(raw[key])} items") + raw = raw[key] + break + else: + print(f"[Parser] {source_id}: dict keys={list(raw.keys())}, wrapping as single item") + raw = [raw] + + if not isinstance(raw, list): + return [] + + print(f"[Parser] {source_id}: parsing {len(raw)} items") + opps: list[Opportunity] = [] + ts = int(time.time() * 1000) + + for i, item in enumerate(raw): + if not isinstance(item, dict): + continue + + title = ( + item.get("title") or item.get("name") or item.get("bounty_title") or + item.get("issue_title") or item.get("program") or item.get("heading") or + item.get("project_title") or "" + ) + if not title: + print(f"[Parser] {source_id}[{i}]: no title — keys: {list(item.keys())[:10]}") + continue + + url = ( + item.get("url") or item.get("link") or item.get("href") or + item.get("issue_url") or item.get("bounty_url") or item.get("apply_url") or "" + ) + amount = _normalise_amount( + item.get("bountyAmount") or item.get("bounty_amount") or + item.get("amount") or item.get("reward") or item.get("prize") or + item.get("maxFunding") or item.get("max_funding") or + item.get("funding") or item.get("stipend") or item.get("value") or + item.get("total_amount") + ) + currency = item.get("currency") or ("USD" if amount else None) + + skills_raw = ( + item.get("skills") or item.get("tags") or item.get("languages") or + item.get("tech_stack") or item.get("technologies") or [] + ) + if isinstance(skills_raw, str): + skills_raw = [s.strip() for s in skills_raw.split(",") if s.strip()] + skills = [str(s) for s in skills_raw if s] + + labels_raw = item.get("labels") or item.get("label") or [] + if isinstance(labels_raw, str): + labels_raw = [labels_raw] + labels = [str(l) for l in labels_raw if l] + + repo = ( + item.get("repo") or item.get("repository") or + item.get("github_repo") or item.get("project") or None + ) + determined_type = "grant" if (tier == 3 or "grant" in " ".join(labels).lower()) else opp_type + + print(f"[Parser] {source_id}[{i}]: '{title[:50]}' amount={amount} url={str(url)[:50]}") + opps.append(Opportunity( + id=f"{source_id}-{i}-{ts}", + type=determined_type, + tier=tier, + source=source_id, + source_label=source_label, + title=str(title), + repo=repo, + url=str(url), + bounty_amount=amount, + currency=currency, + skills=skills, + difficulty=item.get("difficulty"), + deadline=item.get("deadline"), + description=item.get("description"), + labels=labels, + )) + + print(f"[Parser] {source_id}: produced {len(opps)} opportunities") + return opps + + +# ── Tier 1 sources & prompts ─────────────────────────────────────────────────── + +def build_tier1_sources(stack: str, keywords: str) -> list[SourceMeta]: + kw = f" {keywords}" if keywords else "" + return [ + SourceMeta(id="algora", label="Algora", tier=1, url=f"https://algora.io/bounties?lang={stack.lower()}{kw}"), + SourceMeta(id="issuehunt", label="IssueHunt", tier=1, url=f"https://issuehunt.io/repos?language={stack.lower()}"), + SourceMeta(id="gitcoin", label="Gitcoin", tier=1, url=f"https://gitcoin.co/explorer?keywords={stack.lower()}{kw.replace(' ', '+')}"), + SourceMeta(id="bountysource", label="Bountysource", tier=1, url=f"https://www.bountysource.com/trackers?language={stack.lower()}"), + ] + + +def build_tier1_prompts(stack: str, keywords: str) -> dict[str, str]: + """Generate prompts with stack/keyword filters baked in.""" + kw_clause = f" or mention '{keywords}'" if keywords else "" + filter_clause = ( + f"IMPORTANT: Only include bounties relevant to {stack}{kw_clause}. " + f"Skip any bounty that is clearly for a different language or stack " + f"(e.g. if searching for Python, skip Scala/Java/Rust/Go bounties). " + ) + return { + "algora": ( + f"This is Algora.io, a paid OSS bounty platform filtered for {stack}. " + f"Extract open bounty listings. {filter_clause}" + "Return a JSON array where each item has: " + "title, repo (owner/repo), url, bountyAmount (number only), " + "currency (USD), skills (array), difficulty (null), labels ([])." + ), + "issuehunt": ( + f"This is IssueHunt, a paid OSS bounty platform filtered for {stack}. " + f"Extract open bounty listings. {filter_clause}" + "Return a JSON array where each item has: " + "title, repo (owner/repo), url (GitHub issue URL), " + "bountyAmount (number), currency (USD), skills (array), difficulty (null), labels ([])." + ), + "gitcoin": ( + f"This is Gitcoin, a bounty platform filtered for {stack}. " + f"Extract open bounties. {filter_clause}" + "Return a JSON array where each item has: " + "title, repo, url, bountyAmount (number), currency, skills (array), difficulty (null), labels ([])." + ), + "bountysource": ( + f"This is Bountysource, an OSS bounty platform filtered for {stack}. " + f"Extract open bounties. {filter_clause}" + "Return a JSON array where each item has: " + "title, repo (owner/repo), url, bountyAmount (number), " + "currency (USD), skills ([]), difficulty (null), labels ([])." + ), + } + +TIER2A_PROMPT = ( + "This is a GitHub awesome list page (curated list of quality repos). " + "Extract GitHub repository URLs from the README. " + "Return JSON: {\"repos\": [\"https://github.com/owner/repo\", ...]} " + "Rules: only owner/repo paths (2 segments), skip wikis/gists/topics, " + "skip the awesome list repo itself, return first 25 unique repos." +) + +TIER2B_PROMPT = ( + "This is a GitHub issues page showing open issues related to bounties or paid work. " + "Extract ALL visible issues that mention bounty, reward, paid, or prize in title or labels. " + "Return a JSON array where each item has: " + "title, repo (owner/repo), url (full GitHub issue URL), labels (array), " + "bountyAmount (dollar/euro amount as plain number or null), currency (USD/EUR or null), " + "skills (languages/tech mentioned), difficulty (beginner/intermediate/advanced). " + "Return [] if the page is empty." +) + + +def _extract_repo_urls(raw: Any) -> list[str]: + if raw is None: + return [] + if isinstance(raw, dict): + raw = raw.get("repos") or raw.get("results") or [] + if not isinstance(raw, list): + return [] + pattern = re.compile(r"https?://github\.com/([^/\s\"']+)/([^/\s\"'#?]+)") + seen: set[str] = set() + out: list[str] = [] + for item in raw: + text = item if isinstance(item, str) else str(item) + for m in pattern.finditer(text): + url = m.group(0).rstrip("/.") + if url not in seen: + seen.add(url) + out.append(url) + return out[:25] + + +# ── Relevance filter ────────────────────────────────────────────────────────── + +# Map of stacks to languages/terms that are clearly NOT that stack +UNRELATED_STACKS: dict[str, list[str]] = { + "Python": ["scala", "java", "rust", "golang", "swift", "kotlin", "clojure", "haskell", "erlang", "zio", "dotnet", "c#"], + "Rust": ["python", "scala", "java", "swift", "kotlin", "ruby", "golang", "clojure", "zio"], + "Go": ["python", "scala", "java", "swift", "kotlin", "rust", "ruby", "zio", "dotnet"], + "TypeScript": ["python", "scala", "java", "rust", "swift", "kotlin", "ruby", "golang", "zio"], + "JavaScript": ["python", "scala", "java", "rust", "swift", "kotlin", "ruby", "golang", "zio"], + "Java": ["python", "rust", "swift", "golang", "ruby", "typescript", "javascript"], + "Swift": ["python", "rust", "golang", "java", "kotlin", "ruby", "scala"], +} + +def _filter_by_relevance(opps: list[Opportunity], stack: str, keywords: str) -> list[Opportunity]: + """ + Remove opportunities that are clearly for a different stack. + Grants (tier 3) are always kept since they are language-agnostic. + """ + unrelated = [t.lower() for t in UNRELATED_STACKS.get(stack, [])] + if not unrelated: + return opps + + stack_lower = stack.lower() + kw_lower = keywords.lower() if keywords else "" + kept = [] + + for opp in opps: + if opp.tier == 3: # grants are always relevant + kept.append(opp) + continue + + # Build a searchable text blob from the opportunity + blob = " ".join([ + opp.title or "", + opp.repo or "", + " ".join(opp.skills), + " ".join(opp.labels), + opp.description or "", + ]).lower() + + # If the blob explicitly mentions an unrelated stack, skip it + if any(u in blob for u in unrelated): + print(f"[Filter] Dropping '{opp.title[:50]}' — unrelated to {stack}") + continue + + kept.append(opp) + + print(f"[Filter] {len(kept)}/{len(opps)} opportunities kept for stack={stack}") + return kept + + +# ── Agent runners ────────────────────────────────────────────────────────────── + +async def _run_tier1_agent(source: SourceMeta, queue: asyncio.Queue, stack: str, keywords: str) -> None: + await queue.put(AgentStartedEvent(source_id=source.id)) + try: + prompts = build_tier1_prompts(stack, keywords) + prompt = prompts.get(source.id, prompts["algora"]) + # Pass source_id + queue so streaming_url events reach the frontend + raw = await run_tinyfish_agent(source.url, prompt, source_id=source.id, queue=queue) + opps = parse_opportunities(raw, source.id, source.label, tier=1) + # Post-filter: remove results that don't match the stack at all + opps = _filter_by_relevance(opps, stack, keywords) + await queue.put(AgentCompleteEvent(source_id=source.id, count=len(opps), opportunities=opps)) + except Exception as exc: + print(f"[Agent] {source.id} ERROR: {exc}") + await queue.put(AgentErrorEvent(source_id=source.id, error=str(exc))) + + +async def _run_tier3_agent(source: SourceMeta, queue: asyncio.Queue) -> None: + prompt = ( + f"This is the {source.label} grants page listing open funding programs. " + "Extract all currently open programs. Return a JSON array where each item has: " + "title, url (absolute URL), bountyAmount (max grant as number), " + "currency (EUR or USD), skills (tech areas array), deadline, " + "description (one sentence), labels ([\"grant\"])." + ) + await queue.put(AgentStartedEvent(source_id=source.id)) + try: + # Pass source_id + queue so streaming_url events reach the frontend + raw = await run_tinyfish_agent(source.url, prompt, source_id=source.id, queue=queue) + opps = parse_opportunities(raw, source.id, source.label, tier=3, opp_type="grant") + await queue.put(AgentCompleteEvent(source_id=source.id, count=len(opps), opportunities=opps)) + except Exception as exc: + print(f"[Agent] {source.id} ERROR: {exc}") + await queue.put(AgentErrorEvent(source_id=source.id, error=str(exc))) + + +async def _run_tier2(stack: str, queue: asyncio.Queue) -> None: + awesome_url = AWESOME_LISTS.get(stack) + if not awesome_url: + await queue.put(Tier2StatusEvent(phase="skipped")) + return + + await queue.put(Tier2StatusEvent(phase="discovering", lang=stack, url=awesome_url)) + try: + raw = await run_tinyfish_agent(awesome_url, TIER2A_PROMPT) + repo_urls = _extract_repo_urls(raw) + print(f"[Tier2] Discovered {len(repo_urls)} repos") + except Exception as exc: + print(f"[Tier2] Discovery error: {exc}") + await queue.put(Tier2StatusEvent(phase="error", error=str(exc))) + return + + if not repo_urls: + await queue.put(Tier2StatusEvent(phase="done", total=0)) + return + + repo_urls = repo_urls[:20] + total = len(repo_urls) + await queue.put(Tier2StatusEvent(phase="scanning", total=total)) + + # Pre-register all T2 repo sources so Live View can show tiles immediately + for ru in repo_urls: + owner_repo = "/".join(ru.rstrip("/").split("/")[-2:]) + sid = f"tier2-{owner_repo.replace('/', '-')}" + await queue.put({ + "type": "repo_source_registered", + "source_id": sid, + "label": owner_repo, + "tier": 2, + }) + + scanned_count = 0 + lock = asyncio.Lock() + + async def _check_repo(repo_url: str) -> None: + nonlocal scanned_count + owner_repo = "/".join(repo_url.rstrip("/").split("/")[-2:]) + source_id = f"tier2-{owner_repo.replace('/', '-')}" + issues_url = ( + f"{repo_url}/issues?q=is%3Aopen+" + "label%3Abounty+OR+bounty+in%3Atitle+OR+reward+in%3Atitle+OR+paid+in%3Atitle" + ) + # Notify frontend this repo agent has started (enables Live View tile) + await queue.put(AgentStartedEvent(source_id=source_id)) + try: + # Pass source_id + queue so streaming_url events reach the frontend + raw2 = await run_tinyfish_agent( + issues_url, TIER2B_PROMPT, + source_id=source_id, queue=queue, + ) + opps = parse_opportunities(raw2, source_id, owner_repo, tier=2) + except Exception as exc: + print(f"[Tier2] {owner_repo} error: {exc}") + opps = [] + async with lock: + scanned_count += 1 + sc = scanned_count + await queue.put(Tier2RepoDoneEvent( + repo=owner_repo, count=len(opps), + scanned=sc, total=total, opportunities=opps, + )) + + await asyncio.gather(*[_check_repo(u) for u in repo_urls]) + await queue.put(Tier2StatusEvent(phase="done", total=total)) + + +# ── Main orchestrator ────────────────────────────────────────────────────────── + +async def run_search(stack: str, keywords: str, min_amount: float): + """Async generator that yields SSE-ready dicts as agents complete.""" + tier1_sources = build_tier1_sources(stack, keywords) + all_sources = tier1_sources + list(GRANT_SOURCES) + + yield {"type": "sources", "sources": [s.model_dump() for s in all_sources]} + + queue: asyncio.Queue = asyncio.Queue() + _SENTINEL = object() + + async def _run_all(): + tasks = ( + [_run_tier1_agent(s, queue, stack, keywords) for s in tier1_sources] + + [_run_tier2(stack, queue)] + + [_run_tier3_agent(s, queue) for s in GRANT_SOURCES] + ) + await asyncio.gather(*tasks, return_exceptions=True) + await queue.put(_SENTINEL) + + runner = asyncio.create_task(_run_all()) + + while True: + item = await queue.get() + if item is _SENTINEL: + break + payload = item.model_dump() if hasattr(item, "model_dump") else item + if "opportunities" in payload and min_amount > 0: + payload["opportunities"] = [ + o for o in payload["opportunities"] + if (o.get("bounty_amount") or 0) >= min_amount + ] + payload["count"] = len(payload["opportunities"]) + yield payload + + await runner + yield {"type": "done"} diff --git a/git-paid/main.py b/git-paid/main.py new file mode 100644 index 00000000..203a2a76 --- /dev/null +++ b/git-paid/main.py @@ -0,0 +1,126 @@ +""" +main.py — GitPaid FastAPI server +Serves the static frontend and the /api/search SSE endpoint. +""" +from __future__ import annotations + +import json +import os +import traceback +from pathlib import Path + +from dotenv import load_dotenv +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import HTMLResponse, StreamingResponse +from fastapi.staticfiles import StaticFiles + +from agents import run_search +from models import SearchRequest + +load_dotenv() + +app = FastAPI(title="GitPaid", version="1.0.0") + +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_methods=["*"], + allow_headers=["*"], +) + +# Resolve static dir relative to this file +BASE_DIR = Path(__file__).resolve().parent +static_dir = BASE_DIR / "static" +static_dir.mkdir(exist_ok=True) + + +# Serve static assets (styles.css, app.js) +app.mount("/static", StaticFiles(directory=str(static_dir)), name="static") + + +@app.get("/", response_class=HTMLResponse) +async def index(): + html_path = static_dir / "index.html" + print(f"[GitPaid] Frontend path: {html_path} exists={html_path.exists()}") + if html_path.exists(): + return HTMLResponse(html_path.read_text(encoding="utf-8")) + return HTMLResponse(f"

Frontend not found

Expected: {html_path}

") + + +@app.post("/api/search") +async def search(req: SearchRequest): + """SSE endpoint — streams typed events as each agent completes.""" + api_key = os.getenv("TINYFISH_API_KEY", "").strip() + print(f"[GitPaid] Search: stack={req.stack!r} keywords={req.keywords!r} min={req.min_amount}") + print(f"[GitPaid] API key set: {bool(api_key)} length={len(api_key)}") + + if not api_key: + async def _no_key(): + msg = "TINYFISH_API_KEY is not set. Add it to a .env file in the gitpaid folder." + print(f"[GitPaid] ERROR: {msg}") + yield f"data: {json.dumps({'type': 'error', 'message': msg})}\n\n" + yield f"data: {json.dumps({'type': 'done'})}\n\n" + return StreamingResponse(_no_key(), media_type="text/event-stream") + + async def _stream(): + try: + async for event in run_search(req.stack, req.keywords, req.min_amount): + etype = event.get("type", "?") + extra = "" + if "count" in event: + extra = f" count={event['count']}" + if etype == "tier2_status": + extra = f" phase={event.get('phase')}" + if etype == "agent_error": + extra = f" err={event.get('error')}" + print(f"[GitPaid] -> {etype}{extra}") + yield f"data: {json.dumps(event)}\n\n" + except Exception as e: + print(f"[GitPaid] Stream exception: {e}") + traceback.print_exc() + yield f"data: {json.dumps({'type': 'error', 'message': str(e)})}\n\n" + yield f"data: {json.dumps({'type': 'done'})}\n\n" + + return StreamingResponse( + _stream(), + media_type="text/event-stream", + headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}, + ) + + +@app.get("/health") +async def health(): + api_key = os.getenv("TINYFISH_API_KEY", "").strip() + return { + "status": "ok", + "api_key_configured": bool(api_key), + "api_key_preview": (api_key[:6] + "...") if api_key else "NOT SET", + "env_file_exists": (BASE_DIR / ".env").exists(), + "index_html_exists": (static_dir / "index.html").exists(), + } + + +@app.get("/api/test-agent") +async def test_agent(): + """Single-agent smoke test using the official TinyFish Python SDK.""" + from tinyfish import AsyncTinyFish, CompleteEvent, RunStatus + if not os.getenv("TINYFISH_API_KEY"): + return {"ok": False, "error": "TINYFISH_API_KEY not set"} + try: + async with AsyncTinyFish() as client: + async with client.agent.stream( + url="https://algora.io/bounties", + goal='Return the page title as JSON: {"title": "string value here"}', + ) as stream: + async for event in stream: + if isinstance(event, CompleteEvent): + return {"ok": True, "status": str(event.status), "result": event.result_json} + return {"ok": False, "error": "Stream ended without CompleteEvent"} + except Exception as e: + return {"ok": False, "error": str(e), "trace": traceback.format_exc()} + + +if __name__ == "__main__": + import uvicorn + uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=True) diff --git a/git-paid/models.py b/git-paid/models.py new file mode 100644 index 00000000..d13c884b --- /dev/null +++ b/git-paid/models.py @@ -0,0 +1,81 @@ +from __future__ import annotations +from typing import Literal, Optional +from pydantic import BaseModel, Field + + +class SearchRequest(BaseModel): + stack: str = "Python" + keywords: str = "" + min_amount: float = 0 + + +class Opportunity(BaseModel): + id: str + type: Literal["bounty", "grant"] + tier: Literal[1, 2, 3] + source: str + source_label: str + title: str + repo: Optional[str] = None + url: str + bounty_amount: Optional[float] = None + currency: Optional[str] = None + skills: list[str] = Field(default_factory=list) + difficulty: Optional[str] = None + deadline: Optional[str] = None + description: Optional[str] = None + labels: list[str] = Field(default_factory=list) + + +# ── SSE event payloads ───────────────────────────────────────────────────────── + +class SourceMeta(BaseModel): + id: str + label: str + tier: Literal[1, 2, 3] + url: str + + +class SourcesEvent(BaseModel): + type: Literal["sources"] = "sources" + sources: list[SourceMeta] + + +class AgentStartedEvent(BaseModel): + type: Literal["agent_started"] = "agent_started" + source_id: str + + +class AgentCompleteEvent(BaseModel): + type: Literal["agent_complete"] = "agent_complete" + source_id: str + count: int + opportunities: list[Opportunity] + + +class AgentErrorEvent(BaseModel): + type: Literal["agent_error"] = "agent_error" + source_id: str + error: str + + +class Tier2StatusEvent(BaseModel): + type: Literal["tier2_status"] = "tier2_status" + phase: Literal["discovering", "scanning", "done", "skipped", "error"] + lang: Optional[str] = None + url: Optional[str] = None + total: Optional[int] = None + error: Optional[str] = None + + +class Tier2RepoDoneEvent(BaseModel): + type: Literal["tier2_repo_done"] = "tier2_repo_done" + repo: str + count: int + scanned: int + total: int + opportunities: list[Opportunity] + + +class DoneEvent(BaseModel): + type: Literal["done"] = "done" diff --git a/git-paid/requirements.txt b/git-paid/requirements.txt new file mode 100644 index 00000000..f742272b --- /dev/null +++ b/git-paid/requirements.txt @@ -0,0 +1,5 @@ +fastapi==0.115.6 +uvicorn[standard]==0.34.0 +tinyfish # Official TinyFish Python SDK +python-dotenv==1.0.1 +pydantic==2.10.4 \ No newline at end of file diff --git a/git-paid/static/app.js b/git-paid/static/app.js new file mode 100644 index 00000000..edfceb0f --- /dev/null +++ b/git-paid/static/app.js @@ -0,0 +1,540 @@ +/* ── GitPaid — app.js ───────────────────────────────────────────────────────── */ + +// ── State ───────────────────────────────────────────────────────────────────── +let allOpps = []; +let agentStates = {}; // id → { label, status, count, tier, streamingUrl } +let t2Total = 0; +let t2Scanned = 0; +let agentsRun = 0; +let activeReader = null; +let filters = { type: 'all', tier: 'all' }; +let currentView = 'results'; // 'results' | 'live' +let liveFilter = 'all'; // 'all' | 't1t3' | 't2' + +// ── Helpers ─────────────────────────────────────────────────────────────────── +function fmt(amount, currency) { + if (!amount) return null; + const s = currency === 'EUR' ? '€' : '$'; + if (amount >= 1_000_000) return s + (amount / 1_000_000).toFixed(1) + 'M'; + if (amount >= 1_000) return s + (amount / 1_000).toFixed(0) + 'k'; + return s + Math.round(amount).toLocaleString(); +} + +function pop(id) { + const el = document.getElementById(id); + if (!el) return; + el.classList.remove('pop'); + void el.offsetWidth; + el.classList.add('pop'); + setTimeout(() => el.classList.remove('pop'), 400); +} + +// ── View toggle ─────────────────────────────────────────────────────────────── +function setView(view) { + currentView = view; + document.getElementById('results-view').style.display = view === 'results' ? 'block' : 'none'; + document.getElementById('live-view').style.display = view === 'live' ? 'block' : 'none'; + document.querySelectorAll('.view-tab').forEach(b => b.classList.remove('active')); + document.getElementById('tab-' + view).classList.add('active'); + renderLiveGrid(); +} + +function setLiveFilter(f, btn) { + liveFilter = f; + document.querySelectorAll('.live-filter-btn').forEach(b => b.classList.remove('active')); + btn.classList.add('active'); + renderLiveGrid(); +} + +// ── Live View grid ──────────────────────────────────────────────────────────── +/** + * Smart incremental updater — NEVER replaces existing iframes. + * - New agents → create and append a tile + * - Existing agents → patch header + footer only, leave iframe untouched + * - Filter changes → hide/show tiles without destroying them + */ +function renderLiveGrid() { + const grid = document.getElementById('live-grid'); + if (!grid) return; + + const entries = Object.entries(agentStates); + + // Show the empty state if nothing has been registered yet + if (!entries.length) { + if (!grid.querySelector('.live-empty')) { + grid.innerHTML = ` +
+
🤖
+
No agents running yet — hit Search to start
+
`; + } + return; + } + + // Remove empty placeholder once agents arrive + const emptyEl = grid.querySelector('.live-empty'); + if (emptyEl) emptyEl.remove(); + + entries.forEach(([id, s]) => { + const visible = ( + liveFilter === 'all' || + (liveFilter === 't1t3' && (s.tier === 1 || s.tier === 3)) || + (liveFilter === 't2' && s.tier === 2) + ); + + let tile = document.getElementById(`tile-${id}`); + + // ── Create tile if it doesn't exist yet ────────────────────────────────── + if (!tile) { + const tierClass = ['', 'tb1', 'tb2', 'tb3'][s.tier] || ''; + tile = document.createElement('div'); + tile.className = 'live-tile'; + tile.id = `tile-${id}`; + tile.innerHTML = ` +
+
+
+
+ Waiting for agent… +
+
+ `; + grid.appendChild(tile); + } + + // Show/hide based on current filter + tile.style.display = visible ? '' : 'none'; + if (!visible) return; + + // ── Patch header (status badge + LIVE label) — always safe to replace ──── + const hasStream = !!s.streamingUrl; + const isLive = hasStream && s.status === 'scraping'; + const tierClass = ['', 'tb1', 'tb2', 'tb3'][s.tier] || ''; + const statusBadge = { + pending: `Pending`, + scraping: `Scraping`, + done: `Done`, + error: `Error`, + }[s.status] || ''; + + document.getElementById(`hdr-${id}`).innerHTML = ` +
T${s.tier}
+
${s.label}
+ ${isLive ? `LIVE` : statusBadge}`; + + tile.classList.toggle('live-tile--active', isLive); + + // ── Inject iframe ONCE when streaming_url first arrives ────────────────── + // We check for an existing iframe — if one is already there, leave it alone. + const body = document.getElementById(`body-${id}`); + const existingIframe = body.querySelector('iframe'); + + if (hasStream && !existingIframe) { + // Replace placeholder with iframe — happens exactly once per agent + body.innerHTML = ` +
+ +
+
+
+
+ ⛶ Full Screen +
+
`; + + // Hide spinner once iframe loads + const iframe = body.querySelector('iframe'); + iframe.addEventListener('load', () => { + const sp = document.getElementById(`spinner-${id}`); + if (sp) sp.style.display = 'none'; + }); + + } else if (!hasStream && !existingIframe) { + // Update placeholder text to reflect current status + const ph = document.getElementById(`ph-${id}`); + if (ph) { + const inner = + s.status === 'pending' ? `
Waiting for agent…` : + s.status === 'scraping' ? `
Starting browser session…` : + s.status === 'done' ? `
Completed${s.count > 0 ? ` · +${s.count} found` : ''}` : + `
Agent errored`; + ph.innerHTML = inner; + } + } + + // ── Update result count footer ──────────────────────────────────────────── + const cntEl = document.getElementById(`cnt-${id}`); + if (cntEl) { + if (s.count > 0) { + cntEl.textContent = `+${s.count} opportunit${s.count !== 1 ? 'ies' : 'y'} found`; + cntEl.style.display = ''; + } else { + cntEl.style.display = 'none'; + } + } + }); +} + +function clearPreview() {} // sidebar preview removed + +// ── Render: agent sidebar list ──────────────────────────────────────────────── +function renderAgents() { + const list = document.getElementById('agent-list'); + const entries = Object.entries(agentStates).filter(([, s]) => s.tier !== 2); + if (!entries.length) { list.innerHTML = ''; return; } + + list.innerHTML = entries.map(([id, s]) => { + const icon = s.tier === 3 ? '🏛️' : '🔎'; + const tl = ['', 'T1', 'T2', 'T3'][s.tier] || ''; + const badge = { + pending: `Pending`, + scraping: `Scraping`, + done: `Done`, + error: `Error`, + }[s.status] || ''; + + return ` +
+ ${icon} +
+
${s.label}
+
${tl}
+
+ ${badge} + ${s.count > 0 ? '+' + s.count : ''} +
`; + }).join(''); +} + +// ── Render: stats ───────────────────────────────────────────────────────────── +function renderStats() { + document.getElementById('s-total').textContent = allOpps.length; + document.getElementById('s-bounties').textContent = allOpps.filter(o => o.type === 'bounty').length; + document.getElementById('s-grants').textContent = allOpps.filter(o => o.type === 'grant').length; + document.getElementById('s-agents').textContent = agentsRun; + + const rm = document.getElementById('results-meta'); + rm.style.display = allOpps.length ? 'flex' : 'none'; + document.getElementById('r-showing').textContent = getFiltered().length; + document.getElementById('r-total').textContent = allOpps.length; +} + +// ── Render: results grid ────────────────────────────────────────────────────── +function getFiltered() { + let list = [...allOpps]; + if (filters.type !== 'all') list = list.filter(o => o.type === filters.type); + if (filters.tier !== 'all') list = list.filter(o => String(o.tier) === filters.tier); + const sort = document.getElementById('sort-select').value; + if (sort === 'amount_desc') list.sort((a, b) => (b.bounty_amount || 0) - (a.bounty_amount || 0)); + else if (sort === 'amount_asc') list.sort((a, b) => (a.bounty_amount || 0) - (b.bounty_amount || 0)); + else if (sort === 'tier_asc') list.sort((a, b) => a.tier - b.tier); + else list.reverse(); + return list; +} + +function renderResults() { + renderStats(); + document.getElementById('skel-area').innerHTML = ''; + const visible = getFiltered(); + + document.getElementById('results-grid').innerHTML = visible.map((o, i) => { + const a = fmt(o.bounty_amount, o.currency); + const tc = ['', 'tb1', 'tb2', 'tb3'][o.tier]; + const skills = (o.skills || []).slice(0, 4).map(s => `${s}`).join(''); + const diff = o.difficulty ? `${o.difficulty}` : ''; + const type = `${o.type}`; + const repo = o.repo ? `⎇ ${o.repo}` : ''; + const dl = o.deadline ? `
⏰ ${o.deadline}
` : ''; + const desc = o.description ? `
${o.description.slice(0, 120)}${o.description.length > 120 ? '…' : ''}
` : ''; + const amtHtml = a + ? `
${a}
${o.currency || 'USD'}
` + : `
TBD
`; + + return ` + + +
T${o.tier}
+
+
${o.title}
+
${o.source_label}${repo}
+
${type}${diff}${skills}
+ ${desc}${dl} +
+
${amtHtml}
+
`; + }).join(''); + + renderLiveGrid(); +} + +function showSkeletons(n = 4) { + document.getElementById('skel-area').innerHTML = Array(n).fill(0).map((_, i) => ` +
+
+
+
+
+
+
+
+
+
+
+
`).join(''); +} + +// ── SSE event handler ───────────────────────────────────────────────────────── +function handleEvent(evt) { + switch (evt.type) { + + case 'sources': + evt.sources.forEach(s => { + agentStates[s.id] = { label: s.label, status: 'pending', count: 0, tier: s.tier, streamingUrl: null }; + }); + renderAgents(); + renderLiveGrid(); + break; + + // Pre-register T2 repo agents so Live View shows tiles immediately + case 'repo_source_registered': + agentStates[evt.source_id] = { + label: evt.label, status: 'pending', count: 0, tier: 2, streamingUrl: null, + }; + renderLiveGrid(); + break; + + case 'agent_started': + if (agentStates[evt.source_id]) { + agentStates[evt.source_id].status = 'scraping'; + renderAgents(); + renderLiveGrid(); + } + break; + + // ── Live preview: TinyFish fires this as soon as browser session opens ──── + case 'streaming_url': + if (agentStates[evt.source_id]) { + agentStates[evt.source_id].streamingUrl = evt.url; + } + // Always update the live grid + renderLiveGrid(); + // Flash the Live View tab to draw attention + flashLiveTab(); + break; + + case 'agent_complete': + agentsRun++; + if (agentStates[evt.source_id]) { + agentStates[evt.source_id].status = 'done'; + agentStates[evt.source_id].count = evt.count; + } + allOpps.push(...(evt.opportunities || [])); + renderAgents(); + renderResults(); + pop('s-total'); + if (evt.count > 0) pop('s-bounties'); + renderLiveGrid(); + break; + + case 'agent_error': + agentsRun++; + if (agentStates[evt.source_id]) agentStates[evt.source_id].status = 'error'; + renderAgents(); + renderStats(); + renderLiveGrid(); + break; + + case 'tier2_status': { + const ph = document.getElementById('t2-phase'); + const p = document.getElementById('t2-prog'); + const l = document.getElementById('t2-lbl'); + ph.textContent = evt.phase; + if (evt.phase === 'scanning') { + t2Total = evt.total || 0; t2Scanned = 0; + l.textContent = `0 / ${t2Total} repos`; + p.style.width = '0%'; + } else if (evt.phase === 'done') { + l.textContent = `${t2Total} repos scanned`; + p.style.width = '100%'; + agentsRun++; + } else if (evt.phase === 'error') { + ph.style.color = 'var(--red)'; + l.textContent = evt.error || 'error'; + } + break; + } + + case 'tier2_repo_done': { + t2Scanned = evt.scanned; + const pct = t2Total ? Math.round(t2Scanned / t2Total * 100) : 0; + document.getElementById('t2-prog').style.width = pct + '%'; + document.getElementById('t2-lbl').textContent = `${t2Scanned} / ${t2Total} repos`; + const rl = document.getElementById('t2-repos'); + const row = document.createElement('div'); + row.className = 'repo-row'; + const hit = evt.count > 0; + row.innerHTML = `${evt.repo}${hit ? '+' + evt.count : '—'}`; + rl.appendChild(row); + rl.scrollTop = rl.scrollHeight; + + // Mark the repo agent as done in agentStates + const sid = `tier2-${evt.repo.replace('/', '-')}`; + if (agentStates[sid]) { + agentStates[sid].status = 'done'; + agentStates[sid].count = evt.count; + } + + allOpps.push(...(evt.opportunities || [])); + renderResults(); + renderLiveGrid(); + break; + } + + case 'done': + setSearching(false); + document.getElementById('skel-area').innerHTML = ''; + showStatus(`Search complete — ${allOpps.length} result${allOpps.length !== 1 ? 's' : ''} found`, 'info'); + setTimeout(hideStatus, 5000); + renderLiveGrid(); + break; + + case 'error': + setSearching(false); + document.getElementById('skel-area').innerHTML = ''; + showStatus('Error: ' + (evt.message || 'Unknown error'), 'error'); + break; + } +} + +// Flash the Live tab badge when a new stream arrives +let flashTimeout = null; +function flashLiveTab() { + const tab = document.getElementById('tab-live'); + if (!tab) return; + tab.classList.add('flash'); + clearTimeout(flashTimeout); + flashTimeout = setTimeout(() => tab.classList.remove('flash'), 1200); + + // Update live agent count badge + const liveCount = Object.values(agentStates).filter(s => s.streamingUrl && s.status === 'scraping').length; + const badge = document.getElementById('live-tab-count'); + if (badge) { + badge.textContent = liveCount > 0 ? liveCount : ''; + badge.style.display = liveCount > 0 ? 'inline-flex' : 'none'; + } +} + +// ── Search control ──────────────────────────────────────────────────────────── +async function startSearch() { + const stack = document.getElementById('stack').value; + const keywords = document.getElementById('keywords').value.trim(); + const minAmount = parseFloat(document.getElementById('min-amount').value) || 0; + + // Reset state + allOpps = []; agentStates = {}; t2Total = 0; t2Scanned = 0; agentsRun = 0; + document.getElementById('t2-repos').innerHTML = ''; + document.getElementById('t2-phase').textContent = 'idle'; + document.getElementById('t2-phase').style.color = ''; + document.getElementById('t2-prog').style.width = '0%'; + document.getElementById('t2-lbl').textContent = '—'; + document.getElementById('results-grid').innerHTML = ''; + clearPreview(); + + // Clear live grid completely so tile IDs don't carry over to next search + const lg = document.getElementById('live-grid'); + if (lg) { + lg.innerHTML = ` +
+
🤖
+
No agents running yet — hit Search to start
+
`; + } + const badge = document.getElementById('live-tab-count'); + if (badge) { badge.textContent = ''; badge.style.display = 'none'; } + + // Show layout + document.getElementById('empty-state').style.display = 'none'; + document.getElementById('main-layout').style.display = 'grid'; + document.getElementById('filter-bar').classList.add('visible'); + setSearching(true); + hideStatus(); + showSkeletons(5); + + try { + const resp = await fetch('/api/search', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ stack, keywords, min_amount: minAmount }), + }); + if (!resp.ok) throw new Error('HTTP ' + resp.status); + + const reader = resp.body.getReader(); + activeReader = reader; + const decoder = new TextDecoder(); + let buf = ''; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + buf += decoder.decode(value, { stream: true }); + const parts = buf.split('\n\n'); + buf = parts.pop(); + for (const part of parts) { + for (const line of part.split('\n')) { + if (line.startsWith('data: ')) { + try { handleEvent(JSON.parse(line.slice(6))); } catch (e) {} + } + } + } + } + } catch (err) { + if (err.name !== 'AbortError') { + setSearching(false); + document.getElementById('skel-area').innerHTML = ''; + showStatus('Connection error: ' + err.message, 'error'); + } + } +} + +function stopSearch() { + if (activeReader) { activeReader.cancel(); activeReader = null; } + setSearching(false); + document.getElementById('skel-area').innerHTML = ''; + showStatus('Search stopped.', 'info'); + setTimeout(hideStatus, 3000); +} + +function setSearching(on) { + const btn = document.getElementById('btn-search'); + btn.disabled = on; + btn.classList.toggle('loading', on); + document.getElementById('btn-stop').classList.toggle('visible', on); + if (!on) activeReader = null; +} + +// ── UI helpers ──────────────────────────────────────────────────────────────── +function showStatus(msg, type) { + const b = document.getElementById('status-bar'); + b.textContent = msg; + b.className = 'status-bar ' + type; +} +function hideStatus() { document.getElementById('status-bar').className = 'status-bar'; } + +function setFilter(type, val, btn) { + filters[type] = val; + document.querySelectorAll(`[data-filter="${type}"]`).forEach(b => b.classList.remove('active')); + btn.classList.add('active'); + renderResults(); +} + +['keywords', 'min-amount'].forEach(id => { + document.getElementById(id).addEventListener('keydown', e => { + if (e.key === 'Enter') startSearch(); + }); +}); diff --git a/git-paid/static/index.html b/git-paid/static/index.html new file mode 100644 index 00000000..0b6a8b4a --- /dev/null +++ b/git-paid/static/index.html @@ -0,0 +1,193 @@ + + + + + + GitPaid — OSS Bounty & Grant Finder + + + + + +
+ + +
+ +
+
Aggregators
+
Repo Discovery
+
Grants
+
+
+ + +
+
Search Parameters
+
+
+
Stack
+ +
+
+
Keywords
+ +
+
+
Min $
+ +
+ + +
+
+ + +
+ + + + + +
+
💰
+
Find paid open source work
+
+ Select your stack, add optional keywords, and hit Search. + Three agent tiers run concurrently — results stream in live. +
+
+
T1 · Aggregators
+
T2 · Repo Discovery
+
T3 · Grants
+
+
+ +
+ + + diff --git a/git-paid/static/styles.css b/git-paid/static/styles.css new file mode 100644 index 00000000..4f5de93e --- /dev/null +++ b/git-paid/static/styles.css @@ -0,0 +1,857 @@ +/* ── GitPaid — styles.css ───────────────────────────────────────────────────── */ + +:root { + --bg: #0D0E11; + --bg2: #111318; + --bg3: #16181E; + --bg4: #1C1F27; + --border: #22252E; + --border2: #2C3040; + --border3: #383D4F; + --green: #10B981; + --green-dim: #0D9268; + --green-bg: rgba(16,185,129,0.08); + --green-glow: rgba(16,185,129,0.15); + --amber: #F59E0B; + --amber-bg: rgba(245,158,11,0.08); + --blue: #60A5FA; + --blue-bg: rgba(96,165,250,0.08); + --purple: #A78BFA; + --purple-bg: rgba(167,139,250,0.08); + --red: #F87171; + --red-bg: rgba(248,113,113,0.08); + --text: #E8EAF0; + --text-2: #8B90A0; + --text-3: #50556A; + --shadow: 0 4px 24px rgba(0,0,0,0.4); + --shadow-sm: 0 2px 8px rgba(0,0,0,0.3); +} + +*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } +html { height: 100%; } + +body { + background: var(--bg); + color: var(--text); + font-family: 'JetBrains Mono', monospace; + font-size: 13px; + line-height: 1.6; + min-height: 100vh; + -webkit-font-smoothing: antialiased; +} + +/* Subtle top accent line */ +body::before { + content: ''; + position: fixed; + top: 0; left: 0; right: 0; + height: 1px; + background: linear-gradient(90deg, transparent, var(--green), transparent); + opacity: 0.6; + z-index: 100; +} + +.app { max-width: 1400px; margin: 0 auto; padding: 32px 24px 64px; } + +/* ── Header ──────────────────────────────────────────────────────────────────── */ +.header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 28px; + padding-bottom: 20px; + border-bottom: 1px solid var(--border); +} + +.logo { display: flex; align-items: center; gap: 14px; } + +.logo-wordmark { + font-family: 'Syne', sans-serif; + font-size: 22px; + font-weight: 800; + color: var(--text); + letter-spacing: -0.5px; +} +.logo-wordmark span { color: var(--green); } + +.logo-divider { width: 1px; height: 20px; background: var(--border2); } + +.logo-sub { + font-size: 11px; + font-weight: 500; + color: var(--text-3); + letter-spacing: 0.12em; + text-transform: uppercase; +} + +.header-right { display: flex; align-items: center; gap: 6px; } + +.tier-chip { + font-family: 'Syne', sans-serif; + font-size: 10px; + font-weight: 600; + letter-spacing: 0.06em; + padding: 4px 10px; + border-radius: 4px; + border: 1px solid var(--border2); + color: var(--text-3); + background: var(--bg2); + display: flex; align-items: center; gap: 5px; +} + +.chip-dot { width: 5px; height: 5px; border-radius: 50%; } +.cd-green { background: var(--green); box-shadow: 0 0 6px var(--green); } +.cd-blue { background: var(--blue); } +.cd-purple { background: var(--purple); } + +/* ── Search panel ────────────────────────────────────────────────────────────── */ +.search-panel { + background: var(--bg2); + border: 1px solid var(--border); + border-radius: 10px; + padding: 20px 24px; + margin-bottom: 18px; + transition: border-color 0.2s; +} +.search-panel:focus-within { border-color: var(--border3); } + +.panel-eyebrow { + font-family: 'Syne', sans-serif; + font-size: 10px; + font-weight: 700; + letter-spacing: 0.14em; + text-transform: uppercase; + color: var(--text-3); + margin-bottom: 16px; + display: flex; align-items: center; gap: 8px; +} +.panel-eyebrow::before { content: '//'; color: var(--green); } + +.search-row { display: flex; gap: 12px; align-items: flex-end; flex-wrap: wrap; } + +.field { display: flex; flex-direction: column; gap: 6px; } + +.field-label { + font-size: 10px; + font-weight: 600; + color: var(--text-3); + letter-spacing: 0.1em; + text-transform: uppercase; +} + +select, input[type="text"], input[type="number"] { + background: var(--bg3); + border: 1px solid var(--border2); + border-radius: 6px; + color: var(--text); + font-family: 'JetBrains Mono', monospace; + font-size: 13px; + padding: 9px 12px; + outline: none; + transition: border-color 0.15s, background 0.15s, box-shadow 0.15s; + -webkit-appearance: none; +} +select:hover, input:hover { border-color: var(--border3); } +select:focus, input:focus { + border-color: var(--green); + box-shadow: 0 0 0 3px var(--green-glow); + background: var(--bg4); +} +select { + cursor: pointer; + min-width: 155px; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'%3E%3Cpath fill='%2350556A' d='M5 6L0 0h10z'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 12px center; + padding-right: 30px; +} +input[type="text"] { min-width: 200px; } +input[type="number"] { width: 110px; } + +.btn-search { + background: var(--green); + color: #000; + border: none; + border-radius: 6px; + font-family: 'Syne', sans-serif; + font-size: 13px; + font-weight: 700; + letter-spacing: 0.04em; + padding: 9px 22px; + cursor: pointer; + display: flex; align-items: center; gap: 8px; + transition: background 0.15s, box-shadow 0.15s, transform 0.1s; +} +.btn-search:hover { background: #0ea572; box-shadow: 0 4px 16px rgba(16,185,129,0.3); } +.btn-search:active { transform: scale(0.97); } +.btn-search:disabled { background: var(--border2); color: var(--text-3); cursor: not-allowed; box-shadow: none; } + +.btn-spinner { + width: 13px; height: 13px; + border: 2px solid rgba(0,0,0,0.25); + border-top-color: #000; + border-radius: 50%; + animation: spin 0.65s linear infinite; + display: none; +} +.btn-search.loading .btn-spinner { display: block; } +.btn-search.loading .btn-icon { display: none; } +@keyframes spin { to { transform: rotate(360deg); } } + +.btn-stop { + background: transparent; + color: var(--red); + border: 1px solid var(--border2); + border-radius: 6px; + font-family: 'JetBrains Mono', monospace; + font-size: 12px; + padding: 9px 16px; + cursor: pointer; + display: none; + align-items: center; gap: 6px; + transition: border-color 0.15s, background 0.15s; +} +.btn-stop:hover { border-color: var(--red); background: var(--red-bg); } +.btn-stop.visible { display: inline-flex; } + +/* ── Status bar ──────────────────────────────────────────────────────────────── */ +.status-bar { + font-size: 12px; + padding: 10px 14px; + border-radius: 6px; + margin-bottom: 14px; + display: none; + align-items: center; + gap: 8px; + font-weight: 500; +} +.status-bar.info { background: var(--green-bg); border: 1px solid rgba(16,185,129,0.2); color: var(--green); display: flex; } +.status-bar.error { background: var(--red-bg); border: 1px solid rgba(248,113,113,0.2); color: var(--red); display: flex; } + +/* ── Filter bar ──────────────────────────────────────────────────────────────── */ +.filter-bar { + display: flex; gap: 6px; align-items: center; flex-wrap: wrap; + margin-bottom: 18px; + opacity: 0; transform: translateY(4px); + transition: opacity 0.3s, transform 0.3s; + pointer-events: none; +} +.filter-bar.visible { opacity: 1; transform: none; pointer-events: all; } + +.filter-group { display: flex; gap: 3px; align-items: center; } +.filter-sep { + font-size: 10px; color: var(--text-3); font-weight: 600; + letter-spacing: 0.1em; text-transform: uppercase; margin: 0 6px; +} + +.filter-btn { + background: var(--bg2); + border: 1px solid var(--border); + border-radius: 5px; + color: var(--text-3); + font-family: 'JetBrains Mono', monospace; + font-size: 11px; + padding: 5px 12px; + cursor: pointer; + transition: all 0.15s; + font-weight: 500; +} +.filter-btn:hover { border-color: var(--border3); color: var(--text-2); background: var(--bg3); } +.filter-btn.active { background: var(--bg4); border-color: var(--border3); color: var(--text); } + +.sort-select { + margin-left: auto; + background: var(--bg2); + border: 1px solid var(--border); + border-radius: 5px; + color: var(--text-3); + font-family: 'JetBrains Mono', monospace; + font-size: 11px; + padding: 5px 28px 5px 10px; + outline: none; cursor: pointer; + -webkit-appearance: none; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'%3E%3Cpath fill='%2350556A' d='M5 6L0 0h10z'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 10px center; + transition: border-color 0.15s; +} +.sort-select:focus { border-color: var(--border3); outline: none; } + +/* ── Layout ──────────────────────────────────────────────────────────────────── */ +.main-layout { + display: grid; + grid-template-columns: 272px 1fr; + gap: 16px; + align-items: start; +} +@media (max-width: 880px) { .main-layout { grid-template-columns: 1fr; } } + +/* ── Sidebar ─────────────────────────────────────────────────────────────────── */ +.sidebar { display: flex; flex-direction: column; gap: 12px; } + +.panel { + background: var(--bg2); + border: 1px solid var(--border); + border-radius: 10px; + overflow: hidden; + transition: border-color 0.2s; +} + +.panel-header { + display: flex; align-items: center; gap: 7px; + padding: 11px 16px; + border-bottom: 1px solid var(--border); + font-family: 'Syne', sans-serif; + font-size: 10px; + font-weight: 700; + letter-spacing: 0.12em; + text-transform: uppercase; + color: var(--text-3); +} + +/* Stats */ +.stats-grid { display: grid; grid-template-columns: 1fr 1fr; } + +.stat-cell { + padding: 16px; + text-align: center; + border-right: 1px solid var(--border); + border-bottom: 1px solid var(--border); + transition: background 0.2s; +} +.stat-cell:hover { background: var(--bg3); } +.stat-cell:nth-child(2n) { border-right: none; } +.stat-cell:nth-child(3), .stat-cell:nth-child(4) { border-bottom: none; } + +.stat-n { + font-family: 'Syne', sans-serif; + font-size: 26px; + font-weight: 800; + color: var(--text); + line-height: 1; + transition: color 0.3s, transform 0.3s; +} +.stat-n.pop { color: var(--green); transform: scale(1.2); } +.stat-l { font-size: 10px; font-weight: 500; color: var(--text-3); letter-spacing: 0.1em; text-transform: uppercase; margin-top: 4px; } + +/* Agent list */ +.agent-list { padding: 4px 0; } + +.agent-row { + display: flex; align-items: center; gap: 10px; + padding: 9px 16px; + border-bottom: 1px solid var(--border); + transition: background 0.15s; +} +.agent-row:last-child { border-bottom: none; } +.agent-row:hover { background: var(--bg3); } +.agent-icon { font-size: 14px; width: 18px; text-align: center; flex-shrink: 0; } +.agent-info { flex: 1; min-width: 0; } +.agent-name { font-size: 12px; font-weight: 500; color: var(--text); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.agent-tier { font-size: 10px; color: var(--text-3); font-weight: 400; } + +.status-pill { + font-size: 10px; font-weight: 600; padding: 2px 8px; border-radius: 4px; + letter-spacing: 0.04em; white-space: nowrap; flex-shrink: 0; +} +.sp-idle { background: var(--bg4); color: var(--text-3); border: 1px solid var(--border2); } +.sp-running { background: var(--amber-bg); color: var(--amber); border: 1px solid rgba(245,158,11,0.2); } +.sp-done { background: var(--green-bg); color: var(--green); border: 1px solid rgba(16,185,129,0.2); } +.sp-error { background: var(--red-bg); color: var(--red); border: 1px solid rgba(248,113,113,0.2); } + +.running-dot { + display: inline-block; width: 5px; height: 5px; + border-radius: 50%; background: var(--amber); + margin-right: 4px; + animation: blink 1.1s ease infinite; +} +@keyframes blink { 0%,100%{opacity:1} 50%{opacity:0.3} } + +.agent-count { font-size: 11px; color: var(--green); font-weight: 600; flex-shrink: 0; min-width: 22px; text-align: right; } + +/* Tier 2 panel */ +.t2-body { padding: 14px 16px; } +.t2-meta { display: flex; justify-content: space-between; font-size: 11px; margin-bottom: 10px; } +.t2-meta-label { color: var(--text-3); font-weight: 500; } +.t2-phase-val { color: var(--blue); font-weight: 600; } + +.prog-track { + background: var(--bg4); border: 1px solid var(--border); + border-radius: 99px; height: 4px; margin-bottom: 6px; overflow: hidden; +} +.prog-fill { + height: 100%; + background: linear-gradient(90deg, var(--green-dim), var(--green)); + border-radius: 99px; + transition: width 0.5s cubic-bezier(0.4,0,0.2,1); + width: 0%; + box-shadow: 0 0 8px var(--green-glow); +} +.prog-label { font-size: 10px; color: var(--text-3); margin-bottom: 10px; font-weight: 500; } + +.repo-scroll { max-height: 164px; overflow-y: auto; } +.repo-row { + display: flex; justify-content: space-between; align-items: center; + padding: 4px 0; border-bottom: 1px solid var(--border); font-size: 10px; +} +.repo-row:last-child { border-bottom: none; } +.repo-name { color: var(--text-2); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 168px; } +.repo-n { font-weight: 600; flex-shrink: 0; } +.repo-n.hit { color: var(--green); } +.repo-n.miss { color: var(--text-3); } + +/* ── Live Preview panel ──────────────────────────────────────────────────────── */ +.preview-panel { display: none; } +.preview-panel.visible { display: block; } + +.preview-header-row { + display: flex; align-items: center; justify-content: space-between; + gap: 8px; +} +.preview-source-label { + font-size: 10px; color: var(--blue); font-weight: 600; + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; + max-width: 160px; +} +.preview-live-badge { + font-size: 9px; font-weight: 700; letter-spacing: 0.08em; + padding: 2px 7px; border-radius: 3px; + background: var(--red-bg); color: var(--red); + border: 1px solid rgba(248,113,113,0.25); + display: flex; align-items: center; gap: 4px; + flex-shrink: 0; +} +.live-dot { + width: 5px; height: 5px; border-radius: 50%; background: var(--red); + animation: blink 1s ease infinite; +} + +.preview-iframe-wrap { + margin: 12px 0 0; + border-radius: 8px; + overflow: hidden; + border: 1px solid var(--border2); + background: var(--bg4); + position: relative; +} +.preview-iframe-wrap iframe { + width: 100%; + height: 280px; + border: none; + display: block; +} +/* Loading overlay shown before iframe loads */ +.preview-loading { + position: absolute; inset: 0; + display: flex; flex-direction: column; + align-items: center; justify-content: center; + gap: 10px; background: var(--bg3); + font-size: 11px; color: var(--text-3); +} +.preview-loading-spinner { + width: 20px; height: 20px; + border: 2px solid var(--border2); + border-top-color: var(--green); + border-radius: 50%; + animation: spin 0.8s linear infinite; +} +.preview-footer { + padding: 8px 16px; + font-size: 10px; color: var(--text-3); + display: flex; align-items: center; gap: 6px; + border-top: 1px solid var(--border); +} +.preview-footer a { + color: var(--blue); text-decoration: none; font-weight: 500; + transition: color 0.15s; +} +.preview-footer a:hover { color: var(--text); } + +/* ── Results area ────────────────────────────────────────────────────────────── */ +.results-area { min-height: 420px; } + +.results-meta { + display: none; + align-items: center; justify-content: space-between; + font-size: 11px; color: var(--text-3); font-weight: 500; + margin-bottom: 14px; +} +.results-meta strong { color: var(--text-2); } + +/* Empty state */ +.empty-state { + display: flex; flex-direction: column; align-items: center; justify-content: center; + min-height: 400px; text-align: center; gap: 16px; +} +.empty-icon-wrap { + width: 72px; height: 72px; + background: var(--bg2); border: 1px solid var(--border2); + border-radius: 18px; + display: flex; align-items: center; justify-content: center; + font-size: 30px; + animation: levitate 3s ease-in-out infinite; +} +@keyframes levitate { 0%,100%{transform:translateY(0)} 50%{transform:translateY(-7px)} } +.empty-headline { font-family: 'Syne', sans-serif; font-size: 18px; font-weight: 700; color: var(--text); } +.empty-body { font-size: 12px; color: var(--text-2); max-width: 300px; line-height: 1.8; } +.empty-chips { display: flex; gap: 8px; flex-wrap: wrap; justify-content: center; } +.empty-chip { + font-size: 10px; font-weight: 600; padding: 4px 12px; + border-radius: 5px; border: 1px solid var(--border2); + color: var(--text-3); background: var(--bg2); + letter-spacing: 0.06em; text-transform: uppercase; +} + +/* ── Result cards ────────────────────────────────────────────────────────────── */ +.results-grid { display: flex; flex-direction: column; gap: 8px; } + +.opp-card { + background: var(--bg2); + border: 1px solid var(--border); + border-radius: 10px; + padding: 16px 18px; + display: grid; + grid-template-columns: 34px 1fr auto; + gap: 14px; + align-items: start; + text-decoration: none; + color: inherit; + position: relative; + overflow: hidden; + transition: border-color 0.2s, background 0.2s, transform 0.2s, box-shadow 0.2s; + animation: rise 0.3s cubic-bezier(0.4,0,0.2,1) both; +} +@keyframes rise { from { opacity:0; transform:translateY(8px); } to { opacity:1; transform:none; } } + +.opp-card::after { + content: ''; + position: absolute; left: 0; top: 0; bottom: 0; width: 2px; + background: var(--green); + transform: scaleY(0); + transform-origin: bottom; + transition: transform 0.25s cubic-bezier(0.4,0,0.2,1); +} +.opp-card:hover { + border-color: var(--border2); + background: var(--bg3); + transform: translateY(-1px); + box-shadow: 0 8px 32px rgba(0,0,0,0.35); +} +.opp-card:hover::after { transform: scaleY(1); } + +.card-arrow { + position: absolute; top: 14px; right: 14px; + font-size: 11px; color: var(--text-3); + opacity: 0; + transition: opacity 0.2s, transform 0.2s; +} +.opp-card:hover .card-arrow { opacity: 1; transform: translate(1px,-1px); } + +.tbadge { + width: 34px; height: 34px; border-radius: 8px; + display: flex; align-items: center; justify-content: center; + font-family: 'Syne', sans-serif; font-size: 11px; font-weight: 700; + flex-shrink: 0; margin-top: 1px; +} +.tb1 { background: var(--green-bg); color: var(--green); border: 1px solid rgba(16,185,129,0.2); } +.tb2 { background: var(--blue-bg); color: var(--blue); border: 1px solid rgba(96,165,250,0.2); } +.tb3 { background: var(--purple-bg); color: var(--purple); border: 1px solid rgba(167,139,250,0.2); } + +.card-body { min-width: 0; } +.card-title { + font-family: 'Syne', sans-serif; + font-size: 14px; font-weight: 600; color: var(--text); + line-height: 1.4; margin-bottom: 6px; + transition: color 0.15s; word-break: break-word; +} +.opp-card:hover .card-title { color: var(--green); } + +.card-meta { + display: flex; flex-wrap: wrap; gap: 5px 12px; align-items: center; + font-size: 11px; color: var(--text-2); margin-bottom: 8px; +} +.meta-source { font-weight: 600; } +.meta-repo { color: var(--blue); font-size: 10px; display: flex; align-items: center; gap: 3px; } + +.card-tags { display: flex; gap: 5px; flex-wrap: wrap; } + +.ctag { + font-size: 10px; font-weight: 600; padding: 2px 8px; + border-radius: 4px; letter-spacing: 0.04em; border: 1px solid transparent; +} +.ctag-bounty { background: var(--green-bg); color: var(--green); border-color: rgba(16,185,129,0.2); } +.ctag-grant { background: var(--purple-bg); color: var(--purple); border-color: rgba(167,139,250,0.2); } +.ctag-skill { background: var(--bg4); color: var(--text-2); border-color: var(--border2); } +.ctag-beginner { background: var(--green-bg); color: var(--green); border-color: rgba(16,185,129,0.2); } +.ctag-intermediate { background: var(--amber-bg); color: var(--amber); border-color: rgba(245,158,11,0.2); } +.ctag-advanced { background: var(--red-bg); color: var(--red); border-color: rgba(248,113,113,0.2); } + +.card-desc { font-size: 11px; color: var(--text-2); margin-top: 6px; line-height: 1.6; } +.card-deadline { font-size: 10px; color: var(--amber); margin-top: 5px; font-weight: 600; display: flex; align-items: center; gap: 4px; } + +.card-amount { text-align: right; flex-shrink: 0; padding-top: 1px; } +.amount-main { + font-family: 'Syne', sans-serif; + font-size: 20px; font-weight: 800; color: var(--text); + line-height: 1; white-space: nowrap; letter-spacing: -0.3px; +} +.amount-main.sm { font-size: 16px; } +.amount-cur { font-size: 9px; font-weight: 600; color: var(--text-3); letter-spacing: 0.1em; text-transform: uppercase; margin-top: 3px; } +.amount-tbd { font-size: 10px; color: var(--text-3); background: var(--bg4); padding: 4px 8px; border-radius: 4px; border: 1px solid var(--border2); font-weight: 500; } + +/* ── Skeleton loader ─────────────────────────────────────────────────────────── */ +.skel { + background: linear-gradient(90deg, var(--bg3) 25%, var(--bg4) 50%, var(--bg3) 75%); + background-size: 200% 100%; + animation: shimmer 1.4s infinite; + border-radius: 5px; +} +@keyframes shimmer { to { background-position: -200% 0; } } + +.skel-card { + background: var(--bg2); border: 1px solid var(--border); border-radius: 10px; + padding: 16px 18px; display: flex; gap: 14px; align-items: flex-start; margin-bottom: 8px; +} + +/* ── Scrollbar ───────────────────────────────────────────────────────────────── */ +::-webkit-scrollbar { width: 4px; height: 4px; } +::-webkit-scrollbar-track { background: transparent; } +::-webkit-scrollbar-thumb { background: var(--border2); border-radius: 99px; } + +/* ── Live preview button (in agent rows) ─────────────────────────────────────── */ +.live-preview-btn { + font-family: 'JetBrains Mono', monospace; + font-size: 10px; font-weight: 600; + padding: 2px 8px; border-radius: 4px; + border: 1px solid rgba(96,165,250,0.3); + background: var(--blue-bg); color: var(--blue); + cursor: pointer; flex-shrink: 0; + transition: all 0.15s; + letter-spacing: 0.03em; +} +.live-preview-btn:hover { background: rgba(96,165,250,0.15); border-color: var(--blue); } +.live-preview-btn.active { background: rgba(96,165,250,0.2); border-color: var(--blue); } + +/* ── Live preview button (agent sidebar rows) ────────────────────────────────── */ +.live-preview-btn { + font-family: 'JetBrains Mono', monospace; + font-size: 10px; font-weight: 600; + padding: 2px 8px; border-radius: 4px; + border: 1px solid rgba(96,165,250,0.3); + background: var(--blue-bg); color: var(--blue); + cursor: pointer; flex-shrink: 0; + transition: all 0.15s; letter-spacing: 0.03em; +} +.live-preview-btn:hover { background: rgba(96,165,250,0.15); border-color: var(--blue); } + +/* ── View toolbar (tabs + filters above results area) ────────────────────────── */ +.view-toolbar { + display: flex; + flex-direction: column; + gap: 12px; + margin-bottom: 16px; +} + +.view-tabs { + display: flex; + gap: 4px; + align-items: center; + border-bottom: 1px solid var(--border); + padding-bottom: 0; +} + +.view-tab { + font-family: 'Syne', sans-serif; + font-size: 12px; font-weight: 700; + letter-spacing: 0.04em; + padding: 9px 16px; + border: none; border-bottom: 2px solid transparent; + background: transparent; color: var(--text-3); + cursor: pointer; + display: flex; align-items: center; gap: 7px; + transition: color 0.15s, border-color 0.15s; + margin-bottom: -1px; +} +.view-tab:hover { color: var(--text-2); } +.view-tab.active { color: var(--text); border-bottom-color: var(--green); } +.view-tab.flash { color: var(--blue); border-bottom-color: var(--blue); } + +.live-tab-badge { + font-size: 9px; font-weight: 700; + background: var(--red); color: #000; + width: 16px; height: 16px; border-radius: 50%; + display: inline-flex; align-items: center; justify-content: center; + animation: blink 1s ease infinite; +} + +/* ── Filter bar (inside view-toolbar, Results view only) ─────────────────────── */ +.filter-bar { + display: flex; gap: 6px; align-items: center; flex-wrap: wrap; + opacity: 0; transform: translateY(4px); + transition: opacity 0.3s, transform 0.3s; + pointer-events: none; +} +.filter-bar.visible { opacity: 1; transform: none; pointer-events: all; } + +/* ── Live View layout ────────────────────────────────────────────────────────── */ +.live-view-header { + display: flex; + align-items: center; + justify-content: space-between; + flex-wrap: wrap; + gap: 12px; + margin-bottom: 16px; + padding-bottom: 14px; + border-bottom: 1px solid var(--border); +} + +.live-view-desc { + font-size: 11px; color: var(--text-3); font-weight: 400; + max-width: 500px; line-height: 1.7; +} + +.live-filter-bar { + display: flex; gap: 4px; align-items: center; flex-shrink: 0; +} + +.live-filter-btn { + background: var(--bg2); + border: 1px solid var(--border); + border-radius: 5px; + color: var(--text-3); + font-family: 'JetBrains Mono', monospace; + font-size: 11px; font-weight: 500; + padding: 5px 12px; cursor: pointer; + transition: all 0.15s; +} +.live-filter-btn:hover { border-color: var(--border3); color: var(--text-2); } +.live-filter-btn.active { background: var(--bg4); border-color: var(--border3); color: var(--text); } + +/* ── Live grid ───────────────────────────────────────────────────────────────── */ +.live-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); + gap: 14px; + align-items: start; +} + +.live-empty { + grid-column: 1 / -1; + display: flex; flex-direction: column; align-items: center; + justify-content: center; gap: 12px; + min-height: 200px; + font-size: 12px; color: var(--text-3); + text-align: center; +} +.live-empty-icon { font-size: 28px; opacity: 0.4; } + +/* ── Live tile ───────────────────────────────────────────────────────────────── */ +.live-tile { + background: var(--bg2); + border: 1px solid var(--border); + border-radius: 10px; + overflow: hidden; + transition: border-color 0.2s, box-shadow 0.2s; + animation: rise 0.35s cubic-bezier(0.4,0,0.2,1) both; +} +.live-tile--active { + border-color: rgba(96,165,250,0.4); + box-shadow: 0 0 0 1px rgba(96,165,250,0.15), 0 4px 20px rgba(0,0,0,0.35); +} + +.live-tile-header { + display: flex; align-items: center; gap: 8px; + padding: 10px 14px; + border-bottom: 1px solid var(--border); +} + +.live-tile-name { + flex: 1; min-width: 0; + font-family: 'JetBrains Mono', monospace; + font-size: 11px; font-weight: 500; color: var(--text); + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; +} + +/* ── Iframe wrapper ─────────────────────────────────────── */ +.live-tile-iframe-wrap { + position: relative; + background: var(--bg4); + overflow: hidden; +} + +/* The iframe fills the tile - rendered on top of spinner */ +.live-tile-iframe-wrap iframe { + width: 100%; + height: 220px; + border: none; + display: block; + position: relative; + z-index: 2; /* above spinner so it shows once loaded */ + background: var(--bg4); +} + +/* Spinner sits behind iframe, visible only while iframe src is loading */ +.live-tile-spinner { + position: absolute; + inset: 0; z-index: 1; + display: flex; align-items: center; justify-content: center; + background: var(--bg3); + pointer-events: none; +} + +/* Hover bar — full-screen link fades in at bottom of tile */ +.live-tile-hover-bar { + position: absolute; + bottom: 0; left: 0; right: 0; + z-index: 3; + padding: 8px 10px; + background: linear-gradient(transparent, rgba(0,0,0,0.65)); + display: flex; justify-content: flex-end; + opacity: 0; + transition: opacity 0.2s; + pointer-events: none; +} +.live-tile-iframe-wrap:hover .live-tile-hover-bar { + opacity: 1; + pointer-events: all; +} + +.live-fullscreen-btn { + font-family: 'JetBrains Mono', monospace; + font-size: 11px; font-weight: 600; + padding: 5px 12px; border-radius: 5px; + background: rgba(13,14,17,0.85); + color: var(--blue); + border: 1px solid rgba(96,165,250,0.35); + text-decoration: none; + transition: background 0.15s, border-color 0.15s; + backdrop-filter: blur(4px); +} +.live-fullscreen-btn:hover { background: var(--bg2); border-color: var(--blue); } + +/* Placeholder (no stream yet) */ +.live-tile-placeholder { + height: 220px; + display: flex; flex-direction: column; + align-items: center; justify-content: center; + gap: 10px; + font-size: 11px; color: var(--text-3); + background: var(--bg3); +} +.live-placeholder-spinner { + width: 18px; height: 18px; + border: 2px solid var(--border2); + border-top-color: var(--text-3); + border-radius: 50%; + animation: spin 1.2s linear infinite; +} +.live-done-icon { font-size: 22px; color: var(--green); } +.live-err-icon { font-size: 22px; color: var(--red); } + +/* Count footer */ +.live-tile-count { + padding: 7px 14px; + font-size: 10px; font-weight: 600; color: var(--green); + border-top: 1px solid var(--border); + letter-spacing: 0.04em; +}