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);
|
||||
}
|
||||
.month-kw-cell {
|
||||
position: absolute; left: 0; top: 0; bottom: 0;
|
||||
width: 38px;
|
||||
position: absolute; left: 0; top: 0; bottom: 0; width: 38px;
|
||||
display: flex; align-items: flex-start; justify-content: center;
|
||||
padding-top: 6px;
|
||||
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;
|
||||
background: var(--bg-app);
|
||||
}
|
||||
.month-cell {
|
||||
flex: 1;
|
||||
border-right: 1px solid var(--border);
|
||||
padding: 4px 4px 0;
|
||||
cursor: pointer;
|
||||
transition: background var(--transition);
|
||||
min-width: 0;
|
||||
/* Full-height column divs — click target + border */
|
||||
.month-col {
|
||||
flex: 1; border-right: 1px solid var(--border);
|
||||
cursor: pointer; transition: background var(--transition);
|
||||
padding: 4px 4px 0; min-width: 0;
|
||||
}
|
||||
.month-cell:last-child { border-right: none; }
|
||||
.month-cell:hover { background: var(--bg-hover); }
|
||||
.month-cell.today { background: rgba(66,133,244,.08); }
|
||||
.month-cell.other-month .cell-day { color: var(--text-3); }
|
||||
.month-col:last-child { border-right: none; }
|
||||
.month-col:hover { background: var(--bg-hover); }
|
||||
.month-col.today { background: rgba(66,133,244,.08); }
|
||||
.month-col.other-month .cell-day { color: var(--text-3); }
|
||||
.cell-day {
|
||||
font-size: 12px; font-weight: 500; color: var(--text-2);
|
||||
width: 26px; height: 26px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
border-radius: 50%; flex-shrink: 0;
|
||||
}
|
||||
.cell-day.today {
|
||||
background: var(--today-color);
|
||||
color: #fff; font-weight: 700;
|
||||
.cell-day.today { background: var(--today-color); color: #fff; font-weight: 700; }
|
||||
/* Events overlay — pointer-events:none so clicks pass to columns */
|
||||
.month-events-overlay {
|
||||
position: absolute; top: 30px; left: 0; right: 0; bottom: 0;
|
||||
pointer-events: none; overflow: hidden; z-index: 2;
|
||||
}
|
||||
.month-more {
|
||||
position: absolute;
|
||||
font-size: 11px; color: var(--text-2); padding: 0 4px;
|
||||
cursor: pointer; font-weight: 500;
|
||||
pointer-events: all;
|
||||
white-space: nowrap; overflow: hidden;
|
||||
}
|
||||
.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;
|
||||
}
|
||||
.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);
|
||||
}
|
||||
.month-row:last-child { border-bottom: none; }
|
||||
/* row-right: flex row containing 7 full-height column divs + events overlay */
|
||||
.month-row-right {
|
||||
margin-left: 38px; display: flex; flex-direction: column; flex: 1; 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;
|
||||
margin-left: 38px; display: flex; flex: 1; position: relative; min-width: 0;
|
||||
}
|
||||
.month-span-event {
|
||||
position: absolute;
|
||||
height: 18px; line-height: 18px;
|
||||
border-radius: 3px;
|
||||
padding: 0 6px;
|
||||
border-radius: 3px; padding: 0 6px;
|
||||
font-size: 11px; font-weight: 500;
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||
cursor: pointer; color: #fff;
|
||||
transition: filter var(--transition);
|
||||
box-sizing: border-box;
|
||||
z-index: 2;
|
||||
pointer-events: all;
|
||||
}
|
||||
.month-span-event:hover { filter: brightness(1.15); }
|
||||
.month-span-event.past { opacity: .45; }
|
||||
@@ -947,3 +940,118 @@ a { color: var(--primary); text-decoration: none; }
|
||||
.logo-text { display: none; }
|
||||
.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-group half">
|
||||
<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 class="form-group half">
|
||||
<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 class="form-row" id="ev-date-row" style="display:none">
|
||||
<div class="form-group half">
|
||||
<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 class="form-group half">
|
||||
<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 class="form-group">
|
||||
|
||||
@@ -4,6 +4,7 @@ import { renderMonth } from './views/month.js';
|
||||
import { renderWeek } from './views/week.js';
|
||||
import { renderAgenda } from './views/agenda.js';
|
||||
import { openColorPicker } from './color-picker.js';
|
||||
import { openDatePicker, formatDtDisplay } from './date-picker.js';
|
||||
import { t, setLang, getLang } from './i18n.js';
|
||||
|
||||
// Fetch avatar image as blob URL (with auth header)
|
||||
@@ -540,12 +541,13 @@ 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;
|
||||
// Mouse wheel / trackpad scroll navigation (500ms cooldown = 1 nav per gesture)
|
||||
let _wheelLast = 0;
|
||||
document.getElementById('view-container').addEventListener('wheel', e => {
|
||||
e.preventDefault();
|
||||
if (_wheelTimer) return;
|
||||
_wheelTimer = setTimeout(() => { _wheelTimer = null; }, 80);
|
||||
const now = Date.now();
|
||||
if (now - _wheelLast < 500) return;
|
||||
_wheelLast = now;
|
||||
const dir = e.deltaY > 0 ? 1 : -1;
|
||||
if (state.currentView === 'agenda') return;
|
||||
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) {
|
||||
state.editingEvent = null;
|
||||
state.selectedEventColor = '';
|
||||
@@ -723,10 +736,10 @@ function openNewEventModal(date) {
|
||||
const start = new Date(date);
|
||||
const end = new Date(date);
|
||||
end.setHours(end.getHours() + 1);
|
||||
document.getElementById('ev-start').value = toLocalDatetimeInput(start);
|
||||
document.getElementById('ev-end').value = toLocalDatetimeInput(end);
|
||||
document.getElementById('ev-start-date').value = toDateInput(start);
|
||||
document.getElementById('ev-end-date').value = toDateInput(start);
|
||||
setDtValue('ev-start', toLocalDatetimeInput(start), 'datetime');
|
||||
setDtValue('ev-end', toLocalDatetimeInput(end), 'datetime');
|
||||
setDtValue('ev-start-date', toDateInput(start), 'date');
|
||||
setDtValue('ev-end-date', toDateInput(start), 'date');
|
||||
|
||||
toggleAlldayFields(false);
|
||||
populateCalendarSelect(null);
|
||||
@@ -746,14 +759,14 @@ function openEditEventModal(ev) {
|
||||
document.getElementById('ev-allday').checked = ev.allDay;
|
||||
|
||||
if (ev.allDay) {
|
||||
document.getElementById('ev-start-date').value = ev.start.slice(0, 10);
|
||||
document.getElementById('ev-end-date').value = ev.end.slice(0, 10);
|
||||
setDtValue('ev-start-date', ev.start.slice(0, 10), 'date');
|
||||
setDtValue('ev-end-date', ev.end.slice(0, 10), 'date');
|
||||
toggleAlldayFields(true);
|
||||
} else {
|
||||
const s = new Date(ev.start);
|
||||
const e = new Date(ev.end);
|
||||
document.getElementById('ev-start').value = toLocalDatetimeInput(s);
|
||||
document.getElementById('ev-end').value = toLocalDatetimeInput(e);
|
||||
setDtValue('ev-start', toLocalDatetimeInput(s), 'datetime');
|
||||
setDtValue('ev-end', toLocalDatetimeInput(e), 'datetime');
|
||||
toggleAlldayFields(false);
|
||||
}
|
||||
|
||||
@@ -781,6 +794,24 @@ function bindEventModal() {
|
||||
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
|
||||
const evColorPreview = document.getElementById('ev-color-preview');
|
||||
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 { 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
|
||||
const LANE_H = 20; // px per lane (event height 18px + 2px gap)
|
||||
const MAX_LANES = 3; // max visible lanes per row
|
||||
|
||||
export function renderMonth(container, currentDate, events, onDayClick, onEventClick, weekStartDay = 'monday') {
|
||||
const year = currentDate.getFullYear();
|
||||
@@ -24,10 +24,8 @@ export function renderMonth(container, currentDate, events, onDayClick, onEventC
|
||||
|
||||
// Normalize each event's date range once
|
||||
const normed = events.map(ev => {
|
||||
const s = new Date(ev.start);
|
||||
s.setHours(0, 0, 0, 0);
|
||||
const e = new Date(ev.end);
|
||||
e.setHours(0, 0, 0, 0);
|
||||
const s = new Date(ev.start); 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 };
|
||||
});
|
||||
@@ -52,11 +50,10 @@ export function renderMonth(container, currentDate, events, onDayClick, onEventC
|
||||
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,
|
||||
span: colEnd - colStart + 1,
|
||||
continuesLeft: ns < rowStart,
|
||||
continuesRight: ne > rowEnd,
|
||||
});
|
||||
@@ -71,7 +68,7 @@ export function renderMonth(container, currentDate, events, onDayClick, onEventC
|
||||
});
|
||||
|
||||
// Assign lanes (greedy interval packing)
|
||||
const lanes = []; // { colEnd }
|
||||
const lanes = [];
|
||||
rowItems.forEach(item => {
|
||||
let laneIdx = lanes.findIndex(l => item.colStart >= l.colEnd);
|
||||
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 = '';
|
||||
rowItems.forEach(item => {
|
||||
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>`;
|
||||
});
|
||||
|
||||
// Render "+N more" per column
|
||||
// "+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>`;
|
||||
style="left:${((c / 7) * 100).toFixed(3)}%;width:${(100 / 7).toFixed(3)}%;bottom:2px">${t('more_events', { n: count })}</div>`;
|
||||
});
|
||||
|
||||
// Day cells (numbers only)
|
||||
let dayCellsHtml = '';
|
||||
// Full-height column divs (click targets + borders)
|
||||
let colsHtml = '';
|
||||
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}">
|
||||
colsHtml += `<div class="month-col ${todayCls} ${otherCls}" data-date="${key}">
|
||||
<div class="cell-day ${numCls}">${cell.getDate()}</div>
|
||||
</div>`;
|
||||
});
|
||||
@@ -136,8 +131,8 @@ export function renderMonth(container, currentDate, events, onDayClick, onEventC
|
||||
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>
|
||||
${colsHtml}
|
||||
<div class="month-events-overlay">${eventsHtml}</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
@@ -147,7 +142,7 @@ export function renderMonth(container, currentDate, events, onDayClick, onEventC
|
||||
<div class="month-body">${bodyHtml}</div>
|
||||
</div>`;
|
||||
|
||||
// Click handlers — event delegation
|
||||
// Click handlers via event delegation on the body
|
||||
const body = container.querySelector('.month-body');
|
||||
body.addEventListener('click', e => {
|
||||
// Span event click
|
||||
@@ -158,17 +153,17 @@ export function renderMonth(container, currentDate, events, onDayClick, onEventC
|
||||
if (ev) onEventClick(ev, spanEl);
|
||||
return;
|
||||
}
|
||||
// "+N more" click → day view
|
||||
// "+N more" → navigate to 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'));
|
||||
// Column click → navigate to day view
|
||||
const colEl = e.target.closest('.month-col');
|
||||
if (colEl) {
|
||||
onDayClick(new Date(colEl.dataset.date + 'T00:00:00'));
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -176,7 +171,6 @@ export function renderMonth(container, currentDate, events, onDayClick, onEventC
|
||||
// ── Helpers ───────────────────────────────────────────────
|
||||
|
||||
function daysBetween(a, b) {
|
||||
// Number of whole days from date a to date b (can be negative)
|
||||
return Math.round((b - a) / 86400000);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user