diff --git a/backend/main.py b/backend/main.py index eb8e3c7..8b4487a 100644 --- a/backend/main.py +++ b/backend/main.py @@ -7,6 +7,7 @@ import uvicorn from fastapi import FastAPI, HTTPException from fastapi.responses import FileResponse from fastapi.staticfiles import StaticFiles +from sqlalchemy import text sys.path.insert(0, str(Path(__file__).parent)) @@ -18,6 +19,21 @@ logging.basicConfig(level=logging.INFO) # Create DB tables on startup Base.metadata.create_all(bind=engine) +# Run column migrations for new fields (safe: only adds if not exists) +def _migrate(): + with engine.connect() as conn: + # Add week_start_day to user_settings if not present + try: + conn.execute(text( + "ALTER TABLE user_settings ADD COLUMN week_start_day VARCHAR(10) DEFAULT 'monday'" + )) + conn.commit() + logging.info("Migration: added week_start_day column") + except Exception: + pass # Column already exists + +_migrate() + app = FastAPI(title="Calendarr", docs_url=None, redoc_url=None) app.include_router(auth_router.router, prefix="/api/auth", tags=["auth"]) diff --git a/backend/models.py b/backend/models.py index 9107fb8..d292454 100644 --- a/backend/models.py +++ b/backend/models.py @@ -60,6 +60,7 @@ class UserSettings(Base): id = Column(Integer, primary_key=True, index=True) user_id = Column(Integer, ForeignKey("users.id"), unique=True, nullable=False) default_view = Column(String(20), default="month") + week_start_day = Column(String(10), default="monday") primary_color = Column(String(7), default="#4285f4") accent_color = Column(String(7), default="#ea4335") today_color = Column(String(7), default="#4285f4") diff --git a/backend/routers/profile_router.py b/backend/routers/profile_router.py index 305818c..564410b 100644 --- a/backend/routers/profile_router.py +++ b/backend/routers/profile_router.py @@ -6,7 +6,7 @@ from typing import Optional import pyotp import qrcode from fastapi import APIRouter, Depends, File, HTTPException, UploadFile -from fastapi.responses import FileResponse +from fastapi.responses import FileResponse, Response from PIL import Image from pydantic import BaseModel from sqlalchemy.orm import Session @@ -80,10 +80,19 @@ async def upload_avatar( if len(data) > MAX_AVATAR_SIZE: raise HTTPException(400, "Datei zu groß (max. 5 MB)") - # Resize to 256x256 + # Resize to 512x512 square img = Image.open(io.BytesIO(data)) img = img.convert("RGB") - img.thumbnail((512, 512)) + # Use resize instead of thumbnail to ensure exact dimensions + # If already cropped (square), this just resizes; otherwise fit to 512x512 + w, h = img.size + if w != h: + # Crop to square center + side = min(w, h) + left = (w - side) // 2 + top = (h - side) // 2 + img = img.crop((left, top, left + side, top + side)) + img = img.resize((512, 512), Image.LANCZOS) filename = f"user_{current_user.id}.jpg" path = AVATAR_DIR / filename @@ -101,7 +110,11 @@ def get_avatar(current_user: models.User = Depends(get_current_user)): path = AVATAR_DIR / current_user.avatar_filename if not path.exists(): raise HTTPException(404, "Kein Profilbild") - return FileResponse(str(path), media_type="image/jpeg") + return FileResponse( + str(path), + media_type="image/jpeg", + headers={"Cache-Control": "no-cache, no-store, must-revalidate"}, + ) @router.get("/avatar/{user_id}") diff --git a/backend/routers/settings_router.py b/backend/routers/settings_router.py index 6be69f5..35f506a 100644 --- a/backend/routers/settings_router.py +++ b/backend/routers/settings_router.py @@ -13,6 +13,7 @@ router = APIRouter() class SettingsUpdate(BaseModel): default_view: Optional[str] = None + week_start_day: Optional[str] = None primary_color: Optional[str] = None accent_color: Optional[str] = None today_color: Optional[str] = None @@ -22,6 +23,7 @@ class SettingsUpdate(BaseModel): def _settings_dict(s: models.UserSettings) -> dict: return { "default_view": s.default_view, + "week_start_day": s.week_start_day or "monday", "primary_color": s.primary_color, "accent_color": s.accent_color, "today_color": s.today_color, diff --git a/frontend/css/app.css b/frontend/css/app.css index c2e9223..59ea871 100644 --- a/frontend/css/app.css +++ b/frontend/css/app.css @@ -170,7 +170,16 @@ a { color: var(--primary); text-decoration: none; } cursor: pointer; color: var(--text-1); } .toggle-label input[type=checkbox] { width: 16px; height: 16px; accent-color: var(--primary); } -.color-input { width: 44px; height: 36px; padding: 2px; border-radius: var(--radius-sm); border: 1px solid var(--border); background: var(--bg-app); cursor: pointer; } +.color-input { + width: 52px; height: 40px; padding: 3px; + border-radius: var(--radius-sm); + border: 1px solid var(--border); + background: var(--bg-surface); + cursor: pointer; + outline: none; +} +.color-input::-webkit-color-swatch-wrapper { padding: 2px; } +.color-input::-webkit-color-swatch { border-radius: 3px; border: none; } .color-row { display: flex; align-items: center; gap: 10px; } .color-label { font-size: 12px; color: var(--text-2); font-family: monospace; } @@ -219,6 +228,7 @@ a { color: var(--primary); text-decoration: none; } display: flex; align-items: center; justify-content: center; font-weight: 600; font-size: 14px; color: #fff; cursor: pointer; user-select: none; flex-shrink: 0; + overflow: hidden; position: relative; } .user-dropdown { position: absolute; top: 42px; right: 0; @@ -341,19 +351,34 @@ a { color: var(--primary); text-decoration: none; } /* ── Month View ─────────────────────────────────────────── */ .month-view { display: flex; flex-direction: column; height: 100%; } .month-header { - display: grid; grid-template-columns: repeat(7, 1fr); + display: grid; grid-template-columns: 32px repeat(7, 1fr); border-bottom: 1px solid var(--border); flex-shrink: 0; } +.month-kw-header { + padding: 8px 0; text-align: center; + font-size: 10px; font-weight: 600; text-transform: uppercase; + letter-spacing: .3px; color: var(--text-3); + border-right: 1px solid var(--border-light); +} .month-dow { padding: 8px 0; text-align: center; font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: .5px; color: var(--text-2); } .month-grid { - display: grid; grid-template-columns: repeat(7, 1fr); + display: grid; grid-template-columns: 32px 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); + display: flex; align-items: flex-start; justify-content: center; + padding-top: 6px; + font-size: 10px; color: var(--text-3); font-weight: 500; + cursor: default; user-select: none; + min-height: 0; +} .month-cell { border-right: 1px solid var(--border); border-bottom: 1px solid var(--border); @@ -363,7 +388,8 @@ a { color: var(--primary); text-decoration: none; } transition: background var(--transition); min-height: 0; } -.month-cell:nth-child(7n) { border-right: none; } +/* every 8th child is the last day column (after KW cell) */ +.month-cell:nth-child(8n) { 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); } @@ -399,7 +425,18 @@ a { color: var(--primary); text-decoration: none; } flex-shrink: 0; background: var(--bg-app); position: sticky; top: 0; z-index: 10; } -.week-time-gutter { width: 56px; flex-shrink: 0; } +/* KW badge in week view header gutter */ +.week-kw-badge { + font-size: 10px; font-weight: 600; color: var(--text-3); + display: flex; align-items: flex-end; justify-content: center; + padding-bottom: 6px; + text-transform: uppercase; letter-spacing: .3px; + user-select: none; +} +.week-time-gutter { + width: 56px; flex-shrink: 0; + display: flex; flex-direction: column; align-items: stretch; +} .week-day-header { flex: 1; padding: 8px 4px; text-align: center; border-left: 1px solid var(--border); cursor: pointer; diff --git a/frontend/index.html b/frontend/index.html index 77fa55e..7d5845d 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -139,10 +139,10 @@
-
So
Mo
-
Di
Mi
-
Do
Fr
-
Sa
+
Mo
Di
+
Mi
Do
+
Fr
Sa
+
So
@@ -319,6 +319,13 @@ +
+ + +
diff --git a/frontend/js/app.js b/frontend/js/app.js index d1b7a78..718a0ca 100644 --- a/frontend/js/app.js +++ b/frontend/js/app.js @@ -164,12 +164,12 @@ function bindLoginForm() { function loadAvatarImage(avatarEl, username) { const img = new Image(); img.onload = () => { - avatarEl.textContent = ''; - img.style.cssText = 'width:100%;height:100%;object-fit:cover;border-radius:50%'; + avatarEl.innerHTML = ''; + img.style.cssText = 'width:100%;height:100%;object-fit:cover;position:absolute;inset:0'; avatarEl.appendChild(img); }; img.onerror = () => { - // Fallback to letter + avatarEl.innerHTML = ''; avatarEl.textContent = (username || '?')[0].toUpperCase(); }; img.src = `/api/profile/avatar?t=${Date.now()}`; diff --git a/frontend/js/calendar.js b/frontend/js/calendar.js index e70f688..159b1bb 100644 --- a/frontend/js/calendar.js +++ b/frontend/js/calendar.js @@ -1,9 +1,12 @@ import { api } from './api.js'; -import { applyTheme, isToday, isSameDay, toLocalDatetimeInput, toDateInput, dateKey } from './utils.js'; +import { applyTheme, isToday, isSameDay, toLocalDatetimeInput, toDateInput, dateKey, dayOfWeek, weekStart } from './utils.js'; import { renderMonth } from './views/month.js'; import { renderWeek } from './views/week.js'; import { renderAgenda } from './views/agenda.js'; +// week start day global (loaded from settings) +let weekStartDay = 'monday'; + const MONTHS = ['Januar','Februar','März','April','Mai','Juni', 'Juli','August','September','Oktober','November','Dezember']; @@ -29,6 +32,7 @@ export async function initCalendar() { state.accounts = accounts; state.currentView = settings.default_view || 'month'; state.dimPast = settings.dim_past_events; + weekStartDay = settings.week_start_day || 'monday'; applyTheme(settings); updateViewButtons(); @@ -65,13 +69,11 @@ function getViewRange() { if (state.currentView === 'month') { start = new Date(d.getFullYear(), d.getMonth(), 1); - start.setDate(start.getDate() - start.getDay() - 1); + start.setDate(start.getDate() - dayOfWeek(start, weekStartDay) - 1); end = new Date(d.getFullYear(), d.getMonth() + 1, 0); - end.setDate(end.getDate() + (6 - end.getDay()) + 1); + end.setDate(end.getDate() + (6 - dayOfWeek(end, weekStartDay)) + 1); } else if (state.currentView === 'week') { - start = new Date(d); - start.setDate(d.getDate() - d.getDay()); - start.setHours(0, 0, 0, 0); + start = weekStart(d, weekStartDay); end = new Date(start); end.setDate(start.getDate() + 7); } else if (state.currentView === 'day') { @@ -96,7 +98,8 @@ function renderView() { if (state.currentView === 'month') { renderMonth(container, state.currentDate, evs, date => { state.currentDate = date; state.currentView = 'day'; updateViewButtons(); fetchAndRender(); }, - showEventPopup + showEventPopup, + weekStartDay ); } else if (state.currentView === 'week') { renderWeek(container, state.currentDate, evs, @@ -104,13 +107,16 @@ function renderView() { if (switchDay) { state.currentDate = date; state.currentView = 'day'; updateViewButtons(); fetchAndRender(); } else openNewEventModal(date); }, - showEventPopup + showEventPopup, + false, + weekStartDay ); } else if (state.currentView === 'day') { renderWeek(container, state.currentDate, evs, (date, switchDay) => { if (!switchDay) openNewEventModal(date); }, showEventPopup, - true + true, + weekStartDay ); } else { renderAgenda(container, state.currentDate, evs, showEventPopup); @@ -133,14 +139,13 @@ function updateTitle() { if (state.currentView === 'month') { title = `${MONTHS[d.getMonth()]} ${d.getFullYear()}`; } else if (state.currentView === 'week') { - const sun = new Date(d); - sun.setDate(d.getDate() - d.getDay()); - const sat = new Date(sun); - sat.setDate(sun.getDate() + 6); - const sameMonth = sun.getMonth() === sat.getMonth(); + const mon = weekStart(d, weekStartDay); + const sun = new Date(mon); + sun.setDate(mon.getDate() + 6); + const sameMonth = mon.getMonth() === sun.getMonth(); title = sameMonth - ? `${sun.getDate()}. – ${sat.getDate()}. ${MONTHS[sat.getMonth()]} ${sat.getFullYear()}` - : `${sun.getDate()}. ${MONTHS[sun.getMonth()]} – ${sat.getDate()}. ${MONTHS[sat.getMonth()]} ${sat.getFullYear()}`; + ? `${mon.getDate()}. – ${sun.getDate()}. ${MONTHS[sun.getMonth()]} ${sun.getFullYear()}` + : `${mon.getDate()}. ${MONTHS[mon.getMonth()]} – ${sun.getDate()}. ${MONTHS[sun.getMonth()]} ${sun.getFullYear()}`; } else if (state.currentView === 'day') { title = `${d.getDate()}. ${MONTHS[d.getMonth()]} ${d.getFullYear()}`; } else { @@ -164,9 +169,15 @@ function renderMiniCal() { `${MONTHS[miniD.getMonth()]} ${miniD.getFullYear()}`; const firstDay = new Date(miniD.getFullYear(), miniD.getMonth(), 1); - const lastDay = new Date(miniD.getFullYear(), miniD.getMonth() + 1, 0); const gridStart = new Date(firstDay); - gridStart.setDate(gridStart.getDate() - firstDay.getDay()); + gridStart.setDate(gridStart.getDate() - dayOfWeek(firstDay, weekStartDay)); + + // Update mini-cal DOW headers based on weekStartDay + const miniDowEls = document.querySelectorAll('.mini-cal-grid .mini-dow'); + const DOW_MONDAY = ['Mo','Di','Mi','Do','Fr','Sa','So']; + const DOW_SUNDAY = ['So','Mo','Di','Mi','Do','Fr','Sa']; + const DOW_LABELS = weekStartDay === 'sunday' ? DOW_SUNDAY : DOW_MONDAY; + miniDowEls.forEach((el, i) => { el.textContent = DOW_LABELS[i]; }); // Build event date set const eventDates = new Set(state.events.map(ev => { @@ -618,10 +629,11 @@ function bindAccountModal() { // ── Settings Modal ──────────────────────────────────────── function openSettingsModal() { const s = state.settings; - document.getElementById('cfg-default-view').value = s.default_view || 'month'; - document.getElementById('cfg-primary-color').value = s.primary_color || '#4285f4'; - document.getElementById('cfg-accent-color').value = s.accent_color || '#ea4335'; - document.getElementById('cfg-today-color').value = s.today_color || '#4285f4'; + document.getElementById('cfg-default-view').value = s.default_view || 'month'; + document.getElementById('cfg-week-start').value = s.week_start_day || 'monday'; + document.getElementById('cfg-primary-color').value = s.primary_color || '#4285f4'; + document.getElementById('cfg-accent-color').value = s.accent_color || '#ea4335'; + document.getElementById('cfg-today-color').value = s.today_color || '#4285f4'; document.getElementById('cfg-dim-past').checked = !!s.dim_past_events; document.getElementById('cfg-primary-label').textContent = s.primary_color || '#4285f4'; @@ -701,20 +713,23 @@ function bindSettingsModal() { document.getElementById('settings-save').onclick = async () => { const settings = { - default_view: document.getElementById('cfg-default-view').value, - primary_color: document.getElementById('cfg-primary-color').value, - accent_color: document.getElementById('cfg-accent-color').value, - today_color: document.getElementById('cfg-today-color').value, + default_view: document.getElementById('cfg-default-view').value, + week_start_day: document.getElementById('cfg-week-start').value, + primary_color: document.getElementById('cfg-primary-color').value, + accent_color: document.getElementById('cfg-accent-color').value, + today_color: document.getElementById('cfg-today-color').value, dim_past_events: document.getElementById('cfg-dim-past').checked, }; try { await api.put('/settings/', settings); state.settings = { ...state.settings, ...settings }; state.dimPast = settings.dim_past_events; + weekStartDay = settings.week_start_day; applyTheme(settings); showToast('Einstellungen gespeichert'); closeModal('modal-settings'); - renderView(); + renderMiniCal(); + fetchAndRender(); } catch (e) { showToast(e.message, true); } }; } @@ -885,12 +900,27 @@ function updateTopbarAvatar(hasAvatar) { const avatar = document.getElementById('user-avatar'); if (hasAvatar) { const img = new Image(); - img.onload = () => { avatar.textContent = ''; img.style.cssText = 'width:100%;height:100%;object-fit:cover;border-radius:50%'; avatar.appendChild(img); }; - img.onerror = () => { const u = JSON.parse(localStorage.getItem('user')||'{}'); avatar.textContent = (u.username||'?')[0].toUpperCase(); }; + img.onload = () => { + avatar.innerHTML = ''; + img.style.cssText = 'width:100%;height:100%;object-fit:cover;position:absolute;inset:0'; + avatar.appendChild(img); + }; + img.onerror = () => { + avatar.innerHTML = ''; + const u = JSON.parse(localStorage.getItem('user')||'{}'); + avatar.textContent = (u.username||'?')[0].toUpperCase(); + }; img.src = `/api/profile/avatar?t=${Date.now()}`; + // Update localStorage so avatar persists across reloads + const u = JSON.parse(localStorage.getItem('user')||'{}'); + u.has_avatar = true; + localStorage.setItem('user', JSON.stringify(u)); } else { + avatar.innerHTML = ''; const user = JSON.parse(localStorage.getItem('user') || '{}'); avatar.textContent = (user.username || '?')[0].toUpperCase(); + user.has_avatar = false; + localStorage.setItem('user', JSON.stringify(user)); } } @@ -969,25 +999,34 @@ function openCropModal(file) { const reader = new FileReader(); reader.onload = (e) => { const cropImg = document.getElementById('crop-image'); - cropImg.src = e.target.result; - - openModal('modal-crop'); // Destroy previous cropper if any if (activeCropper) { activeCropper.destroy(); activeCropper = null; } - // Wait for image to load then init cropper - cropImg.onload = () => { - activeCropper = new Cropper(cropImg, { - aspectRatio: 1, - viewMode: 1, - dragMode: 'move', - autoCropArea: 1, - cropBoxResizable: true, - cropBoxMovable: true, - background: false, - }); - }; + // Reset image src first to force reload + cropImg.removeAttribute('src'); + + openModal('modal-crop'); + + // Use requestAnimationFrame to ensure modal is visible before initializing cropper + requestAnimationFrame(() => { + cropImg.onload = () => { + // Small delay to ensure the image is fully rendered in the DOM + setTimeout(() => { + if (activeCropper) { activeCropper.destroy(); activeCropper = null; } + activeCropper = new Cropper(cropImg, { + aspectRatio: 1, + viewMode: 1, + dragMode: 'move', + autoCropArea: 1, + cropBoxResizable: true, + cropBoxMovable: true, + background: false, + }); + }, 100); + }; + cropImg.src = e.target.result; + }); }; reader.readAsDataURL(file); } diff --git a/frontend/js/color-picker.js b/frontend/js/color-picker.js new file mode 100644 index 0000000..eb07c03 --- /dev/null +++ b/frontend/js/color-picker.js @@ -0,0 +1,248 @@ +/* ── Gradient Color Picker (Dark Mode) ───────────────────── + Usage: const hex = await openColorPicker(anchorEl, '#4285f4'); + Returns hex string or null if cancelled. +────────────────────────────────────────────────────────── */ + +// ── HSV ↔ RGB helpers ───────────────────────────────────── +function hsvToRgb(h, s, v) { + h = h / 360 * 6; + const i = Math.floor(h), f = h - i, p = v * (1 - s), q = v * (1 - f * s), t = v * (1 - (1 - f) * s); + let r, g, b; + switch (i % 6) { + case 0: r = v; g = t; b = p; break; + case 1: r = q; g = v; b = p; break; + case 2: r = p; g = v; b = t; break; + case 3: r = p; g = q; b = v; break; + case 4: r = t; g = p; b = v; break; + case 5: r = v; g = p; b = q; break; + } + return [Math.round(r * 255), Math.round(g * 255), Math.round(b * 255)]; +} + +function rgbToHsv(r, g, b) { + r /= 255; g /= 255; b /= 255; + const max = Math.max(r, g, b), min = Math.min(r, g, b), d = max - min; + let h = 0, s = max === 0 ? 0 : d / max, v = max; + if (d !== 0) { + switch (max) { + case r: h = ((g - b) / d + (g < b ? 6 : 0)); break; + case g: h = ((b - r) / d + 2); break; + case b: h = ((r - g) / d + 4); break; + } + h *= 60; + } + return [h, s, v]; +} + +function hexToRgb(hex) { + hex = hex.replace('#', ''); + if (hex.length === 3) hex = hex[0]+hex[0]+hex[1]+hex[1]+hex[2]+hex[2]; + return [parseInt(hex.slice(0,2),16), parseInt(hex.slice(2,4),16), parseInt(hex.slice(4,6),16)]; +} + +function rgbToHex(r, g, b) { + return '#' + [r, g, b].map(c => c.toString(16).padStart(2, '0')).join(''); +} + +// ── Active picker tracking ──────────────────────────────── +let activePicker = null; +let activeOutsideHandler = null; + +function closeActivePicker() { + if (activePicker) { + activePicker.remove(); + activePicker = null; + } + if (activeOutsideHandler) { + document.removeEventListener('mousedown', activeOutsideHandler); + activeOutsideHandler = null; + } +} + +// ── Main export ─────────────────────────────────────────── +export function openColorPicker(anchorEl, currentColor = '#4285f4') { + closeActivePicker(); + + return new Promise((resolve) => { + let [h, s, v] = rgbToHsv(...hexToRgb(currentColor)); + + // ── Build DOM ───────────────────────────────────────── + const picker = document.createElement('div'); + picker.className = 'gcp'; + picker.innerHTML = ` + +
+
+ +
+
+
+
+ +
+ + `; + + const svCanvas = picker.querySelector('.gcp-sv'); + const svCtx = svCanvas.getContext('2d', { willReadFrequently: true }); + const svCursor = picker.querySelector('.gcp-sv-cursor'); + const hueCanvas = picker.querySelector('.gcp-hue'); + const hueCtx = hueCanvas.getContext('2d'); + const hueThumb = picker.querySelector('.gcp-hue-thumb'); + const preview = picker.querySelector('.gcp-preview'); + const hexInput = picker.querySelector('.gcp-hex'); + const selectBtn = picker.querySelector('.gcp-select'); + + // ── Draw functions ──────────────────────────────────── + function drawSV() { + const w = svCanvas.width, hh = svCanvas.height; + // Base hue color + const [r, g, b] = hsvToRgb(h, 1, 1); + // Horizontal: white → hue color (saturation) + const gradH = svCtx.createLinearGradient(0, 0, w, 0); + gradH.addColorStop(0, '#fff'); + gradH.addColorStop(1, `rgb(${r},${g},${b})`); + svCtx.fillStyle = gradH; + svCtx.fillRect(0, 0, w, hh); + // Vertical: transparent → black (value) + const gradV = svCtx.createLinearGradient(0, 0, 0, hh); + gradV.addColorStop(0, 'rgba(0,0,0,0)'); + gradV.addColorStop(1, '#000'); + svCtx.fillStyle = gradV; + svCtx.fillRect(0, 0, w, hh); + } + + function drawHue() { + const w = hueCanvas.width, hh = hueCanvas.height; + const grad = hueCtx.createLinearGradient(0, 0, w, 0); + for (let i = 0; i <= 6; i++) { + const [r, g, b] = hsvToRgb(i * 60, 1, 1); + grad.addColorStop(i / 6, `rgb(${r},${g},${b})`); + } + hueCtx.fillStyle = grad; + hueCtx.fillRect(0, 0, w, hh); + } + + function updateUI() { + const [r, g, b] = hsvToRgb(h, s, v); + const hex = rgbToHex(r, g, b); + // SV cursor position + svCursor.style.left = (s * svCanvas.width) + 'px'; + svCursor.style.top = ((1 - v) * svCanvas.height) + 'px'; + // Hue thumb position + hueThumb.style.left = (h / 360 * hueCanvas.width) + 'px'; + // Preview + hex + preview.style.background = hex; + hexInput.value = hex.toUpperCase(); + } + + // ── SV interaction ──────────────────────────────────── + function handleSV(e) { + const rect = svCanvas.getBoundingClientRect(); + const x = Math.max(0, Math.min(e.clientX - rect.left, rect.width)); + const y = Math.max(0, Math.min(e.clientY - rect.top, rect.height)); + s = x / rect.width; + v = 1 - y / rect.height; + updateUI(); + } + + svCanvas.addEventListener('mousedown', (e) => { + e.preventDefault(); + handleSV(e); + const move = (ev) => handleSV(ev); + const up = () => { document.removeEventListener('mousemove', move); document.removeEventListener('mouseup', up); }; + document.addEventListener('mousemove', move); + document.addEventListener('mouseup', up); + }); + + // Touch support for SV + svCanvas.addEventListener('touchstart', (e) => { + e.preventDefault(); + const touch = (ev) => { const t = ev.touches[0]; handleSV(t); }; + touch(e); + const end = () => { document.removeEventListener('touchmove', touch); document.removeEventListener('touchend', end); }; + document.addEventListener('touchmove', touch); + document.addEventListener('touchend', end); + }); + + // ── Hue interaction ─────────────────────────────────── + function handleHue(e) { + const rect = hueCanvas.getBoundingClientRect(); + const x = Math.max(0, Math.min(e.clientX - rect.left, rect.width)); + h = (x / rect.width) * 360; + drawSV(); + updateUI(); + } + + const hueTrack = picker.querySelector('.gcp-hue-track'); + hueTrack.addEventListener('mousedown', (e) => { + e.preventDefault(); + handleHue(e); + const move = (ev) => handleHue(ev); + const up = () => { document.removeEventListener('mousemove', move); document.removeEventListener('mouseup', up); }; + document.addEventListener('mousemove', move); + document.addEventListener('mouseup', up); + }); + + hueTrack.addEventListener('touchstart', (e) => { + e.preventDefault(); + const touch = (ev) => { const t = ev.touches[0]; handleHue(t); }; + touch(e); + const end = () => { document.removeEventListener('touchmove', touch); document.removeEventListener('touchend', end); }; + document.addEventListener('touchmove', touch); + document.addEventListener('touchend', end); + }); + + // ── Hex input ───────────────────────────────────────── + hexInput.addEventListener('change', () => { + let val = hexInput.value.trim(); + if (!val.startsWith('#')) val = '#' + val; + if (/^#[0-9a-fA-F]{6}$/.test(val)) { + [h, s, v] = rgbToHsv(...hexToRgb(val)); + drawSV(); + updateUI(); + } + }); + + // ── Select button ───────────────────────────────────── + selectBtn.addEventListener('click', (e) => { + e.stopPropagation(); + const [r, g, b] = hsvToRgb(h, s, v); + closeActivePicker(); + resolve(rgbToHex(r, g, b)); + }); + + // ── Position picker ─────────────────────────────────── + document.body.appendChild(picker); + activePicker = picker; + + const anchorRect = anchorEl.getBoundingClientRect(); + let top = anchorRect.bottom + 6; + let left = anchorRect.left; + + // Keep in viewport + const pRect = picker.getBoundingClientRect(); + if (left + pRect.width > window.innerWidth - 8) left = window.innerWidth - pRect.width - 8; + if (left < 8) left = 8; + if (top + pRect.height > window.innerHeight - 8) top = anchorRect.top - pRect.height - 6; + + picker.style.top = top + 'px'; + picker.style.left = left + 'px'; + + // ── Initial draw ────────────────────────────────────── + drawSV(); + drawHue(); + updateUI(); + + // ── Close on outside click ──────────────────────────── + setTimeout(() => { + activeOutsideHandler = (e) => { + if (!picker.contains(e.target) && e.target !== anchorEl && !anchorEl.contains(e.target)) { + closeActivePicker(); + resolve(null); + } + }; + document.addEventListener('mousedown', activeOutsideHandler); + }, 0); + }); +} diff --git a/frontend/js/utils.js b/frontend/js/utils.js index aafc823..5306d03 100644 --- a/frontend/js/utils.js +++ b/frontend/js/utils.js @@ -37,6 +37,32 @@ export function toDateInput(d) { return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`; } +// Monday-first: returns 0=Mo, 1=Di, ..., 6=So +export function dayOfWeek(d, weekStartDay = 'monday') { + if (weekStartDay === 'sunday') { + return d.getDay(); // 0=So, 1=Mo, ..., 6=Sa + } + return (d.getDay() + 6) % 7; // 0=Mo, 1=Di, ..., 6=So +} + +// Returns the start-of-week date for d +export function weekStart(d, weekStartDay = 'monday') { + const m = new Date(d); + m.setDate(m.getDate() - dayOfWeek(m, weekStartDay)); + m.setHours(0, 0, 0, 0); + return m; +} + +// Returns the ISO week number (Monday-based, ISO 8601) +export function getISOWeekNumber(d) { + const date = new Date(Date.UTC(d.getFullYear(), d.getMonth(), d.getDate())); + // ISO week: weeks start on Monday, week 1 contains the first Thursday + const day = date.getUTCDay() || 7; // make Sunday = 7 + date.setUTCDate(date.getUTCDate() + 4 - day); + const yearStart = new Date(Date.UTC(date.getUTCFullYear(), 0, 1)); + return Math.ceil((((date - yearStart) / 86400000) + 1) / 7); +} + export function applyTheme(settings) { const root = document.documentElement; root.style.setProperty('--primary', settings.primary_color || '#4285f4'); diff --git a/frontend/js/views/month.js b/frontend/js/views/month.js index 6ff1450..13de6f3 100644 --- a/frontend/js/views/month.js +++ b/frontend/js/views/month.js @@ -1,17 +1,20 @@ -import { formatDate, isSameDay, isToday, isPast } from '../utils.js'; +import { formatDate, isSameDay, isToday, isPast, dayOfWeek, getISOWeekNumber } from '../utils.js'; -const DOW = ['So', 'Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa']; +const DOW_MONDAY = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So']; +const DOW_SUNDAY = ['So', 'Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa']; -export function renderMonth(container, currentDate, events, onDayClick, onEventClick) { +export function renderMonth(container, currentDate, events, onDayClick, onEventClick, weekStartDay = 'monday') { const year = currentDate.getFullYear(); const month = currentDate.getMonth(); + const DOW = weekStartDay === 'sunday' ? DOW_SUNDAY : DOW_MONDAY; const firstDay = new Date(year, month, 1); const lastDay = new Date(year, month + 1, 0); - // Start grid on Sunday of the week containing the 1st + // Start grid on the correct weekday const gridStart = new Date(firstDay); - gridStart.setDate(gridStart.getDate() - firstDay.getDay()); + const offset = dayOfWeek(firstDay, weekStartDay); + gridStart.setDate(gridStart.getDate() - offset); const cells = []; const d = new Date(gridStart); @@ -39,45 +42,55 @@ export function renderMonth(container, currentDate, events, onDayClick, onEventC } }); - // Header - const headerHtml = DOW.map(d => `
${d}
`).join(''); + // Header: KW-Spalte + Wochentage + const headerHtml = `
KW
` + + DOW.map(d => `
${d}
`).join(''); - // Cells - const cellsHtml = cells.map(cell => { - 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); - }); + // Build rows (6 weeks × 7 days) + let cellsHtml = ''; + 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 += `
${kw}
`; - const isOther = cell.getMonth() !== month; - const todayClass = isToday(cell) ? 'today' : ''; - const otherClass = isOther ? 'other-month' : ''; - const numClass = isToday(cell) ? 'today' : ''; + 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); + }); - const MAX_VISIBLE = 3; - const visible = cellEvs.slice(0, MAX_VISIBLE); - const hiddenCount = cellEvs.length - MAX_VISIBLE; + const isOther = cell.getMonth() !== month; + const todayClass = isToday(cell) ? 'today' : ''; + const otherClass = isOther ? 'other-month' : ''; + const numClass = isToday(cell) ? 'today' : ''; - 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 `
${escHtml(title)}
`; - }).join(''); + const MAX_VISIBLE = 3; + const visible = cellEvs.slice(0, MAX_VISIBLE); + const hiddenCount = cellEvs.length - MAX_VISIBLE; - const moreHtml = hiddenCount > 0 - ? `
+${hiddenCount} weitere
` - : ''; + 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 `
${escHtml(title)}
`; + }).join(''); - return `
-
${cell.getDate()}
- ${evHtml}${moreHtml} -
`; - }).join(''); + const moreHtml = hiddenCount > 0 + ? `
+${hiddenCount} weitere
` + : ''; + + cellsHtml += `
+
${cell.getDate()}
+ ${evHtml}${moreHtml} +
`; + } + } container.innerHTML = `
${headerHtml}
diff --git a/frontend/js/views/week.js b/frontend/js/views/week.js index 27018d8..301603c 100644 --- a/frontend/js/views/week.js +++ b/frontend/js/views/week.js @@ -1,17 +1,16 @@ -import { isToday, isPast } from '../utils.js'; +import { isToday, isPast, dayOfWeek, weekStart, getISOWeekNumber } from '../utils.js'; const DOW_SHORT = ['So', 'Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa']; -export function renderWeek(container, currentDate, events, onSlotClick, onEventClick, isSingleDay = false) { +export function renderWeek(container, currentDate, events, onSlotClick, onEventClick, isSingleDay = false, weekStartDay = 'monday') { // Build the days array (7 days for week, 1 for day) const days = []; if (isSingleDay) { days.push(new Date(currentDate)); } else { - const sunday = new Date(currentDate); - sunday.setDate(sunday.getDate() - sunday.getDay()); + const monday = weekStart(currentDate, weekStartDay); for (let i = 0; i < 7; i++) { - const d = new Date(sunday); + const d = new Date(monday); d.setDate(d.getDate() + i); days.push(d); } @@ -21,6 +20,12 @@ export function renderWeek(container, currentDate, events, onSlotClick, onEventC const allDayEvs = events.filter(ev => ev.allDay); const timedEvs = events.filter(ev => !ev.allDay); + // ── KW Badge ────────────────────────────────────────── + const kwNum = getISOWeekNumber(days[0]); + const kwBadge = !isSingleDay + ? `
KW ${kwNum}
` + : ''; + // ── Header ──────────────────────────────────────────── const headerCols = days.map(day => { const todayCls = isToday(day) ? 'today' : ''; @@ -98,7 +103,7 @@ export function renderWeek(container, currentDate, events, onSlotClick, onEventC container.innerHTML = `
-
+
${kwBadge}
${headerCols}