You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
263 lines
12 KiB
263 lines
12 KiB
{% extends "base.html" %} |
|
{% block title %}Тесты — каталог{% endblock %} |
|
|
|
{% macro catalog_test_params_line(t) -%} |
|
{%- set tl = t.time_limit -%} |
|
{%- set timestr = 'без ограничения' if tl is none or tl == 0 else (tl|string ~ ' мин') -%} |
|
{%- set rm = t.result_mode or 'end' -%} |
|
{%- set res = 'сразу' if rm == 'immediate' else 'в конце' -%} |
|
{%- if rm != 'immediate' -%} |
|
{%- set hint = 'недоступны' -%} |
|
{%- elif t.hints_enabled -%} |
|
{%- set hint = 'вкл' -%} |
|
{%- else -%} |
|
{%- set hint = 'выкл' -%} |
|
{%- endif -%} |
|
Порог: {{ t.passing_threshold }}% · Вопросов: {{ t.questions_count }} · Время: {{ timestr }} · Результат: {{ res }} · Подсказки: {{ hint }} |
|
{%- endmacro %} |
|
|
|
{% block content %} |
|
<section class="legacy-list-shell"> |
|
<h1 class="font-headline legacy-list-title">Тесты</h1> |
|
<div class="legacy-list-toolbar legacy-list-toolbar--wrap flex flex-wrap items-center gap-3"> |
|
<div class="flex flex-col sm:flex-row flex-1 min-w-0 gap-2 sm:gap-3"> |
|
<label class="flex flex-col gap-1 flex-1 min-w-[12rem] max-w-md"> |
|
<span class="text-xs font-medium text-ink-600">Поиск по названию</span> |
|
<input id="catalog-search" type="search" autocomplete="off" placeholder="Начните вводить…" |
|
class="rounded-lg border border-ink-300 px-3 py-2 text-sm w-full |
|
focus:border-brand-500 focus:ring-2 focus:ring-brand-500/20" /> |
|
</label> |
|
<label class="flex flex-col gap-1 w-full sm:w-52 shrink-0"> |
|
<span class="text-xs font-medium text-ink-600">Автор</span> |
|
<select id="catalog-author" |
|
class="rounded-lg border border-ink-300 px-3 py-2 text-sm w-full bg-white |
|
focus:border-brand-500 focus:ring-2 focus:ring-brand-500/20"> |
|
<option value="">Все авторы</option> |
|
</select> |
|
</label> |
|
</div> |
|
<button id="btn-create-test" class="btn btn-ghost shrink-0" type="button"> |
|
Создать |
|
</button> |
|
</div> |
|
<p id="catalog-filter-empty" class="text-sm text-ink-500 mt-2 hidden" role="status"></p> |
|
|
|
{% if visible %} |
|
<ul class="list-stack" aria-label="Тесты в общем списке"> |
|
{% for t in visible %} |
|
<li class="list-row list-row--split list-row--catalog" |
|
data-catalog-row |
|
data-title-lower="{{ (t.title or '')|lower }}" |
|
data-author-id="{{ t.created_by or '' }}" |
|
data-author-name="{{ (t.author_full_name or '—')|e }}"> |
|
<div class="list-row__main"> |
|
<a href="{{ url_for('tests.tests_editor_page', test_id=t.id) }}" class="list-row__link"> |
|
<span class="list-row__title">{{ t.title }}</span> |
|
<span class="list-row__meta"> |
|
{{ t.author_full_name or '—' }} |
|
<span class="list-row__meta-tail"> · Версия {{ t.version }}</span> |
|
</span> |
|
<span class="list-row__params muted" style="display:block;font-size:0.78rem;margin-top:0.2rem;line-height:1.35;">{{ catalog_test_params_line(t) }}</span> |
|
</a> |
|
</div> |
|
<div class="list-row__side"> |
|
<button type="button" class="btn btn-ghost btn-start-pass" data-test-id="{{ t.id }}"> |
|
{{ 'Продолжить' if t.has_in_progress_attempt else 'Пройти' }} |
|
</button> |
|
</div> |
|
</li> |
|
{% endfor %} |
|
</ul> |
|
{% else %} |
|
<p class="text-muted">Нет тестов</p> |
|
{% endif %} |
|
|
|
{% if hidden %} |
|
<h2 class="font-headline legacy-list-subtitle">Скрытые вами из списка</h2> |
|
<ul class="list-stack" aria-label="Скрытые тесты автора"> |
|
{% for t in hidden %} |
|
<li class="list-row list-row--split list-row--hidden list-row--catalog" |
|
data-catalog-row |
|
data-title-lower="{{ (t.title or '')|lower }}" |
|
data-author-id="{{ t.created_by or '' }}" |
|
data-author-name="{{ (t.author_full_name or '—')|e }}"> |
|
<div class="list-row__main"> |
|
<a href="{{ url_for('tests.tests_editor_page', test_id=t.id) }}" class="list-row__link"> |
|
<span class="list-row__title">{{ t.title }}</span> |
|
<span class="list-row__meta"> |
|
{{ t.author_full_name or '—' }} |
|
<span class="list-row__meta-tail"> · Версия {{ t.version }} · скрыт</span> |
|
</span> |
|
<span class="list-row__params muted" style="display:block;font-size:0.78rem;margin-top:0.2rem;line-height:1.35;">{{ catalog_test_params_line(t) }}</span> |
|
</a> |
|
</div> |
|
<div class="list-row__side"> |
|
<button type="button" class="btn btn-ghost btn-start-pass" data-test-id="{{ t.id }}"> |
|
{{ 'Продолжить' if t.has_in_progress_attempt else 'Пройти' }} |
|
</button> |
|
</div> |
|
</li> |
|
{% endfor %} |
|
</ul> |
|
{% endif %} |
|
</section> |
|
|
|
<dialog id="dlg-create" |
|
class="m-0 p-0 w-full sm:w-full sm:max-w-md |
|
h-fit max-h-[calc(100vh-2rem)] |
|
rounded-2xl bg-white backdrop:bg-ink-900/50 m-auto"> |
|
<form method="dialog" class="flex flex-col sm:h-auto bg-white sm:rounded-2xl"> |
|
<div class="px-4 sm:px-5 py-3 border-b border-ink-300/60 flex items-center justify-between"> |
|
<h2 class="text-lg font-semibold">Новый тест</h2> |
|
<button type="button" id="dlg-cancel-x" |
|
class="p-2 rounded hover:bg-ink-100 min-w-10 min-h-10" |
|
aria-label="Закрыть"> |
|
<span class="material-symbols-outlined">close</span> |
|
</button> |
|
</div> |
|
<div class="flex-1 overflow-y-auto px-4 sm:px-5 py-4 space-y-3"> |
|
<label class="block"> |
|
<span class="text-sm font-medium text-ink-700">Название</span> |
|
<input id="new-test-title" type="text" required maxlength="200" |
|
class="mt-1 w-full rounded-lg border border-ink-300 px-3 py-3 |
|
focus:border-brand-500 focus:ring-2 focus:ring-brand-500/20" /> |
|
</label> |
|
<label class="block"> |
|
<span class="text-sm font-medium text-ink-700">Описание (опц.)</span> |
|
<textarea id="new-test-desc" rows="3" |
|
class="mt-1 w-full rounded-lg border border-ink-300 px-3 py-2 |
|
focus:border-brand-500 focus:ring-2 focus:ring-brand-500/20"></textarea> |
|
</label> |
|
</div> |
|
<div class="px-4 sm:px-5 py-3 border-t border-ink-300/60 flex justify-end gap-2 |
|
bg-ink-100/40 sm:rounded-b-2xl |
|
pb-[max(env(safe-area-inset-bottom),0.75rem)]"> |
|
<button type="button" id="dlg-cancel" |
|
class="px-4 py-2.5 rounded-lg text-ink-700 hover:bg-ink-100 min-h-11">Отмена</button> |
|
<button type="button" id="dlg-submit" |
|
class="px-4 py-2.5 rounded-lg bg-brand-600 hover:bg-brand-700 text-white min-h-11"> |
|
Создать |
|
</button> |
|
</div> |
|
</form> |
|
</dialog> |
|
{% endblock %} |
|
|
|
{% block scripts %} |
|
<script> |
|
(() => { |
|
const dlg = document.getElementById('dlg-create'); |
|
const titleEl = document.getElementById('new-test-title'); |
|
const descEl = document.getElementById('new-test-desc'); |
|
const catalogSearch = document.getElementById('catalog-search'); |
|
const catalogAuthor = document.getElementById('catalog-author'); |
|
const catalogEmpty = document.getElementById('catalog-filter-empty'); |
|
|
|
(function initCatalogFilter() { |
|
if (!catalogSearch || !catalogAuthor) return; |
|
const rows = Array.from(document.querySelectorAll('[data-catalog-row]')); |
|
const byAuthor = new Map(); |
|
rows.forEach((row) => { |
|
const id = (row.dataset.authorId || '').trim(); |
|
const name = (row.dataset.authorName || '').trim() || '—'; |
|
if (id && !byAuthor.has(id)) byAuthor.set(id, name); |
|
}); |
|
const sorted = Array.from(byAuthor.entries()).sort((a, b) => a[1].localeCompare(b[1], 'ru')); |
|
sorted.forEach(([id, name]) => { |
|
const opt = document.createElement('option'); |
|
opt.value = id; |
|
opt.textContent = name; |
|
catalogAuthor.appendChild(opt); |
|
}); |
|
|
|
function applyFilter() { |
|
const q = (catalogSearch.value || '').trim().toLowerCase(); |
|
const author = (catalogAuthor.value || '').trim(); |
|
let visibleCount = 0; |
|
rows.forEach((row) => { |
|
const title = row.dataset.titleLower || ''; |
|
const aid = (row.dataset.authorId || '').trim(); |
|
const matchQ = !q || title.includes(q); |
|
const matchA = !author || aid === author; |
|
const show = matchQ && matchA; |
|
row.style.display = show ? '' : 'none'; |
|
if (show) visibleCount += 1; |
|
}); |
|
if (catalogEmpty) { |
|
if (rows.length && visibleCount === 0) { |
|
catalogEmpty.textContent = 'Ничего не найдено — измените запрос или фильтр.'; |
|
catalogEmpty.classList.remove('hidden'); |
|
} else { |
|
catalogEmpty.textContent = ''; |
|
catalogEmpty.classList.add('hidden'); |
|
} |
|
} |
|
} |
|
|
|
let t = null; |
|
catalogSearch.addEventListener('input', () => { |
|
clearTimeout(t); |
|
t = setTimeout(applyFilter, 120); |
|
}); |
|
catalogAuthor.addEventListener('change', applyFilter); |
|
})(); |
|
|
|
document.getElementById('btn-create-test').addEventListener('click', () => { |
|
titleEl.value = ''; |
|
descEl.value = ''; |
|
if (typeof dlg.showModal === 'function') dlg.showModal(); |
|
else dlg.setAttribute('open', 'open'); |
|
setTimeout(() => titleEl.focus(), 50); |
|
}); |
|
document.getElementById('dlg-cancel').addEventListener('click', () => dlg.close()); |
|
document.getElementById('dlg-cancel-x').addEventListener('click', () => dlg.close()); |
|
|
|
document.getElementById('dlg-submit').addEventListener('click', async () => { |
|
const title = titleEl.value.trim(); |
|
if (!title) { titleEl.focus(); return; } |
|
try { |
|
const r = await fetch('/api/tests', { |
|
method: 'POST', |
|
headers: { 'Content-Type': 'application/json' }, |
|
body: JSON.stringify({ title, description: descEl.value.trim() || null }), |
|
}); |
|
const data = await r.json(); |
|
if (!r.ok) throw new Error(data.error || 'Не удалось создать тест.'); |
|
window.location.href = `/tests/${data.testId}/edit`; |
|
} catch (e) { |
|
alert(e.message || 'Не удалось создать тест.'); |
|
} |
|
}); |
|
|
|
const passButtons = Array.from(document.querySelectorAll('.btn-start-pass')); |
|
passButtons.forEach((btn) => { |
|
btn.addEventListener('click', async () => { |
|
const testId = btn.dataset.testId; |
|
if (!testId) return; |
|
btn.disabled = true; |
|
const oldText = (btn.textContent || '').trim() || 'Пройти'; |
|
btn.textContent = `${oldText}…`; |
|
try { |
|
const r = await fetch(`/api/tests/${testId}/attempts/start`, { |
|
method: 'POST', |
|
headers: { 'Content-Type': 'application/json' }, |
|
body: JSON.stringify({}), |
|
}); |
|
let data = {}; |
|
try { data = await r.json(); } catch (_) {} |
|
if (!r.ok || !data.attempt || !data.attempt.id) { |
|
// Если нет страницы попытки, уводим в редактор. |
|
// Тогда ведём в карточку теста, чтобы пользователь не попадал на not_found. |
|
window.location.href = `/tests/${testId}/edit`; |
|
return; |
|
} |
|
window.location.href = `/tests/${testId}/attempt/${data.attempt.id}`; |
|
} catch (e) { |
|
window.location.href = `/tests/${testId}/edit`; |
|
return; |
|
} |
|
}); |
|
}); |
|
})(); |
|
</script> |
|
{% endblock %}
|
|
|