feat(imggen): фон питомца, обложки курсов, аватары и доска через ИИ
Питомец: кастомный фон (миграция 068 pet_bg_custom, POST /api/pet/bg/custom, карточка «Свой фон (ИИ)» в гардеробной, применение картинкой). Курсы: обложка-картинка (миграция 069 cover_image, генерация в модалке редактирования, рендер вместо эмодзи). Аватар: кнопка «Сгенерировать (ИИ)» в загрузке → кадрирование → модерация. Доска (classroom): кнопка-инструмент «Сгенерировать картинку (ИИ)». Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -23,6 +23,7 @@ function courseRow(row) {
|
|||||||
title: row.title,
|
title: row.title,
|
||||||
description: row.description || '',
|
description: row.description || '',
|
||||||
coverEmoji: row.cover_emoji,
|
coverEmoji: row.cover_emoji,
|
||||||
|
coverImage: row.cover_image || null,
|
||||||
orderIndex: row.order_index,
|
orderIndex: row.order_index,
|
||||||
isPublished: row.is_published === 1,
|
isPublished: row.is_published === 1,
|
||||||
createdBy: row.created_by,
|
createdBy: row.created_by,
|
||||||
@@ -403,14 +404,15 @@ function duplicate(req, res) {
|
|||||||
function update(req, res) {
|
function update(req, res) {
|
||||||
const row = db.prepare('SELECT * FROM courses WHERE id = ?').get(req.params.id);
|
const row = db.prepare('SELECT * FROM courses WHERE id = ?').get(req.params.id);
|
||||||
if (!row) return res.status(404).json({ error: 'Course not found' });
|
if (!row) return res.status(404).json({ error: 'Course not found' });
|
||||||
const { title, description, coverEmoji, orderIndex, isPublished, subjectSlug } = req.body;
|
const { title, description, coverEmoji, coverImage, orderIndex, isPublished, subjectSlug } = req.body;
|
||||||
|
|
||||||
db.prepare(`
|
db.prepare(`
|
||||||
UPDATE courses SET title=?,description=?,cover_emoji=?,order_index=?,is_published=?,subject_slug=? WHERE id=?
|
UPDATE courses SET title=?,description=?,cover_emoji=?,cover_image=?,order_index=?,is_published=?,subject_slug=? WHERE id=?
|
||||||
`).run(
|
`).run(
|
||||||
title ?? row.title,
|
title ?? row.title,
|
||||||
description !== undefined ? description : row.description,
|
description !== undefined ? description : row.description,
|
||||||
coverEmoji ?? row.cover_emoji,
|
coverEmoji ?? row.cover_emoji,
|
||||||
|
coverImage !== undefined ? (coverImage || null) : row.cover_image,
|
||||||
orderIndex ?? row.order_index,
|
orderIndex ?? row.order_index,
|
||||||
isPublished !== undefined ? (isPublished ? 1 : 0) : row.is_published,
|
isPublished !== undefined ? (isPublished ? 1 : 0) : row.is_published,
|
||||||
subjectSlug ?? row.subject_slug,
|
subjectSlug ?? row.subject_slug,
|
||||||
|
|||||||
@@ -178,7 +178,7 @@ function getPet(req, res) {
|
|||||||
const user = db.prepare(
|
const user = db.prepare(
|
||||||
`SELECT xp, level, streak_current, streak_best, streak_date, coins,
|
`SELECT xp, level, streak_current, streak_best, streak_date, coins,
|
||||||
pet_name, last_login, pet_color, pet_last_petted, pet_petting_streak,
|
pet_name, last_login, pet_color, pet_last_petted, pet_petting_streak,
|
||||||
pet_bg, pet_bg_owned, pet_last_fed, pet_equipped, pet_pattern
|
pet_bg, pet_bg_owned, pet_last_fed, pet_equipped, pet_pattern, pet_bg_custom
|
||||||
FROM users WHERE id = ?`
|
FROM users WHERE id = ?`
|
||||||
).get(req.user.id);
|
).get(req.user.id);
|
||||||
|
|
||||||
@@ -277,6 +277,7 @@ function getPet(req, res) {
|
|||||||
feedCooldown,
|
feedCooldown,
|
||||||
petBg: user.pet_bg || 'default',
|
petBg: user.pet_bg || 'default',
|
||||||
petBgOwned: _parseOwned(user.pet_bg_owned),
|
petBgOwned: _parseOwned(user.pet_bg_owned),
|
||||||
|
petBgCustom: user.pet_bg_custom || null,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -391,7 +392,10 @@ function buyBg(req, res) {
|
|||||||
/* ── PATCH /api/pet/bg ────────────────────────────────────────────────── */
|
/* ── PATCH /api/pet/bg ────────────────────────────────────────────────── */
|
||||||
function setBg(req, res) {
|
function setBg(req, res) {
|
||||||
const { id } = req.body;
|
const { id } = req.body;
|
||||||
if (id !== 'default') {
|
if (id === 'custom') {
|
||||||
|
const url = db.prepare('SELECT pet_bg_custom FROM users WHERE id=?').get(req.user.id)?.pet_bg_custom;
|
||||||
|
if (!url) return res.status(400).json({ error: 'no_custom_bg' });
|
||||||
|
} else if (id !== 'default') {
|
||||||
const owned = _parseOwned(db.prepare('SELECT pet_bg_owned FROM users WHERE id=?').get(req.user.id)?.pet_bg_owned);
|
const owned = _parseOwned(db.prepare('SELECT pet_bg_owned FROM users WHERE id=?').get(req.user.id)?.pet_bg_owned);
|
||||||
if (!owned.includes(id)) return res.status(403).json({ error: 'not owned' });
|
if (!owned.includes(id)) return res.status(403).json({ error: 'not owned' });
|
||||||
}
|
}
|
||||||
@@ -399,6 +403,18 @@ function setBg(req, res) {
|
|||||||
res.json({ ok: true, bg: id });
|
res.json({ ok: true, bg: id });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── POST /api/pet/bg/custom ──────────────────────────────────────────────
|
||||||
|
Сохранить сгенерированную ИИ картинку как кастомный фон и сделать активной.
|
||||||
|
URL принимается только из /uploads/generated/ (то, что отдаёт /api/imggen). */
|
||||||
|
function setCustomBg(req, res) {
|
||||||
|
const url = String(req.body && req.body.url || '');
|
||||||
|
if (!/^\/uploads\/generated\/[A-Za-z0-9._-]+\.(png|jpg|jpeg|webp)$/.test(url)) {
|
||||||
|
return res.status(400).json({ error: 'invalid_url' });
|
||||||
|
}
|
||||||
|
db.prepare('UPDATE users SET pet_bg_custom=?, pet_bg=? WHERE id=?').run(url, 'custom', req.user.id);
|
||||||
|
res.json({ ok: true, bg: 'custom', url });
|
||||||
|
}
|
||||||
|
|
||||||
/* ── POST /api/pet/star ───────────────────────────────────────────────── */
|
/* ── POST /api/pet/star ───────────────────────────────────────────────── */
|
||||||
function starCatch(req, res) {
|
function starCatch(req, res) {
|
||||||
const user = db.prepare('SELECT pet_last_star FROM users WHERE id=?').get(req.user.id);
|
const user = db.prepare('SELECT pet_last_star FROM users WHERE id=?').get(req.user.id);
|
||||||
@@ -436,4 +452,4 @@ function feedPet(req, res) {
|
|||||||
res.json({ ok: true, xpAwarded: 15, xp: updated.xp, coins: updated.coins });
|
res.json({ ok: true, xpAwarded: 15, xp: updated.xp, coins: updated.coins });
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { getPet, renamePet, petAction, updateColor, starCatch, getShop, buyBg, setBg, feedPet, equipAccessories, updatePattern };
|
module.exports = { getPet, renamePet, petAction, updateColor, starCatch, getShop, buyBg, setBg, setCustomBg, feedPet, equipAccessories, updatePattern };
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
-- Кастомный фон питомца: URL сгенерированной ИИ картинки (/uploads/generated/...).
|
||||||
|
-- Когда pet_bg = 'custom', сцена использует это изображение.
|
||||||
|
ALTER TABLE users ADD COLUMN pet_bg_custom TEXT;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- Обложка курса картинкой (URL). Если задана — показывается вместо эмодзи.
|
||||||
|
ALTER TABLE courses ADD COLUMN cover_image TEXT;
|
||||||
@@ -12,6 +12,7 @@ router.post('/star', authMiddleware, c.starCatch);
|
|||||||
router.get('/shop', authMiddleware, c.getShop);
|
router.get('/shop', authMiddleware, c.getShop);
|
||||||
router.post('/shop/buy', authMiddleware, c.buyBg);
|
router.post('/shop/buy', authMiddleware, c.buyBg);
|
||||||
router.patch('/bg', authMiddleware, c.setBg);
|
router.patch('/bg', authMiddleware, c.setBg);
|
||||||
|
router.post('/bg/custom', authMiddleware, c.setCustomBg);
|
||||||
router.post('/feed', authMiddleware, c.feedPet);
|
router.post('/feed', authMiddleware, c.feedPet);
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
|
|||||||
+48
-27
@@ -2452,6 +2452,7 @@
|
|||||||
<button class="cr-tool-btn" id="wb-tool-text" onclick="wbSetTool('text')" title="Текст"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="4 7 4 4 20 4 20 7"/><line x1="9" y1="20" x2="15" y2="20"/><line x1="12" y1="4" x2="12" y2="20"/></svg></button>
|
<button class="cr-tool-btn" id="wb-tool-text" onclick="wbSetTool('text')" title="Текст"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="4 7 4 4 20 4 20 7"/><line x1="9" y1="20" x2="15" y2="20"/><line x1="12" y1="4" x2="12" y2="20"/></svg></button>
|
||||||
<button class="cr-tool-btn" id="wb-tool-mindmap" onclick="wbSetTool('mindmap')" title="Интеллект-карта"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><line x1="12" y1="9" x2="12" y2="3"/><circle cx="12" cy="3" r="1.5"/><line x1="14.6" y1="13.4" x2="19" y2="17"/><circle cx="20" cy="18" r="1.5"/><line x1="9.4" y1="13.4" x2="5" y2="17"/><circle cx="4" cy="18" r="1.5"/><line x1="15" y1="11" x2="21" y2="9"/><circle cx="22" cy="8.5" r="1.5"/><line x1="9" y1="11" x2="3" y2="9"/><circle cx="2" cy="8.5" r="1.5"/></svg></button>
|
<button class="cr-tool-btn" id="wb-tool-mindmap" onclick="wbSetTool('mindmap')" title="Интеллект-карта"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><line x1="12" y1="9" x2="12" y2="3"/><circle cx="12" cy="3" r="1.5"/><line x1="14.6" y1="13.4" x2="19" y2="17"/><circle cx="20" cy="18" r="1.5"/><line x1="9.4" y1="13.4" x2="5" y2="17"/><circle cx="4" cy="18" r="1.5"/><line x1="15" y1="11" x2="21" y2="9"/><circle cx="22" cy="8.5" r="1.5"/><line x1="9" y1="11" x2="3" y2="9"/><circle cx="2" cy="8.5" r="1.5"/></svg></button>
|
||||||
<button class="cr-tool-btn" id="wb-tool-image" onclick="wbOpenImagePicker()" title="Изображение"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg></button>
|
<button class="cr-tool-btn" id="wb-tool-image" onclick="wbOpenImagePicker()" title="Изображение"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg></button>
|
||||||
|
<button class="cr-tool-btn" id="wb-tool-image-ai" onclick="wbGenerateImage()" title="Сгенерировать картинку (ИИ)"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><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>
|
||||||
<input type="file" id="wb-image-input" accept="image/*" style="display:none" onchange="wbImageSelected(this)">
|
<input type="file" id="wb-image-input" accept="image/*" style="display:none" onchange="wbImageSelected(this)">
|
||||||
<div class="cr-tool-sep"></div>
|
<div class="cr-tool-sep"></div>
|
||||||
<!-- undo / redo / clear -->
|
<!-- undo / redo / clear -->
|
||||||
@@ -3093,6 +3094,7 @@
|
|||||||
<script src="/js/board-clip.js"></script>
|
<script src="/js/board-clip.js"></script>
|
||||||
<script src="/js/classroom-rtc.js"></script>
|
<script src="/js/classroom-rtc.js"></script>
|
||||||
<script src="/js/api.js"></script>
|
<script src="/js/api.js"></script>
|
||||||
|
<script src="/js/imggen.js"></script>
|
||||||
<script src="/js/sound.js"></script>
|
<script src="/js/sound.js"></script>
|
||||||
<script src="/js/sidebar.js"></script>
|
<script src="/js/sidebar.js"></script>
|
||||||
<script src="/js/notifications.js"></script>
|
<script src="/js/notifications.js"></script>
|
||||||
@@ -6557,45 +6559,64 @@
|
|||||||
document.getElementById('wb-image-input')?.click();
|
document.getElementById('wb-image-input')?.click();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Поместить загруженный <img> на доску (ресайз до 800px, по центру)
|
||||||
|
function wbPlaceImageFromImg(img) {
|
||||||
|
if (!_wb || !_sessionId) return;
|
||||||
|
const maxPx = 800;
|
||||||
|
let w = img.naturalWidth, h = img.naturalHeight;
|
||||||
|
if (w > maxPx || h > maxPx) {
|
||||||
|
if (w >= h) { h = Math.round(h * maxPx / w); w = maxPx; }
|
||||||
|
else { w = Math.round(w * maxPx / h); h = maxPx; }
|
||||||
|
}
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = w; canvas.height = h;
|
||||||
|
canvas.getContext('2d').drawImage(img, 0, 0, w, h);
|
||||||
|
const src = canvas.toDataURL('image/jpeg', 0.8);
|
||||||
|
const vw = (w / (img.naturalWidth || w)) * 800;
|
||||||
|
const vh = (h / (img.naturalHeight || h)) * 450;
|
||||||
|
const vx = (1920 - vw) / 2;
|
||||||
|
const vy = (1080 - vh) / 2;
|
||||||
|
const stroke = {
|
||||||
|
id: _wb._localIdCounter--,
|
||||||
|
tool: 'image',
|
||||||
|
data: { src, x: vx, y: vy, w: vw, h: vh },
|
||||||
|
};
|
||||||
|
_wb._strokes.push(stroke);
|
||||||
|
_wb._undoStack.push(stroke.id);
|
||||||
|
_wb.render();
|
||||||
|
if (_wb._onStrokeDone) _wb._onStrokeDone(stroke);
|
||||||
|
}
|
||||||
|
|
||||||
function wbImageSelected(input) {
|
function wbImageSelected(input) {
|
||||||
const file = input.files?.[0];
|
const file = input.files?.[0];
|
||||||
if (!file || !_wb || !_sessionId) return;
|
if (!file || !_wb || !_sessionId) return;
|
||||||
input.value = '';
|
input.value = '';
|
||||||
const maxPx = 800;
|
|
||||||
const reader = new FileReader();
|
const reader = new FileReader();
|
||||||
reader.onload = e => {
|
reader.onload = e => {
|
||||||
const img = new Image();
|
const img = new Image();
|
||||||
img.onload = () => {
|
img.onload = () => wbPlaceImageFromImg(img);
|
||||||
// Resize to max 800px on longest side
|
|
||||||
let w = img.naturalWidth, h = img.naturalHeight;
|
|
||||||
if (w > maxPx || h > maxPx) {
|
|
||||||
if (w >= h) { h = Math.round(h * maxPx / w); w = maxPx; }
|
|
||||||
else { w = Math.round(w * maxPx / h); h = maxPx; }
|
|
||||||
}
|
|
||||||
const canvas = document.createElement('canvas');
|
|
||||||
canvas.width = w; canvas.height = h;
|
|
||||||
canvas.getContext('2d').drawImage(img, 0, 0, w, h);
|
|
||||||
const src = canvas.toDataURL('image/jpeg', 0.8);
|
|
||||||
// Place image centered on whiteboard in virtual coords
|
|
||||||
const vw = (w / (img.naturalWidth || w)) * 800;
|
|
||||||
const vh = (h / (img.naturalHeight || h)) * 450;
|
|
||||||
const vx = (1920 - vw) / 2;
|
|
||||||
const vy = (1080 - vh) / 2;
|
|
||||||
const stroke = {
|
|
||||||
id: _wb._localIdCounter--,
|
|
||||||
tool: 'image',
|
|
||||||
data: { src, x: vx, y: vy, w: vw, h: vh },
|
|
||||||
};
|
|
||||||
_wb._strokes.push(stroke);
|
|
||||||
_wb._undoStack.push(stroke.id);
|
|
||||||
_wb.render();
|
|
||||||
if (_wb._onStrokeDone) _wb._onStrokeDone(stroke);
|
|
||||||
};
|
|
||||||
img.src = e.target.result;
|
img.src = e.target.result;
|
||||||
};
|
};
|
||||||
reader.readAsDataURL(file);
|
reader.readAsDataURL(file);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Сгенерировать картинку ИИ и вставить на доску
|
||||||
|
function wbGenerateImage() {
|
||||||
|
if (!_wb || !_sessionId) return;
|
||||||
|
if (!LS.imagePromptModal) { LS.toast?.('Модуль генерации не загружен'); return; }
|
||||||
|
LS.imagePromptModal({
|
||||||
|
title: 'Картинка на доску (ИИ)',
|
||||||
|
placeholder: 'Опиши иллюстрацию: «схема круговорота воды, плоский стиль»',
|
||||||
|
useLabel: 'Вставить на доску',
|
||||||
|
onUse: (url) => {
|
||||||
|
const img = new Image();
|
||||||
|
img.onload = () => wbPlaceImageFromImg(img);
|
||||||
|
img.onerror = () => LS.toast?.('Не удалось загрузить картинку', 'error');
|
||||||
|
img.src = url;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function wbSetCustomColor(input) {
|
function wbSetCustomColor(input) {
|
||||||
if (!_wb) return;
|
if (!_wb) return;
|
||||||
_wb.setColor(input.value);
|
_wb.setColor(input.value);
|
||||||
|
|||||||
+31
-1
@@ -449,6 +449,7 @@
|
|||||||
|
|
||||||
<!-- Add section modal -->
|
<!-- Add section modal -->
|
||||||
<script src="/js/api.js"></script>
|
<script src="/js/api.js"></script>
|
||||||
|
<script src="/js/imggen.js"></script>
|
||||||
<script src="/js/sidebar.js"></script>
|
<script src="/js/sidebar.js"></script>
|
||||||
<script>
|
<script>
|
||||||
if (!LS.requireAuth()) throw new Error();
|
if (!LS.requireAuth()) throw new Error();
|
||||||
@@ -548,7 +549,9 @@
|
|||||||
|
|
||||||
document.getElementById('header-body').innerHTML = `
|
document.getElementById('header-body').innerHTML = `
|
||||||
${!course.isPublished ? '<span class="ch-draft-tag">Черновик</span><br><br>' : ''}
|
${!course.isPublished ? '<span class="ch-draft-tag">Черновик</span><br><br>' : ''}
|
||||||
<div class="ch-emoji">${course.coverEmoji || LS.icon('book-open',24)}</div>
|
${course.coverImage
|
||||||
|
? `<div class="ch-cover" style="width:100%;max-width:440px;height:160px;margin:0 auto 14px;border-radius:16px;background:center/cover url('${esc(course.coverImage)}');box-shadow:0 8px 24px rgba(0,0,0,.18)"></div>`
|
||||||
|
: `<div class="ch-emoji">${course.coverEmoji || LS.icon('book-open',24)}</div>`}
|
||||||
<div class="ch-subj ${SUBJ_CLASS[course.subjectSlug] || ''}">${esc(SUBJ_LABEL[course.subjectSlug] || course.subjectSlug)}</div>
|
<div class="ch-subj ${SUBJ_CLASS[course.subjectSlug] || ''}">${esc(SUBJ_LABEL[course.subjectSlug] || course.subjectSlug)}</div>
|
||||||
<div class="ch-title">${esc(course.title)}</div>
|
<div class="ch-title">${esc(course.title)}</div>
|
||||||
${course.description ? `<div class="ch-desc">${esc(course.description)}</div>` : ''}
|
${course.description ? `<div class="ch-desc">${esc(course.description)}</div>` : ''}
|
||||||
@@ -862,6 +865,15 @@
|
|||||||
<option value="phys">Физика</option>
|
<option value="phys">Физика</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group" style="margin-top:12px">
|
||||||
|
<label class="form-label">Обложка (картинка)</label>
|
||||||
|
<div id="ec-cover-prev" style="${course.coverImage ? '' : 'display:none;'}height:120px;border-radius:12px;margin-bottom:8px;background:center/cover url('${LS.esc(course.coverImage || '')}')"></div>
|
||||||
|
<input type="hidden" id="ec-cover" value="${LS.esc(course.coverImage || '')}" />
|
||||||
|
<div style="display:flex;gap:8px;flex-wrap:wrap">
|
||||||
|
<button type="button" class="btn-primary" id="ec-cover-gen" style="padding:8px 16px;min-height:auto;font-size:.82rem">Сгенерировать ИИ</button>
|
||||||
|
<button type="button" class="btn-ghost" id="ec-cover-clear" style="padding:8px 16px;min-height:auto;${course.coverImage ? '' : 'display:none'}">Убрать</button>
|
||||||
|
</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
_editCourseModal = LS.modal({
|
_editCourseModal = LS.modal({
|
||||||
title: 'Редактировать курс', content: body, size: 'sm',
|
title: 'Редактировать курс', content: body, size: 'sm',
|
||||||
@@ -871,6 +883,23 @@
|
|||||||
],
|
],
|
||||||
});
|
});
|
||||||
_editCourseModal.body.querySelector('#ec-subject').value = course.subjectSlug || '';
|
_editCourseModal.body.querySelector('#ec-subject').value = course.subjectSlug || '';
|
||||||
|
const coverInput = _editCourseModal.body.querySelector('#ec-cover');
|
||||||
|
const coverPrev = _editCourseModal.body.querySelector('#ec-cover-prev');
|
||||||
|
const coverClear = _editCourseModal.body.querySelector('#ec-cover-clear');
|
||||||
|
_editCourseModal.body.querySelector('#ec-cover-gen').onclick = () => {
|
||||||
|
if (!LS.imagePromptModal) { LS.toast('Модуль генерации не загружен'); return; }
|
||||||
|
LS.imagePromptModal({
|
||||||
|
title: 'Обложка курса',
|
||||||
|
placeholder: 'Обложка для курса «' + (course.title || '') + '»: яркая иллюстрация',
|
||||||
|
useLabel: 'Поставить обложкой',
|
||||||
|
onUse: (url) => {
|
||||||
|
coverInput.value = url;
|
||||||
|
coverPrev.style.cssText = "height:120px;border-radius:12px;margin-bottom:8px;background:center/cover url('" + url + "')";
|
||||||
|
coverClear.style.display = '';
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
coverClear.onclick = () => { coverInput.value = ''; coverPrev.style.display = 'none'; coverClear.style.display = 'none'; };
|
||||||
}
|
}
|
||||||
async function doEditCourse() {
|
async function doEditCourse() {
|
||||||
const btn = document.getElementById('btn-do-edit-course');
|
const btn = document.getElementById('btn-do-edit-course');
|
||||||
@@ -883,6 +912,7 @@
|
|||||||
title: document.getElementById('ec-title').value.trim(),
|
title: document.getElementById('ec-title').value.trim(),
|
||||||
description: document.getElementById('ec-desc').value.trim(),
|
description: document.getElementById('ec-desc').value.trim(),
|
||||||
coverEmoji: document.getElementById('ec-emoji').value.trim(),
|
coverEmoji: document.getElementById('ec-emoji').value.trim(),
|
||||||
|
coverImage: document.getElementById('ec-cover').value.trim(),
|
||||||
subjectSlug: document.getElementById('ec-subject').value || null,
|
subjectSlug: document.getElementById('ec-subject').value || null,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
+65
-12
@@ -724,6 +724,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="/js/api.js"></script>
|
<script src="/js/api.js"></script>
|
||||||
|
<script src="/js/imggen.js"></script>
|
||||||
<script src="/js/sidebar.js"></script>
|
<script src="/js/sidebar.js"></script>
|
||||||
<script src="https://cdn.jsdelivr.net/npm/lucide@0.469.0/dist/umd/lucide.min.js"></script>
|
<script src="https://cdn.jsdelivr.net/npm/lucide@0.469.0/dist/umd/lucide.min.js"></script>
|
||||||
<script>
|
<script>
|
||||||
@@ -874,6 +875,25 @@ function applyWeather(mood) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Применить фон сцены (пресет bg-<id> или кастомное изображение) ── */
|
||||||
|
function applyPetBg(id, customUrl) {
|
||||||
|
const scene = document.getElementById('pet-scene');
|
||||||
|
if (!scene) return;
|
||||||
|
scene.className = scene.className.replace(/\bbg-\S+/g, '').replace(/\s+/g, ' ').trim();
|
||||||
|
if (id === 'custom' && customUrl) {
|
||||||
|
scene.style.backgroundImage = `url("${customUrl}")`;
|
||||||
|
scene.style.backgroundSize = 'cover';
|
||||||
|
scene.style.backgroundPosition = 'center';
|
||||||
|
applyBgFX('default');
|
||||||
|
} else {
|
||||||
|
scene.style.backgroundImage = '';
|
||||||
|
scene.style.backgroundSize = '';
|
||||||
|
scene.style.backgroundPosition = '';
|
||||||
|
if (id && id !== 'default') scene.classList.add(`bg-${id}`);
|
||||||
|
applyBgFX(id || 'default');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* ── B2 BgFX particles ── */
|
/* ── B2 BgFX particles ── */
|
||||||
function applyBgFX(bgId) {
|
function applyBgFX(bgId) {
|
||||||
const c = document.getElementById('pet-bgfx');
|
const c = document.getElementById('pet-bgfx');
|
||||||
@@ -1096,10 +1116,8 @@ async function selectBg(id, price, owned, card) {
|
|||||||
if (!res?.ok) return;
|
if (!res?.ok) return;
|
||||||
|
|
||||||
// Update scene bg
|
// Update scene bg
|
||||||
const scene = document.getElementById('pet-scene');
|
|
||||||
scene.className = scene.className.replace(/\bbg-\S+/g, '') + (id !== 'default' ? ` bg-${id}` : '');
|
|
||||||
if (_petData) _petData.petBg = id;
|
if (_petData) _petData.petBg = id;
|
||||||
applyBgFX(id);
|
applyPetBg(id, _petData && _petData.petBgCustom);
|
||||||
|
|
||||||
// Update coins display
|
// Update coins display
|
||||||
if (res.coins !== undefined) {
|
if (res.coins !== undefined) {
|
||||||
@@ -1152,7 +1170,19 @@ async function renderBgPicker() {
|
|||||||
const coinsEl = document.getElementById('pc-bg-coins');
|
const coinsEl = document.getElementById('pc-bg-coins');
|
||||||
if (coinsEl) coinsEl.innerHTML = `Монет: <b style="color:#F9C74F">${data.coins}</b>`;
|
if (coinsEl) coinsEl.innerHTML = `Монет: <b style="color:#F9C74F">${data.coins}</b>`;
|
||||||
const items = [{ id: 'default', name: 'Стандарт', price: 0, owned: true }, ...data.items];
|
const items = [{ id: 'default', name: 'Стандарт', price: 0, owned: true }, ...data.items];
|
||||||
grid.innerHTML = items.map(item => {
|
// Карточка кастомного фона (генерация ИИ) — всегда первой
|
||||||
|
const hasCustom = _petData && _petData.petBgCustom;
|
||||||
|
const activeCustom = data.currentBg === 'custom';
|
||||||
|
const customPrev = hasCustom
|
||||||
|
? `background:center/cover url("${hasCustom}")`
|
||||||
|
: 'background:linear-gradient(135deg,#9B5DE5,#06D6E0);display:flex;align-items:center;justify-content:center';
|
||||||
|
const sparkle = hasCustom ? '' : '<svg viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2" style="width:26px;height:26px"><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>';
|
||||||
|
const customCard = `<div class="pet-bg-card${activeCustom ? ' active' : ''}" data-id="__gen__">
|
||||||
|
<div class="pet-bg-preview" style="${customPrev}">${sparkle}</div>
|
||||||
|
<div class="pet-bg-info"><div class="pet-bg-name">Свой фон (ИИ)</div>
|
||||||
|
<div class="pet-bg-status">${activeCustom ? 'Активен' : hasCustom ? 'Перерисовать' : 'Создать'}</div></div>
|
||||||
|
</div>`;
|
||||||
|
grid.innerHTML = customCard + items.map(item => {
|
||||||
const isActive = data.currentBg === item.id;
|
const isActive = data.currentBg === item.id;
|
||||||
const isOwned = item.owned || item.price === 0;
|
const isOwned = item.owned || item.price === 0;
|
||||||
const status = isActive ? 'Активен' : isOwned ? 'Выбрать' : item.price + ' монет';
|
const status = isActive ? 'Активен' : isOwned ? 'Выбрать' : item.price + ' монет';
|
||||||
@@ -1162,8 +1192,29 @@ async function renderBgPicker() {
|
|||||||
<div class="pet-bg-status">${status}</div></div>
|
<div class="pet-bg-status">${status}</div></div>
|
||||||
</div>`;
|
</div>`;
|
||||||
}).join('');
|
}).join('');
|
||||||
grid.querySelectorAll('.pet-bg-card').forEach(c =>
|
grid.querySelectorAll('.pet-bg-card').forEach(c => {
|
||||||
c.addEventListener('click', () => selectBgInline(c.dataset.id, +c.dataset.price, c.dataset.owned === '1')));
|
if (c.dataset.id === '__gen__') { c.addEventListener('click', openCustomBgModal); return; }
|
||||||
|
c.addEventListener('click', () => selectBgInline(c.dataset.id, +c.dataset.price, c.dataset.owned === '1'));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Кастомный фон: генерация ИИ → /api/pet/bg/custom ── */
|
||||||
|
function openCustomBgModal() {
|
||||||
|
if (!LS.imagePromptModal) { LS.toast?.('Модуль генерации не загружен'); return; }
|
||||||
|
LS.imagePromptModal({
|
||||||
|
title: 'Свой фон для питомца',
|
||||||
|
placeholder: 'Уютная сцена: «звёздная ночь над горами, мягкие тёплые тона»',
|
||||||
|
useLabel: 'Поставить фоном',
|
||||||
|
onUse: async function (url) {
|
||||||
|
const res = await LS.api('/api/pet/bg/custom', { method: 'POST', body: JSON.stringify({ url }) }).catch(() => null);
|
||||||
|
if (!res?.ok) { LS.toast?.('Не удалось установить фон', 'error'); return; }
|
||||||
|
if (_petData) { _petData.petBg = 'custom'; _petData.petBgCustom = res.url; }
|
||||||
|
applyPetBg('custom', res.url);
|
||||||
|
updatePreviewScene();
|
||||||
|
renderBgPicker();
|
||||||
|
LS.toast?.('Фон установлен', 'success');
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
async function selectBgInline(id, price, owned) {
|
async function selectBgInline(id, price, owned) {
|
||||||
// покупка платного фона — подтверждение + предпроверка баланса
|
// покупка платного фона — подтверждение + предпроверка баланса
|
||||||
@@ -1180,10 +1231,8 @@ async function selectBgInline(id, price, owned) {
|
|||||||
return null;
|
return null;
|
||||||
});
|
});
|
||||||
if (!res?.ok) return;
|
if (!res?.ok) return;
|
||||||
const scene = document.getElementById('pet-scene');
|
|
||||||
scene.className = scene.className.replace(/\bbg-\S+/g, '') + (id !== 'default' ? ` bg-${id}` : '');
|
|
||||||
if (_petData) _petData.petBg = id;
|
if (_petData) _petData.petBg = id;
|
||||||
applyBgFX(id);
|
applyPetBg(id, _petData && _petData.petBgCustom);
|
||||||
updatePreviewScene();
|
updatePreviewScene();
|
||||||
if (res.coins !== undefined) {
|
if (res.coins !== undefined) {
|
||||||
document.getElementById('stat-coins').textContent = res.coins;
|
document.getElementById('stat-coins').textContent = res.coins;
|
||||||
@@ -1241,9 +1290,8 @@ function renderPet(d) {
|
|||||||
const prevTod = scene.dataset.tod;
|
const prevTod = scene.dataset.tod;
|
||||||
scene.className = `pet-scene mood-${d.mood}`;
|
scene.className = `pet-scene mood-${d.mood}`;
|
||||||
if (prevTod) scene.dataset.tod = prevTod;
|
if (prevTod) scene.dataset.tod = prevTod;
|
||||||
if (d.petBg && d.petBg !== 'default') scene.classList.add(`bg-${d.petBg}`);
|
|
||||||
applyWeather(d.mood);
|
applyWeather(d.mood);
|
||||||
applyBgFX(d.petBg || 'default');
|
applyPetBg(d.petBg || 'default', d.petBgCustom);
|
||||||
|
|
||||||
// B4: XP float if XP increased since last visit
|
// B4: XP float if XP increased since last visit
|
||||||
const cachedXP = parseInt(localStorage.getItem('ls_pet_xp') || '0');
|
const cachedXP = parseInt(localStorage.getItem('ls_pet_xp') || '0');
|
||||||
@@ -1451,7 +1499,12 @@ function paintPet() {
|
|||||||
}
|
}
|
||||||
function updatePreviewScene() {
|
function updatePreviewScene() {
|
||||||
const sc = document.getElementById('wr-preview-scene');
|
const sc = document.getElementById('wr-preview-scene');
|
||||||
if (sc && _petData) sc.style.background = BG_PREVIEWS[_petData.petBg] || BG_PREVIEWS.default;
|
if (!sc || !_petData) return;
|
||||||
|
if (_petData.petBg === 'custom' && _petData.petBgCustom) {
|
||||||
|
sc.style.background = `center/cover url("${_petData.petBgCustom}")`;
|
||||||
|
} else {
|
||||||
|
sc.style.background = BG_PREVIEWS[_petData.petBg] || BG_PREVIEWS.default;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
function openWardrobe() {
|
function openWardrobe() {
|
||||||
if (!_petData) return;
|
if (!_petData) return;
|
||||||
|
|||||||
@@ -1373,6 +1373,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="/js/api.js"></script>
|
<script src="/js/api.js"></script>
|
||||||
|
<script src="/js/imggen.js"></script>
|
||||||
<script src="/js/sound.js"></script>
|
<script src="/js/sound.js"></script>
|
||||||
<script src="/js/sidebar.js"></script>
|
<script src="/js/sidebar.js"></script>
|
||||||
<script src="/js/notifications.js"></script>
|
<script src="/js/notifications.js"></script>
|
||||||
@@ -2337,6 +2338,30 @@
|
|||||||
_avDrag = false;
|
_avDrag = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Загрузить изображение в шаг кадрирования (из URL — для ИИ-генерации) */
|
||||||
|
function avLoadFromUrl(src) {
|
||||||
|
const img = new Image();
|
||||||
|
img.onload = () => {
|
||||||
|
_avImg = img; _avZoom = 1; _avOffX = 0; _avOffY = 0;
|
||||||
|
document.getElementById('av-zoom').value = 100;
|
||||||
|
document.getElementById('av-s1').style.display = 'none';
|
||||||
|
document.getElementById('av-s2').style.display = 'flex';
|
||||||
|
avDraw(); avBindCanvas();
|
||||||
|
};
|
||||||
|
img.onerror = () => LS.toast('Не удалось загрузить картинку', 'error');
|
||||||
|
img.src = src;
|
||||||
|
}
|
||||||
|
/* Сгенерировать аватар через ИИ → кадрирование → отправка на проверку */
|
||||||
|
function avGenerate() {
|
||||||
|
if (!LS.imagePromptModal) { LS.toast('Модуль генерации не загружен'); return; }
|
||||||
|
LS.imagePromptModal({
|
||||||
|
title: 'Сгенерировать аватар',
|
||||||
|
placeholder: 'Аватар: «дружелюбный лис в наушниках, плоский стиль, по центру»',
|
||||||
|
useLabel: 'Кадрировать',
|
||||||
|
onUse: (url) => avLoadFromUrl(url),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Step 2: Canvas crop ── */
|
/* ── Step 2: Canvas crop ── */
|
||||||
function avDraw() {
|
function avDraw() {
|
||||||
const c = document.getElementById('av-canvas');
|
const c = document.getElementById('av-canvas');
|
||||||
@@ -2623,6 +2648,8 @@
|
|||||||
<input type="file" id="av-file-inp" accept="image/png,image/jpeg,image/webp"
|
<input type="file" id="av-file-inp" accept="image/png,image/jpeg,image/webp"
|
||||||
style="display:none" onchange="avFileChosen(this)">
|
style="display:none" onchange="avFileChosen(this)">
|
||||||
</div>
|
</div>
|
||||||
|
<div class="av-or">или нарисуйте ИИ</div>
|
||||||
|
<button class="av-btn-send" type="button" onclick="avGenerate()" style="width:100%">Сгенерировать аватар (ИИ)</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Delete current avatar -->
|
<!-- Delete current avatar -->
|
||||||
|
|||||||
Reference in New Issue
Block a user