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 import FastAPI, HTTPException
from fastapi.responses import FileResponse from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from sqlalchemy import text
sys.path.insert(0, str(Path(__file__).parent)) sys.path.insert(0, str(Path(__file__).parent))
@@ -18,6 +19,21 @@ logging.basicConfig(level=logging.INFO)
# Create DB tables on startup # Create DB tables on startup
Base.metadata.create_all(bind=engine) 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 = FastAPI(title="Calendarr", docs_url=None, redoc_url=None)
app.include_router(auth_router.router, prefix="/api/auth", tags=["auth"]) 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) id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id"), unique=True, nullable=False) user_id = Column(Integer, ForeignKey("users.id"), unique=True, nullable=False)
default_view = Column(String(20), default="month") default_view = Column(String(20), default="month")
week_start_day = Column(String(10), default="monday")
primary_color = Column(String(7), default="#4285f4") primary_color = Column(String(7), default="#4285f4")
accent_color = Column(String(7), default="#ea4335") accent_color = Column(String(7), default="#ea4335")
today_color = Column(String(7), default="#4285f4") today_color = Column(String(7), default="#4285f4")

View File

@@ -6,7 +6,7 @@ from typing import Optional
import pyotp import pyotp
import qrcode import qrcode
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
from fastapi.responses import FileResponse from fastapi.responses import FileResponse, Response
from PIL import Image from PIL import Image
from pydantic import BaseModel from pydantic import BaseModel
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -80,10 +80,19 @@ async def upload_avatar(
if len(data) > MAX_AVATAR_SIZE: if len(data) > MAX_AVATAR_SIZE:
raise HTTPException(400, "Datei zu groß (max. 5 MB)") raise HTTPException(400, "Datei zu groß (max. 5 MB)")
# Resize to 256x256 # Resize to 512x512 square
img = Image.open(io.BytesIO(data)) img = Image.open(io.BytesIO(data))
img = img.convert("RGB") 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" filename = f"user_{current_user.id}.jpg"
path = AVATAR_DIR / filename 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 path = AVATAR_DIR / current_user.avatar_filename
if not path.exists(): if not path.exists():
raise HTTPException(404, "Kein Profilbild") 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}") @router.get("/avatar/{user_id}")

View File

@@ -13,6 +13,7 @@ router = APIRouter()
class SettingsUpdate(BaseModel): class SettingsUpdate(BaseModel):
default_view: Optional[str] = None default_view: Optional[str] = None
week_start_day: Optional[str] = None
primary_color: Optional[str] = None primary_color: Optional[str] = None
accent_color: Optional[str] = None accent_color: Optional[str] = None
today_color: Optional[str] = None today_color: Optional[str] = None
@@ -22,6 +23,7 @@ class SettingsUpdate(BaseModel):
def _settings_dict(s: models.UserSettings) -> dict: def _settings_dict(s: models.UserSettings) -> dict:
return { return {
"default_view": s.default_view, "default_view": s.default_view,
"week_start_day": s.week_start_day or "monday",
"primary_color": s.primary_color, "primary_color": s.primary_color,
"accent_color": s.accent_color, "accent_color": s.accent_color,
"today_color": s.today_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); cursor: pointer; color: var(--text-1);
} }
.toggle-label input[type=checkbox] { width: 16px; height: 16px; accent-color: var(--primary); } .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-row { display: flex; align-items: center; gap: 10px; }
.color-label { font-size: 12px; color: var(--text-2); font-family: monospace; } .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; display: flex; align-items: center; justify-content: center;
font-weight: 600; font-size: 14px; color: #fff; font-weight: 600; font-size: 14px; color: #fff;
cursor: pointer; user-select: none; flex-shrink: 0; cursor: pointer; user-select: none; flex-shrink: 0;
overflow: hidden; position: relative;
} }
.user-dropdown { .user-dropdown {
position: absolute; top: 42px; right: 0; position: absolute; top: 42px; right: 0;
@@ -341,19 +351,34 @@ a { color: var(--primary); text-decoration: none; }
/* ── Month View ─────────────────────────────────────────── */ /* ── Month View ─────────────────────────────────────────── */
.month-view { display: flex; flex-direction: column; height: 100%; } .month-view { display: flex; flex-direction: column; height: 100%; }
.month-header { .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; 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 { .month-dow {
padding: 8px 0; text-align: center; padding: 8px 0; text-align: center;
font-size: 11px; font-weight: 600; text-transform: uppercase; font-size: 11px; font-weight: 600; text-transform: uppercase;
letter-spacing: .5px; color: var(--text-2); letter-spacing: .5px; color: var(--text-2);
} }
.month-grid { .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); grid-template-rows: repeat(6, 1fr);
flex: 1; overflow: hidden; 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 { .month-cell {
border-right: 1px solid var(--border); border-right: 1px solid var(--border);
border-bottom: 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); transition: background var(--transition);
min-height: 0; 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:hover { background: var(--bg-hover); }
.month-cell.today { background: rgba(66,133,244,.08); } .month-cell.today { background: rgba(66,133,244,.08); }
.month-cell.other-month .cell-day { color: var(--text-3); } .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); flex-shrink: 0; background: var(--bg-app);
position: sticky; top: 0; z-index: 10; 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 { .week-day-header {
flex: 1; padding: 8px 4px; text-align: center; flex: 1; padding: 8px 4px; text-align: center;
border-left: 1px solid var(--border); cursor: pointer; 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> <button class="icon-btn mini-btn" id="mini-next">&#8250;</button>
</div> </div>
<div class="mini-cal-grid"> <div class="mini-cal-grid">
<div class="mini-dow">So</div><div class="mini-dow">Mo</div> <div class="mini-dow">Mo</div><div class="mini-dow">Di</div>
<div class="mini-dow">Di</div><div class="mini-dow">Mi</div> <div class="mini-dow">Mi</div><div class="mini-dow">Do</div>
<div class="mini-dow">Do</div><div class="mini-dow">Fr</div> <div class="mini-dow">Fr</div><div class="mini-dow">Sa</div>
<div class="mini-dow">Sa</div> <div class="mini-dow">So</div>
</div> </div>
<div class="mini-cal-days" id="mini-days"></div> <div class="mini-cal-days" id="mini-days"></div>
</div> </div>
@@ -319,6 +319,13 @@
<option value="agenda">Termine</option> <option value="agenda">Termine</option>
</select> </select>
</div> </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"> <div class="form-group">
<label>Primärfarbe</label> <label>Primärfarbe</label>
<div class="color-row"> <div class="color-row">

View File

@@ -164,12 +164,12 @@ function bindLoginForm() {
function loadAvatarImage(avatarEl, username) { function loadAvatarImage(avatarEl, username) {
const img = new Image(); const img = new Image();
img.onload = () => { img.onload = () => {
avatarEl.textContent = ''; avatarEl.innerHTML = '';
img.style.cssText = 'width:100%;height:100%;object-fit:cover;border-radius:50%'; img.style.cssText = 'width:100%;height:100%;object-fit:cover;position:absolute;inset:0';
avatarEl.appendChild(img); avatarEl.appendChild(img);
}; };
img.onerror = () => { img.onerror = () => {
// Fallback to letter avatarEl.innerHTML = '';
avatarEl.textContent = (username || '?')[0].toUpperCase(); avatarEl.textContent = (username || '?')[0].toUpperCase();
}; };
img.src = `/api/profile/avatar?t=${Date.now()}`; img.src = `/api/profile/avatar?t=${Date.now()}`;

View File

@@ -1,9 +1,12 @@
import { api } from './api.js'; 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 { renderMonth } from './views/month.js';
import { renderWeek } from './views/week.js'; import { renderWeek } from './views/week.js';
import { renderAgenda } from './views/agenda.js'; import { renderAgenda } from './views/agenda.js';
// week start day global (loaded from settings)
let weekStartDay = 'monday';
const MONTHS = ['Januar','Februar','März','April','Mai','Juni', const MONTHS = ['Januar','Februar','März','April','Mai','Juni',
'Juli','August','September','Oktober','November','Dezember']; 'Juli','August','September','Oktober','November','Dezember'];
@@ -29,6 +32,7 @@ export async function initCalendar() {
state.accounts = accounts; state.accounts = accounts;
state.currentView = settings.default_view || 'month'; state.currentView = settings.default_view || 'month';
state.dimPast = settings.dim_past_events; state.dimPast = settings.dim_past_events;
weekStartDay = settings.week_start_day || 'monday';
applyTheme(settings); applyTheme(settings);
updateViewButtons(); updateViewButtons();
@@ -65,13 +69,11 @@ function getViewRange() {
if (state.currentView === 'month') { if (state.currentView === 'month') {
start = new Date(d.getFullYear(), d.getMonth(), 1); 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 = 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') { } else if (state.currentView === 'week') {
start = new Date(d); start = weekStart(d, weekStartDay);
start.setDate(d.getDate() - d.getDay());
start.setHours(0, 0, 0, 0);
end = new Date(start); end = new Date(start);
end.setDate(start.getDate() + 7); end.setDate(start.getDate() + 7);
} else if (state.currentView === 'day') { } else if (state.currentView === 'day') {
@@ -96,7 +98,8 @@ function renderView() {
if (state.currentView === 'month') { if (state.currentView === 'month') {
renderMonth(container, state.currentDate, evs, renderMonth(container, state.currentDate, evs,
date => { state.currentDate = date; state.currentView = 'day'; updateViewButtons(); fetchAndRender(); }, date => { state.currentDate = date; state.currentView = 'day'; updateViewButtons(); fetchAndRender(); },
showEventPopup showEventPopup,
weekStartDay
); );
} else if (state.currentView === 'week') { } else if (state.currentView === 'week') {
renderWeek(container, state.currentDate, evs, renderWeek(container, state.currentDate, evs,
@@ -104,13 +107,16 @@ function renderView() {
if (switchDay) { state.currentDate = date; state.currentView = 'day'; updateViewButtons(); fetchAndRender(); } if (switchDay) { state.currentDate = date; state.currentView = 'day'; updateViewButtons(); fetchAndRender(); }
else openNewEventModal(date); else openNewEventModal(date);
}, },
showEventPopup showEventPopup,
false,
weekStartDay
); );
} else if (state.currentView === 'day') { } else if (state.currentView === 'day') {
renderWeek(container, state.currentDate, evs, renderWeek(container, state.currentDate, evs,
(date, switchDay) => { if (!switchDay) openNewEventModal(date); }, (date, switchDay) => { if (!switchDay) openNewEventModal(date); },
showEventPopup, showEventPopup,
true true,
weekStartDay
); );
} else { } else {
renderAgenda(container, state.currentDate, evs, showEventPopup); renderAgenda(container, state.currentDate, evs, showEventPopup);
@@ -133,14 +139,13 @@ function updateTitle() {
if (state.currentView === 'month') { if (state.currentView === 'month') {
title = `${MONTHS[d.getMonth()]} ${d.getFullYear()}`; title = `${MONTHS[d.getMonth()]} ${d.getFullYear()}`;
} else if (state.currentView === 'week') { } else if (state.currentView === 'week') {
const sun = new Date(d); const mon = weekStart(d, weekStartDay);
sun.setDate(d.getDate() - d.getDay()); const sun = new Date(mon);
const sat = new Date(sun); sun.setDate(mon.getDate() + 6);
sat.setDate(sun.getDate() + 6); const sameMonth = mon.getMonth() === sun.getMonth();
const sameMonth = sun.getMonth() === sat.getMonth();
title = sameMonth title = sameMonth
? `${sun.getDate()}. ${sat.getDate()}. ${MONTHS[sat.getMonth()]} ${sat.getFullYear()}` ? `${mon.getDate()}. ${sun.getDate()}. ${MONTHS[sun.getMonth()]} ${sun.getFullYear()}`
: `${sun.getDate()}. ${MONTHS[sun.getMonth()]} ${sat.getDate()}. ${MONTHS[sat.getMonth()]} ${sat.getFullYear()}`; : `${mon.getDate()}. ${MONTHS[mon.getMonth()]} ${sun.getDate()}. ${MONTHS[sun.getMonth()]} ${sun.getFullYear()}`;
} else if (state.currentView === 'day') { } else if (state.currentView === 'day') {
title = `${d.getDate()}. ${MONTHS[d.getMonth()]} ${d.getFullYear()}`; title = `${d.getDate()}. ${MONTHS[d.getMonth()]} ${d.getFullYear()}`;
} else { } else {
@@ -164,9 +169,15 @@ function renderMiniCal() {
`${MONTHS[miniD.getMonth()]} ${miniD.getFullYear()}`; `${MONTHS[miniD.getMonth()]} ${miniD.getFullYear()}`;
const firstDay = new Date(miniD.getFullYear(), miniD.getMonth(), 1); const firstDay = new Date(miniD.getFullYear(), miniD.getMonth(), 1);
const lastDay = new Date(miniD.getFullYear(), miniD.getMonth() + 1, 0);
const gridStart = new Date(firstDay); 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 // Build event date set
const eventDates = new Set(state.events.map(ev => { const eventDates = new Set(state.events.map(ev => {
@@ -618,10 +629,11 @@ function bindAccountModal() {
// ── Settings Modal ──────────────────────────────────────── // ── Settings Modal ────────────────────────────────────────
function openSettingsModal() { function openSettingsModal() {
const s = state.settings; const s = state.settings;
document.getElementById('cfg-default-view').value = s.default_view || 'month'; document.getElementById('cfg-default-view').value = s.default_view || 'month';
document.getElementById('cfg-primary-color').value = s.primary_color || '#4285f4'; document.getElementById('cfg-week-start').value = s.week_start_day || 'monday';
document.getElementById('cfg-accent-color').value = s.accent_color || '#ea4335'; document.getElementById('cfg-primary-color').value = s.primary_color || '#4285f4';
document.getElementById('cfg-today-color').value = s.today_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-dim-past').checked = !!s.dim_past_events;
document.getElementById('cfg-primary-label').textContent = s.primary_color || '#4285f4'; document.getElementById('cfg-primary-label').textContent = s.primary_color || '#4285f4';
@@ -701,20 +713,23 @@ function bindSettingsModal() {
document.getElementById('settings-save').onclick = async () => { document.getElementById('settings-save').onclick = async () => {
const settings = { const settings = {
default_view: document.getElementById('cfg-default-view').value, default_view: document.getElementById('cfg-default-view').value,
primary_color: document.getElementById('cfg-primary-color').value, week_start_day: document.getElementById('cfg-week-start').value,
accent_color: document.getElementById('cfg-accent-color').value, primary_color: document.getElementById('cfg-primary-color').value,
today_color: document.getElementById('cfg-today-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, dim_past_events: document.getElementById('cfg-dim-past').checked,
}; };
try { try {
await api.put('/settings/', settings); await api.put('/settings/', settings);
state.settings = { ...state.settings, ...settings }; state.settings = { ...state.settings, ...settings };
state.dimPast = settings.dim_past_events; state.dimPast = settings.dim_past_events;
weekStartDay = settings.week_start_day;
applyTheme(settings); applyTheme(settings);
showToast('Einstellungen gespeichert'); showToast('Einstellungen gespeichert');
closeModal('modal-settings'); closeModal('modal-settings');
renderView(); renderMiniCal();
fetchAndRender();
} catch (e) { showToast(e.message, true); } } catch (e) { showToast(e.message, true); }
}; };
} }
@@ -885,12 +900,27 @@ function updateTopbarAvatar(hasAvatar) {
const avatar = document.getElementById('user-avatar'); const avatar = document.getElementById('user-avatar');
if (hasAvatar) { if (hasAvatar) {
const img = new Image(); 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.onload = () => {
img.onerror = () => { const u = JSON.parse(localStorage.getItem('user')||'{}'); avatar.textContent = (u.username||'?')[0].toUpperCase(); }; 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()}`; 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 { } else {
avatar.innerHTML = '';
const user = JSON.parse(localStorage.getItem('user') || '{}'); const user = JSON.parse(localStorage.getItem('user') || '{}');
avatar.textContent = (user.username || '?')[0].toUpperCase(); 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(); const reader = new FileReader();
reader.onload = (e) => { reader.onload = (e) => {
const cropImg = document.getElementById('crop-image'); const cropImg = document.getElementById('crop-image');
cropImg.src = e.target.result;
openModal('modal-crop');
// Destroy previous cropper if any // Destroy previous cropper if any
if (activeCropper) { activeCropper.destroy(); activeCropper = null; } if (activeCropper) { activeCropper.destroy(); activeCropper = null; }
// Wait for image to load then init cropper // Reset image src first to force reload
cropImg.onload = () => { cropImg.removeAttribute('src');
activeCropper = new Cropper(cropImg, {
aspectRatio: 1, openModal('modal-crop');
viewMode: 1,
dragMode: 'move', // Use requestAnimationFrame to ensure modal is visible before initializing cropper
autoCropArea: 1, requestAnimationFrame(() => {
cropBoxResizable: true, cropImg.onload = () => {
cropBoxMovable: true, // Small delay to ensure the image is fully rendered in the DOM
background: false, 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); 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')}`; 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) { export function applyTheme(settings) {
const root = document.documentElement; const root = document.documentElement;
root.style.setProperty('--primary', settings.primary_color || '#4285f4'); 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 year = currentDate.getFullYear();
const month = currentDate.getMonth(); const month = currentDate.getMonth();
const DOW = weekStartDay === 'sunday' ? DOW_SUNDAY : DOW_MONDAY;
const firstDay = new Date(year, month, 1); const firstDay = new Date(year, month, 1);
const lastDay = new Date(year, month + 1, 0); 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); const gridStart = new Date(firstDay);
gridStart.setDate(gridStart.getDate() - firstDay.getDay()); const offset = dayOfWeek(firstDay, weekStartDay);
gridStart.setDate(gridStart.getDate() - offset);
const cells = []; const cells = [];
const d = new Date(gridStart); const d = new Date(gridStart);
@@ -39,45 +42,55 @@ export function renderMonth(container, currentDate, events, onDayClick, onEventC
} }
}); });
// Header // Header: KW-Spalte + Wochentage
const headerHtml = DOW.map(d => `<div class="month-dow">${d}</div>`).join(''); const headerHtml = `<div class="month-kw-header">KW</div>` +
DOW.map(d => `<div class="month-dow">${d}</div>`).join('');
// Cells // Build rows (6 weeks × 7 days)
const cellsHtml = cells.map(cell => { let cellsHtml = '';
const key = dateKey(cell); for (let row = 0; row < 6; row++) {
const cellEvs = (evMap[key] || []).slice().sort((a, b) => { // KW cell for the first day of this row
if (a.allDay && !b.allDay) return -1; const rowFirstDay = cells[row * 7];
if (!a.allDay && b.allDay) return 1; const kw = getISOWeekNumber(rowFirstDay);
return new Date(a.start) - new Date(b.start); cellsHtml += `<div class="month-kw-cell">${kw}</div>`;
});
const isOther = cell.getMonth() !== month; for (let col = 0; col < 7; col++) {
const todayClass = isToday(cell) ? 'today' : ''; const cell = cells[row * 7 + col];
const otherClass = isOther ? 'other-month' : ''; const key = dateKey(cell);
const numClass = isToday(cell) ? 'today' : ''; 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 isOther = cell.getMonth() !== month;
const visible = cellEvs.slice(0, MAX_VISIBLE); const todayClass = isToday(cell) ? 'today' : '';
const hiddenCount = cellEvs.length - MAX_VISIBLE; const otherClass = isOther ? 'other-month' : '';
const numClass = isToday(cell) ? 'today' : '';
const evHtml = visible.map(ev => { const MAX_VISIBLE = 3;
const color = ev.color || ev.calendarColor || '#4285f4'; const visible = cellEvs.slice(0, MAX_VISIBLE);
const pastClass = isPast(ev) ? 'past' : ''; const hiddenCount = cellEvs.length - MAX_VISIBLE;
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 moreHtml = hiddenCount > 0 const evHtml = visible.map(ev => {
? `<div class="month-more" data-date="${key}">+${hiddenCount} weitere</div>` 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}"> const moreHtml = hiddenCount > 0
<div class="cell-day ${numClass}">${cell.getDate()}</div> ? `<div class="month-more" data-date="${key}">+${hiddenCount} weitere</div>`
${evHtml}${moreHtml} : '';
</div>`;
}).join(''); 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"> container.innerHTML = `<div class="month-view">
<div class="month-header">${headerHtml}</div> <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']; 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) // Build the days array (7 days for week, 1 for day)
const days = []; const days = [];
if (isSingleDay) { if (isSingleDay) {
days.push(new Date(currentDate)); days.push(new Date(currentDate));
} else { } else {
const sunday = new Date(currentDate); const monday = weekStart(currentDate, weekStartDay);
sunday.setDate(sunday.getDate() - sunday.getDay());
for (let i = 0; i < 7; i++) { for (let i = 0; i < 7; i++) {
const d = new Date(sunday); const d = new Date(monday);
d.setDate(d.getDate() + i); d.setDate(d.getDate() + i);
days.push(d); days.push(d);
} }
@@ -21,6 +20,12 @@ export function renderWeek(container, currentDate, events, onSlotClick, onEventC
const allDayEvs = events.filter(ev => ev.allDay); const allDayEvs = events.filter(ev => ev.allDay);
const timedEvs = 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 ──────────────────────────────────────────── // ── Header ────────────────────────────────────────────
const headerCols = days.map(day => { const headerCols = days.map(day => {
const todayCls = isToday(day) ? 'today' : ''; const todayCls = isToday(day) ? 'today' : '';
@@ -98,7 +103,7 @@ export function renderWeek(container, currentDate, events, onSlotClick, onEventC
container.innerHTML = `<div class="${viewClass}"> container.innerHTML = `<div class="${viewClass}">
<div class="week-header-row"> <div class="week-header-row">
<div class="week-time-gutter"></div> <div class="week-time-gutter">${kwBadge}</div>
${headerCols} ${headerCols}
</div> </div>
<div class="week-allday-row"> <div class="week-allday-row">