Spaces:
Sleeping
Sleeping
| from __future__ import annotations | |
| import re | |
| from datetime import date, datetime | |
| from typing import Any | |
| from urllib.parse import urlparse | |
| import gradio as gr | |
| from fastapi import Body | |
| from fastapi.responses import HTMLResponse | |
| from pydantic import BaseModel, ValidationError | |
| from src.dataset_store import DatasetStore, DatasetStoreError | |
| from src.llm_client import LLMClient, LLMError | |
| from src.olostep_client import OlostepClient, OlostepError | |
| from src.schemas import Opportunity, ResearchResult, UserProfile | |
| class ResearchRequest(BaseModel): | |
| origin: str | |
| applying_for: str | |
| field_of_study: str | |
| preferences: str = "" | |
| class SaveRequest(BaseModel): | |
| session_id: str | |
| opportunity_title: str | |
| STORE = DatasetStore() | |
| OLOSTEP = OlostepClient() | |
| RANKER = LLMClient() | |
| SESSIONS: dict[str, ResearchResult] = {} | |
| def build_app() -> gr.Server: | |
| app = gr.Server() | |
| def research_api(profile: dict[str, Any]) -> dict[str, Any]: | |
| return research_payload(ResearchRequest(**profile)) | |
| def save_api(session_id: str, opportunity_title: str) -> dict[str, Any]: | |
| return save_payload(SaveRequest(session_id=session_id, opportunity_title=opportunity_title)) | |
| async def homepage() -> str: | |
| return FRONTEND_HTML | |
| async def health() -> dict[str, str]: | |
| return {"status": "ok"} | |
| async def config() -> dict[str, Any]: | |
| return config_status() | |
| async def history(q: str = "") -> dict[str, Any]: | |
| if q and q.strip(): | |
| return {"records": search_history_records(q.strip()), "query": q.strip()} | |
| return {"records": history_records()} | |
| async def research(request: ResearchRequest = Body(...)) -> dict[str, Any]: | |
| return research_payload(request) | |
| async def save(request: SaveRequest = Body(...)) -> dict[str, Any]: | |
| return save_payload(request) | |
| return app | |
| def research_payload(request: ResearchRequest) -> dict[str, Any]: | |
| try: | |
| profile = UserProfile(**request.model_dump()) | |
| except ValidationError as exc: | |
| return { | |
| "ok": False, | |
| "message": f"Input error: {exc.errors()[0]['msg']}", | |
| "result": None, | |
| "history": history_records(), | |
| } | |
| errors: list[str] = [] | |
| live_research_enabled = OLOSTEP.enabled and RANKER.enabled | |
| if not live_research_enabled: | |
| return { | |
| "ok": False, | |
| "message": "Live research is unavailable. Configure OLOSTEP_API_KEY and HF_TOKEN to search real funding sources.", | |
| "result": None, | |
| "history": history_records(), | |
| } | |
| try: | |
| pages = OLOSTEP.search(profile, limit=15) | |
| except OlostepError as exc: | |
| errors.append(str(exc)) | |
| pages = [] | |
| opportunities: list[Opportunity] = [] | |
| if pages: | |
| base = opportunities_from_sources(profile, pages) | |
| ranked: list[Opportunity] = [] | |
| if RANKER.enabled: | |
| try: | |
| ranked = RANKER.rank_opportunities(profile, pages[:8]) | |
| except LLMError as exc: | |
| errors.append(str(exc)) | |
| opportunities = select_providers(base, ranked) if ranked else [] | |
| if not opportunities: | |
| opportunities = filter_direct_providers(base) | |
| opportunities = enrich_opportunities(opportunities, pages) | |
| opportunities = drop_expired_opportunities(opportunities) | |
| if not opportunities: | |
| message = "No direct funding-provider pages were found for this profile. Try broadening the origin, degree, field, or preferences." | |
| if errors: | |
| message += " Service note: " + " | ".join(errors[:2]) | |
| return { | |
| "ok": False, | |
| "message": message, | |
| "result": None, | |
| "history": history_records(), | |
| } | |
| else: | |
| result = ResearchResult( | |
| profile=profile, | |
| query=profile.search_query(), | |
| opportunities=opportunities, | |
| errors=errors, | |
| ) | |
| try: | |
| STORE.record_search(result) | |
| except DatasetStoreError as exc: | |
| result.errors.append(str(exc)) | |
| SESSIONS[result.session_id] = result | |
| return { | |
| "ok": True, | |
| "message": result_status(result), | |
| "result": result.model_dump(mode="json"), | |
| "history": history_records(), | |
| } | |
| def save_payload(request: SaveRequest) -> dict[str, Any]: | |
| result = SESSIONS.get(request.session_id) | |
| if not result: | |
| return {"ok": False, "message": "Research session was not found.", "history": history_records()} | |
| opportunity = find_opportunity(result, request.opportunity_title) | |
| if not opportunity: | |
| return {"ok": False, "message": "Selected opportunity was not found.", "history": history_records()} | |
| try: | |
| STORE.save_opportunity(result, opportunity) | |
| message = f"Saved: {opportunity.title}" | |
| except DatasetStoreError as exc: | |
| message = f"Saved locally, but dataset write failed: {exc}" | |
| return { | |
| "ok": True, | |
| "message": message, | |
| "opportunity": opportunity.model_dump(mode="json"), | |
| "history": history_records(), | |
| } | |
| def find_opportunity(result: ResearchResult, title: str) -> Opportunity | None: | |
| return next((opportunity for opportunity in result.opportunities if opportunity.title == title), None) | |
| _MONTHS = ( | |
| r"(?:Jan(?:uary)?|Feb(?:ruary)?|Mar(?:ch)?|Apr(?:il)?|May|Jun(?:e)?|" | |
| r"Jul(?:y)?|Aug(?:ust)?|Sep(?:t(?:ember)?)?|Oct(?:ober)?|Nov(?:ember)?|Dec(?:ember)?)" | |
| ) | |
| _DATE_PATTERN = ( | |
| r"\d{1,2}\s+" + _MONTHS + r"\.?\s+\d{4}" | |
| r"|" + _MONTHS + r"\.?\s+\d{1,2},?\s+\d{4}" | |
| r"|\d{4}-\d{2}-\d{2}" | |
| r"|\d{1,2}/\d{1,2}/\d{2,4}" | |
| r"|\d{1,2}[\s-]" + _MONTHS + r"[\s-]\d{4}" | |
| r"|(?:Spring|Summer|Fall|Autumn|Winter)\s+\d{4}" | |
| ) | |
| _MONEY_PATTERN = re.compile( | |
| r"[$€£]\s?\d[\d,]*(?:\.\d+)?\s?(?:thousand|million|mill|k|m|bn)?" | |
| r"(?:\s?/\s?(?:year|yr|month|mo|annum|day|week))?" | |
| r"|(?:USD|EUR|GBP|CA\$|AU\$)\s?\d[\d,]*(?:\.\d+)?\s?(?:thousand|million)?" | |
| r"(?:\s?(?:per|/)\s?(?:year|month|annum))?" | |
| r"|(?:up to|worth|award(?:ed)? of|prize of|stipend of|value of|grant(?:s)? of|" | |
| r"scholarship of|fellowship of|totaling|totalling|maximum of|amount of)\s+[$€£]?\s?\d[\d,]*(?:\.\d+)?" | |
| r"\s?(?:thousand|million|k|m)?" | |
| r"|[$€£]\s?\d[\d,]*(?:\.\d+)?\s?(?:to|[\-–—])\s?[$€£]?\s?\d[\d,]*" | |
| r"|full[\s-]?(?:tuition|ride|scholarship|funding|coverage)", | |
| re.IGNORECASE, | |
| ) | |
| _DEADLINE_KEYWORDS = ( | |
| r"(?:deadline|apply by|application(?:s)?\s+(?:close|end|due|deadline|open)|" | |
| r"closes?\s+(?:on|date)?|due(?:\s+date|s)?|submit(?:ted)?\s+by|submission\s+deadline|" | |
| r"closing\s+date|last\s+date|expires?|next\s+deadline|final\s+deadline|deadline\s+for|" | |
| r"applications?\s+(?:will\s+close|must\s+be\s+received))" | |
| ) | |
| _DEADLINE_PATTERN = re.compile( | |
| _DEADLINE_KEYWORDS + r"[^A-Za-z0-9]{0,28}(" + _DATE_PATTERN + r")", | |
| re.IGNORECASE, | |
| ) | |
| _ROLLING_PATTERN = re.compile( | |
| r"\brolling\s+(?:deadline|admissions?|applications?|basis|review)\b|\brolling\b", | |
| re.IGNORECASE, | |
| ) | |
| _FUTURE_YEAR = re.compile(r"20(?:2[4-9]|3[0-1])") | |
| _ANY_DATE = re.compile(_DATE_PATTERN, re.IGNORECASE) | |
| def _clean(value: str) -> str: | |
| return re.sub(r"\s+", " ", value).strip().rstrip(".,;:") | |
| def _extract_funding(text: str) -> str | None: | |
| if not text: | |
| return None | |
| match = _MONEY_PATTERN.search(text) | |
| return _clean(match.group(0)) if match else None | |
| def _extract_deadline(text: str) -> str | None: | |
| if not text: | |
| return None | |
| if _ROLLING_PATTERN.search(text): | |
| return "Rolling" | |
| match = _DEADLINE_PATTERN.search(text) | |
| if match: | |
| return _clean(match.group(1)) | |
| for date_match in _ANY_DATE.finditer(text): | |
| candidate = date_match.group(0) | |
| if _FUTURE_YEAR.search(candidate): | |
| return _clean(candidate) | |
| return None | |
| def _url_key(url: Any) -> str: | |
| parsed = urlparse(str(url)) | |
| host = (parsed.netloc or "").lower() | |
| host = host[4:] if host.startswith("www.") else host | |
| return f"{host}{(parsed.path or '').rstrip('/')}".lower() | |
| def _parse_deadline_date(deadline: str) -> date | None: | |
| if not deadline or deadline.strip().lower() in ("rolling", "not listed", "open", "ongoing", ""): | |
| return None | |
| candidates = [deadline.strip(), *(match.group(0) for match in _ANY_DATE.finditer(deadline))] | |
| for candidate in candidates: | |
| normalized = re.sub(r"(?<=\d)(st|nd|rd|th)\b", "", candidate, flags=re.IGNORECASE) | |
| for fmt in ( | |
| "%B %d, %Y", "%b %d, %Y", "%Y-%m-%d", "%d %B %Y", "%d %b %Y", | |
| "%m/%d/%Y", "%d/%m/%Y", "%m/%d/%y", "%d/%m/%y", | |
| ): | |
| try: | |
| return datetime.strptime(normalized, fmt).date() | |
| except ValueError: | |
| continue | |
| return None | |
| def drop_expired_opportunities(opportunities: list[Opportunity]) -> list[Opportunity]: | |
| """Drop clearly closed or expired opportunities; retain unclear deadlines.""" | |
| today = date.today() | |
| alive: list[Opportunity] = [] | |
| for opp in opportunities: | |
| status = opp.deadline.strip().lower() | |
| if re.search(r"\b(closed|expired|deadline passed|applications? ended)\b", status): | |
| continue | |
| d = _parse_deadline_date(opp.deadline) | |
| if d is None or d >= today: | |
| alive.append(opp) | |
| return alive | |
| def _host_of(url: Any) -> str: | |
| host = (urlparse(str(url)).netloc or "").lower() | |
| return host[4:] if host.startswith("www.") else host | |
| def enrich_opportunities(opportunities: list[Opportunity], pages: list[Any]) -> list[Opportunity]: | |
| """Fill in missing funding amount / deadline from the scraped source content.""" | |
| by_key = {_url_key(page.url): page for page in pages} | |
| by_host: dict[str, Any] = {} | |
| for page in pages: | |
| by_host.setdefault(_host_of(page.url), page) | |
| enriched: list[Opportunity] = [] | |
| for opportunity in opportunities: | |
| page = by_key.get(_url_key(opportunity.source_url)) or by_host.get(_host_of(opportunity.source_url)) | |
| text = "" | |
| if page: | |
| text = " ".join(part for part in (getattr(page, "snippet", ""), getattr(page, "content", "")) if part) | |
| updates: dict[str, Any] = {} | |
| if opportunity.funding_amount == "Not listed": | |
| found = _extract_funding(text) | |
| if found: | |
| updates["funding_amount"] = found | |
| if opportunity.deadline == "Not listed": | |
| found = _extract_deadline(text) | |
| if found: | |
| updates["deadline"] = found | |
| enriched.append(opportunity.model_copy(update=updates) if updates else opportunity) | |
| return enriched | |
| def select_providers( | |
| base: list[Opportunity], ranked: list[Opportunity] | |
| ) -> list[Opportunity]: | |
| """Layer AI analysis onto sources, retaining credible sources the AI omitted.""" | |
| by_key = {_url_key(opp.source_url): opp for opp in ranked} | |
| by_host: dict[str, Opportunity] = {} | |
| for opp in ranked: | |
| by_host.setdefault(_host_of(opp.source_url), opp) | |
| selected: list[Opportunity] = [] | |
| for opp in base: | |
| if _is_aggregator(opp.source_url, opp.title): | |
| continue | |
| match = by_key.get(_url_key(opp.source_url)) or by_host.get(_host_of(opp.source_url)) | |
| if match is None: | |
| if _has_funding_evidence(opp): | |
| selected.append(opp) | |
| continue | |
| updates: dict[str, Any] = {} | |
| if isinstance(match.match_score, int) and match.match_score: | |
| updates["match_score"] = match.match_score | |
| for field in ("why_it_fits", "eligibility_summary"): | |
| value = getattr(match, field, "") | |
| if value and value.strip(): | |
| updates[field] = value | |
| if match.concerns: | |
| updates["concerns"] = list(match.concerns) | |
| if match.funding_amount != "Not listed": | |
| updates["funding_amount"] = match.funding_amount | |
| if match.deadline != "Not listed": | |
| updates["deadline"] = match.deadline | |
| selected.append(opp.model_copy(update=updates) if updates else opp) | |
| return selected | |
| def _has_funding_evidence(opportunity: Opportunity) -> bool: | |
| text = " ".join( | |
| ( | |
| opportunity.title, | |
| str(opportunity.source_url), | |
| opportunity.eligibility_summary, | |
| ) | |
| ).lower() | |
| return any( | |
| term in text | |
| for term in ("scholarship", "grant", "fellowship", "financial aid", "funding", "stipend") | |
| ) | |
| _AGGREGATOR_PARTS = ( | |
| "portal.com", "mastersportal", "scholarshipportal", "bachelorsportal", "phdportal", | |
| "studyportals", "shortcoursesportal", "distancelearningportal", "abroadin.com", | |
| "scholarships.com", "scholarship.com", "fastweb.com", "cappex.com", "niche.com", | |
| "goingmerry.com", "scholarshipdb", "scholars4dev", "scholarshipaz", "scholarshippositions", | |
| "indeed.com", "glassdoor.com", "jooble.org", "simplyhired.com", "ziprecruiter.com", | |
| "monster.com", "medium.com", "wordpress.com", "blogspot.com", "quora.com", "youtube.com", | |
| "wemakescholars.com", | |
| "computerscience.org", | |
| "timesconsultant.com", | |
| "tiktok.com", "facebook.com", "instagram.com", "linkedin.com", "reddit.com", | |
| "pinterest.com", "twitter.com", "x.com", "wikipedia.org", "wikihow.com", | |
| ) | |
| _GENERIC_SCHOLARSHIP_TITLES = ( | |
| "top scholarships", "best scholarships", "scholarships list", | |
| "study scholarships in", "scholarships for students", "scholarships for pakistani", | |
| ) | |
| def _is_aggregator(url: Any, title: str = "") -> bool: | |
| text = str(url).lower() | |
| if any(part in text for part in _AGGREGATOR_PARTS): | |
| return True | |
| parsed = urlparse(text) | |
| path = parsed.path or "" | |
| if "/search" in path and "scholarship" in path: | |
| return True | |
| if "/list-of-" in path or "/list_of" in path: | |
| return True | |
| host = parsed.netloc.removeprefix("www.") | |
| institutional = any(marker in host for marker in (".edu", ".gov", ".ac.")) | |
| if not institutional and any(phrase in title.lower() for phrase in _GENERIC_SCHOLARSHIP_TITLES): | |
| return True | |
| return False | |
| def filter_direct_providers(opportunities: list[Opportunity]) -> list[Opportunity]: | |
| """Heuristic fallback: drop obvious aggregators / boards / listing pages.""" | |
| return [opp for opp in opportunities if not _is_aggregator(opp.source_url, opp.title)] | |
| def opportunities_from_sources(profile: UserProfile, pages: list[Any]) -> list[Opportunity]: | |
| opportunities: list[Opportunity] = [] | |
| for index, page in enumerate(pages[:15]): | |
| hostname = urlparse(str(page.url)).hostname or "Funding provider" | |
| organization = hostname.removeprefix("www.").lower() | |
| summary_source = page.snippet or page.content[:260] | |
| eligibility_summary = ( | |
| summary_source.strip() | |
| if summary_source.strip() | |
| else "Review the provider page for eligibility requirements, deadlines, award amount, and application rules." | |
| ) | |
| try: | |
| opportunities.append( | |
| Opportunity( | |
| title=page.title or f"Funding opportunity from {organization}", | |
| organization=organization, | |
| source_url=page.url, | |
| deadline="Not listed", | |
| funding_amount="Not listed", | |
| eligibility_region="Not listed", | |
| match_score=max(55, 82 - (index * 5)), | |
| eligibility_summary=eligibility_summary[:500], | |
| why_it_fits=( | |
| "This is a real web result returned for the submitted origin, target degree, field, and funding search context. " | |
| "Open the source to verify exact fit before applying." | |
| ), | |
| concerns=[ | |
| "The source is relevant to the search, but deadline, amount, and eligibility must be verified on the provider page." | |
| ], | |
| ) | |
| ) | |
| except ValueError: | |
| continue | |
| return opportunities | |
| def result_status(result: ResearchResult) -> str: | |
| message = f"Live research: found {len(result.opportunities)} opportunities for {result.profile.field_of_study}." | |
| if result.errors: | |
| message += " Showing real source results; verify details on provider websites." | |
| return message | |
| def history_records() -> list[dict[str, Any]]: | |
| records = [ | |
| record | |
| for record in STORE.recent_records(limit=100) | |
| if not is_demo_history_record(record.source_url, record.opportunity_title) | |
| ] | |
| return [record.model_dump(mode="json") for record in _top_result_per_session(records)[-20:]] | |
| def search_history_records(query: str) -> list[dict[str, Any]]: | |
| records = [ | |
| record | |
| for record in STORE.search_records(query, limit=50) | |
| if not is_demo_history_record(record.source_url, record.opportunity_title) | |
| ] | |
| return [record.model_dump(mode="json") for record in _top_result_per_session(records)[-50:]] | |
| def _top_result_per_session(records: list[Any]) -> list[Any]: | |
| """Return one highest-scoring search result per session, preserving session order.""" | |
| top_by_session: dict[str, Any] = {} | |
| order: list[str] = [] | |
| for record in records: | |
| if record.session_id not in top_by_session: | |
| top_by_session[record.session_id] = record | |
| order.append(record.session_id) | |
| elif record.match_score > top_by_session[record.session_id].match_score: | |
| top_by_session[record.session_id] = record | |
| return [top_by_session[session_id] for session_id in order] | |
| def is_demo_history_record(source_url: str, title: str) -> bool: | |
| text = f"{source_url} {title}".lower() | |
| parsed = urlparse(source_url) | |
| host = parsed.netloc.lower() | |
| demo_hosts = ("example.org", "example.com", "example.edu", "demo.", "localhost") | |
| return "demo" in text or any(part in host for part in demo_hosts) | |
| def config_status() -> dict[str, Any]: | |
| missing: list[str] = [] | |
| if not OLOSTEP.enabled: | |
| missing.append("OLOSTEP_API_KEY") | |
| if not RANKER.enabled: | |
| missing.append("HF_TOKEN") | |
| if not STORE.enabled: | |
| missing.extend(["HF_TOKEN", "HF_DATASET_REPO"]) | |
| return { | |
| "live_research_enabled": OLOSTEP.enabled and RANKER.enabled, | |
| "dataset_storage_enabled": STORE.enabled, | |
| "missing": missing, | |
| } | |
| FRONTEND_HTML = r""" | |
| <!doctype html> | |
| <html lang="en"> | |
| <head> | |
| <meta charset="utf-8" /> | |
| <meta name="viewport" content="width=device-width, initial-scale=1" /> | |
| <title>ScholarScope</title> | |
| <link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 36 32'%3E%3Cdefs%3E%3ClinearGradient id='g' x1='0' y1='0' x2='1' y2='1'%3E%3Cstop offset='0' stop-color='%2314b8a6'/%3E%3Cstop offset='.55' stop-color='%23059669'/%3E%3Cstop offset='1' stop-color='%2316a34a'/%3E%3C/linearGradient%3E%3C/defs%3E%3Crect width='36' height='32' rx='7' fill='url(%23g)'/%3E%3Cpath d='M18 5L33 12L18 19L3 12Z' fill='white'/%3E%3Cpath d='M7 15V20.5C7 23.5 12 25 18 25 24 25 29 23.5 29 20.5V15' stroke='white' stroke-width='2.6' stroke-linecap='round' fill='none'/%3E%3Cpath d='M33 12V18' stroke='white' stroke-width='2' stroke-linecap='round'/%3E%3Ccircle cx='33' cy='20' r='2' fill='white'/%3E%3C/svg%3E" /> | |
| <style> | |
| :root { | |
| --bg: #f4f6fb; | |
| --ink: #0f172a; | |
| --muted: #64748b; | |
| --faint: #94a3b8; | |
| --line: #e4e8f0; | |
| --panel: #ffffff; | |
| --panel-soft: #f8fafc; | |
| --accent: #059669; | |
| --accent-dark: #047857; | |
| --accent-soft: #ecfdf5; | |
| --teal: #0d9488; | |
| --teal-soft: #ccfbf1; | |
| --green: #16a34a; | |
| --green-soft: #dcfce7; | |
| --rose: #e11d48; | |
| --amber: #b45309; | |
| --amber-soft: #fef3c7; | |
| --shadow: 0 20px 50px -20px rgba(15, 23, 42, .25); | |
| --shadow-sm: 0 6px 18px -8px rgba(15, 23, 42, .18); | |
| --radius: 16px; | |
| --ease: cubic-bezier(.2, .8, .2, 1); | |
| } | |
| * { box-sizing: border-box; } | |
| html, body { height: 100%; max-width: 100%; } | |
| body { | |
| margin: 0; | |
| overflow: hidden; | |
| color: var(--ink); | |
| font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; | |
| background: | |
| radial-gradient(1100px 540px at 12% -8%, #d1fae5 0%, transparent 60%), | |
| radial-gradient(900px 520px at 100% 0%, #cffafe 0%, transparent 55%), | |
| radial-gradient(800px 600px at 50% 120%, #fef3c7 0%, transparent 60%), | |
| var(--bg); | |
| } | |
| body::before { | |
| content: ""; | |
| position: fixed; inset: 0; pointer-events: none; z-index: 0; | |
| background-image: | |
| linear-gradient(rgba(15,23,42,.04) 1px, transparent 1px), | |
| linear-gradient(90deg, rgba(15,23,42,.04) 1px, transparent 1px); | |
| background-size: 44px 44px; | |
| mask-image: radial-gradient(circle at 50% 30%, #000, transparent 80%); | |
| } | |
| a { color: var(--accent); font-weight: 700; text-decoration: none; } | |
| a:hover { text-decoration: underline; } | |
| button, input, select, textarea { font: inherit; color: inherit; } | |
| button { | |
| min-height: 42px; | |
| border: 1px solid var(--line); | |
| border-radius: 10px; | |
| background: #fff; | |
| cursor: pointer; | |
| padding: 9px 14px; | |
| font-weight: 700; | |
| transition: transform .16s var(--ease), box-shadow .16s var(--ease), background .16s var(--ease), border-color .16s var(--ease), color .16s var(--ease); | |
| } | |
| button:hover:not(:disabled):not(.primary) { transform: translateY(-1px); box-shadow: var(--shadow-sm); } | |
| button.primary:hover:not(:disabled) { transform: translateY(-1px); } | |
| button:active:not(:disabled) { transform: translateY(0); } | |
| button.primary { | |
| background: linear-gradient(135deg, var(--accent), #0d9488); | |
| border-color: transparent; color: #fff; | |
| } | |
| button.primary:hover:not(:disabled) { filter: brightness(1.05); } | |
| button.ghost { background: transparent; border-color: transparent; color: var(--muted); } | |
| button.ghost:hover:not(:disabled) { background: var(--panel-soft); color: var(--ink); } | |
| button.chip { | |
| min-height: 34px; padding: 7px 12px; border-radius: 999px; | |
| background: #fff; border-color: var(--line); color: var(--muted); | |
| font-size: 12.5px; font-weight: 700; | |
| } | |
| button.chip:hover:not(:disabled) { color: var(--accent); border-color: var(--accent); } | |
| button:disabled { cursor: not-allowed; opacity: .55; } | |
| .field { display: grid; gap: 6px; } | |
| .field > span { color: var(--muted); font-size: 11.5px; font-weight: 800; letter-spacing: .03em; text-transform: uppercase; } | |
| fieldset.field { border: 0; padding: 0; margin: 0; } | |
| .checkgroup { display: flex; flex-wrap: wrap; gap: 7px; } | |
| .check { position: relative; display: inline-block; } | |
| .check input { position: absolute; opacity: 0; inset: 0; width: 100%; height: 100%; margin: 0; cursor: pointer; } | |
| .check > span { | |
| display: inline-block; padding: 8px 13px; border-radius: 999px; | |
| border: 1px solid var(--line); background: #fff; color: var(--muted); | |
| font-size: 12.5px; font-weight: 700; cursor: pointer; user-select: none; | |
| transition: border-color .16s var(--ease), background .16s var(--ease), color .16s var(--ease); | |
| } | |
| .check:hover > span { border-color: rgba(5,150,105,.5); color: var(--ink); } | |
| .check input:checked + span { background: var(--accent-soft); border-color: var(--accent); color: var(--accent-dark); } | |
| .check input:focus-visible + span { box-shadow: 0 0 0 3px rgba(5,150,105,.16); } | |
| input, select, textarea { | |
| width: 100%; border: 1px solid var(--line); border-radius: 10px; | |
| background: #fff; padding: 10px 12px; outline: none; font-size: 14px; | |
| transition: border-color .16s var(--ease), box-shadow .16s var(--ease); | |
| } | |
| input:focus, select:focus, textarea:focus { border-color: var(--accent); box-shadow: 0 0 0 3px rgba(5,150,105,.18); } | |
| textarea { min-height: 52px; resize: none; } | |
| .app { position: relative; z-index: 1; height: 100vh; height: 100dvh; min-width: 0; display: grid; grid-template-rows: auto 1fr; gap: 12px; padding: 12px; max-width: 1480px; margin: 0 auto; } | |
| /* Top bar */ | |
| .topbar { | |
| display: flex; align-items: center; gap: 14px; | |
| padding: 10px 14px; | |
| background: rgba(255,255,255,.72); | |
| border: 1px solid var(--line); | |
| border-radius: var(--radius); | |
| backdrop-filter: blur(14px); | |
| box-shadow: var(--shadow-sm); | |
| } | |
| .brand { display: flex; align-items: center; gap: 12px; min-width: 0; } | |
| .logo { | |
| width: 44px; height: 44px; border-radius: 13px; flex: 0 0 auto; | |
| display: grid; place-items: center; | |
| background: linear-gradient(135deg, #14b8a6, #059669 55%, #16a34a); | |
| box-shadow: 0 10px 22px -8px rgba(5, 150, 105, .6); | |
| } | |
| .logo svg { width: 26px; height: 26px; display: block; } | |
| .brand h1 { margin: 0; font-size: 18px; letter-spacing: -.02em; line-height: 1.1; } | |
| .brand p { margin: 0; color: var(--muted); font-size: 12px; font-weight: 600; } | |
| .topbar-spacer { flex: 1 1 auto; } | |
| .status-pill { | |
| display: inline-flex; align-items: center; gap: 8px; | |
| max-width: 420px; | |
| padding: 8px 13px; border-radius: 999px; | |
| background: var(--panel-soft); border: 1px solid var(--line); | |
| color: var(--muted); font-size: 12.5px; font-weight: 700; | |
| white-space: nowrap; overflow: hidden; text-overflow: ellipsis; | |
| } | |
| .status-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--faint); flex: 0 0 auto; } | |
| .status-pill.ok { color: #15803d; background: #f0fdf4; border-color: #bbf7d0; } | |
| .status-pill.ok .status-dot { background: var(--green); box-shadow: 0 0 0 4px var(--green-soft); } | |
| .status-pill.live { color: #0f766e; background: #f0fdfa; border-color: #99f6e4; } | |
| .status-pill.live .status-dot { background: var(--teal); box-shadow: 0 0 0 4px var(--teal-soft); animation: pulse 1.6s ease-in-out infinite; } | |
| .status-pill.error { color: #be123c; background: #fff1f2; border-color: #fecdd3; } | |
| .status-pill.error .status-dot { background: var(--rose); box-shadow: 0 0 0 4px #ffe4e6; } | |
| .iconbtn { | |
| position: relative; min-height: 40px; padding: 0 14px; border-radius: 10px; | |
| display: inline-flex; align-items: center; gap: 8px; font-size: 13px; | |
| } | |
| .badge { | |
| position: absolute; top: -6px; right: -6px; min-width: 18px; height: 18px; padding: 0 5px; | |
| border-radius: 999px; background: var(--accent); color: #fff; font-size: 11px; font-weight: 800; | |
| display: grid; place-items: center; border: 2px solid #fff; | |
| } | |
| .badge[data-count="0"] { display: none; } | |
| /* Main */ | |
| main.workspace { display: grid; grid-template-columns: minmax(340px, 390px) minmax(0, 1fr); gap: 12px; min-width: 0; min-height: 0; } | |
| .panel { | |
| background: var(--panel); border: 1px solid var(--line); border-radius: var(--radius); | |
| box-shadow: var(--shadow-sm); display: flex; flex-direction: column; min-width: 0; min-height: 0; | |
| } | |
| .panel-head { display: flex; align-items: center; justify-content: space-between; gap: 10px; padding: 14px 16px; border-bottom: 1px solid var(--line); } | |
| .panel-head h2 { margin: 0; font-size: 15px; letter-spacing: -.01em; } | |
| .panel-head .sub { color: var(--muted); font-size: 12px; font-weight: 600; } | |
| /* Form panel */ | |
| .form-body { padding: 14px 16px; overflow: auto; min-width: 0; display: flex; flex-direction: column; gap: 12px; } | |
| .form-grid { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 10px; min-width: 0; } | |
| .full { grid-column: 1 / -1; } | |
| .collapsible { border: 1px solid var(--line); border-radius: 12px; overflow: hidden; background: var(--panel-soft); } | |
| .collapsible > summary { | |
| list-style: none; cursor: pointer; padding: 11px 14px; | |
| display: flex; align-items: center; justify-content: space-between; | |
| font-size: 12.5px; font-weight: 800; color: var(--muted); letter-spacing: .03em; text-transform: uppercase; | |
| } | |
| .collapsible > summary::-webkit-details-marker { display: none; } | |
| .collapsible > summary .chev { transition: transform .2s var(--ease); } | |
| .collapsible[open] > summary .chev { transform: rotate(180deg); } | |
| .collapsible-body { padding: 12px 14px 14px; display: grid; grid-template-columns: 1fr 1fr; gap: 10px; border-top: 1px solid var(--line); } | |
| .chips { display: flex; flex-wrap: wrap; gap: 7px; } | |
| .actions { display: grid; grid-template-columns: 1fr auto; gap: 8px; margin-top: 2px; } | |
| .actions .primary { font-size: 14.5px; } | |
| /* Results panel */ | |
| .results-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; } | |
| .count { color: var(--muted); font-size: 12.5px; font-weight: 700; } | |
| .progress { height: 4px; background: var(--line); border-radius: 999px; overflow: hidden; position: relative; } | |
| .progress > div { width: 0%; height: 100%; background: linear-gradient(90deg, var(--accent), var(--teal)); transition: width .15s linear; } | |
| .progress-time { position: absolute; right: 0; top: -18px; font-size: 11px; color: var(--muted); font-weight: 600; font-variant-numeric: tabular-nums; } | |
| .results-body { padding: 14px 16px 18px; overflow: auto; min-height: 0; } | |
| .welcome { | |
| height: 100%; min-height: 320px; display: grid; place-items: center; text-align: center; gap: 6px; | |
| color: var(--muted); padding: 30px; | |
| } | |
| .welcome .glow { | |
| width: 74px; height: 74px; border-radius: 22px; margin-bottom: 6px; | |
| display: grid; place-items: center; font-size: 34px; | |
| background: linear-gradient(135deg, #ecfdf5, #cffafe); border: 1px solid var(--line); | |
| } | |
| .welcome h3 { margin: 0; color: var(--ink); font-size: 19px; letter-spacing: -.02em; } | |
| .welcome p { margin: 0; max-width: 420px; font-size: 13.5px; line-height: 1.5; } | |
| .empty { padding: 18px; text-align: center; color: var(--muted); font-size: 13.5px; border: 1px dashed var(--line); border-radius: 12px; background: #fff; } | |
| .loading { | |
| position: relative; overflow: hidden; border-radius: 14px; min-height: 200px; padding: 18px; | |
| color: var(--muted); background: #fff; border: 1px solid var(--line); | |
| } | |
| .loading::after { | |
| content: ""; position: absolute; inset: 0; transform: translateX(-100%); | |
| background: linear-gradient(90deg, transparent, rgba(5,150,105,.09), transparent); | |
| animation: shimmer 1.2s linear infinite; | |
| } | |
| .cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 12px; align-content: start; } | |
| .opportunity-card { | |
| position: relative; padding: 15px; display: grid; gap: 9px; align-content: start; | |
| border: 1px solid var(--line); border-radius: 14px; background: #fff; | |
| transition: transform .2s var(--ease), border-color .2s var(--ease), box-shadow .2s var(--ease); | |
| animation: cardIn .4s var(--ease) both; | |
| } | |
| .opportunity-card::before { content: ""; position: absolute; left: 0; top: 14px; bottom: 14px; width: 3px; border-radius: 3px; background: linear-gradient(var(--accent), #0d9488); opacity: .85; } | |
| .opportunity-card:hover, .opportunity-card.selected { transform: translateY(-3px); border-color: rgba(5,150,105,.45); box-shadow: var(--shadow-sm); } | |
| .opportunity-card h3 { margin: 0; font-size: 15.5px; line-height: 1.3; letter-spacing: -.01em; padding-left: 8px; overflow-wrap: anywhere; } | |
| .tag-row { display: flex; flex-wrap: wrap; gap: 6px; padding-left: 8px; } | |
| .score { border-radius: 999px; padding: 3px 9px; font-size: 11.5px; font-weight: 900; background: var(--teal-soft); color: #115e59; } | |
| .score.high { background: var(--accent-soft); color: var(--accent-dark); } | |
| .meta { margin: 0; color: var(--muted); font-size: 12.5px; line-height: 1.45; padding-left: 8px; overflow-wrap: anywhere; } | |
| .card-foot { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-top: 2px; padding-left: 8px; } | |
| .card-foot button { min-height: 34px; padding: 7px 12px; font-size: 12.5px; border-radius: 9px; } | |
| .opportunity-card h3 a { color: inherit; text-decoration: none; } | |
| .opportunity-card h3 a:hover { color: var(--accent); } | |
| .card-foot .visit { | |
| display: inline-flex; align-items: center; gap: 4px; min-height: 34px; padding: 7px 14px; | |
| border-radius: 9px; font-size: 12.5px; font-weight: 800; | |
| background: var(--accent-soft); color: var(--accent-dark); border: 1px solid var(--accent); | |
| transition: background .16s var(--ease), color .16s var(--ease); | |
| } | |
| .card-foot .visit:hover { background: var(--accent); color: #fff; text-decoration: none; } | |
| /* Slide-over */ | |
| .sidenav { position: fixed; inset: 0; z-index: 30; display: none; } | |
| .sidenav.open { display: block; } | |
| .sidenav-backdrop { position: absolute; inset: 0; background: rgba(9, 16, 29, .45); backdrop-filter: blur(2px); animation: fade .2s ease; } | |
| .sidenav-card { | |
| position: absolute; right: 0; top: 0; bottom: 0; width: min(400px, 100%); | |
| background: #fff; border-left: 1px solid var(--line); box-shadow: var(--shadow); | |
| display: flex; flex-direction: column; animation: slideIn .26s var(--ease) both; | |
| } | |
| .sidenav-head { display: flex; align-items: center; justify-content: space-between; padding: 14px 16px; border-bottom: 1px solid var(--line); } | |
| .search-box { padding: 10px 14px; border-bottom: 1px solid var(--line); } | |
| .search-box input { padding: 9px 12px; font-size: 13px; } | |
| .tabs { display: flex; gap: 4px; padding: 10px 12px 0; } | |
| .tabs button { flex: 1; min-height: 38px; border-radius: 10px 10px 0 0; border: 1px solid transparent; background: transparent; color: var(--muted); font-size: 13px; } | |
| .tabs button.active { background: #fff; color: var(--ink); border-color: var(--line); border-bottom-color: #fff; box-shadow: 0 -2px 6px -4px rgba(15,23,42,.12); } | |
| .tab-panels { flex: 1; overflow: auto; padding: 12px; } | |
| .tab-panel[hidden] { display: none; } | |
| .list { display: grid; gap: 9px; } | |
| .list-item { border: 1px solid var(--line); border-radius: 12px; padding: 11px 12px; background: #fff; transition: border-color .16s var(--ease), transform .16s var(--ease); } | |
| .list-item:hover { border-color: rgba(5,150,105,.4); transform: translateX(2px); } | |
| .list-item strong { display: block; font-size: 13.5px; line-height: 1.35; margin-bottom: 3px; } | |
| .list-item .meta { padding: 0; } | |
| .history-button { width: 100%; text-align: left; background: #fff; } | |
| /* Drawer (details) */ | |
| .drawer { position: fixed; inset: 0; z-index: 40; display: none; place-items: center; padding: 18px; background: rgba(9,16,29,.5); backdrop-filter: blur(2px); } | |
| .drawer.open { display: grid; } | |
| .drawer-card { width: min(640px, 100%); max-height: 88vh; overflow: auto; background: #fff; border: 1px solid var(--line); border-radius: 18px; box-shadow: var(--shadow); animation: popIn .24s var(--ease) both; } | |
| .drawer-top { position: sticky; top: 0; background: #fff; display: flex; justify-content: space-between; gap: 12px; align-items: start; padding: 18px 20px 12px; border-bottom: 1px solid var(--line); } | |
| .drawer-body { padding: 16px 20px 22px; display: grid; gap: 12px; } | |
| .drawer-body h2 { margin: 0; font-size: 19px; letter-spacing: -.02em; } | |
| .detail-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 9px; } | |
| .fact { border: 1px solid var(--line); border-radius: 11px; padding: 10px 11px; background: var(--panel-soft); } | |
| .fact strong { display: block; font-size: 10.5px; color: var(--muted); margin-bottom: 3px; text-transform: uppercase; letter-spacing: .04em; } | |
| .drawer-body p { margin: 0; line-height: 1.5; font-size: 13.5px; } | |
| .drawer-body ul { margin: 0; padding-left: 18px; line-height: 1.5; font-size: 13.5px; } | |
| .x-btn { min-height: 36px; min-width: 36px; padding: 0; border-radius: 10px; font-size: 16px; } | |
| /* Warning banner (slim, non-blocking) */ | |
| .warn-banner { display: none; align-items: center; gap: 10px; padding: 10px 14px; border-radius: 12px; background: var(--amber-soft); border: 1px solid #fcd34d; color: #78350f; font-size: 12.5px; font-weight: 700; } | |
| .warn-banner.show { display: flex; } | |
| .warn-banner button { min-height: 32px; padding: 5px 10px; font-size: 12px; border-radius: 8px; background: #fff; border-color: #fcd34d; color: #78350f; } | |
| @keyframes pulse { 0%,100% { transform: scale(1); } 50% { transform: scale(1.25); } } | |
| @keyframes fade { from { opacity: 0; } to { opacity: 1; } } | |
| @keyframes cardIn { from { opacity: 0; transform: translateY(10px) scale(.985); } to { opacity: 1; transform: none; } } | |
| @keyframes slideIn { from { transform: translateX(100%); } to { transform: none; } } | |
| @keyframes popIn { from { opacity: 0; transform: translateY(8px) scale(.98); } to { opacity: 1; transform: none; } } | |
| @keyframes shimmer { to { transform: translateX(100%); } } | |
| @media (prefers-reduced-motion: reduce) { *, *::before, *::after { animation-duration: .01ms !important; transition-duration: .01ms !important; } } | |
| @media (max-width: 980px) { | |
| body { overflow: auto; } | |
| .app { height: auto; min-height: 100vh; min-height: 100dvh; } | |
| main.workspace { grid-template-columns: 1fr; } | |
| .results-body { min-height: 360px; } | |
| .form-grid, .collapsible-body { grid-template-columns: 1fr; } | |
| } | |
| @media (max-width: 640px) { | |
| .app { gap: 8px; padding: 8px; } | |
| .topbar { | |
| display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 8px; | |
| padding: 9px 10px; | |
| } | |
| .topbar-spacer { display: none; } | |
| .brand { grid-column: 1; grid-row: 1; } | |
| .brand p { display: none; } | |
| .logo { width: 40px; height: 40px; border-radius: 11px; } | |
| .status-pill { | |
| grid-column: 1 / -1; grid-row: 2; width: 100%; max-width: none; | |
| justify-content: center; padding: 7px 10px; | |
| } | |
| #open-history { grid-column: 2; grid-row: 1; min-height: 44px; } | |
| .panel-head { padding: 12px 14px; } | |
| .form-body, .results-body { padding-left: 14px; padding-right: 14px; } | |
| .actions { grid-template-columns: minmax(0, 1fr) minmax(0, .45fr); } | |
| .actions button { width: 100%; min-width: 0; padding-left: 8px; padding-right: 8px; } | |
| .chips { gap: 6px; } | |
| button.chip { flex: 1 1 auto; min-height: 38px; } | |
| .cards { grid-template-columns: minmax(0, 1fr); } | |
| .card-foot { flex-wrap: wrap; align-items: stretch; } | |
| .card-foot button, .card-foot .visit { flex: 1 1 120px; justify-content: center; text-align: center; } | |
| .welcome { min-height: 260px; padding: 24px 14px; } | |
| .drawer { align-items: end; padding: 0; } | |
| .drawer-card { width: 100%; max-height: 92dvh; border-radius: 18px 18px 0 0; border-bottom: 0; } | |
| .drawer-top { padding: 14px 16px 10px; } | |
| .drawer-body { padding: 14px 16px 20px; } | |
| .detail-grid { grid-template-columns: 1fr; } | |
| .sidenav-card { width: 100%; border-left: 0; } | |
| .warn-banner { align-items: flex-start; flex-wrap: wrap; } | |
| .warn-banner button { margin-left: auto; } | |
| } | |
| @media (max-width: 380px) { | |
| .brand h1 { font-size: 16px; } | |
| #open-history { padding: 0 9px; } | |
| .panel-head .sub { display: none; } | |
| .actions { grid-template-columns: 1fr; } | |
| .actions button { min-height: 44px; } | |
| } | |
| @media (hover: none), (pointer: coarse) { | |
| button:hover:not(:disabled), .opportunity-card:hover, .list-item:hover { transform: none; } | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <div class="app"> | |
| <header class="topbar"> | |
| <div class="brand"> | |
| <div class="logo" aria-hidden="true"> | |
| <svg viewBox="0 0 36 32" fill="none" xmlns="http://www.w3.org/2000/svg"> | |
| <path d="M18 5 L33 12 L18 19 L3 12 Z" fill="#fff"/> | |
| <path d="M7 15 V20.5 C7 23.5 12 25 18 25 C24 25 29 23.5 29 20.5 V15" stroke="#fff" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round"/> | |
| <path d="M33 12 V18" stroke="#fff" stroke-width="2" stroke-linecap="round"/> | |
| <circle cx="33" cy="20" r="2" fill="#fff"/> | |
| </svg> | |
| </div> | |
| <div> | |
| <h1>ScholarScope</h1> | |
| <p>AI funding research</p> | |
| </div> | |
| </div> | |
| <div class="topbar-spacer"></div> | |
| <div id="status" class="status-pill"><span class="status-dot"></span><span id="status-text">Ready</span></div> | |
| <button class="iconbtn ghost" id="open-history" type="button" aria-label="Research history">History</button> | |
| </header> | |
| <div id="warn-banner" class="warn-banner"> | |
| <span id="warn-text"></span> | |
| <button type="button" id="warn-close">Dismiss</button> | |
| </div> | |
| <main class="workspace"> | |
| <section class="panel" aria-label="Research profile"> | |
| <div class="panel-head"> | |
| <div><h2>Research Profile</h2></div> | |
| <span class="sub">Quick start</span> | |
| </div> | |
| <form id="profile-form" class="form-body"> | |
| <div class="form-grid"> | |
| <label class="field"><span>Applicant origin</span><input name="origin" value="Pakistan" placeholder="Country or region you are from" required minlength="2"></label> | |
| <label class="field"><span>Applying for</span> | |
| <select name="applying_for"> | |
| <option>Undergraduate degree</option> | |
| <option selected>Master's degree</option> | |
| <option>PhD</option> | |
| <option>Postdoctoral research</option> | |
| <option>Research project</option> | |
| </select> | |
| </label> | |
| <label class="field full"><span>Field of study</span><input name="field_of_study" value="Computer science or data science" required></label> | |
| <label class="field full"><span>Preferences</span><input name="preferences" value="Fully funded and open to international applicants"></label> | |
| </div> | |
| <div class="chips" aria-label="Example profiles"> | |
| <button class="chip" type="button" data-example="ai-pakistan">Pakistan · AI Master's</button> | |
| <button class="chip" type="button" data-example="health-kenya">Kenya · Public Health Postdoc</button> | |
| <button class="chip" type="button" data-example="cs-canada">Canada · CS PhD</button> | |
| </div> | |
| <div class="actions"> | |
| <button id="research-button" class="primary" type="submit">Research Opportunities</button> | |
| <button id="clear-button" class="ghost" type="button">Clear</button> | |
| </div> | |
| </form> | |
| </section> | |
| <section class="panel" aria-label="Results"> | |
| <div class="panel-head"> | |
| <div class="results-head"> | |
| <h2>Ranked Opportunities</h2> | |
| <span id="count" class="count"></span> | |
| </div> | |
| </div> | |
| <div class="progress" aria-hidden="true"><div id="progress-bar"></div><span id="progress-time" class="progress-time"></span></div> | |
| <div id="results" class="results-body"></div> | |
| </section> | |
| </main> | |
| </div> | |
| <!-- Slide-over for History --> | |
| <aside id="sidenav" class="sidenav" aria-hidden="true"> | |
| <div class="sidenav-backdrop" data-close-sidenav></div> | |
| <div class="sidenav-card"> | |
| <div class="sidenav-head"> | |
| <strong>History</strong> | |
| <button class="ghost x-btn" type="button" data-close-sidenav aria-label="Close">✕</button> | |
| </div> | |
| <div class="search-box"> | |
| <input id="history-search" type="search" placeholder="Search history — title, field, origin, org" autocomplete="off" /> | |
| </div> | |
| <div class="tab-panels"> | |
| <div class="tab-panel"> | |
| <div id="history-list" class="list"><div class="empty">Past research will appear here.</div></div> | |
| </div> | |
| </div> | |
| </div> | |
| </aside> | |
| <!-- Detail drawer --> | |
| <div id="drawer" class="drawer" role="dialog" aria-modal="true" aria-labelledby="drawer-title"> | |
| <div class="drawer-card"> | |
| <div class="drawer-top"> | |
| <h2 id="drawer-title">Opportunity details</h2> | |
| <button class="ghost x-btn" type="button" id="drawer-close" aria-label="Close details">✕</button> | |
| </div> | |
| <div id="drawer-content" class="drawer-body"></div> | |
| </div> | |
| </div> | |
| <script> | |
| const form = document.querySelector("#profile-form"); | |
| const button = document.querySelector("#research-button"); | |
| const clearButton = document.querySelector("#clear-button"); | |
| const exampleButtons = document.querySelectorAll("[data-example]"); | |
| const statusEl = document.querySelector("#status"); | |
| const statusText = document.querySelector("#status-text"); | |
| const progressBar = document.querySelector("#progress-bar"); | |
| const resultsEl = document.querySelector("#results"); | |
| const countEl = document.querySelector("#count"); | |
| const historyList = document.querySelector("#history-list"); | |
| const drawer = document.querySelector("#drawer"); | |
| const drawerContent = document.querySelector("#drawer-content"); | |
| const drawerClose = document.querySelector("#drawer-close"); | |
| const sidenav = document.querySelector("#sidenav"); | |
| const warnBanner = document.querySelector("#warn-banner"); | |
| const warnText = document.querySelector("#warn-text"); | |
| const warnClose = document.querySelector("#warn-close"); | |
| let currentResult = null; | |
| let selectedTitle = null; | |
| let statusTimers = []; | |
| let apiReady = null; | |
| const EXAMPLE_PROFILES = { | |
| "ai-pakistan": { origin: "Pakistan", applying_for: "Master's degree", field_of_study: "Artificial intelligence", preferences: "Fully funded, responsible AI, international applicants" }, | |
| "health-kenya": { origin: "Kenya", applying_for: "Postdoctoral research", field_of_study: "Public health", preferences: "Implementation research and international applicants" }, | |
| "cs-canada": { origin: "Canada", applying_for: "PhD", field_of_study: "Computer science", preferences: "AI safety, systems, and human-centered computing" } | |
| }; | |
| const escapeHtml = (v) => String(v ?? "").replace(/[&<>"']/g, (c) => ({"&":"&","<":"<",">":">",'"':""","'":"'"}[c])); | |
| function setStatus(message, progress = null, tone = "") { | |
| statusText.textContent = message; | |
| statusEl.title = message; | |
| statusEl.classList.remove("ok", "live", "error"); | |
| if (tone) statusEl.classList.add(tone); | |
| if (progress !== null) progressBar.style.width = `${progress}%`; | |
| } | |
| function clearStatusTimers() { statusTimers.forEach((t) => clearInterval(t)); statusTimers = []; } | |
| function scheduleStatus(m, p, d, tone) { statusTimers.push(setTimeout(() => setStatus(m, p, tone), d)); } | |
| function readyStatus() { | |
| if (apiReady) setStatus("Ready", 0, "ok"); | |
| else if (apiReady === false) setStatus("Offline", 0, "error"); | |
| else setStatus("Ready", 0); | |
| } | |
| let _searchTimer = null; | |
| let _searchStart = 0; | |
| const progressTime = document.querySelector("#progress-time"); | |
| function startSearchTimer() { | |
| _searchStart = Date.now(); | |
| clearStatusTimers(); | |
| _searchTimer = setInterval(() => { | |
| const elapsed = ((Date.now() - _searchStart) / 1000).toFixed(1); | |
| const pct = Math.min(95, Math.round((Date.now() - _searchStart) / 600 * 100)); | |
| progressBar.style.width = `${pct}%`; | |
| progressTime.textContent = `${elapsed}s`; | |
| let phase = "Searching\u2026"; | |
| if (pct > 50) phase = "Scanning\u2026"; | |
| if (pct > 78) phase = "Ranking\u2026"; | |
| statusText.textContent = phase; | |
| statusEl.title = `${phase} (${elapsed}s elapsed)`; | |
| }, 100); | |
| } | |
| function stopSearchTimer(finalPct) { | |
| if (_searchTimer) { clearInterval(_searchTimer); _searchTimer = null; } | |
| const elapsed = ((Date.now() - _searchStart) / 1000).toFixed(1); | |
| progressBar.style.width = `${finalPct}%`; | |
| progressTime.textContent = `${elapsed}s`; | |
| } | |
| function payloadFromForm() { | |
| return Object.fromEntries(new FormData(form).entries()); | |
| } | |
| function fillForm(values) { | |
| Object.entries(values).forEach(([n, v]) => { | |
| const f = form.elements[n]; | |
| if (f) f.value = v; | |
| }); | |
| } | |
| function renderWelcome() { | |
| resultsEl.innerHTML = ` | |
| <div class="welcome"> | |
| <div class="glow">🎓</div> | |
| <h3>Find your next scholarship</h3> | |
| <p>Fill in your profile on the left and hit <strong>Research Opportunities</strong>. ScholarScope searches live funding sources and ranks the best matches for you.</p> | |
| </div>`; | |
| countEl.textContent = ""; | |
| } | |
| function clearResearchView() { | |
| currentResult = null; selectedTitle = null; | |
| clearStatusTimers(); | |
| if (_searchTimer) { clearInterval(_searchTimer); _searchTimer = null; } | |
| if (progressTime) progressTime.textContent = ""; | |
| renderWelcome(); | |
| readyStatus(); | |
| } | |
| async function postJson(url, payload) { | |
| const r = await fetch(url, { method: "POST", headers: {"Content-Type": "application/json"}, body: JSON.stringify(payload) }); | |
| if (!r.ok) throw new Error(`Request failed with ${r.status}`); | |
| return r.json(); | |
| } | |
| clearButton.addEventListener("click", () => { | |
| form.reset(); | |
| form.querySelectorAll("input, textarea").forEach((f) => { if (f.type !== "checkbox") f.value = ""; }); | |
| clearResearchView(); | |
| }); | |
| exampleButtons.forEach((b) => b.addEventListener("click", () => { | |
| fillForm(EXAMPLE_PROFILES[b.dataset.example] || {}); | |
| clearResearchView(); | |
| })); | |
| form.addEventListener("submit", async (event) => { | |
| event.preventDefault(); | |
| button.disabled = true; | |
| currentResult = null; selectedTitle = null; | |
| resultsEl.innerHTML = `<div class="loading"><strong>Researching funding sources</strong><p class="meta">Searching, reading, and ranking live opportunities.</p></div>`; | |
| countEl.textContent = ""; | |
| setStatus("Searching\u2026", 2, "live"); | |
| startSearchTimer(); | |
| try { | |
| const data = await postJson("/custom/research", payloadFromForm()); | |
| stopSearchTimer(100); | |
| if (!data.ok) { | |
| setStatus("No results", 0, "error"); | |
| resultsEl.innerHTML = `<div class="empty">${escapeHtml(data.message)}</div>`; | |
| renderHistory(data.history); | |
| return; | |
| } | |
| currentResult = data.result; | |
| const opps = currentResult.opportunities || []; | |
| selectedTitle = opps[0]?.title ?? null; | |
| renderResults(opps); | |
| if (historySearch) historySearch.value = ""; | |
| historyQuery = ""; | |
| renderHistory(data.history); | |
| const elapsed = ((Date.now() - _searchStart) / 1000).toFixed(1); | |
| setStatus(`${opps.length} found \u00b7 ${elapsed}s`, 100, "ok"); | |
| if (opps[0]) openDrawer(opps[0]); | |
| } catch (error) { | |
| stopSearchTimer(0); | |
| setStatus("Failed", 0, "error"); | |
| resultsEl.innerHTML = `<div class="empty">Live research failed. No fallback results were substituted.</div>`; | |
| } finally { | |
| button.disabled = false; | |
| } | |
| }); | |
| /* Drawer */ | |
| drawerClose.addEventListener("click", () => drawer.classList.remove("open")); | |
| drawer.addEventListener("click", (e) => { if (e.target === drawer) drawer.classList.remove("open"); }); | |
| document.addEventListener("keydown", (e) => { | |
| if (e.key === "Escape") { drawer.classList.remove("open"); sidenav.classList.remove("open"); } | |
| }); | |
| function detailMarkup(item) { | |
| const concerns = (item.concerns || []).length | |
| ? item.concerns.map((c) => `<li>${escapeHtml(c)}</li>`).join("") | |
| : "<li>No concerns identified.</li>"; | |
| return ` | |
| <span class="score ${item.match_score >= 80 ? 'high' : ''}">${escapeHtml(item.match_score)}% match</span> | |
| <h2>${escapeHtml(item.title)}</h2> | |
| <p class="meta">${escapeHtml(item.organization)} · ${escapeHtml(item.eligibility_region)}</p> | |
| <div class="detail-grid"> | |
| <div class="fact"><strong>Funding</strong>${escapeHtml(item.funding_amount)}</div> | |
| <div class="fact"><strong>Deadline</strong>${escapeHtml(item.deadline)}</div> | |
| <div class="fact"><strong>Source</strong><a href="${escapeHtml(item.source_url)}" target="_blank" rel="noopener noreferrer">Open provider ↗</a></div> | |
| </div> | |
| <p><strong>Eligibility:</strong> ${escapeHtml(item.eligibility_summary)}</p> | |
| <p><strong>Why it fits:</strong> ${escapeHtml(item.why_it_fits)}</p> | |
| <p><strong>Concerns / missing info:</strong></p> | |
| <ul>${concerns}</ul>`; | |
| } | |
| function openDrawer(item) { | |
| if (!item) return; | |
| drawerContent.innerHTML = detailMarkup(item); | |
| drawer.classList.add("open"); | |
| } | |
| function renderResults(opportunities) { | |
| if (!opportunities.length) { | |
| resultsEl.innerHTML = `<div class="empty">No opportunities found. Try broadening the profile or deadline preferences.</div>`; | |
| countEl.textContent = ""; | |
| return; | |
| } | |
| countEl.textContent = `${opportunities.length} found`; | |
| resultsEl.innerHTML = `<div class="cards">${opportunities.map((item, i) => ` | |
| <article class="opportunity-card ${i === 0 ? "selected" : ""}" style="animation-delay:${i * 60}ms"> | |
| <span class="score ${item.match_score >= 80 ? 'high' : ''}">${escapeHtml(item.match_score)}% match</span> | |
| <h3><a href="${escapeHtml(item.source_url)}" target="_blank" rel="noopener noreferrer">${escapeHtml(item.title)}</a></h3> | |
| <p class="meta">${escapeHtml(item.organization)}</p> | |
| <p class="meta">${escapeHtml(item.funding_amount)} · ${escapeHtml(item.deadline)}</p> | |
| <div class="card-foot"> | |
| <button type="button" data-title="${escapeHtml(item.title)}">Details</button> | |
| <a class="visit" href="${escapeHtml(item.source_url)}" target="_blank" rel="noopener noreferrer">Visit ↗</a> | |
| </div> | |
| </article>`).join("")}</div>`; | |
| resultsEl.querySelectorAll("button[data-title]").forEach((b) => { | |
| b.addEventListener("click", () => { | |
| selectedTitle = b.dataset.title; | |
| const item = (currentResult?.opportunities || opportunities).find((o) => o.title === selectedTitle); | |
| resultsEl.querySelectorAll(".opportunity-card").forEach((c) => c.classList.remove("selected")); | |
| b.closest(".opportunity-card")?.classList.add("selected"); | |
| openDrawer(item); | |
| }); | |
| }); | |
| } | |
| function renderHistory(records) { | |
| if (!records || !records.length) { historyList.innerHTML = `<div class="empty">${historyQuery ? "No matching records." : "Past research will appear here."}</div>`; return; } | |
| const visible = records.slice(-8).reverse(); | |
| historyList.innerHTML = visible.map((record, index) => ` | |
| <button class="list-item history-button" type="button" data-history-index="${index}"> | |
| <strong>${escapeHtml(record.opportunity_title)}</strong> | |
| <p class="meta">${escapeHtml(String(record.created_at).slice(0, 19))} · ${escapeHtml(record.match_score)}% match</p> | |
| </button>`).join(""); | |
| historyList.querySelectorAll("[data-history-index]").forEach((b) => { | |
| b.addEventListener("click", () => { | |
| const item = opportunityFromHistory(visible[Number(b.dataset.historyIndex)]); | |
| currentResult = null; selectedTitle = item.title; | |
| renderResults([item]); | |
| openDrawer(item); | |
| sidenav.classList.remove("open"); | |
| setStatus("Past result", 100, "ok"); | |
| }); | |
| }); | |
| } | |
| function opportunityFromHistory(record) { | |
| const profile = record.user_profile || {}; | |
| return { | |
| title: record.opportunity_title, organization: record.organization, source_url: record.source_url, | |
| deadline: record.deadline, funding_amount: record.funding_amount, | |
| eligibility_region: "Not listed", match_score: record.match_score, | |
| eligibility_summary: record.eligibility_summary, why_it_fits: record.reasoning_summary, | |
| concerns: ["Past research result. Verify current deadlines and eligibility on the provider website."] | |
| }; | |
| } | |
| /* Slide-over */ | |
| function openSidenav() { | |
| sidenav.classList.add("open"); | |
| sidenav.setAttribute("aria-hidden", "false"); | |
| } | |
| function closeSidenav() { sidenav.classList.remove("open"); sidenav.setAttribute("aria-hidden", "true"); } | |
| document.querySelector("#open-history").addEventListener("click", openSidenav); | |
| document.querySelectorAll("[data-close-sidenav]").forEach((el) => el.addEventListener("click", closeSidenav)); | |
| /* Config / warning */ | |
| function showWarn(missing) { | |
| if (!missing || !missing.length) return; | |
| warnText.textContent = "Missing keys: " + missing.join(", ") + ". Live research needs OLOSTEP_API_KEY and HF_TOKEN."; | |
| warnBanner.classList.add("show"); | |
| setStatus("Offline", 0, "error"); | |
| } | |
| warnClose.addEventListener("click", () => warnBanner.classList.remove("show")); | |
| /* History search */ | |
| let historyQuery = ""; | |
| let historySearchTimer = null; | |
| const historySearch = document.querySelector("#history-search"); | |
| function loadHistory(query = historyQuery) { | |
| historyQuery = (query || "").trim(); | |
| const url = historyQuery ? `/custom/history?q=${encodeURIComponent(historyQuery)}` : "/custom/history"; | |
| fetch(url).then((r) => r.json()).then((d) => renderHistory(d.records)).catch(() => {}); | |
| } | |
| if (historySearch) { | |
| historySearch.addEventListener("input", () => { | |
| clearTimeout(historySearchTimer); | |
| historySearchTimer = setTimeout(() => loadHistory(historySearch.value), 250); | |
| }); | |
| } | |
| renderWelcome(); | |
| fetch("/custom/config").then((r) => r.json()).then((c) => { | |
| apiReady = !!c.live_research_enabled; | |
| if (apiReady) setStatus("Ready", 0, "ok"); | |
| else showWarn(c.missing); | |
| }).catch(() => { apiReady = false; setStatus("Offline", 0, "error"); }); | |
| loadHistory(""); | |
| </script> | |
| </body> | |
| </html> | |
| """ | |