big update i guess

This commit is contained in:
2026-03-26 18:55:15 +01:00
parent 1bbabd6c4d
commit 3f3609c944
12 changed files with 511 additions and 104 deletions

View File

@@ -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"])

View File

@@ -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")

View File

@@ -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}")

View File

@@ -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,

View File

@@ -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;

View File

@@ -139,10 +139,10 @@
<button class="icon-btn mini-btn" id="mini-next">&#8250;</button>
</div>
<div class="mini-cal-grid">
<div class="mini-dow">So</div><div class="mini-dow">Mo</div>
<div class="mini-dow">Di</div><div class="mini-dow">Mi</div>
<div class="mini-dow">Do</div><div class="mini-dow">Fr</div>
<div class="mini-dow">Sa</div>
<div class="mini-dow">Mo</div><div class="mini-dow">Di</div>
<div class="mini-dow">Mi</div><div class="mini-dow">Do</div>
<div class="mini-dow">Fr</div><div class="mini-dow">Sa</div>
<div class="mini-dow">So</div>
</div>
<div class="mini-cal-days" id="mini-days"></div>
</div>
@@ -319,6 +319,13 @@
<option value="agenda">Termine</option>
</select>
</div>
<div class="form-group">
<label>Erster Wochentag</label>
<select id="cfg-week-start">
<option value="monday">Montag</option>
<option value="sunday">Sonntag</option>
</select>
</div>
<div class="form-group">
<label>Primärfarbe</label>
<div class="color-row">

View File

@@ -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()}`;

View File

@@ -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);
}

248
frontend/js/color-picker.js Normal file
View File

@@ -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 = `
<canvas class="gcp-sv" width="220" height="160"></canvas>
<div class="gcp-sv-cursor"></div>
<div class="gcp-hue-track">
<canvas class="gcp-hue" width="220" height="14"></canvas>
<div class="gcp-hue-thumb"></div>
</div>
<div class="gcp-bottom">
<div class="gcp-preview"></div>
<input class="gcp-hex" type="text" maxlength="7" spellcheck="false" />
</div>
<button class="gcp-select">Auswählen</button>
`;
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);
});
}

View File

@@ -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');

View File

@@ -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 => `<div class="month-dow">${d}</div>`).join('');
// Header: KW-Spalte + Wochentage
const headerHtml = `<div class="month-kw-header">KW</div>` +
DOW.map(d => `<div class="month-dow">${d}</div>`).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 += `<div class="month-kw-cell">${kw}</div>`;
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 `<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('');
const MAX_VISIBLE = 3;
const visible = cellEvs.slice(0, MAX_VISIBLE);
const hiddenCount = cellEvs.length - MAX_VISIBLE;
const moreHtml = hiddenCount > 0
? `<div class="month-more" data-date="${key}">+${hiddenCount} weitere</div>`
: '';
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('');
return `<div class="month-cell ${todayClass} ${otherClass}" data-date="${key}">
<div class="cell-day ${numClass}">${cell.getDate()}</div>
${evHtml}${moreHtml}
</div>`;
}).join('');
const moreHtml = hiddenCount > 0
? `<div class="month-more" data-date="${key}">+${hiddenCount} weitere</div>`
: '';
cellsHtml += `<div class="month-cell ${todayClass} ${otherClass}" data-date="${key}">
<div class="cell-day ${numClass}">${cell.getDate()}</div>
${evHtml}${moreHtml}
</div>`;
}
}
container.innerHTML = `<div class="month-view">
<div class="month-header">${headerHtml}</div>

View File

@@ -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
? `<div class="week-kw-badge">KW ${kwNum}</div>`
: '';
// ── 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 = `<div class="${viewClass}">
<div class="week-header-row">
<div class="week-time-gutter"></div>
<div class="week-time-gutter">${kwBadge}</div>
${headerCols}
</div>
<div class="week-allday-row">