Spaces:
Sleeping
Sleeping
Commit ·
ccdda22
1
Parent(s): 00b3af7
feat: group questions by save-batch minute, add user label, redesign inspector page
Browse files
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 |
-
|
| 1759 |
-
|
| 1760 |
-
|
| 1761 |
-
|
| 1762 |
-
|
| 1763 |
-
|
| 1764 |
-
|
| 1765 |
-
|
| 1766 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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(
|
|
|
|
|
|
|
|
|
|
| 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.
|
| 1810 |
-
<script defer src="https://cdn.jsdelivr.net/npm/katex@0.16.
|
| 1811 |
-
<script defer src="https://cdn.jsdelivr.net/npm/katex@0.16.
|
| 1812 |
onload="renderMathInElement(document.body, {{ delimiters: [
|
| 1813 |
{{left: '$$', right: '$$', display: true}},
|
| 1814 |
{{left: '$', right: '$', display: false}}
|
| 1815 |
]}});"></script>
|
|
|
|
| 1816 |
<style>
|
| 1817 |
-
|
| 1818 |
-
|
| 1819 |
-
|
| 1820 |
-
|
| 1821 |
-
|
| 1822 |
-
|
| 1823 |
-
|
| 1824 |
-
.
|
| 1825 |
-
.
|
| 1826 |
-
.
|
| 1827 |
-
.
|
| 1828 |
-
.
|
| 1829 |
-
.
|
| 1830 |
-
|
| 1831 |
-
.
|
| 1832 |
-
|
| 1833 |
-
|
| 1834 |
-
|
| 1835 |
-
|
| 1836 |
-
|
| 1837 |
-
|
| 1838 |
-
.
|
| 1839 |
-
.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1840 |
</style>
|
| 1841 |
-
</head>
|
| 1842 |
-
|
| 1843 |
-
|
| 1844 |
-
|
| 1845 |
-
|
| 1846 |
-
|
| 1847 |
-
<
|
| 1848 |
-
<
|
| 1849 |
-
|
| 1850 |
-
|
| 1851 |
-
|
| 1852 |
-
|
| 1853 |
-
|
| 1854 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1855 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1856 |
{empty}
|
| 1857 |
|
| 1858 |
-
<
|
|
|
|
| 1859 |
{dup_html}
|
| 1860 |
|
| 1861 |
-
<
|
| 1862 |
-
<
|
| 1863 |
-
|
| 1864 |
-
|
| 1865 |
-
|
| 1866 |
-
|
| 1867 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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">—</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">—</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> {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} — {chapter_label} — latest {len(questions)} of {total} questions — 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 |
|