feat: Spanning event bars, wheel nav, dark datetime picker, segmented settings UI
- Month view: Multi-day events render as continuous Google Calendar-style spanning bars across days/weeks using a greedy lane-packing algorithm. Timed multi-day events no longer repeat per day. - Mouse wheel / trackpad scrolls week-by-week in month view, day/week in other views (debounced, prevents default page scroll). - datetime-local/date inputs now use color-scheme:dark so the native browser picker opens in dark mode; calendar icon styled to match. - Contrast/hour-height selectors redesigned as connected segmented pill controls instead of individual tiles. - Hidden calendars list gains proper padding and separator lines. - "Google Konten" settings panel renamed "Konten" and expanded to show CalDAV, local calendars, iCal subscriptions, and Google accounts in one unified panel with sync/disconnect actions. - New i18n keys added for accounts panel in both de and en.
This commit is contained in:
@@ -539,6 +539,23 @@ function bindTopbar() {
|
||||
|
||||
document.getElementById('btn-settings').onclick = openSettingsModal;
|
||||
document.getElementById('btn-create-event').onclick = () => openNewEventModal(new Date());
|
||||
|
||||
// Mouse wheel / trackpad scroll navigation
|
||||
let _wheelTimer = null;
|
||||
document.getElementById('view-container').addEventListener('wheel', e => {
|
||||
e.preventDefault();
|
||||
if (_wheelTimer) return;
|
||||
_wheelTimer = setTimeout(() => { _wheelTimer = null; }, 80);
|
||||
const dir = e.deltaY > 0 ? 1 : -1;
|
||||
if (state.currentView === 'agenda') return;
|
||||
if (state.currentView === 'month') {
|
||||
state.currentDate = new Date(state.currentDate);
|
||||
state.currentDate.setDate(state.currentDate.getDate() + dir * 7);
|
||||
fetchAndRender();
|
||||
} else {
|
||||
navigate(dir);
|
||||
}
|
||||
}, { passive: false });
|
||||
}
|
||||
|
||||
// ── Sidebar toggle ────────────────────────────────────────
|
||||
@@ -1071,8 +1088,8 @@ function openSettingsModal() {
|
||||
const firstBtn = document.querySelector('.settings-nav-btn:not(.hidden)');
|
||||
if (firstBtn) activateSettingsPanel(firstBtn.dataset.panel);
|
||||
|
||||
// Render Google accounts and hidden calendars
|
||||
renderGoogleAccounts();
|
||||
// Render all accounts and hidden calendars
|
||||
renderAllAccounts();
|
||||
renderHiddenCalendars();
|
||||
|
||||
openModal('modal-settings');
|
||||
@@ -1129,6 +1146,101 @@ function renderGoogleAccounts() {
|
||||
});
|
||||
}
|
||||
|
||||
function renderAllAccounts() {
|
||||
// CalDAV section
|
||||
const caldavList = document.getElementById('accounts-caldav-list');
|
||||
if (caldavList) {
|
||||
if (!state.accounts.length) {
|
||||
caldavList.innerHTML = `<span class="accounts-section-empty">${t('settings_no_caldav_accounts')}</span>`;
|
||||
} else {
|
||||
caldavList.innerHTML = state.accounts.map(acc =>
|
||||
`<div class="accounts-row">
|
||||
<div class="accounts-row-info">
|
||||
<span class="accounts-row-name">${escHtml(acc.name)}</span>
|
||||
<span class="accounts-row-sub">${escHtml(acc.url || '')}</span>
|
||||
</div>
|
||||
<div class="accounts-row-actions">
|
||||
<button class="btn btn-secondary btn-sm" data-caldav-sync="${acc.id}">${t('sync')}</button>
|
||||
<button class="btn btn-ghost btn-sm" data-caldav-disconnect="${acc.id}">${t('disconnect')}</button>
|
||||
</div>
|
||||
</div>`
|
||||
).join('');
|
||||
caldavList.querySelectorAll('[data-caldav-sync]').forEach(btn => {
|
||||
btn.addEventListener('click', async () => {
|
||||
btn.disabled = true; btn.textContent = '…';
|
||||
try {
|
||||
await api.post(`/caldav/accounts/${btn.dataset.caldavSync}/sync`);
|
||||
renderCalendarList(); fetchAndRender();
|
||||
showToast(t('google_synced'));
|
||||
} catch (e) { showToast(e.message, true); }
|
||||
finally { btn.disabled = false; btn.textContent = t('sync'); }
|
||||
});
|
||||
});
|
||||
caldavList.querySelectorAll('[data-caldav-disconnect]').forEach(btn => {
|
||||
btn.addEventListener('click', async () => {
|
||||
if (!confirm(t('confirm_caldav_disconnect'))) return;
|
||||
try {
|
||||
await api.delete(`/caldav/accounts/${btn.dataset.caldavDisconnect}`);
|
||||
state.accounts = state.accounts.filter(a => a.id !== parseInt(btn.dataset.caldavDisconnect));
|
||||
renderAllAccounts(); renderCalendarList(); fetchAndRender();
|
||||
showToast(t('caldav_disconnected'));
|
||||
} catch (e) { showToast(e.message, true); }
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Local calendars section
|
||||
const localList = document.getElementById('accounts-local-list');
|
||||
if (localList) {
|
||||
if (!state.localCalendars.length) {
|
||||
localList.innerHTML = `<span class="accounts-section-empty">${t('settings_no_local_cals')}</span>`;
|
||||
} else {
|
||||
localList.innerHTML = state.localCalendars.map(cal =>
|
||||
`<div class="accounts-row">
|
||||
<div style="display:flex;align-items:center;gap:8px;min-width:0">
|
||||
<span class="accounts-local-dot" style="background:${cal.color || '#34a853'}"></span>
|
||||
<span class="accounts-row-name">${escHtml(cal.name)}</span>
|
||||
</div>
|
||||
</div>`
|
||||
).join('');
|
||||
}
|
||||
}
|
||||
|
||||
// iCal subscriptions section
|
||||
const icalList = document.getElementById('accounts-ical-list');
|
||||
if (icalList) {
|
||||
if (!state.icalSubscriptions.length) {
|
||||
icalList.innerHTML = `<span class="accounts-section-empty">${t('settings_no_ical_subs')}</span>`;
|
||||
} else {
|
||||
icalList.innerHTML = state.icalSubscriptions.map(sub =>
|
||||
`<div class="accounts-row">
|
||||
<div class="accounts-row-info">
|
||||
<span class="accounts-row-name">${escHtml(sub.name)}</span>
|
||||
<span class="accounts-row-sub">${escHtml(sub.url || '')}</span>
|
||||
</div>
|
||||
<div class="accounts-row-actions">
|
||||
<button class="btn btn-ghost btn-sm" data-ical-delete="${sub.id}">${t('delete')}</button>
|
||||
</div>
|
||||
</div>`
|
||||
).join('');
|
||||
icalList.querySelectorAll('[data-ical-delete]').forEach(btn => {
|
||||
btn.addEventListener('click', async () => {
|
||||
if (!confirm(t('confirm_remove_ical'))) return;
|
||||
try {
|
||||
await api.delete(`/ical/subscriptions/${btn.dataset.icalDelete}`);
|
||||
state.icalSubscriptions = state.icalSubscriptions.filter(s => s.id !== parseInt(btn.dataset.icalDelete));
|
||||
renderAllAccounts(); renderCalendarList(); fetchAndRender();
|
||||
} catch (e) { showToast(e.message, true); }
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Google accounts section — delegate to existing function
|
||||
renderGoogleAccounts();
|
||||
}
|
||||
|
||||
function renderHiddenCalendars() {
|
||||
const list = document.getElementById('hidden-cals-list');
|
||||
const hidden = [];
|
||||
@@ -1147,7 +1259,7 @@ function renderHiddenCalendars() {
|
||||
return;
|
||||
}
|
||||
list.innerHTML = hidden.map(c =>
|
||||
`<div style="display:flex;align-items:center;justify-content:space-between;padding:4px 0">
|
||||
`<div style="display:flex;align-items:center;justify-content:space-between;padding:8px 0;border-bottom:1px solid var(--border-light)">
|
||||
<span style="font-size:13px">${escHtml(c.acc)} / ${escHtml(c.name)}</span>
|
||||
<button class="btn btn-secondary btn-sm" data-restore-cal="${c.id}" data-restore-source="${c.source}">${t('show_cal')}</button>
|
||||
</div>`
|
||||
|
||||
@@ -86,6 +86,17 @@ const translations = {
|
||||
settings_hidden_cals: 'Ausgeblendete Kalender',
|
||||
settings_no_hidden_cals: 'Keine ausgeblendeten Kalender',
|
||||
settings_no_google: 'Keine Google-Konten verbunden',
|
||||
settings_nav_accounts: 'Konten',
|
||||
settings_accounts_caldav: 'CalDAV-Konten',
|
||||
settings_accounts_local: 'Lokale Kalender',
|
||||
settings_accounts_ical: 'iCal-Abonnements',
|
||||
settings_accounts_google: 'Google-Konten',
|
||||
settings_no_caldav_accounts: 'Keine CalDAV-Konten',
|
||||
settings_no_local_cals: 'Keine lokalen Kalender',
|
||||
settings_no_ical_subs: 'Keine Abonnements',
|
||||
settings_no_google_accounts: 'Keine Google-Konten',
|
||||
confirm_caldav_disconnect: 'CalDAV-Konto wirklich trennen?',
|
||||
caldav_disconnected: 'CalDAV-Konto getrennt',
|
||||
|
||||
// User management
|
||||
users_add: 'Benutzer hinzufügen', users_is_admin: 'Administrator',
|
||||
@@ -269,6 +280,17 @@ const translations = {
|
||||
settings_hidden_cals: 'Hidden calendars',
|
||||
settings_no_hidden_cals: 'No hidden calendars',
|
||||
settings_no_google: 'No Google accounts connected',
|
||||
settings_nav_accounts: 'Accounts',
|
||||
settings_accounts_caldav: 'CalDAV Accounts',
|
||||
settings_accounts_local: 'Local Calendars',
|
||||
settings_accounts_ical: 'iCal Subscriptions',
|
||||
settings_accounts_google: 'Google Accounts',
|
||||
settings_no_caldav_accounts: 'No CalDAV accounts',
|
||||
settings_no_local_cals: 'No local calendars',
|
||||
settings_no_ical_subs: 'No subscriptions',
|
||||
settings_no_google_accounts: 'No Google accounts',
|
||||
confirm_caldav_disconnect: 'Really disconnect CalDAV account?',
|
||||
caldav_disconnected: 'CalDAV account disconnected',
|
||||
|
||||
// User management
|
||||
users_add: 'Add user', users_is_admin: 'Administrator',
|
||||
|
||||
@@ -1,121 +1,185 @@
|
||||
import { formatDate, isSameDay, isToday, isPast, dayOfWeek, getISOWeekNumber } from '../utils.js';
|
||||
import { isToday, isPast, dayOfWeek, getISOWeekNumber } from '../utils.js';
|
||||
import { t } from '../i18n.js';
|
||||
|
||||
const LANE_H = 20; // px per lane (height 18px + 2px gap)
|
||||
const MAX_LANES = 3; // max visible event lanes per row
|
||||
|
||||
export function renderMonth(container, currentDate, events, onDayClick, onEventClick, weekStartDay = 'monday') {
|
||||
const year = currentDate.getFullYear();
|
||||
const year = currentDate.getFullYear();
|
||||
const month = currentDate.getMonth();
|
||||
const DOW = weekStartDay === 'sunday' ? t('dow_sunday') : t('dow_monday');
|
||||
const DOW = weekStartDay === 'sunday' ? t('dow_sunday') : t('dow_monday');
|
||||
|
||||
const firstDay = new Date(year, month, 1);
|
||||
const lastDay = new Date(year, month + 1, 0);
|
||||
|
||||
// Start grid on the correct weekday
|
||||
// Build 42-cell grid
|
||||
const cells = [];
|
||||
const gridStart = new Date(firstDay);
|
||||
const offset = dayOfWeek(firstDay, weekStartDay);
|
||||
gridStart.setDate(gridStart.getDate() - offset);
|
||||
|
||||
const cells = [];
|
||||
const d = new Date(gridStart);
|
||||
for (let i = 0; i < 42; i++) {
|
||||
cells.push(new Date(d));
|
||||
d.setDate(d.getDate() + 1);
|
||||
}
|
||||
|
||||
// Build event map keyed by date string
|
||||
const evMap = {};
|
||||
events.forEach(ev => {
|
||||
// Normalize each event's date range once
|
||||
const normed = events.map(ev => {
|
||||
const s = new Date(ev.start);
|
||||
const e = ev.allDay ? new Date(ev.end) : new Date(ev.end);
|
||||
// Spread multi-day events across cells
|
||||
const cur = new Date(s);
|
||||
cur.setHours(0, 0, 0, 0);
|
||||
const endNorm = new Date(e);
|
||||
endNorm.setHours(0, 0, 0, 0);
|
||||
if (ev.allDay && endNorm > cur) endNorm.setDate(endNorm.getDate() - 1);
|
||||
while (cur <= endNorm) {
|
||||
const key = dateKey(cur);
|
||||
if (!evMap[key]) evMap[key] = [];
|
||||
evMap[key].push(ev);
|
||||
cur.setDate(cur.getDate() + 1);
|
||||
}
|
||||
s.setHours(0, 0, 0, 0);
|
||||
const e = new Date(ev.end);
|
||||
e.setHours(0, 0, 0, 0);
|
||||
if (ev.allDay && e > s) e.setDate(e.getDate() - 1); // exclusive → inclusive
|
||||
return { ev, ns: s, ne: e };
|
||||
});
|
||||
|
||||
// Header: KW-Spalte + Wochentage
|
||||
const headerHtml = `<div class="month-kw-header">KW</div>` +
|
||||
// Header
|
||||
const headerHtml =
|
||||
`<div class="month-kw-header">KW</div>` +
|
||||
DOW.map(d => `<div class="month-dow">${d}</div>`).join('');
|
||||
|
||||
// Build rows (6 weeks × 7 days)
|
||||
let cellsHtml = '';
|
||||
// Build rows
|
||||
let bodyHtml = '';
|
||||
for (let row = 0; row < 6; row++) {
|
||||
// KW cell for the first day of this row
|
||||
const rowFirstDay = cells[row * 7];
|
||||
const kw = getISOWeekNumber(rowFirstDay);
|
||||
cellsHtml += `<div class="month-kw-cell">${kw}</div>`;
|
||||
const rowCells = cells.slice(row * 7, row * 7 + 7);
|
||||
const rowStart = new Date(rowCells[0]); rowStart.setHours(0, 0, 0, 0);
|
||||
const rowEnd = new Date(rowCells[6]); rowEnd.setHours(0, 0, 0, 0);
|
||||
const kw = getISOWeekNumber(rowCells[0]);
|
||||
|
||||
for (let col = 0; col < 7; col++) {
|
||||
const cell = cells[row * 7 + col];
|
||||
const key = dateKey(cell);
|
||||
const cellEvs = (evMap[key] || []).slice().sort((a, b) => {
|
||||
if (a.allDay && !b.allDay) return -1;
|
||||
if (!a.allDay && b.allDay) return 1;
|
||||
return new Date(a.start) - new Date(b.start);
|
||||
// Collect events overlapping this row
|
||||
const rowItems = [];
|
||||
normed.forEach(({ ev, ns, ne }) => {
|
||||
if (ne < rowStart || ns > rowEnd) return;
|
||||
const colStart = Math.max(0, daysBetween(rowStart, ns));
|
||||
const colEnd = Math.min(6, daysBetween(rowStart, ne));
|
||||
if (colEnd < colStart) return;
|
||||
const span = colEnd - colStart + 1;
|
||||
rowItems.push({
|
||||
ev,
|
||||
colStart,
|
||||
span,
|
||||
continuesLeft: ns < rowStart,
|
||||
continuesRight: ne > rowEnd,
|
||||
});
|
||||
});
|
||||
|
||||
const isOther = cell.getMonth() !== month;
|
||||
const todayClass = isToday(cell) ? 'today' : '';
|
||||
const otherClass = isOther ? 'other-month' : '';
|
||||
const numClass = isToday(cell) ? 'today' : '';
|
||||
// Sort: all-day first, then span desc, then start time
|
||||
rowItems.sort((a, b) => {
|
||||
if (a.ev.allDay && !b.ev.allDay) return -1;
|
||||
if (!a.ev.allDay && b.ev.allDay) return 1;
|
||||
if (b.span !== a.span) return b.span - a.span;
|
||||
return new Date(a.ev.start) - new Date(b.ev.start);
|
||||
});
|
||||
|
||||
const MAX_VISIBLE = 3;
|
||||
const visible = cellEvs.slice(0, MAX_VISIBLE);
|
||||
const hiddenCount = cellEvs.length - MAX_VISIBLE;
|
||||
// Assign lanes (greedy interval packing)
|
||||
const lanes = []; // { colEnd }
|
||||
rowItems.forEach(item => {
|
||||
let laneIdx = lanes.findIndex(l => item.colStart >= l.colEnd);
|
||||
if (laneIdx === -1) { laneIdx = lanes.length; lanes.push({ colEnd: 0 }); }
|
||||
item.lane = laneIdx;
|
||||
lanes[laneIdx].colEnd = item.colStart + item.span;
|
||||
});
|
||||
|
||||
const evHtml = visible.map(ev => {
|
||||
const color = ev.color || ev.calendarColor || '#4285f4';
|
||||
const pastClass = isPast(ev) ? 'past' : '';
|
||||
const title = ev.allDay ? ev.title : `${fmtTime(new Date(ev.start))} ${ev.title}`;
|
||||
return `<div class="month-event ${pastClass}" data-id="${ev.id}" data-url="${escAttr(ev.url)}"
|
||||
style="background:${color};color:#fff"
|
||||
title="${escAttr(ev.title)}">${escHtml(title)}</div>`;
|
||||
}).join('');
|
||||
// Track overflow per column
|
||||
const overflowByCol = {};
|
||||
rowItems.forEach(item => {
|
||||
if (item.lane >= MAX_LANES) {
|
||||
for (let c = item.colStart; c < item.colStart + item.span; c++) {
|
||||
overflowByCol[c] = (overflowByCol[c] || 0) + 1;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const moreHtml = hiddenCount > 0
|
||||
? `<div class="month-more" data-date="${key}">${t('more_events', {n: hiddenCount})}</div>`
|
||||
: '';
|
||||
// Render event spans
|
||||
let eventsHtml = '';
|
||||
rowItems.forEach(item => {
|
||||
if (item.lane >= MAX_LANES) return;
|
||||
const { ev, colStart, span, continuesLeft, continuesRight } = item;
|
||||
const leftPct = (colStart / 7) * 100;
|
||||
const widthPct = (span / 7) * 100 - 0.4;
|
||||
const topPx = item.lane * LANE_H + 2;
|
||||
const color = ev.color || ev.calendarColor || '#4285f4';
|
||||
const pastCls = isPast(ev) ? 'past' : '';
|
||||
const cL = continuesLeft ? 'continues-left' : '';
|
||||
const cR = continuesRight ? 'continues-right' : '';
|
||||
const label = ev.allDay
|
||||
? ev.title
|
||||
: `${fmtTime(new Date(ev.start))} ${ev.title}`;
|
||||
eventsHtml += `<div class="month-span-event ${pastCls} ${cL} ${cR}"
|
||||
data-id="${ev.id}" data-url="${escAttr(ev.url)}"
|
||||
style="left:${leftPct.toFixed(3)}%;width:${widthPct.toFixed(3)}%;top:${topPx}px;background:${color}"
|
||||
title="${escAttr(ev.title)}">${escHtml(label)}</div>`;
|
||||
});
|
||||
|
||||
cellsHtml += `<div class="month-cell ${todayClass} ${otherClass}" data-date="${key}">
|
||||
<div class="cell-day ${numClass}">${cell.getDate()}</div>
|
||||
${evHtml}${moreHtml}
|
||||
// Render "+N more" per column
|
||||
Object.entries(overflowByCol).forEach(([col, count]) => {
|
||||
const c = parseInt(col);
|
||||
const leftPct = (c / 7) * 100;
|
||||
const widthPct = (1 / 7) * 100;
|
||||
eventsHtml += `<div class="month-more"
|
||||
data-date="${dateKey(rowCells[c])}"
|
||||
style="left:${leftPct.toFixed(3)}%;width:${widthPct.toFixed(3)}%;bottom:2px">${t('more_events', { n: count })}</div>`;
|
||||
});
|
||||
|
||||
// Day cells (numbers only)
|
||||
let dayCellsHtml = '';
|
||||
rowCells.forEach(cell => {
|
||||
const key = dateKey(cell);
|
||||
const isOther = cell.getMonth() !== month;
|
||||
const todayCls = isToday(cell) ? 'today' : '';
|
||||
const otherCls = isOther ? 'other-month' : '';
|
||||
const numCls = isToday(cell) ? 'today' : '';
|
||||
dayCellsHtml += `<div class="month-cell ${todayCls} ${otherCls}" data-date="${key}">
|
||||
<div class="cell-day ${numCls}">${cell.getDate()}</div>
|
||||
</div>`;
|
||||
}
|
||||
});
|
||||
|
||||
bodyHtml += `<div class="month-row">
|
||||
<div class="month-kw-cell">${kw}</div>
|
||||
<div class="month-row-right">
|
||||
<div class="month-day-strip">${dayCellsHtml}</div>
|
||||
<div class="month-events-area">${eventsHtml}</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
container.innerHTML = `<div class="month-view">
|
||||
<div class="month-header">${headerHtml}</div>
|
||||
<div class="month-grid">${cellsHtml}</div>
|
||||
<div class="month-body">${bodyHtml}</div>
|
||||
</div>`;
|
||||
|
||||
// Events
|
||||
container.querySelectorAll('.month-cell').forEach(cell => {
|
||||
cell.addEventListener('click', e => {
|
||||
const evEl = e.target.closest('.month-event');
|
||||
if (evEl) {
|
||||
e.stopPropagation();
|
||||
const ev = events.find(ev => ev.id === evEl.dataset.id && ev.url === evEl.dataset.url);
|
||||
if (ev) onEventClick(ev, evEl);
|
||||
return;
|
||||
}
|
||||
const moreEl = e.target.closest('.month-more');
|
||||
if (moreEl) {
|
||||
e.stopPropagation();
|
||||
onDayClick(new Date(moreEl.dataset.date + 'T00:00:00'));
|
||||
return;
|
||||
}
|
||||
onDayClick(new Date(cell.dataset.date + 'T00:00:00'));
|
||||
});
|
||||
// Click handlers — event delegation
|
||||
const body = container.querySelector('.month-body');
|
||||
body.addEventListener('click', e => {
|
||||
// Span event click
|
||||
const spanEl = e.target.closest('.month-span-event');
|
||||
if (spanEl) {
|
||||
e.stopPropagation();
|
||||
const ev = events.find(ev => ev.id === spanEl.dataset.id && ev.url === spanEl.dataset.url);
|
||||
if (ev) onEventClick(ev, spanEl);
|
||||
return;
|
||||
}
|
||||
// "+N more" click → day view
|
||||
const moreEl = e.target.closest('.month-more');
|
||||
if (moreEl) {
|
||||
e.stopPropagation();
|
||||
onDayClick(new Date(moreEl.dataset.date + 'T00:00:00'));
|
||||
return;
|
||||
}
|
||||
// Day cell click → day view
|
||||
const cellEl = e.target.closest('.month-cell');
|
||||
if (cellEl) {
|
||||
onDayClick(new Date(cellEl.dataset.date + 'T00:00:00'));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────
|
||||
|
||||
function daysBetween(a, b) {
|
||||
// Number of whole days from date a to date b (can be negative)
|
||||
return Math.round((b - a) / 86400000);
|
||||
}
|
||||
|
||||
function dateKey(d) {
|
||||
return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`;
|
||||
}
|
||||
@@ -127,6 +191,7 @@ function fmtTime(d) {
|
||||
function escHtml(s) {
|
||||
return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
|
||||
}
|
||||
|
||||
function escAttr(s) {
|
||||
return String(s).replace(/"/g,'"').replace(/'/g,''');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user