Files
Learn_System/frontend/flashcards.html
T
Maxim Dolgolyov 751d88048c feat(flashcards): ввод формул KaTeX в редакторе (палитра + превью)
Перенесён подход из редактора теории:
- модалка «Вставить формулу»: палитра символов по категориям
  (греческие/операции/степени/отношения/стрелки/скобки/физика),
  LaTeX-поле, живое KaTeX-превью, режим «в строке \( \)» / «блоком \[ \]»
- кнопка «ƒₓ» у каждой стороны карточки и в add-bar; вставка в активное поле
- палитра на data-tex + делегирование (inline-onclick схлопывал «\» в латехе)
- Ctrl+Enter в поле формулы = вставить; разделители совпадают с рендером изучения

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 13:25:02 +03:00

1591 lines
81 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. 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: 12px; margin-bottom: 28px; flex-wrap: wrap; }
.fc-back { display: none; background: #fff; border: 1.5px solid var(--border);
border-radius: 10px; padding: 7px 14px; cursor: pointer; font-size: .82rem;
color: var(--text-2); transition: .15s; font-family: 'Manrope', sans-serif; font-weight: 600; }
.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: 9px 18px; border-radius: 10px; border: none; cursor: pointer;
font-family: 'Manrope', sans-serif; font-size: .82rem; font-weight: 700;
transition: .18s; white-space: nowrap; }
.fc-btn-primary { background: var(--violet); color: #fff;
box-shadow: 0 2px 10px rgba(155,93,229,.3); }
.fc-btn-primary:hover { filter: brightness(1.1); transform: translateY(-1px);
box-shadow: 0 4px 16px rgba(155,93,229,.4); }
.fc-btn-ghost { background: #fff; 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: 28px; flex-wrap: wrap; }
.fc-stat { background: #fff; border: 1.5px solid var(--border); border-radius: 14px;
padding: 16px 20px 14px; display: flex; flex-direction: column; gap: 4px;
min-width: 110px; position: relative; overflow: hidden;
transition: box-shadow .18s, transform .18s; }
.fc-stat::before { content: ''; position: absolute; inset: 0;
background: radial-gradient(ellipse at 110% -10%,
var(--stat-color-a, rgba(155,93,229,.09)) 0%, transparent 65%);
pointer-events: none; }
.fc-stat:hover { box-shadow: 0 4px 16px rgba(0,0,0,.08); transform: translateY(-1px); }
.fc-stat-val { font-family: 'Unbounded', sans-serif; font-size: 1.75rem; font-weight: 800;
color: var(--stat-color, var(--violet)); line-height: 1; position: relative; }
.fc-stat-lbl { font-size: .68rem; font-weight: 600; color: var(--text-3);
text-transform: uppercase; letter-spacing: .05em;
display: flex; align-items: center; gap: 5px; position: relative; }
.fc-stat-lbl .ic { width: 11px; height: 11px; color: var(--stat-color, var(--violet)); opacity: .7; flex-shrink: 0; }
/* ── deck grid ── */
.deck-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 18px; }
.deck-card { background: #fff; border: 1.5px solid var(--border); border-radius: 20px;
overflow: hidden; cursor: default; transition: box-shadow .22s, transform .22s;
display: flex; flex-direction: column; }
.deck-card:hover { box-shadow: 0 10px 32px var(--dc-shadow, rgba(0,0,0,.12));
transform: translateY(-3px); }
/* deck colored header */
.deck-head { height: 80px; display: flex; align-items: center; padding: 0 16px; gap: 12px;
position: relative; overflow: hidden; cursor: pointer; flex-shrink: 0; }
.deck-head::before { content: ''; position: absolute; inset: 0;
background: linear-gradient(135deg, rgba(255,255,255,.2) 0%, rgba(0,0,0,.07) 100%);
pointer-events: none; }
.deck-head::after { content: ''; position: absolute; right: -14px; bottom: -22px;
width: 78px; height: 78px; border-radius: 50%;
background: rgba(255,255,255,.1); pointer-events: none; }
.deck-head-letter { width: 42px; height: 42px; border-radius: 12px;
background: rgba(255,255,255,.3); display: flex; align-items: center;
justify-content: center; font-family: 'Unbounded', sans-serif;
font-size: 1.05rem; font-weight: 800; color: #fff; flex-shrink: 0;
position: relative; z-index: 1; text-shadow: 0 1px 4px rgba(0,0,0,.18);
border: 1px solid rgba(255,255,255,.35); }
.deck-head-count { position: relative; z-index: 1; margin-left: auto;
font-family: 'Manrope', sans-serif; font-size: .7rem; font-weight: 700;
color: rgba(255,255,255,.95); background: rgba(0,0,0,.16);
padding: 3px 10px; border-radius: 20px; }
.deck-body { padding: 14px 16px 10px; flex: 1; cursor: pointer; }
.deck-name { font-family: 'Manrope', sans-serif; font-weight: 700; font-size: .96rem;
color: var(--text); margin-bottom: 4px; }
.deck-desc { font-size: .78rem; color: var(--text-3); margin-bottom: 10px;
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
.deck-meta { display: flex; gap: 7px; align-items: center; flex-wrap: wrap; }
.deck-badge { padding: 3px 8px; border-radius: 20px; font-size: .68rem; font-weight: 700;
background: var(--surface-2); color: var(--text-2);
display: inline-flex; align-items: center; gap: 4px; }
.deck-badge .ic { width: 11px; height: 11px; }
.deck-badge.due { background: #FEF3C7; color: #D97706; }
.deck-badge.zero { background: #DCFCE7; color: #16A34A; }
.deck-actions { display: flex; gap: 8px; padding: 10px 16px 14px; }
.deck-btn-study { flex: 1; padding: 8px 10px; border-radius: 10px; border: none; cursor: pointer;
background: var(--violet); color: #fff; font-family: 'Manrope', sans-serif;
font-size: .8rem; font-weight: 700; transition: .18s;
display: flex; align-items: center; justify-content: center; gap: 6px; }
.deck-btn-study:hover { filter: brightness(1.1); transform: translateY(-1px); }
.deck-btn-study:disabled { background: var(--surface-2); color: var(--text-3);
cursor: default; filter: none; transform: none; }
.deck-btn-edit { padding: 8px 13px; border-radius: 10px; border: 1.5px solid var(--border);
cursor: pointer; background: none; color: var(--text-2);
display: flex; align-items: center; justify-content: center; gap: 5px;
font-size: .8rem; font-weight: 700; font-family: 'Manrope', sans-serif; transition: .15s; }
.deck-btn-edit:hover { background: var(--surface-2); }
/* new deck add card */
.deck-add { border: 2px dashed var(--border); border-radius: 20px; min-height: 175px;
display: flex; align-items: center; justify-content: center;
cursor: pointer; transition: all .2s; flex-direction: column; gap: 10px;
color: var(--text-3); font-size: .84rem; font-weight: 600;
font-family: 'Manrope', sans-serif; }
.deck-add:hover { border-color: var(--violet); color: var(--violet);
background: rgba(155,93,229,.04); transform: translateY(-2px); }
.deck-add svg { transition: opacity .2s; opacity: .4; }
.deck-add:hover svg { opacity: .9; }
/* ── 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: 14px;
display: flex; gap: 0; overflow: hidden; transition: box-shadow .15s; }
.card-item:hover { box-shadow: 0 3px 14px rgba(0,0,0,.07); }
.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 8px; 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: rgba(155,93,229,.05); }
.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: .66rem; font-weight: 700; text-transform: uppercase;
letter-spacing: .07em; color: var(--violet); margin-bottom: 5px; opacity: .75; }
.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 14px; 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 image (editor) */
.card-img-row { margin-top: 8px; }
.card-img-wrap { position: relative; display: inline-block; }
.card-img-thumb { max-width: 140px; max-height: 92px; border-radius: 8px; display: block;
border: 1.5px solid var(--border); object-fit: cover; cursor: zoom-in; }
.card-img-remove { position: absolute; top: -7px; right: -7px; width: 22px; height: 22px;
border-radius: 50%; border: none; background: #DC2626; color: #fff; cursor: pointer;
display: flex; align-items: center; justify-content: center;
box-shadow: 0 2px 6px rgba(0,0,0,.25); }
.card-img-remove svg { width: 12px; height: 12px; }
.card-img-add { display: inline-flex; align-items: center; gap: 5px; padding: 5px 10px; border-radius: 8px;
border: 1.5px dashed var(--border); background: none; cursor: pointer; color: var(--text-3);
font-family: 'Manrope', sans-serif; font-size: .72rem; font-weight: 600; transition: .15s; }
.card-img-add:hover { border-color: var(--violet); color: var(--violet); background: rgba(155,93,229,.04); }
.card-img-add .ic { width: 13px; height: 13px; }
#new-card-imgs { display: none; gap: 14px; align-items: center; margin-bottom: 14px; flex-wrap: wrap; }
.new-img-lbl { font-size: .7rem; font-weight: 700; color: var(--text-3); margin-right: 4px; }
/* bulk import preview */
.bulk-preview-list { max-height: 56vh; overflow-y: auto; display: flex; flex-direction: column;
gap: 8px; margin-bottom: 6px; padding-right: 4px; }
.bulk-row { display: grid; grid-template-columns: 24px 1fr 1fr; gap: 8px; align-items: start;
background: var(--surface-2); border: 1px solid var(--border); border-radius: 10px; padding: 8px 10px; }
.bulk-row-n { font-size: .7rem; font-weight: 800; color: var(--text-3); padding-top: 3px; }
.bulk-row-side { display: flex; flex-direction: column; gap: 6px; min-width: 0; }
.bulk-row-txt { font-size: .8rem; color: var(--text); white-space: pre-wrap; word-break: break-word; line-height: 1.35; }
.bulk-row-empty { color: var(--text-3); font-style: italic; }
.bulk-img-wrap { position: relative; display: inline-block; }
.bulk-img-thumb { max-width: 110px; max-height: 64px; border-radius: 6px; display: block;
border: 1px solid var(--border); object-fit: cover; }
/* ── formula insert (KaTeX) ── */
.card-side-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 5px; }
.card-side-head .card-side-lbl { margin-bottom: 0; }
.fx-mini { background: none; border: none; cursor: pointer; color: var(--violet); font-weight: 700;
font-family: 'Times New Roman', serif; font-style: italic; font-size: .92rem; line-height: 1;
padding: 1px 5px; border-radius: 6px; opacity: .7; transition: .15s; }
.fx-mini:hover { opacity: 1; background: rgba(155,93,229,.08); }
.fx-mode-row { display: flex; gap: 8px; margin-bottom: 12px; }
.fx-mode-btn { flex: 1; padding: 8px; border: 1.5px solid var(--border); border-radius: 9px; background: #fff;
cursor: pointer; font-family: 'Manrope', sans-serif; font-size: .76rem; font-weight: 700;
color: var(--text-2); transition: .15s; }
.fx-mode-btn.active { border-color: var(--violet); background: rgba(155,93,229,.08); color: var(--violet); }
.fx-cats { display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 8px; }
.fx-cat-btn { padding: 4px 11px; border: 1px solid var(--border); border-radius: 20px; background: #fff;
cursor: pointer; font-family: 'Manrope', sans-serif; font-size: .72rem; font-weight: 600;
color: var(--text-2); transition: .15s; }
.fx-cat-btn.active { background: var(--violet); color: #fff; border-color: var(--violet); }
.fx-palette { display: flex; flex-wrap: wrap; gap: 5px; margin-bottom: 12px; max-height: 132px; overflow-y: auto; }
.fx-sym { min-width: 34px; height: 32px; padding: 0 8px; border: 1px solid var(--border); border-radius: 8px;
background: #fff; cursor: pointer; font-size: .98rem; color: var(--text);
display: inline-flex; align-items: center; justify-content: center; transition: .12s; }
.fx-sym:hover { background: rgba(155,93,229,.1); border-color: var(--violet); }
.fx-sym .ic { width: 15px; height: 15px; }
#fx-input { width: 100%; box-sizing: border-box; font-family: 'Courier New', monospace; }
.fx-preview-label { font-size: .7rem; font-weight: 700; color: var(--text-3); text-transform: uppercase;
letter-spacing: .05em; margin: 12px 0 6px; }
.fx-preview { min-height: 50px; padding: 14px; border: 1.5px dashed var(--border); border-radius: 10px;
background: var(--surface-2); display: flex; align-items: center; justify-content: center;
font-size: 1.15rem; overflow-x: auto; }
.fx-ph { color: var(--text-3); font-size: .82rem; font-style: italic; }
.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); box-shadow: 0 0 0 3px rgba(155,93,229,.1); }
/* 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: 6px; background: var(--surface-2); border-radius: 3px;
margin-bottom: 20px; overflow: hidden; }
.study-progress-fill { height: 100%; background: var(--deck-color, var(--violet));
border-radius: 3px; transition: width .4s ease; }
.study-counter { text-align: center; font-size: .8rem; color: var(--text-3);
font-weight: 600; margin-bottom: 18px; }
/* flip card */
.study-card-scene { perspective: 1200px; height: 320px; 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: 22px; padding: 36px 40px;
display: flex; align-items: center; justify-content: center;
flex-direction: column; gap: 12px;
backface-visibility: hidden; -webkit-backface-visibility: hidden;
border: 1.5px solid var(--border); overflow: auto; }
.study-face-img { max-width: 100%; max-height: 190px; border-radius: 12px; object-fit: contain;
box-shadow: 0 2px 10px rgba(0,0,0,.1); }
.study-face-front { background: linear-gradient(170deg, var(--deck-color-a, rgba(155,93,229,.07)) 0%, #fff 38%);
box-shadow: 0 10px 40px rgba(0,0,0,.1), 0 2px 8px rgba(0,0,0,.06); }
.study-face-back { background: linear-gradient(170deg, rgba(246,243,255,.9) 0%, #fff 45%);
transform: rotateY(180deg);
box-shadow: 0 10px 40px rgba(0,0,0,.1), 0 2px 8px rgba(0,0,0,.06);
border-color: rgba(155,93,229,.22); }
.study-face-text { font-family: 'Manrope', sans-serif; font-size: 1.22rem; font-weight: 600;
color: var(--text); text-align: center; line-height: 1.7; }
.study-face-label { position: absolute; top: 14px; left: 18px; font-size: .63rem; font-weight: 700;
text-transform: uppercase; letter-spacing: .08em;
color: var(--deck-color, var(--violet));
background: var(--deck-color-a, rgba(155,93,229,.1));
padding: 3px 10px; border-radius: 20px; }
.study-hint { text-align: center; font-size: .77rem; color: var(--text-3); margin-bottom: 20px;
display: flex; align-items: center; justify-content: center; gap: 6px; }
/* 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: 11px 20px; border-radius: 12px; 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: 3px;
box-shadow: 0 2px 8px rgba(0,0,0,.07); }
.sq-btn:hover { transform: translateY(-2px); box-shadow: 0 5px 16px rgba(0,0,0,.1); }
.sq-btn:active { transform: translateY(0); }
.sq-btn .sq-top { display: flex; align-items: center; gap: 6px; }
.sq-btn .sq-days { font-size: .66rem; font-weight: 600; opacity: .7; }
.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,.09);
border: 1px solid rgba(0,0,0,.13); 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; border-color: #FCA5A5; }
.sq-btn-hard { background: #FEF3C7; border-color: #FDE68A; color: #D97706; }
.sq-btn-hard:hover { background: #FDE68A; border-color: #FCD34D; }
.sq-btn-good { background: #DBEAFE; border-color: #BFDBFE; color: #2563EB; }
.sq-btn-good:hover { background: #BFDBFE; border-color: #93C5FD; }
.sq-btn-easy { background: #DCFCE7; border-color: #BBF7D0; color: #16A34A; }
.sq-btn-easy:hover { background: #BBF7D0; border-color: #86EFAC; }
/* swipe indicator */
.swipe-indicator { position: absolute; top: 18px; font-size: .9rem; font-weight: 900;
letter-spacing: .04em; padding: 6px 14px; border-radius: 10px;
opacity: 0; pointer-events: none; transition: opacity .1s; z-index: 10;
display: flex; align-items: center; gap: 6px; }
.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; }
.swipe-indicator .ic { width: 15px; height: 15px; }
/* finished state */
.study-done { text-align: center; padding: 48px 24px; }
.study-done-icon { margin-bottom: 18px; display: flex; align-items: center; justify-content: center; }
.study-done-icon .ic { width: 56px; height: 56px; color: var(--violet);
filter: drop-shadow(0 4px 14px rgba(155,93,229,.35)); }
.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: 14px;
padding: 14px 22px; text-align: center; }
.ss-stat-n { font-family: 'Unbounded', sans-serif; font-size: 1.5rem; font-weight: 800; }
.ss-stat-l { font-size: .72rem; color: var(--text-3); font-weight: 600; margin-top: 3px; }
/* ── 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); backdrop-filter: blur(4px); }
.fc-modal-box { position: relative; z-index: 1; background: #fff; border-radius: 20px;
padding: 28px; width: 100%; max-width: 480px;
box-shadow: 0 24px 64px 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); display: block;
text-transform: uppercase; letter-spacing: .05em; margin-bottom: 6px; }
.fc-modal-input { width: 100%; padding: 10px 14px; border: 1.5px solid var(--border);
border-radius: 10px; 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); box-shadow: 0 0 0 3px rgba(155,93,229,.1); }
.fc-modal-actions { display: flex; gap: 10px; justify-content: flex-end; margin-top: 22px; }
.color-picker-row { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 2px; }
.cp-swatch { width: 30px; height: 30px; border-radius: 50%; cursor: pointer;
border: 3px solid transparent; transition: .18s; }
.cp-swatch.active, .cp-swatch:hover { border-color: var(--text); transform: scale(1.2);
box-shadow: 0 2px 8px rgba(0,0,0,.2); }
/* ── empty ── */
.fc-empty { text-align: center; padding: 60px 24px; }
.fc-empty-icon { margin-bottom: 14px; display: flex; align-items: center; justify-content: center; }
.fc-empty-icon .ic { width: 48px; height: 48px; color: var(--text-3); }
.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: 92px; padding: 10px 12px; }
.fc-stat-val { font-size: 1.2rem; }
.deck-grid { grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 12px; }
.study-face { padding: 24px 20px; }
.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)" onpaste="onNewCardPaste(event,'front')" />
<input class="card-add-input" id="new-card-back" placeholder="Обратная сторона (ответ)…"
onkeydown="addCardOnEnter(event)" onpaste="onNewCardPaste(event,'back')" />
<button class="fc-btn fc-btn-ghost" title="Вставить формулу (KaTeX)" onclick="openFormula()">ƒₓ Формула</button>
<button class="fc-btn fc-btn-primary" onclick="addCard()">+ Добавить</button>
</div>
<div id="new-card-imgs"></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>
<img class="study-face-img" id="study-front-img" alt="" style="display:none" />
<div class="study-face-text" id="study-front-text"></div>
</div>
<div class="study-face study-face-back">
<span class="study-face-label">Ответ</span>
<img class="study-face-img" id="study-back-img" alt="" style="display:none" />
<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" style="max-width:580px">
<div class="fc-modal-title">Добавить список карточек</div>
<!-- шаг 1: текст -->
<div id="bulk-step-text">
<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="bulkToPreview()">Дальше →</button>
</div>
</div>
<!-- шаг 2: предпросмотр с картинками -->
<div id="bulk-step-preview" style="display:none">
<div class="fc-modal-label" style="margin-bottom:10px">Проверьте карточки и при желании прикрепите картинку к стороне</div>
<div class="bulk-preview-list" id="bulk-preview-list"></div>
<div class="fc-modal-actions">
<button class="fc-btn fc-btn-ghost" onclick="bulkBackToText()">← Назад</button>
<button class="fc-btn fc-btn-primary" id="bulk-import-btn" onclick="saveBulk()">Добавить</button>
</div>
</div>
</div>
</div>
<!-- ── Formula (KaTeX) Modal ── -->
<div class="fc-modal" id="modal-formula">
<div class="fc-modal-bg" onclick="closeModal('modal-formula')"></div>
<div class="fc-modal-box" style="max-width:560px">
<div class="fc-modal-title">Вставить формулу (KaTeX)</div>
<div class="fx-mode-row">
<button class="fx-mode-btn active" id="fx-mode-inline" onclick="fxSetMode('inline')">В строке&nbsp; \( … \)</button>
<button class="fx-mode-btn" id="fx-mode-block" onclick="fxSetMode('block')">Блоком&nbsp; \[ … \]</button>
</div>
<div class="fx-cats" id="fx-cats"></div>
<div class="fx-palette" id="fx-palette"></div>
<textarea class="fc-modal-input" id="fx-input" rows="2"
placeholder="\frac{q_1 q_2}{r^2}" oninput="updateFxPreview()"></textarea>
<div class="fx-preview-label">Превью</div>
<div class="fx-preview" id="fx-preview"></div>
<div class="fc-modal-actions">
<button class="fc-btn fc-btn-ghost" onclick="closeModal('modal-formula')">Отмена</button>
<button class="fc-btn fc-btn-primary" onclick="fxInsert()">Вставить</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'];
function _hexAlpha(hex, a) {
const h = (hex || '#9B5DE5').replace('#','');
const r = parseInt(h.slice(0,2),16), g = parseInt(h.slice(2,4),16), b = parseInt(h.slice(4,6),16);
return `rgba(${r},${g},${b},${a})`;
}
let _decks = [];
let _curDeck = null;
let _cards = [];
let _editingDeckId = null;
let _deckColor = '#9B5DE5';
let _cardFilter = '';
let _newImg = { front: '', back: '' }; // картинки, прикреплённые к ещё не созданной карточке
(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();
bindFormulaUI();
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',
icon: '<svg class="ic" 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>' },
{ val: s.cards_count, lbl: 'Карточек', col: '#3B82F6',
icon: '<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"/></svg>' },
{ val: s.due_count, lbl: 'К повторению', col: '#D97706',
icon: '<svg class="ic" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>' },
{ val: s.reviewed_today, lbl: 'Сегодня', col: '#16A34A',
icon: '<svg class="ic" viewBox="0 0 24 24"><polyline points="20 6 9 17 4 12"/></svg>' },
].map(x => `<div class="fc-stat" style="--stat-color:${x.col};--stat-color-a:${_hexAlpha(x.col,.09)}">
<span class="fc-stat-val">${x.val}</span>
<span class="fc-stat-lbl">${x.icon}${x.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 color = d.color || '#9B5DE5';
const letter = (d.title || '?')[0].toUpperCase();
const shadow = _hexAlpha(color, .22);
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"/><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" style="--dc-shadow:${shadow}">
<div class="deck-head" style="background:${color}" onclick="openDeck(${d.id})">
<div class="deck-head-letter">${letter}</div>
<span class="deck-head-count">${d.card_count} карт.</span>
</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">${dueHtml}</div>
</div>
<div class="deck-actions">
<button class="deck-btn-study" 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="30" height="30" 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-head">
<span class="card-side-lbl">Вопрос</span>
<button class="fx-mini" title="Вставить формулу (KaTeX)" onclick="openFormula(this)">ƒₓ</button>
</div>
<textarea class="card-textarea" rows="2"
onpaste="onCardPaste(event,${c.id},'front')"
onchange="saveCard(${c.id},'front',this.value)">${esc(c.front)}</textarea>
${imgRowHtml(c, 'front')}
</div>
<div class="card-divider"></div>
<div class="card-side">
<div class="card-side-head">
<span class="card-side-lbl">Ответ</span>
<button class="fx-mini" title="Вставить формулу (KaTeX)" onclick="openFormula(this)">ƒₓ</button>
</div>
<textarea class="card-textarea" rows="2"
onpaste="onCardPaste(event,${c.id},'back')"
onchange="saveCard(${c.id},'back',this.value)">${esc(c.back)}</textarea>
${imgRowHtml(c, 'back')}
</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) { LS.toast('Сначала откройте колоду', 'error'); return; }
const front = document.getElementById('new-card-front').value.trim();
const back = document.getElementById('new-card-back').value.trim();
if (!front && !back && !_newImg.front && !_newImg.back) {
LS.toast('Введите вопрос или ответ карточки');
document.getElementById('new-card-front').focus();
return;
}
let card;
try {
card = await LS.api(`/api/flashcards/decks/${_curDeck.id}/cards`, {
method: 'POST',
body: JSON.stringify({ front, back, front_image: _newImg.front, back_image: _newImg.back })
});
} catch (e) {
LS.toast('Не удалось добавить карточку: ' + (e && e.message || 'ошибка'), 'error');
return;
}
if (!card) return;
_cards.push(card);
document.getElementById('new-card-front').value = '';
document.getElementById('new-card-back').value = '';
_newImg = { front: '', back: '' };
renderNewImgs();
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();
}
/* ════ Card images ════
Загрузка идёт ПРЯМЫМ fetch (multipart): LS.api всегда ставит
Content-Type: application/json, что ломает разбор FormData в multer. */
function imgRowHtml(c, side) {
const url = side === 'front' ? c.front_image : c.back_image;
if (url) {
return `<div class="card-img-row"><div class="card-img-wrap">
<img class="card-img-thumb" src="${esc(url)}" alt="" onclick="window.open('${esc(url)}','_blank')" />
<button class="card-img-remove" title="Убрать картинку" onclick="removeCardImage(${c.id},'${side}')">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M6 6l12 12M18 6L6 18"/></svg>
</button>
</div></div>`;
}
return `<div class="card-img-row">
<button class="card-img-add" onclick="pickCardImage(${c.id},'${side}')">
<svg class="ic" viewBox="0 0 24 24"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><path d="m21 15-5-5L5 21"/></svg>
Картинка
</button></div>`;
}
async function uploadFcImage(file) {
if (!file || !file.type || !file.type.startsWith('image/')) throw new Error('Только изображения');
if (file.size > 5 * 1024 * 1024) throw new Error('Файл больше 5 МБ');
const fd = new FormData();
fd.append('file', file);
const token = localStorage.getItem('ls_token');
const res = await fetch('/api/flashcards/upload', {
method: 'POST',
headers: token ? { Authorization: 'Bearer ' + token } : {},
body: fd,
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || 'Не удалось загрузить');
return data.url;
}
let _imgPickInput = null;
function ensureImgPicker() {
if (!_imgPickInput) {
_imgPickInput = document.createElement('input');
_imgPickInput.type = 'file';
_imgPickInput.accept = 'image/*';
_imgPickInput.style.display = 'none';
document.body.appendChild(_imgPickInput);
}
_imgPickInput.value = '';
return _imgPickInput;
}
function pickCardImage(cardId, side) {
const inp = ensureImgPicker();
inp.onchange = () => {
const file = inp.files && inp.files[0];
if (file) attachCardImage(cardId, side, file);
};
inp.click();
}
async function attachCardImage(cardId, side, file) {
const card = _cards.find(c => c.id === cardId);
if (!card) return;
const field = side === 'front' ? 'front_image' : 'back_image';
let url;
try { LS.toast('Загрузка картинки…'); url = await uploadFcImage(file); }
catch (e) { LS.toast(e.message || 'Ошибка загрузки', 'error'); return; }
await LS.api(`/api/flashcards/cards/${cardId}`, {
method: 'PUT', body: JSON.stringify({ [field]: url })
}).catch(()=>{});
card[field] = url;
updateCardImgRow(cardId, side);
LS.toast('Картинка добавлена', 'success');
}
async function removeCardImage(cardId, side) {
const card = _cards.find(c => c.id === cardId);
if (!card) return;
const field = side === 'front' ? 'front_image' : 'back_image';
await LS.api(`/api/flashcards/cards/${cardId}`, {
method: 'PUT', body: JSON.stringify({ [field]: '' })
}).catch(()=>{});
card[field] = '';
updateCardImgRow(cardId, side);
}
/* точечно перерисовываем только блок картинки — textarea'ы не трогаем */
function updateCardImgRow(cardId, side) {
const card = _cards.find(c => c.id === cardId);
const item = document.getElementById('ci-' + cardId);
if (!card || !item) { renderCardList(); return; }
const sides = item.querySelectorAll('.card-side');
const sideEl = side === 'front' ? sides[0] : sides[1];
if (!sideEl) { renderCardList(); return; }
const row = sideEl.querySelector('.card-img-row');
if (row) row.outerHTML = imgRowHtml(card, side);
else sideEl.insertAdjacentHTML('beforeend', imgRowHtml(card, side));
}
function onCardPaste(e, cardId, side) {
const items = e.clipboardData && e.clipboardData.items;
if (!items) return;
for (const it of items) {
if (it.type && it.type.startsWith('image/')) {
const file = it.getAsFile();
if (file) { e.preventDefault(); attachCardImage(cardId, side, file); }
return;
}
}
}
/* ── картинки для ещё не созданной карточки (add-bar) ── */
async function onNewCardPaste(e, side) {
const items = e.clipboardData && e.clipboardData.items;
if (!items) return;
for (const it of items) {
if (it.type && it.type.startsWith('image/')) {
const file = it.getAsFile();
if (!file) return;
e.preventDefault();
try { const url = await uploadFcImage(file); _newImg[side] = url; renderNewImgs(); LS.toast('Картинка прикреплена к новой карточке', 'success'); }
catch (err) { LS.toast(err.message || 'Ошибка загрузки', 'error'); }
return;
}
}
}
function renderNewImgs() {
const box = document.getElementById('new-card-imgs');
if (!box) return;
const chip = (side, label) => _newImg[side]
? `<span class="card-img-wrap" style="display:inline-flex;align-items:center">
<span class="new-img-lbl">${label}</span>
<img class="card-img-thumb" style="max-height:64px" src="${esc(_newImg[side])}" alt="" />
<button class="card-img-remove" onclick="clearNewImg('${side}')"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M6 6l12 12M18 6L6 18"/></svg></button>
</span>` : '';
const f = chip('front', 'Вопрос'), b = chip('back', 'Ответ');
box.innerHTML = f + b;
box.style.display = (f || b) ? 'flex' : 'none';
}
function clearNewImg(side) { _newImg[side] = ''; renderNewImgs(); }
/* ════ Bulk add ════ */
let _bulkCards = []; // [{ front, back, front_image, back_image }] на шаге предпросмотра
function openBulkModal() {
document.getElementById('bulk-text').value = '';
_bulkCards = [];
document.getElementById('bulk-step-text').style.display = '';
document.getElementById('bulk-step-preview').style.display = 'none';
document.getElementById('modal-bulk').classList.add('open');
}
/* шаг 1 → 2: разобрать строки в карточки */
function bulkToPreview() {
const text = document.getElementById('bulk-text').value.trim();
if (!text) { LS.toast('Введите хотя бы одну строку'); return; }
const lines = text.split('\n').map(l => l.trim()).filter(Boolean);
_bulkCards = lines.map(l => {
const sep = l.includes('—') ? '—' : '|';
const [front, ...rest] = l.split(sep);
return { front: (front || '').trim(), back: rest.join(sep).trim(), front_image: '', back_image: '' };
}).filter(c => c.front);
if (!_bulkCards.length) { LS.toast('Не удалось разобрать строки'); return; }
document.getElementById('bulk-step-text').style.display = 'none';
document.getElementById('bulk-step-preview').style.display = '';
renderBulkPreview();
}
function bulkBackToText() {
document.getElementById('bulk-step-preview').style.display = 'none';
document.getElementById('bulk-step-text').style.display = '';
}
function renderBulkPreview() {
const list = document.getElementById('bulk-preview-list');
document.getElementById('bulk-import-btn').textContent = `Добавить ${_bulkCards.length}`;
list.innerHTML = _bulkCards.map((c, i) => `
<div class="bulk-row">
<div class="bulk-row-n">${i + 1}</div>
<div class="bulk-row-side">
<div class="bulk-row-txt">${c.front ? esc(c.front) : '<span class="bulk-row-empty">— нет текста —</span>'}</div>
${bulkImgCell(i, 'front')}
</div>
<div class="bulk-row-side">
<div class="bulk-row-txt">${c.back ? esc(c.back) : '<span class="bulk-row-empty">— нет ответа —</span>'}</div>
${bulkImgCell(i, 'back')}
</div>
</div>`).join('');
}
function bulkImgCell(i, side) {
const url = _bulkCards[i][side === 'front' ? 'front_image' : 'back_image'];
if (url) {
return `<div class="bulk-img-wrap">
<img class="bulk-img-thumb" src="${esc(url)}" alt="" />
<button class="card-img-remove" title="Убрать" onclick="bulkRemoveImg(${i},'${side}')">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M6 6l12 12M18 6L6 18"/></svg>
</button>
</div>`;
}
return `<button class="card-img-add" onclick="bulkPickImg(${i},'${side}')">
<svg class="ic" viewBox="0 0 24 24"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><path d="m21 15-5-5L5 21"/></svg>
Картинка
</button>`;
}
function bulkPickImg(i, side) {
const inp = ensureImgPicker();
inp.onchange = async () => {
const file = inp.files && inp.files[0];
if (!file) return;
try { _bulkCards[i][side === 'front' ? 'front_image' : 'back_image'] = await uploadFcImage(file); renderBulkPreview(); }
catch (e) { LS.toast(e.message || 'Ошибка загрузки', 'error'); }
};
inp.click();
}
function bulkRemoveImg(i, side) {
_bulkCards[i][side === 'front' ? 'front_image' : 'back_image'] = '';
renderBulkPreview();
}
async function saveBulk() {
if (!_curDeck || !_bulkCards.length) { closeModal('modal-bulk'); return; }
const cards = _bulkCards.filter(c => c.front || c.back || c.front_image || c.back_image);
if (!cards.length) { closeModal('modal-bulk'); return; }
const btn = document.getElementById('bulk-import-btn');
btn.disabled = true; btn.textContent = 'Добавляю…';
let result;
try {
result = await LS.api(`/api/flashcards/decks/${_curDeck.id}/cards/bulk`, {
method: 'POST', body: JSON.stringify({ cards })
});
} catch (e) {
LS.toast('Ошибка импорта: ' + (e && e.message || 'не удалось'), 'error');
btn.disabled = false; btn.textContent = `Добавить ${_bulkCards.length}`;
return;
}
btn.disabled = false;
if (result && result.inserted) {
_cards.push(...result.inserted);
renderCardList();
LS.toast(`Добавлено карточек: ${result.inserted.length}`, 'success');
}
closeModal('modal-bulk');
}
/* ════ Formula insert (KaTeX) ════
Палитра символов перенесена из редактора теории (lesson-editor.html).
Текст карточки свободный — вставляем \( … \) (в строке) или \[ … \] (блоком)
в активное поле; в режиме изучения KaTeX уже рендерит эти разделители. */
const FX_SYMS = {
'Греческие': [
['\\alpha','α'],['\\beta','β'],['\\gamma','γ'],['\\delta','δ'],['\\epsilon','ε'],
['\\zeta','ζ'],['\\eta','η'],['\\theta','θ'],['\\lambda','λ'],['\\mu','μ'],
['\\nu','ν'],['\\xi','ξ'],['\\pi','π'],['\\rho','ρ'],['\\sigma','σ'],
['\\tau','τ'],['\\phi','φ'],['\\chi','χ'],['\\psi','ψ'],['\\omega','ω'],
['\\Gamma','Γ'],['\\Delta','Δ'],['\\Theta','Θ'],['\\Lambda','Λ'],['\\Pi','Π'],
['\\Sigma','Σ'],['\\Phi','Φ'],['\\Psi','Ψ'],['\\Omega','Ω'],
],
'Операции': [
['\\frac{a}{b}','a/b'],['\\sqrt{x}','√'],['\\sqrt[n]{x}','ⁿ√'],
['\\sum','∑'],['\\prod','∏'],['\\int','∫'],['\\oint','∮'],
['\\lim','lim'],['\\infty','∞'],['\\partial','∂'],['\\nabla','∇'],
['\\pm','±'],['\\mp','∓'],['\\times','×'],['\\div','÷'],['\\cdot','·'],
],
'Степени': [
['^{2}','x²'],['^{3}','x³'],['_{n}','xₙ'],['_{i}','xᵢ'],
['e^{x}','eˣ'],['10^{n}','10ⁿ'],
],
'Отношения': [
['\\leq','≤'],['\\geq','≥'],['\\neq','≠'],['\\approx','≈'],['\\equiv','≡'],
['\\sim',''],['\\propto','∝'],['\\ll','≪'],['\\gg','≫'],
],
'Стрелки': [
['\\to','→'],['\\leftarrow','←'],['\\Rightarrow','⇒'],['\\Leftrightarrow','⇔'],
['\\uparrow','↑'],['\\downarrow','↓'],
],
'Скобки': [
['\\left( \\right)','(…)'],['\\left[ \\right]','[…]'],['\\left\\{ \\right\\}','{…}'],
['\\left| \\right|','|…|'],
],
'Физика': [
['\\vec{F}','F⃗'],['\\hat{x}','x̂'],['\\hbar','ℏ'],['\\Delta t','Δt'],
['\\mathbf{E}','E'],['\\mathbf{B}','B'],
],
};
let _fxField = null; // целевое поле для вставки (textarea/input)
let _fxMode = 'inline';
let _fxCat = 'Греческие';
function bindFormulaUI() {
// запоминаем последнее сфокусированное поле редактора карточек
document.addEventListener('focusin', (e) => {
const t = e.target;
if (t && ((t.classList && t.classList.contains('card-textarea')) ||
t.id === 'new-card-front' || t.id === 'new-card-back')) {
_fxField = t;
}
});
// делегирование на палитру/категории (без inline-onclick — латех с «\» не ломается)
document.getElementById('fx-cats')?.addEventListener('click', (e) => {
const b = e.target.closest('.fx-cat-btn'); if (b) fxSetCat(b.dataset.cat, b);
});
document.getElementById('fx-palette')?.addEventListener('click', (e) => {
const b = e.target.closest('.fx-sym'); if (b) fxInsertSym(b.dataset.tex || '');
});
document.getElementById('fx-input')?.addEventListener('keydown', (e) => {
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') { e.preventDefault(); fxInsert(); }
});
}
function openFormula(btn) {
if (btn) { const ta = btn.closest('.card-side')?.querySelector('textarea'); if (ta) _fxField = ta; }
if (!_fxField || !document.body.contains(_fxField)) _fxField = document.getElementById('new-card-front');
document.getElementById('fx-input').value = '';
_fxBuildCats();
_fxBuildPalette();
updateFxPreview();
document.getElementById('modal-formula').classList.add('open');
setTimeout(() => document.getElementById('fx-input').focus(), 50);
}
function fxSetMode(m) {
_fxMode = m;
document.getElementById('fx-mode-inline').classList.toggle('active', m === 'inline');
document.getElementById('fx-mode-block').classList.toggle('active', m === 'block');
updateFxPreview();
}
function _fxBuildCats() {
document.getElementById('fx-cats').innerHTML = Object.keys(FX_SYMS).map(c =>
`<button class="fx-cat-btn${c === _fxCat ? ' active' : ''}" type="button" data-cat="${esc(c)}">${esc(c)}</button>`
).join('');
}
function fxSetCat(cat, btn) {
_fxCat = cat;
document.querySelectorAll('#fx-cats .fx-cat-btn').forEach(b => b.classList.remove('active'));
if (btn) btn.classList.add('active');
_fxBuildPalette();
}
function _fxBuildPalette() {
document.getElementById('fx-palette').innerHTML = (FX_SYMS[_fxCat] || []).map(([latex, disp]) =>
`<button class="fx-sym" type="button" title="${esc(latex)}" data-tex="${esc(latex)}">${disp}</button>`
).join('');
}
function fxInsertSym(latex) {
const ta = document.getElementById('fx-input');
const s = ta.selectionStart ?? ta.value.length, e = ta.selectionEnd ?? ta.value.length;
ta.value = ta.value.slice(0, s) + latex + ta.value.slice(e);
ta.selectionStart = ta.selectionEnd = s + latex.length;
ta.focus();
updateFxPreview();
}
function updateFxPreview() {
const latex = document.getElementById('fx-input').value;
const pv = document.getElementById('fx-preview');
if (!latex.trim()) { pv.innerHTML = '<span class="fx-ph">Превью формулы появится здесь</span>'; return; }
const wrapped = _fxMode === 'block' ? `\\[${latex}\\]` : `\\(${latex}\\)`;
pv.innerHTML = mathHtmlFC(wrapped);
}
function fxInsert() {
const latex = document.getElementById('fx-input').value.trim();
if (!latex) { closeModal('modal-formula'); return; }
const wrapped = _fxMode === 'block' ? `\\[ ${latex} \\]` : `\\( ${latex} \\)`;
const ta = _fxField;
if (ta) {
const s = ta.selectionStart ?? ta.value.length, e = ta.selectionEnd ?? ta.value.length;
ta.value = ta.value.slice(0, s) + wrapped + ta.value.slice(e);
ta.selectionStart = ta.selectionEnd = s + wrapped.length;
ta.dispatchEvent(new Event('input', { bubbles: true }));
ta.dispatchEvent(new Event('change', { bubbles: true }));
ta.focus();
}
closeModal('modal-formula');
}
/* ════ 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;
const _dc = _curDeck.color || '#9B5DE5';
const wrap = document.querySelector('.study-wrap');
if (wrap) { wrap.style.setProperty('--deck-color', _dc); wrap.style.setProperty('--deck-color-a', _hexAlpha(_dc, .08)); }
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);
setStudyImg('study-front-img', card.front_image);
setStudyImg('study-back-img', card.back_image);
_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 setStudyImg(id, url) {
const img = document.getElementById(id);
if (!img) return;
if (url) { img.src = url; img.style.display = 'block'; }
else { img.removeAttribute('src'); img.style.display = 'none'; }
}
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>