mirror of
https://github.com/screentinker/screentinker.git
synced 2026-08-14 14:23:14 -06:00
Adding a 32s video gave it the flat 10s default, so it was cut off mid-play unless the operator looked up the runtime and typed it — per item, every time. The content row already carries the probed duration; it now becomes the default. The rule lives in one place (lib/item-duration.js) because the operator sees one product, not six insert paths: playlist add, assign-to-display, group assign, agency portal, content-only schedule, and the public API all share it. Only the playlist route defaulted before, and it stored the raw probe (31.7) which the Android player's optInt read silently truncated back to 31. Explicit values always win. Content with no trustworthy duration (image, widget, YouTube, remote URL, failed probe) keeps the 10s default, and a duration that is 0/negative/NaN or absurd (> 12h, i.e. a broken probe) falls back rather than reaching a device — a 0 makes the players schedule a 0ms advance, which self-loops and black-screens the TV. Dashboard: the add-item picker shows a clip's length, the assign-to-display modal pre-fills the duration field from the selected clip (never overwriting a value the operator typed), and onboarding stops hardcoding 10 on the first assignment. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
248 lines
12 KiB
JavaScript
248 lines
12 KiB
JavaScript
import { showToast } from '../components/toast.js';
|
|
import { t } from '../i18n.js';
|
|
|
|
// Steps are computed lazily so translated strings refresh on language change.
|
|
function getSteps() {
|
|
return [
|
|
{
|
|
title: t('onboarding.step.welcome.title'),
|
|
icon: '👋',
|
|
content: `<p style="font-size:16px;color:var(--text-secondary);margin-bottom:16px">${t('onboarding.step.welcome.intro')}</p>
|
|
<p style="color:var(--text-muted);font-size:14px">${t('onboarding.step.welcome.guide_through')}</p>
|
|
<ul style="color:var(--text-muted);font-size:14px;padding-left:20px;margin-top:8px;line-height:2">
|
|
<li>${t('onboarding.step.welcome.bullet_download')}</li>
|
|
<li>${t('onboarding.step.welcome.bullet_pair')}</li>
|
|
<li>${t('onboarding.step.welcome.bullet_upload')}</li>
|
|
</ul>`,
|
|
action: null
|
|
},
|
|
{
|
|
title: t('onboarding.step.player.title'),
|
|
icon: '📥',
|
|
content: `<p style="color:var(--text-secondary);margin-bottom:16px">${t('onboarding.step.player.intro')}</p>
|
|
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px">
|
|
<a href="/download/apk" style="background:var(--bg-input);border:1px solid var(--border);border-radius:8px;padding:16px;text-align:center;text-decoration:none;color:var(--text-primary)">
|
|
<div style="font-size:32px;margin-bottom:8px">🤖</div>
|
|
<div style="font-weight:600;font-size:14px">${t('onboarding.step.player.android_label')}</div>
|
|
<div style="font-size:11px;color:var(--text-muted);margin-top:4px">${t('onboarding.step.player.android_desc')}</div>
|
|
</a>
|
|
<a href="/player" target="_blank" style="background:var(--bg-input);border:1px solid var(--border);border-radius:8px;padding:16px;text-align:center;text-decoration:none;color:var(--text-primary)">
|
|
<div style="font-size:32px;margin-bottom:8px">🌐</div>
|
|
<div style="font-weight:600;font-size:14px">${t('onboarding.step.player.web_label')}</div>
|
|
<div style="font-size:11px;color:var(--text-muted);margin-top:4px">${t('onboarding.step.player.web_desc')}</div>
|
|
</a>
|
|
</div>
|
|
<p style="color:var(--text-muted);font-size:12px;margin-top:12px">${t('onboarding.step.player.url_hint')}</p>
|
|
<code style="display:block;background:var(--bg-input);padding:10px;border-radius:6px;margin-top:6px;font-size:14px;user-select:all">${window.location.origin}</code>`,
|
|
action: null
|
|
},
|
|
{
|
|
title: t('onboarding.step.pair.title'),
|
|
icon: '🔗',
|
|
content: `<p style="color:var(--text-secondary);margin-bottom:16px">${t('onboarding.step.pair.intro')}</p>
|
|
<div style="text-align:center;margin:20px 0">
|
|
<input type="text" id="onboardPairingCode" maxlength="6" pattern="[0-9]{6}" placeholder="000000"
|
|
style="max-width:240px;width:100%;padding:16px;background:var(--bg-input);border:1px solid var(--border);border-radius:8px;
|
|
color:var(--text-primary);font-size:32px;font-weight:700;text-align:center;letter-spacing:8px;font-family:monospace">
|
|
</div>
|
|
<div style="text-align:center">
|
|
<input type="text" id="onboardDeviceName" placeholder="${t('onboarding.step.pair.name_placeholder')}"
|
|
style="max-width:240px;width:100%;padding:10px;background:var(--bg-input);border:1px solid var(--border);border-radius:8px;color:var(--text-primary);font-size:14px;text-align:center">
|
|
</div>
|
|
<p id="onboardPairStatus" style="color:var(--text-muted);font-size:13px;text-align:center;margin-top:12px"></p>`,
|
|
action: 'pair'
|
|
},
|
|
{
|
|
title: t('onboarding.step.upload.title'),
|
|
icon: '📤',
|
|
content: `<p style="color:var(--text-secondary);margin-bottom:16px">${t('onboarding.step.upload.intro')}</p>
|
|
<div style="border:2px dashed var(--border);border-radius:12px;padding:32px;text-align:center;cursor:pointer" id="onboardUploadArea">
|
|
<div style="font-size:32px;margin-bottom:8px">📁</div>
|
|
<p style="color:var(--text-secondary)">${t('onboarding.step.upload.click_to_select')}</p>
|
|
<p style="color:var(--text-muted);font-size:12px;margin-top:4px">${t('onboarding.step.upload.formats')}</p>
|
|
<input type="file" id="onboardFileInput" style="display:none" accept="video/*,image/*">
|
|
</div>
|
|
<div id="onboardUploadProgress" style="display:none;margin-top:12px">
|
|
<div style="height:4px;background:var(--bg-primary);border-radius:2px;overflow:hidden">
|
|
<div id="onboardProgressBar" style="height:100%;background:var(--accent);width:0%;transition:width 0.3s"></div>
|
|
</div>
|
|
<p id="onboardUploadText" style="font-size:12px;color:var(--text-muted);margin-top:6px">${t('onboarding.step.upload.uploading')}</p>
|
|
</div>`,
|
|
action: 'upload'
|
|
},
|
|
{
|
|
title: t('onboarding.step.done.title'),
|
|
icon: '🎉',
|
|
content: `<p style="font-size:16px;color:var(--text-secondary);margin-bottom:20px">${t('onboarding.step.done.intro')}</p>
|
|
<div style="background:var(--bg-input);border-radius:8px;padding:16px;margin-bottom:16px">
|
|
<p style="font-size:14px;color:var(--text-primary);font-weight:600;margin-bottom:8px">${t('onboarding.step.done.whats_next')}</p>
|
|
<ul style="color:var(--text-muted);font-size:13px;padding-left:20px;line-height:2">
|
|
<li>${t('onboarding.step.done.next_content')}</li>
|
|
<li>${t('onboarding.step.done.next_layouts')}</li>
|
|
<li>${t('onboarding.step.done.next_schedule')}</li>
|
|
<li>${t('onboarding.step.done.next_widgets')}</li>
|
|
<li>${t('onboarding.step.done.next_kiosk')}</li>
|
|
<li>${t('onboarding.step.done.next_designer')}</li>
|
|
</ul>
|
|
</div>`,
|
|
action: null
|
|
}
|
|
];
|
|
}
|
|
|
|
export function render(container) {
|
|
let currentStep = 0;
|
|
let pairedDeviceId = null;
|
|
|
|
function renderStep() {
|
|
const STEPS = getSteps();
|
|
const step = STEPS[currentStep];
|
|
const isFirst = currentStep === 0;
|
|
const isLast = currentStep === STEPS.length - 1;
|
|
|
|
container.innerHTML = `
|
|
<div style="display:flex;align-items:center;justify-content:center;min-height:calc(100vh - 48px)">
|
|
<div style="width:560px;max-width:95vw">
|
|
<!-- Progress -->
|
|
<div style="display:flex;gap:4px;margin-bottom:32px">
|
|
${STEPS.map((_, i) => `<div style="flex:1;height:4px;border-radius:2px;background:${i <= currentStep ? 'var(--accent)' : 'var(--border)'}"></div>`).join('')}
|
|
</div>
|
|
|
|
<div style="text-align:center;margin-bottom:24px">
|
|
<div style="font-size:48px;margin-bottom:12px">${step.icon}</div>
|
|
<h2 style="font-size:24px">${step.title}</h2>
|
|
</div>
|
|
|
|
<div style="background:var(--bg-card);border:1px solid var(--border);border-radius:12px;padding:24px;margin-bottom:24px">
|
|
${step.content}
|
|
</div>
|
|
|
|
<div style="display:flex;justify-content:space-between">
|
|
${isFirst ? '<div></div>' : `<button class="btn btn-secondary" id="prevBtn">${t('onboarding.back')}</button>`}
|
|
<div style="display:flex;gap:8px">
|
|
${!isLast ? `<button class="btn btn-secondary" id="skipBtn" style="color:var(--text-muted)">${t('onboarding.skip')}</button>` : ''}
|
|
<button class="btn btn-primary" id="nextBtn">${isLast ? t('onboarding.go_to_dashboard') : step.action === 'pair' ? t('onboarding.pair_display') : t('onboarding.next')}</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
document.getElementById('prevBtn')?.addEventListener('click', () => { currentStep--; renderStep(); });
|
|
document.getElementById('skipBtn')?.addEventListener('click', () => {
|
|
localStorage.setItem('rd_onboarded', 'true');
|
|
window.location.hash = '#/';
|
|
window.location.reload();
|
|
});
|
|
document.getElementById('nextBtn')?.addEventListener('click', handleNext);
|
|
|
|
if (step.action === 'upload') {
|
|
const area = document.getElementById('onboardUploadArea');
|
|
const input = document.getElementById('onboardFileInput');
|
|
area?.addEventListener('click', () => input.click());
|
|
input?.addEventListener('change', handleUpload);
|
|
}
|
|
}
|
|
|
|
async function handleNext() {
|
|
const STEPS = getSteps();
|
|
const step = STEPS[currentStep];
|
|
|
|
if (step.action === 'pair') {
|
|
const code = document.getElementById('onboardPairingCode')?.value.trim();
|
|
const name = document.getElementById('onboardDeviceName')?.value.trim();
|
|
const status = document.getElementById('onboardPairStatus');
|
|
|
|
if (!code || code.length !== 6) {
|
|
if (status) status.textContent = t('onboarding.toast.invalid_code');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
if (status) status.textContent = t('onboarding.toast.pairing');
|
|
const token = localStorage.getItem('token');
|
|
const res = await fetch('/api/provision/pair', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
|
body: JSON.stringify({ pairing_code: code, name: name || undefined })
|
|
});
|
|
const data = await res.json();
|
|
if (!res.ok) { if (status) status.textContent = data.error || t('onboarding.toast.pair_failed'); return; }
|
|
pairedDeviceId = data.id;
|
|
showToast(t('onboarding.toast.paired'), 'success');
|
|
currentStep++;
|
|
renderStep();
|
|
} catch (err) {
|
|
if (status) status.textContent = t('onboarding.toast.pair_failed_with_error', { error: err.message });
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (currentStep === STEPS.length - 1) {
|
|
localStorage.setItem('rd_onboarded', 'true');
|
|
window.location.hash = '#/';
|
|
window.location.reload();
|
|
return;
|
|
}
|
|
|
|
currentStep++;
|
|
renderStep();
|
|
}
|
|
|
|
async function handleUpload() {
|
|
const file = document.getElementById('onboardFileInput')?.files[0];
|
|
if (!file) return;
|
|
|
|
const progress = document.getElementById('onboardUploadProgress');
|
|
const bar = document.getElementById('onboardProgressBar');
|
|
const text = document.getElementById('onboardUploadText');
|
|
if (progress) progress.style.display = 'block';
|
|
|
|
try {
|
|
const token = localStorage.getItem('token');
|
|
const formData = new FormData();
|
|
formData.append('file', file);
|
|
|
|
const xhr = new XMLHttpRequest();
|
|
xhr.open('POST', '/api/content');
|
|
xhr.setRequestHeader('Authorization', `Bearer ${token}`);
|
|
xhr.upload.onprogress = (e) => {
|
|
if (e.lengthComputable && bar) bar.style.width = Math.round((e.loaded / e.total) * 100) + '%';
|
|
};
|
|
xhr.onload = async () => {
|
|
if (xhr.status >= 200 && xhr.status < 300) {
|
|
const content = JSON.parse(xhr.responseText);
|
|
if (text) text.textContent = t('onboarding.toast.uploaded_assigning');
|
|
|
|
if (pairedDeviceId) {
|
|
try {
|
|
await fetch(`/api/assignments/device/${pairedDeviceId}`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
|
// #237: no duration_sec — onboarding never asked the operator for one, and a
|
|
// hardcoded 10 cut their very first video off mid-play. Omitting it lets the
|
|
// server default to the clip's own length (still 10s for a photo).
|
|
body: JSON.stringify({ content_id: content.id })
|
|
});
|
|
} catch {}
|
|
}
|
|
|
|
showToast(t('onboarding.toast.content_assigned'), 'success');
|
|
currentStep++;
|
|
renderStep();
|
|
} else {
|
|
if (text) text.textContent = t('onboarding.toast.upload_failed');
|
|
}
|
|
};
|
|
xhr.onerror = () => { if (text) text.textContent = t('onboarding.toast.upload_failed'); };
|
|
xhr.send(formData);
|
|
} catch (err) {
|
|
if (text) text.textContent = t('onboarding.toast.error_with_error', { error: err.message });
|
|
}
|
|
}
|
|
|
|
renderStep();
|
|
}
|
|
|
|
export function cleanup() {}
|