269 lines
13 KiB
JavaScript
269 lines
13 KiB
JavaScript
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 = `<div class="empty-state" style="grid-column:1/-1"><h4>${Lang.t('noScenes')}</h4><p>${Lang.t('createFirstScene')}</p></div>`;
|
||
return;
|
||
}
|
||
grid.innerHTML = scenes.map(s => this._sceneCardHtml(s)).join('');
|
||
} catch (e) { grid.innerHTML = '<div class="empty-state"><p>' + Lang.t('loadFailed') + '</p></div>'; }
|
||
},
|
||
|
||
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 =>
|
||
`<div class="glass-card editor-dash-item" draggable="true" data-dash-id="${d.id}" style="padding:10px;margin-bottom:6px;cursor:grab;font-size:13px;display:flex;align-items:center;gap:8px">
|
||
<span>${d.icon||'📊'}</span>
|
||
<span style="flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${d.name}</span>
|
||
</div>`
|
||
).join('') || '<p style="color:var(--text-tertiary);font-size:12px">' + Lang.t('noCompatibleDashForScene') + '</p>';
|
||
|
||
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 `<div class="editor-placement" data-idx="${idx}" style="left:${p.x*scale}px;top:${p.y*scale}px;width:${p.width*scale}px;height:${p.height*scale}px;z-index:${p.z_index||0}">
|
||
<div class="ep-icon">${icon}</div>
|
||
<div class="ep-name">${name}</div>
|
||
<div class="ep-actions">
|
||
<button class="btn btn-sm btn-danger" data-action="remove-place" data-idx="${idx}">×</button>
|
||
</div>
|
||
</div>`;
|
||
}).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 `<div class="glass-card scene-card" data-scene-id="${scene.id}" data-action="render">
|
||
${preview ? `<div style="height:120px;background:url(${preview}) center/cover;border-radius:8px 8px 0 0"></div>` : ''}
|
||
<div class="scene-card-name" style="${preview?'':'padding-top:18px'}">${scene.name}</div>
|
||
<div class="scene-card-game" style="margin-bottom:6px">${this._currentGameName || Lang.t('noGame')}</div>
|
||
<div class="scene-card-meta"><span>${Lang.t('canvasesLabel').replace('%d', canvasCount)}</span><span>${scene.updated_at ? new Date(scene.updated_at).toLocaleDateString() : ''}</span></div>
|
||
<div class="scene-canvas-list">${(scene.canvases||[]).map(c => `<span class="scene-canvas-badge">${c.label||c.width+'x'+c.height}</span>`).join('')}</div>
|
||
<div style="margin-top:12px;display:flex;gap:6px;flex-wrap:wrap">
|
||
<button class="btn btn-sm btn-primary" data-scene-id="${scene.id}" data-action="render">${Lang.t('render')}</button>
|
||
<button class="btn btn-sm btn-secondary" data-scene-id="${scene.id}" data-action="edit">${Lang.t('edit')}</button>
|
||
<button class="btn btn-sm btn-secondary" data-scene-id="${scene.id}" data-action="export">${Lang.t('export')}</button>
|
||
<button class="btn btn-sm btn-danger" data-scene-id="${scene.id}" data-action="delete">${Lang.t('delete')}</button>
|
||
</div></div>`;
|
||
},
|
||
|
||
_template() {
|
||
return `
|
||
<div class="scene-header animated" id="scene-list-container">
|
||
<div>
|
||
<h2>${Lang.t('sceneManagement')}</h2>
|
||
<p style="color:var(--text-secondary);font-size:14px;margin-top:4px">${this._currentGameId ? Lang.t('currentGamePrefix') + this._currentGameName : Lang.t('noGameSelected')}</p>
|
||
</div>
|
||
<div style="display:flex;gap:8px">
|
||
<button id="btn-import-scene" class="btn btn-secondary btn-sm">${Lang.t('importTss')}</button>
|
||
<button id="btn-new-scene" class="btn btn-primary btn-sm" ${!this._currentGameId ? 'disabled' : ''}>${Lang.t('newScene')}</button>
|
||
</div>
|
||
</div>
|
||
<div id="scene-grid" class="scene-grid animated"></div>
|
||
|
||
<div id="scene-editor-container" style="display:none;flex-direction:column;height:100%">
|
||
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:12px">
|
||
<div style="display:flex;align-items:center;gap:12px">
|
||
<button id="btn-back-to-list" class="btn btn-sm btn-secondary">${Lang.t('backToList')}</button>
|
||
<input id="edit-scene-name" placeholder="${Lang.t('sceneName')}" style="font-size:18px;font-weight:600;width:250px" value="New Scene">
|
||
</div>
|
||
<button id="btn-save-scene" class="btn btn-primary btn-sm">${Lang.t('saveScene')}</button>
|
||
</div>
|
||
<div style="display:flex;gap:12px;flex:1;min-height:0">
|
||
<div style="width:180px;flex-shrink:0;overflow-y:auto;border-right:1px solid var(--border-subtle);padding-right:10px">
|
||
<h4 style="font-size:13px;color:var(--text-secondary);margin-bottom:10px">${Lang.t('dashListDrag')}</h4>
|
||
<div id="editor-dash-list"></div>
|
||
</div>
|
||
<div style="flex:1;overflow:auto;background:var(--bg-tertiary);border-radius:8px;position:relative">
|
||
<div id="editor-canvas" style="position:relative;background:rgba(255,255,255,.02);margin:0 auto;overflow:hidden;transition:width 0.2s,height 0.2s"></div>
|
||
</div>
|
||
</div>
|
||
</div>`;
|
||
}
|
||
};
|
||
|
||
window.PageScene = PageScene;
|