Files
Learn_System/frontend/flashcards.html
T
Maxim Dolgolyov be4d43105e LearnSpace: full-stack educational whiteboard platform
Node.js/Express backend + vanilla JS frontend.
Features: real-time collaborative whiteboard (SSE), multi-page support,
LaTeX formulas, shapes/connectors, coordinate systems, number lines,
compass, zoom/pan, Catmull-Rom pencil smoothing, ruler/protractor with
rotation & resize controls, minimap navigation overlay, auto-measurements,
multi-page thumbnails sidebar, PNG export, page templates.
Student/teacher workflows: classes, assignments, library, dashboard.
Mobile responsive. SQLite (better-sqlite3).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 10:10:37 +03:00

872 lines
45 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" />
<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-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); }
/* ── 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-days { font-size: .66rem; font-weight: 600; opacity: .65; }
.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">
<div class="sb-brand">
<a href="/" class="sb-logo">Learn<span>Space</span></a>
<button class="sb-toggle" onclick="toggleSidebar()" title="Свернуть"><i data-lucide="panel-left-close" class="sb-icon"></i></button>
</div>
<button class="sb-link" onclick="lsSearchOpen()" title="Ctrl+K"><i data-lucide="search" class="sb-icon"></i><span class="sb-lbl">Поиск</span></button>
<a href="/dashboard" class="sb-link"><i data-lucide="home" class="sb-icon"></i><span class="sb-lbl">Дашборд</span></a>
<a href="/board" class="sb-link" id="btn-board" style="display:none"><i data-lucide="layout-dashboard" class="sb-icon"></i><span class="sb-lbl">Доска</span></a>
<a href="/classes" class="sb-link" id="btn-classes" style="display:none"><i data-lucide="graduation-cap" class="sb-icon"></i><span class="sb-lbl">Классы</span></a>
<a href="/library" class="sb-link"><i data-lucide="book-open" class="sb-icon"></i><span class="sb-lbl">Библиотека</span></a>
<a href="/flashcards" class="sb-link active"><i data-lucide="layers" class="sb-icon"></i><span class="sb-lbl">Карточки</span></a>
<a href="/lab" class="sb-link"><i data-lucide="atom" class="sb-icon"></i><span class="sb-lbl">Лаборатория</span></a>
<a href="/biochem" class="sb-link"><i data-lucide="flask-conical" class="sb-icon"></i><span class="sb-lbl">Биохимия</span></a>
<a href="/hangman" class="sb-link"><i data-lucide="gamepad-2" class="sb-icon"></i><span class="sb-lbl">Виселица</span></a>
<a href="/crossword" class="sb-link"><i data-lucide="grid-3x3" class="sb-icon"></i><span class="sb-lbl">Кроссворд</span></a>
<a href="/pet" class="sb-link"><i data-lucide="heart" class="sb-icon"></i><span class="sb-lbl">Питомец</span></a>
<a href="/collection" class="sb-link"><i data-lucide="layers" class="sb-icon"></i><span class="sb-lbl">Коллекция</span></a>
<a href="/knowledge-map" class="sb-link"><i data-lucide="share-2" class="sb-icon"></i><span class="sb-lbl">Карта знаний</span></a>
<a href="/red-book.html" class="sb-link"><i data-lucide="leaf" class="sb-icon"></i><span class="sb-lbl">Красная книга</span></a>
<div class="sb-spacer"></div>
<a href="/admin" class="sb-link" id="btn-admin" style="display:none"><i data-lucide="settings" class="sb-icon"></i><span class="sb-lbl">Управление</span></a>
<div class="sb-foot">
<a href="/profile" class="sb-user-row" style="text-decoration:none">
<div class="sb-avatar" id="nav-avatar"></div>
<div class="sb-user-info">
<div class="sb-user-name" id="nav-user"></div>
<span class="sb-logout" style="pointer-events:none">Мой профиль</span>
</div>
</a>
</div>
</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-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">Нажмите, чтобы увидеть ответ</div>
<div class="study-btns" id="study-btns">
<button class="sq-btn sq-btn-again" onclick="answer(0)">Снова<span class="sq-days" id="sq-days-0">&lt;1 мин</span></button>
<button class="sq-btn sq-btn-hard" onclick="answer(3)">Трудно<span class="sq-days" id="sq-days-3"></span></button>
<button class="sq-btn sq-btn-good" onclick="answer(4)">Знаю<span class="sq-days" id="sq-days-4"></span></button>
<button class="sq-btn sq-btn-easy" onclick="answer(5)">Легко<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/search.js"></script>
<script>
(async () => {
/* ── auth ── */
const user = await LS.init();
if (!user) return;
const avatarEl = document.getElementById('nav-avatar');
const nameEl = document.getElementById('nav-user');
const initials = (user.name||'LS').split(' ').slice(0,2).map(w=>w[0]?.toUpperCase()||'').join('')||'LS';
if (avatarEl) avatarEl.textContent = initials;
if (nameEl) nameEl.textContent = user.name || user.username || '';
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();
})();
/* ════ State ════ */
let _decks = [];
let _curDeck = null; // { id, title, color, ... }
let _cards = []; // cards in current deck
let _editingDeckId = null;
let _deckColor = '#9B5DE5';
const COLORS = ['#9B5DE5','#EF476F','#FF9F1C','#06D6E0','#22d399','#3B82F6','#F15BB5','#6B7280'];
/* ════ Init ════ */
async function init() {
buildColorPicker();
await loadDecks();
}
async function loadDecks() {
const [decks, stats] = await Promise.all([
LS.api('/flashcards/decks').catch(()=>({decks:[]})),
LS.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">🃏</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(`/flashcards/decks/${id}/cards`).catch(()=>({cards:[]}));
_cards = data.cards || [];
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 renderCardList() {
const list = document.getElementById('card-list');
if (!_cards.length) {
list.innerHTML = `<div class="fc-empty" style="padding:30px 0">
<div class="fc-empty-icon"><svg class="ic" 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;
}
list.innerHTML = _cards.map((c, i) => `
<div class="card-item" id="ci-${c.id}">
<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('');
}
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(`/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(`/flashcards/cards/${id}`, {
method: 'PUT', body: JSON.stringify({ [field]: value })
}).catch(()=>{});
}
async function deleteCard(id) {
if (!confirm('Удалить карточку?')) return;
await LS.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(`/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(`/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();
}
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').textContent = card.front;
document.getElementById('study-back-text').textContent = 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(`/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 ── */
function updateSQDays(card) {
const ef = card.ease_factor || 2.5;
const iv = card.interval_days || 1;
const rep = card.repetitions || 0;
const preview = (q) => {
if (q < 3) return '<1 мин';
let niv;
if (rep === 0) niv = 1;
else if (rep === 1) niv = 6;
else niv = Math.round(iv * ef);
const nef = Math.max(1.3, ef + 0.1 - (5 - q) * (0.08 + (5 - q) * 0.02));
let n2 = (q === 3 ? Math.max(1, niv - 2) : q === 4 ? niv : Math.round(niv * nef));
return n2 <= 1 ? '1 день' : n2 + ' дн.';
};
document.getElementById('sq-days-0').textContent = '<1 мин';
document.getElementById('sq-days-3').textContent = preview(3);
document.getElementById('sq-days-4').textContent = preview(4);
document.getElementById('sq-days-5').textContent = preview(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(`/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('/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(`/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>