From e6c69c8a78555a0b5a5701cdba3588aadeaef36e Mon Sep 17 00:00:00 2001 From: dev Date: Thu, 24 Sep 2026 12:00:20 +0300 Subject: [PATCH] feat(settings): option to show/hide camera button, preview click opens camera --- db/init.sql | 2 ++ db/migration.sql | 3 +++ public/index.html | 9 ++++++--- public/js/index.js | 25 ++++++++++++++++++++++++- public/js/settings.js | 4 +++- public/settings.html | 5 +++++ public/version.json | 6 +++--- server.js | 5 +++-- 8 files changed, 49 insertions(+), 10 deletions(-) diff --git a/db/init.sql b/db/init.sql index 9ec683e..bbcc54a 100644 --- a/db/init.sql +++ b/db/init.sql @@ -120,6 +120,8 @@ INSERT INTO settings (key, value) VALUES ('share_show_group_photos', 'true') ON CONFLICT (key) DO NOTHING; INSERT INTO settings (key, value) VALUES ('cookie_notice_text', 'Этот сайт использует cookie-файлы для корректной работы. Продолжая просмотр, вы соглашаетесь с их использованием.') ON CONFLICT (key) DO NOTHING; +INSERT INTO settings (key, value) VALUES ('camera_enabled', 'true') +ON CONFLICT (key) DO NOTHING; CREATE TABLE IF NOT EXISTS share_links ( id SERIAL PRIMARY KEY, diff --git a/db/migration.sql b/db/migration.sql index 6b6a3c5..ea01a7e 100644 --- a/db/migration.sql +++ b/db/migration.sql @@ -115,6 +115,9 @@ ON CONFLICT (key) DO NOTHING; INSERT INTO settings (key, value) VALUES ('cookie_notice_text', 'Этот сайт использует cookie-файлы для корректной работы. Продолжая просмотр, вы соглашаетесь с их использованием.') ON CONFLICT (key) DO NOTHING; +INSERT INTO settings (key, value) VALUES ('camera_enabled', 'true') +ON CONFLICT (key) DO NOTHING; + DO $$ BEGIN IF NOT EXISTS (SELECT 1 FROM settings WHERE key = 'ai_autocheck_migrated') THEN diff --git a/public/index.html b/public/index.html index fd88bcf..1d351f9 100644 --- a/public/index.html +++ b/public/index.html @@ -43,8 +43,10 @@ h1{font-size:1.5rem;font-weight:600;margin-bottom:8px;letter-spacing:-.02em;text .files-card .paste-hint{text-align:left} @media(hover:none){.paste-hint{display:none}} .preview-wrap{width:100%;display:flex;flex-direction:column;align-items:center;gap:8px} -.preview{max-width:100%;width:100%;aspect-ratio:4/3;object-fit:cover;border-radius:var(--radius);background:var(--card);display:block;border:1px dashed var(--border)} +.preview{max-width:100%;width:100%;aspect-ratio:4/3;object-fit:cover;border-radius:var(--radius);background:var(--card);display:block;border:1px dashed var(--border);cursor:pointer;transition:border-color .15s} .preview.has-photo{border-style:solid} +.preview:hover,.preview:focus-visible{border-color:var(--accent)} +.preview-caption{font-size:.85rem;color:var(--muted);text-align:center;line-height:1.35} .remove-photo{background:none;border:none;color:#ef4444;font-size:.85rem;cursor:pointer;padding:4px;display:none} .remove-photo.show{display:block} @@ -107,10 +109,11 @@ nav a:hover{color:var(--text)}
- Фото + Фото +

Нажми чтобы сделать фото

-
+
diff --git a/public/js/index.js b/public/js/index.js index d0012c5..9d87ddc 100644 --- a/public/js/index.js +++ b/public/js/index.js @@ -15,6 +15,7 @@ const camCanvas = document.getElementById('camCanvas'); const camModal = document.getElementById('camModal'); let capturedBlob = null; let stream = null; +let camOpening = false; const descInput = document.getElementById('descInput'); const filesInput = document.getElementById('filesInput'); const filesList = document.getElementById('filesList'); @@ -318,6 +319,10 @@ async function resolveModuleId(name) { if (Number.isFinite(h) && h >= 120 && h <= 4096) captureHeight = h; const q = parseFloat(f.photo_capture_quality); if (Number.isFinite(q) && q >= 0.5 && q <= 1) captureQuality = q; + if (f.camera_enabled === 'false') { + const camBtns = document.getElementById('camBtns'); + if (camBtns) camBtns.style.display = 'none'; + } } catch (e) { /* ignore */ } checkPreviousSend(); })(); @@ -336,12 +341,22 @@ function removePhoto() { } async function openCam() { + if (camOpening) return; + if (stream && camModal.classList.contains('open')) return; + camOpening = true; try { - stream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: 'user', width: { ideal: captureWidth }, height: { ideal: captureHeight } } }); + const s = await navigator.mediaDevices.getUserMedia({ video: { facingMode: 'user', width: { ideal: captureWidth }, height: { ideal: captureHeight } } }); + if (camModal.classList.contains('open')) { + s.getTracks().forEach(t => t.stop()); + return; + } + stream = s; camVideo.srcObject = stream; camModal.classList.add('open'); } catch (e) { alert('Не удалось открыть камеру: ' + e.message); + } finally { + camOpening = false; } } @@ -351,6 +366,10 @@ function closeCam() { } function capture() { + if (!camVideo.videoWidth || !camVideo.videoHeight) { + notify('Камера ещё не готова, попробуйте через секунду', true); + return; + } camCanvas.width = camVideo.videoWidth; camCanvas.height = camVideo.videoHeight; const ctx = camCanvas.getContext('2d'); @@ -420,6 +439,10 @@ form.addEventListener('submit', async (e) => { removePhotoBtn.addEventListener('click', removePhoto); document.getElementById('openCamBtn').addEventListener('click', openCam); +preview.addEventListener('click', openCam); +preview.addEventListener('keydown', e => { + if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); openCam(); } +}); filesClearBtn.addEventListener('click', clearFiles); document.getElementById('filesBtn').addEventListener('click', () => document.getElementById('filesInput').click()); document.getElementById('cancelCamBtn').addEventListener('click', closeCam); diff --git a/public/js/settings.js b/public/js/settings.js index 381bda7..8b63433 100644 --- a/public/js/settings.js +++ b/public/js/settings.js @@ -1,4 +1,4 @@ -const DIRTY_FIELDS = ['systemName', 'spamInterval', 'footerLeft', 'footerRight', 'aiPrompt', 'aiAutoCheck', 'shareShowMessage', 'shareShowDate', 'shareShowNames', 'shareShowPhotos', 'cookieNoticeText', 'photoResolution', 'photoQuality', 'photoEngine']; +const DIRTY_FIELDS = ['systemName', 'spamInterval', 'footerLeft', 'footerRight', 'aiPrompt', 'aiAutoCheck', 'shareShowMessage', 'shareShowDate', 'shareShowNames', 'shareShowPhotos', 'cookieNoticeText', 'photoResolution', 'photoQuality', 'photoEngine', 'camEnabled']; const SECTION_IDS = ['sec-system', 'sec-brand', 'sec-antispam', 'sec-share', 'sec-ai', 'sec-backup', 'sec-form', 'sec-bans']; const SPAM_MAX = 10080; const BAN_REASONS = { @@ -181,6 +181,7 @@ async function loadSettings() { const pe = document.getElementById('photoEngine'); pe.value = ['auto', 'server', 'client'].includes(s.photo_enhance_engine) ? s.photo_enhance_engine : 'auto'; if (!pe.value) pe.value = 'auto'; + document.getElementById('camEnabled').checked = s.camera_enabled !== 'false'; snapshotDirty(); updateSpamUi(); loadAiQueue(); @@ -522,6 +523,7 @@ async function saveSettings() { photo_capture_resolution: document.getElementById('photoResolution').value, photo_capture_quality: String(document.getElementById('photoQuality').value), photo_enhance_engine: document.getElementById('photoEngine').value, + camera_enabled: document.getElementById('camEnabled').checked ? 'true' : 'false', } }) }); if (res.ok) { diff --git a/public/settings.html b/public/settings.html index c072703..51b03e7 100644 --- a/public/settings.html +++ b/public/settings.html @@ -208,6 +208,11 @@

Фотография с веб-камеры

Разрешение и качество снимка на форме ответа ученика. Камера может выдать разрешение, ближайшее к выбранному.

+
diff --git a/public/version.json b/public/version.json index 80e370c..57961e3 100644 --- a/public/version.json +++ b/public/version.json @@ -1,5 +1,5 @@ { - "full": "d943b77f585041cbb8ae8efed31cc3e326e1e140", - "short": "d943b77", - "date": "2026-09-23T23:01:36+03:00" + "full": "e4704c2dc69361318d9aab490128f30c4d875bda", + "short": "e4704c2", + "date": "2026-09-24T11:21:58+03:00" } \ No newline at end of file diff --git a/server.js b/server.js index 424a950..fc5d596 100644 --- a/server.js +++ b/server.js @@ -1038,8 +1038,8 @@ app.get('/api/settings', requireAdmin, async (_, res) => { app.get('/api/public-settings', apiLimiter, async (_, res) => { const out = await cacheWrap('public-settings', PUBLIC_TTL_MS, async () => { - const keys = ['system_name', 'system_logo', 'footer_left', 'footer_right', 'share_show_student_message', 'share_show_entry_date', 'share_show_student_names', 'share_show_group_photos', 'cookie_notice_text', 'spam_interval_min', 'photo_capture_resolution', 'photo_capture_quality', 'photo_enhance_engine']; - const defaults = { system_name: 'WhatIDo', system_logo: '', spam_interval_min: '30', photo_capture_resolution: '640x480', photo_capture_quality: '0.92', photo_enhance_engine: 'auto' }; + const keys = ['system_name', 'system_logo', 'footer_left', 'footer_right', 'share_show_student_message', 'share_show_entry_date', 'share_show_student_names', 'share_show_group_photos', 'cookie_notice_text', 'spam_interval_min', 'photo_capture_resolution', 'photo_capture_quality', 'photo_enhance_engine', 'camera_enabled']; + const defaults = { system_name: 'WhatIDo', system_logo: '', spam_interval_min: '30', photo_capture_resolution: '640x480', photo_capture_quality: '0.92', photo_enhance_engine: 'auto', camera_enabled: 'true' }; const result = {}; for (const k of keys) result[k] = await getSetting(k, defaults[k] || ''); const rm = /^(\d{2,5})x(\d{2,5})$/.exec(result.photo_capture_resolution); @@ -5079,6 +5079,7 @@ if (fs.existsSync(certPath) && fs.existsSync(keyPath)) { try { await ensurePhotoOriginalColumn(); } catch (err) { console.error('Entry original photo column:', err); } try { await ensureEntryAiColumns(); } catch (err) { console.error('Entry AI columns:', err); } try { await ensurePhotoJobsTable(); } catch (err) { console.error('Photo jobs table:', err); } + try { await pool.query(`INSERT INTO settings (key, value) VALUES ('camera_enabled', 'true') ON CONFLICT (key) DO NOTHING`); } catch (err) { console.error('Camera setting:', err); } try { await sweepOrphanedUploads(); } catch (err) { console.error('Upload sweep:', err); } entryAutoChecker = createEntryAutoChecker({ pool, getSetting, logAudit, aiUrl: AI_URL, defaultPrompt: AI_DEFAULT_PROMPT }); entryAutoChecker.start();