Awab-Elsadig commited on
Commit
ccdda22
·
1 Parent(s): 00b3af7

feat: group questions by save-batch minute, add user label, redesign inspector page

Browse files
Files changed (1) hide show
  1. main.py +321 -79
main.py CHANGED
@@ -1730,12 +1730,50 @@ async def latest_questions_page(courseId: int, chapterId: Optional[int] = None,
1730
  )
1731
  questions = questions[:limit]
1732
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1733
  dup_groups: dict[str, list[dict]] = defaultdict(list)
1734
  for q in questions:
1735
  text = (q.get("questionText") or "").strip().lower()
1736
  if text:
1737
  dup_groups[text].append(q)
1738
  dups = {k: v for k, v in dup_groups.items() if len(v) > 1}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1739
 
1740
  def fmt_date(s):
1741
  if not s:
@@ -1747,6 +1785,16 @@ async def latest_questions_page(courseId: int, chapterId: Optional[int] = None,
1747
  except Exception:
1748
  return str(s)
1749
 
 
 
 
 
 
 
 
 
 
 
1750
  def trunc(s, n=240):
1751
  s = s or ""
1752
  return s if len(s) <= n else s[: n - 1] + "…"
@@ -1755,48 +1803,93 @@ async def latest_questions_page(courseId: int, chapterId: Optional[int] = None,
1755
  url = q.get("questionImageUrl")
1756
  if not url:
1757
  return '<span class="muted">—</span>'
1758
- return f'<a href="{html.escape(url)}" target="_blank"><img src="{html.escape(url)}" alt="figure"></a>'
1759
-
1760
- def meta_line(q):
1761
- return (
1762
- f"id={q.get('id')} · "
1763
- f"saved {fmt_date(q.get('createdAt'))} · "
1764
- f"chapter={q.get('chapterId')} · "
1765
- f"type={html.escape(str(q.get('questionType', '')))} · "
1766
- f"hasImage={'yes' if q.get('questionFileId') else 'no'}"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1767
  )
1768
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1769
  dup_html = ""
1770
  if dups:
1771
  parts = []
1772
  for text, items in sorted(dups.items(), key=lambda kv: -len(kv[1])):
1773
  sample = items[0].get("questionText") or ""
1774
- rows = "".join(f"<li><code>{meta_line(q)}</code></li>" for q in items)
 
 
 
1775
  parts.append(
1776
- f'<div class="card dup">'
1777
- f'<div class="dup-count">{len(items)} copies</div>'
1778
  f'<div class="qtext">{html.escape(sample)}</div>'
1779
- f'<ul>{rows}</ul></div>'
1780
  )
1781
  dup_html = "".join(parts)
1782
  else:
1783
- dup_html = '<p class="muted">No exact duplicates found.</p>'
1784
-
1785
- rows_html = "".join(
1786
- f"<tr>"
1787
- f"<td>{q.get('id')}</td>"
1788
- f"<td>{html.escape(fmt_date(q.get('createdAt')))}</td>"
1789
- f"<td>{q.get('chapterId')}</td>"
1790
- f"<td>{html.escape(str(q.get('questionType', '')))}</td>"
1791
- f"<td>{html.escape(str(q.get('difficulty', '') or ''))}</td>"
1792
- f"<td>{html.escape(str(q.get('bloomLevel', '') or ''))}</td>"
1793
- f"<td>{html.escape(str(q.get('status', '') or ''))}</td>"
1794
- f"<td class='img-cell'>{img_tag(q)}</td>"
1795
- f"<td>{'yes' if (q.get('expectedAnswerText') or '').strip() else 'no'}</td>"
1796
- f"<td title=\"{html.escape(q.get('questionText') or '')}\">{html.escape(trunc(q.get('questionText') or ''))}</td>"
1797
- f"</tr>"
1798
- for q in questions
1799
- )
1800
 
1801
  chapter_label = f"chapter {chapterId}" if chapterId is not None else "all chapters"
1802
  empty = "" if questions else f'<p class="muted">No questions found for course {courseId} ({chapter_label}).</p>'
@@ -1805,66 +1898,215 @@ async def latest_questions_page(courseId: int, chapterId: Optional[int] = None,
1805
  return f"""<!doctype html>
1806
  <html lang="en"><head>
1807
  <meta charset="utf-8">
 
1808
  <title>Latest Questions — course {courseId}</title>
1809
- <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.css">
1810
- <script defer src="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.js"></script>
1811
- <script defer src="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/contrib/auto-render.min.js"
1812
  onload="renderMathInElement(document.body, {{ delimiters: [
1813
  {{left: '$$', right: '$$', display: true}},
1814
  {{left: '$', right: '$', display: false}}
1815
  ]}});"></script>
 
1816
  <style>
1817
- body {{ font-family: -apple-system, Segoe UI, Roboto, sans-serif; max-width: 1300px; margin: 24px auto; padding: 0 16px; color: #1b1b1b; }}
1818
- h1 {{ font-size: 22px; margin-bottom: 4px; }}
1819
- h2 {{ margin-top: 32px; border-bottom: 1px solid #e5e5e5; padding-bottom: 4px; }}
1820
- form.filters {{ margin: 12px 0; padding: 10px 14px; background: #f6f8fa; border: 1px solid #e1e4e8; border-radius: 6px; display: flex; gap: 12px; align-items: end; flex-wrap: wrap; }}
1821
- form.filters label {{ display: flex; flex-direction: column; font-size: 12px; color: #555; }}
1822
- form.filters input {{ padding: 4px 8px; font-size: 14px; width: 110px; }}
1823
- form.filters button {{ padding: 6px 14px; font-size: 14px; background: #0969da; color: #fff; border: 0; border-radius: 4px; cursor: pointer; }}
1824
- .summary {{ background: #f6f8fa; border: 1px solid #e1e4e8; border-radius: 6px; padding: 12px 16px; }}
1825
- .summary span {{ display: inline-block; margin-right: 18px; }}
1826
- .summary b {{ font-size: 18px; }}
1827
- .muted {{ color: #6a737d; }}
1828
- .card {{ border: 1px solid #e1e4e8; border-radius: 6px; padding: 12px 14px; margin: 10px 0; background: #fff; }}
1829
- .dup {{ border-left: 4px solid #d73a49; }}
1830
- .dup-count {{ font-weight: bold; color: #d73a49; margin-bottom: 6px; }}
1831
- .qtext {{ white-space: pre-wrap; margin-bottom: 8px; }}
1832
- ul {{ margin: 4px 0 0 0; padding-left: 18px; }}
1833
- code {{ font-size: 12px; color: #444; }}
1834
- table {{ width: 100%; border-collapse: collapse; font-size: 13px; }}
1835
- th, td {{ text-align: left; padding: 6px 8px; border-bottom: 1px solid #eee; vertical-align: top; }}
1836
- th {{ background: #f6f8fa; position: sticky; top: 0; }}
1837
- td:last-child {{ max-width: 540px; }}
1838
- .img-cell img {{ max-width: 140px; max-height: 100px; border: 1px solid #ddd; border-radius: 3px; display: block; }}
1839
- .note {{ color: #6a737d; font-size: 12px; margin-top: 4px; }}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1840
  </style>
1841
- </head><body>
1842
- <h1>Latest Questions — course {courseId} ({chapter_label})</h1>
1843
- <div class="note">Sorted by createdAt DESC (falls back to id DESC if backend hasn't been redeployed with the createdAt field). Image URLs are Supabase signed URLs (1-hour TTL) — refresh the page if they stop loading.</div>
1844
- <form class="filters" method="get" action="/latest-questions">
1845
- <label>Course ID<input type="number" name="courseId" value="{courseId}" required></label>
1846
- <label>Chapter ID (optional)<input type="number" name="chapterId" value="{chapterId if chapterId is not None else ''}"></label>
1847
- <label>Limit<input type="number" name="limit" value="{limit}" min="1" max="500"></label>
1848
- <button type="submit">Refresh</button>
1849
- </form>
1850
- <div class="summary">
1851
- <span>Showing: <b>{len(questions)}</b> of <b>{total}</b></span>
1852
- <span>Duplicate groups: <b>{len(dups)}</b></span>
1853
- <span>Duplicate rows: <b>{sum(len(v) for v in dups.values())}</b></span>
1854
- <span>Generated: <b>{generated}</b></span>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1855
  </div>
 
 
 
 
 
 
 
 
 
 
 
1856
  {empty}
1857
 
1858
- <h2>Duplicates (exact text, case-insensitive)</h2>
 
1859
  {dup_html}
1860
 
1861
- <h2>Latest questions</h2>
1862
- <table>
1863
- <thead><tr>
1864
- <th>id</th><th>saved</th><th>ch</th><th>type</th><th>diff</th><th>bloom</th><th>status</th><th>image</th><th>answer</th><th>text</th>
1865
- </tr></thead>
1866
- <tbody>{rows_html}</tbody>
1867
- </table>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1868
  </body></html>"""
1869
 
1870
 
 
1730
  )
1731
  questions = questions[:limit]
1732
 
1733
+ # Fetch the logged-in user's profile to resolve their name
1734
+ known_users: dict[int, str] = {}
1735
+ try:
1736
+ async with httpx.AsyncClient() as _mc:
1737
+ _mr = await _mc.get(
1738
+ f"{EDUVERSE_API_URL}/api/auth/me",
1739
+ headers={"Authorization": f"Bearer {token}"},
1740
+ timeout=10,
1741
+ )
1742
+ if _mr.status_code == 200:
1743
+ _md = _mr.json()
1744
+ _uid = int(_md.get("userId") or 0)
1745
+ _name = f"{_md.get('firstName', '')} {_md.get('lastName', '')}".strip()
1746
+ if _uid and _name:
1747
+ known_users[_uid] = _name
1748
+ except Exception:
1749
+ pass
1750
+
1751
+ # Duplicate detection
1752
  dup_groups: dict[str, list[dict]] = defaultdict(list)
1753
  for q in questions:
1754
  text = (q.get("questionText") or "").strip().lower()
1755
  if text:
1756
  dup_groups[text].append(q)
1757
  dups = {k: v for k, v in dup_groups.items() if len(v) > 1}
1758
+ dup_ids: set[int] = {q.get("id") for items in dups.values() for q in items}
1759
+
1760
+ # Group by save-minute (YYYY-MM-DD HH:MM) + createdBy
1761
+ def minute_key(q):
1762
+ raw = q.get("createdAt") or ""
1763
+ if not raw:
1764
+ return ("~no-date", q.get("createdBy") or 0)
1765
+ try:
1766
+ iso = raw[:-1] + "+00:00" if raw.endswith("Z") else raw
1767
+ d = _dt.fromisoformat(iso)
1768
+ return (d.strftime("%Y-%m-%d %H:%M"), q.get("createdBy") or 0)
1769
+ except Exception:
1770
+ return ("~no-date", q.get("createdBy") or 0)
1771
+
1772
+ batches: dict[tuple, list[dict]] = defaultdict(list)
1773
+ for q in questions:
1774
+ batches[minute_key(q)].append(q)
1775
+ # Sort batches newest first
1776
+ sorted_batches = sorted(batches.items(), key=lambda kv: kv[0][0], reverse=True)
1777
 
1778
  def fmt_date(s):
1779
  if not s:
 
1785
  except Exception:
1786
  return str(s)
1787
 
1788
+ def fmt_minute(key):
1789
+ ts, _ = key
1790
+ if ts == "~no-date":
1791
+ return "Unknown time"
1792
+ try:
1793
+ d = _dt.strptime(ts, "%Y-%m-%d %H:%M")
1794
+ return d.strftime("%Y-%m-%d %I:%M %p") + " UTC"
1795
+ except Exception:
1796
+ return ts
1797
+
1798
  def trunc(s, n=240):
1799
  s = s or ""
1800
  return s if len(s) <= n else s[: n - 1] + "…"
 
1803
  url = q.get("questionImageUrl")
1804
  if not url:
1805
  return '<span class="muted">—</span>'
1806
+ esc = html.escape(url)
1807
+ return f'<img src="{esc}" alt="figure" onclick="openModal(\'{esc}\')">'
1808
+
1809
+ def type_badge(v):
1810
+ v = str(v or "")
1811
+ return f'<span class="badge badge-blue">{html.escape(v)}</span>' if v else '<span class="muted">—</span>'
1812
+
1813
+ def diff_badge(v):
1814
+ v = str(v or "")
1815
+ c = {"easy": "badge-green", "medium": "badge-blue", "hard": "badge-red"}.get(v.lower(), "badge-gray")
1816
+ return f'<span class="badge {c}">{html.escape(v)}</span>' if v else '<span class="muted">—</span>'
1817
+
1818
+ def status_badge(v):
1819
+ v = str(v or "")
1820
+ c = {"approved": "badge-green", "draft": "badge-gray", "rejected": "badge-red"}.get(v.lower(), "badge-gray")
1821
+ return f'<span class="badge {c}">{html.escape(v)}</span>' if v else '<span class="muted">—</span>'
1822
+
1823
+ def user_label(uid):
1824
+ uid = int(uid) if uid else 0
1825
+ name = known_users.get(uid)
1826
+ if name:
1827
+ return f'<span class="badge badge-blue">{html.escape(name)}</span>'
1828
+ return f'<span class="badge badge-gray">User #{uid}</span>' if uid else '<span class="muted">—</span>'
1829
+
1830
+ # Build batch HTML
1831
+ batches_html_parts = []
1832
+ for key, qs in sorted_batches:
1833
+ _, by_id = key
1834
+ q_rows = "".join(
1835
+ f"<tr{' class=\"dup-row\"' if q.get('id') in dup_ids else ''}>"
1836
+ f"<td><span class='badge badge-gray'>{q.get('id')}</span>{'<span class=\"dup-marker\" title=\"Duplicate\">⚠</span>' if q.get('id') in dup_ids else ''}</td>"
1837
+ f"<td>{type_badge(q.get('questionType'))}</td>"
1838
+ f"<td>{diff_badge(q.get('difficulty'))}</td>"
1839
+ f"<td><span class='badge badge-gray'>{html.escape(str(q.get('bloomLevel','') or ''))}</span></td>"
1840
+ f"<td>{status_badge(q.get('status'))}</td>"
1841
+ f"<td class='img-cell'>{img_tag(q)}</td>"
1842
+ f"<td>{'<span class=\"badge badge-green\">yes</span>' if (q.get('expectedAnswerText') or '').strip() else '<span class=\"badge badge-gray\">no</span>'}</td>"
1843
+ f"<td class='text-cell' title=\"{html.escape(q.get('questionText') or '')}\">{html.escape(trunc(q.get('questionText') or ''))}</td>"
1844
+ f"</tr>"
1845
+ for q in qs
1846
  )
1847
+ has_dups = any(q.get("id") in dup_ids for q in qs)
1848
+ dup_warn = ' <span class="badge badge-red" style="font-size:0.65rem;">has duplicates</span>' if has_dups else ""
1849
+ batches_html_parts.append(f"""
1850
+ <div class="batch-block">
1851
+ <div class="batch-header">
1852
+ <div class="batch-meta">
1853
+ <i data-lucide="clock" style="width:13px;height:13px;flex-shrink:0;"></i>
1854
+ <span class="batch-time">{html.escape(fmt_minute(key))}</span>
1855
+ <span class="batch-sep">&mdash;</span>
1856
+ <i data-lucide="user" style="width:13px;height:13px;flex-shrink:0;"></i>
1857
+ {user_label(by_id)}
1858
+ <span class="batch-sep">&mdash;</span>
1859
+ <span class="badge badge-gray">{len(qs)} question{'s' if len(qs) != 1 else ''}</span>
1860
+ {dup_warn}
1861
+ </div>
1862
+ </div>
1863
+ <div class="tbl-wrap">
1864
+ <table>
1865
+ <thead><tr>
1866
+ <th>ID</th><th>Type</th><th>Difficulty</th><th>Bloom</th><th>Status</th><th>Image</th><th>Answer</th><th>Question text</th>
1867
+ </tr></thead>
1868
+ <tbody>{q_rows}</tbody>
1869
+ </table>
1870
+ </div>
1871
+ </div>""")
1872
+ batches_html = "".join(batches_html_parts)
1873
+
1874
+ # Duplicate cards
1875
  dup_html = ""
1876
  if dups:
1877
  parts = []
1878
  for text, items in sorted(dups.items(), key=lambda kv: -len(kv[1])):
1879
  sample = items[0].get("questionText") or ""
1880
+ rows = "".join(
1881
+ f'<li>id={q.get("id")} · {html.escape(fmt_date(q.get("createdAt")))} · {html.escape(str(q.get("questionType","")))} · ch={q.get("chapterId")}</li>'
1882
+ for q in items
1883
+ )
1884
  parts.append(
1885
+ f'<div class="card dup-card">'
1886
+ f'<div class="dup-count"><i data-lucide="copy" style="width:11px;height:11px;"></i>&nbsp;{len(items)} copies</div>'
1887
  f'<div class="qtext">{html.escape(sample)}</div>'
1888
+ f'<ul class="meta-list">{rows}</ul></div>'
1889
  )
1890
  dup_html = "".join(parts)
1891
  else:
1892
+ dup_html = '<p class="muted" style="padding:4px 0;">No exact duplicates found.</p>'
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1893
 
1894
  chapter_label = f"chapter {chapterId}" if chapterId is not None else "all chapters"
1895
  empty = "" if questions else f'<p class="muted">No questions found for course {courseId} ({chapter_label}).</p>'
 
1898
  return f"""<!doctype html>
1899
  <html lang="en"><head>
1900
  <meta charset="utf-8">
1901
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
1902
  <title>Latest Questions — course {courseId}</title>
1903
+ <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/katex.min.css">
1904
+ <script defer src="https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/katex.min.js"></script>
1905
+ <script defer src="https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/contrib/auto-render.min.js"
1906
  onload="renderMathInElement(document.body, {{ delimiters: [
1907
  {{left: '$$', right: '$$', display: true}},
1908
  {{left: '$', right: '$', display: false}}
1909
  ]}});"></script>
1910
+ <script src="https://unpkg.com/lucide@latest/dist/umd/lucide.min.js"></script>
1911
  <style>
1912
+ *, *::before, *::after {{ box-sizing: border-box; margin: 0; padding: 0; }}
1913
+ body {{ font-family: system-ui, sans-serif; background: #f8fafc; color: #1e293b; line-height: 1.5; }}
1914
+ .container {{ max-width: 1200px; margin: 0 auto; padding: 28px 16px 60px; }}
1915
+ h1 {{ font-size: 1.4rem; font-weight: 700; }}
1916
+ .subtitle {{ color: #64748b; margin: 3px 0 20px; font-size: 0.85rem; }}
1917
+ h2 {{ font-size: 0.8rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.08em; color: #64748b; margin: 24px 0 10px; }}
1918
+ /* Buttons */
1919
+ .btn {{ padding: 8px 16px; border: none; border-radius: 6px; cursor: pointer; font-size: 0.85rem; font-weight: 600; font-family: inherit; transition: background 0.12s; display: inline-flex; align-items: center; gap: 6px; }}
1920
+ .btn-primary {{ background: #2563eb; color: white; }}
1921
+ .btn-primary:hover {{ background: #1d4ed8; }}
1922
+ .btn-secondary {{ background: #f1f5f9; color: #475569; border: 1px solid #e2e8f0; }}
1923
+ .btn-secondary:hover {{ background: #e2e8f0; }}
1924
+ .btn-sm {{ padding: 5px 12px; font-size: 0.78rem; }}
1925
+ /* Card */
1926
+ .card {{ background: white; border: 1px solid #e2e8f0; border-radius: 8px; padding: 16px 20px; margin-bottom: 10px; }}
1927
+ /* Dark mode toggle */
1928
+ .dm-toggle {{ display: inline-flex; align-items: center; justify-content: center; width: 32px; height: 32px; border-radius: 6px; border: 1px solid #e2e8f0; background: #f1f5f9; color: #475569; cursor: pointer; transition: background 0.15s, border-color 0.15s, color 0.15s; flex-shrink: 0; }}
1929
+ .dm-toggle:hover {{ background: #e2e8f0; color: #1e293b; }}
1930
+ /* Filter card */
1931
+ .filter-row {{ display: flex; gap: 10px; align-items: flex-end; flex-wrap: wrap; }}
1932
+ .filter-row label {{ display: flex; flex-direction: column; gap: 3px; font-size: 0.72rem; font-weight: 600; color: #64748b; text-transform: uppercase; letter-spacing: 0.05em; }}
1933
+ .filter-row input[type=number] {{ width: 100px; padding: 6px 8px; border: 1px solid #cbd5e1; border-radius: 6px; font-size: 0.85rem; font-family: inherit; -moz-appearance: textfield; appearance: textfield; }}
1934
+ .filter-row input[type=number]::-webkit-outer-spin-button,
1935
+ .filter-row input[type=number]::-webkit-inner-spin-button {{ -webkit-appearance: none; margin: 0; }}
1936
+ .filter-row input:focus {{ outline: none; border-color: #2563eb; }}
1937
+ /* Summary strip */
1938
+ .summary-strip {{ display: flex; flex-wrap: wrap; gap: 20px; }}
1939
+ .summary-strip .stat {{ display: flex; flex-direction: column; }}
1940
+ .summary-strip .stat-val {{ font-size: 1.5rem; font-weight: 700; line-height: 1.1; }}
1941
+ .summary-strip .stat-lbl {{ font-size: 0.72rem; color: #64748b; text-transform: uppercase; letter-spacing: 0.05em; }}
1942
+ /* Duplicate card */
1943
+ .dup-card {{ border-left: 3px solid #dc2626; }}
1944
+ .dup-count {{ display: inline-flex; align-items: center; gap: 5px; font-size: 0.72rem; font-weight: 700; color: #dc2626; text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 8px; }}
1945
+ .qtext {{ font-size: 0.875rem; line-height: 1.7; margin-bottom: 10px; overflow-x: auto; }}
1946
+ .meta-list {{ list-style: none; padding: 0; display: flex; flex-direction: column; gap: 4px; }}
1947
+ .meta-list li {{ font-size: 0.75rem; color: #64748b; background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 5px; padding: 4px 8px; font-family: monospace; }}
1948
+ /* Table */
1949
+ .tbl-wrap {{ overflow-x: auto; border: 1px solid #e2e8f0; border-radius: 8px; }}
1950
+ table {{ width: 100%; border-collapse: collapse; font-size: 0.8rem; }}
1951
+ th {{ background: #f8fafc; font-size: 0.68rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.07em; color: #64748b; padding: 8px 10px; text-align: left; position: sticky; top: 0; border-bottom: 1px solid #e2e8f0; }}
1952
+ td {{ padding: 8px 10px; border-bottom: 1px solid #f1f5f9; vertical-align: top; color: #1e293b; }}
1953
+ tr:last-child td {{ border-bottom: none; }}
1954
+ tr:hover td {{ background: #f8fafc; }}
1955
+ td.text-cell {{ max-width: 480px; font-size: 0.8rem; line-height: 1.6; overflow-x: auto; }}
1956
+ .badge {{ padding: 2px 7px; border-radius: 9999px; font-size: 0.65rem; font-weight: 600; white-space: nowrap; }}
1957
+ .badge-blue {{ background: #dbeafe; color: #1d4ed8; }}
1958
+ .badge-green {{ background: #dcfce7; color: #166534; }}
1959
+ .badge-gray {{ background: #f1f5f9; color: #64748b; }}
1960
+ .badge-red {{ background: #fee2e2; color: #dc2626; }}
1961
+ /* Image cell */
1962
+ .img-cell img {{ max-width: 120px; max-height: 90px; border: 1px solid #e2e8f0; border-radius: 5px; display: block; cursor: zoom-in; object-fit: contain; }}
1963
+ /* Batch groups */
1964
+ .batch-block {{ margin-bottom: 16px; }}
1965
+ .batch-header {{ display: flex; align-items: center; justify-content: space-between; margin-bottom: 6px; }}
1966
+ .batch-meta {{ display: flex; align-items: center; gap: 7px; font-size: 0.78rem; flex-wrap: wrap; }}
1967
+ .batch-time {{ font-weight: 600; color: #1e293b; }}
1968
+ .batch-sep {{ color: #cbd5e1; }}
1969
+ .dup-row td {{ background: #fff7ed !important; }}
1970
+ .dup-marker {{ color: #f59e0b; font-size: 0.75rem; margin-left: 4px; }}
1971
+ body.dark .batch-time {{ color: #e2e8f0; }}
1972
+ body.dark .batch-sep {{ color: #334155; }}
1973
+ body.dark .dup-row td {{ background: #2d1f0a !important; }}
1974
+ /* Image modal */
1975
+ .img-modal {{ position: fixed; inset: 0; background: rgba(15,23,42,0.8); display: none; align-items: center; justify-content: center; z-index: 300; padding: 18px; }}
1976
+ .img-modal.open {{ display: flex; }}
1977
+ .img-modal img {{ max-width: min(1100px,96vw); max-height: 90vh; border-radius: 8px; box-shadow: 0 16px 40px rgba(15,23,42,0.45); background: white; object-fit: contain; }}
1978
+ .img-modal-close {{ position: absolute; top: 14px; right: 14px; border: 1px solid #94a3b8; background: #0f172a; color: white; border-radius: 6px; font-size: 0.78rem; padding: 6px 10px; cursor: pointer; }}
1979
+ .muted {{ color: #94a3b8; font-size: 0.78rem; }}
1980
+ /* Dark mode */
1981
+ body.dark {{ background: #0f172a; color: #e2e8f0; }}
1982
+ body.dark .card {{ background: #1e293b; border-color: #334155; }}
1983
+ body.dark .btn-secondary {{ background: #1e293b; color: #94a3b8; border-color: #334155; }}
1984
+ body.dark .btn-secondary:hover {{ background: #334155; color: #e2e8f0; }}
1985
+ body.dark .dm-toggle {{ background: #1e293b; border-color: #334155; color: #94a3b8; }}
1986
+ body.dark .dm-toggle:hover {{ background: #334155; color: #e2e8f0; }}
1987
+ body.dark .subtitle {{ color: #64748b; }}
1988
+ body.dark h2 {{ color: #475569; }}
1989
+ body.dark .filter-row label {{ color: #64748b; }}
1990
+ body.dark .filter-row input[type=number] {{ background: #0f172a; border-color: #334155; color: #e2e8f0; }}
1991
+ body.dark .filter-row input:focus {{ border-color: #2563eb; }}
1992
+ body.dark .summary-strip .stat-lbl {{ color: #475569; }}
1993
+ body.dark .dup-card {{ border-left-color: #f87171; }}
1994
+ body.dark .dup-count {{ color: #f87171; }}
1995
+ body.dark .meta-list li {{ background: #0f172a; border-color: #334155; color: #94a3b8; }}
1996
+ body.dark .tbl-wrap {{ border-color: #334155; }}
1997
+ body.dark th {{ background: #1e293b; border-color: #334155; color: #475569; }}
1998
+ body.dark td {{ color: #e2e8f0; border-color: #1e293b; }}
1999
+ body.dark tr:hover td {{ background: #1e293b; }}
2000
+ body.dark .badge-blue {{ background: #1e3a5f; color: #93c5fd; }}
2001
+ body.dark .badge-green {{ background: #14532d; color: #86efac; }}
2002
+ body.dark .badge-gray {{ background: #1e293b; color: #64748b; }}
2003
+ body.dark .badge-red {{ background: #450a0a; color: #f87171; }}
2004
+ body.dark .img-cell img {{ border-color: #334155; }}
2005
+ body.dark .muted {{ color: #475569; }}
2006
  </style>
2007
+ </head>
2008
+ <body>
2009
+ <div class="container">
2010
+
2011
+ <!-- Header -->
2012
+ <div style="display:flex;align-items:flex-start;justify-content:space-between;gap:12px;margin-bottom:3px;">
2013
+ <h1>Latest Saved Questions</h1>
2014
+ <div style="display:flex;gap:8px;align-items:center;">
2015
+ <a href="/" class="btn btn-secondary btn-sm" style="text-decoration:none;">
2016
+ <i data-lucide="arrow-left" style="width:13px;height:13px;"></i>
2017
+ Back
2018
+ </a>
2019
+ <button class="dm-toggle" id="dm-toggle-btn" onclick="toggleDark()" title="Toggle dark mode" aria-label="Toggle dark mode">
2020
+ <svg id="dm-icon-moon" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>
2021
+ <svg id="dm-icon-sun" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="display:none;"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg>
2022
+ </button>
2023
+ </div>
2024
+ </div>
2025
+ <p class="subtitle">Course {courseId} &mdash; {chapter_label} &mdash; latest {len(questions)} of {total} questions &mdash; generated {generated}</p>
2026
+
2027
+ <!-- Filter card -->
2028
+ <div class="card" style="margin-bottom:16px;">
2029
+ <form method="get" action="/latest-questions">
2030
+ <div class="filter-row">
2031
+ <label>Course ID
2032
+ <input type="number" name="courseId" value="{courseId}" required>
2033
+ </label>
2034
+ <label>Chapter ID (optional)
2035
+ <input type="number" name="chapterId" value="{chapterId if chapterId is not None else ''}">
2036
+ </label>
2037
+ <label>Limit
2038
+ <input type="number" name="limit" value="{limit}" min="1" max="500">
2039
+ </label>
2040
+ <button type="submit" class="btn btn-primary btn-sm" style="margin-bottom:1px;">
2041
+ <i data-lucide="refresh-cw" style="width:13px;height:13px;"></i>
2042
+ Refresh
2043
+ </button>
2044
+ </div>
2045
+ </form>
2046
  </div>
2047
+
2048
+ <!-- Summary -->
2049
+ <div class="card" style="margin-bottom:16px;">
2050
+ <div class="summary-strip">
2051
+ <div class="stat"><span class="stat-val">{len(questions)}</span><span class="stat-lbl">Showing</span></div>
2052
+ <div class="stat"><span class="stat-val">{total}</span><span class="stat-lbl">Total</span></div>
2053
+ <div class="stat"><span class="stat-val">{len(dups)}</span><span class="stat-lbl">Dup groups</span></div>
2054
+ <div class="stat"><span class="stat-val">{sum(len(v) for v in dups.values())}</span><span class="stat-lbl">Dup rows</span></div>
2055
+ </div>
2056
+ </div>
2057
+
2058
  {empty}
2059
 
2060
+ <!-- Duplicates -->
2061
+ <h2><i data-lucide="copy" style="width:12px;height:12px;display:inline;vertical-align:middle;margin-right:4px;"></i>Duplicates (exact text)</h2>
2062
  {dup_html}
2063
 
2064
+ <!-- Batch groups -->
2065
+ <h2><i data-lucide="layers" style="width:12px;height:12px;display:inline;vertical-align:middle;margin-right:4px;"></i>Save batches (grouped by minute)</h2>
2066
+ {batches_html}
2067
+
2068
+ </div>
2069
+
2070
+ <!-- Image zoom modal -->
2071
+ <div class="img-modal" id="img-modal" onclick="closeModal()">
2072
+ <button class="img-modal-close" onclick="closeModal()">Close</button>
2073
+ <img id="img-modal-src" src="" alt="figure">
2074
+ </div>
2075
+
2076
+ <script>
2077
+ // Dark mode — reads same localStorage key as main page
2078
+ function applyDark(dark) {{
2079
+ document.body.classList.toggle('dark', dark);
2080
+ document.getElementById('dm-icon-moon').style.display = dark ? 'none' : '';
2081
+ document.getElementById('dm-icon-sun').style.display = dark ? '' : 'none';
2082
+ }}
2083
+ function toggleDark() {{
2084
+ var isDark = document.body.classList.contains('dark');
2085
+ applyDark(!isDark);
2086
+ try {{ localStorage.setItem('dm', !isDark ? '1' : '0'); }} catch(e) {{}}
2087
+ }}
2088
+ (function() {{
2089
+ try {{
2090
+ var saved = localStorage.getItem('dm');
2091
+ var prefersDark = saved !== null ? saved === '1' : window.matchMedia('(prefers-color-scheme: dark)').matches;
2092
+ applyDark(prefersDark);
2093
+ }} catch(e) {{}}
2094
+ }})();
2095
+
2096
+ // Image zoom
2097
+ function openModal(src) {{
2098
+ document.getElementById('img-modal-src').src = src;
2099
+ document.getElementById('img-modal').classList.add('open');
2100
+ }}
2101
+ function closeModal() {{
2102
+ document.getElementById('img-modal').classList.remove('open');
2103
+ document.getElementById('img-modal-src').src = '';
2104
+ }}
2105
+ document.addEventListener('keydown', function(e) {{ if (e.key === 'Escape') closeModal(); }});
2106
+
2107
+ // Lucide icons
2108
+ lucide.createIcons();
2109
+ </script>
2110
  </body></html>"""
2111
 
2112