Initial tablet POC: project restructure + WebSocket/HTTP server + JS client
- Restructured project into /app, /server, /client with run.py entry point - WSServer (QWebSocketServer) with log_signal, start/stop, client tracking - HTTP server serves /client on port 8080 - Vanilla JS client: faders, toggles, transport, drag-to-arrange, lock/save layout - Start Tablet button + floating Tablet log window in app UI - Preset broadcast on load/switch, DAW state relay, hardware sync via widget_update - Widget colors synced from app palette, toggle state fixed (sends 0/127 correctly) - Layout saved per preset UUID back to presets.json Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+236
@@ -0,0 +1,236 @@
|
||||
const WS_PORT = 8765;
|
||||
|
||||
const state = {
|
||||
widgets: {},
|
||||
layout: {},
|
||||
locked: true,
|
||||
presetUuid: null,
|
||||
presetName: '',
|
||||
};
|
||||
|
||||
// --- WebSocket ---
|
||||
|
||||
const ws = new WebSocket(`ws://${window.location.hostname}:${WS_PORT}`);
|
||||
|
||||
ws.onopen = () => {
|
||||
document.getElementById('status').innerText = 'connected';
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
document.getElementById('status').innerText = 'disconnected';
|
||||
};
|
||||
|
||||
ws.onerror = () => {
|
||||
document.getElementById('status').innerText = 'error';
|
||||
};
|
||||
|
||||
ws.onmessage = (e) => {
|
||||
const msg = JSON.parse(e.data);
|
||||
if (msg.event === 'preset') renderPreset(msg.data, msg.layout || {});
|
||||
else if (msg.event === 'widget_update') updateWidget(msg.uid, msg.value);
|
||||
else if (msg.event === 'daw_state') updateDaw(msg);
|
||||
};
|
||||
|
||||
function send(data) {
|
||||
ws.send(JSON.stringify(data));
|
||||
}
|
||||
|
||||
// --- Rendering ---
|
||||
|
||||
const canvas = document.getElementById('canvas');
|
||||
|
||||
function renderPreset(data, savedLayout) {
|
||||
state.layout = savedLayout;
|
||||
state.presetUuid = data.preset_uuid;
|
||||
state.presetName = data.preset_name || '';
|
||||
state.widgets = {};
|
||||
canvas.innerHTML = '';
|
||||
|
||||
document.getElementById('preset-name').innerText = state.presetName;
|
||||
|
||||
let idx = 0;
|
||||
(data.faders || []).filter(w => w.visible !== false).forEach(w => makeFader(w, idx++));
|
||||
(data.toggles || []).filter(w => w.visible !== false).forEach(w => makeToggle(w, idx++));
|
||||
(data.transports|| []).filter(w => w.visible !== false).forEach(w => makeTransport(w, idx++));
|
||||
|
||||
state.locked = Object.keys(savedLayout).length > 0;
|
||||
refreshToolbar();
|
||||
}
|
||||
|
||||
function defaultPos(idx) {
|
||||
const cols = 3;
|
||||
return { x: 16 + (idx % cols) * 170, y: 48 + Math.floor(idx / cols) * 420, w: 140, h: 380 };
|
||||
}
|
||||
|
||||
function makeContainer(uid, label, idx) {
|
||||
const pos = state.layout[uid] || defaultPos(idx);
|
||||
|
||||
const el = document.createElement('div');
|
||||
el.className = 'widget';
|
||||
el.style.left = `${pos.x}px`;
|
||||
el.style.top = `${pos.y}px`;
|
||||
el.style.width = `${pos.w || 70}px`;
|
||||
|
||||
const lbl = document.createElement('div');
|
||||
lbl.className = 'widget-label';
|
||||
lbl.innerText = label || ' ';
|
||||
el.appendChild(lbl);
|
||||
|
||||
canvas.appendChild(el);
|
||||
state.widgets[uid] = { el, pos: { ...pos } };
|
||||
setupDrag(el, lbl, uid);
|
||||
return el;
|
||||
}
|
||||
|
||||
function makeFader(data, idx) {
|
||||
const { uid, color = '#00e676' } = data;
|
||||
const el = makeContainer(uid, data.label || '', idx);
|
||||
el.style.borderColor = color;
|
||||
|
||||
const inp = document.createElement('input');
|
||||
inp.type = 'range';
|
||||
inp.min = '0'; inp.max = '127';
|
||||
inp.value = String(data.value ?? 0);
|
||||
inp.style.accentColor = color;
|
||||
|
||||
const readout = document.createElement('div');
|
||||
readout.className = 'val-readout';
|
||||
readout.innerText = String(data.value ?? 0);
|
||||
readout.id = `ro-${uid}`;
|
||||
|
||||
inp.oninput = () => {
|
||||
const v = parseInt(inp.value);
|
||||
readout.innerText = String(v);
|
||||
send({ event: 'control', uid, value: v });
|
||||
};
|
||||
|
||||
el.appendChild(inp);
|
||||
el.appendChild(readout);
|
||||
state.widgets[uid].input = inp;
|
||||
state.widgets[uid].color = color;
|
||||
}
|
||||
|
||||
function makeToggle(data, idx) {
|
||||
const { uid, color = '#00e676' } = data;
|
||||
const el = makeContainer(uid, data.label || '', idx);
|
||||
el.style.borderColor = color;
|
||||
|
||||
const btn = document.createElement('button');
|
||||
const on = !!data.state;
|
||||
btn.className = 'ctrl-btn' + (on ? ' on' : '');
|
||||
btn.innerText = on ? 'ON' : 'OFF';
|
||||
if (on) { btn.style.background = color; btn.style.color = '#000'; btn.style.borderColor = color; }
|
||||
|
||||
btn.onclick = () => {
|
||||
const isOn = !!state.widgets[uid].isOn;
|
||||
send({ event: 'control', uid, value: isOn ? 0 : 127 });
|
||||
};
|
||||
|
||||
el.appendChild(btn);
|
||||
state.widgets[uid].btn = btn;
|
||||
state.widgets[uid].kind = 'toggle';
|
||||
state.widgets[uid].color = color;
|
||||
state.widgets[uid].isOn = on;
|
||||
}
|
||||
|
||||
function makeTransport(data, idx) {
|
||||
const { uid } = data;
|
||||
const el = makeContainer(uid, '', idx);
|
||||
|
||||
const btn = document.createElement('button');
|
||||
btn.className = 'ctrl-btn';
|
||||
btn.innerText = data.label || '';
|
||||
|
||||
btn.onclick = () => send({ event: 'control', uid, value: 127 });
|
||||
|
||||
el.appendChild(btn);
|
||||
state.widgets[uid].btn = btn;
|
||||
state.widgets[uid].kind = 'transport';
|
||||
}
|
||||
|
||||
// --- Widget updates from hardware ---
|
||||
|
||||
function updateWidget(uid, value) {
|
||||
const w = state.widgets[uid];
|
||||
if (!w) return;
|
||||
|
||||
if (w.input) {
|
||||
w.input.value = String(value);
|
||||
const ro = document.getElementById(`ro-${uid}`);
|
||||
if (ro) ro.innerText = String(value);
|
||||
}
|
||||
|
||||
if (w.btn && w.kind === 'toggle') {
|
||||
const on = value > 63;
|
||||
const color = w.color || '#00e676';
|
||||
w.isOn = on;
|
||||
w.btn.className = 'ctrl-btn' + (on ? ' on' : '');
|
||||
w.btn.innerText = on ? 'ON' : 'OFF';
|
||||
if (on) { w.btn.style.background = color; w.btn.style.color = '#000'; w.btn.style.borderColor = color; }
|
||||
else { w.btn.style.background = ''; w.btn.style.color = ''; w.btn.style.borderColor = ''; }
|
||||
}
|
||||
}
|
||||
|
||||
function updateDaw(msg) {
|
||||
const parts = [];
|
||||
if (msg.track) parts.push(msg.track);
|
||||
if (msg.bar) parts.push(`Bar ${msg.bar}`);
|
||||
if (msg.playing !== undefined) parts.push(msg.playing ? '▶' : '■');
|
||||
const text = parts.filter(Boolean).join(' ');
|
||||
if (text) document.getElementById('status').innerText = text;
|
||||
}
|
||||
|
||||
// --- Drag to arrange ---
|
||||
|
||||
function setupDrag(el, handle, uid) {
|
||||
let active = false, sx = 0, sy = 0, ox = 0, oy = 0;
|
||||
|
||||
handle.addEventListener('pointerdown', (e) => {
|
||||
if (state.locked) return;
|
||||
active = true;
|
||||
sx = e.clientX; sy = e.clientY;
|
||||
ox = parseInt(el.style.left) || 0;
|
||||
oy = parseInt(el.style.top) || 0;
|
||||
handle.setPointerCapture(e.pointerId);
|
||||
e.preventDefault();
|
||||
});
|
||||
|
||||
handle.addEventListener('pointermove', (e) => {
|
||||
if (!active) return;
|
||||
const x = ox + e.clientX - sx;
|
||||
const y = oy + e.clientY - sy;
|
||||
el.style.left = `${x}px`;
|
||||
el.style.top = `${y}px`;
|
||||
if (state.widgets[uid]) {
|
||||
state.widgets[uid].pos = { x, y, w: el.offsetWidth, h: el.offsetHeight };
|
||||
}
|
||||
});
|
||||
|
||||
handle.addEventListener('pointerup', () => { active = false; });
|
||||
}
|
||||
|
||||
// --- Toolbar ---
|
||||
|
||||
function saveLayout() {
|
||||
if (!state.presetUuid) return;
|
||||
const layout = {};
|
||||
Object.entries(state.widgets).forEach(([uid, w]) => { layout[uid] = w.pos; });
|
||||
send({ event: 'save_layout', preset_uuid: state.presetUuid, layout });
|
||||
}
|
||||
|
||||
function refreshToolbar() {
|
||||
document.getElementById('btn-lock').className = 'tbtn' + (state.locked ? ' active' : '');
|
||||
document.getElementById('btn-arrange').className = 'tbtn' + (state.locked ? '' : ' active');
|
||||
document.getElementById('arrange-hint').style.display = state.locked ? 'none' : 'block';
|
||||
}
|
||||
|
||||
document.getElementById('btn-arrange').onclick = () => {
|
||||
state.locked = false;
|
||||
refreshToolbar();
|
||||
};
|
||||
|
||||
document.getElementById('btn-lock').onclick = () => {
|
||||
state.locked = true;
|
||||
refreshToolbar();
|
||||
saveLayout();
|
||||
};
|
||||
+292
@@ -0,0 +1,292 @@
|
||||
import json
|
||||
import js
|
||||
|
||||
js.document.getElementById("loader").className = "hidden"
|
||||
|
||||
WS_PORT = 8765
|
||||
|
||||
_state = {
|
||||
"widgets": {},
|
||||
"layout": {},
|
||||
"locked": True,
|
||||
"preset_uuid": None,
|
||||
"preset_name": "",
|
||||
}
|
||||
|
||||
|
||||
# --- WebSocket ---
|
||||
|
||||
_host = js.window.location.hostname
|
||||
_ws = js.WebSocket.new(f"ws://{_host}:{WS_PORT}")
|
||||
|
||||
|
||||
def _on_open(e):
|
||||
js.document.getElementById("status").innerText = "connected"
|
||||
|
||||
|
||||
def _on_close(e):
|
||||
js.document.getElementById("status").innerText = "disconnected"
|
||||
|
||||
|
||||
def _on_error(e):
|
||||
js.document.getElementById("status").innerText = "error"
|
||||
|
||||
|
||||
def _on_message(e):
|
||||
msg = json.loads(e.data)
|
||||
ev = msg.get("event")
|
||||
if ev == "preset":
|
||||
_render_preset(msg["data"], msg.get("layout") or {})
|
||||
elif ev == "widget_update":
|
||||
_update_widget(msg["uid"], msg["value"])
|
||||
elif ev == "daw_state":
|
||||
_update_daw(msg)
|
||||
|
||||
|
||||
_ws.onopen = _on_open
|
||||
_ws.onclose = _on_close
|
||||
_ws.onerror = _on_error
|
||||
_ws.onmessage = _on_message
|
||||
|
||||
|
||||
def _send(data):
|
||||
_ws.send(json.dumps(data))
|
||||
|
||||
|
||||
# --- Rendering ---
|
||||
|
||||
_canvas = js.document.getElementById("canvas")
|
||||
|
||||
|
||||
def _render_preset(data, saved_layout):
|
||||
_state["layout"] = saved_layout
|
||||
_state["preset_uuid"] = data.get("preset_uuid")
|
||||
_state["preset_name"] = data.get("preset_name", "")
|
||||
_state["widgets"].clear()
|
||||
_canvas.innerHTML = ""
|
||||
|
||||
js.document.getElementById("preset-name").innerText = _state["preset_name"]
|
||||
|
||||
idx = 0
|
||||
for w in data.get("faders", []):
|
||||
if w.get("visible", True):
|
||||
_make_fader(w, idx)
|
||||
idx += 1
|
||||
for w in data.get("toggles", []):
|
||||
if w.get("visible", True):
|
||||
_make_toggle(w, idx)
|
||||
idx += 1
|
||||
for w in data.get("transports", []):
|
||||
if w.get("visible", True):
|
||||
_make_transport(w, idx)
|
||||
idx += 1
|
||||
|
||||
_state["locked"] = bool(saved_layout)
|
||||
_refresh_toolbar()
|
||||
|
||||
|
||||
def _default_pos(idx):
|
||||
cols = 4
|
||||
return {"x": 16 + (idx % cols) * 90, "y": 48 + (idx // cols) * 220, "w": 70, "h": 200}
|
||||
|
||||
|
||||
def _make_container(uid, label, idx):
|
||||
pos = _state["layout"].get(uid) or _default_pos(idx)
|
||||
el = js.document.createElement("div")
|
||||
el.className = "widget"
|
||||
el.style.left = f"{pos['x']}px"
|
||||
el.style.top = f"{pos['y']}px"
|
||||
el.style.width = f"{pos.get('w', 70)}px"
|
||||
|
||||
lbl = js.document.createElement("div")
|
||||
lbl.className = "widget-label"
|
||||
lbl.innerText = label or " "
|
||||
el.appendChild(lbl)
|
||||
|
||||
_canvas.appendChild(el)
|
||||
_state["widgets"][uid] = {"el": el, "pos": dict(pos)}
|
||||
_setup_drag(el, lbl, uid)
|
||||
return el
|
||||
|
||||
|
||||
def _make_fader(data, idx):
|
||||
uid = data["uid"]
|
||||
color = data.get("color", "#00e676")
|
||||
el = _make_container(uid, data.get("label", ""), idx)
|
||||
el.style.borderColor = color
|
||||
|
||||
inp = js.document.createElement("input")
|
||||
inp.type = "range"
|
||||
inp.min = "0"
|
||||
inp.max = "127"
|
||||
inp.value = str(data.get("value", 0))
|
||||
inp.style.accentColor = color
|
||||
|
||||
readout = js.document.createElement("div")
|
||||
readout.className = "val-readout"
|
||||
readout.innerText = str(data.get("value", 0))
|
||||
readout.id = f"ro-{uid}"
|
||||
|
||||
def on_input(e, _uid=uid, _ro=readout):
|
||||
v = int(e.target.value)
|
||||
_ro.innerText = str(v)
|
||||
_send({"event": "control", "uid": _uid, "value": v})
|
||||
|
||||
inp.oninput = on_input
|
||||
el.appendChild(inp)
|
||||
el.appendChild(readout)
|
||||
_state["widgets"][uid]["input"] = inp
|
||||
_state["widgets"][uid]["color"] = color
|
||||
|
||||
|
||||
def _make_toggle(data, idx):
|
||||
uid = data["uid"]
|
||||
color = data.get("color", "#00e676")
|
||||
el = _make_container(uid, data.get("label", ""), idx)
|
||||
el.style.borderColor = color
|
||||
|
||||
btn = js.document.createElement("button")
|
||||
on = bool(data.get("state"))
|
||||
btn.className = "ctrl-btn" + (" on" if on else "")
|
||||
btn.innerText = "ON" if on else "OFF"
|
||||
if on:
|
||||
btn.style.background = color
|
||||
btn.style.color = "#000"
|
||||
btn.style.borderColor = color
|
||||
|
||||
def on_click(e, _uid=uid):
|
||||
_send({"event": "control", "uid": _uid, "value": 127})
|
||||
|
||||
btn.onclick = on_click
|
||||
el.appendChild(btn)
|
||||
_state["widgets"][uid]["btn"] = btn
|
||||
_state["widgets"][uid]["kind"] = "toggle"
|
||||
_state["widgets"][uid]["color"] = color
|
||||
|
||||
|
||||
def _make_transport(data, idx):
|
||||
uid = data["uid"]
|
||||
el = _make_container(uid, "", idx)
|
||||
|
||||
btn = js.document.createElement("button")
|
||||
btn.className = "ctrl-btn"
|
||||
btn.innerText = data.get("label", "")
|
||||
|
||||
def on_click(e, _uid=uid):
|
||||
_send({"event": "control", "uid": _uid, "value": 127})
|
||||
|
||||
btn.onclick = on_click
|
||||
el.appendChild(btn)
|
||||
_state["widgets"][uid]["btn"] = btn
|
||||
_state["widgets"][uid]["kind"] = "transport"
|
||||
|
||||
|
||||
# --- Widget updates from hardware ---
|
||||
|
||||
def _update_widget(uid, value):
|
||||
w = _state["widgets"].get(uid)
|
||||
if not w:
|
||||
return
|
||||
if "input" in w:
|
||||
w["input"].value = str(value)
|
||||
ro = js.document.getElementById(f"ro-{uid}")
|
||||
if ro:
|
||||
ro.innerText = str(value)
|
||||
if "btn" in w and w.get("kind") == "toggle":
|
||||
is_on = value > 63
|
||||
color = w.get("color", "#00e676")
|
||||
btn = w["btn"]
|
||||
btn.className = "ctrl-btn" + (" on" if is_on else "")
|
||||
btn.innerText = "ON" if is_on else "OFF"
|
||||
if is_on:
|
||||
btn.style.background = color
|
||||
btn.style.color = "#000"
|
||||
btn.style.borderColor = color
|
||||
else:
|
||||
btn.style.background = ""
|
||||
btn.style.color = ""
|
||||
btn.style.borderColor = ""
|
||||
|
||||
|
||||
def _update_daw(msg):
|
||||
parts = []
|
||||
if "track" in msg:
|
||||
parts.append(msg["track"])
|
||||
if "bar" in msg:
|
||||
parts.append(f"Bar {msg['bar']}")
|
||||
if "playing" in msg:
|
||||
parts.append("▶" if msg["playing"] else "■")
|
||||
text = " ".join(p for p in parts if p)
|
||||
if text:
|
||||
js.document.getElementById("status").innerText = text
|
||||
|
||||
|
||||
# --- Drag to arrange ---
|
||||
|
||||
def _setup_drag(el, handle, uid):
|
||||
drag = {"active": False, "sx": 0, "sy": 0, "ox": 0, "oy": 0}
|
||||
|
||||
def on_down(e, _d=drag, _el=el, _uid=uid):
|
||||
if _state["locked"]:
|
||||
return
|
||||
_d["active"] = True
|
||||
_d["sx"] = e.clientX
|
||||
_d["sy"] = e.clientY
|
||||
_d["ox"] = int(_el.style.left.replace("px", "") or 0)
|
||||
_d["oy"] = int(_el.style.top.replace("px", "") or 0)
|
||||
handle.setPointerCapture(e.pointerId)
|
||||
e.preventDefault()
|
||||
|
||||
def on_move(e, _d=drag, _el=el, _uid=uid):
|
||||
if not _d["active"]:
|
||||
return
|
||||
x = _d["ox"] + e.clientX - _d["sx"]
|
||||
y = _d["oy"] + e.clientY - _d["sy"]
|
||||
_el.style.left = f"{x}px"
|
||||
_el.style.top = f"{y}px"
|
||||
if _uid in _state["widgets"]:
|
||||
_state["widgets"][_uid]["pos"] = {
|
||||
"x": x, "y": y,
|
||||
"w": int(_el.offsetWidth),
|
||||
"h": int(_el.offsetHeight),
|
||||
}
|
||||
|
||||
def on_up(e, _d=drag):
|
||||
_d["active"] = False
|
||||
|
||||
handle.addEventListener("pointerdown", on_down)
|
||||
handle.addEventListener("pointermove", on_move)
|
||||
handle.addEventListener("pointerup", on_up)
|
||||
|
||||
|
||||
# --- Toolbar ---
|
||||
|
||||
def _save_layout():
|
||||
uuid = _state["preset_uuid"]
|
||||
if not uuid:
|
||||
return
|
||||
layout = {uid: w["pos"] for uid, w in _state["widgets"].items()}
|
||||
_send({"event": "save_layout", "preset_uuid": uuid, "layout": layout})
|
||||
|
||||
|
||||
def _refresh_toolbar():
|
||||
locked = _state["locked"]
|
||||
js.document.getElementById("btn-lock").className = "tbtn" + (" active" if locked else "")
|
||||
js.document.getElementById("btn-arrange").className = "tbtn" + (" active" if not locked else "")
|
||||
hint = js.document.getElementById("arrange-hint")
|
||||
hint.style.display = "none" if locked else "block"
|
||||
|
||||
|
||||
def _on_arrange(e):
|
||||
_state["locked"] = False
|
||||
_refresh_toolbar()
|
||||
|
||||
|
||||
def _on_lock(e):
|
||||
_state["locked"] = True
|
||||
_refresh_toolbar()
|
||||
_save_layout()
|
||||
|
||||
|
||||
js.document.getElementById("btn-arrange").onclick = _on_arrange
|
||||
js.document.getElementById("btn-lock").onclick = _on_lock
|
||||
@@ -0,0 +1,36 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
|
||||
<title>Virtual Controller</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body { background: #1a1a1a; color: #fff; font-family: sans-serif; overflow: hidden; touch-action: none; }
|
||||
#canvas { position: relative; width: 100vw; height: 100vh; }
|
||||
#toolbar { position: fixed; top: 8px; right: 8px; z-index: 1000; display: flex; gap: 6px; align-items: center; }
|
||||
.tbtn { background: #2a2a2a; color: #ccc; border: 1px solid #444; padding: 6px 14px; border-radius: 4px; font-size: 12px; cursor: pointer; -webkit-tap-highlight-color: transparent; }
|
||||
.tbtn.active { background: #00e676; color: #000; border-color: #00e676; }
|
||||
#status { position: fixed; top: 8px; left: 8px; font-size: 11px; color: #555; }
|
||||
#preset-name { position: fixed; bottom: 8px; left: 8px; font-size: 11px; color: #555; }
|
||||
.widget { position: absolute; background: #222; border: 1px solid #3a3a3a; border-radius: 6px; padding: 12px; min-width: 140px; }
|
||||
.widget-label { font-size: 16px; color: #888; text-align: center; margin-bottom: 10px; padding: 4px 0; cursor: grab; user-select: none; -webkit-user-select: none; }
|
||||
input[type=range] { accent-color: #00e676; cursor: pointer; writing-mode: vertical-lr; direction: rtl; width: 64px; height: 240px; display: block; margin: 0 auto; }
|
||||
.val-readout { font-size: 14px; color: #555; text-align: center; margin-top: 6px; }
|
||||
.ctrl-btn { width: 100%; padding: 24px 16px; background: #2a2a2a; color: #ccc; border: 1px solid #444; border-radius: 4px; font-size: 20px; cursor: pointer; -webkit-tap-highlight-color: transparent; }
|
||||
.ctrl-btn.on { background: #1a472a; border-color: #00e676; color: #00e676; }
|
||||
.arrange-hint { position: fixed; bottom: 8px; right: 8px; font-size: 11px; color: #555; display: none; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="canvas"></div>
|
||||
<div id="toolbar">
|
||||
<button class="tbtn" id="btn-arrange">Arrange</button>
|
||||
<button class="tbtn active" id="btn-lock">Lock</button>
|
||||
</div>
|
||||
<div id="status">connecting...</div>
|
||||
<div id="preset-name"></div>
|
||||
<div class="arrange-hint" id="arrange-hint">drag to reposition · lock to save</div>
|
||||
<script src="./app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user