big update i guess
This commit is contained in:
@@ -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"])
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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}")
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -139,10 +139,10 @@
|
|||||||
<button class="icon-btn mini-btn" id="mini-next">›</button>
|
<button class="icon-btn mini-btn" id="mini-next">›</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">
|
||||||
|
|||||||
@@ -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()}`;
|
||||||
|
|||||||
@@ -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
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')}`;
|
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');
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
Reference in New Issue
Block a user