Richer scene cues: full text styling (direction/effect/font/size/color) and
image size/position, plus a redesigned scene editor UI Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
144aaaa233
commit
8db2578eba
3 files changed
+161
-66
No files matched your search
+122
-47
@@ -222,17 +222,58 @@ function parseTime(str) {
|
|||||||
return parseFloat(str) || 0;
|
return parseFloat(str) || 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Snapshot the current Text-tab styling for a cue.
|
||||||
|
function captureTextStyle() {
|
||||||
|
return {
|
||||||
|
dir: $('#ticker-dir').value,
|
||||||
|
fx: $('#ticker-fx').value,
|
||||||
|
font: $('#ticker-font').value,
|
||||||
|
size: parseFloat($('#ticker-size').value),
|
||||||
|
weight: $('#ticker-bold').checked,
|
||||||
|
color: $('#ticker-color').value
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const FX_NAMES = { none: 'Nessuno', updown: 'Su-giù', wave: 'Onda', zoom: 'Zoom', flash: 'Flash', rotate: 'Rotazione' };
|
||||||
|
function textStyleSummary(ts) {
|
||||||
|
if (!ts) return 'stile attuale del pannello';
|
||||||
|
const dir = ts.dir === 'h' ? 'Orizz.' : (ts.dir === 'vup' ? 'Vert.↑' : 'Vert.↓');
|
||||||
|
return dir + ' · ' + (FX_NAMES[ts.fx] || ts.fx) + ' · ' + ts.size + 'vh';
|
||||||
|
}
|
||||||
|
const IMG_POS = { center: { x: 50, y: 50 }, top: { x: 50, y: 18 }, bottom: { x: 50, y: 82 }, left: { x: 20, y: 50 }, right: { x: 80, y: 50 } };
|
||||||
|
|
||||||
// Apply one cue (effect optional; text and image always set so a timeline reads
|
// Apply one cue (effect optional; text and image always set so a timeline reads
|
||||||
// predictably — empty text / null image hide them).
|
// predictably — empty text / null image hide them).
|
||||||
function applyCue(c) {
|
function applyCue(c) {
|
||||||
if (!c) return;
|
if (!c) return;
|
||||||
if (c.effectIndex != null && EFFECTS.list[c.effectIndex]) applyEffect(EFFECTS.list[c.effectIndex]);
|
if (c.effectIndex != null && EFFECTS.list[c.effectIndex]) applyEffect(EFFECTS.list[c.effectIndex]);
|
||||||
|
|
||||||
|
// Text + its full styling
|
||||||
const showText = !!(c.text && c.text.trim());
|
const showText = !!(c.text && c.text.trim());
|
||||||
send({ type: 'tickerText', text: c.text || '' });
|
send({ type: 'tickerText', text: c.text || '' });
|
||||||
send({ type: 'tickerOn', on: showText });
|
send({ type: 'tickerOn', on: showText });
|
||||||
$('#ticker-text').value = c.text || '';
|
$('#ticker-text').value = c.text || '';
|
||||||
$('#ticker-on').checked = showText;
|
$('#ticker-on').checked = showText;
|
||||||
send({ type: 'sceneImage', path: c.image || null });
|
const ts = c.textStyle;
|
||||||
|
if (ts) {
|
||||||
|
send({ type: 'tickerDir', value: ts.dir });
|
||||||
|
send({ type: 'tickerFx', value: ts.fx });
|
||||||
|
send({ type: 'tickerFont', value: ts.font });
|
||||||
|
send({ type: 'tickerSize', value: ts.size });
|
||||||
|
send({ type: 'tickerWeight', on: ts.weight });
|
||||||
|
send({ type: 'tickerColor', value: ts.color });
|
||||||
|
$('#ticker-dir').value = ts.dir; $('#ticker-fx').value = ts.fx;
|
||||||
|
$('#ticker-font').value = ts.font; $('#ticker-size').value = ts.size;
|
||||||
|
$('#ticker-size-val').textContent = (+ts.size).toFixed(1);
|
||||||
|
$('#ticker-bold').checked = ts.weight; $('#ticker-color').value = ts.color;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Image + size/position
|
||||||
|
if (c.image) {
|
||||||
|
const p = IMG_POS[c.imagePos || 'center'] || IMG_POS.center;
|
||||||
|
send({ type: 'sceneImage', path: c.image, size: c.imageSize || 60, x: p.x, y: p.y });
|
||||||
|
} else {
|
||||||
|
send({ type: 'sceneImage', path: null });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
function startCues(tr) {
|
function startCues(tr) {
|
||||||
activeCues = (tr.cues || []).slice().sort((a, b) => a.time - b.time);
|
activeCues = (tr.cues || []).slice().sort((a, b) => a.time - b.time);
|
||||||
@@ -308,6 +349,84 @@ function moveTrack(from, to) {
|
|||||||
|
|
||||||
let dragFrom = -1;
|
let dragFrom = -1;
|
||||||
|
|
||||||
|
function defaultCue(time) {
|
||||||
|
return { time, effectIndex: null, effectName: '', text: '', textStyle: null, image: null, imageSize: 60, imagePos: 'center' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build the per-track scene editor (timeline of cues with text + image styling).
|
||||||
|
function buildSceneEditor(tr, i, li) {
|
||||||
|
const cues = tr.cues || (tr.cues = []);
|
||||||
|
if (cues.length === 0) cues.push(defaultCue(0));
|
||||||
|
cues.sort((a, b) => a.time - b.time);
|
||||||
|
const ed = document.createElement('li');
|
||||||
|
ed.className = 'scene-editor';
|
||||||
|
|
||||||
|
const posLabels = { center: 'Centro', top: 'Alto', bottom: 'Basso', left: 'Sinistra', right: 'Destra' };
|
||||||
|
let html = '<div class="se-title">🎬 Scene a tempo <small>— la cue @00:00 parte col brano, le altre scattano al loro tempo</small></div>';
|
||||||
|
cues.forEach((c, ci) => {
|
||||||
|
html +=
|
||||||
|
'<div class="cue" data-c="' + ci + '">' +
|
||||||
|
'<div class="cue-head">' +
|
||||||
|
'<span class="cue-at">@</span>' +
|
||||||
|
'<input class="cue-time" type="text" value="' + fmtTime(Math.floor(c.time)) + '" title="Tempo (mm:ss)" />' +
|
||||||
|
'<button class="cue-eff" title="Usa l\'effetto attualmente visibile">🌀 ' + (c.effectName || 'imposta effetto') + '</button>' +
|
||||||
|
(ci > 0 ? '<button class="cue-del" title="Rimuovi cue">✕</button>' : '<span class="cue-base">base</span>') +
|
||||||
|
'</div>' +
|
||||||
|
'<div class="cue-block">' +
|
||||||
|
'<div class="cue-bh">🔤 Testo</div>' +
|
||||||
|
'<input class="cue-text textfield" placeholder="Testo (vuoto = nessuno)" />' +
|
||||||
|
'<div class="cue-row">' +
|
||||||
|
'<button class="cue-tstyle">🎨 Cattura stile</button>' +
|
||||||
|
'<span class="cue-tinfo">' + textStyleSummary(c.textStyle) + '</span>' +
|
||||||
|
(c.textStyle ? '<button class="cue-tstyle-clear" title="Stile predefinito">✕</button>' : '') +
|
||||||
|
'</div>' +
|
||||||
|
'</div>' +
|
||||||
|
'<div class="cue-block">' +
|
||||||
|
'<div class="cue-bh">🖼 Immagine</div>' +
|
||||||
|
'<div class="cue-row">' +
|
||||||
|
'<button class="cue-img">Carica…</button>' +
|
||||||
|
'<button class="cue-img-clear">✕</button>' +
|
||||||
|
'<span class="cue-name">' + (c.image ? baseName(c.image) : 'nessuna') + '</span>' +
|
||||||
|
'</div>' +
|
||||||
|
(c.image ?
|
||||||
|
'<div class="cue-row"><select class="cue-img-pos">' +
|
||||||
|
Object.keys(posLabels).map(p => '<option value="' + p + '"' + ((c.imagePos || 'center') === p ? ' selected' : '') + '>' + posLabels[p] + '</option>').join('') +
|
||||||
|
'</select><span class="cue-dim">Dim</span><input class="cue-img-size" type="range" min="10" max="100" step="1" value="' + (c.imageSize || 60) + '" /></div>'
|
||||||
|
: '') +
|
||||||
|
'</div>' +
|
||||||
|
'</div>';
|
||||||
|
});
|
||||||
|
html += '<div class="se-foot"><button class="cue-add">➕ Aggiungi cue</button><button class="se-clear">🗑 Rimuovi scena</button></div>';
|
||||||
|
ed.innerHTML = html;
|
||||||
|
|
||||||
|
ed.querySelectorAll('.cue').forEach(cueEl => {
|
||||||
|
const ci = parseInt(cueEl.dataset.c, 10);
|
||||||
|
const c = cues[ci];
|
||||||
|
cueEl.querySelector('.cue-text').value = c.text || '';
|
||||||
|
cueEl.querySelector('.cue-time').addEventListener('change', (e) => { c.time = parseTime(e.target.value); savePlaylistState(); renderPlaylist(); });
|
||||||
|
cueEl.querySelector('.cue-eff').addEventListener('click', () => { c.effectIndex = EFFECTS.list.indexOf(currentEffect); c.effectName = currentEffect.name; savePlaylistState(); renderPlaylist(); });
|
||||||
|
cueEl.querySelector('.cue-text').addEventListener('input', (e) => { c.text = e.target.value; li.querySelector('.scene-btn').classList.toggle('active', hasScene(tr)); savePlaylistState(); });
|
||||||
|
cueEl.querySelector('.cue-tstyle').addEventListener('click', () => { c.textStyle = captureTextStyle(); savePlaylistState(); renderPlaylist(); });
|
||||||
|
const tsc = cueEl.querySelector('.cue-tstyle-clear');
|
||||||
|
if (tsc) tsc.addEventListener('click', () => { c.textStyle = null; savePlaylistState(); renderPlaylist(); });
|
||||||
|
cueEl.querySelector('.cue-img').addEventListener('click', () => { sceneImgTarget = i; sceneCueTarget = ci; $('#scene-img-input').click(); });
|
||||||
|
cueEl.querySelector('.cue-img-clear').addEventListener('click', () => { c.image = null; savePlaylistState(); renderPlaylist(); });
|
||||||
|
const posSel = cueEl.querySelector('.cue-img-pos');
|
||||||
|
if (posSel) posSel.addEventListener('change', (e) => { c.imagePos = e.target.value; savePlaylistState(); });
|
||||||
|
const sizeSl = cueEl.querySelector('.cue-img-size');
|
||||||
|
if (sizeSl) sizeSl.addEventListener('input', (e) => { c.imageSize = parseInt(e.target.value, 10); savePlaylistState(); });
|
||||||
|
const del = cueEl.querySelector('.cue-del');
|
||||||
|
if (del) del.addEventListener('click', () => { cues.splice(ci, 1); savePlaylistState(); renderPlaylist(); });
|
||||||
|
});
|
||||||
|
ed.querySelector('.cue-add').addEventListener('click', () => {
|
||||||
|
const lastT = cues.length ? cues[cues.length - 1].time : 0;
|
||||||
|
cues.push(defaultCue(lastT + 30));
|
||||||
|
savePlaylistState(); renderPlaylist();
|
||||||
|
});
|
||||||
|
ed.querySelector('.se-clear').addEventListener('click', () => { tr.cues = []; sceneEditing = -1; savePlaylistState(); renderPlaylist(); });
|
||||||
|
return ed;
|
||||||
|
}
|
||||||
|
|
||||||
function renderPlaylist() {
|
function renderPlaylist() {
|
||||||
const ol = $('#playlist');
|
const ol = $('#playlist');
|
||||||
ol.innerHTML = '';
|
ol.innerHTML = '';
|
||||||
@@ -362,51 +481,7 @@ function renderPlaylist() {
|
|||||||
|
|
||||||
ol.appendChild(li);
|
ol.appendChild(li);
|
||||||
|
|
||||||
if (sceneEditing === i) {
|
if (sceneEditing === i) ol.appendChild(buildSceneEditor(tr, i, li));
|
||||||
const cues = tr.cues || (tr.cues = []);
|
|
||||||
if (cues.length === 0) cues.push({ time: 0, effectIndex: null, effectName: '', text: '', image: null });
|
|
||||||
cues.sort((a, b) => a.time - b.time);
|
|
||||||
const ed = document.createElement('li');
|
|
||||||
ed.className = 'scene-editor';
|
|
||||||
let html = '<div class="se-title">🎬 Scene a tempo del brano</div>';
|
|
||||||
cues.forEach((c, ci) => {
|
|
||||||
html += '<div class="cue" data-c="' + ci + '">' +
|
|
||||||
'<div class="se-row"><span class="cue-at">@</span>' +
|
|
||||||
'<input class="cue-time" type="text" value="' + fmtTime(Math.floor(c.time)) + '" title="Tempo (mm:ss)" />' +
|
|
||||||
'<button class="cue-eff">🌀 effetto</button>' +
|
|
||||||
'<span class="se-name">' + (c.effectName || '—') + '</span>' +
|
|
||||||
(ci > 0 ? '<button class="cue-del" title="Rimuovi cue">✕</button>' : '') +
|
|
||||||
'</div>' +
|
|
||||||
'<input class="cue-text textfield" placeholder="Testo (vuoto = nascondi)" />' +
|
|
||||||
'<div class="se-row"><button class="cue-img">🖼 Immagine</button>' +
|
|
||||||
'<button class="cue-img-clear">✕</button>' +
|
|
||||||
'<span class="se-name">' + (c.image ? baseName(c.image) : 'nessuna') + '</span></div>' +
|
|
||||||
'</div>';
|
|
||||||
});
|
|
||||||
html += '<div class="se-row"><button class="cue-add">➕ Aggiungi cue</button>' +
|
|
||||||
'<button class="se-clear">Rimuovi scena</button></div>' +
|
|
||||||
'<div class="hint-mini" style="margin:4px 0 0">La cue @00:00 parte col brano; le altre scattano al loro tempo.</div>';
|
|
||||||
ed.innerHTML = html;
|
|
||||||
ed.querySelectorAll('.cue').forEach(cueEl => {
|
|
||||||
const ci = parseInt(cueEl.dataset.c, 10);
|
|
||||||
const c = cues[ci];
|
|
||||||
cueEl.querySelector('.cue-text').value = c.text || '';
|
|
||||||
cueEl.querySelector('.cue-time').addEventListener('change', (e) => { c.time = parseTime(e.target.value); savePlaylistState(); renderPlaylist(); });
|
|
||||||
cueEl.querySelector('.cue-eff').addEventListener('click', () => { c.effectIndex = EFFECTS.list.indexOf(currentEffect); c.effectName = currentEffect.name; savePlaylistState(); renderPlaylist(); });
|
|
||||||
cueEl.querySelector('.cue-text').addEventListener('input', (e) => { c.text = e.target.value; li.querySelector('.scene-btn').classList.toggle('active', hasScene(tr)); savePlaylistState(); });
|
|
||||||
cueEl.querySelector('.cue-img').addEventListener('click', () => { sceneImgTarget = i; sceneCueTarget = ci; $('#scene-img-input').click(); });
|
|
||||||
cueEl.querySelector('.cue-img-clear').addEventListener('click', () => { c.image = null; savePlaylistState(); renderPlaylist(); });
|
|
||||||
const del = cueEl.querySelector('.cue-del');
|
|
||||||
if (del) del.addEventListener('click', () => { cues.splice(ci, 1); savePlaylistState(); renderPlaylist(); });
|
|
||||||
});
|
|
||||||
ed.querySelector('.cue-add').addEventListener('click', () => {
|
|
||||||
const lastT = cues.length ? cues[cues.length - 1].time : 0;
|
|
||||||
cues.push({ time: lastT + 30, effectIndex: null, effectName: '', text: '', image: null });
|
|
||||||
savePlaylistState(); renderPlaylist();
|
|
||||||
});
|
|
||||||
ed.querySelector('.se-clear').addEventListener('click', () => { tr.cues = []; sceneEditing = -1; savePlaylistState(); renderPlaylist(); });
|
|
||||||
ol.appendChild(ed);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
$('#playlist-count').textContent = playlist.length + (playlist.length === 1 ? ' brano' : ' brani');
|
$('#playlist-count').textContent = playlist.length + (playlist.length === 1 ? ' brano' : ' brani');
|
||||||
}
|
}
|
||||||
@@ -424,7 +499,7 @@ $('#scene-img-input').addEventListener('change', (e) => {
|
|||||||
const tr = playlist[sceneImgTarget];
|
const tr = playlist[sceneImgTarget];
|
||||||
tr.cues = tr.cues || [];
|
tr.cues = tr.cues || [];
|
||||||
if (!tr.cues[sceneCueTarget]) sceneCueTarget = 0;
|
if (!tr.cues[sceneCueTarget]) sceneCueTarget = 0;
|
||||||
if (!tr.cues[sceneCueTarget]) tr.cues.push({ time: 0, effectIndex: null, effectName: '', text: '', image: null });
|
if (!tr.cues[sceneCueTarget]) tr.cues.push(defaultCue(0));
|
||||||
tr.cues[sceneCueTarget].image = p;
|
tr.cues[sceneCueTarget].image = p;
|
||||||
savePlaylistState(); renderPlaylist();
|
savePlaylistState(); renderPlaylist();
|
||||||
}
|
}
|
||||||
|
|||||||
+9
-2
@@ -367,8 +367,15 @@ djv.onControl(async (m) => {
|
|||||||
case 'trackVideoFit': trackVideo.style.objectFit = m.value; break;
|
case 'trackVideoFit': trackVideo.style.objectFit = m.value; break;
|
||||||
|
|
||||||
case 'sceneImage':
|
case 'sceneImage':
|
||||||
if (m.path) { sceneImage.src = toFileURL(m.path); sceneImage.classList.add('show'); hideHint(); }
|
if (m.path) {
|
||||||
else { sceneImage.classList.remove('show'); sceneImage.removeAttribute('src'); }
|
sceneImage.src = toFileURL(m.path);
|
||||||
|
const s = m.size || 60;
|
||||||
|
sceneImage.style.maxWidth = s + 'vw';
|
||||||
|
sceneImage.style.maxHeight = s + 'vh';
|
||||||
|
sceneImage.style.left = (m.x != null ? m.x : 50) + '%';
|
||||||
|
sceneImage.style.top = (m.y != null ? m.y : 50) + '%';
|
||||||
|
sceneImage.classList.add('show'); hideHint();
|
||||||
|
} else { sceneImage.classList.remove('show'); sceneImage.removeAttribute('src'); }
|
||||||
break;
|
break;
|
||||||
case 'logo': setLogo(m.index, m.path ? toFileURL(m.path) : ''); break;
|
case 'logo': setLogo(m.index, m.path ? toFileURL(m.path) : ''); break;
|
||||||
case 'logoX': logos[m.index].style.left = m.value + '%'; break;
|
case 'logoX': logos[m.index].style.left = m.value + '%'; break;
|
||||||
|
|||||||
+30
-17
@@ -353,29 +353,42 @@ textarea.textarea-lg {
|
|||||||
/* Per-track scene editor */
|
/* Per-track scene editor */
|
||||||
.scene-editor {
|
.scene-editor {
|
||||||
list-style: none;
|
list-style: none;
|
||||||
margin: -2px 0 7px;
|
margin: -2px 0 9px;
|
||||||
padding: 10px;
|
padding: 12px;
|
||||||
background: rgba(110,150,255,0.10);
|
background: rgba(110,150,255,0.10);
|
||||||
border: 1px solid rgba(120,150,255,0.3);
|
border: 1px solid rgba(120,150,255,0.38);
|
||||||
border-radius: 9px;
|
border-radius: 12px;
|
||||||
}
|
}
|
||||||
.scene-editor .se-row { display: flex; align-items: center; gap: 6px; margin-bottom: 6px; }
|
.scene-editor .se-title { font-size: 12px; font-weight: 600; opacity: 0.92; margin-bottom: 10px; }
|
||||||
.scene-editor .se-row:last-child { margin-bottom: 0; }
|
.scene-editor .se-title small { font-weight: 400; opacity: 0.55; }
|
||||||
.scene-editor .se-name { font-size: 10.5px; opacity: 0.7; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
||||||
.scene-editor .se-text { margin: 0 0 6px; }
|
|
||||||
.scene-editor .se-img-clear, .scene-editor .se-clear { flex: 0 0 auto; }
|
|
||||||
.scene-editor .se-title { font-size: 11px; opacity: 0.75; margin-bottom: 8px; }
|
|
||||||
.scene-editor .cue {
|
.scene-editor .cue {
|
||||||
border-top: 1px dashed rgba(255,255,255,0.12);
|
background: rgba(0,0,0,0.22);
|
||||||
padding-top: 7px;
|
border: 1px solid rgba(255,255,255,0.08);
|
||||||
margin-top: 7px;
|
border-radius: 10px;
|
||||||
|
padding: 9px;
|
||||||
|
margin-bottom: 9px;
|
||||||
}
|
}
|
||||||
.scene-editor .cue:first-of-type { border-top: none; padding-top: 0; margin-top: 0; }
|
.scene-editor .cue-head { display: flex; align-items: center; gap: 6px; }
|
||||||
.scene-editor .cue-at { opacity: 0.5; flex: 0 0 auto; }
|
.scene-editor .cue-at { opacity: 0.5; flex: 0 0 auto; }
|
||||||
.scene-editor .cue-time { width: 58px; flex: 0 0 auto; text-align: center; padding: 6px 4px; }
|
.scene-editor .cue-time { width: 60px; flex: 0 0 auto; text-align: center; padding: 6px 4px; font-variant-numeric: tabular-nums; }
|
||||||
|
.scene-editor .cue-eff { flex: 1; min-width: 0; text-align: left; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.scene-editor .cue-del { flex: 0 0 auto; color: #ff9a9a; }
|
||||||
|
.scene-editor .cue-base { flex: 0 0 auto; font-size: 9px; opacity: 0.45; padding: 0 4px; }
|
||||||
|
|
||||||
|
.scene-editor .cue-block { background: rgba(255,255,255,0.035); border-radius: 8px; padding: 7px 8px; margin-top: 7px; }
|
||||||
|
.scene-editor .cue-bh { font-size: 9.5px; text-transform: uppercase; letter-spacing: 0.6px; opacity: 0.55; margin-bottom: 5px; }
|
||||||
.scene-editor .cue-text { width: 100%; margin: 0 0 6px; }
|
.scene-editor .cue-text { width: 100%; margin: 0 0 6px; }
|
||||||
.scene-editor .cue-eff, .scene-editor .cue-img, .scene-editor .cue-img-clear,
|
.scene-editor .cue-row { display: flex; align-items: center; gap: 6px; }
|
||||||
.scene-editor .cue-del, .scene-editor .cue-add { flex: 0 0 auto; }
|
.scene-editor .cue-row + .cue-row { margin-top: 6px; }
|
||||||
|
.scene-editor .cue-tstyle, .scene-editor .cue-img,
|
||||||
|
.scene-editor .cue-img-clear, .scene-editor .cue-tstyle-clear { flex: 0 0 auto; }
|
||||||
|
.scene-editor .cue-tinfo, .scene-editor .cue-name { flex: 1; min-width: 0; font-size: 10px; opacity: 0.72; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.scene-editor .cue-img-pos { flex: 0 0 auto; width: 92px; }
|
||||||
|
.scene-editor .cue-dim { flex: 0 0 auto; font-size: 10px; opacity: 0.55; }
|
||||||
|
.scene-editor .cue-img-size { flex: 1; }
|
||||||
|
.scene-editor .se-foot { display: flex; gap: 6px; margin-top: 2px; }
|
||||||
|
.scene-editor .se-foot .se-clear { color: #ff9a9a; }
|
||||||
|
|
||||||
/* ============ OUTPUT WINDOW ============ */
|
/* ============ OUTPUT WINDOW ============ */
|
||||||
body.output { cursor: none; background: #000; }
|
body.output { cursor: none; background: #000; }
|
||||||
|
|||||||
Reference in new issue
Block a user