Kethan Dosapati commited on
Commit
8893529
·
1 Parent(s): dc57b3b

Add static UI implementation for browsing, submitting, and searching tips with HTML, CSS, and JavaScript; remove dependency on Gradio-based UI.

Browse files
README.md CHANGED
@@ -6,6 +6,7 @@ colorTo: purple
6
  sdk: docker
7
  pinned: false
8
  short_description: YantraBodha (యంత్రబోధ) - An open-source knowledge base where
 
9
  ---
10
 
11
  # Yantrabodha API
@@ -20,10 +21,17 @@ FastAPI backend with static UI for the Yantrabodha knowledge base.
20
  - `POST /api/post` — submit a new article
21
  - `GET /api/match?q=...` — full-text search
22
 
 
 
 
 
 
 
 
23
  ## Deploy on Hugging Face Spaces
24
 
25
  1. Create a new **Space**, choose **Docker** as the SDK.
26
- 2. Add **Secrets** in Space settings: `SUPABASE_URL`, `SUPABASE_SERVICE_KEY`.
27
  3. HF builds and runs the app; the UI is at the Space root.
28
 
29
  ## Local run
@@ -32,6 +40,9 @@ FastAPI backend with static UI for the Yantrabodha knowledge base.
32
  ```
33
  SUPABASE_URL=your_supabase_url
34
  SUPABASE_SERVICE_KEY=your_service_key
 
 
 
35
  ```
36
  2. Run the app:
37
 
 
6
  sdk: docker
7
  pinned: false
8
  short_description: YantraBodha (యంత్రబోధ) - An open-source knowledge base where
9
+ hf_oauth: true
10
  ---
11
 
12
  # Yantrabodha API
 
21
  - `POST /api/post` — submit a new article
22
  - `GET /api/match?q=...` — full-text search
23
 
24
+ ## Commenting (requires an account)
25
+
26
+ Users must sign in to comment. Two options:
27
+
28
+ - **Hugging Face** (when the app is hosted on HF Spaces): “Sign in with Hugging Face” uses HF OAuth (`hf_oauth: true` in the Space).
29
+ - **App account**: “Create account” / “Sign in” with email and password (Supabase Auth). To enable this, set **Secrets**: `SUPABASE_ANON_KEY` (Project Settings → API → anon public) and `SUPABASE_JWT_SECRET` (Project Settings → API → JWT Secret). The UI fetches `/api/config` to get the anon key for sign-up/sign-in; the backend uses the JWT secret to verify tokens when posting comments.
30
+
31
  ## Deploy on Hugging Face Spaces
32
 
33
  1. Create a new **Space**, choose **Docker** as the SDK.
34
+ 2. Add **Secrets**: `SUPABASE_URL`, `SUPABASE_SERVICE_KEY`. Optional (for app accounts): `SUPABASE_ANON_KEY`, `SUPABASE_JWT_SECRET`.
35
  3. HF builds and runs the app; the UI is at the Space root.
36
 
37
  ## Local run
 
40
  ```
41
  SUPABASE_URL=your_supabase_url
42
  SUPABASE_SERVICE_KEY=your_service_key
43
+ # Optional, for “Create account” / “Sign in”:
44
+ SUPABASE_ANON_KEY=your_anon_key
45
+ SUPABASE_JWT_SECRET=your_jwt_secret
46
  ```
47
  2. Run the app:
48
 
api/auth.py ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Resolve comment author from Supabase JWT (app account) or request body (HF OAuth)."""
2
+ import os
3
+ from typing import Optional
4
+
5
+ import jwt
6
+ from fastapi import Header, HTTPException
7
+
8
+ # JWT secret from Supabase Project Settings → API → JWT Secret (optional; needed for app-account comments)
9
+ SUPABASE_JWT_SECRET = os.environ.get("SUPABASE_JWT_SECRET")
10
+
11
+
12
+ def get_author_from_bearer(authorization: Optional[str] = Header(None)) -> Optional[str]:
13
+ """If Authorization: Bearer <supabase_jwt> is present, verify and return author name/email."""
14
+ if not SUPABASE_JWT_SECRET or not authorization or not authorization.startswith("Bearer "):
15
+ return None
16
+ token = authorization[7:].strip()
17
+ if not token:
18
+ return None
19
+ try:
20
+ payload = jwt.decode(
21
+ token,
22
+ SUPABASE_JWT_SECRET,
23
+ audience="authenticated",
24
+ algorithms=["HS256"],
25
+ )
26
+ meta = payload.get("user_metadata") or {}
27
+ name = meta.get("name") or meta.get("full_name")
28
+ if name:
29
+ return str(name).strip()
30
+ email = payload.get("email")
31
+ if email:
32
+ return str(email).strip()
33
+ return payload.get("sub", "")
34
+ except jwt.PyJWTError:
35
+ return None
api/endpoints/articles.py CHANGED
@@ -18,7 +18,7 @@ def list_articles(
18
  """List recent articles, newest first. Optional filters: since (ISO date), language, type."""
19
  query = (
20
  supabase.table("articles")
21
- .select("id, title, body, language, tags, type, contributing_agent, confidence, created_at, user_id, username, avatar_url")
22
  .order("created_at", desc=True)
23
  .limit(min(limit, 100))
24
  )
 
18
  """List recent articles, newest first. Optional filters: since (ISO date), language, type."""
19
  query = (
20
  supabase.table("articles")
21
+ .select("id, title, body, language, tags, type, contributing_agent, confidence, created_at")
22
  .order("created_at", desc=True)
23
  .limit(min(limit, 100))
24
  )
api/endpoints/comments.py CHANGED
@@ -1,8 +1,9 @@
1
  """GET/POST comments for an article."""
2
  from typing import Optional
3
 
4
- from fastapi import APIRouter, HTTPException
5
 
 
6
  from database import supabase
7
 
8
  router = APIRouter(tags=["comments"])
@@ -13,7 +14,7 @@ def list_comments(article_id: str):
13
  """List comments for an article, oldest first."""
14
  result = (
15
  supabase.table("comments")
16
- .select("id, article_id, body, author, created_at, user_id, avatar_url")
17
  .eq("article_id", article_id)
18
  .order("created_at", desc=False)
19
  .execute()
@@ -22,20 +23,30 @@ def list_comments(article_id: str):
22
 
23
 
24
  @router.post("/articles/{article_id}/comments", status_code=201)
25
- def create_comment(article_id: str, payload: dict):
26
- """Add a comment. JSON: { "body": "...", "author": "..." (optional), "user_id", "username", "avatar_url" (optional, for HF OAuth) }."""
 
 
 
 
27
  body = (payload.get("body") or "").strip()
28
- author = (payload.get("author") or "").strip() or None
29
- username = (payload.get("username") or "").strip() or None
30
- user_id = (payload.get("user_id") or "").strip() or None
31
- avatar_url = (payload.get("avatar_url") or "").strip() or None
32
- if username:
33
- author = author or username
34
  if not body:
35
  raise HTTPException(status_code=400, detail="body required")
36
  if len(body) > 2000:
37
  raise HTTPException(status_code=400, detail="body max 2000 characters")
38
- row = {"article_id": article_id, "body": body, "author": (author or "").strip() or None}
 
 
 
 
 
 
 
39
  if user_id:
40
  row["user_id"] = user_id
41
  if avatar_url:
 
1
  """GET/POST comments for an article."""
2
  from typing import Optional
3
 
4
+ from fastapi import APIRouter, Header, HTTPException
5
 
6
+ from auth import get_author_from_bearer
7
  from database import supabase
8
 
9
  router = APIRouter(tags=["comments"])
 
14
  """List comments for an article, oldest first."""
15
  result = (
16
  supabase.table("comments")
17
+ .select("id, article_id, body, author, created_at")
18
  .eq("article_id", article_id)
19
  .order("created_at", desc=False)
20
  .execute()
 
23
 
24
 
25
  @router.post("/articles/{article_id}/comments", status_code=201)
26
+ def create_comment(
27
+ article_id: str,
28
+ payload: dict,
29
+ authorization: Optional[str] = Header(None),
30
+ ):
31
+ """Add a comment. Requires an account: sign in with Hugging Face (send author in body) or with app account (Authorization: Bearer <token>)."""
32
  body = (payload.get("body") or "").strip()
33
+ author = get_author_from_bearer(authorization)
34
+ if not author:
35
+ author = (payload.get("author") or "").strip() or None
36
+ if payload.get("username"):
37
+ author = author or (payload.get("username") or "").strip()
 
38
  if not body:
39
  raise HTTPException(status_code=400, detail="body required")
40
  if len(body) > 2000:
41
  raise HTTPException(status_code=400, detail="body max 2000 characters")
42
+ if not author:
43
+ raise HTTPException(
44
+ status_code=401,
45
+ detail="Sign in to comment. Use Hugging Face (when on HF) or create an account / sign in.",
46
+ )
47
+ user_id = (payload.get("user_id") or "").strip() or None
48
+ avatar_url = (payload.get("avatar_url") or "").strip() or None
49
+ row = {"article_id": article_id, "body": body, "author": author}
50
  if user_id:
51
  row["user_id"] = user_id
52
  if avatar_url:
api/endpoints/config.py ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Public config for the frontend (e.g. Supabase URL and anon key for app auth)."""
2
+ import os
3
+
4
+ from fastapi import APIRouter
5
+
6
+ router = APIRouter(tags=["config"])
7
+
8
+
9
+ @router.get("/config")
10
+ def get_config():
11
+ """Return public config so the frontend can init Supabase Auth (create account / sign in)."""
12
+ url = os.environ.get("SUPABASE_URL", "")
13
+ anon = os.environ.get("SUPABASE_ANON_KEY", "")
14
+ return {"supabaseUrl": url, "supabaseAnonKey": anon}
api/endpoints/match.py CHANGED
@@ -18,7 +18,7 @@ def match_articles(
18
  """Full-text search across article titles and bodies."""
19
  query = (
20
  supabase.table("articles")
21
- .select("id, title, body, language, tags, type, contributing_agent, confidence, created_at, user_id, username, avatar_url")
22
  .text_search("fts", q, config="english")
23
  .limit(limit)
24
  )
 
18
  """Full-text search across article titles and bodies."""
19
  query = (
20
  supabase.table("articles")
21
+ .select("id, title, body, language, tags, type, contributing_agent, confidence, created_at")
22
  .text_search("fts", q, config="english")
23
  .limit(limit)
24
  )
api/main.py CHANGED
@@ -21,6 +21,7 @@ from fastapi.staticfiles import StaticFiles
21
  from database import supabase # noqa: F401 — ensure DB is loaded
22
  from endpoints.articles import router as articles_router
23
  from endpoints.comments import router as comments_router
 
24
  from endpoints.match import router as match_router
25
  from endpoints.post import router as post_router
26
  from endpoints.report import router as report_router
@@ -30,6 +31,7 @@ app = FastAPI(title="Yantrabodha API", version="1.0.0")
30
  app.include_router(post_router, prefix="/api/post")
31
  app.include_router(match_router, prefix="/api/match")
32
  app.include_router(report_router, prefix="/api")
 
33
  # More specific routes first: /api/articles/{id}/comments before /api/articles
34
  app.include_router(comments_router, prefix="/api")
35
  app.include_router(articles_router, prefix="/api")
 
21
  from database import supabase # noqa: F401 — ensure DB is loaded
22
  from endpoints.articles import router as articles_router
23
  from endpoints.comments import router as comments_router
24
+ from endpoints.config import router as config_router
25
  from endpoints.match import router as match_router
26
  from endpoints.post import router as post_router
27
  from endpoints.report import router as report_router
 
31
  app.include_router(post_router, prefix="/api/post")
32
  app.include_router(match_router, prefix="/api/match")
33
  app.include_router(report_router, prefix="/api")
34
+ app.include_router(config_router, prefix="/api")
35
  # More specific routes first: /api/articles/{id}/comments before /api/articles
36
  app.include_router(comments_router, prefix="/api")
37
  app.include_router(articles_router, prefix="/api")
api/requirements.txt CHANGED
@@ -3,3 +3,4 @@ uvicorn
3
  supabase
4
  pydantic
5
  python-dotenv
 
 
3
  supabase
4
  pydantic
5
  python-dotenv
6
+ PyJWT
api/static/app.js CHANGED
@@ -170,6 +170,15 @@
170
  });
171
  }
172
 
 
 
 
 
 
 
 
 
 
173
  // ——— Browse ———
174
  function loadBrowseFeed() {
175
  const timeVal = document.getElementById("browse-time").value;
@@ -203,16 +212,24 @@
203
  );
204
  })
205
  ).then(function (rows) {
 
206
  const cards = rows.map(function (r) {
207
  const cardHtml = articleToCard(r.article, r.comments);
208
- const commentId = "comment-" + r.article.id;
 
 
 
 
 
 
 
 
 
 
209
  return (
210
  '<div class="article-card-wrapper">' +
211
  '<div class="card-html">' + cardHtml + "</div>" +
212
- '<div class="card-comment-row">' +
213
- '<textarea class="comment-input" data-article-id="' + escapeHtml(r.article.id) + '" placeholder="Add a comment…" rows="3" maxlength="' + COMMENT_BODY_MAX + '"></textarea>' +
214
- '<button type="button" class="btn secondary submit-comment-btn">Submit comment</button>' +
215
- "</div>" +
216
  "</div>"
217
  );
218
  });
@@ -228,15 +245,19 @@
228
  }
229
 
230
  function bindBrowseCommentButtons() {
231
- const authorInput = document.getElementById("browse-comment-author");
232
  const msgEl = document.getElementById("browse-comment-msg");
233
  document.querySelectorAll(".submit-comment-btn").forEach(function (btn) {
234
  btn.onclick = function () {
 
 
 
 
 
235
  const row = btn.closest(".card-comment-row");
236
- const textarea = row.querySelector(".comment-input");
 
237
  const articleId = textarea.getAttribute("data-article-id");
238
  const body = (textarea.value || "").trim();
239
- const author = (authorInput && authorInput.value || "").trim() || null;
240
  if (!body) {
241
  msgEl.textContent = "Enter a comment.";
242
  return;
@@ -246,7 +267,14 @@
246
  return;
247
  }
248
  msgEl.textContent = "";
249
- apiPost("/api/articles/" + encodeURIComponent(articleId) + "/comments", { body: body, author: author })
 
 
 
 
 
 
 
250
  .then(function () {
251
  msgEl.textContent = "Comment added.";
252
  textarea.value = "";
@@ -350,6 +378,25 @@
350
  if (tabId === "browse") loadBrowseFeed();
351
  }
352
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
353
  // ——— Init ———
354
  document.querySelectorAll(".tab").forEach(function (t) {
355
  t.addEventListener("click", function () {
@@ -365,5 +412,12 @@
365
  if (e.key === "Enter") runSearch();
366
  });
367
 
 
 
 
 
 
 
368
  loadBrowseFeed();
 
369
  })();
 
170
  });
171
  }
172
 
173
+ function apiPostWithAuth(path, body, extraHeaders) {
174
+ var headers = { "Content-Type": "application/json", Accept: "application/json" };
175
+ if (extraHeaders) for (var k in extraHeaders) headers[k] = extraHeaders[k];
176
+ return fetch(path, { method: "POST", headers: headers, body: JSON.stringify(body) }).then(function (r) {
177
+ if (!r.ok) return r.json().then(function (j) { throw new Error(j.detail || r.statusText); });
178
+ return r.json();
179
+ });
180
+ }
181
+
182
  // ——— Browse ———
183
  function loadBrowseFeed() {
184
  const timeVal = document.getElementById("browse-time").value;
 
212
  );
213
  })
214
  ).then(function (rows) {
215
+ const isLoggedIn = typeof window.getCurrentUser === "function" && window.getCurrentUser();
216
  const cards = rows.map(function (r) {
217
  const cardHtml = articleToCard(r.article, r.comments);
218
+ const articleId = escapeHtml(r.article.id);
219
+ let commentRow;
220
+ if (isLoggedIn) {
221
+ commentRow =
222
+ '<textarea class="comment-input" data-article-id="' + articleId + '" placeholder="Add a comment…" rows="3" maxlength="' + COMMENT_BODY_MAX + '"></textarea>' +
223
+ '<button type="button" class="btn secondary submit-comment-btn">Submit comment</button>';
224
+ } else {
225
+ commentRow =
226
+ '<p class="signin-to-comment">Sign in with Hugging Face to comment.</p>' +
227
+ '<button type="button" class="btn primary hf-signin-btn">Sign in to comment</button>';
228
+ }
229
  return (
230
  '<div class="article-card-wrapper">' +
231
  '<div class="card-html">' + cardHtml + "</div>" +
232
+ '<div class="card-comment-row">' + commentRow + "</div>" +
 
 
 
233
  "</div>"
234
  );
235
  });
 
245
  }
246
 
247
  function bindBrowseCommentButtons() {
 
248
  const msgEl = document.getElementById("browse-comment-msg");
249
  document.querySelectorAll(".submit-comment-btn").forEach(function (btn) {
250
  btn.onclick = function () {
251
+ const user = typeof window.getCurrentUser === "function" ? window.getCurrentUser() : null;
252
+ if (!user) {
253
+ msgEl.textContent = "Sign in to comment (Hugging Face or create an account).";
254
+ return;
255
+ }
256
  const row = btn.closest(".card-comment-row");
257
+ const textarea = row && row.querySelector(".comment-input");
258
+ if (!textarea) return;
259
  const articleId = textarea.getAttribute("data-article-id");
260
  const body = (textarea.value || "").trim();
 
261
  if (!body) {
262
  msgEl.textContent = "Enter a comment.";
263
  return;
 
267
  return;
268
  }
269
  msgEl.textContent = "";
270
+ var payload = { body: body };
271
+ var headers = {};
272
+ if (user.token) {
273
+ headers["Authorization"] = "Bearer " + user.token;
274
+ } else {
275
+ payload.author = user.name || user.preferred_username || user.sub || "User";
276
+ }
277
+ apiPostWithAuth("/api/articles/" + encodeURIComponent(articleId) + "/comments", payload, headers)
278
  .then(function () {
279
  msgEl.textContent = "Comment added.";
280
  textarea.value = "";
 
378
  if (tabId === "browse") loadBrowseFeed();
379
  }
380
 
381
+ function updateTabsForAuth() {
382
+ var user = typeof window.getCurrentUser === "function" ? window.getCurrentUser() : null;
383
+ var submitTab = document.querySelector('.tab[data-tab="submit"]');
384
+ var searchTab = document.querySelector('.tab[data-tab="search"]');
385
+ if (!user) {
386
+ if (submitTab) submitTab.style.display = "none";
387
+ if (searchTab) searchTab.style.display = "none";
388
+ var submitPanel = document.getElementById("submit-panel");
389
+ var searchPanel = document.getElementById("search-panel");
390
+ if (submitPanel && !submitPanel.hidden) switchTab("browse");
391
+ if (searchPanel && !searchPanel.hidden) switchTab("browse");
392
+ } else {
393
+ if (submitTab) submitTab.style.display = "";
394
+ if (searchTab) searchTab.style.display = "";
395
+ }
396
+ }
397
+
398
+ window.__updateTabsForAuth = updateTabsForAuth;
399
+
400
  // ——— Init ———
401
  document.querySelectorAll(".tab").forEach(function (t) {
402
  t.addEventListener("click", function () {
 
412
  if (e.key === "Enter") runSearch();
413
  });
414
 
415
+ window.__onHfAuthChange = function () {
416
+ updateTabsForAuth();
417
+ var panel = document.getElementById("browse-panel");
418
+ if (panel && panel.classList.contains("active")) loadBrowseFeed();
419
+ };
420
+
421
  loadBrowseFeed();
422
+ updateTabsForAuth();
423
  })();
api/static/auth.js ADDED
@@ -0,0 +1,233 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Auth: Hugging Face (when on HF Spaces) or app account (create account / sign in with email).
3
+ * Commenting requires being signed in one way or the other.
4
+ */
5
+ (function () {
6
+ const HF_STORAGE_KEY = "yantrabodha_hf_oauth";
7
+ var supabase = null;
8
+
9
+ window.getHfUser = function () {
10
+ return window.__HF_USER__ || null;
11
+ };
12
+
13
+ window.getAppUser = function () {
14
+ return window.__APP_USER__ || null;
15
+ };
16
+
17
+ function setAppUserFromSession(session) {
18
+ if (!session || !session.user) { window.__APP_USER__ = null; return; }
19
+ var user = session.user;
20
+ var name = (user.user_metadata && (user.user_metadata.name || user.user_metadata.full_name)) || user.email;
21
+ window.__APP_USER__ = { name: name || user.email, email: user.email, token: session.access_token };
22
+ }
23
+
24
+ window.getCurrentUser = function () {
25
+ return window.getHfUser() || window.getAppUser();
26
+ };
27
+
28
+ window.signOutHf = function () {
29
+ try { sessionStorage.removeItem(HF_STORAGE_KEY); } catch (_) {}
30
+ window.__HF_USER__ = null;
31
+ renderAuthUI();
32
+ if (typeof window.__onHfAuthChange === "function") window.__onHfAuthChange();
33
+ };
34
+
35
+ window.signOutApp = function () {
36
+ if (window.__SUPABASE_CLIENT__) {
37
+ window.__SUPABASE_CLIENT__.auth.signOut().then(function () {
38
+ renderAuthUI();
39
+ if (typeof window.__onHfAuthChange === "function") window.__onHfAuthChange();
40
+ });
41
+ }
42
+ };
43
+
44
+ function renderAuthUI() {
45
+ var user = window.getCurrentUser();
46
+ var signin = document.getElementById("auth-signin");
47
+ var loggedin = document.getElementById("auth-loggedin");
48
+ var loggedinName = document.getElementById("auth-loggedin-name");
49
+ var loggedinImg = document.getElementById("auth-loggedin-img");
50
+ var hfBtn = document.getElementById("auth-signin-btn");
51
+ if (hfBtn) hfBtn.style.display = (window.huggingface && window.huggingface.variables) ? "" : "none";
52
+ var sep = document.querySelector(".auth-sep");
53
+ if (sep) sep.style.display = (window.huggingface && window.huggingface.variables) ? "" : "none";
54
+ if (!signin && !loggedin) return;
55
+ if (user) {
56
+ if (signin) signin.style.display = "none";
57
+ if (typeof window.__updateTabsForAuth === "function") window.__updateTabsForAuth();
58
+ if (loggedin) {
59
+ loggedin.style.display = "flex";
60
+ if (loggedinName) loggedinName.textContent = user.name || user.email || "User";
61
+ if (loggedinImg) {
62
+ if (user.picture) {
63
+ loggedinImg.src = user.picture;
64
+ loggedinImg.alt = "";
65
+ loggedinImg.style.display = "";
66
+ } else {
67
+ loggedinImg.style.display = "none";
68
+ }
69
+ }
70
+ }
71
+ } else {
72
+ if (signin) signin.style.display = "flex";
73
+ if (loggedin) loggedin.style.display = "none";
74
+ if (typeof window.__updateTabsForAuth === "function") window.__updateTabsForAuth();
75
+ }
76
+ }
77
+
78
+ function persistAndRender(result) {
79
+ var user = result && result.userInfo ? {
80
+ sub: result.userInfo.sub,
81
+ name: result.userInfo.name,
82
+ preferred_username: result.userInfo.preferred_username,
83
+ picture: result.userInfo.picture,
84
+ } : null;
85
+ try { if (user) sessionStorage.setItem(HF_STORAGE_KEY, JSON.stringify(user)); } catch (_) {}
86
+ window.__HF_USER__ = user;
87
+ renderAuthUI();
88
+ if (typeof window.__onHfAuthChange === "function") window.__onHfAuthChange();
89
+ }
90
+
91
+ async function doHfLogin() {
92
+ try {
93
+ var hub = await import("https://esm.sh/@huggingface/hub@0.21.0");
94
+ var scopes = window.huggingface && window.huggingface.variables && window.huggingface.variables.OAUTH_SCOPES;
95
+ var url = await hub.oauthLoginUrl(scopes ? { scopes: scopes } : {});
96
+ window.location.href = url + (url.indexOf("?") >= 0 ? "&" : "?") + "prompt=consent";
97
+ } catch (err) { console.warn("HF OAuth login failed:", err); }
98
+ }
99
+
100
+ function openModal(panel) {
101
+ var modal = document.getElementById("auth-modal");
102
+ if (!modal) return;
103
+ modal.hidden = false;
104
+ modal.setAttribute("aria-hidden", "false");
105
+ document.body.classList.add("auth-modal-open");
106
+ document.getElementById("auth-modal-signin").style.display = panel === "signin" ? "block" : "none";
107
+ document.getElementById("auth-modal-register").style.display = panel === "register" ? "block" : "none";
108
+ document.getElementById("auth-modal-error").style.display = "none";
109
+ document.addEventListener("keydown", onModalEscape);
110
+ }
111
+
112
+ function closeModal() {
113
+ var modal = document.getElementById("auth-modal");
114
+ if (modal) {
115
+ modal.hidden = true;
116
+ modal.setAttribute("aria-hidden", "true");
117
+ }
118
+ document.body.classList.remove("auth-modal-open");
119
+ document.removeEventListener("keydown", onModalEscape);
120
+ }
121
+
122
+ function onModalEscape(e) {
123
+ if (e.key === "Escape") closeModal();
124
+ }
125
+
126
+ function showAuthError(msg) {
127
+ var el = document.getElementById("auth-modal-error");
128
+ if (el) { el.textContent = msg || ""; el.style.display = msg ? "block" : "none"; }
129
+ }
130
+
131
+ async function initSupabase() {
132
+ try {
133
+ var r = await fetch("/api/config");
134
+ var config = await r.json();
135
+ if (!config.supabaseUrl || !config.supabaseAnonKey) return;
136
+ var mod = await import("https://cdn.jsdelivr.net/npm/@supabase/supabase-js@2/+esm");
137
+ var createClient = mod.createClient || (mod.default && mod.default.createClient);
138
+ window.__SUPABASE_CLIENT__ = createClient(config.supabaseUrl, config.supabaseAnonKey);
139
+ supabase = window.__SUPABASE_CLIENT__;
140
+ supabase.auth.onAuthStateChange(function (event, session) {
141
+ setAppUserFromSession(session);
142
+ renderAuthUI();
143
+ if (typeof window.__onHfAuthChange === "function") window.__onHfAuthChange();
144
+ });
145
+ var session = (await supabase.auth.getSession()).data.session;
146
+ setAppUserFromSession(session);
147
+ if (session) { renderAuthUI(); if (typeof window.__onHfAuthChange === "function") window.__onHfAuthChange(); }
148
+ } catch (_) {}
149
+ }
150
+
151
+ async function init() {
152
+ var stored = null;
153
+ try { var raw = sessionStorage.getItem(HF_STORAGE_KEY); if (raw) stored = JSON.parse(raw); } catch (_) {}
154
+ if (stored) { window.__HF_USER__ = stored; renderAuthUI(); }
155
+
156
+ await initSupabase();
157
+
158
+ if (window.huggingface && window.huggingface.variables) {
159
+ try {
160
+ var hub = await import("https://esm.sh/@huggingface/hub@0.21.0");
161
+ var oauthResult = await hub.oauthHandleRedirectIfPresent();
162
+ if (oauthResult) { persistAndRender(oauthResult); bindButtons(); return; }
163
+ } catch (e) { console.warn("HF OAuth redirect failed:", e); }
164
+ }
165
+
166
+ renderAuthUI();
167
+ bindButtons();
168
+ if (typeof window.__updateTabsForAuth === "function") window.__updateTabsForAuth();
169
+ }
170
+
171
+ function bindButtons() {
172
+ var signinBtn = document.getElementById("auth-signin-btn");
173
+ if (signinBtn) signinBtn.onclick = doHfLogin;
174
+ document.body.addEventListener("click", function (e) {
175
+ if (e.target.closest(".hf-signin-btn")) { e.preventDefault(); doHfLogin(); }
176
+ });
177
+ var signoutBtn = document.getElementById("auth-signout-btn");
178
+ if (signoutBtn) {
179
+ signoutBtn.onclick = function () {
180
+ if (window.getHfUser()) window.signOutHf();
181
+ else window.signOutApp();
182
+ };
183
+ }
184
+ var appSigninBtn = document.getElementById("auth-app-signin-btn");
185
+ if (appSigninBtn) appSigninBtn.onclick = function () { openModal("signin"); };
186
+ var appRegisterBtn = document.getElementById("auth-app-register-btn");
187
+ if (appRegisterBtn) appRegisterBtn.onclick = function () { openModal("register"); };
188
+ document.getElementById("auth-show-register") && document.getElementById("auth-show-register").addEventListener("click", function () { openModal("register"); });
189
+ document.getElementById("auth-show-signin") && document.getElementById("auth-show-signin").addEventListener("click", function () { openModal("signin"); });
190
+ var closeBtn = document.querySelector(".auth-modal-close");
191
+ if (closeBtn) closeBtn.addEventListener("click", closeModal);
192
+ var backdrop = document.querySelector(".auth-modal-backdrop");
193
+ if (backdrop) backdrop.addEventListener("click", closeModal);
194
+ var formSignin = document.getElementById("auth-form-signin");
195
+ if (formSignin) {
196
+ formSignin.onsubmit = async function (e) {
197
+ e.preventDefault();
198
+ showAuthError("");
199
+ if (!window.__SUPABASE_CLIENT__) { showAuthError("App sign-in not configured."); return; }
200
+ var email = document.getElementById("auth-email-signin").value.trim();
201
+ var password = document.getElementById("auth-password-signin").value;
202
+ try {
203
+ var res = await window.__SUPABASE_CLIENT__.auth.signInWithPassword({ email: email, password: password });
204
+ if (res.error) throw new Error(res.error.message);
205
+ closeModal();
206
+ renderAuthUI();
207
+ if (typeof window.__onHfAuthChange === "function") window.__onHfAuthChange();
208
+ } catch (err) { showAuthError(err.message || "Sign in failed."); }
209
+ };
210
+ }
211
+ var formRegister = document.getElementById("auth-form-register");
212
+ if (formRegister) {
213
+ formRegister.onsubmit = async function (e) {
214
+ e.preventDefault();
215
+ showAuthError("");
216
+ if (!window.__SUPABASE_CLIENT__) { showAuthError("Create account not configured."); return; }
217
+ var name = document.getElementById("auth-name-register").value.trim();
218
+ var email = document.getElementById("auth-email-register").value.trim();
219
+ var password = document.getElementById("auth-password-register").value;
220
+ try {
221
+ var res = await window.__SUPABASE_CLIENT__.auth.signUp({ email: email, password: password, options: { data: { name: name || email } } });
222
+ if (res.error) throw new Error(res.error.message);
223
+ closeModal();
224
+ showAuthError("");
225
+ renderAuthUI();
226
+ if (typeof window.__onHfAuthChange === "function") window.__onHfAuthChange();
227
+ } catch (err) { showAuthError(err.message || "Create account failed."); }
228
+ };
229
+ }
230
+ }
231
+
232
+ init();
233
+ })();
api/static/index.html CHANGED
@@ -13,8 +13,25 @@
13
  <body>
14
  <div class="container">
15
  <header class="header">
16
- <h1>Yantrabodha</h1>
17
- <p class="subtitle">Knowledge base tips — browse, add, and search</p>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
18
  </header>
19
 
20
  <nav class="tabs" role="tablist">
@@ -65,10 +82,6 @@
65
  </select>
66
  </label>
67
  </div>
68
- <p class="comment-author-row">
69
- <label for="browse-comment-author">Your name (optional)</label>
70
- <input type="text" id="browse-comment-author" maxlength="100" placeholder="Your name (optional)" />
71
- </p>
72
  <div id="browse-feed" class="feed" aria-busy="false">
73
  <div class="empty-state">Loading…</div>
74
  </div>
@@ -176,6 +189,33 @@
176
  </div>
177
  </section>
178
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
179
  <script src="app.js"></script>
180
  </body>
181
  </html>
 
13
  <body>
14
  <div class="container">
15
  <header class="header">
16
+ <div class="header-top">
17
+ <div>
18
+ <h1>Yantrabodha</h1>
19
+ <p class="subtitle">Knowledge base tips — browse, add, and search</p>
20
+ </div>
21
+ <div id="auth-header" class="auth-header">
22
+ <div id="auth-signin" class="auth-signin-row" style="display: none;">
23
+ <button type="button" id="auth-signin-btn" class="btn secondary btn-sm">Sign in with Hugging Face</button>
24
+ <span class="auth-sep">or</span>
25
+ <button type="button" id="auth-app-signin-btn" class="btn secondary btn-sm">Sign in</button>
26
+ <button type="button" id="auth-app-register-btn" class="btn primary btn-sm">Create account</button>
27
+ </div>
28
+ <div id="auth-loggedin" class="auth-loggedin" style="display: none;">
29
+ <img id="auth-loggedin-img" class="auth-avatar" src="" alt="" />
30
+ <span id="auth-loggedin-name" class="auth-name"></span>
31
+ <button type="button" id="auth-signout-btn" class="btn secondary btn-sm">Sign out</button>
32
+ </div>
33
+ </div>
34
+ </div>
35
  </header>
36
 
37
  <nav class="tabs" role="tablist">
 
82
  </select>
83
  </label>
84
  </div>
 
 
 
 
85
  <div id="browse-feed" class="feed" aria-busy="false">
86
  <div class="empty-state">Loading…</div>
87
  </div>
 
189
  </div>
190
  </section>
191
  </div>
192
+ <div id="auth-modal" class="auth-modal" hidden aria-modal="true" aria-hidden="true" role="dialog" aria-label="Sign in or create account">
193
+ <div class="auth-modal-backdrop"></div>
194
+ <div class="auth-modal-box">
195
+ <button type="button" class="auth-modal-close" aria-label="Close">&times;</button>
196
+ <div id="auth-modal-signin" class="auth-modal-panel">
197
+ <h3 class="auth-modal-title">Sign in</h3>
198
+ <form id="auth-form-signin" class="auth-form">
199
+ <label>Email <input type="email" id="auth-email-signin" required placeholder="you@example.com" /></label>
200
+ <label>Password <input type="password" id="auth-password-signin" required /></label>
201
+ <button type="submit" class="btn primary">Sign in</button>
202
+ </form>
203
+ <p class="auth-modal-switch">Don’t have an account? <button type="button" id="auth-show-register" class="auth-link">Create account</button></p>
204
+ </div>
205
+ <div id="auth-modal-register" class="auth-modal-panel" style="display: none;">
206
+ <h3 class="auth-modal-title">Create account</h3>
207
+ <form id="auth-form-register" class="auth-form">
208
+ <label>Name <input type="text" id="auth-name-register" placeholder="Your name" /></label>
209
+ <label>Email <input type="email" id="auth-email-register" required placeholder="you@example.com" /></label>
210
+ <label>Password <input type="password" id="auth-password-register" required minlength="6" placeholder="min 6 characters" /></label>
211
+ <button type="submit" class="btn primary">Create account</button>
212
+ </form>
213
+ <p class="auth-modal-switch">Already have an account? <button type="button" id="auth-show-signin" class="auth-link">Sign in</button></p>
214
+ </div>
215
+ <p id="auth-modal-error" class="auth-modal-error" style="display: none;"></p>
216
+ </div>
217
+ </div>
218
+ <script src="auth.js"></script>
219
  <script src="app.js"></script>
220
  </body>
221
  </html>
api/static/styles.css CHANGED
@@ -63,6 +63,165 @@ body {
63
  font-size: 0.9375rem;
64
  }
65
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
66
  /* Tabs — pill style */
67
  .tabs {
68
  display: flex;
 
63
  font-size: 0.9375rem;
64
  }
65
 
66
+ .header-top {
67
+ display: flex;
68
+ flex-wrap: wrap;
69
+ justify-content: space-between;
70
+ align-items: flex-start;
71
+ gap: 1rem;
72
+ }
73
+
74
+ .auth-header {
75
+ flex-shrink: 0;
76
+ }
77
+
78
+ .auth-loggedin {
79
+ display: flex;
80
+ align-items: center;
81
+ gap: 0.5rem;
82
+ }
83
+
84
+ .auth-avatar {
85
+ width: 28px;
86
+ height: 28px;
87
+ border-radius: 50%;
88
+ object-fit: cover;
89
+ }
90
+
91
+ .auth-name {
92
+ font-size: 0.875rem;
93
+ font-weight: 500;
94
+ color: var(--text-muted);
95
+ }
96
+
97
+ .btn-sm {
98
+ padding: 0.4rem 0.75rem;
99
+ font-size: 0.8125rem;
100
+ }
101
+
102
+ .signin-to-comment {
103
+ margin: 0 0 0.5rem 0;
104
+ font-size: 0.875rem;
105
+ color: var(--text-light);
106
+ }
107
+
108
+ .auth-signin-row {
109
+ display: flex;
110
+ align-items: center;
111
+ gap: 0.5rem;
112
+ flex-wrap: wrap;
113
+ }
114
+
115
+ .auth-sep {
116
+ font-size: 0.75rem;
117
+ color: var(--text-light);
118
+ }
119
+
120
+ body.auth-modal-open {
121
+ overflow: hidden;
122
+ height: 100%;
123
+ }
124
+
125
+ .auth-modal {
126
+ position: fixed;
127
+ inset: 0;
128
+ z-index: 1000;
129
+ display: flex;
130
+ align-items: center;
131
+ justify-content: center;
132
+ padding: 1rem;
133
+ overflow: auto;
134
+ }
135
+
136
+ .auth-modal[hidden] {
137
+ display: none !important;
138
+ }
139
+
140
+ .auth-modal-backdrop {
141
+ position: absolute;
142
+ inset: 0;
143
+ background: rgba(0, 0, 0, 0.4);
144
+ }
145
+
146
+ .auth-modal-box {
147
+ position: relative;
148
+ background: var(--bg-card);
149
+ border-radius: var(--radius);
150
+ box-shadow: var(--shadow-md);
151
+ padding: 1.5rem;
152
+ max-width: 360px;
153
+ width: 100%;
154
+ }
155
+
156
+ .auth-modal-close {
157
+ position: absolute;
158
+ top: 0.5rem;
159
+ right: 0.5rem;
160
+ background: none;
161
+ border: none;
162
+ font-size: 1.5rem;
163
+ cursor: pointer;
164
+ color: var(--text-muted);
165
+ line-height: 1;
166
+ }
167
+
168
+ .auth-modal-close:hover {
169
+ color: var(--text);
170
+ }
171
+
172
+ .auth-modal-title {
173
+ margin: 0 0 1rem 0;
174
+ font-size: 1.25rem;
175
+ font-weight: 600;
176
+ }
177
+
178
+ .auth-form label {
179
+ display: block;
180
+ margin-bottom: 0.75rem;
181
+ font-size: 0.875rem;
182
+ font-weight: 500;
183
+ color: var(--text-muted);
184
+ }
185
+
186
+ .auth-form input {
187
+ display: block;
188
+ width: 100%;
189
+ margin-top: 0.25rem;
190
+ padding: 0.5rem 0.75rem;
191
+ font-size: 0.9rem;
192
+ border: 1px solid var(--border);
193
+ border-radius: var(--radius-sm);
194
+ }
195
+
196
+ .auth-form button[type="submit"] {
197
+ margin-top: 0.5rem;
198
+ }
199
+
200
+ .auth-modal-switch {
201
+ margin: 1rem 0 0 0;
202
+ font-size: 0.875rem;
203
+ color: var(--text-light);
204
+ }
205
+
206
+ .auth-link {
207
+ background: none;
208
+ border: none;
209
+ color: var(--primary);
210
+ cursor: pointer;
211
+ font-size: inherit;
212
+ text-decoration: underline;
213
+ }
214
+
215
+ .auth-link:hover {
216
+ color: var(--primary-hover);
217
+ }
218
+
219
+ .auth-modal-error {
220
+ margin: 0.75rem 0 0 0;
221
+ font-size: 0.875rem;
222
+ color: #b91c1c;
223
+ }
224
+
225
  /* Tabs — pill style */
226
  .tabs {
227
  display: flex;