RohitPoreddy commited on
Commit
8e70728
·
verified ·
1 Parent(s): 98fded2

Upload 10 files

Browse files
Dockerfile ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Single-container deployment: FastAPI + static SPA
2
+ FROM python:3.11-slim
3
+
4
+ WORKDIR /app
5
+
6
+ # System deps (optional; keep minimal)
7
+ RUN pip install --no-cache-dir --upgrade pip
8
+
9
+ COPY requirements.txt /app/requirements.txt
10
+ RUN pip install --no-cache-dir -r /app/requirements.txt
11
+
12
+ COPY app /app/app
13
+ COPY static /app/static
14
+ COPY data /app/data
15
+
16
+ ENV PYTHONUNBUFFERED=1
17
+ ENV PORT=8080
18
+
19
+ EXPOSE 8080
20
+
21
+ CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8080"]
app/__init__.py ADDED
File without changes
app/__pycache__/__init__.cpython-313.pyc ADDED
Binary file (166 Bytes). View file
 
app/__pycache__/main.cpython-313.pyc ADDED
Binary file (3.02 kB). View file
 
app/__pycache__/updater.cpython-313.pyc ADDED
Binary file (7.21 kB). View file
 
app/main.py ADDED
@@ -0,0 +1,63 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ from datetime import datetime, timezone
6
+ from pathlib import Path
7
+ from typing import Any, Dict, List, Optional
8
+
9
+ from fastapi import FastAPI, Query
10
+ from fastapi.responses import HTMLResponse, JSONResponse
11
+ from fastapi.staticfiles import StaticFiles
12
+
13
+ from .updater import ensure_monthly_refresh, load_events, query_events
14
+
15
+ ROOT = Path(__file__).resolve().parents[1]
16
+ DATA_DIR = ROOT / "data"
17
+ STATIC_DIR = ROOT / "static"
18
+
19
+ app = FastAPI(title="AI Timeline", version="0.1.0")
20
+
21
+ # Serve the SPA
22
+ app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
23
+
24
+ @app.get("/", response_class=HTMLResponse)
25
+ def index() -> HTMLResponse:
26
+ return HTMLResponse((STATIC_DIR / "index.html").read_text(encoding="utf-8"))
27
+
28
+ @app.get("/api/events")
29
+ def api_events(
30
+ q: Optional[str] = Query(default=None, description="Full-text search (title/description/org)."),
31
+ category: Optional[str] = Query(default=None, description="open-weights | api | all"),
32
+ org: Optional[str] = Query(default=None, description="Filter by org/author (HF)."),
33
+ date_from: Optional[str] = Query(default=None, description="YYYY-MM-DD"),
34
+ date_to: Optional[str] = Query(default=None, description="YYYY-MM-DD"),
35
+ ) -> JSONResponse:
36
+ """
37
+ Returns events for the timeline.
38
+ Refresh logic: on every request we *cheap-check* whether the month changed since last refresh.
39
+ If yes, we refresh the Hugging Face portion (open-weight models) and merge into events.json.
40
+ """
41
+ ensure_monthly_refresh(data_dir=DATA_DIR)
42
+
43
+ payload = load_events(DATA_DIR)
44
+ events = query_events(
45
+ payload["events"],
46
+ q=q,
47
+ category=category,
48
+ org=org,
49
+ date_from=date_from,
50
+ date_to=date_to,
51
+ )
52
+ return JSONResponse(
53
+ {
54
+ "as_of": datetime.now(timezone.utc).isoformat(),
55
+ "count": len(events),
56
+ "events": events,
57
+ "meta": {
58
+ "tracked_orgs": payload.get("tracked_orgs", []),
59
+ "last_refresh_month": payload.get("last_refresh_month"),
60
+ "last_refresh_at": payload.get("last_refresh_at"),
61
+ },
62
+ }
63
+ )
app/updater.py ADDED
@@ -0,0 +1,155 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ from dataclasses import dataclass
6
+ from datetime import datetime, timezone
7
+ from pathlib import Path
8
+ from typing import Any, Dict, Iterable, List, Optional, Tuple
9
+
10
+ try:
11
+ from huggingface_hub import HfApi
12
+ except Exception: # pragma: no cover
13
+ HfApi = None # type: ignore
14
+
15
+ SCHEMA_VERSION = 1
16
+
17
+ def _utcnow() -> datetime:
18
+ return datetime.now(timezone.utc)
19
+
20
+ def _month_key(dt: datetime) -> str:
21
+ return dt.strftime("%Y-%m")
22
+
23
+ def load_events(data_dir: Path) -> Dict[str, Any]:
24
+ p = data_dir / "events.json"
25
+ if not p.exists():
26
+ return {"schema_version": SCHEMA_VERSION, "events": [], "tracked_orgs": []}
27
+ return json.loads(p.read_text(encoding="utf-8"))
28
+
29
+ def save_events(data_dir: Path, payload: Dict[str, Any]) -> None:
30
+ p = data_dir / "events.json"
31
+ p.write_text(json.dumps(payload, indent=2, ensure_ascii=False), encoding="utf-8")
32
+
33
+ def query_events(
34
+ events: List[Dict[str, Any]],
35
+ q: Optional[str] = None,
36
+ category: Optional[str] = None,
37
+ org: Optional[str] = None,
38
+ date_from: Optional[str] = None,
39
+ date_to: Optional[str] = None,
40
+ ) -> List[Dict[str, Any]]:
41
+ def matches(e: Dict[str, Any]) -> bool:
42
+ if category and category != "all":
43
+ if e.get("category") != category:
44
+ return False
45
+ if org:
46
+ if (e.get("org") or "").lower() != org.lower():
47
+ return False
48
+ if date_from and (e.get("date") or "") < date_from:
49
+ return False
50
+ if date_to and (e.get("date") or "") > date_to:
51
+ return False
52
+ if q:
53
+ hay = " ".join(
54
+ [
55
+ str(e.get("title", "")),
56
+ str(e.get("description", "")),
57
+ str(e.get("org", "")),
58
+ str(e.get("model_id", "")),
59
+ ]
60
+ ).lower()
61
+ if q.lower() not in hay:
62
+ return False
63
+ return True
64
+
65
+ # Sort newest-first by date, then title
66
+ out = [e for e in events if matches(e)]
67
+ out.sort(key=lambda x: (x.get("date", ""), x.get("title", "")), reverse=True)
68
+ return out
69
+
70
+ def ensure_monthly_refresh(data_dir: Path) -> None:
71
+ payload = load_events(data_dir)
72
+ now = _utcnow()
73
+ current_month = _month_key(now)
74
+ last_month = payload.get("last_refresh_month")
75
+
76
+ if last_month == current_month:
77
+ return
78
+
79
+ # First request in a new month triggers refresh.
80
+ refreshed = refresh_hf_open_weights(payload)
81
+ refreshed["last_refresh_month"] = current_month
82
+ refreshed["last_refresh_at"] = now.isoformat()
83
+ save_events(data_dir, refreshed)
84
+
85
+ def refresh_hf_open_weights(payload: Dict[str, Any]) -> Dict[str, Any]:
86
+ """
87
+ Pull new/updated open-weight model releases from Hugging Face Hub and merge into events.
88
+ This runs at most once per month (lazy-triggered), unless you delete last_refresh_month.
89
+ """
90
+ if HfApi is None:
91
+ # huggingface_hub not installed in runtime image
92
+ return payload
93
+
94
+ hf = HfApi(token=os.getenv("HF_TOKEN")) # token optional; unauthenticated is fine for public data
95
+
96
+ tracked_orgs = payload.get("tracked_orgs", [])
97
+ if not tracked_orgs:
98
+ return payload
99
+
100
+ # We use lastModified as a proxy for "release activity" because it is consistently available
101
+ # via list_models(sort="lastModified", direction=-1, full=True).
102
+ # See huggingface_hub docs for list_models parameters.
103
+ new_events: List[Dict[str, Any]] = []
104
+ for org in tracked_orgs:
105
+ try:
106
+ models = hf.list_models(author=org, sort="lastModified", direction=-1, limit=150, full=True)
107
+ except TypeError:
108
+ # Older versions use different signature; fall back to minimal params.
109
+ models = hf.list_models(author=org)
110
+
111
+ for m in models:
112
+ # ModelInfo has "modelId"/"id" and "lastModified" depending on version.
113
+ model_id = getattr(m, "modelId", None) or getattr(m, "id", None) or ""
114
+ last_modified = getattr(m, "lastModified", None) or getattr(m, "last_modified", None)
115
+ if not model_id or not last_modified:
116
+ continue
117
+
118
+ # Normalize to ISO date (YYYY-MM-DD)
119
+ if isinstance(last_modified, str):
120
+ try:
121
+ dt = datetime.fromisoformat(last_modified.replace("Z", "+00:00"))
122
+ except Exception:
123
+ continue
124
+ else:
125
+ dt = last_modified
126
+
127
+ date_iso = dt.date().isoformat()
128
+ title = model_id.split("/")[-1]
129
+ url = f"https://huggingface.co/{model_id}"
130
+
131
+ new_events.append(
132
+ {
133
+ "date": date_iso,
134
+ "month": dt.strftime("%B %Y"),
135
+ "title": title,
136
+ "description": f"{model_id} updated on Hugging Face Hub.",
137
+ "link": url,
138
+ "category": "open-weights",
139
+ "source": "hf:hub",
140
+ "org": org,
141
+ "model_id": model_id,
142
+ }
143
+ )
144
+
145
+ # Merge (de-dup by model_id + date)
146
+ existing = payload.get("events", [])
147
+ seen = {(e.get("model_id"), e.get("date")) for e in existing if e.get("source") == "hf:hub"}
148
+ for e in new_events:
149
+ key = (e.get("model_id"), e.get("date"))
150
+ if key not in seen:
151
+ existing.append(e)
152
+ seen.add(key)
153
+
154
+ payload["events"] = existing
155
+ return payload
data/events.json ADDED
The diff for this file is too large to render. See raw diff
 
requirements.txt ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ fastapi==0.115.0
2
+ uvicorn[standard]==0.30.6
3
+ huggingface_hub>=0.24.0
4
+ python-dateutil==2.9.0.post0
static/index.html ADDED
@@ -0,0 +1,881 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <title>AI Model & API Release Timeline</title>
7
+
8
+
9
+ <style>
10
+ :root{
11
+ --bg: #f5f5f7;
12
+ --panel: rgba(255,255,255,.72);
13
+ --panel-solid: #ffffff;
14
+ --text: #1d1d1f;
15
+ --muted: #6e6e73;
16
+ --border: rgba(0,0,0,.08);
17
+ --shadow: 0 8px 30px rgba(0,0,0,.08);
18
+ --accent: #0071e3; /* Apple blue */
19
+ --good: #34c759; /* Apple green */
20
+ --bad: #ff3b30; /* Apple red */
21
+ --warn: #ffcc00; /* Apple yellow */
22
+ --radius-xl: 22px;
23
+ --radius-lg: 16px;
24
+ --radius-md: 12px;
25
+ --radius-sm: 10px;
26
+ }
27
+
28
+ *{ box-sizing: border-box; }
29
+ html, body { height: 100%; }
30
+ body{
31
+ margin:0;
32
+ font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "SF Pro Display", "SF Pro Text", "Segoe UI", Roboto, Helvetica, Arial, "Apple Color Emoji","Segoe UI Emoji";
33
+ color: var(--text);
34
+ background: radial-gradient(1200px 800px at 20% 0%, rgba(0,113,227,.15), transparent 55%),
35
+ radial-gradient(900px 700px at 90% 20%, rgba(52,199,89,.10), transparent 55%),
36
+ var(--bg);
37
+ }
38
+
39
+ a{ color: var(--accent); text-decoration: none; }
40
+ a:hover{ text-decoration: underline; }
41
+
42
+ /* Top bar */
43
+ .topbar{
44
+ position: sticky;
45
+ top: 0;
46
+ z-index: 50;
47
+ backdrop-filter: blur(18px);
48
+ -webkit-backdrop-filter: blur(18px);
49
+ background: rgba(245,245,247,.75);
50
+ border-bottom: 1px solid var(--border);
51
+ }
52
+ .topbar-inner{
53
+ max-width: 1200px;
54
+ margin: 0 auto;
55
+ padding: 14px 16px;
56
+ display: flex;
57
+ align-items: center;
58
+ gap: 12px;
59
+ }
60
+ .brand{
61
+ display:flex; align-items:center; gap:10px;
62
+ min-width: 230px;
63
+ }
64
+ .logo{
65
+ width: 34px; height: 34px; border-radius: 10px;
66
+ background: linear-gradient(135deg, rgba(0,113,227,.95), rgba(88,86,214,.92));
67
+ box-shadow: 0 12px 24px rgba(0,113,227,.22);
68
+ }
69
+ .brand h1{
70
+ font-size: 14px;
71
+ margin: 0;
72
+ letter-spacing: .2px;
73
+ font-weight: 650;
74
+ }
75
+ .brand p{
76
+ margin: 0;
77
+ font-size: 12px;
78
+ color: var(--muted);
79
+ }
80
+
81
+ .search{
82
+ flex: 1;
83
+ display:flex;
84
+ align-items:center;
85
+ gap: 10px;
86
+ padding: 10px 12px;
87
+ border-radius: 999px;
88
+ border: 1px solid var(--border);
89
+ background: rgba(255,255,255,.65);
90
+ box-shadow: 0 1px 0 rgba(255,255,255,.6) inset;
91
+ }
92
+ .search input{
93
+ border:none;
94
+ outline:none;
95
+ background: transparent;
96
+ font-size: 14px;
97
+ width: 100%;
98
+ color: var(--text);
99
+ }
100
+ .pillrow{
101
+ display:flex; gap:8px; align-items:center;
102
+ flex-wrap: wrap;
103
+ justify-content: flex-end;
104
+ }
105
+ .pill{
106
+ padding: 8px 12px;
107
+ border-radius: 999px;
108
+ border: 1px solid var(--border);
109
+ background: rgba(255,255,255,.55);
110
+ color: var(--text);
111
+ font-size: 13px;
112
+ cursor: pointer;
113
+ user-select: none;
114
+ transition: transform .12s ease, background .15s ease, border-color .15s ease;
115
+ }
116
+ .pill:hover{ transform: translateY(-1px); }
117
+ .pill.active{
118
+ background: rgba(0,113,227,.12);
119
+ border-color: rgba(0,113,227,.35);
120
+ color: var(--accent);
121
+ font-weight: 650;
122
+ }
123
+
124
+ .select{
125
+ padding: 8px 12px;
126
+ border-radius: 999px;
127
+ border: 1px solid var(--border);
128
+ background: rgba(255,255,255,.55);
129
+ font-size: 13px;
130
+ color: var(--text);
131
+ outline: none;
132
+ cursor: pointer;
133
+ }
134
+
135
+ /* Summary row */
136
+ .summary{
137
+ max-width: 1200px;
138
+ margin: 14px auto 0;
139
+ padding: 0 16px;
140
+ display: grid;
141
+ grid-template-columns: 1.3fr .7fr;
142
+ gap: 12px;
143
+ }
144
+ .card{
145
+ background: var(--panel);
146
+ border: 1px solid var(--border);
147
+ border-radius: var(--radius-xl);
148
+ box-shadow: var(--shadow);
149
+ backdrop-filter: blur(18px);
150
+ -webkit-backdrop-filter: blur(18px);
151
+ }
152
+ .card-inner{ padding: 14px 16px; }
153
+ .kpis{
154
+ display:flex; gap: 14px; flex-wrap: wrap; align-items: center;
155
+ }
156
+ .kpi{
157
+ display:flex; gap:10px; align-items:center;
158
+ padding: 10px 12px;
159
+ border-radius: 16px;
160
+ background: rgba(255,255,255,.55);
161
+ border: 1px solid rgba(0,0,0,.06);
162
+ }
163
+ .dot{
164
+ width: 10px; height: 10px; border-radius: 999px;
165
+ }
166
+ .kpi .label{ font-size: 12px; color: var(--muted); margin:0; }
167
+ .kpi .value{ font-size: 16px; font-weight: 750; margin:0; }
168
+
169
+ .hint{
170
+ color: var(--muted);
171
+ font-size: 12px;
172
+ line-height: 1.35;
173
+ }
174
+
175
+ /* Timeline layout */
176
+ .wrap{
177
+ max-width: 1200px;
178
+ margin: 14px auto 30px;
179
+ padding: 0 16px;
180
+ }
181
+ /* .timeline{
182
+ display:flex;
183
+ gap: 14px;
184
+ overflow-x: auto;
185
+ padding: 14px;
186
+ scroll-snap-type: x mandatory;
187
+ border-radius: var(--radius-xl);
188
+ border: 1px solid var(--border);
189
+ background: rgba(255,255,255,.35);
190
+ backdrop-filter: blur(18px);
191
+ -webkit-backdrop-filter: blur(18px);
192
+ box-shadow: var(--shadow);
193
+ } */
194
+ .timeline {
195
+ display: flex;
196
+ gap: 14px;
197
+
198
+ /* FULL WIDTH FIX */
199
+ width: 100vw;
200
+ margin-left: calc(50% - 50vw);
201
+ margin-right: calc(50% - 50vw);
202
+
203
+ overflow-x: auto;
204
+ padding: 20px 28px;
205
+
206
+ scroll-snap-type: x mandatory;
207
+
208
+ border-radius: 0; /* full-bleed = no rounded corners */
209
+ border-top: 1px solid var(--border);
210
+ border-bottom: 1px solid var(--border);
211
+
212
+ background: rgba(255,255,255,0.55);
213
+ backdrop-filter: blur(20px);
214
+ -webkit-backdrop-filter: blur(20px);
215
+
216
+ box-shadow: inset 0 1px 0 rgba(255,255,255,.4);
217
+ }
218
+
219
+
220
+
221
+
222
+
223
+ .timeline::-webkit-scrollbar{ height: 10px; }
224
+ .timeline::-webkit-scrollbar-thumb{ background: rgba(0,0,0,.18); border-radius: 999px; }
225
+ .timeline::-webkit-scrollbar-track{ background: rgba(0,0,0,.06); border-radius: 999px; }
226
+ .timeline::-webkit-scrollbar {
227
+ height: 6px;
228
+ }
229
+
230
+ .timeline::-webkit-scrollbar-thumb {
231
+ background: rgba(0,0,0,0.25);
232
+ border-radius: 999px;
233
+ }
234
+
235
+ .timeline::-webkit-scrollbar-track {
236
+ background: transparent;
237
+ }
238
+
239
+ /* Scroll buttons */
240
+ .scroll-controls{ position: relative; }
241
+ .scroll-btn{
242
+ position: absolute;
243
+ top: 50%; transform: translateY(-50%);
244
+ width: 44px; height: 44px; border-radius: 999px;
245
+ border: 1px solid var(--border);
246
+ background: rgba(255,255,255,.9);
247
+ box-shadow: 0 6px 18px rgba(0,0,0,.06);
248
+ display:flex; align-items:center; justify-content:center;
249
+ cursor:pointer; font-size:18px; color:var(--text);
250
+ z-index: 40;
251
+ }
252
+ .scroll-btn.left{ left: 8px; }
253
+ .scroll-btn.right{ right: 8px; }
254
+ .scroll-btn:active{ transform: translateY(-50%) scale(.98); }
255
+ .scroll-btn.fixed{ position: fixed; left: 12px; top: 48%; transform: translateY(-50%); }
256
+ .scroll-btn.fixed.right{ left: auto; right: 12px; }
257
+ .scroll-btn.fixed{ box-shadow: 0 10px 30px rgba(0,0,0,.12); background: rgba(255,255,255,.95); }
258
+
259
+ /* translucent vertical bands behind fixed scroll buttons to make them pop */
260
+ .scroll-band{
261
+ position: fixed;
262
+ top: 0;
263
+ bottom: 0;
264
+ width: 60px;
265
+ pointer-events: none;
266
+ z-index: 30;
267
+ backdrop-filter: blur(6px);
268
+ }
269
+ .scroll-band.left{
270
+ left: 0;
271
+ background: linear-gradient(to right, rgba(81, 127, 255, 0.1), rgba(0,0,0,0));
272
+ }
273
+ .scroll-band.right{
274
+ right: 0;
275
+ background: linear-gradient(to left, rgba(252, 188, 188, 0.1), rgba(0,0,0,0));
276
+ }
277
+
278
+
279
+
280
+
281
+ .month{
282
+ scroll-snap-align: start;
283
+ min-width: 320px;
284
+ max-width: 320px;
285
+ display:flex;
286
+ flex-direction: column;
287
+ gap: 10px;
288
+ }
289
+ .month-header{
290
+ position: sticky; /* stays visible within the month column while scrolling its content */
291
+ top: 74px; /* below topbar */
292
+ z-index: 20;
293
+ display:flex;
294
+ align-items:center;
295
+ justify-content: space-between;
296
+ padding: 12px 14px;
297
+ border-radius: 16px;
298
+ background: linear-gradient(135deg, rgba(0,113,227,0.95), rgba(88,86,214,0.92));
299
+ border: 1px solid rgba(255,255,255,0.14);
300
+ box-shadow: 0 6px 18px rgba(0,0,0,0.08);
301
+ color: #fff;
302
+ backdrop-filter: blur(10px);
303
+ -webkit-backdrop-filter: blur(10px);
304
+ }
305
+ .month-header h2{
306
+ margin:0;
307
+ font-size: 14px;
308
+ letter-spacing: .2px;
309
+ font-weight: 750;
310
+ }
311
+ .month-header .count{
312
+ font-size: 12px;
313
+ color: rgba(255,255,255,0.88);
314
+ }
315
+
316
+ .events{
317
+ display:flex;
318
+ flex-direction: column;
319
+ gap: 10px;
320
+ /* reserve vertical space so sticky month header does not visually overlap the first cards */
321
+ padding-top: 64px;
322
+ }
323
+
324
+ .event{
325
+ background: rgba(255,255,255,.78);
326
+ border: 1px solid rgba(0,0,0,.06);
327
+ border-radius: 18px;
328
+ padding: 12px 12px 11px;
329
+ box-shadow: 0 10px 25px rgba(0,0,0,.06);
330
+ transition: transform .12s ease, box-shadow .2s ease;
331
+ cursor: pointer;
332
+ }
333
+ .event:hover{
334
+ transform: translateY(-1px);
335
+ box-shadow: 0 14px 32px rgba(0,0,0,.09);
336
+ }
337
+ .event-title{
338
+ margin:0;
339
+ font-size: 13px;
340
+ font-weight: 720;
341
+ letter-spacing: .1px;
342
+ }
343
+ .event-desc{
344
+ margin: 6px 0 0;
345
+ font-size: 12.5px;
346
+ color: var(--muted);
347
+ line-height: 1.35;
348
+ }
349
+ .meta{
350
+ margin-top: 10px;
351
+ display:flex;
352
+ align-items:center;
353
+ justify-content: space-between;
354
+ gap: 10px;
355
+ }
356
+ .tag{
357
+ font-size: 11px;
358
+ padding: 5px 8px;
359
+ border-radius: 999px;
360
+ border: 1px solid rgba(0,0,0,.08);
361
+ background: rgba(255,255,255,.55);
362
+ color: var(--text);
363
+ display:inline-flex;
364
+ align-items:center;
365
+ gap:6px;
366
+ }
367
+ .tag .dot{ width: 7px; height: 7px; }
368
+ .tag.open-weights{ border-color: rgba(52,199,89,.25); background: rgba(52,199,89,.09); }
369
+ .tag.open-weights .dot{ background: var(--good); }
370
+ .tag.api{ border-color: rgba(255,59,48,.22); background: rgba(255,59,48,.08); }
371
+ .tag.api .dot{ background: var(--bad); }
372
+
373
+ .small{
374
+ font-size: 11px;
375
+ color: var(--muted);
376
+ white-space: nowrap;
377
+ }
378
+
379
+ /* Modal */
380
+ .overlay{
381
+ position: fixed;
382
+ inset: 0;
383
+ background: rgba(0,0,0,.45);
384
+ display:none;
385
+ align-items:center;
386
+ justify-content:center;
387
+ padding: 18px;
388
+ z-index: 80;
389
+ }
390
+ .overlay.open{ display:flex; }
391
+ .modal{
392
+ width: min(720px, 100%);
393
+ border-radius: 26px;
394
+ background: rgba(255,255,255,.92);
395
+ border: 1px solid rgba(255,255,255,.65);
396
+ box-shadow: 0 30px 80px rgba(0,0,0,.35);
397
+ overflow: hidden;
398
+ backdrop-filter: blur(18px);
399
+ -webkit-backdrop-filter: blur(18px);
400
+ }
401
+ .modal-head{
402
+ padding: 14px 16px;
403
+ display:flex;
404
+ justify-content: space-between;
405
+ align-items:center;
406
+ border-bottom: 1px solid rgba(0,0,0,.06);
407
+ }
408
+ .modal-head h3{ margin:0; font-size: 15px; font-weight: 760; }
409
+ .xbtn{
410
+ border: 1px solid rgba(0,0,0,.08);
411
+ background: rgba(255,255,255,.7);
412
+ border-radius: 999px;
413
+ padding: 8px 10px;
414
+ cursor: pointer;
415
+ font-size: 12px;
416
+ }
417
+ .modal-body{ padding: 16px; }
418
+ .modal-body p{ margin: 8px 0 0; color: var(--muted); line-height: 1.45; }
419
+ .modal-grid{
420
+ display:grid;
421
+ grid-template-columns: 1fr 1fr;
422
+ gap: 10px;
423
+ margin-top: 14px;
424
+ }
425
+ .mini{
426
+ border: 1px solid rgba(0,0,0,.06);
427
+ background: rgba(245,245,247,.85);
428
+ border-radius: 16px;
429
+ padding: 10px 12px;
430
+ }
431
+ .mini .k{ font-size: 11px; color: var(--muted); }
432
+ .mini .v{ font-size: 12.5px; font-weight: 650; margin-top: 3px; }
433
+
434
+ footer{
435
+ max-width: 1200px;
436
+ margin: 0 auto 30px;
437
+ padding: 0 16px;
438
+ color: var(--muted);
439
+ font-size: 12px;
440
+ display:flex;
441
+ justify-content: space-between;
442
+ gap: 14px;
443
+ flex-wrap: wrap;
444
+ }
445
+
446
+ @media (max-width: 860px){
447
+ .topbar-inner{ flex-wrap: wrap; }
448
+ .brand{ min-width: unset; width: 100%; }
449
+ .pillrow{ width: 100%; justify-content: flex-start; }
450
+ .summary{ grid-template-columns: 1fr; }
451
+ .month{ min-width: 290px; max-width: 290px; }
452
+ .modal-grid{ grid-template-columns: 1fr; }
453
+ }
454
+ </style>
455
+ </head>
456
+
457
+ <body>
458
+ <div class="topbar">
459
+ <div class="topbar-inner">
460
+ <div class="brand">
461
+ <div class="logo" aria-hidden="true"></div>
462
+ <div>
463
+ <h1>AI Release Timeline</h1>
464
+ <p id="subtitle">Open weights + API releases • 2024 → present</p>
465
+ </div>
466
+ </div>
467
+
468
+ <div class="search" role="search">
469
+ <span style="color:var(--muted);font-size:13px;">⌘K</span>
470
+ <input id="searchInput" placeholder="Search: Llama, Mistral, diffusion, Claude…" autocomplete="off" />
471
+ </div>
472
+
473
+ <div class="pillrow">
474
+ <span class="pill active" data-filter="all">All</span>
475
+ <span class="pill" data-filter="open-weights">Open weights</span>
476
+ <span class="pill" data-filter="api">API only</span>
477
+ <select id="monthJump" class="select" title="Jump to month"></select>
478
+ </div>
479
+ </div>
480
+ </div>
481
+
482
+ <div class="summary">
483
+ <div class="card">
484
+ <div class="card-inner kpis">
485
+ <div class="kpi">
486
+ <span class="dot" style="background:var(--good)"></span>
487
+ <div>
488
+ <p class="label">Open weights</p>
489
+ <p class="value" id="kpiOpen">—</p>
490
+ </div>
491
+ </div>
492
+ <div class="kpi">
493
+ <span class="dot" style="background:var(--bad)"></span>
494
+ <div>
495
+ <p class="label">API only</p>
496
+ <p class="value" id="kpiApi">—</p>
497
+ </div>
498
+ </div>
499
+ <div class="kpi">
500
+ <span class="dot" style="background:var(--accent)"></span>
501
+ <div>
502
+ <p class="label">Total</p>
503
+ <p class="value" id="kpiTotal">—</p>
504
+ </div>
505
+ </div>
506
+ </div>
507
+ </div>
508
+
509
+ <div class="card">
510
+ <div class="card-inner">
511
+ <div class="hint">
512
+ <div><b>Tips</b></div>
513
+ <div>• Press <b>⌘K</b> (or Ctrl+K) to focus search.</div>
514
+ <div>• Use ← / → to move month-by-month.</div>
515
+ <div>• Click any card to open details.</div>
516
+ <div style="margin-top:8px;">
517
+ Data refresh: <span id="asOf">—</span>
518
+ </div>
519
+ </div>
520
+ </div>
521
+ </div>
522
+ </div>
523
+
524
+ <div class="wrap">
525
+ <div class="scroll-controls">
526
+ <div id="timeline" class="timeline" aria-label="Timeline (horizontal scroll)">
527
+ <!-- populated by JS -->
528
+ </div>
529
+ </div>
530
+ </div>
531
+
532
+ <!-- Fixed overlay buttons for easy access -->
533
+ <button id="scrollLeftFixed" class="scroll-btn left fixed" aria-label="Scroll left">◀</button>
534
+ <button id="scrollRightFixed" class="scroll-btn right fixed" aria-label="Scroll right">▶</button>
535
+ <div class="scroll-band left" aria-hidden="true"></div>
536
+ <div class="scroll-band right" aria-hidden="true"></div>
537
+
538
+ <div id="overlay" class="overlay" role="dialog" aria-modal="true" aria-label="Event details">
539
+ <div class="modal">
540
+ <div class="modal-head">
541
+ <h3 id="mTitle">—</h3>
542
+ <button class="xbtn" id="closeBtn">Close</button>
543
+ </div>
544
+ <div class="modal-body">
545
+ <div id="mTag"></div>
546
+ <p id="mDesc">—</p>
547
+ <div style="margin-top:10px;">
548
+ <a id="mLink" href="#" target="_blank" rel="noreferrer">Open link →</a>
549
+ </div>
550
+
551
+ <div class="modal-grid">
552
+ <div class="mini">
553
+ <div class="k">Month</div>
554
+ <div class="v" id="mMonth">—</div>
555
+ </div>
556
+ <div class="mini">
557
+ <div class="k">Date</div>
558
+ <div class="v" id="mDate">—</div>
559
+ </div>
560
+ <div class="mini">
561
+ <div class="k">Source</div>
562
+ <div class="v" id="mSource">—</div>
563
+ </div>
564
+ <div class="mini">
565
+ <div class="k">Org / Author</div>
566
+ <div class="v" id="mOrg">—</div>
567
+ </div>
568
+ </div>
569
+ </div>
570
+ </div>
571
+ </div>
572
+
573
+ <footer>
574
+ <div> &#169 Made for fast scanning & navigation by Rohit Poreddy.</div>
575
+ <div><span id="metaNote">—</span></div>
576
+ </footer>
577
+
578
+ <script>
579
+ const el = (id) => document.getElementById(id);
580
+ const timeline = el("timeline");
581
+
582
+ let STATE = {
583
+ events: [],
584
+ filter: "all",
585
+ q: "",
586
+ months: [],
587
+ monthIndexByName: new Map(),
588
+ };
589
+
590
+ function groupByMonth(events){
591
+ const groups = new Map();
592
+ for (const e of events){
593
+ const month = e.month || "Unknown";
594
+ if (!groups.has(month)) groups.set(month, []);
595
+ groups.get(month).push(e);
596
+ }
597
+ // Sort months chronologically by first event date we have (date is YYYY-MM-DD)
598
+ const monthList = Array.from(groups.entries()).map(([month, evs]) => {
599
+ evs.sort((a,b)=> (b.date||"").localeCompare(a.date||""));
600
+ const key = evs.length ? (evs[0].date || "") : "";
601
+ return { month, key, events: evs };
602
+ });
603
+ monthList.sort((a,b)=> (a.key||"").localeCompare(b.key||"")); // oldest -> newest
604
+ return monthList;
605
+ }
606
+
607
+ function tagChip(category){
608
+ const cls = category === "open-weights" ? "open-weights" : "api";
609
+ const label = category === "open-weights" ? "Open weights" : "API only";
610
+ return `<span class="tag ${cls}"><span class="dot"></span>${label}</span>`;
611
+ }
612
+
613
+ function render(){
614
+ timeline.innerHTML = "";
615
+
616
+ const months = groupByMonth(STATE.events);
617
+ STATE.months = months;
618
+ STATE.monthIndexByName = new Map(months.map((m,i)=>[m.month,i]));
619
+
620
+ // Month jump options
621
+ const jump = el("monthJump");
622
+ jump.innerHTML = "";
623
+ for (let i=0;i<months.length;i++){
624
+ const opt = document.createElement("option");
625
+ opt.value = months[i].month;
626
+ opt.textContent = months[i].month;
627
+ jump.appendChild(opt);
628
+ }
629
+ // default to latest month
630
+ if (months.length){
631
+ jump.value = months[months.length-1].month;
632
+ }
633
+
634
+ // Render month columns
635
+ months.forEach((m, idx) => {
636
+ const col = document.createElement("section");
637
+ col.className = "month";
638
+ col.dataset.month = m.month;
639
+
640
+ const head = document.createElement("div");
641
+ head.className = "month-header";
642
+ head.innerHTML = `<h2>${m.month}</h2><div class="count">${m.events.length} items</div>`;
643
+ col.appendChild(head);
644
+
645
+ const list = document.createElement("div");
646
+ list.className = "events";
647
+
648
+ for (const e of m.events){
649
+ const card = document.createElement("article");
650
+ card.className = "event";
651
+ card.tabIndex = 0;
652
+ card.innerHTML = `
653
+ <div style="display:flex;justify-content:space-between;gap:10px;align-items:flex-start;">
654
+ <div>
655
+ <p class="event-title">${escapeHtml(e.title || "Untitled")}</p>
656
+ <p class="event-desc">${escapeHtml(e.description || "")}</p>
657
+ </div>
658
+ </div>
659
+ <div class="meta">
660
+ ${tagChip(e.category)}
661
+ <span class="small">${escapeHtml(e.date || "")}</span>
662
+ </div>
663
+ `;
664
+ card.addEventListener("click", () => openModal(e));
665
+ card.addEventListener("keydown", (ev) => { if (ev.key === "Enter") openModal(e); });
666
+ list.appendChild(card);
667
+ }
668
+
669
+ col.appendChild(list);
670
+ timeline.appendChild(col);
671
+ });
672
+
673
+ // KPIs
674
+ const openCount = STATE.events.filter(e => e.category === "open-weights").length;
675
+ const apiCount = STATE.events.filter(e => e.category === "api").length;
676
+ el("kpiOpen").textContent = openCount;
677
+ el("kpiApi").textContent = apiCount;
678
+ el("kpiTotal").textContent = STATE.events.length;
679
+
680
+ // Meta footer
681
+ el("metaNote").textContent = `${STATE.events.length} events loaded • filter: ${STATE.filter}${STATE.q ? " • search: "+STATE.q : ""}`;
682
+ }
683
+
684
+ function escapeHtml(s){
685
+ return String(s).replace(/[&<>"']/g, (m)=>({ "&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#039;" }[m]));
686
+ }
687
+
688
+ async function load(){
689
+ const params = new URLSearchParams();
690
+ if (STATE.filter !== "all") params.set("category", STATE.filter);
691
+ if (STATE.q) params.set("q", STATE.q);
692
+
693
+ const res = await fetch(`/api/events?${params.toString()}`, { cache: "no-store" });
694
+ const data = await res.json();
695
+
696
+ el("asOf").textContent = new Date(data.as_of).toLocaleString();
697
+ el("subtitle").textContent = `Open weights + API releases • 2024 → present • ${data.count} showing`;
698
+ STATE.events = data.events || [];
699
+
700
+ // update month jump once we render
701
+ render();
702
+
703
+ // snap to latest month
704
+ requestAnimationFrame(() => scrollToMonth(el("monthJump").value, "instant"));
705
+ }
706
+
707
+ function scrollToMonth(month, behavior="smooth"){
708
+ const col = timeline.querySelector(`.month[data-month="${cssEscape(month)}"]`);
709
+ if (!col) return;
710
+ // center the month column in the timeline viewport
711
+ const containerWidth = timeline.clientWidth;
712
+ const colWidth = col.offsetWidth;
713
+ const maxScroll = Math.max(0, timeline.scrollWidth - containerWidth);
714
+
715
+ // Compute desired scrollLeft so the column is centered
716
+ const target = col.offsetLeft - Math.round((containerWidth - colWidth) / 2);
717
+ const bounded = Math.min(maxScroll, Math.max(0, target));
718
+
719
+ if (behavior === 'instant'){
720
+ timeline.scrollLeft = bounded;
721
+ return;
722
+ }
723
+
724
+ // perform smooth scroll to center the column
725
+ timeline.scrollTo({ left: bounded, behavior });
726
+ }
727
+
728
+ function cssEscape(s){
729
+ // minimal escape for attribute selector
730
+ return String(s).replace(/"/g, '\\"');
731
+ }
732
+
733
+ // Filters
734
+ document.querySelectorAll(".pill").forEach(p => {
735
+ p.addEventListener("click", () => {
736
+ document.querySelectorAll(".pill").forEach(x=>x.classList.remove("active"));
737
+ p.classList.add("active");
738
+ STATE.filter = p.dataset.filter;
739
+ load();
740
+ });
741
+ });
742
+
743
+ // Month jump
744
+ el("monthJump").addEventListener("change", (e) => scrollToMonth(e.target.value));
745
+
746
+ // Search (debounced)
747
+ let t = null;
748
+ el("searchInput").addEventListener("input", (e) => {
749
+ clearTimeout(t);
750
+ t = setTimeout(() => { STATE.q = e.target.value.trim(); load(); }, 220);
751
+ });
752
+
753
+ // Keyboard shortcuts
754
+ window.addEventListener("keydown", (e) => {
755
+ const isMac = navigator.platform.toLowerCase().includes("mac");
756
+ const cmdk = (isMac && e.metaKey && e.key.toLowerCase()==="k") || (!isMac && e.ctrlKey && e.key.toLowerCase()==="k");
757
+ if (cmdk){
758
+ e.preventDefault();
759
+ el("searchInput").focus();
760
+ el("searchInput").select();
761
+ return;
762
+ }
763
+ if (e.key === "Escape") closeModal();
764
+
765
+ if (e.key === "ArrowRight" || e.key === "ArrowLeft"){
766
+ const months = STATE.months || [];
767
+ if (!months.length) return;
768
+
769
+ // Find closest visible month column (based on scrollLeft)
770
+ const cols = Array.from(timeline.querySelectorAll(".month"));
771
+ const left = timeline.scrollLeft;
772
+ let bestIdx = 0;
773
+ let bestDist = Infinity;
774
+ cols.forEach((c, idx) => {
775
+ const dist = Math.abs(c.offsetLeft - left);
776
+ if (dist < bestDist){ bestDist = dist; bestIdx = idx; }
777
+ });
778
+
779
+ const nextIdx = e.key === "ArrowRight" ? Math.min(cols.length-1, bestIdx+1) : Math.max(0, bestIdx-1);
780
+ cols[nextIdx].scrollIntoView({ behavior: "smooth", inline: "start", block: "nearest" });
781
+ }
782
+ });
783
+
784
+ // Modal
785
+ const overlay = el("overlay");
786
+ const closeBtn = el("closeBtn");
787
+ closeBtn.addEventListener("click", closeModal);
788
+ overlay.addEventListener("click", (e) => { if (e.target === overlay) closeModal(); });
789
+
790
+ function openModal(e){
791
+ el("mTitle").textContent = e.title || "Untitled";
792
+ el("mDesc").textContent = e.description || "";
793
+ el("mMonth").textContent = e.month || "—";
794
+ el("mDate").textContent = e.date || "—";
795
+ el("mSource").textContent = e.source || "—";
796
+ el("mOrg").textContent = e.org || "—";
797
+
798
+ el("mTag").innerHTML = tagChip(e.category);
799
+
800
+ const link = el("mLink");
801
+ if (e.link){
802
+ link.style.display = "inline";
803
+ link.href = e.link;
804
+ } else {
805
+ link.style.display = "none";
806
+ }
807
+
808
+ overlay.classList.add("open");
809
+ }
810
+ function closeModal(){
811
+ overlay.classList.remove("open");
812
+ }
813
+
814
+ // Replace cursor auto-scroll with left/right arrow buttons (4-month jump)
815
+ (function(){
816
+ const leftBtn = el('scrollLeft');
817
+ const rightBtn = el('scrollRight');
818
+ const leftFixed = el('scrollLeftFixed');
819
+ const rightFixed = el('scrollRightFixed');
820
+
821
+ function scrollMonths(delta){
822
+ const cols = Array.from(timeline.querySelectorAll('.month'));
823
+ if (!cols.length) return;
824
+ const left = timeline.scrollLeft;
825
+ let bestIdx = 0;
826
+ let bestDist = Infinity;
827
+ cols.forEach((c, idx) => {
828
+ const dist = Math.abs(c.offsetLeft - left);
829
+ if (dist < bestDist){ bestDist = dist; bestIdx = idx; }
830
+ });
831
+ const targetIdx = Math.min(cols.length - 1, Math.max(0, bestIdx + delta));
832
+ cols[targetIdx].scrollIntoView({ behavior: 'smooth', inline: 'start', block: 'nearest' });
833
+ }
834
+
835
+ [leftBtn, rightBtn, leftFixed, rightFixed].forEach(btn => {
836
+ if (!btn) return;
837
+ btn.addEventListener('click', (e) => {
838
+ if (btn === leftBtn || btn === leftFixed) scrollMonths(-4);
839
+ else scrollMonths(4);
840
+ });
841
+ });
842
+
843
+ // Horizontal scrolling with mouse wheel when over timeline
844
+ // Allow normal vertical page scroll unless horizontal intent is clear or Shift is held.
845
+ timeline.addEventListener('wheel', (ev) => {
846
+ const absX = Math.abs(ev.deltaX);
847
+ const absY = Math.abs(ev.deltaY);
848
+ const wantHorizontal = ev.shiftKey || absX > absY;
849
+ if (wantHorizontal){
850
+ // use deltaX when available, otherwise fall back to deltaY
851
+ const delta = ev.deltaX || ev.deltaY;
852
+ timeline.scrollLeft += delta;
853
+ ev.preventDefault();
854
+ }
855
+ // otherwise, let the wheel event scroll the page vertically
856
+ }, { passive: false });
857
+
858
+ // Click-and-drag to pan timeline
859
+ (function(){
860
+ let isDown = false;
861
+ let startX = 0;
862
+ let startScroll = 0;
863
+ timeline.addEventListener('mousedown', (e) => {
864
+ isDown = true; startX = e.clientX; startScroll = timeline.scrollLeft;
865
+ timeline.classList.add('dragging');
866
+ e.preventDefault();
867
+ });
868
+ window.addEventListener('mousemove', (e) => {
869
+ if (!isDown) return;
870
+ const dx = e.clientX - startX;
871
+ timeline.scrollLeft = startScroll - dx;
872
+ });
873
+ window.addEventListener('mouseup', () => { isDown = false; timeline.classList.remove('dragging'); });
874
+ })();
875
+ })();
876
+
877
+ // Initial load
878
+ load();
879
+ </script>
880
+ </body>
881
+ </html>