Add capture template system with in-memory defaults and split device settings UI
Some checks failed
Validate / validate (push) Failing after 8s

- Generate default templates (MSS, DXcam, WGC) in memory from EngineRegistry at startup
- Only persist user-created templates to JSON, skip defaults on load/save
- Add capture_template_id to Device model and DeviceCreate schema
- Remember last used template in localStorage, use it for new devices with fallback
- Split Device Settings dialog into General Settings and Capture Settings
- Add capture settings button (🎬) to device card
- Separate default and custom templates with visual separator in Templates tab
- Add capture engine integration to ProcessorManager
- Add CLAUDE.md with git commit/push policy and server restart instructions
- Add en/ru localization for all new UI elements

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-10 02:43:49 +03:00
parent b5545d3198
commit 5370d80466
15 changed files with 772 additions and 106 deletions

View File

@@ -630,6 +630,9 @@ function createDeviceCard(device) {
<button class="btn btn-icon btn-secondary" onclick="showSettings('${device.id}')" title="${t('device.button.settings')}">
⚙️
</button>
<button class="btn btn-icon btn-secondary" onclick="showCaptureSettings('${device.id}')" title="${t('device.button.capture_settings')}">
🎬
</button>
<button class="btn btn-icon btn-secondary" onclick="showCalibration('${device.id}')" title="${t('device.button.calibrate')}">
📐
</button>
@@ -737,12 +740,7 @@ async function removeDevice(deviceId) {
async function showSettings(deviceId) {
try {
// Fetch device data, displays, and templates in parallel
const [deviceResponse, displaysResponse, templatesResponse] = await Promise.all([
fetch(`${API_BASE}/devices/${deviceId}`, { headers: getHeaders() }),
fetch(`${API_BASE}/config/displays`, { headers: getHeaders() }),
fetchWithAuth('/capture-templates'),
]);
const deviceResponse = await fetch(`${API_BASE}/devices/${deviceId}`, { headers: getHeaders() });
if (deviceResponse.status === 401) {
handle401Error();
@@ -756,48 +754,7 @@ async function showSettings(deviceId) {
const device = await deviceResponse.json();
// Populate display index select
const displaySelect = document.getElementById('settings-display-index');
displaySelect.innerHTML = '';
if (displaysResponse.ok) {
const displaysData = await displaysResponse.json();
(displaysData.displays || []).forEach(d => {
const opt = document.createElement('option');
opt.value = d.index;
opt.textContent = `${d.index}: ${d.width}x${d.height}${d.is_primary ? ` (${t('displays.badge.primary')})` : ''}`;
displaySelect.appendChild(opt);
});
}
if (displaySelect.options.length === 0) {
const opt = document.createElement('option');
opt.value = '0';
opt.textContent = '0';
displaySelect.appendChild(opt);
}
displaySelect.value = String(device.settings.display_index ?? 0);
// Populate capture template select
const templateSelect = document.getElementById('settings-capture-template');
templateSelect.innerHTML = '';
if (templatesResponse.ok) {
const templatesData = await templatesResponse.json();
(templatesData.templates || []).forEach(t => {
const opt = document.createElement('option');
opt.value = t.id;
const engineIcon = getEngineIcon(t.engine_type);
opt.textContent = `${engineIcon} ${t.name}`;
templateSelect.appendChild(opt);
});
}
if (templateSelect.options.length === 0) {
const opt = document.createElement('option');
opt.value = 'tpl_mss_default';
opt.textContent = 'MSS (Default)';
templateSelect.appendChild(opt);
}
templateSelect.value = device.capture_template_id || 'tpl_mss_default';
// Populate other fields
// Populate fields
document.getElementById('settings-device-id').value = device.id;
document.getElementById('settings-device-name').value = device.name;
document.getElementById('settings-device-url').value = device.url;
@@ -807,9 +764,7 @@ async function showSettings(deviceId) {
settingsInitialValues = {
name: device.name,
url: device.url,
display_index: String(device.settings.display_index ?? 0),
state_check_interval: String(device.settings.state_check_interval || 30),
capture_template_id: device.capture_template_id || 'tpl_mss_default',
};
// Show modal
@@ -832,9 +787,7 @@ function isSettingsDirty() {
return (
document.getElementById('settings-device-name').value !== settingsInitialValues.name ||
document.getElementById('settings-device-url').value !== settingsInitialValues.url ||
document.getElementById('settings-display-index').value !== settingsInitialValues.display_index ||
document.getElementById('settings-health-interval').value !== settingsInitialValues.state_check_interval ||
document.getElementById('settings-capture-template').value !== settingsInitialValues.capture_template_id
document.getElementById('settings-health-interval').value !== settingsInitialValues.state_check_interval
);
}
@@ -859,9 +812,7 @@ async function saveDeviceSettings() {
const deviceId = document.getElementById('settings-device-id').value;
const name = document.getElementById('settings-device-name').value.trim();
const url = document.getElementById('settings-device-url').value.trim();
const display_index = parseInt(document.getElementById('settings-display-index').value) || 0;
const state_check_interval = parseInt(document.getElementById('settings-health-interval').value) || 30;
const capture_template_id = document.getElementById('settings-capture-template').value;
const error = document.getElementById('settings-error');
// Validation
@@ -872,11 +823,11 @@ async function saveDeviceSettings() {
}
try {
// Update device info (name, url, capture_template_id)
// Update device info (name, url)
const deviceResponse = await fetch(`${API_BASE}/devices/${deviceId}`, {
method: 'PUT',
headers: getHeaders(),
body: JSON.stringify({ name, url, capture_template_id })
body: JSON.stringify({ name, url })
});
if (deviceResponse.status === 401) {
@@ -895,7 +846,7 @@ async function saveDeviceSettings() {
const settingsResponse = await fetch(`${API_BASE}/devices/${deviceId}/settings`, {
method: 'PUT',
headers: getHeaders(),
body: JSON.stringify({ display_index, state_check_interval })
body: JSON.stringify({ state_check_interval })
});
if (settingsResponse.status === 401) {
@@ -904,7 +855,7 @@ async function saveDeviceSettings() {
}
if (settingsResponse.ok) {
showToast('Device settings updated', 'success');
showToast(t('settings.saved'), 'success');
forceCloseDeviceSettingsModal();
loadDevices();
} else {
@@ -919,6 +870,170 @@ async function saveDeviceSettings() {
}
}
// ===== Capture Settings Modal =====
let captureSettingsInitialValues = {};
async function showCaptureSettings(deviceId) {
try {
// Fetch device data, displays, and templates in parallel
const [deviceResponse, displaysResponse, templatesResponse] = await Promise.all([
fetch(`${API_BASE}/devices/${deviceId}`, { headers: getHeaders() }),
fetch(`${API_BASE}/config/displays`, { headers: getHeaders() }),
fetchWithAuth('/capture-templates'),
]);
if (deviceResponse.status === 401) {
handle401Error();
return;
}
if (!deviceResponse.ok) {
showToast('Failed to load capture settings', 'error');
return;
}
const device = await deviceResponse.json();
// Populate display index select
const displaySelect = document.getElementById('capture-settings-display-index');
displaySelect.innerHTML = '';
if (displaysResponse.ok) {
const displaysData = await displaysResponse.json();
(displaysData.displays || []).forEach(d => {
const opt = document.createElement('option');
opt.value = d.index;
opt.textContent = `${d.index}: ${d.width}x${d.height}${d.is_primary ? ` (${t('displays.badge.primary')})` : ''}`;
displaySelect.appendChild(opt);
});
}
if (displaySelect.options.length === 0) {
const opt = document.createElement('option');
opt.value = '0';
opt.textContent = '0';
displaySelect.appendChild(opt);
}
displaySelect.value = String(device.settings.display_index ?? 0);
// Populate capture template select
const templateSelect = document.getElementById('capture-settings-template');
templateSelect.innerHTML = '';
if (templatesResponse.ok) {
const templatesData = await templatesResponse.json();
(templatesData.templates || []).forEach(t => {
const opt = document.createElement('option');
opt.value = t.id;
const engineIcon = getEngineIcon(t.engine_type);
opt.textContent = `${engineIcon} ${t.name}`;
templateSelect.appendChild(opt);
});
}
if (templateSelect.options.length === 0) {
const opt = document.createElement('option');
opt.value = 'tpl_mss_default';
opt.textContent = 'MSS (Default)';
templateSelect.appendChild(opt);
}
templateSelect.value = device.capture_template_id || 'tpl_mss_default';
// Store device ID and snapshot initial values
document.getElementById('capture-settings-device-id').value = device.id;
captureSettingsInitialValues = {
display_index: String(device.settings.display_index ?? 0),
capture_template_id: device.capture_template_id || 'tpl_mss_default',
};
// Show modal
const modal = document.getElementById('capture-settings-modal');
modal.style.display = 'flex';
lockBody();
} catch (error) {
console.error('Failed to load capture settings:', error);
showToast('Failed to load capture settings', 'error');
}
}
function isCaptureSettingsDirty() {
return (
document.getElementById('capture-settings-display-index').value !== captureSettingsInitialValues.display_index ||
document.getElementById('capture-settings-template').value !== captureSettingsInitialValues.capture_template_id
);
}
function forceCloseCaptureSettingsModal() {
const modal = document.getElementById('capture-settings-modal');
const error = document.getElementById('capture-settings-error');
modal.style.display = 'none';
error.style.display = 'none';
unlockBody();
captureSettingsInitialValues = {};
}
async function closeCaptureSettingsModal() {
if (isCaptureSettingsDirty()) {
const confirmed = await showConfirm(t('modal.discard_changes'));
if (!confirmed) return;
}
forceCloseCaptureSettingsModal();
}
async function saveCaptureSettings() {
const deviceId = document.getElementById('capture-settings-device-id').value;
const display_index = parseInt(document.getElementById('capture-settings-display-index').value) || 0;
const capture_template_id = document.getElementById('capture-settings-template').value;
const error = document.getElementById('capture-settings-error');
try {
// Update capture template on device
const deviceResponse = await fetch(`${API_BASE}/devices/${deviceId}`, {
method: 'PUT',
headers: getHeaders(),
body: JSON.stringify({ capture_template_id })
});
if (deviceResponse.status === 401) {
handle401Error();
return;
}
if (!deviceResponse.ok) {
const errorData = await deviceResponse.json();
error.textContent = `Failed to update capture template: ${errorData.detail}`;
error.style.display = 'block';
return;
}
// Update display index in settings
const settingsResponse = await fetch(`${API_BASE}/devices/${deviceId}/settings`, {
method: 'PUT',
headers: getHeaders(),
body: JSON.stringify({ display_index })
});
if (settingsResponse.status === 401) {
handle401Error();
return;
}
if (settingsResponse.ok) {
// Remember last used template for new device creation
localStorage.setItem('lastCaptureTemplateId', capture_template_id);
showToast(t('settings.capture.saved'), 'success');
forceCloseCaptureSettingsModal();
loadDevices();
} else {
const errorData = await settingsResponse.json();
error.textContent = `Failed to update settings: ${errorData.detail}`;
error.style.display = 'block';
}
} catch (err) {
console.error('Failed to save capture settings:', err);
error.textContent = t('settings.capture.failed');
error.style.display = 'block';
}
}
// Card brightness controls
function updateBrightnessLabel(deviceId, value) {
const slider = document.querySelector(`[data-device-id="${deviceId}"] .brightness-slider`);
@@ -971,10 +1086,16 @@ async function handleAddDevice(event) {
}
try {
const body = { name, url };
const lastTemplateId = localStorage.getItem('lastCaptureTemplateId');
if (lastTemplateId) {
body.capture_template_id = lastTemplateId;
}
const response = await fetch(`${API_BASE}/devices`, {
method: 'POST',
headers: getHeaders(),
body: JSON.stringify({ name, url })
body: JSON.stringify(body)
});
if (response.status === 401) {
@@ -1928,12 +2049,18 @@ document.addEventListener('click', (e) => {
return;
}
// Settings modal: dirty check
// General settings modal: dirty check
if (modalId === 'device-settings-modal') {
closeDeviceSettingsModal();
return;
}
// Capture settings modal: dirty check
if (modalId === 'capture-settings-modal') {
closeCaptureSettingsModal();
return;
}
// Calibration modal: dirty check
if (modalId === 'calibration-modal') {
closeCalibrationModal();
@@ -1977,8 +2104,9 @@ const deviceTutorialSteps = [
{ selector: '.brightness-control', textKey: 'device.tip.brightness', position: 'bottom' },
{ selector: '.card-actions .btn:nth-child(1)', textKey: 'device.tip.start', position: 'top' },
{ selector: '.card-actions .btn:nth-child(2)', textKey: 'device.tip.settings', position: 'top' },
{ selector: '.card-actions .btn:nth-child(3)', textKey: 'device.tip.calibrate', position: 'top' },
{ selector: '.card-actions .btn:nth-child(4)', textKey: 'device.tip.webui', position: 'top' }
{ selector: '.card-actions .btn:nth-child(3)', textKey: 'device.tip.capture_settings', position: 'top' },
{ selector: '.card-actions .btn:nth-child(4)', textKey: 'device.tip.calibrate', position: 'top' },
{ selector: '.card-actions .btn:nth-child(5)', textKey: 'device.tip.webui', position: 'top' }
];
function startTutorial(config) {
@@ -2218,7 +2346,10 @@ function renderTemplatesList(templates) {
return;
}
container.innerHTML = templates.map(template => {
const defaultTemplates = templates.filter(t => t.is_default);
const customTemplates = templates.filter(t => !t.is_default);
const renderCard = (template) => {
const engineIcon = getEngineIcon(template.engine_type);
const defaultBadge = template.is_default
? `<span class="badge badge-default">${t('templates.default')}</span>`
@@ -2258,10 +2389,21 @@ function renderTemplatesList(templates) {
</div>
</div>
`;
}).join('') + `<div class="template-card add-template-card" onclick="showAddTemplateModal()">
};
let html = defaultTemplates.map(renderCard).join('');
if (customTemplates.length > 0) {
html += `<div class="templates-separator"><span>${t('templates.custom')}</span></div>`;
html += customTemplates.map(renderCard).join('');
}
html += `<div class="template-card add-template-card" onclick="showAddTemplateModal()">
<div class="add-template-icon">+</div>
<div class="add-template-label">${t('templates.add')}</div>
</div>`;
container.innerHTML = html;
}
// Get engine icon