Files
Learn_System/frontend/flashcards.html
T
Maxim Dolgolyov 29301ff87d feat(flashcards): фаза 1 полировки — хоткеи, поиск, drag-reorder, честные интервалы
- study: хоткеи Space/стрелки=флип, 1-4/←→=оценка
- превью интервалов = точная копия серверного SM-2 (было враньё «<1 мин»)
- поиск/фильтр карточек внутри колоды
- drag-reorder карточек + endpoint PUT /decks/:id/reorder (requireOwnership)
- flashcard_decks добавлен в ALLOWED_TABLES requireOwnership
- эмодзи в empty-state → inline SVG .ic
- deleteCard: нативный confirm() → LS.confirm

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 09:53:03 +03:00

1016 lines
51 KiB
HTML
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Флэш-карточки — LearnSpace</title>
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<link href="https://fonts.googleapis.com/css2?family=Unbounded:wght@400;700;800&family=Manrope:wght@400;500;600;700&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="/css/ls.css" />
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.css" />
<style>
.sb-content { background: #f4f5f8; min-height: 100vh; }
.fc-wrap { max-width: 1100px; margin: 0 auto; padding: 28px 28px 80px; }
/* ── header ── */
.fc-header { display: flex; align-items: center; gap: 14px; margin-bottom: 28px; }
.fc-back { display: none; background: none; border: 1.5px solid var(--border);
border-radius: 8px; padding: 6px 12px; cursor: pointer; font-size: .82rem;
color: var(--text-2); transition: .15s; }
.fc-back:hover { background: var(--surface-2); }
.fc-back.visible { display: flex; align-items: center; gap: 6px; }
.fc-title { font-family: 'Unbounded', sans-serif; font-size: 1.25rem; font-weight: 800;
color: var(--text); flex: 1; }
.fc-btn { padding: 8px 16px; border-radius: 9px; border: none; cursor: pointer;
font-family: 'Manrope', sans-serif; font-size: .82rem; font-weight: 700;
transition: .15s; }
.fc-btn-primary { background: var(--violet); color: #fff; }
.fc-btn-primary:hover { filter: brightness(1.12); }
.fc-btn-ghost { background: var(--surface); border: 1.5px solid var(--border); color: var(--text-2); }
.fc-btn-ghost:hover { background: var(--surface-2); }
.fc-btn-danger { background: #FEE2E2; border: 1.5px solid #FECACA; color: #DC2626; }
.fc-btn-danger:hover { background: #FECACA; }
/* ── stats bar ── */
.fc-stats { display: flex; gap: 12px; margin-bottom: 24px; flex-wrap: wrap; }
.fc-stat { background: #fff; border: 1.5px solid var(--border); border-radius: 12px;
padding: 12px 18px; display: flex; flex-direction: column; gap: 2px; min-width: 110px; }
.fc-stat-val { font-family: 'Unbounded', sans-serif; font-size: 1.4rem; font-weight: 800; color: var(--violet); }
.fc-stat-lbl { font-size: .72rem; font-weight: 600; color: var(--text-3); text-transform: uppercase; letter-spacing: .04em; }
/* ── deck grid ── */
.deck-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 16px; }
.deck-card { background: #fff; border: 1.5px solid var(--border); border-radius: 16px;
overflow: hidden; cursor: pointer; transition: box-shadow .18s, transform .18s;
display: flex; flex-direction: column; }
.deck-card:hover { box-shadow: 0 6px 24px rgba(0,0,0,.1); transform: translateY(-2px); }
.deck-stripe { height: 6px; }
.deck-body { padding: 16px 18px 14px; flex: 1; }
.deck-name { font-family: 'Manrope', sans-serif; font-weight: 700; font-size: .96rem;
color: var(--text); margin-bottom: 5px; }
.deck-desc { font-size: .78rem; color: var(--text-3); margin-bottom: 12px;
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
.deck-meta { display: flex; gap: 8px; align-items: center; margin-bottom: 14px; }
.deck-badge { padding: 3px 9px; border-radius: 20px; font-size: .7rem; font-weight: 700;
background: var(--surface-2); color: var(--text-2); }
.deck-badge.due { background: #FEF3C7; color: #D97706; }
.deck-badge.zero { background: #DCFCE7; color: #16A34A; }
.deck-actions { display: flex; gap: 8px; padding: 0 18px 14px; }
.deck-btn-study { flex: 1; padding: 7px; border-radius: 8px; border: none; cursor: pointer;
background: var(--violet); color: #fff; font-family: 'Manrope', sans-serif;
font-size: .8rem; font-weight: 700; transition: .15s; }
.deck-btn-study:hover { filter: brightness(1.1); }
.deck-btn-study:disabled { background: var(--surface-2); color: var(--text-3); cursor: default; filter: none; }
.deck-btn-edit { padding: 7px 12px; border-radius: 8px; border: 1.5px solid var(--border);
cursor: pointer; background: none; color: var(--text-2);
font-size: .8rem; font-weight: 700; font-family: 'Manrope', sans-serif; transition: .15s; }
.deck-btn-edit:hover { background: var(--surface-2); }
/* new deck card */
.deck-add { border: 2px dashed var(--border); border-radius: 16px; min-height: 140px;
display: flex; align-items: center; justify-content: center;
cursor: pointer; transition: .15s; flex-direction: column; gap: 8px;
color: var(--text-3); font-size: .84rem; font-weight: 600; }
.deck-add:hover { border-color: var(--violet); color: var(--violet); background: rgba(155,93,229,.04); }
.deck-add svg { opacity: .5; }
/* ── card list (deck detail) ── */
#view-cards { display: none; }
.card-list { display: flex; flex-direction: column; gap: 10px; margin-bottom: 24px; }
.card-item { background: #fff; border: 1.5px solid var(--border); border-radius: 12px;
display: flex; gap: 0; overflow: hidden; }
.card-item.editing { border-color: var(--violet); }
.card-item.dragging { opacity: .45; }
.card-item.drag-over-top { box-shadow: inset 0 3px 0 0 var(--violet); }
.card-item.drag-over-bottom { box-shadow: inset 0 -3px 0 0 var(--violet); }
.card-drag { display: flex; align-items: center; padding: 0 6px; cursor: grab;
color: var(--text-3); flex-shrink: 0; border-right: 1px solid var(--border); }
.card-drag:active { cursor: grabbing; }
.card-drag:hover { color: var(--violet); background: var(--surface-2); }
.card-drag .ic { width: 18px; height: 18px; }
.card-side { flex: 1; padding: 12px 14px; min-width: 0; }
.card-divider { width: 1px; background: var(--border); flex-shrink: 0; }
.card-side-lbl { font-size: .68rem; font-weight: 700; text-transform: uppercase;
letter-spacing: .06em; color: var(--text-3); margin-bottom: 5px; }
.card-text { font-size: .88rem; color: var(--text); white-space: pre-wrap; }
.card-textarea { width: 100%; border: none; outline: none; resize: none; background: transparent;
font-family: 'Manrope', sans-serif; font-size: .88rem; color: var(--text);
min-height: 48px; line-height: 1.5; padding: 0; }
.card-actions { display: flex; flex-direction: column; gap: 0; border-left: 1px solid var(--border); }
.card-act-btn { padding: 0 12px; height: 100%; flex: 1; border: none; background: none;
cursor: pointer; color: var(--text-3); transition: .15s; display: flex;
align-items: center; justify-content: center; }
.card-act-btn:hover { background: var(--surface-2); color: var(--text); }
.card-act-btn.del:hover { background: #FEE2E2; color: #DC2626; }
.card-add-bar { display: flex; gap: 10px; align-items: center; }
.card-add-input { flex: 1; padding: 10px 14px; border: 1.5px solid var(--border); border-radius: 10px;
font-family: 'Manrope', sans-serif; font-size: .88rem; background: #fff;
color: var(--text); outline: none; transition: .15s; }
.card-add-input:focus { border-color: var(--violet); }
/* card search */
.card-search-bar { display: flex; align-items: center; gap: 8px; margin-bottom: 14px;
background: #fff; border: 1.5px solid var(--border); border-radius: 10px; padding: 0 12px; }
.card-search-bar:focus-within { border-color: var(--violet); }
.card-search-ic { width: 16px; height: 16px; color: var(--text-3); flex-shrink: 0; }
.card-search-input { flex: 1; border: none; outline: none; background: transparent; padding: 9px 0;
font-family: 'Manrope', sans-serif; font-size: .86rem; color: var(--text); }
.card-search-count { font-size: .72rem; color: var(--text-3); font-weight: 600; flex-shrink: 0; }
/* ── study mode ── */
#view-study { display: none; }
.study-wrap { max-width: 600px; margin: 0 auto; }
.study-progress-bar { height: 5px; background: var(--surface-2); border-radius: 3px;
margin-bottom: 22px; overflow: hidden; }
.study-progress-fill { height: 100%; background: var(--violet); border-radius: 3px;
transition: width .35s ease; }
.study-counter { text-align: center; font-size: .8rem; color: var(--text-3);
font-weight: 600; margin-bottom: 18px; }
/* card */
.study-card-scene { perspective: 1000px; height: 260px; margin-bottom: 22px; cursor: pointer;
user-select: none; -webkit-user-select: none; }
.study-card-inner { width: 100%; height: 100%; position: relative; transition: transform .5s cubic-bezier(.4,0,.2,1);
transform-style: preserve-3d; will-change: transform; }
.study-card-inner.flipped { transform: rotateY(180deg); }
.study-card-inner.swipe-right { animation: swipeRight .4s ease forwards; }
.study-card-inner.swipe-left { animation: swipeLeft .4s ease forwards; }
@keyframes swipeRight { to { transform: translateX(110%) rotate(20deg) rotateY(0deg); opacity: 0; } }
@keyframes swipeLeft { to { transform: translateX(-110%) rotate(-20deg) rotateY(180deg); opacity: 0; } }
.study-face { position: absolute; inset: 0; border-radius: 18px; padding: 28px 32px;
display: flex; align-items: center; justify-content: center;
backface-visibility: hidden; -webkit-backface-visibility: hidden;
border: 1.5px solid var(--border); overflow: auto; }
.study-face-front { background: #fff; box-shadow: 0 4px 24px rgba(0,0,0,.08); }
.study-face-back { background: #fff; transform: rotateY(180deg);
box-shadow: 0 4px 24px rgba(0,0,0,.08); }
.study-face-text { font-family: 'Manrope', sans-serif; font-size: 1.15rem; font-weight: 600;
color: var(--text); text-align: center; line-height: 1.6; }
.study-face-label { position: absolute; top: 12px; left: 16px; font-size: .65rem; font-weight: 700;
text-transform: uppercase; letter-spacing: .06em; color: var(--text-3); }
.study-hint { text-align: center; font-size: .77rem; color: var(--text-3); margin-bottom: 20px; }
/* drag tilt */
.study-card-inner.drag-right { transform: rotate(6deg) translateX(30px); }
.study-card-inner.drag-left { transform: rotate(-6deg) translateX(-30px); }
/* answer quality buttons */
.study-btns { display: none; gap: 10px; justify-content: center; flex-wrap: wrap; margin-bottom: 24px; }
.study-btns.visible { display: flex; }
.sq-btn { padding: 10px 20px; border-radius: 10px; border: 2px solid transparent;
cursor: pointer; font-family: 'Manrope', sans-serif; font-size: .84rem; font-weight: 700;
transition: .18s; display: flex; flex-direction: column; align-items: center; gap: 2px; }
.sq-btn .sq-top { display: flex; align-items: center; gap: 6px; }
.sq-btn .sq-days { font-size: .66rem; font-weight: 600; opacity: .65; }
.fc-kbd { font-family: 'Manrope', sans-serif; font-size: .62rem; font-weight: 800; line-height: 1;
padding: 2px 5px; border-radius: 5px; background: rgba(0,0,0,.08);
border: 1px solid rgba(0,0,0,.12); color: inherit; opacity: .8; }
.study-hint .fc-kbd { opacity: .9; }
.sq-btn-again { background: #FEE2E2; border-color: #FECACA; color: #DC2626; }
.sq-btn-again:hover { background: #FECACA; }
.sq-btn-hard { background: #FEF3C7; border-color: #FDE68A; color: #D97706; }
.sq-btn-hard:hover { background: #FDE68A; }
.sq-btn-good { background: #DBEAFE; border-color: #BFDBFE; color: #2563EB; }
.sq-btn-good:hover { background: #BFDBFE; }
.sq-btn-easy { background: #DCFCE7; border-color: #BBF7D0; color: #16A34A; }
.sq-btn-easy:hover { background: #BBF7D0; }
/* swipe indicator */
.swipe-indicator { position: absolute; top: 18px; font-size: 1.4rem; font-weight: 900;
letter-spacing: .04em; padding: 5px 14px; border-radius: 8px;
opacity: 0; pointer-events: none; transition: opacity .1s; z-index: 10; }
.swipe-right-ind { right: 20px; background: #DCFCE7; color: #16A34A; border: 2px solid #BBF7D0; }
.swipe-left-ind { left: 20px; background: #FEE2E2; color: #DC2626; border: 2px solid #FECACA; }
/* finished state */
.study-done { text-align: center; padding: 48px 24px; }
.study-done-icon { font-size: 3rem; margin-bottom: 16px; }
.study-done h2 { font-family: 'Unbounded', sans-serif; font-size: 1.4rem; font-weight: 800;
color: var(--text); margin-bottom: 8px; }
.study-done p { color: var(--text-3); font-size: .88rem; margin-bottom: 24px; }
.study-session-stats { display: flex; gap: 12px; justify-content: center; flex-wrap: wrap; margin-bottom: 28px; }
.ss-stat { background: #fff; border: 1.5px solid var(--border); border-radius: 12px;
padding: 12px 20px; text-align: center; }
.ss-stat-n { font-family: 'Unbounded', sans-serif; font-size: 1.4rem; font-weight: 800; }
.ss-stat-l { font-size: .72rem; color: var(--text-3); font-weight: 600; }
/* ── modals ── */
.fc-modal { position: fixed; inset: 0; z-index: 300; display: none;
align-items: center; justify-content: center; padding: 16px; }
.fc-modal.open { display: flex; }
.fc-modal-bg { position: absolute; inset: 0; background: rgba(0,0,0,.45); }
.fc-modal-box { position: relative; z-index: 1; background: #fff; border-radius: 18px;
padding: 28px; width: 100%; max-width: 480px; box-shadow: 0 20px 60px rgba(0,0,0,.18); }
.fc-modal-title { font-family: 'Unbounded', sans-serif; font-size: 1rem; font-weight: 800;
color: var(--text); margin-bottom: 20px; }
.fc-modal-field { margin-bottom: 14px; }
.fc-modal-label { font-size: .75rem; font-weight: 700; color: var(--text-2);
text-transform: uppercase; letter-spacing: .05em; margin-bottom: 5px; }
.fc-modal-input { width: 100%; padding: 9px 12px; border: 1.5px solid var(--border);
border-radius: 9px; font-family: 'Manrope', sans-serif; font-size: .88rem;
color: var(--text); outline: none; box-sizing: border-box; transition: .15s; }
.fc-modal-input:focus { border-color: var(--violet); }
.fc-modal-actions { display: flex; gap: 10px; justify-content: flex-end; margin-top: 22px; }
.color-picker-row { display: flex; gap: 8px; flex-wrap: wrap; }
.cp-swatch { width: 28px; height: 28px; border-radius: 50%; cursor: pointer;
border: 3px solid transparent; transition: .15s; }
.cp-swatch.active, .cp-swatch:hover { border-color: var(--text); transform: scale(1.15); }
/* ── empty ── */
.fc-empty { text-align: center; padding: 60px 24px; }
.fc-empty-icon { font-size: 3rem; margin-bottom: 14px; }
.fc-empty h3 { font-family: 'Unbounded', sans-serif; font-size: 1rem; font-weight: 800;
color: var(--text); margin-bottom: 8px; }
.fc-empty p { color: var(--text-3); font-size: .84rem; margin-bottom: 22px; }
@media (max-width: 768px) {
.fc-wrap { padding: 16px 14px 60px; }
.fc-title { font-size: 1rem; }
.fc-stats { gap: 8px; }
.fc-stat { min-width: 90px; padding: 10px 12px; }
.fc-stat-val { font-size: 1.1rem; }
.deck-grid { grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 12px; }
.study-face { padding: 24px 18px; }
.sq-btn { padding: 9px 16px; font-size: .8rem; }
}
@media (max-width: 480px) {
.fc-wrap { padding: 12px 10px 60px; }
.fc-header { flex-wrap: wrap; gap: 10px; }
.fc-title { font-size: 0.92rem; }
.deck-grid { grid-template-columns: 1fr; }
.study-face { padding: 20px 14px; }
.sq-btn { padding: 8px 14px; font-size: .78rem; flex: 1; justify-content: center; }
}
</style>
</head>
<body>
<div class="app-layout">
<aside class="sidebar" id="app-sidebar"></aside>
<main class="sb-content">
<div class="fc-wrap">
<!-- ── DECKS VIEW ── -->
<div id="view-decks">
<div class="fc-header">
<h1 class="fc-title">Флэш-карточки</h1>
<button class="fc-btn fc-btn-primary" onclick="openNewDeckModal()">+ Новая колода</button>
</div>
<div class="fc-stats" id="fc-stats-bar"></div>
<div class="deck-grid" id="deck-grid">
<div style="grid-column:1/-1; text-align:center; padding:40px; color:var(--text-3)">Загрузка…</div>
</div>
</div>
<!-- ── CARDS VIEW ── -->
<div id="view-cards">
<div class="fc-header">
<button class="fc-back visible" id="cards-back-btn" onclick="showDecks()">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M19 12H5M12 5l-7 7 7 7"/></svg>
Колоды
</button>
<h1 class="fc-title" id="cards-deck-title">Название колоды</h1>
<button class="fc-btn fc-btn-ghost" onclick="openBulkModal()">Добавить список</button>
<button class="fc-btn fc-btn-primary" id="cards-study-btn" onclick="startStudy()">Начать изучение</button>
</div>
<div class="card-search-bar" id="card-search-bar" style="display:none">
<svg class="ic card-search-ic" viewBox="0 0 24 24"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/></svg>
<input class="card-search-input" id="card-search" placeholder="Поиск по карточкам…" oninput="onCardSearch(this.value)" />
<span class="card-search-count" id="card-search-count"></span>
</div>
<div class="card-list" id="card-list"></div>
<!-- Add card row -->
<div class="card-add-bar" style="margin-bottom:14px">
<input class="card-add-input" id="new-card-front" placeholder="Лицевая сторона (вопрос)…" onkeydown="addCardOnEnter(event)" />
<input class="card-add-input" id="new-card-back" placeholder="Обратная сторона (ответ)…" onkeydown="addCardOnEnter(event)" />
<button class="fc-btn fc-btn-primary" onclick="addCard()">+ Добавить</button>
</div>
<div style="display:flex;gap:10px;align-items:center">
<button class="fc-btn fc-btn-ghost" onclick="openEditDeckModal()"><svg class="ic" viewBox="0 0 24 24"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.12 2.12 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>️ Редактировать колоду</button>
<button class="fc-btn fc-btn-danger" onclick="confirmDeleteDeck()">Удалить колоду</button>
</div>
</div>
<!-- ── STUDY VIEW ── -->
<div id="view-study">
<div class="fc-header">
<button class="fc-back visible" onclick="showCards()">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M19 12H5M12 5l-7 7 7 7"/></svg>
Карточки
</button>
<h1 class="fc-title" id="study-deck-title">Изучение</h1>
</div>
<div class="study-wrap">
<div class="study-progress-bar"><div class="study-progress-fill" id="study-prog" style="width:0%"></div></div>
<div class="study-counter" id="study-counter">1 / 10</div>
<div class="study-card-scene" id="study-scene" onclick="flipCard()">
<div class="study-card-inner" id="study-card">
<div class="study-face study-face-front">
<span class="study-face-label">Вопрос</span>
<div class="study-face-text" id="study-front-text"></div>
</div>
<div class="study-face study-face-back">
<span class="study-face-label">Ответ</span>
<div class="study-face-text" id="study-back-text"></div>
</div>
<span class="swipe-indicator swipe-right-ind" id="ind-right">ЗНАЮ <svg class="ic" viewBox="0 0 24 24"><polyline points="20 6 9 17 4 12"/></svg></span>
<span class="swipe-indicator swipe-left-ind" id="ind-left">ЕЩЁ РАЗ <svg class="ic" viewBox="0 0 24 24"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></span>
</div>
</div>
<div class="study-hint" id="study-flip-hint">Нажмите или <kbd class="fc-kbd">Пробел</kbd>, чтобы увидеть ответ</div>
<div class="study-btns" id="study-btns">
<button class="sq-btn sq-btn-again" onclick="answer(0)"><span class="sq-top">Снова<kbd class="fc-kbd">1</kbd></span><span class="sq-days" id="sq-days-0">1 день</span></button>
<button class="sq-btn sq-btn-hard" onclick="answer(3)"><span class="sq-top">Трудно<kbd class="fc-kbd">2</kbd></span><span class="sq-days" id="sq-days-3"></span></button>
<button class="sq-btn sq-btn-good" onclick="answer(4)"><span class="sq-top">Знаю<kbd class="fc-kbd">3</kbd></span><span class="sq-days" id="sq-days-4"></span></button>
<button class="sq-btn sq-btn-easy" onclick="answer(5)"><span class="sq-top">Легко<kbd class="fc-kbd">4</kbd></span><span class="sq-days" id="sq-days-5"></span></button>
</div>
<!-- done screen -->
<div class="study-done" id="study-done" style="display:none">
<div class="study-done-icon"><svg class="ic" viewBox="0 0 24 24"><path d="m12 3-1.9 5.8a2 2 0 0 1-1.3 1.3L3 12l5.8 1.9a2 2 0 0 1 1.3 1.3L12 21l1.9-5.8a2 2 0 0 1 1.3-1.3L21 12l-5.8-1.9a2 2 0 0 1-1.3-1.3z"/></svg></div>
<h2>Сессия завершена!</h2>
<p id="study-done-sub">Хорошая работа — вы просмотрели все карточки</p>
<div class="study-session-stats" id="study-session-stats"></div>
<div style="display:flex;gap:12px;justify-content:center">
<button class="fc-btn fc-btn-primary" onclick="startStudy()">Повторить ещё раз</button>
<button class="fc-btn fc-btn-ghost" onclick="showCards()">Вернуться</button>
</div>
</div>
</div>
</div>
</div>
</main>
</div>
<!-- ── New / Edit Deck Modal ── -->
<div class="fc-modal" id="modal-deck">
<div class="fc-modal-bg" onclick="closeModal('modal-deck')"></div>
<div class="fc-modal-box">
<div class="fc-modal-title" id="modal-deck-title">Новая колода</div>
<div class="fc-modal-field">
<div class="fc-modal-label">Название</div>
<input class="fc-modal-input" id="modal-deck-name" placeholder="Биология — клетка" maxlength="80" />
</div>
<div class="fc-modal-field">
<div class="fc-modal-label">Описание (необязательно)</div>
<input class="fc-modal-input" id="modal-deck-desc" placeholder="Краткое описание…" maxlength="200" />
</div>
<div class="fc-modal-field">
<div class="fc-modal-label">Цвет</div>
<div class="color-picker-row" id="color-picker">
<!-- filled by JS -->
</div>
</div>
<div class="fc-modal-actions">
<button class="fc-btn fc-btn-ghost" onclick="closeModal('modal-deck')">Отмена</button>
<button class="fc-btn fc-btn-primary" onclick="saveDeckModal()">Сохранить</button>
</div>
</div>
</div>
<!-- ── Bulk add Modal ── -->
<div class="fc-modal" id="modal-bulk">
<div class="fc-modal-bg" onclick="closeModal('modal-bulk')"></div>
<div class="fc-modal-box">
<div class="fc-modal-title">Добавить список карточек</div>
<div class="fc-modal-field">
<div class="fc-modal-label">Формат: одна карточка = одна строка, вопрос и ответ разделены «—» или «|»</div>
<textarea class="fc-modal-input" id="bulk-text" rows="10" style="resize:vertical"
placeholder="Митохондрия — органелл клетки, производит АТФ&#10;Ядро | содержит ДНК&#10;Рибосома — синтез белка"></textarea>
</div>
<div class="fc-modal-actions">
<button class="fc-btn fc-btn-ghost" onclick="closeModal('modal-bulk')">Отмена</button>
<button class="fc-btn fc-btn-primary" onclick="saveBulk()">Добавить</button>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/lucide@0.469.0/dist/umd/lucide.min.js"></script>
<script src="/js/api.js"></script>
<script src="/js/sidebar.js"></script>
<script src="/js/notifications.js"></script>
<script src="/js/search.js"></script>
<script src="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/contrib/auto-render.min.js"></script>
<script>
/* ════ Constants & State ════ */
const COLORS = ['#9B5DE5','#EF476F','#FF9F1C','#06D6E0','#22d399','#3B82F6','#F15BB5','#6B7280'];
let _decks = [];
let _curDeck = null;
let _cards = [];
let _editingDeckId = null;
let _deckColor = '#9B5DE5';
let _cardFilter = '';
(async () => {
/* ── auth ── */
const { user } = LS.initPage();
if (!user) return;
const avatarEl = document.getElementById('nav-avatar');
const nameEl = document.getElementById('nav-user');
LS.renderNavAvatar(avatarEl, user);
if (nameEl) nameEl.textContent = user.name || '';
LS.showBoardIfAllowed();
if (user.role!=='student') { document.getElementById('btn-classes')?.style && (document.getElementById('btn-classes').style.display='flex'); }
if (user.role==='admin') { document.getElementById('btn-admin')?.style && (document.getElementById('btn-admin').style.display='flex'); }
if (localStorage.getItem('ls_sb_collapsed') === '1')
document.querySelector('.app-layout')?.classList.add('sb-collapsed');
lucide.createIcons();
init();
})();
/* ════ Init ════ */
async function init() {
buildColorPicker();
bindStudyKeys();
await loadDecks();
}
/* ── keyboard shortcuts in study mode ──
Space/Enter/↑/↓ — перевернуть; после переворота 1-4 или ←/→ — оценка. */
function bindStudyKeys() {
document.addEventListener('keydown', (e) => {
if (document.getElementById('view-study')?.style.display !== 'block') return;
if (document.getElementById('study-done')?.style.display === 'block') return;
const t = e.target;
if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable)) return;
const flip = ['Space', 'Enter', 'ArrowUp', 'ArrowDown'];
if (!_studyFlipped) {
if (flip.includes(e.code) || e.key === ' ') { e.preventDefault(); flipCard(); }
else if (e.code === 'ArrowLeft' || e.code === 'ArrowRight') { e.preventDefault(); flipCard(); }
return;
}
// flipped → grade
const map = { Digit1: 0, Digit2: 3, Digit3: 4, Digit4: 5,
Numpad1: 0, Numpad2: 3, Numpad3: 4, Numpad4: 5,
ArrowLeft: 0, ArrowRight: 5 };
if (e.code in map) { e.preventDefault(); answer(map[e.code]); }
});
}
async function loadDecks() {
const [decks, stats] = await Promise.all([
LS.api('/api/flashcards/decks').catch(()=>({decks:[]})),
LS.api('/api/flashcards/stats').catch(()=>null),
]);
_decks = decks.decks || [];
renderStats(stats);
renderDecks();
}
/* ════ Sidebar toggle ════ */
function toggleSidebar() {
const l = document.querySelector('.app-layout');
l.classList.toggle('sb-collapsed');
localStorage.setItem('ls_sb_collapsed', l.classList.contains('sb-collapsed') ? '1' : '0');
}
/* ════ Stats bar ════ */
function renderStats(s) {
if (!s) return;
const bar = document.getElementById('fc-stats-bar');
bar.innerHTML = [
{ val: s.decks_count, lbl: 'Колод', col: '#9B5DE5' },
{ val: s.cards_count, lbl: 'Карточек', col: '#3B82F6' },
{ val: s.due_count, lbl: 'К повторению', col: '#D97706' },
{ val: s.reviewed_today, lbl: 'Сегодня', col: '#16A34A' },
].map(s => `<div class="fc-stat">
<span class="fc-stat-val" style="color:${s.col}">${s.val}</span>
<span class="fc-stat-lbl">${s.lbl}</span>
</div>`).join('');
}
/* ════ Deck grid ════ */
function renderDecks() {
const grid = document.getElementById('deck-grid');
if (!_decks.length) {
grid.innerHTML = `<div class="fc-empty" style="grid-column:1/-1">
<div class="fc-empty-icon"><svg class="ic" style="width:46px;height:46px" viewBox="0 0 24 24"><rect x="3" y="5" width="13" height="15" rx="2"/><path d="M8 5V3.5A1.5 1.5 0 0 1 9.5 2h9A1.5 1.5 0 0 1 20 3.5V16a1.5 1.5 0 0 1-1.5 1.5H16"/></svg></div>
<h3>Нет колод</h3>
<p>Создайте первую колоду карточек</p>
<button class="fc-btn fc-btn-primary" onclick="openNewDeckModal()">+ Создать колоду</button>
</div>`;
return;
}
grid.innerHTML = _decks.map(d => {
const due = d.due_count;
const dueHtml = due > 0
? `<span class="deck-badge due"><svg class="ic" viewBox="0 0 24 24"><rect x="3" y="4" width="18" height="18" rx="2" ry="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg> ${due} к повторению</span>`
: `<span class="deck-badge zero"><svg class="ic" viewBox="0 0 24 24"><polyline points="20 6 9 17 4 12"/></svg> Актуально</span>`;
return `<div class="deck-card">
<div class="deck-stripe" style="background:${d.color}"></div>
<div class="deck-body" onclick="openDeck(${d.id})">
<div class="deck-name">${esc(d.title)}</div>
${d.description ? `<div class="deck-desc">${esc(d.description)}</div>` : ''}
<div class="deck-meta">
<span class="deck-badge"><svg class="ic" viewBox="0 0 24 24"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg> ${d.card_count} карточек</span>
${dueHtml}
</div>
</div>
<div class="deck-actions">
<button class="deck-btn-study" ${due===0&&d.card_count>0?'':''}
onclick="openDeckStudy(${d.id})" ${d.card_count===0?'disabled':''}>
${due > 0 ? `<svg class="ic" viewBox="0 0 24 24"><polygon points="5 3 19 12 5 21 5 3"/></svg> Повторить (${due})` : d.card_count > 0 ? '<svg class="ic" viewBox="0 0 24 24"><polygon points="5 3 19 12 5 21 5 3"/></svg> Изучать' : 'Нет карточек'}
</button>
<button class="deck-btn-edit" onclick="openDeck(${d.id})"><svg class="ic" viewBox="0 0 24 24"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.12 2.12 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg></button>
</div>
</div>`;
}).join('') + `<div class="deck-add" onclick="openNewDeckModal()">
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M12 8v8M8 12h8"/></svg>
Новая колода
</div>`;
}
/* ════ Open deck (card editor) ════ */
async function openDeck(id) {
_curDeck = _decks.find(d => d.id === id);
if (!_curDeck) return;
document.getElementById('cards-deck-title').textContent = _curDeck.title;
const data = await LS.api(`/api/flashcards/decks/${id}/cards`).catch(()=>({cards:[]}));
_cards = data.cards || [];
_cardFilter = '';
const si = document.getElementById('card-search'); if (si) si.value = '';
renderCardList();
document.getElementById('view-decks').style.display = 'none';
document.getElementById('view-cards').style.display = 'block';
document.getElementById('view-study').style.display = 'none';
}
function openDeckStudy(id) {
_curDeck = _decks.find(d => d.id === id);
if (!_curDeck) return;
startStudyForDeck(id);
}
function showDecks() {
document.getElementById('view-decks').style.display = 'block';
document.getElementById('view-cards').style.display = 'none';
document.getElementById('view-study').style.display = 'none';
loadDecks();
}
function showCards() {
document.getElementById('view-decks').style.display = 'none';
document.getElementById('view-cards').style.display = 'block';
document.getElementById('view-study').style.display = 'none';
}
/* ════ Card list ════ */
function onCardSearch(v) {
_cardFilter = (v || '').trim().toLowerCase();
renderCardList();
}
function renderCardList() {
const list = document.getElementById('card-list');
const bar = document.getElementById('card-search-bar');
// строка поиска появляется, когда карточек достаточно для фильтрации
if (bar) bar.style.display = _cards.length > 4 ? 'flex' : 'none';
if (!_cards.length) {
if (bar) bar.style.display = 'none';
list.innerHTML = `<div class="fc-empty" style="padding:30px 0">
<div class="fc-empty-icon"><svg class="ic" style="width:40px;height:40px" viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg></div>
<h3>Нет карточек</h3>
<p>Добавьте первую карточку ниже</p>
</div>`;
return;
}
const q = _cardFilter;
const shown = q
? _cards.filter(c => (c.front || '').toLowerCase().includes(q) || (c.back || '').toLowerCase().includes(q))
: _cards;
const cnt = document.getElementById('card-search-count');
if (cnt) cnt.textContent = q ? `${shown.length} из ${_cards.length}` : `${_cards.length} карточек`;
if (!shown.length) {
list.innerHTML = `<div class="fc-empty" style="padding:24px 0">
<h3>Ничего не найдено</h3>
<p>По запросу «${esc(q)}» карточек нет</p>
</div>`;
return;
}
list.innerHTML = shown.map((c) => `
<div class="card-item" id="ci-${c.id}" data-id="${c.id}">
${q ? '' : `<div class="card-drag" title="Перетащите для сортировки">
<svg class="ic" viewBox="0 0 24 24"><circle cx="9" cy="6" r="1"/><circle cx="9" cy="12" r="1"/><circle cx="9" cy="18" r="1"/><circle cx="15" cy="6" r="1"/><circle cx="15" cy="12" r="1"/><circle cx="15" cy="18" r="1"/></svg>
</div>`}
<div class="card-side">
<div class="card-side-lbl">Вопрос</div>
<textarea class="card-textarea" rows="2"
onchange="saveCard(${c.id},'front',this.value)">${esc(c.front)}</textarea>
</div>
<div class="card-divider"></div>
<div class="card-side">
<div class="card-side-lbl">Ответ</div>
<textarea class="card-textarea" rows="2"
onchange="saveCard(${c.id},'back',this.value)">${esc(c.back)}</textarea>
</div>
<div class="card-actions">
<button class="card-act-btn del" onclick="deleteCard(${c.id})" title="Удалить">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14H6L5 6"/><path d="M10 11v6M14 11v6"/></svg>
</button>
</div>
</div>`).join('');
if (!q) bindCardDrag();
}
/* ── drag-reorder карточек (только без активного фильтра) ── */
let _dragId = null;
function bindCardDrag() {
const list = document.getElementById('card-list');
if (!list) return;
list.querySelectorAll('.card-item').forEach(el => {
const handle = el.querySelector('.card-drag');
if (!handle) return;
// перетаскивание стартует только с ручки — textarea остаётся редактируемой
handle.addEventListener('mousedown', () => el.setAttribute('draggable', 'true'));
handle.addEventListener('touchstart', () => el.setAttribute('draggable', 'true'), { passive: true });
el.addEventListener('dragstart', (e) => {
_dragId = +el.dataset.id;
el.classList.add('dragging');
try { e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.setData('text/plain', String(_dragId)); } catch {}
});
el.addEventListener('dragend', () => {
el.classList.remove('dragging'); el.removeAttribute('draggable');
list.querySelectorAll('.drag-over-top,.drag-over-bottom')
.forEach(x => x.classList.remove('drag-over-top', 'drag-over-bottom'));
_dragId = null;
});
el.addEventListener('dragover', (e) => {
e.preventDefault();
const r = el.getBoundingClientRect();
const after = (e.clientY - r.top) > r.height / 2;
el.classList.toggle('drag-over-bottom', after);
el.classList.toggle('drag-over-top', !after);
});
el.addEventListener('dragleave', () => el.classList.remove('drag-over-top', 'drag-over-bottom'));
el.addEventListener('drop', (e) => {
e.preventDefault();
el.classList.remove('drag-over-top', 'drag-over-bottom');
const targetId = +el.dataset.id;
if (_dragId == null || _dragId === targetId) return;
const r = el.getBoundingClientRect();
const after = (e.clientY - r.top) > r.height / 2;
moveCard(_dragId, targetId, after);
});
});
}
function moveCard(dragId, targetId, after) {
const from = _cards.findIndex(c => c.id === dragId);
if (from < 0) return;
const item = _cards.splice(from, 1)[0];
let to = _cards.findIndex(c => c.id === targetId);
if (to < 0) { _cards.splice(from, 0, item); return; }
if (after) to += 1;
_cards.splice(to, 0, item);
renderCardList();
persistCardOrder();
}
async function persistCardOrder() {
if (!_curDeck) return;
const order = _cards.map(c => c.id);
await LS.api(`/api/flashcards/decks/${_curDeck.id}/reorder`, {
method: 'PUT', body: JSON.stringify({ order })
}).catch(() => {});
}
async function addCard() {
if (!_curDeck) return;
const front = document.getElementById('new-card-front').value.trim();
const back = document.getElementById('new-card-back').value.trim();
if (!front && !back) return;
const card = await LS.api(`/api/flashcards/decks/${_curDeck.id}/cards`, {
method: 'POST', body: JSON.stringify({ front, back })
}).catch(()=>null);
if (!card) return;
_cards.push(card);
document.getElementById('new-card-front').value = '';
document.getElementById('new-card-back').value = '';
document.getElementById('new-card-front').focus();
renderCardList();
}
function addCardOnEnter(e) {
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); addCard(); }
}
async function saveCard(id, field, value) {
const card = _cards.find(c => c.id === id);
if (!card) return;
card[field] = value;
await LS.api(`/api/flashcards/cards/${id}`, {
method: 'PUT', body: JSON.stringify({ [field]: value })
}).catch(()=>{});
}
async function deleteCard(id) {
if (!await LS.confirm('Удалить карточку?', { title: 'Удаление карточки', confirmText: 'Удалить', danger: true })) return;
await LS.api(`/api/flashcards/cards/${id}`, { method: 'DELETE' }).catch(()=>{});
_cards = _cards.filter(c => c.id !== id);
renderCardList();
}
/* ════ Bulk add ════ */
function openBulkModal() {
document.getElementById('bulk-text').value = '';
document.getElementById('modal-bulk').classList.add('open');
}
async function saveBulk() {
const text = document.getElementById('bulk-text').value.trim();
if (!text || !_curDeck) return;
const lines = text.split('\n').map(l => l.trim()).filter(Boolean);
const cards = lines.map(l => {
const sep = l.includes('—') ? '—' : '|';
const [front, ...rest] = l.split(sep);
return { front: (front||'').trim(), back: rest.join(sep).trim() };
}).filter(c => c.front);
if (!cards.length) return;
const result = await LS.api(`/api/flashcards/decks/${_curDeck.id}/cards/bulk`, {
method: 'POST', body: JSON.stringify({ cards })
}).catch(()=>null);
if (result?.inserted) {
_cards.push(...result.inserted);
renderCardList();
}
closeModal('modal-bulk');
}
/* ════ Study mode ════ */
let _studyCards = [];
let _studyIdx = 0;
let _studyFlipped = false;
let _sessionStats = { again: 0, hard: 0, good: 0, easy: 0 };
async function startStudy() {
if (!_curDeck) return;
await startStudyForDeck(_curDeck.id);
}
async function startStudyForDeck(deckId) {
_curDeck = _curDeck || _decks.find(d => d.id === deckId);
if (!_curDeck) return;
const data = await LS.api(`/api/flashcards/decks/${deckId}/study`).catch(()=>null);
if (!data || !data.cards?.length) {
LS.toast('Нет карточек для повторения — всё актуально!', 'success');
return;
}
_studyCards = data.cards;
_studyIdx = 0;
_studyFlipped = false;
_sessionStats = { again: 0, hard: 0, good: 0, easy: 0 };
document.getElementById('study-deck-title').textContent = _curDeck.title;
document.getElementById('study-done').style.display = 'none';
document.getElementById('study-scene').style.display = 'block';
document.getElementById('study-flip-hint').style.display = 'block';
document.getElementById('view-decks').style.display = 'none';
document.getElementById('view-cards').style.display = 'none';
document.getElementById('view-study').style.display = 'block';
showStudyCard();
bindSwipe();
}
const _FC_DELIMS = [
{ left: '\\(', right: '\\)', display: false },
{ left: '\\[', right: '\\]', display: true },
{ left: '$', right: '$', display: false },
];
function mathHtmlFC(text) {
if (!text) return '';
const tmp = document.createElement('span');
tmp.textContent = text;
if (window.renderMathInElement) {
try { renderMathInElement(tmp, { delimiters: _FC_DELIMS, throwOnError: false }); } catch {}
}
return tmp.innerHTML;
}
function showStudyCard() {
const card = _studyCards[_studyIdx];
if (!card) { finishStudy(); return; }
const el = document.getElementById('study-card');
el.className = 'study-card-inner';
document.getElementById('study-front-text').innerHTML = mathHtmlFC(card.front);
document.getElementById('study-back-text').innerHTML = mathHtmlFC(card.back);
_studyFlipped = false;
document.getElementById('study-btns').classList.remove('visible');
document.getElementById('study-flip-hint').style.display = 'block';
document.getElementById('ind-right').style.opacity = '0';
document.getElementById('ind-left').style.opacity = '0';
updateStudyProgress();
updateSQDays(card);
}
function updateStudyProgress() {
const total = _studyCards.length;
const done = _studyIdx;
document.getElementById('study-prog').style.width = (done / total * 100) + '%';
document.getElementById('study-counter').textContent = `${done + 1} / ${total}`;
}
function flipCard() {
if (_studyFlipped) return;
_studyFlipped = true;
document.getElementById('study-card').classList.add('flipped');
document.getElementById('study-btns').classList.add('visible');
document.getElementById('study-flip-hint').style.display = 'none';
}
async function answer(quality) {
const card = _studyCards[_studyIdx];
if (!card) return;
// track session stats
if (quality === 0) _sessionStats.again++;
else if (quality === 3) _sessionStats.hard++;
else if (quality === 4) _sessionStats.good++;
else if (quality === 5) _sessionStats.easy++;
// send review
await LS.api(`/api/flashcards/cards/${card.id}/review`, {
method: 'POST', body: JSON.stringify({ quality })
}).catch(()=>{});
// animate swipe
const el = document.getElementById('study-card');
el.classList.add(quality >= 3 ? 'swipe-right' : 'swipe-left');
setTimeout(() => {
_studyIdx++;
if (_studyIdx >= _studyCards.length) finishStudy();
else showStudyCard();
}, 380);
}
function finishStudy() {
document.getElementById('study-scene').style.display = 'none';
document.getElementById('study-btns').classList.remove('visible');
document.getElementById('study-flip-hint').style.display = 'none';
document.getElementById('study-done').style.display = 'block';
const s = _sessionStats;
const total = s.again + s.hard + s.good + s.easy;
document.getElementById('study-done-sub').textContent =
`Просмотрено ${total} карточек`;
document.getElementById('study-session-stats').innerHTML = [
{ n: s.again, l: 'Снова', c: '#DC2626' },
{ n: s.hard, l: 'Трудно', c: '#D97706' },
{ n: s.good, l: 'Знаю', c: '#2563EB' },
{ n: s.easy, l: 'Легко', c: '#16A34A' },
].map(x => `<div class="ss-stat">
<div class="ss-stat-n" style="color:${x.c}">${x.n}</div>
<div class="ss-stat-l">${x.l}</div>
</div>`).join('');
}
/* ── estimated next interval preview for sq buttons ──
ВАЖНО: точная копия серверного sm2() (flashcardController.js), иначе
превью врёт. В чистом SM-2 интервал для q>=3 НЕ зависит от значения q
(q влияет только на ease factor), поэтому «Трудно/Знаю/Легко» при первых
повторениях дают одинаковый интервал — это корректно.
(Дифференциация интервалов по кнопкам — кандидат на Фазу 4.) */
function fcNextInterval(card, q) {
const ef = card.ease_factor || 2.5;
const iv = card.interval_days || 1;
const rep = card.repetitions || 0;
if (q < 3) return 1;
if (rep === 0) return 1;
if (rep === 1) return 6;
return Math.round(iv * ef);
}
function fcDaysLabel(n) {
if (n <= 1) return '1 день';
if (n < 5) return n + ' дня';
return n + ' дн.';
}
function updateSQDays(card) {
document.getElementById('sq-days-0').textContent = fcDaysLabel(fcNextInterval(card, 0));
document.getElementById('sq-days-3').textContent = fcDaysLabel(fcNextInterval(card, 3));
document.getElementById('sq-days-4').textContent = fcDaysLabel(fcNextInterval(card, 4));
document.getElementById('sq-days-5').textContent = fcDaysLabel(fcNextInterval(card, 5));
}
/* ── touch/mouse swipe ── */
function bindSwipe() {
const scene = document.getElementById('study-scene');
let startX = null, curX = null;
const THRESHOLD = 80;
const onStart = e => { startX = (e.touches ? e.touches[0] : e).clientX; curX = startX; };
const onMove = e => {
if (startX === null) return;
curX = (e.touches ? e.touches[0] : e).clientX;
const dx = curX - startX;
const card = document.getElementById('study-card');
if (Math.abs(dx) < 10) return;
card.style.transform = `rotate(${dx * 0.04}deg) translateX(${dx * 0.3}px)`;
document.getElementById('ind-right').style.opacity = dx > 30 ? Math.min(1, (dx - 30) / 50) : '0';
document.getElementById('ind-left').style.opacity = dx < -30 ? Math.min(1, (-dx - 30) / 50) : '0';
if (e.cancelable) e.preventDefault();
};
const onEnd = () => {
if (startX === null) return;
const dx = curX - startX;
const card = document.getElementById('study-card');
card.style.transform = '';
if (dx > THRESHOLD && _studyFlipped) answer(5);
else if (dx < -THRESHOLD) { if (!_studyFlipped) flipCard(); else answer(0); }
startX = null; curX = null;
document.getElementById('ind-right').style.opacity = '0';
document.getElementById('ind-left').style.opacity = '0';
};
scene.addEventListener('mousedown', onStart);
scene.addEventListener('mousemove', onMove);
scene.addEventListener('mouseup', onEnd);
scene.addEventListener('mouseleave', onEnd);
scene.addEventListener('touchstart', onStart, { passive: true });
scene.addEventListener('touchmove', onMove, { passive: false });
scene.addEventListener('touchend', onEnd);
}
/* ════ Deck modals ════ */
function buildColorPicker() {
document.getElementById('color-picker').innerHTML = COLORS.map(c =>
`<div class="cp-swatch${c===_deckColor?' active':''}" style="background:${c}"
onclick="selectColor('${c}',this)" data-color="${c}"></div>`
).join('');
}
function selectColor(color, el) {
_deckColor = color;
document.querySelectorAll('.cp-swatch').forEach(s => s.classList.remove('active'));
el.classList.add('active');
}
function openNewDeckModal() {
_editingDeckId = null;
document.getElementById('modal-deck-title').textContent = 'Новая колода';
document.getElementById('modal-deck-name').value = '';
document.getElementById('modal-deck-desc').value = '';
_deckColor = '#9B5DE5';
buildColorPicker();
document.getElementById('modal-deck').classList.add('open');
setTimeout(() => document.getElementById('modal-deck-name').focus(), 50);
}
function openEditDeckModal() {
if (!_curDeck) return;
_editingDeckId = _curDeck.id;
document.getElementById('modal-deck-title').textContent = 'Редактировать колоду';
document.getElementById('modal-deck-name').value = _curDeck.title;
document.getElementById('modal-deck-desc').value = _curDeck.description || '';
_deckColor = _curDeck.color || '#9B5DE5';
buildColorPicker();
document.getElementById('modal-deck').classList.add('open');
}
async function saveDeckModal() {
const title = document.getElementById('modal-deck-name').value.trim();
const desc = document.getElementById('modal-deck-desc').value.trim();
if (!title) { document.getElementById('modal-deck-name').focus(); return; }
if (_editingDeckId) {
await LS.api(`/api/flashcards/decks/${_editingDeckId}`, {
method: 'PUT', body: JSON.stringify({ title, description: desc, color: _deckColor })
}).catch(()=>{});
_curDeck.title = title; _curDeck.description = desc; _curDeck.color = _deckColor;
document.getElementById('cards-deck-title').textContent = title;
const d = _decks.find(x => x.id === _editingDeckId);
if (d) { d.title = title; d.description = desc; d.color = _deckColor; }
} else {
const deck = await LS.api('/api/flashcards/decks', {
method: 'POST', body: JSON.stringify({ title, description: desc, color: _deckColor })
}).catch(()=>null);
if (deck) { _decks.unshift(deck); renderDecks(); }
}
closeModal('modal-deck');
}
async function confirmDeleteDeck() {
if (!_curDeck) return;
if (!await LS.confirm(`Удалить колоду «${_curDeck.title}» и все карточки?`, { title: 'Удаление колоды', confirmText: 'Удалить', danger: true })) return;
await LS.api(`/api/flashcards/decks/${_curDeck.id}`, { method: 'DELETE' }).catch(()=>{});
_decks = _decks.filter(d => d.id !== _curDeck.id);
_curDeck = null; _cards = [];
showDecks();
}
function closeModal(id) { document.getElementById(id).classList.remove('open'); }
</script>
<script src="/js/mobile.js"></script>
</body>
</html>