1099 lines
57 KiB
HTML
1099 lines
57 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: 14px 16px 12px; display: flex; flex-direction: column; gap: 3px;
|
|
min-width: 112px; position: relative; overflow: hidden; }
|
|
.fc-stat::before { content: ''; position: absolute; top: 0; left: 0; width: 3px;
|
|
height: 100%; background: var(--stat-color, var(--violet));
|
|
border-radius: 0 2px 2px 0; }
|
|
.fc-stat-icon { width: 28px; height: 28px; border-radius: 8px;
|
|
background: var(--stat-bg, rgba(155,93,229,.1));
|
|
display: flex; align-items: center; justify-content: center;
|
|
color: var(--stat-color, var(--violet)); margin-bottom: 4px; }
|
|
.fc-stat-icon .ic { width: 14px; height: 14px; }
|
|
.fc-stat-val { font-family: 'Unbounded', sans-serif; font-size: 1.5rem; font-weight: 800;
|
|
color: var(--text); line-height: 1; }
|
|
.fc-stat-lbl { font-size: .7rem; font-weight: 600; color: var(--text-3);
|
|
text-transform: uppercase; letter-spacing: .04em; }
|
|
|
|
/* ── deck grid ── */
|
|
.deck-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 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-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;
|
|
backface-visibility: hidden; -webkit-backface-visibility: hidden;
|
|
border: 1.5px solid var(--border); overflow: auto; }
|
|
.study-face-front { background: linear-gradient(170deg, var(--deck-color-a, rgba(155,93,229,.07)) 0%, #fff 38%);
|
|
box-shadow: 0 10px 40px rgba(0,0,0,.1), 0 2px 8px rgba(0,0,0,.06); }
|
|
.study-face-back { background: linear-gradient(170deg, rgba(246,243,255,.9) 0%, #fff 45%);
|
|
transform: rotateY(180deg);
|
|
box-shadow: 0 10px 40px rgba(0,0,0,.1), 0 2px 8px rgba(0,0,0,.06);
|
|
border-color: rgba(155,93,229,.22); }
|
|
.study-face-text { font-family: 'Manrope', sans-serif; font-size: 1.22rem; font-weight: 600;
|
|
color: var(--text); text-align: center; line-height: 1.7; }
|
|
.study-face-label { position: absolute; top: 14px; left: 18px; font-size: .63rem; font-weight: 700;
|
|
text-transform: uppercase; letter-spacing: .08em;
|
|
color: var(--deck-color, var(--violet));
|
|
background: var(--deck-color-a, rgba(155,93,229,.1));
|
|
padding: 3px 10px; border-radius: 20px; }
|
|
.study-hint { text-align: center; font-size: .77rem; color: var(--text-3); margin-bottom: 20px;
|
|
display: flex; align-items: center; justify-content: center; gap: 6px; }
|
|
|
|
/* drag tilt */
|
|
.study-card-inner.drag-right { transform: rotate(6deg) translateX(30px); }
|
|
.study-card-inner.drag-left { transform: rotate(-6deg) translateX(-30px); }
|
|
|
|
/* answer quality buttons */
|
|
.study-btns { display: none; gap: 10px; justify-content: center; flex-wrap: wrap; margin-bottom: 24px; }
|
|
.study-btns.visible { display: flex; }
|
|
.sq-btn { padding: 11px 20px; border-radius: 12px; border: 2px solid transparent;
|
|
cursor: pointer; font-family: 'Manrope', sans-serif; font-size: .84rem; font-weight: 700;
|
|
transition: .18s; display: flex; flex-direction: column; align-items: center; gap: 3px;
|
|
box-shadow: 0 2px 8px rgba(0,0,0,.07); }
|
|
.sq-btn:hover { transform: translateY(-2px); box-shadow: 0 5px 16px rgba(0,0,0,.1); }
|
|
.sq-btn:active { transform: translateY(0); }
|
|
.sq-btn .sq-top { display: flex; align-items: center; gap: 6px; }
|
|
.sq-btn .sq-days { font-size: .66rem; font-weight: 600; opacity: .7; }
|
|
.fc-kbd { font-family: 'Manrope', sans-serif; font-size: .62rem; font-weight: 800; line-height: 1;
|
|
padding: 2px 5px; border-radius: 5px; background: rgba(0,0,0,.09);
|
|
border: 1px solid rgba(0,0,0,.13); color: inherit; opacity: .8; }
|
|
.study-hint .fc-kbd { opacity: .9; }
|
|
.sq-btn-again { background: #FEE2E2; border-color: #FECACA; color: #DC2626; }
|
|
.sq-btn-again:hover { background: #FECACA; border-color: #FCA5A5; }
|
|
.sq-btn-hard { background: #FEF3C7; border-color: #FDE68A; color: #D97706; }
|
|
.sq-btn-hard:hover { background: #FDE68A; border-color: #FCD34D; }
|
|
.sq-btn-good { background: #DBEAFE; border-color: #BFDBFE; color: #2563EB; }
|
|
.sq-btn-good:hover { background: #BFDBFE; border-color: #93C5FD; }
|
|
.sq-btn-easy { background: #DCFCE7; border-color: #BBF7D0; color: #16A34A; }
|
|
.sq-btn-easy:hover { background: #BBF7D0; border-color: #86EFAC; }
|
|
|
|
/* swipe indicator */
|
|
.swipe-indicator { position: absolute; top: 18px; font-size: .9rem; font-weight: 900;
|
|
letter-spacing: .04em; padding: 6px 14px; border-radius: 10px;
|
|
opacity: 0; pointer-events: none; transition: opacity .1s; z-index: 10;
|
|
display: flex; align-items: center; gap: 6px; }
|
|
.swipe-right-ind { right: 20px; background: #DCFCE7; color: #16A34A; border: 2px solid #BBF7D0; }
|
|
.swipe-left-ind { left: 20px; background: #FEE2E2; color: #DC2626; border: 2px solid #FECACA; }
|
|
.swipe-indicator .ic { width: 15px; height: 15px; }
|
|
|
|
/* finished state */
|
|
.study-done { text-align: center; padding: 48px 24px; }
|
|
.study-done-icon { margin-bottom: 18px; display: flex; align-items: center; justify-content: center; }
|
|
.study-done-icon .ic { width: 56px; height: 56px; color: var(--violet);
|
|
filter: drop-shadow(0 4px 14px rgba(155,93,229,.35)); }
|
|
.study-done h2 { font-family: 'Unbounded', sans-serif; font-size: 1.4rem; font-weight: 800;
|
|
color: var(--text); margin-bottom: 8px; }
|
|
.study-done p { color: var(--text-3); font-size: .88rem; margin-bottom: 24px; }
|
|
.study-session-stats { display: flex; gap: 12px; justify-content: center; flex-wrap: wrap; margin-bottom: 28px; }
|
|
.ss-stat { background: #fff; border: 1.5px solid var(--border); border-radius: 14px;
|
|
padding: 14px 22px; text-align: center; }
|
|
.ss-stat-n { font-family: 'Unbounded', sans-serif; font-size: 1.5rem; font-weight: 800; }
|
|
.ss-stat-l { font-size: .72rem; color: var(--text-3); font-weight: 600; margin-top: 3px; }
|
|
|
|
/* ── modals ── */
|
|
.fc-modal { position: fixed; inset: 0; z-index: 300; display: none;
|
|
align-items: center; justify-content: center; padding: 16px; }
|
|
.fc-modal.open { display: flex; }
|
|
.fc-modal-bg { position: absolute; inset: 0; background: rgba(0,0,0,.45); backdrop-filter: blur(4px); }
|
|
.fc-modal-box { position: relative; z-index: 1; background: #fff; border-radius: 20px;
|
|
padding: 28px; width: 100%; max-width: 480px;
|
|
box-shadow: 0 24px 64px rgba(0,0,0,.18); }
|
|
.fc-modal-title { font-family: 'Unbounded', sans-serif; font-size: 1rem; font-weight: 800;
|
|
color: var(--text); margin-bottom: 20px; }
|
|
.fc-modal-field { margin-bottom: 14px; }
|
|
.fc-modal-label { font-size: .75rem; font-weight: 700; color: var(--text-2); display: block;
|
|
text-transform: uppercase; letter-spacing: .05em; margin-bottom: 6px; }
|
|
.fc-modal-input { width: 100%; padding: 10px 14px; border: 1.5px solid var(--border);
|
|
border-radius: 10px; font-family: 'Manrope', sans-serif; font-size: .88rem;
|
|
color: var(--text); outline: none; box-sizing: border-box; transition: .15s; }
|
|
.fc-modal-input:focus { border-color: var(--violet); box-shadow: 0 0 0 3px rgba(155,93,229,.1); }
|
|
.fc-modal-actions { display: flex; gap: 10px; justify-content: flex-end; margin-top: 22px; }
|
|
.color-picker-row { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 2px; }
|
|
.cp-swatch { width: 30px; height: 30px; border-radius: 50%; cursor: pointer;
|
|
border: 3px solid transparent; transition: .18s; }
|
|
.cp-swatch.active, .cp-swatch:hover { border-color: var(--text); transform: scale(1.2);
|
|
box-shadow: 0 2px 8px rgba(0,0,0,.2); }
|
|
|
|
/* ── empty ── */
|
|
.fc-empty { text-align: center; padding: 60px 24px; }
|
|
.fc-empty-icon { margin-bottom: 14px; display: flex; align-items: center; justify-content: center; }
|
|
.fc-empty-icon .ic { width: 48px; height: 48px; color: var(--text-3); }
|
|
.fc-empty h3 { font-family: 'Unbounded', sans-serif; font-size: 1rem; font-weight: 800;
|
|
color: var(--text); margin-bottom: 8px; }
|
|
.fc-empty p { color: var(--text-3); font-size: .84rem; margin-bottom: 22px; }
|
|
|
|
@media (max-width: 768px) {
|
|
.fc-wrap { padding: 16px 14px 60px; }
|
|
.fc-title { font-size: 1rem; }
|
|
.fc-stats { gap: 8px; }
|
|
.fc-stat { min-width: 92px; padding: 10px 12px; }
|
|
.fc-stat-val { font-size: 1.2rem; }
|
|
.deck-grid { grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 12px; }
|
|
.study-face { padding: 24px 20px; }
|
|
.sq-btn { padding: 9px 16px; font-size: .8rem; }
|
|
}
|
|
@media (max-width: 480px) {
|
|
.fc-wrap { padding: 12px 10px 60px; }
|
|
.fc-header { flex-wrap: wrap; gap: 10px; }
|
|
.fc-title { font-size: 0.92rem; }
|
|
.deck-grid { grid-template-columns: 1fr; }
|
|
.study-face { padding: 20px 14px; }
|
|
.sq-btn { padding: 8px 14px; font-size: .78rem; flex: 1; justify-content: center; }
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="app-layout">
|
|
<aside class="sidebar" id="app-sidebar"></aside>
|
|
|
|
<main class="sb-content">
|
|
<div class="fc-wrap">
|
|
|
|
<!-- ── DECKS VIEW ── -->
|
|
<div id="view-decks">
|
|
<div class="fc-header">
|
|
<h1 class="fc-title">Флэш-карточки</h1>
|
|
<button class="fc-btn fc-btn-primary" onclick="openNewDeckModal()">+ Новая колода</button>
|
|
</div>
|
|
<div class="fc-stats" id="fc-stats-bar"></div>
|
|
<div class="deck-grid" id="deck-grid">
|
|
<div style="grid-column:1/-1; text-align:center; padding:40px; color:var(--text-3)">Загрузка…</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- ── CARDS VIEW ── -->
|
|
<div id="view-cards">
|
|
<div class="fc-header">
|
|
<button class="fc-back visible" id="cards-back-btn" onclick="showDecks()">
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M19 12H5M12 5l-7 7 7 7"/></svg>
|
|
Колоды
|
|
</button>
|
|
<h1 class="fc-title" id="cards-deck-title">Название колоды</h1>
|
|
<button class="fc-btn fc-btn-ghost" onclick="openBulkModal()">Добавить список</button>
|
|
<button class="fc-btn fc-btn-primary" id="cards-study-btn" onclick="startStudy()">Начать изучение</button>
|
|
</div>
|
|
<div class="card-search-bar" id="card-search-bar" style="display:none">
|
|
<svg class="ic card-search-ic" viewBox="0 0 24 24"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/></svg>
|
|
<input class="card-search-input" id="card-search" placeholder="Поиск по карточкам…" oninput="onCardSearch(this.value)" />
|
|
<span class="card-search-count" id="card-search-count"></span>
|
|
</div>
|
|
<div class="card-list" id="card-list"></div>
|
|
<!-- Add card row -->
|
|
<div class="card-add-bar" style="margin-bottom:14px">
|
|
<input class="card-add-input" id="new-card-front" placeholder="Лицевая сторона (вопрос)…" onkeydown="addCardOnEnter(event)" />
|
|
<input class="card-add-input" id="new-card-back" placeholder="Обратная сторона (ответ)…" onkeydown="addCardOnEnter(event)" />
|
|
<button class="fc-btn fc-btn-primary" onclick="addCard()">+ Добавить</button>
|
|
</div>
|
|
<div style="display:flex;gap:10px;align-items:center">
|
|
<button class="fc-btn fc-btn-ghost" onclick="openEditDeckModal()"><svg class="ic" viewBox="0 0 24 24"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.12 2.12 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>️ Редактировать колоду</button>
|
|
<button class="fc-btn fc-btn-danger" onclick="confirmDeleteDeck()">Удалить колоду</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- ── STUDY VIEW ── -->
|
|
<div id="view-study">
|
|
<div class="fc-header">
|
|
<button class="fc-back visible" onclick="showCards()">
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M19 12H5M12 5l-7 7 7 7"/></svg>
|
|
Карточки
|
|
</button>
|
|
<h1 class="fc-title" id="study-deck-title">Изучение</h1>
|
|
</div>
|
|
<div class="study-wrap">
|
|
<div class="study-progress-bar"><div class="study-progress-fill" id="study-prog" style="width:0%"></div></div>
|
|
<div class="study-counter" id="study-counter">1 / 10</div>
|
|
|
|
<div class="study-card-scene" id="study-scene" onclick="flipCard()">
|
|
<div class="study-card-inner" id="study-card">
|
|
<div class="study-face study-face-front">
|
|
<span class="study-face-label">Вопрос</span>
|
|
<div class="study-face-text" id="study-front-text"></div>
|
|
</div>
|
|
<div class="study-face study-face-back">
|
|
<span class="study-face-label">Ответ</span>
|
|
<div class="study-face-text" id="study-back-text"></div>
|
|
</div>
|
|
<span class="swipe-indicator swipe-right-ind" id="ind-right">ЗНАЮ <svg class="ic" viewBox="0 0 24 24"><polyline points="20 6 9 17 4 12"/></svg></span>
|
|
<span class="swipe-indicator swipe-left-ind" id="ind-left">ЕЩЁ РАЗ <svg class="ic" viewBox="0 0 24 24"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></span>
|
|
</div>
|
|
</div>
|
|
<div class="study-hint" id="study-flip-hint">Нажмите или <kbd class="fc-kbd">Пробел</kbd>, чтобы увидеть ответ</div>
|
|
|
|
<div class="study-btns" id="study-btns">
|
|
<button class="sq-btn sq-btn-again" onclick="answer(0)"><span class="sq-top">Снова<kbd class="fc-kbd">1</kbd></span><span class="sq-days" id="sq-days-0">1 день</span></button>
|
|
<button class="sq-btn sq-btn-hard" onclick="answer(3)"><span class="sq-top">Трудно<kbd class="fc-kbd">2</kbd></span><span class="sq-days" id="sq-days-3">—</span></button>
|
|
<button class="sq-btn sq-btn-good" onclick="answer(4)"><span class="sq-top">Знаю<kbd class="fc-kbd">3</kbd></span><span class="sq-days" id="sq-days-4">—</span></button>
|
|
<button class="sq-btn sq-btn-easy" onclick="answer(5)"><span class="sq-top">Легко<kbd class="fc-kbd">4</kbd></span><span class="sq-days" id="sq-days-5">—</span></button>
|
|
</div>
|
|
|
|
<!-- done screen -->
|
|
<div class="study-done" id="study-done" style="display:none">
|
|
<div class="study-done-icon"><svg class="ic" viewBox="0 0 24 24"><path d="m12 3-1.9 5.8a2 2 0 0 1-1.3 1.3L3 12l5.8 1.9a2 2 0 0 1 1.3 1.3L12 21l1.9-5.8a2 2 0 0 1 1.3-1.3L21 12l-5.8-1.9a2 2 0 0 1-1.3-1.3z"/></svg></div>
|
|
<h2>Сессия завершена!</h2>
|
|
<p id="study-done-sub">Хорошая работа — вы просмотрели все карточки</p>
|
|
<div class="study-session-stats" id="study-session-stats"></div>
|
|
<div style="display:flex;gap:12px;justify-content:center">
|
|
<button class="fc-btn fc-btn-primary" onclick="startStudy()">Повторить ещё раз</button>
|
|
<button class="fc-btn fc-btn-ghost" onclick="showCards()">Вернуться</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
</div>
|
|
</main>
|
|
</div>
|
|
|
|
<!-- ── New / Edit Deck Modal ── -->
|
|
<div class="fc-modal" id="modal-deck">
|
|
<div class="fc-modal-bg" onclick="closeModal('modal-deck')"></div>
|
|
<div class="fc-modal-box">
|
|
<div class="fc-modal-title" id="modal-deck-title">Новая колода</div>
|
|
<div class="fc-modal-field">
|
|
<div class="fc-modal-label">Название</div>
|
|
<input class="fc-modal-input" id="modal-deck-name" placeholder="Биология — клетка" maxlength="80" />
|
|
</div>
|
|
<div class="fc-modal-field">
|
|
<div class="fc-modal-label">Описание (необязательно)</div>
|
|
<input class="fc-modal-input" id="modal-deck-desc" placeholder="Краткое описание…" maxlength="200" />
|
|
</div>
|
|
<div class="fc-modal-field">
|
|
<div class="fc-modal-label">Цвет</div>
|
|
<div class="color-picker-row" id="color-picker">
|
|
<!-- filled by JS -->
|
|
</div>
|
|
</div>
|
|
<div class="fc-modal-actions">
|
|
<button class="fc-btn fc-btn-ghost" onclick="closeModal('modal-deck')">Отмена</button>
|
|
<button class="fc-btn fc-btn-primary" onclick="saveDeckModal()">Сохранить</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- ── Bulk add Modal ── -->
|
|
<div class="fc-modal" id="modal-bulk">
|
|
<div class="fc-modal-bg" onclick="closeModal('modal-bulk')"></div>
|
|
<div class="fc-modal-box">
|
|
<div class="fc-modal-title">Добавить список карточек</div>
|
|
<div class="fc-modal-field">
|
|
<div class="fc-modal-label">Формат: одна карточка = одна строка, вопрос и ответ разделены «—» или «|»</div>
|
|
<textarea class="fc-modal-input" id="bulk-text" rows="10" style="resize:vertical"
|
|
placeholder="Митохондрия — органелл клетки, производит АТФ Ядро | содержит ДНК Рибосома — синтез белка"></textarea>
|
|
</div>
|
|
<div class="fc-modal-actions">
|
|
<button class="fc-btn fc-btn-ghost" onclick="closeModal('modal-bulk')">Отмена</button>
|
|
<button class="fc-btn fc-btn-primary" onclick="saveBulk()">Добавить</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<script src="https://cdn.jsdelivr.net/npm/lucide@0.469.0/dist/umd/lucide.min.js"></script>
|
|
<script src="/js/api.js"></script>
|
|
<script src="/js/sidebar.js"></script>
|
|
<script src="/js/notifications.js"></script>
|
|
<script src="/js/search.js"></script>
|
|
<script src="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.js"></script>
|
|
<script src="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/contrib/auto-render.min.js"></script>
|
|
<script>
|
|
/* ════ Constants & State ════ */
|
|
const COLORS = ['#9B5DE5','#EF476F','#FF9F1C','#06D6E0','#22d399','#3B82F6','#F15BB5','#6B7280'];
|
|
|
|
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 = '';
|
|
|
|
(async () => {
|
|
/* ── auth ── */
|
|
const { user } = LS.initPage();
|
|
if (!user) return;
|
|
const avatarEl = document.getElementById('nav-avatar');
|
|
const nameEl = document.getElementById('nav-user');
|
|
LS.renderNavAvatar(avatarEl, user);
|
|
if (nameEl) nameEl.textContent = user.name || '';
|
|
LS.showBoardIfAllowed();
|
|
if (user.role!=='student') { document.getElementById('btn-classes')?.style && (document.getElementById('btn-classes').style.display='flex'); }
|
|
if (user.role==='admin') { document.getElementById('btn-admin')?.style && (document.getElementById('btn-admin').style.display='flex'); }
|
|
|
|
if (localStorage.getItem('ls_sb_collapsed') === '1')
|
|
document.querySelector('.app-layout')?.classList.add('sb-collapsed');
|
|
|
|
lucide.createIcons();
|
|
init();
|
|
})();
|
|
|
|
/* ════ Init ════ */
|
|
async function init() {
|
|
buildColorPicker();
|
|
bindStudyKeys();
|
|
await loadDecks();
|
|
}
|
|
|
|
/* ── keyboard shortcuts in study mode ──
|
|
Space/Enter/↑/↓ — перевернуть; после переворота 1-4 или ←/→ — оценка. */
|
|
function bindStudyKeys() {
|
|
document.addEventListener('keydown', (e) => {
|
|
if (document.getElementById('view-study')?.style.display !== 'block') return;
|
|
if (document.getElementById('study-done')?.style.display === 'block') return;
|
|
const t = e.target;
|
|
if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable)) return;
|
|
|
|
const flip = ['Space', 'Enter', 'ArrowUp', 'ArrowDown'];
|
|
if (!_studyFlipped) {
|
|
if (flip.includes(e.code) || e.key === ' ') { e.preventDefault(); flipCard(); }
|
|
else if (e.code === 'ArrowLeft' || e.code === 'ArrowRight') { e.preventDefault(); flipCard(); }
|
|
return;
|
|
}
|
|
// flipped → grade
|
|
const map = { Digit1: 0, Digit2: 3, Digit3: 4, Digit4: 5,
|
|
Numpad1: 0, Numpad2: 3, Numpad3: 4, Numpad4: 5,
|
|
ArrowLeft: 0, ArrowRight: 5 };
|
|
if (e.code in map) { e.preventDefault(); answer(map[e.code]); }
|
|
});
|
|
}
|
|
|
|
async function loadDecks() {
|
|
const [decks, stats] = await Promise.all([
|
|
LS.api('/api/flashcards/decks').catch(()=>({decks:[]})),
|
|
LS.api('/api/flashcards/stats').catch(()=>null),
|
|
]);
|
|
_decks = decks.decks || [];
|
|
renderStats(stats);
|
|
renderDecks();
|
|
}
|
|
|
|
/* ════ Sidebar toggle ════ */
|
|
function toggleSidebar() {
|
|
const l = document.querySelector('.app-layout');
|
|
l.classList.toggle('sb-collapsed');
|
|
localStorage.setItem('ls_sb_collapsed', l.classList.contains('sb-collapsed') ? '1' : '0');
|
|
}
|
|
|
|
/* ════ Stats bar ════ */
|
|
function renderStats(s) {
|
|
if (!s) return;
|
|
const bar = document.getElementById('fc-stats-bar');
|
|
bar.innerHTML = [
|
|
{ val: s.decks_count, lbl: 'Колод', col: '#9B5DE5', bg: 'rgba(155,93,229,.1)',
|
|
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', bg: 'rgba(59,130,246,.1)',
|
|
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', bg: 'rgba(217,119,6,.1)',
|
|
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', bg: 'rgba(22,163,74,.1)',
|
|
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-bg:${x.bg}">
|
|
<div class="fc-stat-icon">${x.icon}</div>
|
|
<span class="fc-stat-val">${x.val}</span>
|
|
<span class="fc-stat-lbl">${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-lbl">Вопрос</div>
|
|
<textarea class="card-textarea" rows="2"
|
|
onchange="saveCard(${c.id},'front',this.value)">${esc(c.front)}</textarea>
|
|
</div>
|
|
<div class="card-divider"></div>
|
|
<div class="card-side">
|
|
<div class="card-side-lbl">Ответ</div>
|
|
<textarea class="card-textarea" rows="2"
|
|
onchange="saveCard(${c.id},'back',this.value)">${esc(c.back)}</textarea>
|
|
</div>
|
|
<div class="card-actions">
|
|
<button class="card-act-btn del" onclick="deleteCard(${c.id})" title="Удалить">
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14H6L5 6"/><path d="M10 11v6M14 11v6"/></svg>
|
|
</button>
|
|
</div>
|
|
</div>`).join('');
|
|
|
|
if (!q) bindCardDrag();
|
|
}
|
|
|
|
/* ── drag-reorder карточек (только без активного фильтра) ── */
|
|
let _dragId = null;
|
|
function bindCardDrag() {
|
|
const list = document.getElementById('card-list');
|
|
if (!list) return;
|
|
list.querySelectorAll('.card-item').forEach(el => {
|
|
const handle = el.querySelector('.card-drag');
|
|
if (!handle) return;
|
|
// перетаскивание стартует только с ручки — textarea остаётся редактируемой
|
|
handle.addEventListener('mousedown', () => el.setAttribute('draggable', 'true'));
|
|
handle.addEventListener('touchstart', () => el.setAttribute('draggable', 'true'), { passive: true });
|
|
|
|
el.addEventListener('dragstart', (e) => {
|
|
_dragId = +el.dataset.id;
|
|
el.classList.add('dragging');
|
|
try { e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.setData('text/plain', String(_dragId)); } catch {}
|
|
});
|
|
el.addEventListener('dragend', () => {
|
|
el.classList.remove('dragging'); el.removeAttribute('draggable');
|
|
list.querySelectorAll('.drag-over-top,.drag-over-bottom')
|
|
.forEach(x => x.classList.remove('drag-over-top', 'drag-over-bottom'));
|
|
_dragId = null;
|
|
});
|
|
el.addEventListener('dragover', (e) => {
|
|
e.preventDefault();
|
|
const r = el.getBoundingClientRect();
|
|
const after = (e.clientY - r.top) > r.height / 2;
|
|
el.classList.toggle('drag-over-bottom', after);
|
|
el.classList.toggle('drag-over-top', !after);
|
|
});
|
|
el.addEventListener('dragleave', () => el.classList.remove('drag-over-top', 'drag-over-bottom'));
|
|
el.addEventListener('drop', (e) => {
|
|
e.preventDefault();
|
|
el.classList.remove('drag-over-top', 'drag-over-bottom');
|
|
const targetId = +el.dataset.id;
|
|
if (_dragId == null || _dragId === targetId) return;
|
|
const r = el.getBoundingClientRect();
|
|
const after = (e.clientY - r.top) > r.height / 2;
|
|
moveCard(_dragId, targetId, after);
|
|
});
|
|
});
|
|
}
|
|
|
|
function moveCard(dragId, targetId, after) {
|
|
const from = _cards.findIndex(c => c.id === dragId);
|
|
if (from < 0) return;
|
|
const item = _cards.splice(from, 1)[0];
|
|
let to = _cards.findIndex(c => c.id === targetId);
|
|
if (to < 0) { _cards.splice(from, 0, item); return; }
|
|
if (after) to += 1;
|
|
_cards.splice(to, 0, item);
|
|
renderCardList();
|
|
persistCardOrder();
|
|
}
|
|
|
|
async function persistCardOrder() {
|
|
if (!_curDeck) return;
|
|
const order = _cards.map(c => c.id);
|
|
await LS.api(`/api/flashcards/decks/${_curDeck.id}/reorder`, {
|
|
method: 'PUT', body: JSON.stringify({ order })
|
|
}).catch(() => {});
|
|
}
|
|
|
|
async function addCard() {
|
|
if (!_curDeck) return;
|
|
const front = document.getElementById('new-card-front').value.trim();
|
|
const back = document.getElementById('new-card-back').value.trim();
|
|
if (!front && !back) return;
|
|
const card = await LS.api(`/api/flashcards/decks/${_curDeck.id}/cards`, {
|
|
method: 'POST', body: JSON.stringify({ front, back })
|
|
}).catch(()=>null);
|
|
if (!card) return;
|
|
_cards.push(card);
|
|
document.getElementById('new-card-front').value = '';
|
|
document.getElementById('new-card-back').value = '';
|
|
document.getElementById('new-card-front').focus();
|
|
renderCardList();
|
|
}
|
|
|
|
function addCardOnEnter(e) {
|
|
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); addCard(); }
|
|
}
|
|
|
|
async function saveCard(id, field, value) {
|
|
const card = _cards.find(c => c.id === id);
|
|
if (!card) return;
|
|
card[field] = value;
|
|
await LS.api(`/api/flashcards/cards/${id}`, {
|
|
method: 'PUT', body: JSON.stringify({ [field]: value })
|
|
}).catch(()=>{});
|
|
}
|
|
|
|
async function deleteCard(id) {
|
|
if (!await LS.confirm('Удалить карточку?', { title: 'Удаление карточки', confirmText: 'Удалить', danger: true })) return;
|
|
await LS.api(`/api/flashcards/cards/${id}`, { method: 'DELETE' }).catch(()=>{});
|
|
_cards = _cards.filter(c => c.id !== id);
|
|
renderCardList();
|
|
}
|
|
|
|
/* ════ Bulk add ════ */
|
|
function openBulkModal() {
|
|
document.getElementById('bulk-text').value = '';
|
|
document.getElementById('modal-bulk').classList.add('open');
|
|
}
|
|
|
|
async function saveBulk() {
|
|
const text = document.getElementById('bulk-text').value.trim();
|
|
if (!text || !_curDeck) return;
|
|
const lines = text.split('\n').map(l => l.trim()).filter(Boolean);
|
|
const cards = lines.map(l => {
|
|
const sep = l.includes('—') ? '—' : '|';
|
|
const [front, ...rest] = l.split(sep);
|
|
return { front: (front||'').trim(), back: rest.join(sep).trim() };
|
|
}).filter(c => c.front);
|
|
if (!cards.length) return;
|
|
const result = await LS.api(`/api/flashcards/decks/${_curDeck.id}/cards/bulk`, {
|
|
method: 'POST', body: JSON.stringify({ cards })
|
|
}).catch(()=>null);
|
|
if (result?.inserted) {
|
|
_cards.push(...result.inserted);
|
|
renderCardList();
|
|
}
|
|
closeModal('modal-bulk');
|
|
}
|
|
|
|
/* ════ Study mode ════ */
|
|
let _studyCards = [];
|
|
let _studyIdx = 0;
|
|
let _studyFlipped = false;
|
|
let _sessionStats = { again: 0, hard: 0, good: 0, easy: 0 };
|
|
|
|
async function startStudy() {
|
|
if (!_curDeck) return;
|
|
await startStudyForDeck(_curDeck.id);
|
|
}
|
|
|
|
async function startStudyForDeck(deckId) {
|
|
_curDeck = _curDeck || _decks.find(d => d.id === deckId);
|
|
if (!_curDeck) return;
|
|
const data = await LS.api(`/api/flashcards/decks/${deckId}/study`).catch(()=>null);
|
|
if (!data || !data.cards?.length) {
|
|
LS.toast('Нет карточек для повторения — всё актуально!', 'success');
|
|
return;
|
|
}
|
|
_studyCards = data.cards;
|
|
_studyIdx = 0;
|
|
_studyFlipped = false;
|
|
_sessionStats = { again: 0, hard: 0, good: 0, easy: 0 };
|
|
document.getElementById('study-deck-title').textContent = _curDeck.title;
|
|
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);
|
|
_studyFlipped = false;
|
|
document.getElementById('study-btns').classList.remove('visible');
|
|
document.getElementById('study-flip-hint').style.display = 'block';
|
|
document.getElementById('ind-right').style.opacity = '0';
|
|
document.getElementById('ind-left').style.opacity = '0';
|
|
updateStudyProgress();
|
|
updateSQDays(card);
|
|
}
|
|
|
|
function updateStudyProgress() {
|
|
const total = _studyCards.length;
|
|
const done = _studyIdx;
|
|
document.getElementById('study-prog').style.width = (done / total * 100) + '%';
|
|
document.getElementById('study-counter').textContent = `${done + 1} / ${total}`;
|
|
}
|
|
|
|
function flipCard() {
|
|
if (_studyFlipped) return;
|
|
_studyFlipped = true;
|
|
document.getElementById('study-card').classList.add('flipped');
|
|
document.getElementById('study-btns').classList.add('visible');
|
|
document.getElementById('study-flip-hint').style.display = 'none';
|
|
}
|
|
|
|
async function answer(quality) {
|
|
const card = _studyCards[_studyIdx];
|
|
if (!card) return;
|
|
// track session stats
|
|
if (quality === 0) _sessionStats.again++;
|
|
else if (quality === 3) _sessionStats.hard++;
|
|
else if (quality === 4) _sessionStats.good++;
|
|
else if (quality === 5) _sessionStats.easy++;
|
|
// send review
|
|
await LS.api(`/api/flashcards/cards/${card.id}/review`, {
|
|
method: 'POST', body: JSON.stringify({ quality })
|
|
}).catch(()=>{});
|
|
// animate swipe
|
|
const el = document.getElementById('study-card');
|
|
el.classList.add(quality >= 3 ? 'swipe-right' : 'swipe-left');
|
|
setTimeout(() => {
|
|
_studyIdx++;
|
|
if (_studyIdx >= _studyCards.length) finishStudy();
|
|
else showStudyCard();
|
|
}, 380);
|
|
}
|
|
|
|
function finishStudy() {
|
|
document.getElementById('study-scene').style.display = 'none';
|
|
document.getElementById('study-btns').classList.remove('visible');
|
|
document.getElementById('study-flip-hint').style.display = 'none';
|
|
document.getElementById('study-done').style.display = 'block';
|
|
const s = _sessionStats;
|
|
const total = s.again + s.hard + s.good + s.easy;
|
|
document.getElementById('study-done-sub').textContent =
|
|
`Просмотрено ${total} карточек`;
|
|
document.getElementById('study-session-stats').innerHTML = [
|
|
{ n: s.again, l: 'Снова', c: '#DC2626' },
|
|
{ n: s.hard, l: 'Трудно', c: '#D97706' },
|
|
{ n: s.good, l: 'Знаю', c: '#2563EB' },
|
|
{ n: s.easy, l: 'Легко', c: '#16A34A' },
|
|
].map(x => `<div class="ss-stat">
|
|
<div class="ss-stat-n" style="color:${x.c}">${x.n}</div>
|
|
<div class="ss-stat-l">${x.l}</div>
|
|
</div>`).join('');
|
|
}
|
|
|
|
/* ── estimated next interval preview for sq buttons ──
|
|
ВАЖНО: точная копия серверного sm2() (flashcardController.js), иначе
|
|
превью врёт. В чистом SM-2 интервал для q>=3 НЕ зависит от значения q
|
|
(q влияет только на ease factor), поэтому «Трудно/Знаю/Легко» при первых
|
|
повторениях дают одинаковый интервал — это корректно.
|
|
(Дифференциация интервалов по кнопкам — кандидат на Фазу 4.) */
|
|
function fcNextInterval(card, q) {
|
|
const ef = card.ease_factor || 2.5;
|
|
const iv = card.interval_days || 1;
|
|
const rep = card.repetitions || 0;
|
|
if (q < 3) return 1;
|
|
if (rep === 0) return 1;
|
|
if (rep === 1) return 6;
|
|
return Math.round(iv * ef);
|
|
}
|
|
function fcDaysLabel(n) {
|
|
if (n <= 1) return '1 день';
|
|
if (n < 5) return n + ' дня';
|
|
return n + ' дн.';
|
|
}
|
|
function updateSQDays(card) {
|
|
document.getElementById('sq-days-0').textContent = fcDaysLabel(fcNextInterval(card, 0));
|
|
document.getElementById('sq-days-3').textContent = fcDaysLabel(fcNextInterval(card, 3));
|
|
document.getElementById('sq-days-4').textContent = fcDaysLabel(fcNextInterval(card, 4));
|
|
document.getElementById('sq-days-5').textContent = fcDaysLabel(fcNextInterval(card, 5));
|
|
}
|
|
|
|
/* ── touch/mouse swipe ── */
|
|
function bindSwipe() {
|
|
const scene = document.getElementById('study-scene');
|
|
let startX = null, curX = null;
|
|
const THRESHOLD = 80;
|
|
|
|
const onStart = e => { startX = (e.touches ? e.touches[0] : e).clientX; curX = startX; };
|
|
const onMove = e => {
|
|
if (startX === null) return;
|
|
curX = (e.touches ? e.touches[0] : e).clientX;
|
|
const dx = curX - startX;
|
|
const card = document.getElementById('study-card');
|
|
if (Math.abs(dx) < 10) return;
|
|
card.style.transform = `rotate(${dx * 0.04}deg) translateX(${dx * 0.3}px)`;
|
|
document.getElementById('ind-right').style.opacity = dx > 30 ? Math.min(1, (dx - 30) / 50) : '0';
|
|
document.getElementById('ind-left').style.opacity = dx < -30 ? Math.min(1, (-dx - 30) / 50) : '0';
|
|
if (e.cancelable) e.preventDefault();
|
|
};
|
|
const onEnd = () => {
|
|
if (startX === null) return;
|
|
const dx = curX - startX;
|
|
const card = document.getElementById('study-card');
|
|
card.style.transform = '';
|
|
if (dx > THRESHOLD && _studyFlipped) answer(5);
|
|
else if (dx < -THRESHOLD) { if (!_studyFlipped) flipCard(); else answer(0); }
|
|
startX = null; curX = null;
|
|
document.getElementById('ind-right').style.opacity = '0';
|
|
document.getElementById('ind-left').style.opacity = '0';
|
|
};
|
|
scene.addEventListener('mousedown', onStart);
|
|
scene.addEventListener('mousemove', onMove);
|
|
scene.addEventListener('mouseup', onEnd);
|
|
scene.addEventListener('mouseleave', onEnd);
|
|
scene.addEventListener('touchstart', onStart, { passive: true });
|
|
scene.addEventListener('touchmove', onMove, { passive: false });
|
|
scene.addEventListener('touchend', onEnd);
|
|
}
|
|
|
|
/* ════ Deck modals ════ */
|
|
function buildColorPicker() {
|
|
document.getElementById('color-picker').innerHTML = COLORS.map(c =>
|
|
`<div class="cp-swatch${c===_deckColor?' active':''}" style="background:${c}"
|
|
onclick="selectColor('${c}',this)" data-color="${c}"></div>`
|
|
).join('');
|
|
}
|
|
|
|
function selectColor(color, el) {
|
|
_deckColor = color;
|
|
document.querySelectorAll('.cp-swatch').forEach(s => s.classList.remove('active'));
|
|
el.classList.add('active');
|
|
}
|
|
|
|
function openNewDeckModal() {
|
|
_editingDeckId = null;
|
|
document.getElementById('modal-deck-title').textContent = 'Новая колода';
|
|
document.getElementById('modal-deck-name').value = '';
|
|
document.getElementById('modal-deck-desc').value = '';
|
|
_deckColor = '#9B5DE5';
|
|
buildColorPicker();
|
|
document.getElementById('modal-deck').classList.add('open');
|
|
setTimeout(() => document.getElementById('modal-deck-name').focus(), 50);
|
|
}
|
|
|
|
function openEditDeckModal() {
|
|
if (!_curDeck) return;
|
|
_editingDeckId = _curDeck.id;
|
|
document.getElementById('modal-deck-title').textContent = 'Редактировать колоду';
|
|
document.getElementById('modal-deck-name').value = _curDeck.title;
|
|
document.getElementById('modal-deck-desc').value = _curDeck.description || '';
|
|
_deckColor = _curDeck.color || '#9B5DE5';
|
|
buildColorPicker();
|
|
document.getElementById('modal-deck').classList.add('open');
|
|
}
|
|
|
|
async function saveDeckModal() {
|
|
const title = document.getElementById('modal-deck-name').value.trim();
|
|
const desc = document.getElementById('modal-deck-desc').value.trim();
|
|
if (!title) { document.getElementById('modal-deck-name').focus(); return; }
|
|
if (_editingDeckId) {
|
|
await LS.api(`/api/flashcards/decks/${_editingDeckId}`, {
|
|
method: 'PUT', body: JSON.stringify({ title, description: desc, color: _deckColor })
|
|
}).catch(()=>{});
|
|
_curDeck.title = title; _curDeck.description = desc; _curDeck.color = _deckColor;
|
|
document.getElementById('cards-deck-title').textContent = title;
|
|
const d = _decks.find(x => x.id === _editingDeckId);
|
|
if (d) { d.title = title; d.description = desc; d.color = _deckColor; }
|
|
} else {
|
|
const deck = await LS.api('/api/flashcards/decks', {
|
|
method: 'POST', body: JSON.stringify({ title, description: desc, color: _deckColor })
|
|
}).catch(()=>null);
|
|
if (deck) { _decks.unshift(deck); renderDecks(); }
|
|
}
|
|
closeModal('modal-deck');
|
|
}
|
|
|
|
async function confirmDeleteDeck() {
|
|
if (!_curDeck) return;
|
|
if (!await LS.confirm(`Удалить колоду «${_curDeck.title}» и все карточки?`, { title: 'Удаление колоды', confirmText: 'Удалить', danger: true })) return;
|
|
await LS.api(`/api/flashcards/decks/${_curDeck.id}`, { method: 'DELETE' }).catch(()=>{});
|
|
_decks = _decks.filter(d => d.id !== _curDeck.id);
|
|
_curDeck = null; _cards = [];
|
|
showDecks();
|
|
}
|
|
|
|
function closeModal(id) { document.getElementById(id).classList.remove('open'); }
|
|
|
|
|
|
</script>
|
|
<script src="/js/mobile.js"></script>
|
|
</body>
|
|
</html>
|