const PageScene = {
_el: null,
_currentGameId: null,
_currentGameName: '',
_editorSceneId: null,
_editorCanvasIdx: 0,
_placements: [],
_dashboards: [],
_dragItem: null,
_dragOffsetX: 0,
_dragOffsetY: 0,
_resizeItem: null,
_resizeDir: '',
init() {
this._el = document.getElementById('page-scene');
},
async render() {
const cfg = await API.getConfig();
this._currentGameId = cfg.selected_game_id;
if (this._currentGameId) {
const games = await API.getGames();
const game = games.find(g => g.id === this._currentGameId);
this._currentGameName = game ? game.name : this._currentGameId;
this._dashboards = await API.getDashboards('all');
}
this._el.innerHTML = this._template();
this._showSceneList();
this._bindEvents();
},
async _showSceneList() {
document.getElementById('scene-list-container').style.display = 'block';
document.getElementById('scene-editor-container').style.display = 'none';
const grid = document.getElementById('scene-grid');
if (!grid) return;
try {
const scenes = await API.getScenes(this._currentGameId);
if (!scenes || scenes.length === 0) {
grid.innerHTML = `
${Lang.t('noScenes')}
${Lang.t('createFirstScene')}
`;
return;
}
grid.innerHTML = scenes.map(s => this._sceneCardHtml(s)).join('');
} catch (e) { grid.innerHTML = '' + Lang.t('loadFailed') + '
'; }
},
async _openEditor(sceneId) {
this._editorSceneId = sceneId;
this._editorCanvasIdx = 0;
this._placements = [];
let scene = null;
if (sceneId) {
scene = await API.getScene(sceneId);
if (scene && scene.canvases && scene.canvases[0]) {
this._placements = scene.canvases[0].placements || [];
}
}
const listC = document.getElementById('scene-list-container');
const editorC = document.getElementById('scene-editor-container');
listC.style.display = 'none';
editorC.style.display = 'flex';
this._renderCanvasEditor(scene);
},
_renderCanvasEditor(scene) {
const nameInput = document.getElementById('edit-scene-name');
if (nameInput && scene) nameInput.value = scene.name || '';
this._renderDashboardList();
this._renderCanvas();
},
_renderDashboardList() {
const list = document.getElementById('editor-dash-list');
if (!list) return;
const filtered = this._dashboards.filter(d => d.supported_games === 'all' || (d.supported_games||'').split(',').includes(this._currentGameId));
list.innerHTML = filtered.map(d =>
`
${d.icon||'📊'}
${d.name}
`
).join('') || '' + Lang.t('noCompatibleDashForScene') + '
';
list.querySelectorAll('.editor-dash-item').forEach(el => {
el.addEventListener('dragstart', (e) => {
this._dragItem = { dashId: el.dataset.dashId, isNew: true };
});
});
},
_renderCanvas() {
const canvas = document.getElementById('editor-canvas');
if (!canvas) return;
const cw = 1920, ch = 1080;
const scale = Math.min(canvas.parentElement.clientWidth / cw, 1);
canvas.style.width = (cw * scale) + 'px';
canvas.style.height = (ch * scale) + 'px';
canvas.innerHTML = this._placements.map((p, idx) => {
const dash = this._dashboards.find(d => d.id === p.dashboard_id);
const name = dash ? dash.name : p.dashboard_id;
const icon = dash ? (dash.icon || '📊') : '❓';
return ``;
}).join('');
canvas.querySelectorAll('.editor-placement').forEach(el => {
const idx = parseInt(el.dataset.idx);
el.addEventListener('mousedown', (e) => this._startDragPlace(e, idx, el));
el.addEventListener('click', (e) => {
if (e.target.closest('[data-action="remove-place"]')) {
this._placements.splice(idx, 1);
this._renderCanvas();
}
});
});
canvas.addEventListener('dragover', (e) => e.preventDefault());
canvas.addEventListener('drop', (e) => {
e.preventDefault();
const rect = canvas.getBoundingClientRect();
const x = (e.clientX - rect.left) / scale;
const y = (e.clientY - rect.top) / scale;
if (this._dragItem && this._dragItem.isNew) {
this._placements.push({
dashboard_id: this._dragItem.dashId,
x, y, width: 300, height: 200, z_index: this._placements.length
});
this._renderCanvas();
}
});
},
_startDragPlace(e, idx, el) {
if (e.target.closest('button')) return;
const canvasEl = document.getElementById('editor-canvas');
const rect = canvasEl.getBoundingClientRect();
const scale = parseFloat(canvasEl.style.width) / 1920;
const startX = e.clientX, startY = e.clientY;
const origP = { ...this._placements[idx] };
const onMove = (ev) => {
const dx = (ev.clientX - startX) / scale;
const dy = (ev.clientY - startY) / scale;
this._placements[idx].x = origP.x + dx;
this._placements[idx].y = origP.y + dy;
el.style.left = (this._placements[idx].x * scale) + 'px';
el.style.top = (this._placements[idx].y * scale) + 'px';
};
const onUp = () => {
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
};
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
},
async _saveScene() {
const name = document.getElementById('edit-scene-name')?.value || 'New Scene';
const data = {
name,
game_id: this._currentGameId || '',
canvases: [{ width: 1920, height: 1080, label: '16:9', placements: this._placements }]
};
try {
if (this._editorSceneId) {
await API.updateScene(this._editorSceneId, data);
} else {
const created = await API.createScene(data);
this._editorSceneId = created.id;
}
Toast.show(Lang.t('sceneSaveSuccess'), 'success');
this._showSceneList();
} catch (e) { Toast.show(Lang.t('saveFailed') + e.message, 'error'); }
},
_bindEvents() {
document.getElementById('btn-new-scene')?.addEventListener('click', () => this._openEditor(null));
document.getElementById('btn-import-scene')?.addEventListener('click', () => {
const input = document.createElement('input'); input.type = 'file'; input.accept = '.tss';
input.onchange = async (e) => {
const file = e.target.files[0]; if (!file) return;
const fd = new FormData(); fd.append('file', file);
const res = await fetch('/api/scenes/import', { method: 'POST', body: fd });
const data = await res.json();
if (data.ok) { Toast.show(Lang.t('sceneImportSuccess'), 'success'); this._showSceneList(); }
else Toast.show(Lang.t('importFailed'), 'error');
}; input.click();
});
document.getElementById('btn-back-to-list')?.addEventListener('click', () => this._showSceneList());
document.getElementById('btn-save-scene')?.addEventListener('click', () => this._saveScene());
document.getElementById('scene-grid')?.addEventListener('click', (e) => {
const card = e.target.closest('[data-scene-id]');
if (!card) return;
const action = card.dataset.action, sceneId = card.dataset.sceneId;
if (action === 'render') this._renderScene(sceneId);
else if (action === 'edit') this._openEditor(sceneId);
else if (action === 'delete') { if (confirm(Lang.t('confirmDelete'))) { API.deleteScene(sceneId); this._showSceneList(); } }
else if (action === 'export') { const a = document.createElement('a'); a.href = '/api/scenes/' + sceneId + '/export'; a.download = sceneId + '.tss'; a.click(); }
});
},
_renderScene(sceneId) {
const url = `${location.origin}/scene/${sceneId}`;
window.open(url, '_blank');
navigator.clipboard.writeText(url).then(() => Toast.show(Lang.t('linkCopiedShort'), 'success')).catch(() => {});
},
_sceneCardHtml(scene) {
const canvasCount = (scene.canvases || []).length;
const preview = scene.preview_ext ? `/scene/${scene.id}/preview.${scene.preview_ext}` : '';
return `
${preview ? `
` : ''}
${scene.name}
${this._currentGameName || Lang.t('noGame')}
${Lang.t('canvasesLabel').replace('%d', canvasCount)}${scene.updated_at ? new Date(scene.updated_at).toLocaleDateString() : ''}
${(scene.canvases||[]).map(c => `${c.label||c.width+'x'+c.height}`).join('')}
`;
},
_template() {
return `
${Lang.t('dashListDrag')}
`;
}
};
window.PageScene = PageScene;