feat(settings): option to show/hide camera button, preview click opens camera
This commit is contained in:
@@ -120,6 +120,8 @@ INSERT INTO settings (key, value) VALUES ('share_show_group_photos', 'true')
|
|||||||
ON CONFLICT (key) DO NOTHING;
|
ON CONFLICT (key) DO NOTHING;
|
||||||
INSERT INTO settings (key, value) VALUES ('cookie_notice_text', 'Этот сайт использует cookie-файлы для корректной работы. Продолжая просмотр, вы соглашаетесь с их использованием.')
|
INSERT INTO settings (key, value) VALUES ('cookie_notice_text', 'Этот сайт использует cookie-файлы для корректной работы. Продолжая просмотр, вы соглашаетесь с их использованием.')
|
||||||
ON CONFLICT (key) DO NOTHING;
|
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 (
|
CREATE TABLE IF NOT EXISTS share_links (
|
||||||
id SERIAL PRIMARY KEY,
|
id SERIAL PRIMARY KEY,
|
||||||
|
|||||||
@@ -115,6 +115,9 @@ ON CONFLICT (key) DO NOTHING;
|
|||||||
INSERT INTO settings (key, value) VALUES ('cookie_notice_text', 'Этот сайт использует cookie-файлы для корректной работы. Продолжая просмотр, вы соглашаетесь с их использованием.')
|
INSERT INTO settings (key, value) VALUES ('cookie_notice_text', 'Этот сайт использует cookie-файлы для корректной работы. Продолжая просмотр, вы соглашаетесь с их использованием.')
|
||||||
ON CONFLICT (key) DO NOTHING;
|
ON CONFLICT (key) DO NOTHING;
|
||||||
|
|
||||||
|
INSERT INTO settings (key, value) VALUES ('camera_enabled', 'true')
|
||||||
|
ON CONFLICT (key) DO NOTHING;
|
||||||
|
|
||||||
DO $$
|
DO $$
|
||||||
BEGIN
|
BEGIN
|
||||||
IF NOT EXISTS (SELECT 1 FROM settings WHERE key = 'ai_autocheck_migrated') THEN
|
IF NOT EXISTS (SELECT 1 FROM settings WHERE key = 'ai_autocheck_migrated') THEN
|
||||||
|
|||||||
+6
-3
@@ -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}
|
.files-card .paste-hint{text-align:left}
|
||||||
@media(hover:none){.paste-hint{display:none}}
|
@media(hover:none){.paste-hint{display:none}}
|
||||||
.preview-wrap{width:100%;display:flex;flex-direction:column;align-items:center;gap:8px}
|
.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.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{background:none;border:none;color:#ef4444;font-size:.85rem;cursor:pointer;padding:4px;display:none}
|
||||||
.remove-photo.show{display:block}
|
.remove-photo.show{display:block}
|
||||||
|
|
||||||
@@ -107,10 +109,11 @@ nav a:hover{color:var(--text)}
|
|||||||
<div class="photo-col">
|
<div class="photo-col">
|
||||||
<div class="photo-card">
|
<div class="photo-card">
|
||||||
<div class="preview-wrap">
|
<div class="preview-wrap">
|
||||||
<img class="preview" id="preview" alt="Фото" src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='640' height='480'%3E%3Crect width='100%25' height='100%25' fill='none' stroke='%23475569' stroke-width='2' stroke-dasharray='8 8'/%3E%3Ctext x='50%25' y='50%25' fill='%2364748b' font-family='sans-serif' font-size='28' text-anchor='middle' dominant-baseline='middle'%3EФото%3C/text%3E%3C/svg%3E">
|
<img class="preview" id="preview" alt="Фото" title="Открыть камеру" tabindex="0" role="button" src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='640' height='480'%3E%3Crect width='100%25' height='100%25' fill='none' stroke='%23475569' stroke-width='2' stroke-dasharray='8 8'/%3E%3Ctext x='50%25' y='50%25' fill='%2364748b' font-family='sans-serif' font-size='28' text-anchor='middle' dominant-baseline='middle'%3EФото%3C/text%3E%3C/svg%3E">
|
||||||
|
<p class="preview-caption" id="previewCaption">Нажми чтобы сделать фото</p>
|
||||||
<button type="button" class="remove-photo" id="removePhotoBtn">Убрать фото</button>
|
<button type="button" class="remove-photo" id="removePhotoBtn">Убрать фото</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="photo-btns">
|
<div class="photo-btns" id="camBtns">
|
||||||
<button type="button" class="cam" id="openCamBtn">Камера</button>
|
<button type="button" class="cam" id="openCamBtn">Камера</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+24
-1
@@ -15,6 +15,7 @@ const camCanvas = document.getElementById('camCanvas');
|
|||||||
const camModal = document.getElementById('camModal');
|
const camModal = document.getElementById('camModal');
|
||||||
let capturedBlob = null;
|
let capturedBlob = null;
|
||||||
let stream = null;
|
let stream = null;
|
||||||
|
let camOpening = false;
|
||||||
const descInput = document.getElementById('descInput');
|
const descInput = document.getElementById('descInput');
|
||||||
const filesInput = document.getElementById('filesInput');
|
const filesInput = document.getElementById('filesInput');
|
||||||
const filesList = document.getElementById('filesList');
|
const filesList = document.getElementById('filesList');
|
||||||
@@ -318,6 +319,10 @@ async function resolveModuleId(name) {
|
|||||||
if (Number.isFinite(h) && h >= 120 && h <= 4096) captureHeight = h;
|
if (Number.isFinite(h) && h >= 120 && h <= 4096) captureHeight = h;
|
||||||
const q = parseFloat(f.photo_capture_quality);
|
const q = parseFloat(f.photo_capture_quality);
|
||||||
if (Number.isFinite(q) && q >= 0.5 && q <= 1) captureQuality = q;
|
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 */ }
|
} catch (e) { /* ignore */ }
|
||||||
checkPreviousSend();
|
checkPreviousSend();
|
||||||
})();
|
})();
|
||||||
@@ -336,12 +341,22 @@ function removePhoto() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function openCam() {
|
async function openCam() {
|
||||||
|
if (camOpening) return;
|
||||||
|
if (stream && camModal.classList.contains('open')) return;
|
||||||
|
camOpening = true;
|
||||||
try {
|
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;
|
camVideo.srcObject = stream;
|
||||||
camModal.classList.add('open');
|
camModal.classList.add('open');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
alert('Не удалось открыть камеру: ' + e.message);
|
alert('Не удалось открыть камеру: ' + e.message);
|
||||||
|
} finally {
|
||||||
|
camOpening = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -351,6 +366,10 @@ function closeCam() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function capture() {
|
function capture() {
|
||||||
|
if (!camVideo.videoWidth || !camVideo.videoHeight) {
|
||||||
|
notify('Камера ещё не готова, попробуйте через секунду', true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
camCanvas.width = camVideo.videoWidth;
|
camCanvas.width = camVideo.videoWidth;
|
||||||
camCanvas.height = camVideo.videoHeight;
|
camCanvas.height = camVideo.videoHeight;
|
||||||
const ctx = camCanvas.getContext('2d');
|
const ctx = camCanvas.getContext('2d');
|
||||||
@@ -420,6 +439,10 @@ form.addEventListener('submit', async (e) => {
|
|||||||
|
|
||||||
removePhotoBtn.addEventListener('click', removePhoto);
|
removePhotoBtn.addEventListener('click', removePhoto);
|
||||||
document.getElementById('openCamBtn').addEventListener('click', openCam);
|
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);
|
filesClearBtn.addEventListener('click', clearFiles);
|
||||||
document.getElementById('filesBtn').addEventListener('click', () => document.getElementById('filesInput').click());
|
document.getElementById('filesBtn').addEventListener('click', () => document.getElementById('filesInput').click());
|
||||||
document.getElementById('cancelCamBtn').addEventListener('click', closeCam);
|
document.getElementById('cancelCamBtn').addEventListener('click', closeCam);
|
||||||
|
|||||||
@@ -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 SECTION_IDS = ['sec-system', 'sec-brand', 'sec-antispam', 'sec-share', 'sec-ai', 'sec-backup', 'sec-form', 'sec-bans'];
|
||||||
const SPAM_MAX = 10080;
|
const SPAM_MAX = 10080;
|
||||||
const BAN_REASONS = {
|
const BAN_REASONS = {
|
||||||
@@ -181,6 +181,7 @@ async function loadSettings() {
|
|||||||
const pe = document.getElementById('photoEngine');
|
const pe = document.getElementById('photoEngine');
|
||||||
pe.value = ['auto', 'server', 'client'].includes(s.photo_enhance_engine) ? s.photo_enhance_engine : 'auto';
|
pe.value = ['auto', 'server', 'client'].includes(s.photo_enhance_engine) ? s.photo_enhance_engine : 'auto';
|
||||||
if (!pe.value) pe.value = 'auto';
|
if (!pe.value) pe.value = 'auto';
|
||||||
|
document.getElementById('camEnabled').checked = s.camera_enabled !== 'false';
|
||||||
snapshotDirty();
|
snapshotDirty();
|
||||||
updateSpamUi();
|
updateSpamUi();
|
||||||
loadAiQueue();
|
loadAiQueue();
|
||||||
@@ -522,6 +523,7 @@ async function saveSettings() {
|
|||||||
photo_capture_resolution: document.getElementById('photoResolution').value,
|
photo_capture_resolution: document.getElementById('photoResolution').value,
|
||||||
photo_capture_quality: String(document.getElementById('photoQuality').value),
|
photo_capture_quality: String(document.getElementById('photoQuality').value),
|
||||||
photo_enhance_engine: document.getElementById('photoEngine').value,
|
photo_enhance_engine: document.getElementById('photoEngine').value,
|
||||||
|
camera_enabled: document.getElementById('camEnabled').checked ? 'true' : 'false',
|
||||||
} })
|
} })
|
||||||
});
|
});
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
|
|||||||
@@ -208,6 +208,11 @@
|
|||||||
<h3>Фотография с веб-камеры</h3>
|
<h3>Фотография с веб-камеры</h3>
|
||||||
</div>
|
</div>
|
||||||
<p class="hint">Разрешение и качество снимка на форме ответа ученика. Камера может выдать разрешение, ближайшее к выбранному.</p>
|
<p class="hint">Разрешение и качество снимка на форме ответа ученика. Камера может выдать разрешение, ближайшее к выбранному.</p>
|
||||||
|
<label class="toggle">
|
||||||
|
<input type="checkbox" id="camEnabled">
|
||||||
|
<span class="toggle-track"><span class="toggle-thumb"></span></span>
|
||||||
|
<span class="toggle-text"><b>Кнопка «Камера»</b><small>Показывать кнопку «Камера» на форме ответа. Если выключено, фото делается кликом по превью «Нажми чтобы сделать фото»</small></span>
|
||||||
|
</label>
|
||||||
<div class="settings-row">
|
<div class="settings-row">
|
||||||
<div class="settings-field">
|
<div class="settings-field">
|
||||||
<label for="photoResolution">Разрешение</label>
|
<label for="photoResolution">Разрешение</label>
|
||||||
|
|||||||
+3
-3
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"full": "d943b77f585041cbb8ae8efed31cc3e326e1e140",
|
"full": "e4704c2dc69361318d9aab490128f30c4d875bda",
|
||||||
"short": "d943b77",
|
"short": "e4704c2",
|
||||||
"date": "2026-09-23T23:01:36+03:00"
|
"date": "2026-09-24T11:21:58+03:00"
|
||||||
}
|
}
|
||||||
@@ -1038,8 +1038,8 @@ app.get('/api/settings', requireAdmin, async (_, res) => {
|
|||||||
|
|
||||||
app.get('/api/public-settings', apiLimiter, async (_, res) => {
|
app.get('/api/public-settings', apiLimiter, async (_, res) => {
|
||||||
const out = await cacheWrap('public-settings', PUBLIC_TTL_MS, async () => {
|
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 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' };
|
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 = {};
|
const result = {};
|
||||||
for (const k of keys) result[k] = await getSetting(k, defaults[k] || '');
|
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);
|
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 ensurePhotoOriginalColumn(); } catch (err) { console.error('Entry original photo column:', err); }
|
||||||
try { await ensureEntryAiColumns(); } catch (err) { console.error('Entry AI columns:', 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 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); }
|
try { await sweepOrphanedUploads(); } catch (err) { console.error('Upload sweep:', err); }
|
||||||
entryAutoChecker = createEntryAutoChecker({ pool, getSetting, logAudit, aiUrl: AI_URL, defaultPrompt: AI_DEFAULT_PROMPT });
|
entryAutoChecker = createEntryAutoChecker({ pool, getSetting, logAudit, aiUrl: AI_URL, defaultPrompt: AI_DEFAULT_PROMPT });
|
||||||
entryAutoChecker.start();
|
entryAutoChecker.start();
|
||||||
|
|||||||
Reference in New Issue
Block a user