feat(materials): Фаза 1 — правка, переименование, создание заметки
- PATCH /api/materials/:id (title, body) с проверкой владельца (@public-by-design) + LS.updateMaterial. - /my-materials: кнопка «+ Заметка» (личный блокнот с нуля), «Изменить» на карточках (заголовок; для заметок — и текст) через LS.modal. - Добавлен план развития «Мои материалы»: plans/my-materials/PLAN.md (6 фаз). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -45,6 +45,22 @@ function create(req, res) {
|
|||||||
res.status(201).json({ id: Number(r.lastInsertRowid) });
|
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 */
|
/* DELETE /api/materials/:id — remove one of the current user's items */
|
||||||
function remove(req, res) {
|
function remove(req, res) {
|
||||||
const row = db.prepare('SELECT user_id FROM student_materials WHERE id = ?').get(req.params.id);
|
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 });
|
res.json({ ok: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { list, create, remove };
|
module.exports = { list, create, update, remove };
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ router.use(authMiddleware);
|
|||||||
router.get('/', c.list);
|
router.get('/', c.list);
|
||||||
router.post('/', c.create);
|
router.post('/', c.create);
|
||||||
// @public-by-design: router-level authMiddleware (above) + per-row ownership check in handler
|
// @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);
|
router.delete('/:id', c.remove);
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
|
|||||||
@@ -39,6 +39,7 @@
|
|||||||
<div class="mm-main">
|
<div class="mm-main">
|
||||||
<div class="mm-head">
|
<div class="mm-head">
|
||||||
<span class="mm-title">Мои материалы</span>
|
<span class="mm-title">Мои материалы</span>
|
||||||
|
<button class="mm-btn" style="margin-left:auto" onclick="createNote()"><i data-lucide="plus"></i> Заметка</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="mm-sub">Сохранённые с уроков: страницы доски, заметки и вложения. Хранятся у вас и не пропадают, даже если урок удалят.</div>
|
<div class="mm-sub">Сохранённые с уроков: страницы доски, заметки и вложения. Хранятся у вас и не пропадают, даже если урок удалят.</div>
|
||||||
<div class="mm-grid" id="mm-grid"><div class="mm-empty">Загрузка…</div></div>
|
<div class="mm-grid" id="mm-grid"><div class="mm-empty">Загрузка…</div></div>
|
||||||
@@ -61,11 +62,13 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
const KIND_LABEL = { board: 'Доска', note: 'Заметка', link: 'Ссылка', image: 'Изображение' };
|
const KIND_LABEL = { board: 'Доска', note: 'Заметка', link: 'Ссылка', image: 'Изображение' };
|
||||||
|
let _mats = [];
|
||||||
|
|
||||||
function card(m) {
|
function card(m) {
|
||||||
const kind = KIND_LABEL[m.kind] || m.kind;
|
const kind = KIND_LABEL[m.kind] || m.kind;
|
||||||
const meta = `${esc(m.source_title || '')}${m.source_title ? ' · ' : ''}${fmtDate(m.created_at)}`;
|
const meta = `${esc(m.source_title || '')}${m.source_title ? ' · ' : ''}${fmtDate(m.created_at)}`;
|
||||||
const del = `<button class="mm-btn danger" onclick="delMaterial(${m.id})"><i data-lucide="trash-2"></i> Удалить</button>`;
|
const del = `<button class="mm-btn danger" onclick="delMaterial(${m.id})"><i data-lucide="trash-2"></i> Удалить</button>`;
|
||||||
|
const edit = `<button class="mm-btn" onclick="editMaterial(${m.id})" title="Изменить"><i data-lucide="pencil"></i></button>`;
|
||||||
if (m.kind === 'board' || m.kind === 'image') {
|
if (m.kind === 'board' || m.kind === 'image') {
|
||||||
return `<div class="mm-card">
|
return `<div class="mm-card">
|
||||||
<span class="mm-kind">${kind}</span>
|
<span class="mm-kind">${kind}</span>
|
||||||
@@ -76,7 +79,7 @@
|
|||||||
<div class="mm-card-actions">
|
<div class="mm-card-actions">
|
||||||
<a class="mm-btn" href="${esc(m.url)}" target="_blank" rel="noopener"><i data-lucide="external-link"></i> Открыть</a>
|
<a class="mm-btn" href="${esc(m.url)}" target="_blank" rel="noopener"><i data-lucide="external-link"></i> Открыть</a>
|
||||||
<a class="mm-btn" href="${esc(m.url)}" download><i data-lucide="download"></i></a>
|
<a class="mm-btn" href="${esc(m.url)}" download><i data-lucide="download"></i></a>
|
||||||
${del}
|
${edit}${del}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
@@ -88,7 +91,7 @@
|
|||||||
<div class="mm-card-body">
|
<div class="mm-card-body">
|
||||||
<div class="mm-card-title">${esc(m.title || kind)}</div>
|
<div class="mm-card-title">${esc(m.title || kind)}</div>
|
||||||
<div class="mm-card-meta">${meta}</div>
|
<div class="mm-card-meta">${meta}</div>
|
||||||
<div class="mm-card-actions">${del}</div>
|
<div class="mm-card-actions">${edit}${del}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
@@ -108,6 +111,7 @@
|
|||||||
const grid = document.getElementById('mm-grid');
|
const grid = document.getElementById('mm-grid');
|
||||||
try {
|
try {
|
||||||
const { materials } = await LS.listMaterials();
|
const { materials } = await LS.listMaterials();
|
||||||
|
_mats = materials || [];
|
||||||
if (!materials || !materials.length) {
|
if (!materials || !materials.length) {
|
||||||
grid.innerHTML = `<div class="mm-empty" style="grid-column:1/-1">
|
grid.innerHTML = `<div class="mm-empty" style="grid-column:1/-1">
|
||||||
<i data-lucide="folder-open"></i>
|
<i data-lucide="folder-open"></i>
|
||||||
@@ -130,6 +134,45 @@
|
|||||||
}
|
}
|
||||||
window.delMaterial = delMaterial;
|
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 = `<div style="display:flex;flex-direction:column;gap:10px">
|
||||||
|
<input id="mm-nt-title" placeholder="Заголовок (необязательно)" style="${FLD}" />
|
||||||
|
<textarea id="mm-nt-body" rows="7" placeholder="Текст заметки…" style="${FLD};resize:vertical"></textarea>
|
||||||
|
</div>`;
|
||||||
|
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 = `<div style="display:flex;flex-direction:column;gap:10px">
|
||||||
|
<input id="mm-ed-title" value="${esc(mt.title || '')}" placeholder="Заголовок" style="${FLD}" />
|
||||||
|
${isNote ? `<textarea id="mm-ed-body" rows="7" style="${FLD};resize:vertical">${esc(mt.body || '')}</textarea>` : ''}
|
||||||
|
</div>`;
|
||||||
|
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();
|
load();
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -1048,7 +1048,7 @@ window.LS = {
|
|||||||
crJoin, crLeave, crSendChat, crGetChat, crGetAttendance, crSignal, crGetOnlineStudents, crGetMySession,
|
crJoin, crLeave, crSendChat, crGetChat, crGetAttendance, crSignal, crGetOnlineStudents, crGetMySession,
|
||||||
crGetMyHistory, crGetClassHistory, crGetSessionSummary, crExportChatUrl, crGetAllNotes, crDeleteHistory,
|
crGetMyHistory, crGetClassHistory, crGetSessionSummary, crExportChatUrl, crGetAllNotes, crDeleteHistory,
|
||||||
crAdminGetAllHistory, crAdminGetTeachersList,
|
crAdminGetAllHistory, crAdminGetTeachersList,
|
||||||
listMaterials, saveMaterial, deleteMaterial,
|
listMaterials, saveMaterial, updateMaterial, deleteMaterial,
|
||||||
escapeHtml, esc,
|
escapeHtml, esc,
|
||||||
parseDate, fmtRelTime, safeHref,
|
parseDate, fmtRelTime, safeHref,
|
||||||
initPage,
|
initPage,
|
||||||
@@ -1243,9 +1243,10 @@ async function uploadFile(formData) {
|
|||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
function downloadFileUrl(id) { return `${API}/files/${id}/download`; }
|
function downloadFileUrl(id) { return `${API}/files/${id}/download`; }
|
||||||
async function listMaterials() { return req('GET', '/materials'); }
|
async function listMaterials() { return req('GET', '/materials'); }
|
||||||
async function saveMaterial(data) { return req('POST', '/materials', data); }
|
async function saveMaterial(data) { return req('POST', '/materials', data); }
|
||||||
async function deleteMaterial(id) { return req('DELETE', `/materials/${id}`); }
|
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 deleteFile(id) { return req('DELETE', `/files/${id}`); }
|
||||||
async function getFileAccess(id) { return req('GET', `/files/${id}/access`); }
|
async function getFileAccess(id) { return req('GET', `/files/${id}/access`); }
|
||||||
async function assignFile(id, data) { return req('POST', `/files/${id}/assign`, data); }
|
async function assignFile(id, data) { return req('POST', `/files/${id}/assign`, data); }
|
||||||
|
|||||||
@@ -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.
|
||||||
Reference in New Issue
Block a user