Files
Learn_System/frontend/flashcards.html
T
Maxim Dolgolyov 4aacb2d369 feat(prep): фронтенд мастер-флага ЦТ — папка-коллекция карточек + тумблер у учителя
- flashcards.html: колоды коллекции рендерятся сворачиваемой папкой «Подготовка к ЦТ»
  (deckCardHtml вынесен, секции <details> по collection; метки из LS.prepListTracks)
- classes.html: в таблице учеников колонка «ЦТ» с тумблером флага + кнопки «Весь класс → ЦТ»/
  «Снять ЦТ» (LS.prepClassStatus/prepSetStudent/prepUnsetStudent/prepSetClass)
Иконки — inline SVG, без эмодзи.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 15:37:36 +03:00

1979 lines
105 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; }
/* ── коллекция-папка колод (напр. «Подготовка к ЦТ») ── */
.deck-collection { border: 1px solid var(--border); border-radius: 18px; padding: 2px 14px 16px;
background: rgba(123,97,255,.045); margin-top: 6px; }
.deck-coll-head { display: flex; align-items: center; gap: 10px; cursor: pointer; list-style: none;
padding: 13px 4px; font-weight: 700; color: var(--text); user-select: none; }
.deck-coll-head::-webkit-details-marker { display: none; }
.deck-coll-head > .ic { width: 19px; height: 19px; color: var(--violet); flex: none; }
.deck-coll-title { font-size: 1.03rem; }
.deck-coll-count { font-size: .82rem; font-weight: 600; color: #94a3b8; }
.deck-coll-chev { margin-left: auto; transition: transform .2s; opacity: .55; width: 18px; height: 18px; }
.deck-collection[open] .deck-coll-chev { transform: rotate(180deg); }
.deck-coll-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 18px; }
@media (max-width: 760px) { .deck-coll-grid { grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 12px; } }
@media (max-width: 480px) { .deck-coll-grid { grid-template-columns: 1fr; } }
/* ── 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; }
/* Карточка по умолчанию — отрисованный текст (с KaTeX). Клик → редактирование. */
.card-display { min-height: 48px; line-height: 1.5; font-size: .88rem; color: var(--text);
cursor: text; padding: 2px 4px; margin: 0 -4px; border-radius: 7px;
transition: background .12s; overflow-x: auto; white-space: pre-wrap; word-break: break-word; }
.card-display:hover { background: rgba(155,93,229,.06); }
.card-display.fc-empty { color: var(--text-3); font-style: italic; }
.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; }
}
/* ── shared decks (назначенные учителем) ── */
.deck-badge.shared { background: rgba(6,214,224,.14); color: #0891b2; max-width: 100%; }
.deck-badge.shared .ic { width: 11px; height: 11px; }
.deck-card.shared { border-color: rgba(6,214,224,.4); }
/* ── read-only режим списка карточек (общая колода) ── */
#card-list.readonly .card-drag,
#card-list.readonly .card-actions,
#card-list.readonly .fx-mini,
#card-list.readonly .card-img-add,
#card-list.readonly .card-img-remove { display: none !important; }
#card-list.readonly .card-display { cursor: default; }
#card-list.readonly .card-display:hover { background: transparent; }
/* ── share modal ── */
.share-sub { font-size: .82rem; color: var(--text-3); margin: -8px 0 16px; line-height: 1.5; }
.share-tabs { display: flex; gap: 8px; margin-bottom: 14px; }
.share-tab { flex: 1; padding: 9px; border: 1.5px solid var(--border); border-radius: 10px; background: #fff;
cursor: pointer; font-family: 'Manrope', sans-serif; font-size: .82rem; font-weight: 700;
color: var(--text-2); transition: .15s; }
.share-tab.active { border-color: var(--violet); background: rgba(155,93,229,.08); color: var(--violet); }
.share-list { max-height: 46vh; overflow-y: auto; display: flex; flex-direction: column; gap: 8px;
margin-bottom: 6px; padding-right: 4px; }
.share-row { display: flex; align-items: center; gap: 12px; padding: 10px 14px;
border: 1.5px solid var(--border); border-radius: 12px; background: var(--surface-2);
cursor: pointer; transition: .15s; }
.share-row:hover { border-color: var(--violet); }
.share-row.on { border-color: var(--violet); background: rgba(155,93,229,.07); }
.share-row-name { flex: 1; font-size: .88rem; font-weight: 600; color: var(--text); min-width: 0;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.share-row-sub { font-size: .72rem; color: var(--text-3); font-weight: 500; }
.share-toggle { width: 40px; height: 22px; border-radius: 99px; background: var(--border); position: relative;
flex-shrink: 0; transition: background .18s; }
.share-row.on .share-toggle { background: var(--violet); }
.share-toggle::after { content: ''; position: absolute; top: 2px; left: 2px; width: 18px; height: 18px;
border-radius: 50%; background: #fff; transition: transform .18s; box-shadow: 0 1px 3px rgba(0,0,0,.2); }
.share-row.on .share-toggle::after { transform: translateX(18px); }
.share-empty { text-align: center; padding: 28px 12px; color: var(--text-3); font-size: .84rem; }
.app-layout.dark .share-tab { background: #1A1D27; }
</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" id="cards-ai-btn" onclick="openAiGenModal()">Сгенерировать ИИ</button>
<button class="fc-btn fc-btn-ghost" id="cards-bulk-btn" onclick="openBulkModal()">Добавить список</button>
<button class="fc-btn fc-btn-ghost" id="cards-share-btn" style="display:none" onclick="openShareModal()">
<svg class="ic" viewBox="0 0 24 24" style="width:14px;height:14px;vertical-align:-2px;margin-right:4px"><circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/><line x1="8.6" y1="13.5" x2="15.4" y2="17.5"/><line x1="15.4" y1="6.5" x2="8.6" y2="10.5"/></svg>Поделиться</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" id="card-add-row" 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 id="deck-manage-row" 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>
<!-- ── AI generate Modal ── -->
<div class="fc-modal" id="modal-aigen">
<div class="fc-modal-bg" onclick="closeModal('modal-aigen')"></div>
<div class="fc-modal-box" style="max-width:560px">
<div class="fc-modal-title">Сгенерировать карточки ИИ</div>
<div class="fc-modal-field">
<div class="fc-modal-label">Тема или текст</div>
<textarea class="fc-modal-input" id="aigen-text" rows="6" style="resize:vertical"
placeholder="Например: Теорема Пифагора&#10;— или вставьте параграф / конспект, по которому сделать карточки"></textarea>
</div>
<div class="fc-modal-field" style="display:flex;align-items:center;gap:10px">
<span class="fc-modal-label" style="margin:0">Сколько карточек</span>
<select class="fc-modal-input" id="aigen-count" style="width:auto;padding:8px 12px">
<option>4</option><option selected>6</option><option>8</option><option>10</option>
</select>
</div>
<div class="fc-modal-actions">
<button class="fc-btn fc-btn-ghost" onclick="closeModal('modal-aigen')">Отмена</button>
<button class="fc-btn fc-btn-primary" id="aigen-btn" onclick="runAiGen()">Сгенерировать</button>
</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>
<!-- ── Share Deck Modal ── -->
<div class="fc-modal" id="modal-share">
<div class="fc-modal-bg" onclick="closeModal('modal-share')"></div>
<div class="fc-modal-box" style="max-width:520px">
<div class="fc-modal-title">Поделиться колодой</div>
<p class="share-sub">Назначьте колоду классу или отдельным ученикам. Карточки общие, а прогресс у каждого ученика — свой.</p>
<div class="share-tabs">
<button class="share-tab active" id="share-tab-class" onclick="shareSetTab('class')">Классы</button>
<button class="share-tab" id="share-tab-user" onclick="shareSetTab('user')">Ученики</button>
</div>
<div class="share-list" id="share-list"></div>
<div class="fc-modal-actions">
<button class="fc-btn fc-btn-primary" onclick="closeModal('modal-share')">Готово</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/imggen.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: '' }; // картинки, прикреплённые к ещё не созданной карточке
let _user = null;
let _isTeacher = false;
let _curDeckReadonly = false; // общая колода (не владелец) — редактирование скрыто
// модалка шаринга
let _shareData = { shares: [], classes: [], students: [] };
let _shareTab = 'class';
let _shareSet = new Set(); // ключи 'class:<id>' / 'user:<id>' текущих назначений
(async () => {
/* ── auth ── */
const { user } = LS.initPage();
if (!user) return;
_user = user;
_isTeacher = (user.role === 'teacher' || user.role === 'admin');
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]); }
});
}
let _collLabels = null; // { collectionKey: label } для заголовков папок-коллекций
async function loadDecks() {
const [decks, stats, tracks] = await Promise.all([
LS.api('/api/flashcards/decks').catch(()=>({decks:[]})),
LS.api('/api/flashcards/stats').catch(()=>null),
_collLabels ? Promise.resolve(null) : LS.prepListTracks().catch(()=>null),
]);
if (tracks && tracks.tracks) { // collection-ключ == ключ трека (1:1)
_collLabels = {};
tracks.tracks.forEach(t => { _collLabels[t.key] = t.label || t.title || t.key; });
}
_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 ════ */
/* HTML одной карточки колоды (общий грид + папки-коллекции). */
function deckCardHtml(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>`;
// Общая колода (назначена мне учителем/направлением): бейдж владельца, без карандаша правки.
const sharedHtml = d.shared
? `<span class="deck-badge shared" title="Колода от учителя"><svg class="ic" viewBox="0 0 24 24"><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>${esc(d.owner_name || 'учитель')}</span>`
: '';
return `<div class="deck-card${d.shared ? ' shared' : ''}" 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}${sharedHtml}</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})" title="${d.shared ? 'Открыть' : 'Редактировать'}">
${d.shared
? `<svg class="ic" viewBox="0 0 24 24"><path d="M2 12s3-7 10-7 10 7 10 7-3 7-10 7-10-7-10-7z"/><circle cx="12" cy="12" r="3"/></svg>`
: `<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>`;
}
function _plDecks(n) {
const a = n % 10, b = n % 100;
const w = (a === 1 && b !== 11) ? 'колода'
: (a >= 2 && a <= 4 && (b < 10 || b >= 20)) ? 'колоды' : 'колод';
return n + ' ' + w;
}
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;
}
// Колоды без коллекции — обычный грид; колоды коллекций — отдельными папками снизу.
const ungrouped = _decks.filter(d => !d.collection);
const byColl = {};
for (const d of _decks) if (d.collection) (byColl[d.collection] = byColl[d.collection] || []).push(d);
let html = ungrouped.map(deckCardHtml).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>`;
for (const coll of Object.keys(byColl)) {
const decks = byColl[coll];
const label = (_collLabels && _collLabels[coll]) || coll;
const totalDue = decks.reduce((s, d) => s + (d.due_count || 0), 0);
const sub = `${_plDecks(decks.length)}${totalDue ? ` · ${totalDue} к повторению` : ''}`;
html += `<details class="deck-collection" open style="grid-column:1/-1">
<summary class="deck-coll-head">
<svg class="ic" viewBox="0 0 24 24"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/></svg>
<span class="deck-coll-title">${esc(label)}</span>
<span class="deck-coll-count">${sub}</span>
<svg class="ic deck-coll-chev" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
</summary>
<div class="deck-coll-grid">${decks.map(deckCardHtml).join('')}</div>
</details>`;
}
grid.innerHTML = html;
}
/* ════ Open deck (card editor) ════ */
async function openDeck(id) {
_curDeck = _decks.find(d => d.id === id);
if (!_curDeck) return;
// Общая колода (назначена мне) — только просмотр и изучение, без правки.
_curDeckReadonly = (_curDeck.shared === 1) || (_curDeck.can_edit === 0);
document.getElementById('cards-deck-title').textContent = _curDeck.title;
applyCardsPermissions();
const data = await LS.api(`/api/flashcards/decks/${id}/cards`).catch(()=>({cards:[]}));
if (data && data.can_edit === false) _curDeckReadonly = true; // страховка по серверу
_cards = data.cards || [];
_cardFilter = '';
const si = document.getElementById('card-search'); if (si) si.value = '';
applyCardsPermissions();
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);
}
/* Показ/скрытие редактирующих элементов в зависимости от прав на колоду.
readonly (общая, не владелец) → прячем добавление/ИИ/список/правку колоды;
кнопка «Поделиться» — только владельцу-учителю/админу. */
function applyCardsPermissions() {
const ed = !_curDeckReadonly;
['cards-ai-btn', 'cards-bulk-btn', 'card-add-row', 'deck-manage-row'].forEach(id => {
const el = document.getElementById(id); if (el) el.style.display = ed ? '' : 'none';
});
const imgs = document.getElementById('new-card-imgs'); if (imgs) imgs.style.display = ed ? '' : 'none';
const shareBtn = document.getElementById('cards-share-btn');
if (shareBtn) shareBtn.style.display = (ed && _isTeacher) ? '' : 'none';
}
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>
<div class="card-display" data-ph="Вопрос…" onclick="fcStartEdit(this)"></div>
<textarea class="card-textarea" rows="2" style="display:none" data-cid="${c.id}" data-side="front"
onpaste="onCardPaste(event,${c.id},'front')"
onblur="fcEndEdit(this)">${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>
<div class="card-display" data-ph="Ответ…" onclick="fcStartEdit(this)"></div>
<textarea class="card-textarea" rows="2" style="display:none" data-cid="${c.id}" data-side="back"
onpaste="onCardPaste(event,${c.id},'back')"
onblur="fcEndEdit(this)">${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('');
// read-only (общая колода) → CSS прячет ручки/удаление/правку, drag не вешаем
list.classList.toggle('readonly', _curDeckReadonly);
// Отрисовать карточки (KaTeX). Правка — по клику (textarea), как в Anki.
list.querySelectorAll('.card-display').forEach(fcRenderDisplay);
if (!q && !_curDeckReadonly) bindCardDrag();
}
/* Показать отрисованный текст карточки (или плейсхолдер, если пусто). */
function fcRenderDisplay(disp) {
const side = disp.closest('.card-side');
const ta = side && side.querySelector('.card-textarea');
const text = ta ? ta.value : '';
if (text && text.trim()) { disp.innerHTML = mathHtmlFC(text); disp.classList.remove('fc-empty'); }
else { disp.textContent = disp.dataset.ph || ''; disp.classList.add('fc-empty'); }
}
/* Клик по отрисованной карточке → редактирование (textarea с сырым LaTeX). */
function fcStartEdit(disp) {
if (_curDeckReadonly) return; // общая колода — только чтение
const side = disp.closest('.card-side');
const ta = side && side.querySelector('.card-textarea');
if (!ta) return;
disp.style.display = 'none';
ta.style.display = '';
ta.focus();
const v = ta.value; try { ta.setSelectionRange(v.length, v.length); } catch (e) {}
}
/* Конец редактирования (blur / после вставки формулы): сохранить + отрисовать. */
function fcEndEdit(ta) {
const id = +ta.dataset.cid, side = ta.dataset.side;
if (id && side) saveCard(id, side, ta.value);
const sideEl = ta.closest('.card-side');
const disp = sideEl && sideEl.querySelector('.card-display');
ta.style.display = 'none';
if (disp) { disp.style.display = ''; fcRenderDisplay(disp); }
}
/* ── 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" style="display:flex;gap:6px;flex-wrap:wrap">
<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>
<button class="card-img-add" onclick="genCardImage(${c.id},'${side}')" title="Сгенерировать с ИИ">
<svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M13 3l2.2 6.3L22 12l-6.8 2.7L13 21l-2.2-6.3L4 12l6.8-2.7z"/><path d="M5 4v3M3.5 5.5h3"/></svg>
ИИ
</button></div>`;
}
function genCardImage(cardId, side) {
if (!LS.imagePromptModal) { LS.toast('Модуль генерации не загружен'); return; }
const card = _cards.find(c => c.id === cardId);
LS.imagePromptModal({
title: 'Картинка для карточки',
placeholder: card && card[side === 'front' ? 'front' : 'back'] ? 'Иллюстрация к: ' + (card[side === 'front' ? 'front' : 'back'] || '') : '',
onUse: async function (url) {
const c = _cards.find(x => x.id === cardId); if (!c) return;
const field = side === 'front' ? 'front_image' : 'back_image';
await LS.api(`/api/flashcards/cards/${cardId}`, { method: 'PUT', body: JSON.stringify({ [field]: url }) }).catch(()=>{});
c[field] = url; updateCardImgRow(cardId, side); LS.toast('Картинка добавлена', 'success');
}
});
}
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');
}
/* ════ Генерация карточек ИИ (тема/текст → предпросмотр bulk → текущая колода) ════
Переиспользует экран предпросмотра bulk-импорта: ИИ заполняет _bulkCards,
пользователь правит и сохраняет в текущую колоду через saveBulk(). */
function openAiGenModal() {
if (!_curDeck) { LS.toast('Сначала откройте колоду', 'error'); return; }
document.getElementById('aigen-text').value = '';
document.getElementById('modal-aigen').classList.add('open');
setTimeout(() => { try { document.getElementById('aigen-text').focus(); } catch (e) {} }, 50);
}
async function runAiGen() {
const text = document.getElementById('aigen-text').value.trim();
if (text.length < 3) { LS.toast('Введите тему или текст'); return; }
const count = Number(document.getElementById('aigen-count').value) || 6;
const btn = document.getElementById('aigen-btn');
btn.disabled = true; btn.textContent = 'Генерирую…';
try {
const r = await LS.assistantFlashcards(text, (_curDeck && _curDeck.title) || 'Карточки', count);
const cards = (r && r.cards) || [];
if (!cards.length) throw new Error('ИИ не вернул карточек');
_bulkCards = cards.map(c => ({ front: c.front || '', back: c.back || '', front_image: '', back_image: '' }));
closeModal('modal-aigen');
// открыть bulk-модалку сразу на шаге предпросмотра
document.getElementById('bulk-text').value = '';
document.getElementById('bulk-step-text').style.display = 'none';
document.getElementById('bulk-step-preview').style.display = '';
document.getElementById('modal-bulk').classList.add('open');
renderBulkPreview();
} catch (e) {
LS.toast(e && e.message ? ('ИИ: ' + e.message) : 'Не удалось сгенерировать', 'error');
} finally {
btn.disabled = false; btn.textContent = 'Сгенерировать';
}
}
/* ════ 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();
// Поле карточки скрылось при открытии модалки (blur) — сохранить и переотрисовать.
if (ta.classList && ta.classList.contains('card-textarea')) fcEndEdit(ta);
}
closeModal('modal-formula');
}
/* ════ Study mode ════
_studyCards — ДИНАМИЧЕСКАЯ очередь, не фиксированный список: карта, отвеченная
«Снова»/недоученная (server: graduated=false), возвращается в очередь через
FC_RQ_GAP карт и показывается снова в этой же сессии. _studyDone — сколько карт
реально выпущено (ушли из очереди). */
const FC_RQ_GAP = 3;
let _studyCards = [];
let _studyIdx = 0;
let _studyDone = 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;
_studyDone = 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 remaining = _studyCards.length - _studyIdx; // ещё в очереди (вкл. текущую)
const total = _studyDone + remaining; // растёт при re-queue недоученных
const pct = total ? (_studyDone / total * 100) : 0;
document.getElementById('study-prog').style.width = pct + '%';
document.getElementById('study-counter').textContent = `${Math.min(_studyDone + 1, total)} / ${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 — ответ несёт следующее расписание и флаг graduated
let resp = null;
try {
resp = await LS.api(`/api/flashcards/cards/${card.id}/review`, {
method: 'POST', body: JSON.stringify({ quality })
});
} catch (e) { /* офлайн — оценим re-queue эвристикой ниже */ }
// обновить локальное расписание карты, чтобы повторное превью было верным
if (resp && resp.next) {
card.state = resp.next.state;
card.learning_step = resp.next.learning_step;
card.ease_factor = resp.next.ease_factor;
card.interval_days = resp.next.interval_days;
card.repetitions = resp.next.repetitions;
card.seen = 1;
}
// карта не выпущена (всё ещё learning/relearning) → вернуть в очередь этой сессии
const requeue = resp ? !resp.graduated : (quality < 3);
// animate swipe
const el = document.getElementById('study-card');
el.classList.add(quality >= 3 ? 'swipe-right' : 'swipe-left');
setTimeout(() => {
_studyCards.splice(_studyIdx, 1); // вынуть текущую
if (requeue) {
const pos = Math.min(_studyIdx + FC_RQ_GAP, _studyCards.length);
_studyCards.splice(pos, 0, card); // показать снова позже
} else {
_studyDone++;
}
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('');
}
/* ── превью следующего интервала для кнопок Снова/Трудно/Знаю/Легко ──
ВАЖНО: зеркало интервальной части серверного schedule() (flashcardController.js),
иначе превью врёт. learning/relearning → минуты (шаги), review → дни SM-2.
Константы держим в синхроне с контроллером. */
const FC_LEARN_STEPS = [1, 10], FC_RELEARN_STEPS = [10];
const FC_GRAD_IV = 1, FC_EASY_IV = 4, FC_HARD_MULT = 1.2, FC_EASY_BONUS = 1.3;
/* → { kind: 'min'|'day', n } */
function fcPreview(card, q) {
const state = card.state || (card.seen ? 'review' : 'new');
const step = card.learning_step || 0;
const ef = card.ease_factor || 2.5;
const iv = card.interval_days || 0;
const learning = (state === 'new' || state === 'learning' || state === 'relearning');
if (learning) {
const steps = (state === 'relearning') ? FC_RELEARN_STEPS : FC_LEARN_STEPS;
if (q === 5) return { kind: 'day', n: FC_EASY_IV };
if (q < 3) return { kind: 'min', n: steps[0] };
if (q === 3) return { kind: 'min', n: steps[Math.min(step, steps.length - 1)] };
const ns = step + 1; // q === 4 (Знаю)
if (ns >= steps.length)
return { kind: 'day', n: (state === 'relearning') ? Math.max(1, Math.round(iv)) : FC_GRAD_IV };
return { kind: 'min', n: steps[ns] };
}
if (q < 3) return { kind: 'min', n: FC_RELEARN_STEPS[0] };
if (q === 3) return { kind: 'day', n: Math.max(iv + 1, Math.round(iv * FC_HARD_MULT)) };
if (q === 4) return { kind: 'day', n: Math.max(iv + 1, Math.round(iv * ef)) };
return { kind: 'day', n: Math.max(iv + 1, Math.round(iv * ef * FC_EASY_BONUS)) };
}
function fcDaysLabel(n) {
if (n <= 1) return '1 день';
if (n < 5) return n + ' дня';
return n + ' дн.';
}
function fcSchedLabel(p) {
if (p.kind === 'min') return p.n < 60 ? p.n + ' мин' : Math.round(p.n / 60) + ' ч';
return fcDaysLabel(p.n);
}
function updateSQDays(card) {
document.getElementById('sq-days-0').textContent = fcSchedLabel(fcPreview(card, 0));
document.getElementById('sq-days-3').textContent = fcSchedLabel(fcPreview(card, 3));
document.getElementById('sq-days-4').textContent = fcSchedLabel(fcPreview(card, 4));
document.getElementById('sq-days-5').textContent = fcSchedLabel(fcPreview(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'); }
/* ════ Поделиться колодой (учитель → класс/ученик) ════
Карты общие, прогресс у каждого ученика свой. Тоггл сразу шлёт add/remove
share (оптимистично, с откатом при ошибке). */
async function openShareModal() {
if (!_curDeck || !_isTeacher || _curDeckReadonly) return;
document.getElementById('modal-share').classList.add('open');
document.getElementById('share-list').innerHTML =
'<div class="share-empty">Загрузка…</div>';
try {
const [sh, cls, st] = await Promise.all([
LS.api(`/api/flashcards/decks/${_curDeck.id}/shares`).catch(() => ({ shares: [] })),
LS.getClasses().catch(() => []),
LS.getStudents().catch(() => []),
]);
_shareData.shares = (sh && sh.shares) || [];
_shareData.classes = Array.isArray(cls) ? cls : (cls && cls.classes) || [];
_shareData.students = Array.isArray(st) ? st : (st && st.students) || [];
_shareSet = new Set(_shareData.shares.map(s => `${s.type}:${s.target_id}`));
} catch (e) {
_shareData = { shares: [], classes: [], students: [] };
_shareSet = new Set();
}
shareSetTab(_shareTab);
}
function shareSetTab(tab) {
_shareTab = tab;
document.getElementById('share-tab-class').classList.toggle('active', tab === 'class');
document.getElementById('share-tab-user').classList.toggle('active', tab === 'user');
renderShareList();
}
function renderShareList() {
const box = document.getElementById('share-list');
const items = _shareTab === 'class' ? _shareData.classes : _shareData.students;
if (!items.length) {
box.innerHTML = `<div class="share-empty">${_shareTab === 'class'
? 'У вас пока нет классов' : 'У вас пока нет учеников'}</div>`;
return;
}
box.innerHTML = items.map(it => {
const on = _shareSet.has(`${_shareTab}:${it.id}`);
const sub = _shareTab === 'class'
? (it.member_count != null ? `${it.member_count} учеников` : '')
: (it.email || '');
return `<div class="share-row${on ? ' on' : ''}" data-id="${it.id}" onclick="toggleShare(${it.id}, this)">
<div class="share-row-name">${esc(it.name)}${sub ? `<span class="share-row-sub" style="display:block">${esc(sub)}</span>` : ''}</div>
<div class="share-toggle"></div>
</div>`;
}).join('');
}
async function toggleShare(id, row) {
const key = `${_shareTab}:${id}`;
const wasOn = _shareSet.has(key);
// оптимистично
if (wasOn) { _shareSet.delete(key); row.classList.remove('on'); }
else { _shareSet.add(key); row.classList.add('on'); }
try {
if (wasOn) {
await LS.api(`/api/flashcards/decks/${_curDeck.id}/share?type=${_shareTab}&target_id=${id}`, { method: 'DELETE' });
} else {
await LS.api(`/api/flashcards/decks/${_curDeck.id}/share`, {
method: 'POST', body: JSON.stringify({ type: _shareTab, target_id: id })
});
}
} catch (e) {
// откат
if (wasOn) { _shareSet.add(key); row.classList.add('on'); }
else { _shareSet.delete(key); row.classList.remove('on'); }
LS.toast('Не удалось изменить доступ: ' + (e && e.message || 'ошибка'), 'error');
}
}
</script>
<script src="/js/mobile.js"></script>
</body>
</html>