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