diff --git a/backend/src/controllers/studentMaterialsController.js b/backend/src/controllers/studentMaterialsController.js index 69f674c..a7cca86 100644 --- a/backend/src/controllers/studentMaterialsController.js +++ b/backend/src/controllers/studentMaterialsController.js @@ -45,6 +45,22 @@ function create(req, res) { res.status(201).json({ id: Number(r.lastInsertRowid) }); } +/* PATCH /api/materials/:id — rename / edit one of the current user's items. + Editable: title, body. (collection_id/tags wired in a later phase.) */ +function update(req, res) { + const row = db.prepare('SELECT user_id FROM student_materials WHERE id = ?').get(req.params.id); + if (!row) return res.status(404).json({ error: 'not found' }); + if (row.user_id !== req.user.id) return res.status(403).json({ error: 'forbidden' }); + const b = req.body || {}; + const fields = [], args = []; + if (b.title !== undefined) { fields.push('title = ?'); args.push(String(b.title || '').slice(0, 300)); } + if (b.body !== undefined) { fields.push('body = ?'); args.push(b.body != null ? String(b.body).slice(0, 60000) : null); } + if (!fields.length) return res.json({ ok: true }); + args.push(req.params.id); + db.prepare(`UPDATE student_materials SET ${fields.join(', ')} WHERE id = ?`).run(...args); + res.json({ ok: true }); +} + /* DELETE /api/materials/:id — remove one of the current user's items */ function remove(req, res) { const row = db.prepare('SELECT user_id FROM student_materials WHERE id = ?').get(req.params.id); @@ -54,4 +70,4 @@ function remove(req, res) { res.json({ ok: true }); } -module.exports = { list, create, remove }; +module.exports = { list, create, update, remove }; diff --git a/backend/src/routes/materials.js b/backend/src/routes/materials.js index 3297d32..e728a97 100644 --- a/backend/src/routes/materials.js +++ b/backend/src/routes/materials.js @@ -9,6 +9,8 @@ router.use(authMiddleware); router.get('/', c.list); router.post('/', c.create); // @public-by-design: router-level authMiddleware (above) + per-row ownership check in handler +router.patch('/:id', c.update); +// @public-by-design: router-level authMiddleware (above) + per-row ownership check in handler router.delete('/:id', c.remove); module.exports = router; diff --git a/frontend/my-materials.html b/frontend/my-materials.html index 06178a8..0d71952 100644 --- a/frontend/my-materials.html +++ b/frontend/my-materials.html @@ -39,6 +39,7 @@
Мои материалы +
Сохранённые с уроков: страницы доски, заметки и вложения. Хранятся у вас и не пропадают, даже если урок удалят.
Загрузка…
@@ -61,11 +62,13 @@ } const KIND_LABEL = { board: 'Доска', note: 'Заметка', link: 'Ссылка', image: 'Изображение' }; + let _mats = []; function card(m) { const kind = KIND_LABEL[m.kind] || m.kind; const meta = `${esc(m.source_title || '')}${m.source_title ? ' · ' : ''}${fmtDate(m.created_at)}`; - const del = ``; + const del = ``; + const edit = ``; if (m.kind === 'board' || m.kind === 'image') { return `
${kind} @@ -76,7 +79,7 @@
Открыть - ${del} + ${edit}${del}
`; @@ -88,7 +91,7 @@
${esc(m.title || kind)}
${meta}
-
${del}
+
${edit}${del}
`; } @@ -108,6 +111,7 @@ const grid = document.getElementById('mm-grid'); try { const { materials } = await LS.listMaterials(); + _mats = materials || []; if (!materials || !materials.length) { grid.innerHTML = `
@@ -130,6 +134,45 @@ } window.delMaterial = delMaterial; + const FLD = 'padding:9px 12px;border:1px solid var(--border);border-radius:9px;font:inherit;width:100%;box-sizing:border-box'; + function createNote() { + const content = `
+ + +
`; + const m = LS.modal({ title: 'Новая заметка', content, size: 'sm', actions: [ + { label: 'Отмена', onClick: () => m.close() }, + { label: 'Создать', primary: true, onClick: async () => { + const title = m.body.querySelector('#mm-nt-title').value.trim(); + const text = m.body.querySelector('#mm-nt-body').value.trim(); + if (!text && !title) { LS.toast('Введите текст заметки', 'warn'); return; } + try { await LS.saveMaterial({ kind: 'note', title, body: text }); m.close(); load(); } + catch (e) { LS.toast(e.message || 'Ошибка', 'error'); } + } }, + ] }); + } + window.createNote = createNote; + + function editMaterial(id) { + const mt = _mats.find(x => x.id === id); + if (!mt) return; + const isNote = mt.kind === 'note'; + const content = `
+ + ${isNote ? `` : ''} +
`; + const m = LS.modal({ title: 'Изменить', content, size: 'sm', actions: [ + { label: 'Отмена', onClick: () => m.close() }, + { label: 'Сохранить', primary: true, onClick: async () => { + const data = { title: m.body.querySelector('#mm-ed-title').value.trim() }; + if (isNote) data.body = m.body.querySelector('#mm-ed-body').value; + try { await LS.updateMaterial(id, data); m.close(); load(); } + catch (e) { LS.toast(e.message || 'Ошибка', 'error'); } + } }, + ] }); + } + window.editMaterial = editMaterial; + load(); diff --git a/js/api.js b/js/api.js index 762abb4..3bcddbc 100644 --- a/js/api.js +++ b/js/api.js @@ -1048,7 +1048,7 @@ window.LS = { crJoin, crLeave, crSendChat, crGetChat, crGetAttendance, crSignal, crGetOnlineStudents, crGetMySession, crGetMyHistory, crGetClassHistory, crGetSessionSummary, crExportChatUrl, crGetAllNotes, crDeleteHistory, crAdminGetAllHistory, crAdminGetTeachersList, - listMaterials, saveMaterial, deleteMaterial, + listMaterials, saveMaterial, updateMaterial, deleteMaterial, escapeHtml, esc, parseDate, fmtRelTime, safeHref, initPage, @@ -1243,9 +1243,10 @@ async function uploadFile(formData) { return data; } function downloadFileUrl(id) { return `${API}/files/${id}/download`; } -async function listMaterials() { return req('GET', '/materials'); } -async function saveMaterial(data) { return req('POST', '/materials', data); } -async function deleteMaterial(id) { return req('DELETE', `/materials/${id}`); } +async function listMaterials() { return req('GET', '/materials'); } +async function saveMaterial(data) { return req('POST', '/materials', data); } +async function updateMaterial(id, d) { return req('PATCH', `/materials/${id}`, d); } +async function deleteMaterial(id) { return req('DELETE', `/materials/${id}`); } async function deleteFile(id) { return req('DELETE', `/files/${id}`); } async function getFileAccess(id) { return req('GET', `/files/${id}/access`); } async function assignFile(id, data) { return req('POST', `/files/${id}/assign`, data); } diff --git a/plans/my-materials/PLAN.md b/plans/my-materials/PLAN.md new file mode 100644 index 0000000..a5c6837 --- /dev/null +++ b/plans/my-materials/PLAN.md @@ -0,0 +1,89 @@ +# План развития «Мои материалы» + +> Составлен Opus 2026-06-04. Базис уже есть: `student_materials(kind board|note|link|image, +> title, body, url, source_session_id, source_title, created_at)`, API `/api/materials` +> (GET/POST/DELETE) + `LS.listMaterials/saveMaterial/deleteMaterial`, страница `/my-materials`, +> модуль `/js/board-clip.js` (сохранение страницы/фрагмента доски), сохранение из `my-lessons.html` +> и живого урока `classroom.html`. См. [[reference_student_materials]]. + +Следующая миграция: **061**. Готчи: новый `:id`-роут → пометить `// @public-by-design` (route-auth lint); +большие HTML-файлы — только Edit; без эмодзи; коммит поимённо + push; перезапуск сервера при правке backend. + +--- + +## Фаза 1 — Переименование, правка, создание заметки (S, фундамент) +Базовые операции над материалом + личный блокнот. +- **Backend:** `PATCH /api/materials/:id` (поля: title, body, collection_id, tags) с проверкой владельца; + пометить `@public-by-design`. Хелпер `LS.updateMaterial(id, data)`. +- **Frontend `/my-materials`:** инлайн-правка заголовка; модалка правки текста заметки; кнопка + **«+ Заметка»** (создать заметку с нуля → POST kind='note'). Карточкам — меню (правка/удалить). +- Зависимостей нет. Делается первым. + +## Фаза 2 — Коллекции (папки) + поиск/фильтры (M, организация) +Решение: **коллекции (папки)** как основной примитив + свободный поиск + фильтр по типу. +Теги — опциональный стретч (поле `tags TEXT`, чипы), если понадобится. +- **Миграция 061:** `material_collections(id, user_id, name, color, sort_order, created_at)` + + `ALTER student_materials ADD COLUMN collection_id INTEGER REFERENCES material_collections(id) ON DELETE SET NULL` + (+ опц. `ADD COLUMN tags TEXT`). +- **Backend:** CRUD коллекций `GET/POST/PATCH/DELETE /api/materials/collections`; перенос материала — + через `PATCH /api/materials/:id {collection_id}`; список `GET /api/materials?collection=ID`. +- **Frontend:** слева/сверху список коллекций (создать/переименовать/цвет/удалить), «Переместить в…» + на карточке, фильтр по коллекции + поиск по тексту + фильтр по типу (клиент, данных немного). +- Делается после Ф1 (нужна перед «универсальным буфером», иначе всё свалится в одну кучу). + +## Фаза 3 — Универсальный буфер «Сохранить к себе» ⭐ (M–L, стратегически) +Кнопка «В мои материалы» по всей платформе. Общий клиентский модуль + поэтапная разводка по источникам. +- **Модуль `/js/material-save.js`:** `MaterialSave.note({title,body,...})`, `.link({title,url})`, + `.image({title,blobOrUrl})`, и `MaterialSave.button(opts)` (готовая кнопка). Поверх `LS.saveMaterial`. + Везде проставлять `source_title` (откуда сохранено). +- **Источники (по убыванию ценности, разводить инкрементально):** + 1. Учебник (`/textbook/...`): «В мои материалы» на § → kind='link' (`/textbook/slug#sec-pN`), на рисунок/ + выделенный фрагмент → kind='image'/'note'. + 2. Экзамен (exam-prep): сохранить задачу+решение → kind='note' (html в body) или 'link' на задачу. + 3. Лаборатория/симуляция, флешкарты, вложения чата урока → link/image. +- **Kinds:** переиспользуем существующие (note/link/image), чтобы не трогать CHECK. Новый kind — только + при явной нужде (тогда миграция-пересборка CHECK). +- Делать: модуль + 2–3 топ-источника (учебник, экзамен, ссылка) в первой итерации; остальное — добивать. + +## Фаза 4 — Редактирование и аннотации (M, синергия с SVG-рисовалкой) +Рисовать поверх сохранённого изображения и создавать рисунки. +- **`/js/svg-draw.js`:** добавить `opts.bgImage` (рисунок-подложка) + при экспорте компоновать + подложку + вектор в один PNG (offscreen canvas). +- **Frontend `/my-materials`:** на image-карточке «Аннотировать» → модалка SvgDraw(bgImage=url) → + сохранить как НОВЫЙ материал (kind='image'). Кнопка «Создать рисунок» (пустой холст → image). +- Подпись/комментарий к материалу — уже покрывается правкой title/body из Ф1. +- Зависит от Ф1 (правка) и существующих svg-draw.js/svg-sanitize.js. + +## Фаза 5 — Учебная интеграция (M) +Превратить материал в инструмент повторения. +- **«В флешкарты»:** из заметки создать карточку (front=title, back=body) в выбранной/личной колоде — + через существующий API флешкард (decks/cards). Из image — карточка с картинкой. +- **«Учить из материалов»:** лёгкий режим перелистывания заметок/карточек. +- (Полноценный SRS — отдельная инициатива из экзамена; здесь не блокер.) +- Зависит от API флешкард (проверить контракт). + +## Фаза 6 — Учитель: своя коллекция + «раздатка» (S + M–L) +- **6a (S): своя коллекция учителю.** Подключить `board-clip.js` в `lesson-history.html` (страница + учителя) → кнопки «Область»/«К себе» учителю. Раздел «Мои материалы» уже user-agnostic (показать + пункт сайдбара и учителю). Быстрый вин, можно сделать рано. +- **6b (M–L): «Раздатка» — отправить материал ученику/классу.** + - Модель: **копия при отправке** (проще и согласуется с «у каждого своя копия»): `POST /api/materials/:id/share + {classId|userId}` → вставляет копию в `student_materials` получателям (source_title='Раздатка: <учитель>'). + Без новой таблицы шеринга. (Альтернатива — таблица `material_shares` со ссылкой; копия проще.) + - Уведомление получателям через существующую систему (`emit`/notifications). + - Frontend: на материале учителя «Раздать» → выбор класса/ученика. + +--- + +## Рекомендованный порядок +**1 → 2 → 3 → 6a (быстрый вин) → 4 → 5 → 6b.** +Ф1 (правка/заметки) и Ф2 (коллекции+поиск) — фундамент; Ф3 (универсальный буфер) — главный стратегический +шаг, но после Ф2 (иначе захламит); 6a дешёвый — можно вклинить рано; 4/5 — обогащение; 6b (раздатка) — крупное, в конце. + +## Сквозные риски / заметки +- **Хранилище:** изображения идут в `/api/files`; универсальный буфер + раздатка-копии множат файлы — + заложить (позже) учёт/лимиты/чистку. +- **Route-auth lint:** новые `:id`-роуты (PATCH, share) пометить `@public-by-design` + проверка владельца. +- **CHECK kind:** держимся в note/link/image, чтобы не пересобирать таблицу. +- **Тесты:** добавить `materials.test.js` (CRUD + collections + share-копия + проверка владельца). +- **Разрешения:** create-note/правка — любой авторизованный (свои); раздатка — teacher/admin.