Add settings dialog and first-visit setup prompt
A gear button and a setup prompt edit the saved settings, with name suggestions, and apply them without a reload. Panels and status columns can be hidden; the closed watcher and ding keep running. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
9479b122fa
commit
53c519ed62
@ -519,6 +519,122 @@ body > footer p + p { margin-top: 4px; }
|
||||
.verify td, .verify th { text-align: left; }
|
||||
.verify td.subject-cell { white-space: normal; min-width: 260px; }
|
||||
|
||||
/* ---- settings ---- */
|
||||
|
||||
dialog {
|
||||
width: calc(100% - 32px);
|
||||
max-width: 640px;
|
||||
max-height: calc(100vh - 48px);
|
||||
padding: 0;
|
||||
background: var(--card);
|
||||
color: var(--ink);
|
||||
border: 1px solid var(--rule);
|
||||
}
|
||||
dialog::backdrop { background: hsla(210, 18%, 4%, 0.7); }
|
||||
|
||||
.settings-head { padding: 20px 24px 4px; }
|
||||
.settings-head h2 { margin: 0; font-size: 17px; font-weight: 600; }
|
||||
.settings-head p { margin: 6px 0 0; color: var(--muted); font-size: 14px; }
|
||||
|
||||
.settings-body { padding: 4px 24px; }
|
||||
|
||||
fieldset {
|
||||
margin: 16px 0 0;
|
||||
padding: 0;
|
||||
border: none;
|
||||
}
|
||||
legend, .field > label {
|
||||
display: block;
|
||||
margin-bottom: 6px;
|
||||
font-family: var(--mono);
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--muted);
|
||||
}
|
||||
.field + .field { margin-top: 14px; }
|
||||
.hint { margin-top: 4px; font-size: 12px; color: var(--faint); }
|
||||
|
||||
.chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
padding: 6px;
|
||||
background: var(--ground);
|
||||
border: 1px solid var(--rule);
|
||||
}
|
||||
.chips:focus-within { border-color: var(--signal); }
|
||||
.chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 2px 4px 2px 9px;
|
||||
background: var(--card-raised);
|
||||
border: 1px solid var(--rule);
|
||||
font-size: 13px;
|
||||
}
|
||||
.chip button {
|
||||
padding: 0 5px;
|
||||
background: none;
|
||||
color: var(--muted);
|
||||
font-size: 15px;
|
||||
line-height: 1;
|
||||
}
|
||||
.chip button:hover { background: none; color: var(--alert); }
|
||||
.chips input {
|
||||
flex: 1;
|
||||
min-width: 160px;
|
||||
padding: 4px;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--ink);
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.row { display: flex; flex-wrap: wrap; gap: 14px 24px; }
|
||||
.row .field + .field { margin-top: 0; }
|
||||
|
||||
input[type="number"], select {
|
||||
padding: 7px 9px;
|
||||
background: var(--ground);
|
||||
border: 1px solid var(--rule);
|
||||
color: var(--ink);
|
||||
font: inherit;
|
||||
font-size: 14px;
|
||||
}
|
||||
input[type="number"] { width: 90px; }
|
||||
input[type="number"]:focus, select:focus { outline: none; border-color: var(--signal); }
|
||||
|
||||
.checks { display: flex; flex-wrap: wrap; gap: 6px 18px; font-size: 14px; }
|
||||
.checks label { display: flex; align-items: center; gap: 7px; cursor: pointer; }
|
||||
.checks input { accent-color: var(--signal); }
|
||||
|
||||
.settings-error {
|
||||
margin: 16px 24px 0;
|
||||
padding: 10px 14px;
|
||||
border-left: 3px solid var(--alert);
|
||||
background: var(--ground);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.settings-foot {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-top: 20px;
|
||||
padding: 16px 24px;
|
||||
border-top: 1px solid var(--rule);
|
||||
}
|
||||
.settings-foot .spacer { flex: 1; }
|
||||
button.secondary { background: var(--card-raised); color: var(--ink); border: 1px solid var(--rule); }
|
||||
button.secondary:hover { background: var(--rule); }
|
||||
button:disabled { opacity: 0.5; cursor: default; }
|
||||
|
||||
.board.solo { grid-template-columns: minmax(0, 1fr); }
|
||||
|
||||
@media (max-width: 1040px) {
|
||||
.board { grid-template-columns: minmax(0, 1fr); }
|
||||
.stats { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
||||
@ -552,6 +668,7 @@ body > footer p + p { margin-top: 4px; }
|
||||
<span class="track"></span>Sound on close
|
||||
</button>
|
||||
<button class="quiet" id="checkBtn" onclick="checkNow(this)" hidden>Refresh now</button>
|
||||
<button class="quiet" id="settingsBtn" onclick="openSettings('edit')" hidden>⚙ Settings</button>
|
||||
<span class="user" id="user" hidden></span>
|
||||
<button class="quiet" id="signOutBtn" onclick="signOut(this)" hidden>Sign out</button>
|
||||
</div>
|
||||
@ -562,6 +679,8 @@ body > footer p + p { margin-top: 4px; }
|
||||
<div id="main"></div>
|
||||
</div>
|
||||
|
||||
<dialog id="settings" aria-labelledby="settingsTitle"></dialog>
|
||||
|
||||
<script>
|
||||
const $ = id => document.getElementById(id);
|
||||
|
||||
@ -602,6 +721,8 @@ let flashTechs = new Set();
|
||||
// the browser builds the closed feed now; the server only answers each check with the user's token
|
||||
const feed = new Map();
|
||||
let feedReady = false;
|
||||
// bumped when settings change, so a check that started under the old settings is dropped
|
||||
let generation = 0;
|
||||
let timers = [];
|
||||
let signInProblem = null;
|
||||
|
||||
@ -675,20 +796,29 @@ async function start() {
|
||||
checkClosed();
|
||||
checkWorkload();
|
||||
loadWeather();
|
||||
timers.push(
|
||||
startTimers();
|
||||
// first visit: offer the setup prompt; the dashboard loads with the defaults behind it
|
||||
if (!data.settingsSaved && data.canSaveSettings) openSettings('setup');
|
||||
}
|
||||
|
||||
function startTimers() {
|
||||
timers.forEach(clearInterval);
|
||||
timers = [
|
||||
setInterval(checkClosed, data.checkMinutes * 60 * 1000),
|
||||
setInterval(checkWorkload, data.workloadMinutes * 60 * 1000),
|
||||
setInterval(checkWorkload, data.settings.workloadMinutes * 60 * 1000),
|
||||
setInterval(loadWeather, 10 * 60 * 1000) // server caches for 10 min, no point asking sooner
|
||||
);
|
||||
];
|
||||
}
|
||||
|
||||
async function checkClosed() {
|
||||
// the whole feed on first load, then a short window that overlaps the last check
|
||||
const minutes = feedReady ? data.windowMinutes : data.feedHours * 60;
|
||||
const minutes = feedReady ? data.windowMinutes : data.settings.feedHours * 60;
|
||||
const gen = generation;
|
||||
try {
|
||||
const result = await api('/api/closed?minutes=' + minutes);
|
||||
if (gen !== generation) return;
|
||||
for (const t of result.tickets) feed.set(String(t.id), t);
|
||||
const cutoff = Date.now() - data.feedHours * 60 * 60 * 1000;
|
||||
const cutoff = Date.now() - data.settings.feedHours * 60 * 60 * 1000;
|
||||
for (const [id, t] of feed) {
|
||||
if (t.closedAt < cutoff) feed.delete(id);
|
||||
}
|
||||
@ -705,8 +835,10 @@ async function checkClosed() {
|
||||
}
|
||||
|
||||
async function checkWorkload() {
|
||||
const gen = generation;
|
||||
try {
|
||||
const result = await api('/api/workload');
|
||||
if (gen !== generation) return;
|
||||
data.workload = result.workload;
|
||||
data.unassigned = result.unassigned;
|
||||
} catch (err) {
|
||||
@ -726,6 +858,7 @@ function render() {
|
||||
$('statusText').textContent = STATUS_TEXT[data.auth] || data.auth;
|
||||
const live = data.auth === 'connected';
|
||||
$('checkBtn').hidden = !live;
|
||||
$('settingsBtn').hidden = !live;
|
||||
$('signOutBtn').hidden = !live;
|
||||
$('user').hidden = !live;
|
||||
const user = data.user || {};
|
||||
@ -733,10 +866,10 @@ function render() {
|
||||
$('user').title = user.email || '';
|
||||
|
||||
const wl = data.workload || {};
|
||||
const s = data.settings || { sites: [], groups: [], technicians: [] };
|
||||
const s = data.settings || { sites: [], groups: [], technicians: [], hiddenPanels: [] };
|
||||
$('meta').innerHTML = !live ? '' : [
|
||||
'Closed tickets checked every ' + data.checkMinutes + ' min',
|
||||
'Status counts every ' + data.workloadMinutes + ' min',
|
||||
'Status counts every ' + s.workloadMinutes + ' min',
|
||||
s.technicians.length ? 'Watching ' + s.technicians.length + ' technicians' : 'Watching all technicians',
|
||||
s.sites.length ? 'Sites: ' + s.sites.join(', ') : '',
|
||||
s.groups.length ? 'Groups: ' + s.groups.join(', ') : '',
|
||||
@ -766,13 +899,13 @@ function render() {
|
||||
}
|
||||
|
||||
if (data.auth === 'connected') {
|
||||
html += renderStats(wl);
|
||||
html += `<div class="board">
|
||||
const shown = panel => !s.hiddenPanels.includes(panel);
|
||||
if (shown('stats')) html += renderStats(wl);
|
||||
// the watcher and its ding keep running with the feed hidden; only the list goes
|
||||
const side = (shown('weather') ? renderWeather() : '') + (shown('feed') ? renderFeed() : '');
|
||||
html += `<div class="board${side ? '' : ' solo'}">
|
||||
${renderWorkload(wl)}
|
||||
<div class="side">
|
||||
${renderWeather()}
|
||||
${renderFeed()}
|
||||
</div>
|
||||
${side ? `<div class="side">${side}</div>` : ''}
|
||||
</div>`;
|
||||
html += renderUnassignedList(data.unassigned || {});
|
||||
}
|
||||
@ -816,7 +949,9 @@ function renderStats(wl) {
|
||||
}
|
||||
|
||||
function renderWorkload(wl) {
|
||||
const statuses = wl.statuses || [];
|
||||
// hidden columns only leave the table; Total open still counts them
|
||||
const hidden = new Set(data.settings.hiddenStatuses.map(n => n.toLowerCase()));
|
||||
const statuses = (wl.statuses || []).filter(st => !hidden.has(st.name.toLowerCase()));
|
||||
const techs = wl.technicians || [];
|
||||
|
||||
const note = wl.updatedAt
|
||||
@ -945,7 +1080,7 @@ function renderFeed() {
|
||||
: '<div class="empty"><p>Nothing closed yet.</p></div>';
|
||||
|
||||
return `<section class="section">
|
||||
<div class="section-head"><h2>Recently closed</h2><span class="note">last ${data.feedHours}h</span></div>
|
||||
<div class="section-head"><h2>Recently closed</h2><span class="note">last ${data.settings.feedHours}h</span></div>
|
||||
${list}
|
||||
</section>`;
|
||||
}
|
||||
@ -998,6 +1133,244 @@ async function loadWeather() {
|
||||
} catch (err) {}
|
||||
}
|
||||
|
||||
// ---- settings: saved on the server per Zoho user, so they follow people between browsers ----
|
||||
|
||||
const LIST_FIELDS = { sites: 'Sites', groups: 'Groups', technicians: 'Technicians' };
|
||||
const PANEL_LABELS = { stats: 'Stat tiles', weather: 'Weather', feed: 'Recently closed feed' };
|
||||
|
||||
let names = null; // suggestions from /api/names, fetched the first time settings open
|
||||
let draft = null;
|
||||
let settingsMode = 'edit'; // 'setup' on first visit
|
||||
let settingsError = null;
|
||||
|
||||
const lower = list => list.map(n => n.toLowerCase());
|
||||
|
||||
function openSettings(mode) {
|
||||
settingsMode = mode;
|
||||
settingsError = null;
|
||||
draft = JSON.parse(JSON.stringify(data.settings));
|
||||
renderSettings();
|
||||
if (!$('settings').open) $('settings').showModal();
|
||||
loadNames();
|
||||
}
|
||||
|
||||
async function loadNames() {
|
||||
if (names) return;
|
||||
try {
|
||||
names = await api('/api/names');
|
||||
if ($('settings').open) renderSettings();
|
||||
} catch (err) {}
|
||||
}
|
||||
|
||||
function renderSettings() {
|
||||
const d = draft;
|
||||
const lim = data.settingsLimits;
|
||||
const setup = settingsMode === 'setup';
|
||||
// keep the cursor in the list being typed into across re-renders
|
||||
const focused = document.activeElement && document.activeElement.dataset.add;
|
||||
|
||||
const chipList = field => {
|
||||
const taken = new Set(lower(d[field]));
|
||||
const options = ((names && names[field]) || []).filter(n => !taken.has(n.toLowerCase()));
|
||||
return `<div class="field">
|
||||
<label for="add-${field}">${LIST_FIELDS[field]}</label>
|
||||
<div class="chips">
|
||||
${d[field].map((n, i) => `<span class="chip">${esc(n)}<button type="button" data-remove="${field}" data-index="${i}" aria-label="Remove ${esc(n)}">×</button></span>`).join('')}
|
||||
<input id="add-${field}" data-add="${field}" list="names-${field}" autocomplete="off"
|
||||
placeholder="${d[field].length ? 'Add another…' : 'All ' + field + '. Type to add one'}">
|
||||
</div>
|
||||
<datalist id="names-${field}">${options.map(n => `<option value="${esc(n)}">`).join('')}</datalist>
|
||||
</div>`;
|
||||
};
|
||||
|
||||
// columns from the last count, plus any hidden ones that didn't show up this time
|
||||
const statusNames = ((data.workload && data.workload.statuses) || []).map(st => st.name);
|
||||
for (const n of d.hiddenStatuses) if (!lower(statusNames).includes(n.toLowerCase())) statusNames.push(n);
|
||||
const hiddenStatuses = new Set(lower(d.hiddenStatuses));
|
||||
|
||||
const body = !data.canSaveSettings
|
||||
? `<p class="settings-error">Zoho didn't share your email at sign-in, so settings can't be saved. Sign out and in again.</p>`
|
||||
: `<div class="settings-body">
|
||||
<fieldset>
|
||||
<legend>What to watch</legend>
|
||||
${Object.keys(LIST_FIELDS).map(chipList).join('')}
|
||||
<div class="hint">Leave a list empty to include everyone. Suggestions come from recent tickets; any name can be typed, but it has to match ServiceDesk Plus exactly.</div>
|
||||
</fieldset>
|
||||
<fieldset>
|
||||
<legend>Timing</legend>
|
||||
<div class="row">
|
||||
<div class="field">
|
||||
<label for="set-workload">Recount status table every (min)</label>
|
||||
<input type="number" id="set-workload" data-num="workloadMinutes" min="${lim.workloadMinutes.min}" max="${lim.workloadMinutes.max}" value="${d.workloadMinutes}">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="set-feed">Keep closed tickets for (hours)</label>
|
||||
<input type="number" id="set-feed" data-num="feedHours" min="${lim.feedHours.min}" max="${lim.feedHours.max}" value="${d.feedHours}">
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
${data.places.length ? `<fieldset>
|
||||
<legend>Weather</legend>
|
||||
<div class="field">
|
||||
<label for="set-zip">Location</label>
|
||||
<select id="set-zip" data-zip>
|
||||
${d.weatherZip ? '' : '<option value="" selected>Choose a location…</option>'}
|
||||
${data.places.map(p => `<option value="${esc(p.zip)}"${p.zip === d.weatherZip ? ' selected' : ''}>${esc(p.label)}</option>`).join('')}
|
||||
</select>
|
||||
</div>
|
||||
</fieldset>` : ''}
|
||||
<fieldset>
|
||||
<legend>Show on the dashboard</legend>
|
||||
<div class="checks">
|
||||
${lim.panels.map(p => `<label><input type="checkbox" data-panel="${p}"${d.hiddenPanels.includes(p) ? '' : ' checked'}>${PANEL_LABELS[p] || p}</label>`).join('')}
|
||||
</div>
|
||||
<div class="hint">The ding for newly closed tickets plays even with the feed hidden.</div>
|
||||
</fieldset>
|
||||
<fieldset>
|
||||
<legend>Status columns</legend>
|
||||
${statusNames.length
|
||||
? `<div class="checks">${statusNames.map(n => `<label><input type="checkbox" data-status="${esc(n)}"${hiddenStatuses.has(n.toLowerCase()) ? '' : ' checked'}>${esc(n)}</label>`).join('')}</div>
|
||||
<div class="hint">Hidden columns still count toward Total open.</div>`
|
||||
: '<div class="hint">The columns appear here after the first status count.</div>'}
|
||||
</fieldset>
|
||||
</div>`;
|
||||
|
||||
const foot = setup
|
||||
? `<span class="spacer"></span>
|
||||
<button type="button" class="secondary" data-action="defaults">Use the defaults</button>
|
||||
<button type="button" data-action="save">Save</button>`
|
||||
: `<button type="button" class="quiet" data-action="reset"${data.canSaveSettings ? '' : ' disabled'}>Reset to defaults</button>
|
||||
<span class="spacer"></span>
|
||||
<button type="button" class="secondary" data-action="cancel">Cancel</button>
|
||||
<button type="button" data-action="save"${data.canSaveSettings ? '' : ' disabled'}>Save</button>`;
|
||||
|
||||
$('settings').innerHTML = `<div class="settings-head">
|
||||
<h2 id="settingsTitle">${setup ? 'Set up your dashboard' : 'Settings'}</h2>
|
||||
<p>${setup
|
||||
? 'Pick what this dashboard watches. It starts with the help desk defaults, and you can change it any time with ⚙ Settings.'
|
||||
: 'Saved to your Zoho account, so they follow you to any browser you sign in on.'}</p>
|
||||
</div>
|
||||
${settingsError ? `<div class="settings-error">${esc(settingsError)}</div>` : ''}
|
||||
${body}
|
||||
<div class="settings-foot">${foot}</div>`;
|
||||
|
||||
if (focused) $('add-' + focused).focus();
|
||||
}
|
||||
|
||||
function addName(field, input) {
|
||||
const name = input.value.replace(/\s+/g, ' ').trim().replace(/,$/, '');
|
||||
input.value = '';
|
||||
if (!name || lower(draft[field]).includes(name.toLowerCase())) return;
|
||||
// take the suggestion's spelling when only the case differs
|
||||
const match = ((names && names[field]) || []).find(n => n.toLowerCase() === name.toLowerCase());
|
||||
draft[field].push(match || name);
|
||||
renderSettings();
|
||||
}
|
||||
|
||||
$('settings').addEventListener('keydown', e => {
|
||||
const field = e.target.dataset.add;
|
||||
if (!field) return;
|
||||
if (e.key === 'Enter' || e.key === ',') {
|
||||
e.preventDefault();
|
||||
addName(field, e.target);
|
||||
} else if (e.key === 'Backspace' && !e.target.value && draft[field].length) {
|
||||
draft[field].pop();
|
||||
renderSettings();
|
||||
}
|
||||
});
|
||||
|
||||
// picking a suggestion from the list fills the input in one go; add it straight away
|
||||
$('settings').addEventListener('input', e => {
|
||||
const field = e.target.dataset.add;
|
||||
if (!field || (e.inputType && e.inputType !== 'insertReplacementText')) return;
|
||||
if (((names && names[field]) || []).includes(e.target.value)) addName(field, e.target);
|
||||
});
|
||||
|
||||
$('settings').addEventListener('change', e => {
|
||||
const t = e.target;
|
||||
if (t.dataset.num) draft[t.dataset.num] = Number(t.value);
|
||||
if (t.dataset.zip !== undefined) draft.weatherZip = t.value;
|
||||
if (t.dataset.panel) {
|
||||
draft.hiddenPanels = draft.hiddenPanels.filter(p => p !== t.dataset.panel);
|
||||
if (!t.checked) draft.hiddenPanels.push(t.dataset.panel);
|
||||
}
|
||||
if (t.dataset.status) {
|
||||
draft.hiddenStatuses = draft.hiddenStatuses.filter(n => n.toLowerCase() !== t.dataset.status.toLowerCase());
|
||||
if (!t.checked) draft.hiddenStatuses.push(t.dataset.status);
|
||||
}
|
||||
});
|
||||
|
||||
$('settings').addEventListener('click', e => {
|
||||
const remove = e.target.closest('[data-remove]');
|
||||
if (remove) {
|
||||
draft[remove.dataset.remove].splice(Number(remove.dataset.index), 1);
|
||||
renderSettings();
|
||||
return;
|
||||
}
|
||||
const action = e.target.closest('[data-action]');
|
||||
if (!action) return;
|
||||
if (action.dataset.action === 'cancel') $('settings').close();
|
||||
if (action.dataset.action === 'save') saveSettings(action);
|
||||
if (action.dataset.action === 'defaults') saveSettings(action, {});
|
||||
if (action.dataset.action === 'reset') saveSettings(action, null);
|
||||
});
|
||||
|
||||
// body null resets to the defaults; {} keeps the defaults but stops the setup prompt
|
||||
async function saveSettings(btn, body) {
|
||||
if (body === undefined) {
|
||||
// a name typed but not yet added with Enter still counts
|
||||
for (const field of Object.keys(LIST_FIELDS)) {
|
||||
const input = $('add-' + field);
|
||||
if (input && input.value.trim()) addName(field, input);
|
||||
}
|
||||
const { sites, groups, technicians, workloadMinutes, feedHours, weatherZip, hiddenStatuses, hiddenPanels } = draft;
|
||||
body = { sites, groups, technicians, workloadMinutes, feedHours, hiddenStatuses, hiddenPanels };
|
||||
if (data.places.length && weatherZip) body.weatherZip = weatherZip;
|
||||
}
|
||||
|
||||
btn.disabled = true;
|
||||
try {
|
||||
const reply = await api('/api/settings', body === null
|
||||
? { method: 'DELETE' }
|
||||
: { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
|
||||
$('settings').close();
|
||||
applySettings(reply);
|
||||
} catch (err) {
|
||||
if (data.auth !== 'connected') return $('settings').close();
|
||||
settingsError = err.message;
|
||||
renderSettings();
|
||||
}
|
||||
}
|
||||
|
||||
function applySettings(reply) {
|
||||
const before = data.settings;
|
||||
Object.assign(data, reply);
|
||||
const after = data.settings;
|
||||
const changed = (...fields) => fields.some(f => JSON.stringify(before[f]) !== JSON.stringify(after[f]));
|
||||
if (changed('sites', 'groups', 'technicians', 'feedHours')) generation++;
|
||||
|
||||
if (changed('sites', 'groups', 'technicians', 'feedHours')) {
|
||||
// a different region or feed length: start the feed over, quietly, like a fresh page load
|
||||
feed.clear();
|
||||
data.tickets = [];
|
||||
seen = new Set();
|
||||
feedReady = false;
|
||||
firstLoad = true;
|
||||
checkClosed();
|
||||
}
|
||||
if (changed('sites', 'groups', 'technicians')) {
|
||||
data.workload = {};
|
||||
data.unassigned = {};
|
||||
checkWorkload();
|
||||
}
|
||||
if (changed('workloadMinutes')) startTimers();
|
||||
if (changed('weatherZip')) {
|
||||
weather = null;
|
||||
loadWeather();
|
||||
}
|
||||
render();
|
||||
}
|
||||
|
||||
start();
|
||||
setInterval(render, 20000); // re-age the rails between checks
|
||||
</script>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user