big update i guess
This commit is contained in:
@@ -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"])
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -139,10 +139,10 @@
|
||||
<button class="icon-btn mini-btn" id="mini-next">›</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">
|
||||
|
||||
@@ -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()}`;
|
||||
|
||||
@@ -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 => {
|
||||
@@ -619,6 +630,7 @@ function bindAccountModal() {
|
||||
function openSettingsModal() {
|
||||
const s = state.settings;
|
||||
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';
|
||||
@@ -702,6 +714,7 @@ function bindSettingsModal() {
|
||||
document.getElementById('settings-save').onclick = async () => {
|
||||
const settings = {
|
||||
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,
|
||||
@@ -711,10 +724,12 @@ function bindSettingsModal() {
|
||||
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,15 +999,21 @@ 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
|
||||
// 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,
|
||||
@@ -987,7 +1023,10 @@ function openCropModal(file) {
|
||||
cropBoxMovable: true,
|
||||
background: false,
|
||||
});
|
||||
}, 100);
|
||||
};
|
||||
cropImg.src = e.target.result;
|
||||
});
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
|
||||
248
frontend/js/color-picker.js
Normal file
248
frontend/js/color-picker.js
Normal 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);
|
||||
});
|
||||
}
|
||||
@@ -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');
|
||||
|
||||
@@ -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,11 +42,20 @@ 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 => {
|
||||
// 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>`;
|
||||
|
||||
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;
|
||||
@@ -73,11 +85,12 @@ export function renderMonth(container, currentDate, events, onDayClick, onEventC
|
||||
? `<div class="month-more" data-date="${key}">+${hiddenCount} weitere</div>`
|
||||
: '';
|
||||
|
||||
return `<div class="month-cell ${todayClass} ${otherClass}" data-date="${key}">
|
||||
cellsHtml += `<div class="month-cell ${todayClass} ${otherClass}" data-date="${key}">
|
||||
<div class="cell-day ${numClass}">${cell.getDate()}</div>
|
||||
${evHtml}${moreHtml}
|
||||
</div>`;
|
||||
}).join('');
|
||||
}
|
||||
}
|
||||
|
||||
container.innerHTML = `<div class="month-view">
|
||||
<div class="month-header">${headerHtml}</div>
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user