Awab-Elsadig commited on
Commit
73e9d46
·
1 Parent(s): eb69985

Inspector: instant shell + animated loader, fix scrollbars and dark hint color

Browse files

- Page loads immediately showing a bouncing books animation while data fetches
- Data fetched async via /latest-questions-data, injected with createContextualFragment
- Removed overflow-x scroll from card text/answer/hint, use word-break instead
- Fixed hint color in dark mode (#94a3b8 instead of near-invisible #475569)

Files changed (1) hide show
  1. main.py +272 -189
main.py CHANGED
@@ -1668,9 +1668,181 @@ def serve_output(filename: str):
1668
 
1669
 
1670
  @app.get("/latest-questions", response_class=HTMLResponse)
1671
- async def latest_questions_page(courseId: int, chapterId: Optional[int] = None, limit: int = 100):
1672
- """Inspector page: latest questions saved to EduVerse for a given course/chapter,
1673
- with attached Supabase images and exact-text duplicate detection."""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1674
  from collections import defaultdict
1675
  from datetime import datetime as _dt, timezone as _tz
1676
 
@@ -1928,46 +2100,18 @@ async def latest_questions_page(courseId: int, chapterId: Optional[int] = None,
1928
  f'<span class="chip-sep"></span>'
1929
  ) if values else ""
1930
 
1931
- return f"""<!doctype html>
1932
- <html lang="en"><head>
1933
- <meta charset="utf-8">
1934
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
1935
- <title>Latest Questions — course {courseId}</title>
1936
- <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/katex.min.css">
1937
- <script defer src="https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/katex.min.js"></script>
1938
- <script defer src="https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/contrib/auto-render.min.js"
1939
- onload="renderMathInElement(document.body, {{ delimiters: [
1940
- {{left: '$$', right: '$$', display: true}},
1941
- {{left: '$', right: '$', display: false}}
1942
- ]}});"></script>
1943
- <script src="https://unpkg.com/lucide@latest/dist/umd/lucide.min.js"></script>
1944
- <style>
1945
- *, *::before, *::after {{ box-sizing: border-box; margin: 0; padding: 0; }}
1946
- body {{ font-family: system-ui, sans-serif; background: #f8fafc; color: #1e293b; line-height: 1.5; }}
1947
- .container {{ max-width: 1200px; margin: 0 auto; padding: 28px 16px 60px; }}
1948
- h1 {{ font-size: 1.4rem; font-weight: 700; }}
1949
  .subtitle {{ color: #64748b; margin: 3px 0 20px; font-size: 0.85rem; }}
1950
  h2 {{ font-size: 0.8rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.08em; color: #64748b; margin: 24px 0 10px; }}
1951
- /* Buttons */
1952
- .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; }}
1953
- .btn-primary {{ background: #2563eb; color: white; }}
1954
  .btn-primary:hover {{ background: #1d4ed8; }}
1955
- .btn-secondary {{ background: #f1f5f9; color: #475569; border: 1px solid #e2e8f0; }}
1956
- .btn-secondary:hover {{ background: #e2e8f0; }}
1957
- .btn-sm {{ padding: 5px 12px; font-size: 0.78rem; }}
1958
- /* Card */
1959
  .card {{ background: white; border: 1px solid #e2e8f0; border-radius: 8px; padding: 16px 20px; margin-bottom: 10px; }}
1960
- /* Dark mode toggle */
1961
- .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; }}
1962
- .dm-toggle:hover {{ background: #e2e8f0; color: #1e293b; }}
1963
- /* Filter card */
1964
  .filter-row {{ display: flex; gap: 10px; align-items: flex-end; flex-wrap: wrap; }}
1965
  .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; }}
1966
  .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; }}
1967
  .filter-row input[type=number]::-webkit-outer-spin-button,
1968
  .filter-row input[type=number]::-webkit-inner-spin-button {{ -webkit-appearance: none; margin: 0; }}
1969
  .filter-row input:focus {{ outline: none; border-color: #2563eb; }}
1970
- /* Live search + filter chips */
1971
  .search-bar {{ width: 100%; padding: 7px 11px 7px 34px; border: 1px solid #cbd5e1; border-radius: 6px; font-size: 0.85rem; font-family: inherit; background: #fff url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='14' height='14' viewBox='0 0 24 24' fill='none' stroke='%2394a3b8' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='11' cy='11' r='8'/%3E%3Cline x1='21' y1='21' x2='16.65' y2='16.65'/%3E%3C/svg%3E") no-repeat 10px center; }}
1972
  .search-bar:focus {{ outline: none; border-color: #2563eb; }}
1973
  .chip-row {{ display: flex; gap: 6px; flex-wrap: wrap; margin-top: 10px; align-items: center; }}
@@ -1977,87 +2121,60 @@ async def latest_questions_page(courseId: int, chapterId: Optional[int] = None,
1977
  .chip.active {{ background: #2563eb; color: #fff; border-color: #2563eb; }}
1978
  .chip-sep {{ width: 1px; height: 18px; background: #e2e8f0; margin: 0 2px; }}
1979
  .filter-count {{ font-size: 0.72rem; color: #64748b; margin-left: auto; }}
1980
- body.dark .search-bar {{ background-color: #0f172a; border-color: #334155; color: #e2e8f0; }}
1981
- body.dark .search-bar:focus {{ border-color: #2563eb; }}
1982
- body.dark .chip {{ background: #1e293b; border-color: #334155; color: #94a3b8; }}
1983
- body.dark .chip:hover {{ background: #334155; color: #e2e8f0; }}
1984
- body.dark .chip.active {{ background: #2563eb; color: #fff; border-color: #2563eb; }}
1985
- body.dark .chip-sep {{ background: #334155; }}
1986
- body.dark .filter-count {{ color: #475569; }}
1987
- /* Summary strip */
1988
  .summary-strip {{ display: flex; flex-wrap: wrap; gap: 20px; }}
1989
  .summary-strip .stat {{ display: flex; flex-direction: column; }}
1990
  .summary-strip .stat-val {{ font-size: 1.5rem; font-weight: 700; line-height: 1.1; }}
1991
  .summary-strip .stat-lbl {{ font-size: 0.72rem; color: #64748b; text-transform: uppercase; letter-spacing: 0.05em; }}
1992
- /* Duplicate card */
1993
  .dup-card {{ border-left: 3px solid #dc2626; }}
1994
  .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; }}
1995
- .qtext {{ font-size: 0.875rem; line-height: 1.7; margin-bottom: 10px; overflow-x: auto; }}
1996
  .meta-list {{ list-style: none; padding: 0; display: flex; flex-direction: column; gap: 4px; }}
1997
  .meta-list li {{ font-size: 0.75rem; color: #64748b; background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 5px; padding: 4px 8px; font-family: monospace; }}
1998
- /* Badges */
1999
  .badge {{ padding: 2px 7px; border-radius: 9999px; font-size: 0.65rem; font-weight: 600; white-space: nowrap; }}
2000
  .badge-blue {{ background: #dbeafe; color: #1d4ed8; }}
2001
  .badge-green {{ background: #dcfce7; color: #166534; }}
2002
  .badge-gray {{ background: #f1f5f9; color: #64748b; }}
2003
  .badge-red {{ background: #fee2e2; color: #dc2626; }}
2004
- /* Batch groups */
2005
  .batch-block {{ margin-bottom: 24px; }}
2006
  .batch-header {{ display: flex; align-items: center; justify-content: space-between; margin-bottom: 10px; }}
2007
  .batch-meta {{ display: flex; align-items: center; gap: 7px; font-size: 0.78rem; flex-wrap: wrap; }}
2008
  .batch-time {{ font-weight: 600; color: #1e293b; }}
2009
  .batch-sep {{ color: #cbd5e1; }}
2010
- body.dark .batch-time {{ color: #e2e8f0; }}
2011
- body.dark .batch-sep {{ color: #334155; }}
2012
- /* Cards grid */
2013
  .cards-grid {{ display: flex; flex-direction: column; gap: 12px; }}
2014
- /* Question card */
2015
  .q-card {{ background: white; border: 1px solid #e2e8f0; border-radius: 10px; overflow: hidden; }}
2016
  .dup-card-q {{ border-left: 3px solid #f59e0b; }}
2017
- /* Top bar */
2018
  .card-topbar {{ display: flex; align-items: center; gap: 6px; flex-wrap: wrap; padding: 10px 14px; border-bottom: 1px solid #f1f5f9; background: #f8fafc; }}
2019
  .card-id {{ font-family: monospace; }}
2020
  .card-ts {{ margin-left: auto; display: inline-flex; align-items: center; gap: 4px; font-size: 0.7rem; color: #94a3b8; white-space: nowrap; }}
2021
  .dup-marker-badge {{ display: inline-flex; align-items: center; gap: 3px; font-size: 0.65rem; font-weight: 700; color: #d97706; background: #fef3c7; border: 1px solid #fde68a; border-radius: 9999px; padding: 1px 7px; }}
2022
- /* Card body layouts */
2023
  .card-body-split {{ display: grid; grid-template-columns: 1fr auto; gap: 16px; padding: 16px 14px; align-items: start; }}
2024
  .card-body-single {{ padding: 16px 14px; }}
2025
- .card-text {{ font-size: 0.9rem; line-height: 1.75; color: #1e293b; overflow-x: auto; }}
2026
- /* Image in card */
2027
  .card-img-wrap {{ flex-shrink: 0; }}
2028
  .card-img-wrap img {{ max-width: 220px; max-height: 180px; border: 1px solid #e2e8f0; border-radius: 7px; display: block; cursor: zoom-in; object-fit: contain; }}
2029
- /* Answer / Hint sections */
2030
  .card-section {{ padding: 10px 14px; border-top: 1px solid #f1f5f9; }}
2031
  .section-label {{ display: block; font-size: 0.65rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.07em; color: #94a3b8; margin-bottom: 4px; }}
2032
- .card-ans {{ font-size: 0.82rem; line-height: 1.65; color: #1e293b; white-space: pre-wrap; overflow-x: auto; }}
2033
- .card-hint {{ font-size: 0.82rem; line-height: 1.65; color: #64748b; font-style: italic; white-space: pre-wrap; overflow-x: auto; }}
2034
- /* Dark mode cards */
2035
- body.dark .q-card {{ background: #1e293b; border-color: #334155; }}
2036
- body.dark .dup-card-q {{ border-left-color: #f59e0b; }}
2037
- body.dark .card-topbar {{ background: #0f172a; border-bottom-color: #334155; }}
2038
- body.dark .card-text {{ color: #e2e8f0; }}
2039
- body.dark .card-section {{ border-top-color: #334155; }}
2040
- body.dark .card-ans {{ color: #e2e8f0; }}
2041
- body.dark .card-hint {{ color: #475569; }}
2042
- body.dark .card-img-wrap img {{ border-color: #334155; }}
2043
- /* Image modal */
2044
  .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; }}
2045
  .img-modal.open {{ display: flex; }}
2046
  .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; }}
2047
  .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; }}
2048
  .muted {{ color: #94a3b8; font-size: 0.78rem; }}
2049
  /* Dark mode */
2050
- body.dark {{ background: #0f172a; color: #e2e8f0; }}
2051
  body.dark .card {{ background: #1e293b; border-color: #334155; }}
2052
- body.dark .btn-secondary {{ background: #1e293b; color: #94a3b8; border-color: #334155; }}
2053
- body.dark .btn-secondary:hover {{ background: #334155; color: #e2e8f0; }}
2054
- body.dark .dm-toggle {{ background: #1e293b; border-color: #334155; color: #94a3b8; }}
2055
- body.dark .dm-toggle:hover {{ background: #334155; color: #e2e8f0; }}
2056
  body.dark .subtitle {{ color: #64748b; }}
2057
  body.dark h2 {{ color: #475569; }}
2058
  body.dark .filter-row label {{ color: #64748b; }}
2059
  body.dark .filter-row input[type=number] {{ background: #0f172a; border-color: #334155; color: #e2e8f0; }}
2060
  body.dark .filter-row input:focus {{ border-color: #2563eb; }}
 
 
 
 
 
 
 
2061
  body.dark .summary-strip .stat-lbl {{ color: #475569; }}
2062
  body.dark .dup-card {{ border-left-color: #f87171; }}
2063
  body.dark .dup-count {{ color: #f87171; }}
@@ -2067,130 +2184,107 @@ async def latest_questions_page(courseId: int, chapterId: Optional[int] = None,
2067
  body.dark .badge-gray {{ background: #334155; color: #94a3b8; }}
2068
  body.dark .badge-red {{ background: #450a0a; color: #f87171; }}
2069
  body.dark .muted {{ color: #475569; }}
 
 
 
 
 
 
 
 
 
 
2070
  </style>
2071
- </head>
2072
- <body>
2073
- <div class="container">
2074
 
2075
- <!-- Header -->
2076
- <div style="display:flex;align-items:flex-start;justify-content:space-between;gap:12px;margin-bottom:3px;">
2077
- <h1>Latest Saved Questions</h1>
2078
- <div style="display:flex;gap:8px;align-items:center;">
2079
- <a href="/" class="btn btn-secondary btn-sm" style="text-decoration:none;">
2080
- <i data-lucide="arrow-left" style="width:13px;height:13px;"></i>
2081
- Back
2082
- </a>
2083
- <button class="dm-toggle" id="dm-toggle-btn" onclick="toggleDark()" title="Toggle dark mode" aria-label="Toggle dark mode">
2084
- <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>
2085
- <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>
 
 
 
 
 
 
 
2086
  </button>
2087
  </div>
2088
- </div>
2089
- <p class="subtitle">Course {courseId} &mdash; {chapter_label} &mdash; latest {len(questions)} of {total} questions &mdash; generated {generated}</p>
2090
-
2091
- <!-- Filter card -->
2092
- <div class="card" style="margin-bottom:16px;">
2093
- <form method="get" action="/latest-questions">
2094
- <div class="filter-row">
2095
- <label>Course ID
2096
- <input type="number" name="courseId" value="{courseId}" required>
2097
- </label>
2098
- <label>Chapter ID (optional)
2099
- <input type="number" name="chapterId" value="{chapterId if chapterId is not None else ''}">
2100
- </label>
2101
- <label>Limit
2102
- <input type="number" name="limit" value="{limit}" min="1" max="500">
2103
- </label>
2104
- <button type="submit" class="btn btn-primary btn-sm" style="margin-bottom:1px;">
2105
- <i data-lucide="refresh-cw" style="width:13px;height:13px;"></i>
2106
- Refresh
2107
- </button>
2108
- </div>
2109
- </form>
2110
- </div>
2111
 
2112
- <!-- Summary -->
2113
- <div class="card" style="margin-bottom:16px;">
2114
- <div class="summary-strip">
2115
- <div class="stat"><span class="stat-val">{len(questions)}</span><span class="stat-lbl">Showing</span></div>
2116
- <div class="stat"><span class="stat-val">{total}</span><span class="stat-lbl">Total</span></div>
2117
- <div class="stat"><span class="stat-val">{len(dups)}</span><span class="stat-lbl">Dup groups</span></div>
2118
- <div class="stat"><span class="stat-val">{sum(len(v) for v in dups.values())}</span><span class="stat-lbl">Dup rows</span></div>
2119
- </div>
2120
  </div>
 
2121
 
2122
- {empty}
2123
-
2124
- <!-- Live search + filter chips -->
2125
- <div class="card" style="margin-bottom:16px;">
2126
- <input class="search-bar" id="q-search" type="text" placeholder="Search by ID or question text…" oninput="applyFilters()">
2127
- <div class="chip-row" id="chip-area">
2128
- {chip_row("type", types_vals, "Type")}
2129
- {chip_row("difficulty", diff_vals, "Difficulty")}
2130
- {chip_row("bloom", bloom_vals, "Bloom")}
2131
- {chip_row("status", status_vals, "Status")}
2132
- <span class="chip" data-group="answer" data-val="yes" onclick="toggleChip(this)">Has answer</span>
2133
- <span class="chip" data-group="answer" data-val="no" onclick="toggleChip(this)">No answer</span>
2134
- <span class="chip-sep"></span>
2135
- <span class="chip" data-group="dup" data-val="yes" onclick="toggleChip(this)">Duplicates only</span>
2136
- <span class="filter-count" id="filter-count"></span>
2137
- </div>
2138
  </div>
 
2139
 
2140
- <!-- Duplicates -->
2141
- <h2><i data-lucide="copy" style="width:12px;height:12px;display:inline;vertical-align:middle;margin-right:4px;"></i>Duplicates (exact text)</h2>
2142
- {dup_html}
2143
-
2144
- <!-- Batch groups -->
2145
- <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>
2146
- {batches_html}
2147
 
2148
- </div>
 
 
2149
 
2150
- <!-- Image zoom modal -->
2151
- <div class="img-modal" id="img-modal" onclick="closeModal()">
2152
- <button class="img-modal-close" onclick="closeModal()">Close</button>
2153
- <img id="img-modal-src" src="" alt="figure">
2154
- </div>
 
 
2155
 
2156
  <script>
2157
- // Dark mode reads same localStorage key as main page
2158
- function applyDark(dark) {{
2159
- document.body.classList.toggle('dark', dark);
2160
- document.getElementById('dm-icon-moon').style.display = dark ? 'none' : '';
2161
- document.getElementById('dm-icon-sun').style.display = dark ? '' : 'none';
2162
- }}
2163
- function toggleDark() {{
2164
- var isDark = document.body.classList.contains('dark');
2165
- applyDark(!isDark);
2166
- try {{ localStorage.setItem('dm', !isDark ? '1' : '0'); }} catch(e) {{}}
2167
- }}
2168
  (function() {{
2169
- try {{
2170
- var saved = localStorage.getItem('dm');
2171
- var prefersDark = saved !== null ? saved === '1' : window.matchMedia('(prefers-color-scheme: dark)').matches;
2172
- applyDark(prefersDark);
2173
- }} catch(e) {{}}
2174
  }})();
2175
 
2176
- // Image zoom
2177
- function openModal(src) {{
2178
  document.getElementById('img-modal-src').src = src;
2179
  document.getElementById('img-modal').classList.add('open');
2180
- }}
2181
- function closeModal() {{
2182
  document.getElementById('img-modal').classList.remove('open');
2183
  document.getElementById('img-modal-src').src = '';
2184
- }}
2185
- document.addEventListener('keydown', function(e) {{ if (e.key === 'Escape') closeModal(); }});
2186
-
2187
- // Lucide icons
2188
- lucide.createIcons();
2189
 
2190
  // Live search + filter chips
2191
- var activeChips = {{}}; // group -> Set of active values
2192
 
2193
- function toggleChip(el) {{
2194
  var group = el.dataset.group;
2195
  var val = el.dataset.val;
2196
  if (!activeChips[group]) activeChips[group] = new Set();
@@ -2198,7 +2292,6 @@ async def latest_questions_page(courseId: int, chapterId: Optional[int] = None,
2198
  activeChips[group].delete(val);
2199
  el.classList.remove('active');
2200
  }} else {{
2201
- // For answer + dup groups only allow one active at a time
2202
  if (group === 'answer' || group === 'dup') {{
2203
  document.querySelectorAll('.chip[data-group="' + group + '"]').forEach(function(c) {{
2204
  c.classList.remove('active');
@@ -2208,45 +2301,35 @@ async def latest_questions_page(courseId: int, chapterId: Optional[int] = None,
2208
  activeChips[group].add(val);
2209
  el.classList.add('active');
2210
  }}
2211
- applyFilters();
2212
- }}
2213
 
2214
- function applyFilters() {{
2215
  var search = (document.getElementById('q-search').value || '').toLowerCase().trim();
2216
  var cards = document.querySelectorAll('.q-card');
2217
  var visible = 0;
2218
-
2219
  cards.forEach(function(card) {{
2220
- // Search: match id or text
2221
  var idMatch = !search || card.dataset.id.includes(search);
2222
  var textMatch = !search || card.dataset.text.includes(search);
2223
  if (!idMatch && !textMatch) {{ card.style.display = 'none'; return; }}
2224
-
2225
- // Chip filters — each group is OR within, AND across groups
2226
  for (var group in activeChips) {{
2227
  if (!activeChips[group].size) continue;
2228
- var field = card.dataset[group];
2229
- if (!activeChips[group].has(field)) {{ card.style.display = 'none'; return; }}
2230
  }}
2231
-
2232
  card.style.display = '';
2233
  visible++;
2234
  }});
2235
-
2236
- // Hide batch blocks that have no visible cards
2237
  document.querySelectorAll('.batch-block').forEach(function(block) {{
2238
  var anyVisible = Array.from(block.querySelectorAll('.q-card')).some(function(c) {{
2239
  return c.style.display !== 'none';
2240
  }});
2241
  block.style.display = anyVisible ? '' : 'none';
2242
  }});
2243
-
2244
  var fc = document.getElementById('filter-count');
2245
  if (fc) fc.textContent = (search || Object.values(activeChips).some(function(s){{return s.size;}}))
2246
  ? visible + ' match' + (visible !== 1 ? 'es' : '') : '';
2247
- }}
2248
- </script>
2249
- </body></html>"""
2250
 
2251
 
2252
  if __name__ == "__main__":
 
1668
 
1669
 
1670
  @app.get("/latest-questions", response_class=HTMLResponse)
1671
+ async def latest_questions_shell(courseId: int = 30, chapterId: Optional[int] = None, limit: int = 100):
1672
+ """Shell page loads instantly, then fetches content via AJAX."""
1673
+ chapter_param = f"&chapterId={chapterId}" if chapterId is not None else ""
1674
+ data_url = f"/latest-questions-data?courseId={courseId}{chapter_param}&limit={limit}"
1675
+ return f"""<!doctype html>
1676
+ <html lang="en"><head>
1677
+ <meta charset="utf-8">
1678
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
1679
+ <title>Latest Questions — course {courseId}</title>
1680
+ <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/katex.min.css">
1681
+ <script defer src="https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/katex.min.js"></script>
1682
+ <script defer src="https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/contrib/auto-render.min.js"></script>
1683
+ <script src="https://unpkg.com/lucide@latest/dist/umd/lucide.min.js"></script>
1684
+ <style>
1685
+ *, *::before, *::after {{ box-sizing: border-box; margin: 0; padding: 0; }}
1686
+ body {{ font-family: system-ui, sans-serif; background: #f8fafc; color: #1e293b; line-height: 1.5; }}
1687
+ body.dark {{ background: #0f172a; color: #e2e8f0; }}
1688
+ .container {{ max-width: 1200px; margin: 0 auto; padding: 28px 16px 60px; }}
1689
+ h1 {{ font-size: 1.4rem; font-weight: 700; }}
1690
+ .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; }}
1691
+ .btn-secondary {{ background: #f1f5f9; color: #475569; border: 1px solid #e2e8f0; }}
1692
+ .btn-secondary:hover {{ background: #e2e8f0; }}
1693
+ .btn-sm {{ padding: 5px 12px; font-size: 0.78rem; }}
1694
+ .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; }}
1695
+ .dm-toggle:hover {{ background: #e2e8f0; color: #1e293b; }}
1696
+ body.dark .btn-secondary {{ background: #1e293b; color: #94a3b8; border-color: #334155; }}
1697
+ body.dark .btn-secondary:hover {{ background: #334155; color: #e2e8f0; }}
1698
+ body.dark .dm-toggle {{ background: #1e293b; border-color: #334155; color: #94a3b8; }}
1699
+ body.dark .dm-toggle:hover {{ background: #334155; color: #e2e8f0; }}
1700
+
1701
+ /* ── Loading scene ── */
1702
+ #loading-scene {{
1703
+ display: flex; flex-direction: column; align-items: center;
1704
+ justify-content: center; padding: 80px 20px 100px; gap: 28px;
1705
+ }}
1706
+ .loader-books {{
1707
+ display: flex; gap: 10px; align-items: flex-end; height: 60px;
1708
+ }}
1709
+ .book {{
1710
+ width: 18px; border-radius: 3px 3px 0 0;
1711
+ animation: bookBounce 1.1s ease-in-out infinite;
1712
+ transform-origin: bottom center;
1713
+ }}
1714
+ .book:nth-child(1) {{ height: 46px; background: #2563eb; animation-delay: 0s; }}
1715
+ .book:nth-child(2) {{ height: 54px; background: #7c3aed; animation-delay: 0.15s; }}
1716
+ .book:nth-child(3) {{ height: 38px; background: #059669; animation-delay: 0.30s; }}
1717
+ .book:nth-child(4) {{ height: 58px; background: #d97706; animation-delay: 0.45s; }}
1718
+ .book:nth-child(5) {{ height: 42px; background: #dc2626; animation-delay: 0.60s; }}
1719
+ @keyframes bookBounce {{
1720
+ 0%, 100% {{ transform: scaleY(1); opacity: 1; }}
1721
+ 50% {{ transform: scaleY(0.55); opacity: 0.6; }}
1722
+ }}
1723
+ .loader-dots {{
1724
+ display: flex; gap: 7px;
1725
+ }}
1726
+ .loader-dots span {{
1727
+ width: 8px; height: 8px; border-radius: 50%; background: #94a3b8;
1728
+ animation: dotPulse 1.4s ease-in-out infinite;
1729
+ }}
1730
+ .loader-dots span:nth-child(2) {{ animation-delay: 0.2s; }}
1731
+ .loader-dots span:nth-child(3) {{ animation-delay: 0.4s; }}
1732
+ @keyframes dotPulse {{
1733
+ 0%, 80%, 100% {{ transform: scale(0.7); opacity: 0.5; }}
1734
+ 40% {{ transform: scale(1); opacity: 1; background: #2563eb; }}
1735
+ }}
1736
+ .loader-msg {{
1737
+ font-size: 0.9rem; color: #64748b; text-align: center; max-width: 300px; line-height: 1.6;
1738
+ }}
1739
+ .loader-msg strong {{ color: #1e293b; display: block; font-size: 1rem; margin-bottom: 4px; }}
1740
+ body.dark .loader-msg {{ color: #475569; }}
1741
+ body.dark .loader-msg strong {{ color: #e2e8f0; }}
1742
+ #error-scene {{
1743
+ display: none; text-align: center; padding: 60px 20px;
1744
+ color: #dc2626; font-size: 0.9rem;
1745
+ }}
1746
+ body.dark #error-scene {{ color: #f87171; }}
1747
+ </style>
1748
+ </head>
1749
+ <body>
1750
+ <div class="container">
1751
+ <div style="display:flex;align-items:flex-start;justify-content:space-between;gap:12px;margin-bottom:20px;">
1752
+ <h1>Latest Saved Questions</h1>
1753
+ <div style="display:flex;gap:8px;align-items:center;">
1754
+ <a href="/" class="btn btn-secondary btn-sm" style="text-decoration:none;">
1755
+ <i data-lucide="arrow-left" style="width:13px;height:13px;"></i> Back
1756
+ </a>
1757
+ <button class="dm-toggle" onclick="toggleDark()" title="Toggle dark mode" aria-label="Toggle dark mode">
1758
+ <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>
1759
+ <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>
1760
+ </button>
1761
+ </div>
1762
+ </div>
1763
+
1764
+ <!-- Loading animation -->
1765
+ <div id="loading-scene">
1766
+ <div class="loader-books">
1767
+ <div class="book"></div><div class="book"></div><div class="book"></div>
1768
+ <div class="book"></div><div class="book"></div>
1769
+ </div>
1770
+ <div class="loader-dots">
1771
+ <span></span><span></span><span></span>
1772
+ </div>
1773
+ <div class="loader-msg">
1774
+ <strong>Fetching questions...</strong>
1775
+ Pulling from the question bank and checking for duplicates.
1776
+ </div>
1777
+ </div>
1778
+ <div id="error-scene"></div>
1779
+
1780
+ <!-- Content injected here -->
1781
+ <div id="content"></div>
1782
+ </div>
1783
+
1784
+ <script>
1785
+ // Dark mode
1786
+ function applyDark(dark) {{
1787
+ document.body.classList.toggle('dark', dark);
1788
+ document.getElementById('dm-icon-moon').style.display = dark ? 'none' : '';
1789
+ document.getElementById('dm-icon-sun').style.display = dark ? '' : 'none';
1790
+ }}
1791
+ function toggleDark() {{
1792
+ var isDark = document.body.classList.contains('dark');
1793
+ applyDark(!isDark);
1794
+ try {{ localStorage.setItem('dm', !isDark ? '1' : '0'); }} catch(e) {{}}
1795
+ }}
1796
+ (function() {{
1797
+ try {{
1798
+ var saved = localStorage.getItem('dm');
1799
+ applyDark(saved !== null ? saved === '1' : window.matchMedia('(prefers-color-scheme: dark)').matches);
1800
+ }} catch(e) {{}}
1801
+ }})();
1802
+
1803
+ lucide.createIcons();
1804
+
1805
+ // Fetch data
1806
+ fetch("{data_url}")
1807
+ .then(function(r) {{
1808
+ if (!r.ok) throw new Error('HTTP ' + r.status);
1809
+ return r.text();
1810
+ }})
1811
+ .then(function(fragHtml) {{
1812
+ document.getElementById('loading-scene').style.display = 'none';
1813
+ var el = document.getElementById('content');
1814
+ // Use createContextualFragment so <script> tags execute
1815
+ var frag = document.createRange().createContextualFragment(fragHtml);
1816
+ el.appendChild(frag);
1817
+ // Re-run lucide on injected icons
1818
+ lucide.createIcons();
1819
+ // KaTeX (may already be loaded; retry if deferred scripts aren't done yet)
1820
+ function tryRenderMath() {{
1821
+ if (window.renderMathInElement) {{
1822
+ renderMathInElement(el, {{ delimiters: [
1823
+ {{left: '$$', right: '$$', display: true}},
1824
+ {{left: '$', right: '$', display: false}}
1825
+ ]}});
1826
+ }} else {{
1827
+ setTimeout(tryRenderMath, 200);
1828
+ }}
1829
+ }}
1830
+ tryRenderMath();
1831
+ }})
1832
+ .catch(function(err) {{
1833
+ document.getElementById('loading-scene').style.display = 'none';
1834
+ var es = document.getElementById('error-scene');
1835
+ es.style.display = 'block';
1836
+ es.innerHTML = '<strong style="font-size:1rem;display:block;margin-bottom:8px;">Failed to load questions</strong>' + err.message
1837
+ + '<br><br><a href="" style="color:#2563eb;">Retry</a>';
1838
+ }});
1839
+ </script>
1840
+ </body></html>"""
1841
+
1842
+
1843
+ @app.get("/latest-questions-data", response_class=HTMLResponse)
1844
+ async def latest_questions_data(courseId: int, chapterId: Optional[int] = None, limit: int = 100):
1845
+ """Data fragment — fetched by the shell page via AJAX."""
1846
  from collections import defaultdict
1847
  from datetime import datetime as _dt, timezone as _tz
1848
 
 
2100
  f'<span class="chip-sep"></span>'
2101
  ) if values else ""
2102
 
2103
+ return f"""<style>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2104
  .subtitle {{ color: #64748b; margin: 3px 0 20px; font-size: 0.85rem; }}
2105
  h2 {{ font-size: 0.8rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.08em; color: #64748b; margin: 24px 0 10px; }}
2106
+ .btn-primary {{ background: #2563eb; color: white; border: none; }}
 
 
2107
  .btn-primary:hover {{ background: #1d4ed8; }}
 
 
 
 
2108
  .card {{ background: white; border: 1px solid #e2e8f0; border-radius: 8px; padding: 16px 20px; margin-bottom: 10px; }}
 
 
 
 
2109
  .filter-row {{ display: flex; gap: 10px; align-items: flex-end; flex-wrap: wrap; }}
2110
  .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; }}
2111
  .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; }}
2112
  .filter-row input[type=number]::-webkit-outer-spin-button,
2113
  .filter-row input[type=number]::-webkit-inner-spin-button {{ -webkit-appearance: none; margin: 0; }}
2114
  .filter-row input:focus {{ outline: none; border-color: #2563eb; }}
 
2115
  .search-bar {{ width: 100%; padding: 7px 11px 7px 34px; border: 1px solid #cbd5e1; border-radius: 6px; font-size: 0.85rem; font-family: inherit; background: #fff url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='14' height='14' viewBox='0 0 24 24' fill='none' stroke='%2394a3b8' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='11' cy='11' r='8'/%3E%3Cline x1='21' y1='21' x2='16.65' y2='16.65'/%3E%3C/svg%3E") no-repeat 10px center; }}
2116
  .search-bar:focus {{ outline: none; border-color: #2563eb; }}
2117
  .chip-row {{ display: flex; gap: 6px; flex-wrap: wrap; margin-top: 10px; align-items: center; }}
 
2121
  .chip.active {{ background: #2563eb; color: #fff; border-color: #2563eb; }}
2122
  .chip-sep {{ width: 1px; height: 18px; background: #e2e8f0; margin: 0 2px; }}
2123
  .filter-count {{ font-size: 0.72rem; color: #64748b; margin-left: auto; }}
 
 
 
 
 
 
 
 
2124
  .summary-strip {{ display: flex; flex-wrap: wrap; gap: 20px; }}
2125
  .summary-strip .stat {{ display: flex; flex-direction: column; }}
2126
  .summary-strip .stat-val {{ font-size: 1.5rem; font-weight: 700; line-height: 1.1; }}
2127
  .summary-strip .stat-lbl {{ font-size: 0.72rem; color: #64748b; text-transform: uppercase; letter-spacing: 0.05em; }}
 
2128
  .dup-card {{ border-left: 3px solid #dc2626; }}
2129
  .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; }}
2130
+ .qtext {{ font-size: 0.875rem; line-height: 1.7; margin-bottom: 10px; word-break: break-word; overflow-wrap: break-word; }}
2131
  .meta-list {{ list-style: none; padding: 0; display: flex; flex-direction: column; gap: 4px; }}
2132
  .meta-list li {{ font-size: 0.75rem; color: #64748b; background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 5px; padding: 4px 8px; font-family: monospace; }}
 
2133
  .badge {{ padding: 2px 7px; border-radius: 9999px; font-size: 0.65rem; font-weight: 600; white-space: nowrap; }}
2134
  .badge-blue {{ background: #dbeafe; color: #1d4ed8; }}
2135
  .badge-green {{ background: #dcfce7; color: #166534; }}
2136
  .badge-gray {{ background: #f1f5f9; color: #64748b; }}
2137
  .badge-red {{ background: #fee2e2; color: #dc2626; }}
 
2138
  .batch-block {{ margin-bottom: 24px; }}
2139
  .batch-header {{ display: flex; align-items: center; justify-content: space-between; margin-bottom: 10px; }}
2140
  .batch-meta {{ display: flex; align-items: center; gap: 7px; font-size: 0.78rem; flex-wrap: wrap; }}
2141
  .batch-time {{ font-weight: 600; color: #1e293b; }}
2142
  .batch-sep {{ color: #cbd5e1; }}
 
 
 
2143
  .cards-grid {{ display: flex; flex-direction: column; gap: 12px; }}
 
2144
  .q-card {{ background: white; border: 1px solid #e2e8f0; border-radius: 10px; overflow: hidden; }}
2145
  .dup-card-q {{ border-left: 3px solid #f59e0b; }}
 
2146
  .card-topbar {{ display: flex; align-items: center; gap: 6px; flex-wrap: wrap; padding: 10px 14px; border-bottom: 1px solid #f1f5f9; background: #f8fafc; }}
2147
  .card-id {{ font-family: monospace; }}
2148
  .card-ts {{ margin-left: auto; display: inline-flex; align-items: center; gap: 4px; font-size: 0.7rem; color: #94a3b8; white-space: nowrap; }}
2149
  .dup-marker-badge {{ display: inline-flex; align-items: center; gap: 3px; font-size: 0.65rem; font-weight: 700; color: #d97706; background: #fef3c7; border: 1px solid #fde68a; border-radius: 9999px; padding: 1px 7px; }}
 
2150
  .card-body-split {{ display: grid; grid-template-columns: 1fr auto; gap: 16px; padding: 16px 14px; align-items: start; }}
2151
  .card-body-single {{ padding: 16px 14px; }}
2152
+ .card-text {{ font-size: 0.9rem; line-height: 1.75; color: #1e293b; word-break: break-word; overflow-wrap: break-word; }}
 
2153
  .card-img-wrap {{ flex-shrink: 0; }}
2154
  .card-img-wrap img {{ max-width: 220px; max-height: 180px; border: 1px solid #e2e8f0; border-radius: 7px; display: block; cursor: zoom-in; object-fit: contain; }}
 
2155
  .card-section {{ padding: 10px 14px; border-top: 1px solid #f1f5f9; }}
2156
  .section-label {{ display: block; font-size: 0.65rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.07em; color: #94a3b8; margin-bottom: 4px; }}
2157
+ .card-ans {{ font-size: 0.82rem; line-height: 1.65; color: #1e293b; white-space: pre-wrap; word-break: break-word; overflow-wrap: break-word; }}
2158
+ .card-hint {{ font-size: 0.82rem; line-height: 1.65; color: #64748b; font-style: italic; white-space: pre-wrap; word-break: break-word; overflow-wrap: break-word; }}
 
 
 
 
 
 
 
 
 
 
2159
  .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; }}
2160
  .img-modal.open {{ display: flex; }}
2161
  .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; }}
2162
  .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; }}
2163
  .muted {{ color: #94a3b8; font-size: 0.78rem; }}
2164
  /* Dark mode */
 
2165
  body.dark .card {{ background: #1e293b; border-color: #334155; }}
 
 
 
 
2166
  body.dark .subtitle {{ color: #64748b; }}
2167
  body.dark h2 {{ color: #475569; }}
2168
  body.dark .filter-row label {{ color: #64748b; }}
2169
  body.dark .filter-row input[type=number] {{ background: #0f172a; border-color: #334155; color: #e2e8f0; }}
2170
  body.dark .filter-row input:focus {{ border-color: #2563eb; }}
2171
+ body.dark .search-bar {{ background-color: #0f172a; border-color: #334155; color: #e2e8f0; }}
2172
+ body.dark .search-bar:focus {{ border-color: #2563eb; }}
2173
+ body.dark .chip {{ background: #1e293b; border-color: #334155; color: #94a3b8; }}
2174
+ body.dark .chip:hover {{ background: #334155; color: #e2e8f0; }}
2175
+ body.dark .chip.active {{ background: #2563eb; color: #fff; border-color: #2563eb; }}
2176
+ body.dark .chip-sep {{ background: #334155; }}
2177
+ body.dark .filter-count {{ color: #475569; }}
2178
  body.dark .summary-strip .stat-lbl {{ color: #475569; }}
2179
  body.dark .dup-card {{ border-left-color: #f87171; }}
2180
  body.dark .dup-count {{ color: #f87171; }}
 
2184
  body.dark .badge-gray {{ background: #334155; color: #94a3b8; }}
2185
  body.dark .badge-red {{ background: #450a0a; color: #f87171; }}
2186
  body.dark .muted {{ color: #475569; }}
2187
+ body.dark .batch-time {{ color: #e2e8f0; }}
2188
+ body.dark .batch-sep {{ color: #334155; }}
2189
+ body.dark .q-card {{ background: #1e293b; border-color: #334155; }}
2190
+ body.dark .dup-card-q {{ border-left-color: #f59e0b; }}
2191
+ body.dark .card-topbar {{ background: #0f172a; border-bottom-color: #334155; }}
2192
+ body.dark .card-text {{ color: #e2e8f0; }}
2193
+ body.dark .card-section {{ border-top-color: #334155; }}
2194
+ body.dark .card-ans {{ color: #e2e8f0; }}
2195
+ body.dark .card-hint {{ color: #94a3b8; }}
2196
+ body.dark .card-img-wrap img {{ border-color: #334155; }}
2197
  </style>
 
 
 
2198
 
2199
+ <p class="subtitle">Course {courseId} &mdash; {chapter_label} &mdash; {len(questions)} of {total} questions &mdash; generated {generated}</p>
2200
+
2201
+ <!-- Filter card -->
2202
+ <div class="card" style="margin-bottom:16px;">
2203
+ <form method="get" action="/latest-questions">
2204
+ <div class="filter-row">
2205
+ <label>Course ID
2206
+ <input type="number" name="courseId" value="{courseId}" required>
2207
+ </label>
2208
+ <label>Chapter ID (optional)
2209
+ <input type="number" name="chapterId" value="{chapterId if chapterId is not None else ''}">
2210
+ </label>
2211
+ <label>Limit
2212
+ <input type="number" name="limit" value="{limit}" min="1" max="500">
2213
+ </label>
2214
+ <button type="submit" class="btn btn-primary btn-sm" style="margin-bottom:1px;">
2215
+ <i data-lucide="refresh-cw" style="width:13px;height:13px;"></i>
2216
+ Refresh
2217
  </button>
2218
  </div>
2219
+ </form>
2220
+ </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2221
 
2222
+ <!-- Summary -->
2223
+ <div class="card" style="margin-bottom:16px;">
2224
+ <div class="summary-strip">
2225
+ <div class="stat"><span class="stat-val">{len(questions)}</span><span class="stat-lbl">Showing</span></div>
2226
+ <div class="stat"><span class="stat-val">{total}</span><span class="stat-lbl">Total</span></div>
2227
+ <div class="stat"><span class="stat-val">{len(dups)}</span><span class="stat-lbl">Dup groups</span></div>
2228
+ <div class="stat"><span class="stat-val">{sum(len(v) for v in dups.values())}</span><span class="stat-lbl">Dup rows</span></div>
 
2229
  </div>
2230
+ </div>
2231
 
2232
+ {empty}
2233
+
2234
+ <!-- Live search + filter chips -->
2235
+ <div class="card" style="margin-bottom:16px;">
2236
+ <input class="search-bar" id="q-search" type="text" placeholder="Search by ID or question text…" oninput="applyFilters()">
2237
+ <div class="chip-row" id="chip-area">
2238
+ {chip_row("type", types_vals, "Type")}
2239
+ {chip_row("difficulty", diff_vals, "Difficulty")}
2240
+ {chip_row("bloom", bloom_vals, "Bloom")}
2241
+ {chip_row("status", status_vals, "Status")}
2242
+ <span class="chip" data-group="answer" data-val="yes" onclick="toggleChip(this)">Has answer</span>
2243
+ <span class="chip" data-group="answer" data-val="no" onclick="toggleChip(this)">No answer</span>
2244
+ <span class="chip-sep"></span>
2245
+ <span class="chip" data-group="dup" data-val="yes" onclick="toggleChip(this)">Duplicates only</span>
2246
+ <span class="filter-count" id="filter-count"></span>
 
2247
  </div>
2248
+ </div>
2249
 
2250
+ <!-- Duplicates -->
2251
+ <h2><i data-lucide="copy" style="width:12px;height:12px;display:inline;vertical-align:middle;margin-right:4px;"></i>Duplicates (exact text)</h2>
2252
+ {dup_html}
 
 
 
 
2253
 
2254
+ <!-- Batch groups -->
2255
+ <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>
2256
+ {batches_html}
2257
 
2258
+ <!-- Image zoom modal (appended to body by script below) -->
2259
+ <template id="modal-tpl">
2260
+ <div class="img-modal" id="img-modal" onclick="closeModal()">
2261
+ <button class="img-modal-close" onclick="closeModal()">Close</button>
2262
+ <img id="img-modal-src" src="" alt="figure">
2263
+ </div>
2264
+ </template>
2265
 
2266
  <script>
2267
+ // Mount modal onto body
 
 
 
 
 
 
 
 
 
 
2268
  (function() {{
2269
+ var tpl = document.getElementById('modal-tpl');
2270
+ if (tpl) document.body.appendChild(tpl.content.cloneNode(true));
 
 
 
2271
  }})();
2272
 
2273
+ // Image zoom (global so inline onclick works)
2274
+ window.openModal = function(src) {{
2275
  document.getElementById('img-modal-src').src = src;
2276
  document.getElementById('img-modal').classList.add('open');
2277
+ }};
2278
+ window.closeModal = function() {{
2279
  document.getElementById('img-modal').classList.remove('open');
2280
  document.getElementById('img-modal-src').src = '';
2281
+ }};
2282
+ document.addEventListener('keydown', function(e) {{ if (e.key === 'Escape') window.closeModal(); }});
 
 
 
2283
 
2284
  // Live search + filter chips
2285
+ var activeChips = {{}};
2286
 
2287
+ window.toggleChip = function(el) {{
2288
  var group = el.dataset.group;
2289
  var val = el.dataset.val;
2290
  if (!activeChips[group]) activeChips[group] = new Set();
 
2292
  activeChips[group].delete(val);
2293
  el.classList.remove('active');
2294
  }} else {{
 
2295
  if (group === 'answer' || group === 'dup') {{
2296
  document.querySelectorAll('.chip[data-group="' + group + '"]').forEach(function(c) {{
2297
  c.classList.remove('active');
 
2301
  activeChips[group].add(val);
2302
  el.classList.add('active');
2303
  }}
2304
+ window.applyFilters();
2305
+ }};
2306
 
2307
+ window.applyFilters = function() {{
2308
  var search = (document.getElementById('q-search').value || '').toLowerCase().trim();
2309
  var cards = document.querySelectorAll('.q-card');
2310
  var visible = 0;
 
2311
  cards.forEach(function(card) {{
 
2312
  var idMatch = !search || card.dataset.id.includes(search);
2313
  var textMatch = !search || card.dataset.text.includes(search);
2314
  if (!idMatch && !textMatch) {{ card.style.display = 'none'; return; }}
 
 
2315
  for (var group in activeChips) {{
2316
  if (!activeChips[group].size) continue;
2317
+ if (!activeChips[group].has(card.dataset[group])) {{ card.style.display = 'none'; return; }}
 
2318
  }}
 
2319
  card.style.display = '';
2320
  visible++;
2321
  }});
 
 
2322
  document.querySelectorAll('.batch-block').forEach(function(block) {{
2323
  var anyVisible = Array.from(block.querySelectorAll('.q-card')).some(function(c) {{
2324
  return c.style.display !== 'none';
2325
  }});
2326
  block.style.display = anyVisible ? '' : 'none';
2327
  }});
 
2328
  var fc = document.getElementById('filter-count');
2329
  if (fc) fc.textContent = (search || Object.values(activeChips).some(function(s){{return s.size;}}))
2330
  ? visible + ' match' + (visible !== 1 ? 'es' : '') : '';
2331
+ }};
2332
+ </script>"""
 
2333
 
2334
 
2335
  if __name__ == "__main__":