fix: Month grid lines, scroll throttle, custom dark date/time picker
- Month view: Replaced day-strip+events-area with full-height column divs (.month-col) so borders extend the full row height and clicking anywhere in a day column (including below events) navigates to day view. Events overlay uses pointer-events:none (pass-through) while span bars and +N-more labels stay pointer-events:all. - Scroll navigation: Changed wheel handler from 80ms debounce to 500ms leading-edge throttle — one navigation per trackpad gesture. - Custom date/time picker (date-picker.js): Dark calendar grid with prev/next navigation, today/selected highlighting, and a CSS scroll-snap time scroller (hours 0-23, minutes 0-59) matching the app's primary color. Language-aware (month names, day headers via t()). - Event modal datetime inputs replaced with hidden inputs + .dt-display click targets that open the custom picker. setDtValue() helper keeps hidden input and display label in sync.
This commit is contained in:
@@ -469,8 +469,7 @@ a { color: var(--primary); text-decoration: none; }
|
|||||||
letter-spacing: .5px; color: var(--text-2);
|
letter-spacing: .5px; color: var(--text-2);
|
||||||
}
|
}
|
||||||
.month-kw-cell {
|
.month-kw-cell {
|
||||||
position: absolute; left: 0; top: 0; bottom: 0;
|
position: absolute; left: 0; top: 0; bottom: 0; width: 38px;
|
||||||
width: 38px;
|
|
||||||
display: flex; align-items: flex-start; justify-content: center;
|
display: flex; align-items: flex-start; justify-content: center;
|
||||||
padding-top: 6px;
|
padding-top: 6px;
|
||||||
font-size: 13px; color: var(--text-3); font-weight: 700;
|
font-size: 13px; color: var(--text-3); font-weight: 700;
|
||||||
@@ -478,32 +477,34 @@ a { color: var(--primary); text-decoration: none; }
|
|||||||
cursor: default; user-select: none; z-index: 1;
|
cursor: default; user-select: none; z-index: 1;
|
||||||
background: var(--bg-app);
|
background: var(--bg-app);
|
||||||
}
|
}
|
||||||
.month-cell {
|
/* Full-height column divs — click target + border */
|
||||||
flex: 1;
|
.month-col {
|
||||||
border-right: 1px solid var(--border);
|
flex: 1; border-right: 1px solid var(--border);
|
||||||
padding: 4px 4px 0;
|
cursor: pointer; transition: background var(--transition);
|
||||||
cursor: pointer;
|
padding: 4px 4px 0; min-width: 0;
|
||||||
transition: background var(--transition);
|
|
||||||
min-width: 0;
|
|
||||||
}
|
}
|
||||||
.month-cell:last-child { border-right: none; }
|
.month-col:last-child { border-right: none; }
|
||||||
.month-cell:hover { background: var(--bg-hover); }
|
.month-col:hover { background: var(--bg-hover); }
|
||||||
.month-cell.today { background: rgba(66,133,244,.08); }
|
.month-col.today { background: rgba(66,133,244,.08); }
|
||||||
.month-cell.other-month .cell-day { color: var(--text-3); }
|
.month-col.other-month .cell-day { color: var(--text-3); }
|
||||||
.cell-day {
|
.cell-day {
|
||||||
font-size: 12px; font-weight: 500; color: var(--text-2);
|
font-size: 12px; font-weight: 500; color: var(--text-2);
|
||||||
width: 26px; height: 26px;
|
width: 26px; height: 26px;
|
||||||
display: flex; align-items: center; justify-content: center;
|
display: flex; align-items: center; justify-content: center;
|
||||||
border-radius: 50%; flex-shrink: 0;
|
border-radius: 50%; flex-shrink: 0;
|
||||||
}
|
}
|
||||||
.cell-day.today {
|
.cell-day.today { background: var(--today-color); color: #fff; font-weight: 700; }
|
||||||
background: var(--today-color);
|
/* Events overlay — pointer-events:none so clicks pass to columns */
|
||||||
color: #fff; font-weight: 700;
|
.month-events-overlay {
|
||||||
|
position: absolute; top: 30px; left: 0; right: 0; bottom: 0;
|
||||||
|
pointer-events: none; overflow: hidden; z-index: 2;
|
||||||
}
|
}
|
||||||
.month-more {
|
.month-more {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
font-size: 11px; color: var(--text-2); padding: 0 4px;
|
font-size: 11px; color: var(--text-2); padding: 0 4px;
|
||||||
cursor: pointer; font-weight: 500;
|
cursor: pointer; font-weight: 500;
|
||||||
|
pointer-events: all;
|
||||||
|
white-space: nowrap; overflow: hidden;
|
||||||
}
|
}
|
||||||
.month-more:hover { color: var(--primary); }
|
.month-more:hover { color: var(--primary); }
|
||||||
|
|
||||||
@@ -901,32 +902,24 @@ a { color: var(--primary); text-decoration: none; }
|
|||||||
display: flex; flex-direction: column; flex: 1; overflow: hidden;
|
display: flex; flex-direction: column; flex: 1; overflow: hidden;
|
||||||
}
|
}
|
||||||
.month-row {
|
.month-row {
|
||||||
display: flex; flex: 1; position: relative; min-height: 0;
|
display: flex; flex: 1; position: relative; min-height: 100px;
|
||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid var(--border);
|
||||||
}
|
}
|
||||||
.month-row:last-child { border-bottom: none; }
|
.month-row:last-child { border-bottom: none; }
|
||||||
|
/* row-right: flex row containing 7 full-height column divs + events overlay */
|
||||||
.month-row-right {
|
.month-row-right {
|
||||||
margin-left: 38px; display: flex; flex-direction: column; flex: 1; min-width: 0;
|
margin-left: 38px; display: flex; flex: 1; position: relative; min-width: 0;
|
||||||
}
|
|
||||||
.month-day-strip {
|
|
||||||
display: flex; flex-shrink: 0;
|
|
||||||
}
|
|
||||||
.month-events-area {
|
|
||||||
position: relative; flex: 1;
|
|
||||||
min-height: 72px; /* 3 lanes × 22px + 6px padding */
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
}
|
||||||
.month-span-event {
|
.month-span-event {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
height: 18px; line-height: 18px;
|
height: 18px; line-height: 18px;
|
||||||
border-radius: 3px;
|
border-radius: 3px; padding: 0 6px;
|
||||||
padding: 0 6px;
|
|
||||||
font-size: 11px; font-weight: 500;
|
font-size: 11px; font-weight: 500;
|
||||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||||
cursor: pointer; color: #fff;
|
cursor: pointer; color: #fff;
|
||||||
transition: filter var(--transition);
|
transition: filter var(--transition);
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
z-index: 2;
|
pointer-events: all;
|
||||||
}
|
}
|
||||||
.month-span-event:hover { filter: brightness(1.15); }
|
.month-span-event:hover { filter: brightness(1.15); }
|
||||||
.month-span-event.past { opacity: .45; }
|
.month-span-event.past { opacity: .45; }
|
||||||
@@ -947,3 +940,118 @@ a { color: var(--primary); text-decoration: none; }
|
|||||||
.logo-text { display: none; }
|
.logo-text { display: none; }
|
||||||
.view-title { font-size: 16px; }
|
.view-title { font-size: 16px; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Custom Date/Time Display Field ─────────────────────── */
|
||||||
|
.dt-display {
|
||||||
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
|
background: var(--bg-app);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
padding: 10px 12px;
|
||||||
|
color: var(--text-1);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color var(--transition);
|
||||||
|
user-select: none;
|
||||||
|
min-height: 42px;
|
||||||
|
}
|
||||||
|
.dt-display:hover, .dt-display:focus { border-color: var(--primary); outline: none; }
|
||||||
|
.dt-display-text { font-size: 14px; }
|
||||||
|
.dt-display-icon { color: var(--text-3); flex-shrink: 0; margin-left: 6px; }
|
||||||
|
|
||||||
|
/* ── Date/Time Picker Card ───────────────────────────────── */
|
||||||
|
.dtp-overlay {
|
||||||
|
position: fixed; inset: 0; z-index: 700;
|
||||||
|
}
|
||||||
|
.dtp-card {
|
||||||
|
position: fixed; z-index: 701;
|
||||||
|
background: var(--bg-surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
padding: 16px;
|
||||||
|
width: 272px;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
/* Calendar header */
|
||||||
|
.dtp-cal-header {
|
||||||
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
.dtp-month-label {
|
||||||
|
font-size: 14px; font-weight: 600; color: var(--text-1);
|
||||||
|
}
|
||||||
|
.dtp-nav-btn {
|
||||||
|
background: none; border: none; cursor: pointer;
|
||||||
|
color: var(--text-2); font-size: 22px; line-height: 1;
|
||||||
|
padding: 2px 6px; border-radius: var(--radius-sm);
|
||||||
|
transition: background var(--transition), color var(--transition);
|
||||||
|
}
|
||||||
|
.dtp-nav-btn:hover { background: var(--bg-hover); color: var(--text-1); }
|
||||||
|
/* Day-of-week headers */
|
||||||
|
.dtp-grid {
|
||||||
|
display: grid; grid-template-columns: repeat(7, 1fr);
|
||||||
|
gap: 2px; margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
.dtp-dow {
|
||||||
|
text-align: center; font-size: 11px; font-weight: 600;
|
||||||
|
color: var(--text-3); padding: 4px 0; text-transform: uppercase;
|
||||||
|
}
|
||||||
|
/* Day cells */
|
||||||
|
.dtp-day {
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
height: 34px; border-radius: 6px;
|
||||||
|
font-size: 13px; font-weight: 500; color: var(--text-1);
|
||||||
|
cursor: pointer; transition: background var(--transition);
|
||||||
|
}
|
||||||
|
.dtp-day:hover { background: var(--bg-hover); }
|
||||||
|
.dtp-day.other { color: var(--text-3); }
|
||||||
|
.dtp-day.other:hover { background: var(--bg-hover); }
|
||||||
|
.dtp-day.today { color: var(--primary); font-weight: 700; }
|
||||||
|
.dtp-day.selected {
|
||||||
|
background: var(--primary) !important;
|
||||||
|
color: #fff !important; font-weight: 700;
|
||||||
|
}
|
||||||
|
/* Time picker */
|
||||||
|
.dtp-time-row {
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
gap: 4px; margin: 12px 0 8px;
|
||||||
|
border-top: 1px solid var(--border-light);
|
||||||
|
padding-top: 12px;
|
||||||
|
}
|
||||||
|
.dtp-colon {
|
||||||
|
font-size: 22px; font-weight: 600; color: var(--text-2);
|
||||||
|
padding: 0 2px; line-height: 1;
|
||||||
|
align-self: center;
|
||||||
|
}
|
||||||
|
.dtp-tc-wrap {
|
||||||
|
position: relative; width: 64px;
|
||||||
|
}
|
||||||
|
.dtp-tc {
|
||||||
|
height: calc(3 * 40px); /* 120px = 3 visible items */
|
||||||
|
overflow-y: scroll;
|
||||||
|
scroll-snap-type: y mandatory;
|
||||||
|
scrollbar-width: none;
|
||||||
|
padding: 40px 0; /* top/bottom padding so first/last can center */
|
||||||
|
box-sizing: content-box;
|
||||||
|
}
|
||||||
|
.dtp-tc::-webkit-scrollbar { display: none; }
|
||||||
|
.dtp-ti {
|
||||||
|
height: 40px; line-height: 40px;
|
||||||
|
text-align: center; font-size: 20px; font-weight: 500;
|
||||||
|
scroll-snap-align: center;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--text-2);
|
||||||
|
transition: background .1s, color .1s;
|
||||||
|
}
|
||||||
|
.dtp-ti:hover { color: var(--text-1); }
|
||||||
|
.dtp-ti.selected {
|
||||||
|
background: var(--primary);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
/* Actions */
|
||||||
|
.dtp-actions {
|
||||||
|
display: flex; justify-content: flex-end; gap: 8px;
|
||||||
|
margin-top: 12px; padding-top: 10px;
|
||||||
|
border-top: 1px solid var(--border-light);
|
||||||
|
}
|
||||||
|
|||||||
@@ -202,21 +202,37 @@
|
|||||||
<div class="form-row" id="ev-time-row">
|
<div class="form-row" id="ev-time-row">
|
||||||
<div class="form-group half">
|
<div class="form-group half">
|
||||||
<label>Start</label>
|
<label>Start</label>
|
||||||
<input type="datetime-local" id="ev-start" />
|
<input type="hidden" id="ev-start" />
|
||||||
|
<div class="dt-display" id="ev-start-display" tabindex="0" role="button">
|
||||||
|
<span class="dt-display-text">—</span>
|
||||||
|
<svg class="dt-display-icon" viewBox="0 0 24 24" fill="currentColor" width="16" height="16"><path d="M19 3h-1V1h-2v2H8V1H6v2H5c-1.1 0-2 .9-2 2v14a2 2 0 002 2h14a2 2 0 002-2V5c0-1.1-.9-2-2-2zm0 16H5V8h14v11zM7 10h5v5H7z"/></svg>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group half">
|
<div class="form-group half">
|
||||||
<label>Ende</label>
|
<label>Ende</label>
|
||||||
<input type="datetime-local" id="ev-end" />
|
<input type="hidden" id="ev-end" />
|
||||||
|
<div class="dt-display" id="ev-end-display" tabindex="0" role="button">
|
||||||
|
<span class="dt-display-text">—</span>
|
||||||
|
<svg class="dt-display-icon" viewBox="0 0 24 24" fill="currentColor" width="16" height="16"><path d="M19 3h-1V1h-2v2H8V1H6v2H5c-1.1 0-2 .9-2 2v14a2 2 0 002 2h14a2 2 0 002-2V5c0-1.1-.9-2-2-2zm0 16H5V8h14v11zM7 10h5v5H7z"/></svg>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-row" id="ev-date-row" style="display:none">
|
<div class="form-row" id="ev-date-row" style="display:none">
|
||||||
<div class="form-group half">
|
<div class="form-group half">
|
||||||
<label>Start</label>
|
<label>Start</label>
|
||||||
<input type="date" id="ev-start-date" />
|
<input type="hidden" id="ev-start-date" />
|
||||||
|
<div class="dt-display" id="ev-start-date-display" tabindex="0" role="button">
|
||||||
|
<span class="dt-display-text">—</span>
|
||||||
|
<svg class="dt-display-icon" viewBox="0 0 24 24" fill="currentColor" width="16" height="16"><path d="M19 3h-1V1h-2v2H8V1H6v2H5c-1.1 0-2 .9-2 2v14a2 2 0 002 2h14a2 2 0 002-2V5c0-1.1-.9-2-2-2zm0 16H5V8h14v11zM7 10h5v5H7z"/></svg>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group half">
|
<div class="form-group half">
|
||||||
<label>Ende</label>
|
<label>Ende</label>
|
||||||
<input type="date" id="ev-end-date" />
|
<input type="hidden" id="ev-end-date" />
|
||||||
|
<div class="dt-display" id="ev-end-date-display" tabindex="0" role="button">
|
||||||
|
<span class="dt-display-text">—</span>
|
||||||
|
<svg class="dt-display-icon" viewBox="0 0 24 24" fill="currentColor" width="16" height="16"><path d="M19 3h-1V1h-2v2H8V1H6v2H5c-1.1 0-2 .9-2 2v14a2 2 0 002 2h14a2 2 0 002-2V5c0-1.1-.9-2-2-2zm0 16H5V8h14v11zM7 10h5v5H7z"/></svg>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { renderMonth } from './views/month.js';
|
|||||||
import { renderWeek } from './views/week.js';
|
import { renderWeek } from './views/week.js';
|
||||||
import { renderAgenda } from './views/agenda.js';
|
import { renderAgenda } from './views/agenda.js';
|
||||||
import { openColorPicker } from './color-picker.js';
|
import { openColorPicker } from './color-picker.js';
|
||||||
|
import { openDatePicker, formatDtDisplay } from './date-picker.js';
|
||||||
import { t, setLang, getLang } from './i18n.js';
|
import { t, setLang, getLang } from './i18n.js';
|
||||||
|
|
||||||
// Fetch avatar image as blob URL (with auth header)
|
// Fetch avatar image as blob URL (with auth header)
|
||||||
@@ -540,12 +541,13 @@ function bindTopbar() {
|
|||||||
document.getElementById('btn-settings').onclick = openSettingsModal;
|
document.getElementById('btn-settings').onclick = openSettingsModal;
|
||||||
document.getElementById('btn-create-event').onclick = () => openNewEventModal(new Date());
|
document.getElementById('btn-create-event').onclick = () => openNewEventModal(new Date());
|
||||||
|
|
||||||
// Mouse wheel / trackpad scroll navigation
|
// Mouse wheel / trackpad scroll navigation (500ms cooldown = 1 nav per gesture)
|
||||||
let _wheelTimer = null;
|
let _wheelLast = 0;
|
||||||
document.getElementById('view-container').addEventListener('wheel', e => {
|
document.getElementById('view-container').addEventListener('wheel', e => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (_wheelTimer) return;
|
const now = Date.now();
|
||||||
_wheelTimer = setTimeout(() => { _wheelTimer = null; }, 80);
|
if (now - _wheelLast < 500) return;
|
||||||
|
_wheelLast = now;
|
||||||
const dir = e.deltaY > 0 ? 1 : -1;
|
const dir = e.deltaY > 0 ? 1 : -1;
|
||||||
if (state.currentView === 'agenda') return;
|
if (state.currentView === 'agenda') return;
|
||||||
if (state.currentView === 'month') {
|
if (state.currentView === 'month') {
|
||||||
@@ -710,6 +712,17 @@ function populateCalendarSelect(selectedId) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Date field helpers ────────────────────────────────────
|
||||||
|
function setDtValue(id, isoStr, mode) {
|
||||||
|
const input = document.getElementById(id);
|
||||||
|
if (input) input.value = isoStr || '';
|
||||||
|
const display = document.getElementById(id + '-display');
|
||||||
|
if (display) {
|
||||||
|
display.querySelector('.dt-display-text').textContent =
|
||||||
|
formatDtDisplay(isoStr, mode, getLang());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function openNewEventModal(date) {
|
function openNewEventModal(date) {
|
||||||
state.editingEvent = null;
|
state.editingEvent = null;
|
||||||
state.selectedEventColor = '';
|
state.selectedEventColor = '';
|
||||||
@@ -723,10 +736,10 @@ function openNewEventModal(date) {
|
|||||||
const start = new Date(date);
|
const start = new Date(date);
|
||||||
const end = new Date(date);
|
const end = new Date(date);
|
||||||
end.setHours(end.getHours() + 1);
|
end.setHours(end.getHours() + 1);
|
||||||
document.getElementById('ev-start').value = toLocalDatetimeInput(start);
|
setDtValue('ev-start', toLocalDatetimeInput(start), 'datetime');
|
||||||
document.getElementById('ev-end').value = toLocalDatetimeInput(end);
|
setDtValue('ev-end', toLocalDatetimeInput(end), 'datetime');
|
||||||
document.getElementById('ev-start-date').value = toDateInput(start);
|
setDtValue('ev-start-date', toDateInput(start), 'date');
|
||||||
document.getElementById('ev-end-date').value = toDateInput(start);
|
setDtValue('ev-end-date', toDateInput(start), 'date');
|
||||||
|
|
||||||
toggleAlldayFields(false);
|
toggleAlldayFields(false);
|
||||||
populateCalendarSelect(null);
|
populateCalendarSelect(null);
|
||||||
@@ -746,14 +759,14 @@ function openEditEventModal(ev) {
|
|||||||
document.getElementById('ev-allday').checked = ev.allDay;
|
document.getElementById('ev-allday').checked = ev.allDay;
|
||||||
|
|
||||||
if (ev.allDay) {
|
if (ev.allDay) {
|
||||||
document.getElementById('ev-start-date').value = ev.start.slice(0, 10);
|
setDtValue('ev-start-date', ev.start.slice(0, 10), 'date');
|
||||||
document.getElementById('ev-end-date').value = ev.end.slice(0, 10);
|
setDtValue('ev-end-date', ev.end.slice(0, 10), 'date');
|
||||||
toggleAlldayFields(true);
|
toggleAlldayFields(true);
|
||||||
} else {
|
} else {
|
||||||
const s = new Date(ev.start);
|
const s = new Date(ev.start);
|
||||||
const e = new Date(ev.end);
|
const e = new Date(ev.end);
|
||||||
document.getElementById('ev-start').value = toLocalDatetimeInput(s);
|
setDtValue('ev-start', toLocalDatetimeInput(s), 'datetime');
|
||||||
document.getElementById('ev-end').value = toLocalDatetimeInput(e);
|
setDtValue('ev-end', toLocalDatetimeInput(e), 'datetime');
|
||||||
toggleAlldayFields(false);
|
toggleAlldayFields(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -781,6 +794,24 @@ function bindEventModal() {
|
|||||||
toggleAlldayFields(e.target.checked);
|
toggleAlldayFields(e.target.checked);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Date/time pickers
|
||||||
|
[
|
||||||
|
{ displayId: 'ev-start-display', inputId: 'ev-start', mode: 'datetime' },
|
||||||
|
{ displayId: 'ev-end-display', inputId: 'ev-end', mode: 'datetime' },
|
||||||
|
{ displayId: 'ev-start-date-display', inputId: 'ev-start-date', mode: 'date' },
|
||||||
|
{ displayId: 'ev-end-date-display', inputId: 'ev-end-date', mode: 'date' },
|
||||||
|
].forEach(({ displayId, inputId, mode }) => {
|
||||||
|
const disp = document.getElementById(displayId);
|
||||||
|
if (!disp) return;
|
||||||
|
const open = async () => {
|
||||||
|
const current = document.getElementById(inputId)?.value || '';
|
||||||
|
const result = await openDatePicker(disp, current, mode);
|
||||||
|
if (result !== null) setDtValue(inputId, result, mode);
|
||||||
|
};
|
||||||
|
disp.addEventListener('click', open);
|
||||||
|
disp.addEventListener('keydown', e => { if (e.key === 'Enter' || e.key === ' ') open(); });
|
||||||
|
});
|
||||||
|
|
||||||
// Color picker: click preview to open gradient picker
|
// Color picker: click preview to open gradient picker
|
||||||
const evColorPreview = document.getElementById('ev-color-preview');
|
const evColorPreview = document.getElementById('ev-color-preview');
|
||||||
const evColorHex = document.getElementById('ev-color-hex');
|
const evColorHex = document.getElementById('ev-color-hex');
|
||||||
|
|||||||
271
frontend/js/date-picker.js
Normal file
271
frontend/js/date-picker.js
Normal file
@@ -0,0 +1,271 @@
|
|||||||
|
/**
|
||||||
|
* Custom dark date/time picker
|
||||||
|
* openDatePicker(anchor, value, mode) → Promise<string|null>
|
||||||
|
* anchor : DOM element to position near
|
||||||
|
* value : ISO string ("YYYY-MM-DDTHH:MM" | "YYYY-MM-DD") or ""
|
||||||
|
* mode : 'datetime' | 'date'
|
||||||
|
*/
|
||||||
|
import { t } from './i18n.js';
|
||||||
|
|
||||||
|
const ITEM_H = 40; // px per scroll item
|
||||||
|
const VISIBLE = 3; // visible items in time scroller
|
||||||
|
|
||||||
|
export function openDatePicker(anchor, value, mode = 'datetime') {
|
||||||
|
return new Promise(resolve => {
|
||||||
|
// ── Parse initial value ───────────────────────────────
|
||||||
|
let selDate = new Date();
|
||||||
|
selDate.setHours(0, 0, 0, 0);
|
||||||
|
let selHour = selDate.getHours();
|
||||||
|
let selMin = 0;
|
||||||
|
|
||||||
|
if (value) {
|
||||||
|
try {
|
||||||
|
const raw = mode === 'datetime'
|
||||||
|
? value.replace(' ', 'T')
|
||||||
|
: value + 'T00:00:00';
|
||||||
|
const d = new Date(raw);
|
||||||
|
if (!isNaN(d)) {
|
||||||
|
selDate = new Date(d.getFullYear(), d.getMonth(), d.getDate());
|
||||||
|
if (mode === 'datetime') { selHour = d.getHours(); selMin = d.getMinutes(); }
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
let viewYear = selDate.getFullYear();
|
||||||
|
let viewMonth = selDate.getMonth();
|
||||||
|
|
||||||
|
// ── Build DOM ─────────────────────────────────────────
|
||||||
|
const overlay = document.createElement('div');
|
||||||
|
overlay.className = 'dtp-overlay';
|
||||||
|
document.body.appendChild(overlay);
|
||||||
|
|
||||||
|
const card = document.createElement('div');
|
||||||
|
card.className = 'dtp-card';
|
||||||
|
overlay.appendChild(card);
|
||||||
|
|
||||||
|
function done(result) {
|
||||||
|
overlay.remove();
|
||||||
|
resolve(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Click outside → cancel
|
||||||
|
overlay.addEventListener('mousedown', e => {
|
||||||
|
if (e.target === overlay) done(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Calendar builder ──────────────────────────────────
|
||||||
|
function buildCalendar() {
|
||||||
|
const months = t('months');
|
||||||
|
const dowKeys = t('dow_monday'); // always Monday-first in calendar
|
||||||
|
|
||||||
|
const firstDay = new Date(viewYear, viewMonth, 1);
|
||||||
|
const gridStart = new Date(firstDay);
|
||||||
|
let dow = firstDay.getDay();
|
||||||
|
dow = dow === 0 ? 6 : dow - 1; // 0=Mon…6=Sun
|
||||||
|
gridStart.setDate(gridStart.getDate() - dow);
|
||||||
|
|
||||||
|
const cells = [];
|
||||||
|
const iter = new Date(gridStart);
|
||||||
|
for (let i = 0; i < 42; i++) {
|
||||||
|
cells.push(new Date(iter));
|
||||||
|
iter.setDate(iter.getDate() + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const today = new Date(); today.setHours(0, 0, 0, 0);
|
||||||
|
|
||||||
|
const dowHtml = dowKeys.map(d => `<div class="dtp-dow">${d}</div>`).join('');
|
||||||
|
const daysHtml = cells.map(cell => {
|
||||||
|
const isOther = cell.getMonth() !== viewMonth;
|
||||||
|
const isToday = cell.getTime() === today.getTime();
|
||||||
|
const isSelected = cell.getTime() === selDate.getTime();
|
||||||
|
let cls = 'dtp-day';
|
||||||
|
if (isOther) cls += ' other';
|
||||||
|
if (isToday) cls += ' today';
|
||||||
|
if (isSelected) cls += ' selected';
|
||||||
|
return `<div class="${cls}" data-ts="${cell.getTime()}">${cell.getDate()}</div>`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
return `<div class="dtp-cal-header">
|
||||||
|
<button class="dtp-nav-btn" id="dtp-prev">‹</button>
|
||||||
|
<span class="dtp-month-label">${months[viewMonth]} ${viewYear}</span>
|
||||||
|
<button class="dtp-nav-btn" id="dtp-next">›</button>
|
||||||
|
</div>
|
||||||
|
<div class="dtp-grid">
|
||||||
|
${dowHtml}
|
||||||
|
${daysHtml}
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Time scroll builder ───────────────────────────────
|
||||||
|
function buildTime() {
|
||||||
|
if (mode !== 'datetime') return '';
|
||||||
|
const hItems = Array.from({ length: 24 }, (_, i) =>
|
||||||
|
`<div class="dtp-ti" data-val="${i}">${String(i).padStart(2,'0')}</div>`
|
||||||
|
).join('');
|
||||||
|
const mItems = Array.from({ length: 60 }, (_, i) =>
|
||||||
|
`<div class="dtp-ti" data-val="${i}">${String(i).padStart(2,'0')}</div>`
|
||||||
|
).join('');
|
||||||
|
return `<div class="dtp-time-row">
|
||||||
|
<div class="dtp-tc-wrap">
|
||||||
|
<div class="dtp-tc" id="dtp-h">${hItems}</div>
|
||||||
|
</div>
|
||||||
|
<div class="dtp-colon">:</div>
|
||||||
|
<div class="dtp-tc-wrap">
|
||||||
|
<div class="dtp-tc" id="dtp-m">${mItems}</div>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Render ────────────────────────────────────────────
|
||||||
|
function render() {
|
||||||
|
card.innerHTML =
|
||||||
|
buildCalendar() +
|
||||||
|
buildTime() +
|
||||||
|
`<div class="dtp-actions">
|
||||||
|
<button class="btn btn-ghost btn-sm" id="dtp-cancel">${t('cancel')}</button>
|
||||||
|
<button class="btn btn-primary btn-sm" id="dtp-ok">${t('save')}</button>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
bindEvents();
|
||||||
|
if (mode === 'datetime') initScrollers();
|
||||||
|
positionCard();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Event bindings ────────────────────────────────────
|
||||||
|
function bindEvents() {
|
||||||
|
card.querySelector('#dtp-prev').onclick = () => {
|
||||||
|
viewMonth--;
|
||||||
|
if (viewMonth < 0) { viewMonth = 11; viewYear--; }
|
||||||
|
render();
|
||||||
|
};
|
||||||
|
card.querySelector('#dtp-next').onclick = () => {
|
||||||
|
viewMonth++;
|
||||||
|
if (viewMonth > 11) { viewMonth = 0; viewYear++; }
|
||||||
|
render();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Day click
|
||||||
|
card.querySelectorAll('.dtp-day').forEach(el => {
|
||||||
|
el.addEventListener('click', () => {
|
||||||
|
selDate = new Date(parseInt(el.dataset.ts));
|
||||||
|
if (el.classList.contains('other')) {
|
||||||
|
viewYear = selDate.getFullYear();
|
||||||
|
viewMonth = selDate.getMonth();
|
||||||
|
}
|
||||||
|
render();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
card.querySelector('#dtp-cancel').onclick = () => done(null);
|
||||||
|
card.querySelector('#dtp-ok').onclick = () => done(buildResult());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Time scroll initialisation ────────────────────────
|
||||||
|
function initScrollers() {
|
||||||
|
const hCol = card.querySelector('#dtp-h');
|
||||||
|
const mCol = card.querySelector('#dtp-m');
|
||||||
|
if (!hCol || !mCol) return;
|
||||||
|
|
||||||
|
// Scroll to selected value (padding-top = ITEM_H, so scrollTop = val * ITEM_H)
|
||||||
|
hCol.scrollTop = selHour * ITEM_H;
|
||||||
|
mCol.scrollTop = selMin * ITEM_H;
|
||||||
|
highlightItems(hCol, selHour);
|
||||||
|
highlightItems(mCol, selMin);
|
||||||
|
|
||||||
|
let hTimer, mTimer;
|
||||||
|
|
||||||
|
hCol.addEventListener('scroll', () => {
|
||||||
|
clearTimeout(hTimer);
|
||||||
|
hTimer = setTimeout(() => {
|
||||||
|
selHour = Math.max(0, Math.min(23, Math.round(hCol.scrollTop / ITEM_H)));
|
||||||
|
hCol.scrollTop = selHour * ITEM_H;
|
||||||
|
highlightItems(hCol, selHour);
|
||||||
|
}, 80);
|
||||||
|
});
|
||||||
|
|
||||||
|
mCol.addEventListener('scroll', () => {
|
||||||
|
clearTimeout(mTimer);
|
||||||
|
mTimer = setTimeout(() => {
|
||||||
|
selMin = Math.max(0, Math.min(59, Math.round(mCol.scrollTop / ITEM_H)));
|
||||||
|
mCol.scrollTop = selMin * ITEM_H;
|
||||||
|
highlightItems(mCol, selMin);
|
||||||
|
}, 80);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Click item to select
|
||||||
|
hCol.querySelectorAll('.dtp-ti').forEach((el, i) => {
|
||||||
|
el.addEventListener('click', () => {
|
||||||
|
selHour = i;
|
||||||
|
hCol.scrollTo({ top: i * ITEM_H, behavior: 'smooth' });
|
||||||
|
highlightItems(hCol, i);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
mCol.querySelectorAll('.dtp-ti').forEach((el, i) => {
|
||||||
|
el.addEventListener('click', () => {
|
||||||
|
selMin = i;
|
||||||
|
mCol.scrollTo({ top: i * ITEM_H, behavior: 'smooth' });
|
||||||
|
highlightItems(mCol, i);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function highlightItems(col, val) {
|
||||||
|
col.querySelectorAll('.dtp-ti').forEach((el, i) => {
|
||||||
|
el.classList.toggle('selected', i === val);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Result builder ────────────────────────────────────
|
||||||
|
function buildResult() {
|
||||||
|
const y = selDate.getFullYear();
|
||||||
|
const mo = String(selDate.getMonth() + 1).padStart(2, '0');
|
||||||
|
const dd = String(selDate.getDate()).padStart(2, '0');
|
||||||
|
if (mode === 'date') return `${y}-${mo}-${dd}`;
|
||||||
|
const h = String(selHour).padStart(2, '0');
|
||||||
|
const mi = String(selMin).padStart(2, '0');
|
||||||
|
return `${y}-${mo}-${dd}T${h}:${mi}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Positioning ───────────────────────────────────────
|
||||||
|
function positionCard() {
|
||||||
|
const r = anchor.getBoundingClientRect();
|
||||||
|
const cw = card.offsetWidth || 280;
|
||||||
|
const ch = card.offsetHeight || 420;
|
||||||
|
let left = r.left;
|
||||||
|
let top = r.bottom + 6;
|
||||||
|
if (left + cw > window.innerWidth - 8) left = window.innerWidth - cw - 8;
|
||||||
|
if (top + ch > window.innerHeight - 8) top = r.top - ch - 6;
|
||||||
|
if (left < 8) left = 8;
|
||||||
|
if (top < 8) top = 8;
|
||||||
|
card.style.left = left + 'px';
|
||||||
|
card.style.top = top + 'px';
|
||||||
|
}
|
||||||
|
|
||||||
|
render();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format an ISO value for display in the UI
|
||||||
|
* mode: 'datetime' | 'date'
|
||||||
|
* lang: 'de' | 'en'
|
||||||
|
*/
|
||||||
|
export function formatDtDisplay(isoStr, mode, lang = 'de') {
|
||||||
|
if (!isoStr) return '—';
|
||||||
|
try {
|
||||||
|
const d = mode === 'datetime'
|
||||||
|
? new Date(isoStr.replace(' ', 'T'))
|
||||||
|
: new Date(isoStr + 'T00:00:00');
|
||||||
|
if (isNaN(d)) return isoStr;
|
||||||
|
const locale = lang === 'en' ? 'en-GB' : 'de-CH';
|
||||||
|
if (mode === 'datetime') {
|
||||||
|
return d.toLocaleString(locale, {
|
||||||
|
day: '2-digit', month: '2-digit', year: 'numeric',
|
||||||
|
hour: '2-digit', minute: '2-digit', hour12: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return d.toLocaleDateString(locale, {
|
||||||
|
day: '2-digit', month: '2-digit', year: 'numeric',
|
||||||
|
});
|
||||||
|
} catch (_) { return isoStr; }
|
||||||
|
}
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
import { isToday, isPast, dayOfWeek, getISOWeekNumber } from '../utils.js';
|
import { isToday, isPast, dayOfWeek, getISOWeekNumber } from '../utils.js';
|
||||||
import { t } from '../i18n.js';
|
import { t } from '../i18n.js';
|
||||||
|
|
||||||
const LANE_H = 20; // px per lane (height 18px + 2px gap)
|
const LANE_H = 20; // px per lane (event height 18px + 2px gap)
|
||||||
const MAX_LANES = 3; // max visible event lanes per row
|
const MAX_LANES = 3; // max visible lanes per row
|
||||||
|
|
||||||
export function renderMonth(container, currentDate, events, onDayClick, onEventClick, weekStartDay = 'monday') {
|
export function renderMonth(container, currentDate, events, onDayClick, onEventClick, weekStartDay = 'monday') {
|
||||||
const year = currentDate.getFullYear();
|
const year = currentDate.getFullYear();
|
||||||
@@ -24,10 +24,8 @@ export function renderMonth(container, currentDate, events, onDayClick, onEventC
|
|||||||
|
|
||||||
// Normalize each event's date range once
|
// Normalize each event's date range once
|
||||||
const normed = events.map(ev => {
|
const normed = events.map(ev => {
|
||||||
const s = new Date(ev.start);
|
const s = new Date(ev.start); s.setHours(0, 0, 0, 0);
|
||||||
s.setHours(0, 0, 0, 0);
|
const e = new Date(ev.end); e.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
|
if (ev.allDay && e > s) e.setDate(e.getDate() - 1); // exclusive → inclusive
|
||||||
return { ev, ns: s, ne: e };
|
return { ev, ns: s, ne: e };
|
||||||
});
|
});
|
||||||
@@ -52,11 +50,10 @@ export function renderMonth(container, currentDate, events, onDayClick, onEventC
|
|||||||
const colStart = Math.max(0, daysBetween(rowStart, ns));
|
const colStart = Math.max(0, daysBetween(rowStart, ns));
|
||||||
const colEnd = Math.min(6, daysBetween(rowStart, ne));
|
const colEnd = Math.min(6, daysBetween(rowStart, ne));
|
||||||
if (colEnd < colStart) return;
|
if (colEnd < colStart) return;
|
||||||
const span = colEnd - colStart + 1;
|
|
||||||
rowItems.push({
|
rowItems.push({
|
||||||
ev,
|
ev,
|
||||||
colStart,
|
colStart,
|
||||||
span,
|
span: colEnd - colStart + 1,
|
||||||
continuesLeft: ns < rowStart,
|
continuesLeft: ns < rowStart,
|
||||||
continuesRight: ne > rowEnd,
|
continuesRight: ne > rowEnd,
|
||||||
});
|
});
|
||||||
@@ -71,7 +68,7 @@ export function renderMonth(container, currentDate, events, onDayClick, onEventC
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Assign lanes (greedy interval packing)
|
// Assign lanes (greedy interval packing)
|
||||||
const lanes = []; // { colEnd }
|
const lanes = [];
|
||||||
rowItems.forEach(item => {
|
rowItems.forEach(item => {
|
||||||
let laneIdx = lanes.findIndex(l => item.colStart >= l.colEnd);
|
let laneIdx = lanes.findIndex(l => item.colStart >= l.colEnd);
|
||||||
if (laneIdx === -1) { laneIdx = lanes.length; lanes.push({ colEnd: 0 }); }
|
if (laneIdx === -1) { laneIdx = lanes.length; lanes.push({ colEnd: 0 }); }
|
||||||
@@ -89,7 +86,7 @@ export function renderMonth(container, currentDate, events, onDayClick, onEventC
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Render event spans
|
// Render event spans HTML (placed in overlay)
|
||||||
let eventsHtml = '';
|
let eventsHtml = '';
|
||||||
rowItems.forEach(item => {
|
rowItems.forEach(item => {
|
||||||
if (item.lane >= MAX_LANES) return;
|
if (item.lane >= MAX_LANES) return;
|
||||||
@@ -110,25 +107,23 @@ export function renderMonth(container, currentDate, events, onDayClick, onEventC
|
|||||||
title="${escAttr(ev.title)}">${escHtml(label)}</div>`;
|
title="${escAttr(ev.title)}">${escHtml(label)}</div>`;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Render "+N more" per column
|
// "+N more" per column
|
||||||
Object.entries(overflowByCol).forEach(([col, count]) => {
|
Object.entries(overflowByCol).forEach(([col, count]) => {
|
||||||
const c = parseInt(col);
|
const c = parseInt(col);
|
||||||
const leftPct = (c / 7) * 100;
|
|
||||||
const widthPct = (1 / 7) * 100;
|
|
||||||
eventsHtml += `<div class="month-more"
|
eventsHtml += `<div class="month-more"
|
||||||
data-date="${dateKey(rowCells[c])}"
|
data-date="${dateKey(rowCells[c])}"
|
||||||
style="left:${leftPct.toFixed(3)}%;width:${widthPct.toFixed(3)}%;bottom:2px">${t('more_events', { n: count })}</div>`;
|
style="left:${((c / 7) * 100).toFixed(3)}%;width:${(100 / 7).toFixed(3)}%;bottom:2px">${t('more_events', { n: count })}</div>`;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Day cells (numbers only)
|
// Full-height column divs (click targets + borders)
|
||||||
let dayCellsHtml = '';
|
let colsHtml = '';
|
||||||
rowCells.forEach(cell => {
|
rowCells.forEach(cell => {
|
||||||
const key = dateKey(cell);
|
const key = dateKey(cell);
|
||||||
const isOther = cell.getMonth() !== month;
|
const isOther = cell.getMonth() !== month;
|
||||||
const todayCls = isToday(cell) ? 'today' : '';
|
const todayCls = isToday(cell) ? 'today' : '';
|
||||||
const otherCls = isOther ? 'other-month' : '';
|
const otherCls = isOther ? 'other-month' : '';
|
||||||
const numCls = isToday(cell) ? 'today' : '';
|
const numCls = isToday(cell) ? 'today' : '';
|
||||||
dayCellsHtml += `<div class="month-cell ${todayCls} ${otherCls}" data-date="${key}">
|
colsHtml += `<div class="month-col ${todayCls} ${otherCls}" data-date="${key}">
|
||||||
<div class="cell-day ${numCls}">${cell.getDate()}</div>
|
<div class="cell-day ${numCls}">${cell.getDate()}</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
});
|
});
|
||||||
@@ -136,8 +131,8 @@ export function renderMonth(container, currentDate, events, onDayClick, onEventC
|
|||||||
bodyHtml += `<div class="month-row">
|
bodyHtml += `<div class="month-row">
|
||||||
<div class="month-kw-cell">${kw}</div>
|
<div class="month-kw-cell">${kw}</div>
|
||||||
<div class="month-row-right">
|
<div class="month-row-right">
|
||||||
<div class="month-day-strip">${dayCellsHtml}</div>
|
${colsHtml}
|
||||||
<div class="month-events-area">${eventsHtml}</div>
|
<div class="month-events-overlay">${eventsHtml}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
@@ -147,7 +142,7 @@ export function renderMonth(container, currentDate, events, onDayClick, onEventC
|
|||||||
<div class="month-body">${bodyHtml}</div>
|
<div class="month-body">${bodyHtml}</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
|
|
||||||
// Click handlers — event delegation
|
// Click handlers via event delegation on the body
|
||||||
const body = container.querySelector('.month-body');
|
const body = container.querySelector('.month-body');
|
||||||
body.addEventListener('click', e => {
|
body.addEventListener('click', e => {
|
||||||
// Span event click
|
// Span event click
|
||||||
@@ -158,17 +153,17 @@ export function renderMonth(container, currentDate, events, onDayClick, onEventC
|
|||||||
if (ev) onEventClick(ev, spanEl);
|
if (ev) onEventClick(ev, spanEl);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// "+N more" click → day view
|
// "+N more" → navigate to day view
|
||||||
const moreEl = e.target.closest('.month-more');
|
const moreEl = e.target.closest('.month-more');
|
||||||
if (moreEl) {
|
if (moreEl) {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onDayClick(new Date(moreEl.dataset.date + 'T00:00:00'));
|
onDayClick(new Date(moreEl.dataset.date + 'T00:00:00'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Day cell click → day view
|
// Column click → navigate to day view
|
||||||
const cellEl = e.target.closest('.month-cell');
|
const colEl = e.target.closest('.month-col');
|
||||||
if (cellEl) {
|
if (colEl) {
|
||||||
onDayClick(new Date(cellEl.dataset.date + 'T00:00:00'));
|
onDayClick(new Date(colEl.dataset.date + 'T00:00:00'));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -176,7 +171,6 @@ export function renderMonth(container, currentDate, events, onDayClick, onEventC
|
|||||||
// ── Helpers ───────────────────────────────────────────────
|
// ── Helpers ───────────────────────────────────────────────
|
||||||
|
|
||||||
function daysBetween(a, b) {
|
function daysBetween(a, b) {
|
||||||
// Number of whole days from date a to date b (can be negative)
|
|
||||||
return Math.round((b - a) / 86400000);
|
return Math.round((b - a) / 86400000);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user