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:
@@ -151,6 +151,19 @@ a { color: var(--primary); text-decoration: none; }
|
||||
border-color: var(--primary);
|
||||
}
|
||||
.form-group textarea { resize: vertical; }
|
||||
|
||||
/* ── Date/time input dark mode ──────────────────────────── */
|
||||
.form-group input[type="datetime-local"],
|
||||
.form-group input[type="date"] {
|
||||
color-scheme: dark;
|
||||
}
|
||||
.form-group input[type="datetime-local"]::-webkit-calendar-picker-indicator,
|
||||
.form-group input[type="date"]::-webkit-calendar-picker-indicator {
|
||||
filter: invert(0.8);
|
||||
opacity: 0.7;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: flex; gap: 12px; margin-bottom: 16px; align-items: center;
|
||||
}
|
||||
@@ -455,31 +468,25 @@ a { color: var(--primary); text-decoration: none; }
|
||||
font-size: 11px; font-weight: 600; text-transform: uppercase;
|
||||
letter-spacing: .5px; color: var(--text-2);
|
||||
}
|
||||
.month-grid {
|
||||
display: grid; grid-template-columns: 38px repeat(7, 1fr);
|
||||
grid-template-rows: repeat(6, 1fr);
|
||||
flex: 1; overflow: hidden;
|
||||
}
|
||||
.month-kw-cell {
|
||||
border-right: 1px solid var(--border-light);
|
||||
border-bottom: 1px solid var(--border);
|
||||
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;
|
||||
cursor: default; user-select: none;
|
||||
min-height: 0;
|
||||
border-right: 1px solid var(--border-light);
|
||||
cursor: default; user-select: none; z-index: 1;
|
||||
background: var(--bg-app);
|
||||
}
|
||||
.month-cell {
|
||||
flex: 1;
|
||||
border-right: 1px solid var(--border);
|
||||
border-bottom: 1px solid var(--border);
|
||||
padding: 4px;
|
||||
overflow: hidden;
|
||||
padding: 4px 4px 0;
|
||||
cursor: pointer;
|
||||
transition: background var(--transition);
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
}
|
||||
/* every 8th child is the last day column (after KW cell) */
|
||||
.month-cell:nth-child(8n) { border-right: none; }
|
||||
.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); }
|
||||
@@ -487,23 +494,15 @@ a { color: var(--primary); text-decoration: none; }
|
||||
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%; margin-bottom: 2px; flex-shrink: 0;
|
||||
border-radius: 50%; flex-shrink: 0;
|
||||
}
|
||||
.cell-day.today {
|
||||
background: var(--today-color);
|
||||
color: #fff; font-weight: 700;
|
||||
}
|
||||
.month-event {
|
||||
font-size: 11px; font-weight: 500;
|
||||
padding: 1px 6px; border-radius: 3px;
|
||||
margin-bottom: 1px; cursor: pointer;
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||
transition: filter var(--transition);
|
||||
}
|
||||
.month-event:hover { filter: brightness(1.15); }
|
||||
.month-event.past { opacity: .45; }
|
||||
.month-more {
|
||||
font-size: 11px; color: var(--text-2); padding: 1px 6px;
|
||||
position: absolute;
|
||||
font-size: 11px; color: var(--text-2); padding: 0 4px;
|
||||
cursor: pointer; font-weight: 500;
|
||||
}
|
||||
.month-more:hover { color: var(--primary); }
|
||||
@@ -747,29 +746,38 @@ a { color: var(--primary); text-decoration: none; }
|
||||
font-size: 12px; color: var(--text-3); margin: 0 0 12px;
|
||||
}
|
||||
|
||||
/* Contrast / option selectors */
|
||||
/* Contrast / option selectors — segmented pill */
|
||||
.contrast-selector {
|
||||
display: flex; gap: 8px; flex-wrap: wrap;
|
||||
display: inline-flex;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 20px;
|
||||
overflow: hidden;
|
||||
background: var(--bg-surface);
|
||||
}
|
||||
.contrast-btn {
|
||||
display: flex; flex-direction: column; align-items: center; gap: 6px;
|
||||
padding: 10px 16px; border-radius: var(--radius);
|
||||
border: 1px solid var(--border); background: var(--bg-surface);
|
||||
cursor: pointer; transition: border-color .15s, background .15s;
|
||||
min-width: 70px;
|
||||
display: flex; flex-direction: column; align-items: center; gap: 4px;
|
||||
padding: 8px 14px; min-width: 64px;
|
||||
background: transparent; border: none;
|
||||
border-right: 1px solid var(--border);
|
||||
cursor: pointer; color: var(--text-2);
|
||||
transition: background var(--transition), color var(--transition);
|
||||
}
|
||||
.contrast-btn:hover { border-color: var(--primary); }
|
||||
.contrast-btn.active { border-color: var(--primary); background: var(--primary-dim); }
|
||||
.contrast-btn:last-child { border-right: none; }
|
||||
.contrast-btn:hover { background: var(--bg-hover); color: var(--text-1); }
|
||||
.contrast-btn.active { background: var(--primary); color: #fff; }
|
||||
.contrast-btn span { font-size: 18px; font-weight: 700; line-height: 1; }
|
||||
.contrast-lbl { font-size: 11px; color: var(--text-2); white-space: nowrap; }
|
||||
.contrast-btn.active span { color: #fff !important; }
|
||||
.contrast-lbl { font-size: 11px; white-space: nowrap; }
|
||||
.contrast-btn.active .contrast-lbl { color: #fff; }
|
||||
.line-preview {
|
||||
display: block; width: 36px; height: 0;
|
||||
border-top: 2px solid; border-radius: 1px;
|
||||
margin: 6px 0;
|
||||
margin: 4px 0;
|
||||
}
|
||||
.hour-preview {
|
||||
font-size: 14px; line-height: 1; color: var(--text-2);
|
||||
}
|
||||
.contrast-btn.active .hour-preview { color: #fff; }
|
||||
|
||||
/* ── Settings (legacy) ──────────────────────────────────── */
|
||||
.settings-section { margin-bottom: 28px; }
|
||||
@@ -861,6 +869,74 @@ a { color: var(--primary); text-decoration: none; }
|
||||
|
||||
.loading-view { display: flex; justify-content: center; align-items: center; height: 200px; }
|
||||
|
||||
/* ── Accounts Panel ──────────────────────────────────────── */
|
||||
.accounts-section { margin-bottom: 24px; }
|
||||
.accounts-section-heading {
|
||||
font-size: 11px; font-weight: 600; text-transform: uppercase;
|
||||
letter-spacing: .5px; color: var(--text-3);
|
||||
padding: 0 0 8px;
|
||||
border-bottom: 1px solid var(--border-light);
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.accounts-section-empty {
|
||||
font-size: 13px; color: var(--text-3); padding: 4px 0; display: block;
|
||||
}
|
||||
.accounts-row {
|
||||
display: flex; align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px solid var(--border-light);
|
||||
}
|
||||
.accounts-row:last-child { border-bottom: none; }
|
||||
.accounts-row-info { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
|
||||
.accounts-row-name { font-size: 13px; color: var(--text-1); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.accounts-row-sub { font-size: 11px; color: var(--text-3); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.accounts-row-actions { display: flex; gap: 6px; flex-shrink: 0; margin-left: 8px; }
|
||||
.accounts-local-dot {
|
||||
width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; display: inline-block;
|
||||
}
|
||||
|
||||
/* ── Month View (spanning bars) ─────────────────────────── */
|
||||
.month-body {
|
||||
display: flex; flex-direction: column; flex: 1; overflow: hidden;
|
||||
}
|
||||
.month-row {
|
||||
display: flex; flex: 1; position: relative; min-height: 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.month-row:last-child { border-bottom: none; }
|
||||
.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;
|
||||
}
|
||||
.month-span-event {
|
||||
position: absolute;
|
||||
height: 18px; line-height: 18px;
|
||||
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;
|
||||
}
|
||||
.month-span-event:hover { filter: brightness(1.15); }
|
||||
.month-span-event.past { opacity: .45; }
|
||||
.month-span-event.continues-left {
|
||||
border-top-left-radius: 0; border-bottom-left-radius: 0; padding-left: 3px;
|
||||
}
|
||||
.month-span-event.continues-right {
|
||||
border-top-right-radius: 0; border-bottom-right-radius: 0; padding-right: 3px;
|
||||
}
|
||||
|
||||
/* ── Responsive ─────────────────────────────────────────── */
|
||||
@media (max-width: 768px) {
|
||||
:root { --sidebar-w: 0px; }
|
||||
|
||||
@@ -391,7 +391,7 @@
|
||||
<div class="settings-page-body">
|
||||
<nav class="settings-nav">
|
||||
<button class="settings-nav-btn active" data-panel="general" data-i18n="settings_nav_appearance">Darstellung</button>
|
||||
<button class="settings-nav-btn" data-panel="google" data-i18n="settings_nav_google">Google Konten</button>
|
||||
<button class="settings-nav-btn" data-panel="accounts" data-i18n="settings_nav_accounts">Konten</button>
|
||||
<button class="settings-nav-btn hidden" data-panel="users" id="settings-nav-users" data-i18n="settings_nav_users">Benutzerverwaltung</button>
|
||||
</nav>
|
||||
|
||||
@@ -486,10 +486,29 @@
|
||||
<div id="hidden-cals-list"><span style="font-size:13px;color:var(--text-3)" data-i18n="settings_no_hidden_cals">Keine ausgeblendeten Kalender</span></div>
|
||||
</div>
|
||||
|
||||
<!-- Google Konten -->
|
||||
<div class="settings-panel" id="settings-panel-google">
|
||||
<h4 class="panel-title" data-i18n="settings_nav_google">Google Konten</h4>
|
||||
<div id="google-accounts-list"><span style="font-size:13px;color:var(--text-3)" data-i18n="settings_no_google">Keine Google-Konten verbunden</span></div>
|
||||
<!-- Konten (CalDAV, Lokal, iCal, Google) -->
|
||||
<div class="settings-panel" id="settings-panel-accounts">
|
||||
<h4 class="panel-title" data-i18n="settings_nav_accounts">Konten</h4>
|
||||
|
||||
<div class="accounts-section">
|
||||
<div class="accounts-section-heading" data-i18n="settings_accounts_caldav">CalDAV-Konten</div>
|
||||
<div id="accounts-caldav-list"><span class="accounts-section-empty" data-i18n="settings_no_caldav_accounts">Keine CalDAV-Konten</span></div>
|
||||
</div>
|
||||
|
||||
<div class="accounts-section">
|
||||
<div class="accounts-section-heading" data-i18n="settings_accounts_local">Lokale Kalender</div>
|
||||
<div id="accounts-local-list"><span class="accounts-section-empty" data-i18n="settings_no_local_cals">Keine lokalen Kalender</span></div>
|
||||
</div>
|
||||
|
||||
<div class="accounts-section">
|
||||
<div class="accounts-section-heading" data-i18n="settings_accounts_ical">iCal-Abonnements</div>
|
||||
<div id="accounts-ical-list"><span class="accounts-section-empty" data-i18n="settings_no_ical_subs">Keine Abonnements</span></div>
|
||||
</div>
|
||||
|
||||
<div class="accounts-section">
|
||||
<div class="accounts-section-heading" data-i18n="settings_accounts_google">Google-Konten</div>
|
||||
<div id="google-accounts-list"><span class="accounts-section-empty" data-i18n="settings_no_google_accounts">Keine Google-Konten</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Benutzerverwaltung -->
|
||||
|
||||
@@ -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