Compare commits

..

3 Commits

Author SHA1 Message Date
Scarriffle
58faf3876c fix: hartcodiertes Blau bei "heute"-Spalte & Admin-Badge entfernt
- .month-col.today nutzt jetzt --today-color statt fixem Blau
  (Spaltenhintergrund passt nun zum Tageskreis / Theme-Farbe)
- .badge-admin nutzt --primary-dim statt fixem Blau

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 22:07:20 +02:00
Scarriffle
639d7f3c9c style: Feinschliff – Event-Popup, KW-Anzeige, Termin-Formular
- Event-Popup: Kopf neu ausgerichtet (Titel umbricht sauber, Aktions-
  Icons oben rechts statt gequetscht), breiter, weicherer Rahmen
- Kalenderwoche: KW-Badge in Wochen- & Monatsansicht vertikal zentriert
- Formulare: Feld-Labels nicht mehr in Großbuchstaben (moderner),
  Modal-Kopf kräftiger, weichere Trennlinien in Modals

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 21:50:44 +02:00
Scarriffle
a60c27f66f style: UI modernisiert – weniger Tabellen-Look, mehr Tiefe
- Größere Radien (Karten 14px, Inputs 9px, Event-Chips 6px)
- Geschichtete Dark-Flächen + weiche Schatten statt harter 1px-Linien
- Primär-Buttons mit dezentem Verlauf/Glow, segmentierter View-Switcher
- Sanfte Hover-/Press-/Modal-Animationen (respektiert prefers-reduced-motion)
- Fokus-Ringe für Buttons/Inputs (A11y), feinerer System-Font-Stack
- Fix: --bg-2 / --bg-card definiert (Quartalskarten & Wiederholungs-Buttons)

Theme-Variablen (Farben/Kontrast aus den Einstellungen) bleiben unangetastet.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 21:37:54 +02:00
11 changed files with 224 additions and 624 deletions

View File

@@ -4,16 +4,11 @@ import sys
from pathlib import Path
import uvicorn
from fastapi import FastAPI, HTTPException, Request
from fastapi import FastAPI, HTTPException
from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles
from sqlalchemy import text
# How long the browser may keep static assets before revalidating.
STATIC_MAX_AGE_SECONDS = 2 * 60 * 60 # 2 hours
NO_CACHE = "no-cache, no-store, must-revalidate"
STATIC_CACHE = f"public, max-age={STATIC_MAX_AGE_SECONDS}, must-revalidate"
sys.path.insert(0, str(Path(__file__).parent))
from database import Base, engine
@@ -114,56 +109,10 @@ def _migrate():
except Exception:
pass
try:
conn.execute(text("ALTER TABLE user_settings ADD COLUMN text_color VARCHAR(7)"))
conn.commit()
except Exception:
pass
try:
conn.execute(text("ALTER TABLE user_settings ADD COLUMN line_color VARCHAR(7)"))
conn.commit()
except Exception:
pass
try:
conn.execute(text("ALTER TABLE user_settings ADD COLUMN bg_color VARCHAR(7)"))
conn.commit()
except Exception:
pass
_migrate()
app = FastAPI(title="Calendarr", docs_url=None, redoc_url=None)
@app.middleware("http")
async def add_cache_headers(request: Request, call_next):
"""Force ≤ 2h browser cache for static assets and disable cache for the
entry HTML / SW / version file. API responses are left alone (handlers
decide their own caching)."""
response = await call_next(request)
path = request.url.path
# Never cache: entry HTML, manifest, service worker, version marker
if (
path in ("/", "/index.html", "/manifest.json", "/sw.js")
or path == "/static/js/version.js"
):
response.headers["Cache-Control"] = NO_CACHE
response.headers["Pragma"] = "no-cache"
response.headers["Expires"] = "0"
# 2h cache for the rest of the frontend (JS/CSS/icons/etc.)
elif path.startswith("/static/") or path.startswith("/icons/"):
response.headers["Cache-Control"] = STATIC_CACHE
# SPA fallback (everything else that isn't an API route) returns HTML;
# don't let the browser cache that either.
elif not path.startswith("/api/"):
response.headers["Cache-Control"] = NO_CACHE
return response
app.include_router(auth_router.router, prefix="/api/auth", tags=["auth"])
app.include_router(users_router.router, prefix="/api/users", tags=["users"])
app.include_router(caldav_router.router, prefix="/api/caldav", tags=["caldav"])

View File

@@ -84,9 +84,6 @@ class UserSettings(Base):
language = Column(String(5), default="de")
month_divider_color = Column(String(7), default="#7090c0")
month_label_color = Column(String(7), default="#7090c0")
text_color = Column(String(7), nullable=True) # Override für --text-1 (NULL = nutze text_contrast)
line_color = Column(String(7), nullable=True) # Override für --border (NULL = nutze line_contrast)
bg_color = Column(String(7), nullable=True) # Override für --bg-app (NULL = Default)
user = relationship("User", back_populates="settings")

View File

@@ -24,9 +24,6 @@ class SettingsUpdate(BaseModel):
language: Optional[str] = None
month_divider_color: Optional[str] = None
month_label_color: Optional[str] = None
text_color: Optional[str] = None
line_color: Optional[str] = None
bg_color: Optional[str] = None
def _settings_dict(s: models.UserSettings) -> dict:
@@ -43,9 +40,6 @@ def _settings_dict(s: models.UserSettings) -> dict:
"language": s.language or "de",
"month_divider_color": s.month_divider_color or "#7090c0",
"month_label_color": s.month_label_color or "#7090c0",
"text_color": s.text_color,
"line_color": s.line_color,
"bg_color": s.bg_color,
}
@@ -82,16 +76,8 @@ def update_settings(
settings = models.UserSettings(user_id=current_user.id)
db.add(settings)
# For these three override colours, an explicit null is meaningful
# ("reset to default") and must be persisted as NULL. All other fields
# keep the previous behaviour where a null/missing value is ignored.
NULLABLE_OVERRIDES = {"text_color", "line_color", "bg_color"}
update_data = data.model_dump(exclude_unset=True)
for field, value in update_data.items():
if field in NULLABLE_OVERRIDES:
setattr(settings, field, value or None)
elif value is not None:
setattr(settings, field, value)
for field, value in data.model_dump(exclude_none=True).items():
setattr(settings, field, value)
db.commit()
return {"ok": True}

View File

@@ -9,38 +9,50 @@
--accent: #ea4335;
--today-color: #4285f4;
--bg-app: #0e0e14;
--bg-topbar: #18181f;
--bg-sidebar: #18181f;
--bg-surface: #22222c;
--bg-hover: #2a2a38;
--bg-active: #32324a;
/* Layered surfaces — subtly cool-tinted, slightly more depth between layers
so panels read via elevation instead of hard table-like 1px borders */
--bg-app: #0c0c13;
--bg-topbar: #14141d;
--bg-sidebar: #14141d;
--bg-surface: #1d1d2a;
--bg-2: #16161f; /* used by quarter month cards */
--bg-card: #1d1d2a; /* used by recurrence day buttons */
--bg-hover: #262636;
--bg-active: #31314f;
--text-1: #e8e8f0;
--text-2: #9090aa;
--text-3: #55556a;
--border: #2e2e40;
--border-light: #242438;
--scrollbar: #30303c;
--scrollbar: #36364a;
--topbar-h: 64px;
--sidebar-w: 256px;
--shadow: 0 2px 12px rgba(0,0,0,.45);
--shadow-lg: 0 8px 28px rgba(0,0,0,.55);
--radius: 8px;
--radius-sm: 4px;
--transition: .15s ease;
--shadow-sm: 0 1px 3px rgba(0,0,0,.30);
--shadow: 0 6px 20px rgba(0,0,0,.40);
--shadow-lg: 0 16px 44px rgba(0,0,0,.55);
--radius-lg: 20px;
--radius: 14px;
--radius-sm: 9px;
--transition: .18s cubic-bezier(.4, 0, .2, 1);
--ring: 0 0 0 3px var(--primary-dim);
}
/* ── Reset ──────────────────────────────────────────────── */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html, body { height: 100%; overflow: hidden; }
body {
font-family: 'Google Sans', 'Roboto', system-ui, sans-serif;
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto,
'Helvetica Neue', system-ui, sans-serif;
background: var(--bg-app);
color: var(--text-1);
font-size: 14px;
line-height: 1.5;
letter-spacing: .1px;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-rendering: optimizeLegibility;
}
input, select, textarea, button { font-family: inherit; font-size: inherit; color: inherit; }
button { cursor: pointer; border: none; background: none; }
@@ -55,129 +67,59 @@ a { color: var(--primary); text-decoration: none; }
.flex-col { display: flex; flex-direction: column; }
.gap-8 { gap: 8px; }
/* ── Buttons ──────────────────────────────────────────────
Modern pill style: fully rounded, subtle coloured shadow on the
prominent variants, lift on hover, snap back on press. The
primary-coloured glow follows --primary via color-mix(), so it adapts
when the user changes the theme colour in settings. */
/* ── Buttons ────────────────────────────────────────────── */
.btn {
display: inline-flex; align-items: center; justify-content: center;
gap: 8px;
padding: 10px 22px;
border-radius: 999px;
font-weight: 500; font-size: 14px;
letter-spacing: .1px;
display: inline-flex; align-items: center; gap: 6px;
padding: 8px 16px; border-radius: 20px;
font-weight: 500;
transition: background var(--transition), color var(--transition),
box-shadow var(--transition), transform var(--transition),
filter var(--transition);
white-space: nowrap;
user-select: none;
-webkit-tap-highlight-color: transparent;
transition:
background var(--transition),
color var(--transition),
border-color var(--transition),
box-shadow .18s ease,
transform .12s ease,
filter var(--transition);
}
.btn:active { transform: translateY(0) scale(.985); transition-duration: .05s; }
.btn:focus-visible {
outline: 2px solid var(--primary);
outline-offset: 2px;
}
.btn:active { transform: translateY(1px); }
.btn:focus-visible { outline: none; box-shadow: var(--ring); }
.btn-primary {
background: var(--primary);
background: linear-gradient(135deg, var(--primary), color-mix(in srgb, var(--primary) 80%, #14141d));
color: #fff;
box-shadow: 0 2px 8px rgba(66,133,244,.28);
box-shadow: 0 2px 8px color-mix(in srgb, var(--primary) 30%, transparent);
box-shadow: 0 2px 10px var(--primary-dim);
}
.btn-primary:hover {
filter: brightness(1.08);
transform: translateY(-1px);
box-shadow: 0 6px 18px rgba(66,133,244,.42);
box-shadow: 0 6px 18px color-mix(in srgb, var(--primary) 45%, transparent);
}
.btn-primary:hover { filter: brightness(1.10); box-shadow: 0 5px 18px var(--primary-dim); }
.btn-secondary {
background: var(--bg-surface);
color: var(--text-1);
border: 1px solid var(--border);
}
.btn-secondary:hover {
background: var(--bg-hover);
border-color: var(--primary);
transform: translateY(-1px);
}
.btn-ghost {
color: var(--primary);
background: transparent;
}
.btn-ghost:hover {
background: var(--primary-dim);
transform: translateY(-1px);
}
.btn-danger {
background: var(--accent);
color: #fff;
box-shadow: 0 2px 8px rgba(234,67,53,.28);
box-shadow: 0 2px 8px color-mix(in srgb, var(--accent) 30%, transparent);
}
.btn-danger:hover {
filter: brightness(1.08);
transform: translateY(-1px);
box-shadow: 0 6px 18px rgba(234,67,53,.42);
box-shadow: 0 6px 18px color-mix(in srgb, var(--accent) 45%, transparent);
}
.btn-full { width: 100%; }
/* The big sidebar "Erstellen" button: same pill aesthetic, primary tinted,
lives in the calm dark sidebar so the shadow is a touch stronger. */
.btn-secondary:hover { background: var(--bg-hover); }
.btn-ghost { color: var(--primary); }
.btn-ghost:hover { background: var(--primary-dim); }
.btn-danger { background: var(--accent); color: #fff; }
.btn-danger:hover { filter: brightness(1.1); }
.btn-full { width: 100%; justify-content: center; }
.btn-fab {
display: flex; align-items: center; gap: 10px;
padding: 12px 22px;
border-radius: 999px;
background: var(--primary);
color: #fff;
padding: 12px 20px; border-radius: 24px;
background: var(--bg-surface);
color: var(--text-1);
font-weight: 600;
box-shadow: var(--shadow);
margin: 16px 12px 8px;
box-shadow: 0 4px 14px rgba(66,133,244,.32);
box-shadow: 0 4px 14px color-mix(in srgb, var(--primary) 35%, transparent);
transition:
background var(--transition),
box-shadow .18s ease,
transform .12s ease,
filter var(--transition);
transition: background var(--transition), box-shadow var(--transition);
}
.btn-fab:hover {
filter: brightness(1.08);
transform: translateY(-1px);
box-shadow: 0 8px 22px rgba(66,133,244,.5);
box-shadow: 0 8px 22px color-mix(in srgb, var(--primary) 50%, transparent);
}
.btn-fab:active { transform: translateY(0) scale(.985); }
.btn-fab:hover { background: var(--bg-hover); box-shadow: var(--shadow-lg); }
/* Circular icon buttons (topbar nav, modal close, etc.) */
.icon-btn {
display: inline-flex; align-items: center; justify-content: center;
width: 40px; height: 40px;
border-radius: 50%;
width: 40px; height: 40px; border-radius: 50%;
color: var(--text-2);
transition: background var(--transition), color var(--transition), transform var(--transition);
flex-shrink: 0;
-webkit-tap-highlight-color: transparent;
transition:
background var(--transition),
color var(--transition),
transform .1s ease;
}
.icon-btn svg { width: 20px; height: 20px; fill: currentColor; }
.icon-btn:hover { background: var(--bg-hover); color: var(--text-1); }
.icon-btn:active { transform: scale(.92); }
.icon-btn:focus-visible {
outline: 2px solid var(--primary);
outline-offset: 2px;
}
.icon-btn:focus-visible { outline: none; box-shadow: var(--ring); }
/* ── Auth Screens ───────────────────────────────────────── */
.auth-screen {
@@ -216,26 +158,20 @@ a { color: var(--primary); text-decoration: none; }
display: flex; flex-direction: column; gap: 6px;
margin-bottom: 16px;
}
.form-group label { color: var(--text-2); font-size: 12px; font-weight: 500; text-transform: uppercase; letter-spacing: .5px; }
.form-group label { color: var(--text-2); font-size: 13px; font-weight: 500; letter-spacing: 0; }
.form-group input, .form-group select, .form-group textarea {
background: var(--bg-app);
border: 1px solid var(--border);
border-radius: 8px;
padding: 11px 14px;
border-radius: var(--radius-sm);
padding: 10px 12px;
color: var(--text-1);
outline: none;
transition: border-color var(--transition), box-shadow var(--transition);
width: 100%;
}
.form-group input:hover:not(:focus),
.form-group select:hover:not(:focus),
.form-group textarea:hover:not(:focus) {
border-color: var(--text-3);
}
.form-group input:focus, .form-group select:focus, .form-group textarea:focus {
border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(66,133,244,.18);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary) 18%, transparent);
box-shadow: var(--ring);
}
.form-group textarea { resize: vertical; }
@@ -376,7 +312,8 @@ a { color: var(--primary); text-decoration: none; }
position: fixed; top: 0; left: 0; right: 0; z-index: 100;
height: var(--topbar-h);
background: var(--bg-topbar);
border-bottom: 1px solid var(--border);
border-bottom: 1px solid var(--border-light);
box-shadow: var(--shadow-sm);
display: flex; align-items: center;
padding: 0 8px;
gap: 8px;
@@ -389,15 +326,16 @@ a { color: var(--primary); text-decoration: none; }
.view-title { font-size: 20px; font-weight: 400; color: var(--text-1); white-space: nowrap; padding-left: 8px; }
.topbar-right { display: flex; align-items: center; gap: 4px; }
.view-switcher { display: flex; background: var(--bg-surface); border-radius: 20px; overflow: hidden; border: 1px solid var(--border); }
.view-switcher { display: flex; gap: 2px; padding: 3px; background: var(--bg-surface); border-radius: 22px; border: 1px solid var(--border-light); }
.view-btn {
padding: 6px 14px; font-size: 13px; font-weight: 500;
color: var(--text-2);
transition: background var(--transition), color var(--transition);
border-radius: 20px;
transition: background var(--transition), color var(--transition), box-shadow var(--transition);
border-radius: 18px;
}
.view-btn:hover { background: var(--bg-hover); color: var(--text-1); }
.view-btn.active { background: var(--primary-dim); color: var(--primary); }
.view-btn:focus-visible { outline: none; box-shadow: var(--ring); }
.view-btn.active { background: var(--primary-dim); color: var(--primary); font-weight: 600; box-shadow: inset 0 0 0 1px var(--primary-dim); }
.user-menu-wrapper { position: relative; }
.user-avatar {
@@ -440,7 +378,7 @@ a { color: var(--primary); text-decoration: none; }
.sidebar {
width: var(--sidebar-w);
background: var(--bg-sidebar);
border-right: 1px solid var(--border);
border-right: 1px solid var(--border-light);
flex-shrink: 0;
overflow-y: auto;
overflow-x: hidden;
@@ -557,8 +495,7 @@ a { color: var(--primary); text-decoration: none; }
}
.month-kw-cell {
position: absolute; left: 0; top: 0; bottom: 0; width: 38px;
display: flex; align-items: flex-start; justify-content: center;
padding-top: 6px;
display: flex; align-items: center; justify-content: center;
font-size: 13px; color: var(--text-3); font-weight: 700;
border-right: 1px solid var(--border-light);
cursor: default; user-select: none; z-index: 1;
@@ -572,7 +509,7 @@ a { color: var(--primary); text-decoration: none; }
}
.month-col:last-child { border-right: none; }
.month-col:hover { background: var(--bg-hover); }
.month-col.today { background: rgba(66,133,244,.08); }
.month-col.today { background: color-mix(in srgb, var(--today-color) 9%, transparent); }
.month-col.month-selected { background: var(--primary-dim); }
.month-col.month-selected .cell-day { border: 2px solid var(--primary); color: var(--primary); font-weight: 700; }
.month-col.month-selected .cell-day.today { background: var(--today-color); color: #fff; border: none; }
@@ -686,8 +623,7 @@ a { color: var(--primary); text-decoration: none; }
/* KW badge in week view header gutter */
.week-kw-badge {
font-size: 14px; font-weight: 700; color: var(--text-3);
display: flex; align-items: flex-end; justify-content: center;
padding-bottom: 6px;
display: flex; align-items: center; justify-content: center;
text-transform: uppercase; letter-spacing: .3px;
user-select: none;
}
@@ -727,7 +663,7 @@ a { color: var(--primary); text-decoration: none; }
position: absolute;
height: 18px; line-height: 18px;
font-size: 11px; font-weight: 500;
padding: 0 6px; border-radius: 3px;
padding: 0 6px; border-radius: 6px;
cursor: pointer; pointer-events: all;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
@@ -778,7 +714,7 @@ a { color: var(--primary); text-decoration: none; }
/* Timed events */
.timed-event {
position: absolute; border-radius: 4px;
position: absolute; border-radius: 7px;
padding: 2px 4px; cursor: pointer; overflow: hidden;
font-size: 11px; font-weight: 500;
border-left: 3px solid rgba(0,0,0,.25);
@@ -810,16 +746,19 @@ a { color: var(--primary); text-decoration: none; }
flex-direction: column;
min-width: 0;
background: var(--bg-2);
border: 1px solid var(--border);
border-radius: 8px;
border: 1px solid var(--border-light);
border-radius: var(--radius);
overflow: hidden;
box-shadow: var(--shadow-sm);
transition: box-shadow var(--transition), transform var(--transition);
}
.qtr-month:hover { box-shadow: var(--shadow); transform: translateY(-2px); }
.qtr-month-name {
font-size: 13px;
font-weight: 600;
color: var(--text-1);
padding: 10px 12px 8px;
border-bottom: 1px solid var(--border);
border-bottom: 1px solid var(--border-light);
letter-spacing: .3px;
}
.qtr-month-grid { padding: 6px 8px 8px; }
@@ -922,24 +861,34 @@ a { color: var(--primary); text-decoration: none; }
/* ── Modals ─────────────────────────────────────────────── */
.modal-overlay {
position: fixed; inset: 0; z-index: 500;
background: rgba(0,0,0,.6);
background: rgba(8,8,14,.55);
-webkit-backdrop-filter: blur(5px);
backdrop-filter: blur(5px);
display: flex; align-items: center; justify-content: center;
padding: 16px;
}
.modal-card {
background: var(--bg-surface);
border: 1px solid var(--border);
border: 1px solid var(--border-light);
border-radius: var(--radius);
width: 100%;
max-height: 90vh; overflow-y: auto;
box-shadow: var(--shadow-lg);
animation: card-in .2s cubic-bezier(.4, 0, .2, 1);
}
@keyframes card-in {
from { opacity: 0; transform: translateY(8px) scale(.985); }
to { opacity: 1; transform: none; }
}
@media (prefers-reduced-motion: reduce) {
.modal-card { animation: none; }
}
.modal-header {
display: flex; align-items: center;
padding: 16px 20px; border-bottom: 1px solid var(--border);
padding: 16px 20px; border-bottom: 1px solid var(--border-light);
gap: 8px;
}
.modal-header h3 { font-size: 18px; font-weight: 500; flex: 1; }
.modal-header h3 { font-size: 18px; font-weight: 600; flex: 1; letter-spacing: -.2px; }
.modal-close { font-size: 24px; }
.modal-body { padding: 20px; }
.modal-body p { margin: 0 0 14px; font-size: 14px; color: var(--text-1); }
@@ -948,7 +897,7 @@ a { color: var(--primary); text-decoration: none; }
.modal-body a:hover { text-decoration: underline; }
.modal-footer {
display: flex; align-items: center; gap: 8px;
padding: 12px 20px; border-top: 1px solid var(--border);
padding: 14px 20px; border-top: 1px solid var(--border-light);
}
/* ── Recurrence UI ─────────────────────────────────────── */
@@ -975,79 +924,26 @@ a { color: var(--primary); text-decoration: none; }
}
.ctx-item:hover { background: var(--bg-hover); }
<<<<<<< HEAD
/* ── Event Popup ──────────────────────────────────────────
Layout: Color-Dot + Title links, kleine Icon-Toolbar rechts oben.
Icons sind im Ruhezustand transparent (nur das SVG selbst sichtbar),
bekommen erst beim Hover einen runden farbigen Hintergrund. Wirkt
modern und lässt dem Titel die meiste Breite. */
=======
/* ── Event Popup ────────────────────────────────────────── */
>>>>>>> e744b1829e99db6b80922f75542ced329138e474
.event-popup {
position: fixed; z-index: 600;
background: var(--bg-surface);
border: 1px solid var(--border);
border: 1px solid var(--border-light);
border-radius: var(--radius);
width: 360px;
width: 340px;
box-shadow: var(--shadow-lg);
overflow: hidden;
animation: card-in .16s cubic-bezier(.4, 0, .2, 1);
}
@media (prefers-reduced-motion: reduce) { .event-popup { animation: none; } }
.popup-header {
display: flex; align-items: flex-start; gap: 10px;
padding: 12px 10px 12px 16px;
border-bottom: 1px solid var(--border);
padding: 14px 12px 12px 16px; border-bottom: 1px solid var(--border-light);
}
.popup-color-dot {
width: 11px; height: 11px; border-radius: 50%;
flex-shrink: 0;
margin-top: 6px;
}
.popup-header h4 {
flex: 1;
font-size: 14px; font-weight: 500;
line-height: 1.4;
word-break: break-word;
padding-top: 2px;
}
.popup-toolbar {
display: flex;
gap: 2px;
flex-shrink: 0;
margin-left: 4px;
}
.popup-icon-btn {
width: 30px; height: 30px;
border-radius: 50%;
display: inline-flex; align-items: center; justify-content: center;
background: transparent;
border: none;
cursor: pointer;
color: var(--text-3);
-webkit-tap-highlight-color: transparent;
transition:
background var(--transition),
color var(--transition),
transform .1s ease;
}
.popup-icon-btn svg { width: 15px; height: 15px; fill: currentColor; flex-shrink: 0; }
.popup-icon-btn:hover {
background: rgba(66,133,244,.16);
background: color-mix(in srgb, var(--primary) 16%, transparent);
color: var(--primary);
}
.popup-icon-btn-danger:hover {
background: rgba(234,67,53,.16);
background: color-mix(in srgb, var(--accent) 16%, transparent);
color: var(--accent);
}
.popup-icon-btn-close:hover {
background: var(--bg-hover);
color: var(--text-1);
}
.popup-icon-btn:active { transform: scale(.9); }
.popup-color-dot { width: 11px; height: 11px; border-radius: 50%; flex-shrink: 0; margin-top: 5px; }
.popup-header h4 { flex: 1; min-width: 0; font-size: 15px; font-weight: 600; line-height: 1.35; overflow-wrap: anywhere; padding-top: 1px; }
.popup-action, .popup-close { width: 30px; height: 30px; font-size: 16px; flex-shrink: 0; }
.popup-close { margin-left: 2px; }
.popup-body { padding: 12px 16px; }
.popup-time, .popup-location, .popup-calendar { font-size: 13px; color: var(--text-2); margin-bottom: 6px; }
.popup-description { font-size: 13px; color: var(--text-1); margin-bottom: 6px; white-space: pre-wrap; }
@@ -1161,7 +1057,7 @@ a { color: var(--primary); text-decoration: none; }
.settings-section h4 { font-size: 14px; font-weight: 600; color: var(--text-1); margin-bottom: 16px; display: flex; align-items: center; gap: 8px; }
.badge-admin {
font-size: 10px; padding: 2px 6px;
background: rgba(66,133,244,.2); color: var(--primary);
background: var(--primary-dim); color: var(--primary);
border-radius: 10px; font-weight: 600;
}
.users-list-item {
@@ -1289,7 +1185,7 @@ a { color: var(--primary); text-decoration: none; }
.month-span-event {
position: absolute;
height: 18px; line-height: 18px;
border-radius: 3px; padding: 0 6px;
border-radius: 6px; padding: 0 6px;
font-size: 11px; font-weight: 500;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
cursor: pointer; color: #fff;
@@ -1701,14 +1597,6 @@ a { color: var(--primary); text-decoration: none; }
.topbar-left { gap: 0; }
.topbar-right { gap: 0; }
<<<<<<< HEAD
/* Event-Popup auf Mobile: an Viewport-Breite anpassen */
.event-popup { width: min(94vw, 380px); max-width: 94vw; }
.popup-header { padding: 10px 8px 10px 14px; }
.popup-header h4 { font-size: 13.5px; }
.popup-icon-btn { width: 32px; height: 32px; }
.popup-icon-btn svg { width: 16px; height: 16px; }
=======
/* Event-Popup: Buttons kompakt halten, kein 44px-Override ───── */
.event-popup .icon-btn {
min-width: 32px !important;
@@ -1718,7 +1606,6 @@ a { color: var(--primary); text-decoration: none; }
}
.event-popup .popup-header { gap: 2px; padding: 10px 12px; }
.event-popup { width: min(92vw, 340px); max-width: 92vw; }
>>>>>>> e744b1829e99db6b80922f75542ced329138e474
/* Monatsansicht: Startzeit ausblenden — nur Titel anzeigen ──── */
.month-event-time { display: none; }

View File

@@ -1,10 +1,10 @@
<!DOCTYPE html>
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0, user-scalable=no, viewport-fit=cover" />
<!-- APP_VERSION: update here + version.js on every release -->
<title>Calendarr v18</title>
<title>Calendarr v11</title>
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg" />
<link rel="manifest" href="/manifest.json" />
<meta name="theme-color" content="#4285f4" />
@@ -80,7 +80,7 @@
<button type="submit" class="btn btn-primary btn-full">Anmelden</button>
</form>
</div>
<button class="impressum-link" onclick="openImpressum()">©&nbsp;2026&nbsp;Scarriffleservices&nbsp;·&nbsp;v18</button>
<button class="impressum-link" onclick="openImpressum()">©&nbsp;2026&nbsp;Scarriffleservices&nbsp;·&nbsp;v11</button>
</div>
<!-- ─── MAIN APP ──────────────────────────────────────────── -->
@@ -199,7 +199,7 @@
<div id="cal-list-items"></div>
</div>
</div>
<button class="sidebar-copyright" onclick="openImpressum()">©&nbsp;2026&nbsp;Scarriffleservices&nbsp;·&nbsp;v18</button>
<button class="sidebar-copyright" onclick="openImpressum()">©&nbsp;2026&nbsp;Scarriffleservices&nbsp;·&nbsp;v11</button>
</aside>
<div id="sidebar-backdrop" class="sidebar-backdrop"></div>
@@ -235,7 +235,7 @@
<input type="hidden" id="ev-start" />
<div class="dt-display" id="ev-start-display" tabindex="0" role="button">
<span class="dt-display-text"></span>
<svg class="dt-display-icon" viewBox="0 0 24 24" fill="currentColor" width="16" height="16"><path d="M19 3h-1V1h-2v2H8V1H6v2H5c-1.1 0-2 .9-2 2v24a2 2 0 002 2h14a2 2 0 002-2V5c0-1.1-.9-2-2-2zm0 16H5V8h14v21zM7 10h5v17H7z"/></svg>
<svg class="dt-display-icon" viewBox="0 0 24 24" fill="currentColor" width="16" height="16"><path d="M19 3h-1V1h-2v2H8V1H6v2H5c-1.1 0-2 .9-2 2v24a2 2 0 002 2h14a2 2 0 002-2V5c0-1.1-.9-2-2-2zm0 16H5V8h14v21zM7 10h5v11H7z"/></svg>
</div>
</div>
<div class="form-group half">
@@ -243,7 +243,7 @@
<input type="hidden" id="ev-end" />
<div class="dt-display" id="ev-end-display" tabindex="0" role="button">
<span class="dt-display-text"></span>
<svg class="dt-display-icon" viewBox="0 0 24 24" fill="currentColor" width="16" height="16"><path d="M19 3h-1V1h-2v2H8V1H6v2H5c-1.1 0-2 .9-2 2v24a2 2 0 002 2h14a2 2 0 002-2V5c0-1.1-.9-2-2-2zm0 16H5V8h14v21zM7 10h5v17H7z"/></svg>
<svg class="dt-display-icon" viewBox="0 0 24 24" fill="currentColor" width="16" height="16"><path d="M19 3h-1V1h-2v2H8V1H6v2H5c-1.1 0-2 .9-2 2v24a2 2 0 002 2h14a2 2 0 002-2V5c0-1.1-.9-2-2-2zm0 16H5V8h14v21zM7 10h5v11H7z"/></svg>
</div>
</div>
</div>
@@ -253,7 +253,7 @@
<input type="hidden" id="ev-start-date" />
<div class="dt-display" id="ev-start-date-display" tabindex="0" role="button">
<span class="dt-display-text"></span>
<svg class="dt-display-icon" viewBox="0 0 24 24" fill="currentColor" width="16" height="16"><path d="M19 3h-1V1h-2v2H8V1H6v2H5c-1.1 0-2 .9-2 2v24a2 2 0 002 2h14a2 2 0 002-2V5c0-1.1-.9-2-2-2zm0 16H5V8h14v21zM7 10h5v17H7z"/></svg>
<svg class="dt-display-icon" viewBox="0 0 24 24" fill="currentColor" width="16" height="16"><path d="M19 3h-1V1h-2v2H8V1H6v2H5c-1.1 0-2 .9-2 2v24a2 2 0 002 2h14a2 2 0 002-2V5c0-1.1-.9-2-2-2zm0 16H5V8h14v21zM7 10h5v11H7z"/></svg>
</div>
</div>
<div class="form-group half">
@@ -261,7 +261,7 @@
<input type="hidden" id="ev-end-date" />
<div class="dt-display" id="ev-end-date-display" tabindex="0" role="button">
<span class="dt-display-text"></span>
<svg class="dt-display-icon" viewBox="0 0 24 24" fill="currentColor" width="16" height="16"><path d="M19 3h-1V1h-2v2H8V1H6v2H5c-1.1 0-2 .9-2 2v24a2 2 0 002 2h14a2 2 0 002-2V5c0-1.1-.9-2-2-2zm0 16H5V8h14v21zM7 10h5v17H7z"/></svg>
<svg class="dt-display-icon" viewBox="0 0 24 24" fill="currentColor" width="16" height="16"><path d="M19 3h-1V1h-2v2H8V1H6v2H5c-1.1 0-2 .9-2 2v24a2 2 0 002 2h14a2 2 0 002-2V5c0-1.1-.9-2-2-2zm0 16H5V8h14v21zM7 10h5v11H7z"/></svg>
</div>
</div>
</div>
@@ -311,7 +311,7 @@
<input type="hidden" id="ev-rec-until" />
<div class="dt-display" id="ev-rec-until-display" tabindex="0" role="button">
<span class="dt-display-text"></span>
<svg class="dt-display-icon" viewBox="0 0 24 24" fill="currentColor" width="16" height="16"><path d="M19 3h-1V1h-2v2H8V1H6v2H5c-1.1 0-2 .9-2 2v24a2 2 0 002 2h14a2 2 0 002-2V5c0-1.1-.9-2-2-2zm0 16H5V8h14v21zM7 10h5v17H7z"/></svg>
<svg class="dt-display-icon" viewBox="0 0 24 24" fill="currentColor" width="16" height="16"><path d="M19 3h-1V1h-2v2H8V1H6v2H5c-1.1 0-2 .9-2 2v24a2 2 0 002 2h14a2 2 0 002-2V5c0-1.1-.9-2-2-2zm0 16H5V8h14v21zM7 10h5v11H7z"/></svg>
</div>
</div>
</div>
@@ -375,20 +375,16 @@
<div class="popup-header">
<div class="popup-color-dot" id="popup-color-dot"></div>
<h4 id="popup-title"></h4>
<div class="popup-toolbar">
<button class="popup-icon-btn" id="popup-edit" title="Bearbeiten" aria-label="Bearbeiten">
<svg width="15" height="15" viewBox="0 0 24 24" fill="currentColor"><path d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04c.39-.39.39-1.02 0-1.41l-2.34-2.34c-.39-.39-1.02-.39-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z"/></svg>
</button>
<button class="popup-icon-btn" id="popup-copy" title="Kopieren" aria-label="Kopieren">
<svg width="15" height="15" viewBox="0 0 24 24" fill="currentColor"><path d="M16 1H4c-1.1 0-2 .9-2 2v14h2V3h12V1zm3 4H8c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h11c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm0 16H8V7h11v14z"/></svg>
</button>
<button class="popup-icon-btn popup-icon-btn-danger" id="popup-delete" title="Löschen" aria-label="Löschen">
<svg width="15" height="15" viewBox="0 0 24 24" fill="currentColor"><path d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z"/></svg>
</button>
<button class="popup-icon-btn popup-icon-btn-close" id="popup-close" title="Schließen" aria-label="Schließen">
<svg width="15" height="15" viewBox="0 0 24 24" fill="currentColor"><path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></svg>
</button>
</div>
<button class="icon-btn popup-action" id="popup-edit" title="Bearbeiten">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04c.39-.39.39-1.02 0-1.41l-2.34-2.34c-.39-.39-1.02-.39-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z"/></svg>
</button>
<button class="icon-btn popup-action" id="popup-copy" title="Kopieren nach…">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M16 1H4c-1.1 0-2 .9-2 2v24h2V3h12V1zm3 4H8c-1.1 0-2 .9-2 2v24c0 1.1.9 2 2 2h11c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm0 16H8V7h11v24z"/></svg>
</button>
<button class="icon-btn popup-action" id="popup-delete" title="Löschen">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v22zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z"/></svg>
</button>
<button class="icon-btn popup-close" id="popup-close">&times;</button>
</div>
<div class="popup-body">
<div class="popup-time" id="popup-time"></div>
@@ -622,29 +618,22 @@
</div>
</div>
<div class="form-group">
<label data-i18n="settings_text_color">Schriftfarbe</label>
<div class="ev-color-row">
<input type="text" id="cfg-text-color-hex" class="ev-color-hex" maxlength="7" spellcheck="false" placeholder="auto" />
<div class="ev-color-preview" id="cfg-text-color-preview" data-i18n-title="color_pick" title="Farbe wählen"></div>
<button type="button" class="btn btn-ghost btn-sm" id="cfg-text-color-reset" data-i18n="reset" title="Zurücksetzen">Reset</button>
</div>
<h4 class="panel-title" style="margin-top:24px" data-i18n="settings_text_contrast">Schriftkontrast</h4>
<p class="panel-desc" data-i18n="settings_text_contrast_desc">Helligkeit der Beschriftungen und Texte</p>
<div class="contrast-selector" id="cfg-text-contrast" data-setting="text_contrast">
<button class="contrast-btn" data-val="1"><span style="color:#606070">Aa</span><span class="contrast-lbl" data-i18n="contrast_dark">Dunkel</span></button>
<button class="contrast-btn" data-val="2"><span style="color:#9090a8">Aa</span><span class="contrast-lbl" data-i18n="contrast_medium">Mittel</span></button>
<button class="contrast-btn" data-val="3"><span style="color:#c8c8d8">Aa</span><span class="contrast-lbl" data-i18n="contrast_light">Hell</span></button>
<button class="contrast-btn" data-val="4"><span style="color:#ffffff">Aa</span><span class="contrast-lbl" data-i18n="contrast_max">Maximum</span></button>
</div>
<div class="form-group">
<label data-i18n="settings_line_color">Linienfarbe</label>
<div class="ev-color-row">
<input type="text" id="cfg-line-color-hex" class="ev-color-hex" maxlength="7" spellcheck="false" placeholder="auto" />
<div class="ev-color-preview" id="cfg-line-color-preview" data-i18n-title="color_pick" title="Farbe wählen"></div>
<button type="button" class="btn btn-ghost btn-sm" id="cfg-line-color-reset" data-i18n="reset" title="Zurücksetzen">Reset</button>
</div>
</div>
<div class="form-group">
<label data-i18n="settings_bg_color">Hintergrundfarbe</label>
<div class="ev-color-row">
<input type="text" id="cfg-bg-color-hex" class="ev-color-hex" maxlength="7" spellcheck="false" placeholder="auto" />
<div class="ev-color-preview" id="cfg-bg-color-preview" data-i18n-title="color_pick" title="Farbe wählen"></div>
<button type="button" class="btn btn-ghost btn-sm" id="cfg-bg-color-reset" data-i18n="reset" title="Zurücksetzen">Reset</button>
</div>
<h4 class="panel-title" style="margin-top:24px" data-i18n="settings_line_contrast">Linienkontrast</h4>
<p class="panel-desc" data-i18n="settings_line_contrast_desc">Sichtbarkeit von Trennlinien und Rahmen</p>
<div class="contrast-selector" id="cfg-line-contrast" data-setting="line_contrast">
<button class="contrast-btn" data-val="1"><span class="line-preview" style="border-color:#1e1e2c"></span><span class="contrast-lbl" data-i18n="line_barely">Kaum</span></button>
<button class="contrast-btn" data-val="2"><span class="line-preview" style="border-color:#2a2a3c"></span><span class="contrast-lbl" data-i18n="line_subtle">Subtil</span></button>
<button class="contrast-btn" data-val="3"><span class="line-preview" style="border-color:#3a3a52"></span><span class="contrast-lbl" data-i18n="line_normal">Normal</span></button>
<button class="contrast-btn" data-val="4"><span class="line-preview" style="border-color:#5a5a78"></span><span class="contrast-lbl" data-i18n="line_strong">Stark</span></button>
</div>
<h4 class="panel-title" style="margin-top:24px" data-i18n="settings_calendar_view">Kalenderansicht</h4>
@@ -895,7 +884,7 @@
<a href="mailto:scarriffleservices@gmail.com">scarriffleservices@gmail.com</a></p>
</div>
<div class="modal-footer" style="justify-content:space-between;align-items:center">
<span style="font-size:12px;color:var(--text-3)">Calendarr v18</span>
<span style="font-size:12px;color:var(--text-3)">Calendarr v11</span>
<button class="btn btn-ghost" onclick="closeImpressum()">Schliessen</button>
</div>
</div>

View File

@@ -40,38 +40,6 @@ let state = {
selectedEventColor: '', // '' = use calendar color
};
// ── URL state ────────────────────────────────────────────
// View + Date werden in der URL als #date=YYYY-MM-DD&view=<view> gespiegelt,
// damit Reload/PWA-restore den letzten Stand wiederherstellen statt auf
// heute zu springen.
const VALID_VIEWS = ['month', 'week', 'day', 'quarter', 'agenda'];
function readUrlState() {
const hash = window.location.hash.replace(/^#/, '');
if (!hash) return {};
const params = new URLSearchParams(hash);
const out = {};
const view = params.get('view');
if (view && VALID_VIEWS.includes(view)) out.view = view;
const date = params.get('date');
if (date && /^\d{4}-\d{2}-\d{2}$/.test(date)) {
const d = new Date(date + 'T00:00:00');
if (!isNaN(d.getTime())) out.date = d;
}
return out;
}
function writeUrlState() {
const d = state.currentDate;
const dateStr = `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`;
const newHash = `date=${dateStr}&view=${state.currentView}`;
if (window.location.hash.replace(/^#/,'') !== newHash) {
// replaceState statt pushState: prev/next-Klicks sollen nicht jeden
// einzelnen Tag in den Browser-History-Stack drücken
window.history.replaceState(null, '', '#' + newHash);
}
}
// ── Public init ───────────────────────────────────────────
export async function initCalendar() {
const [settings, accounts, localCalendars, icalSubscriptions, googleAccounts, haAccounts] = await Promise.all([
@@ -93,11 +61,6 @@ export async function initCalendar() {
state.dimPast = settings.dim_past_events;
weekStartDay = settings.week_start_day || 'monday';
// URL state takes precedence over defaults (settings + today)
const urlState = readUrlState();
if (urlState.date) state.currentDate = urlState.date;
if (urlState.view) state.currentView = urlState.view;
setLang(settings.language || 'de');
applyTheme(settings);
updateViewButtons();
@@ -115,25 +78,6 @@ export async function initCalendar() {
bindProfileModal();
bindSwipeNavigation();
handleHAOAuthReturn();
<<<<<<< HEAD
// Browser-Back/Forward: URL-Hash → State synchronisieren
window.addEventListener('hashchange', () => {
const u = readUrlState();
let changed = false;
if (u.view && u.view !== state.currentView) {
state.currentView = u.view;
updateViewButtons();
changed = true;
}
if (u.date && !isSameDay(u.date, state.currentDate)) {
state.currentDate = u.date;
changed = true;
}
if (changed) fetchAndRender();
});
=======
>>>>>>> e744b1829e99db6b80922f75542ced329138e474
}
function handleHAOAuthReturn() {
@@ -147,11 +91,7 @@ function handleHAOAuthReturn() {
};
if (params.has('ha_connected')) {
showToast('Home Assistant verbunden');
<<<<<<< HEAD
window.history.replaceState({}, '', window.location.pathname + window.location.hash);
=======
window.history.replaceState({}, '', window.location.pathname);
>>>>>>> e744b1829e99db6b80922f75542ced329138e474
fetchAndRender(true);
api.get('/homeassistant/accounts').then(accs => {
state.haAccounts = accs || [];
@@ -161,11 +101,7 @@ function handleHAOAuthReturn() {
} else if (params.has('ha_error')) {
const code = params.get('ha_error');
showToast(errMap[code] || `HA-Anmeldung fehlgeschlagen: ${code}`, true);
<<<<<<< HEAD
window.history.replaceState({}, '', window.location.pathname + window.location.hash);
=======
window.history.replaceState({}, '', window.location.pathname);
>>>>>>> e744b1829e99db6b80922f75542ced329138e474
}
}
@@ -260,10 +196,6 @@ async function fetchAndRender(force = false, silent = false) {
renderView();
updateTitle();
renderMiniCal();
<<<<<<< HEAD
writeUrlState();
=======
>>>>>>> e744b1829e99db6b80922f75542ced329138e474
prefetchIfNeeded(start, end); // extend cache in background if approaching an edge
return;
}
@@ -292,7 +224,6 @@ async function fetchAndRender(force = false, silent = false) {
renderView();
updateTitle();
renderMiniCal();
writeUrlState();
}
function getViewRange() {
@@ -2045,24 +1976,6 @@ function openSettingsModal() {
document.getElementById(id + '-hex').value = val.toUpperCase();
document.getElementById(id + '-preview').style.background = val;
});
// Optional colour overrides — empty hex input means "auto"
[
{ id: 'cfg-text-color', val: s.text_color },
{ id: 'cfg-line-color', val: s.line_color },
{ id: 'cfg-bg-color', val: s.bg_color },
].forEach(({ id, val }) => {
const hex = document.getElementById(id + '-hex');
const prev = document.getElementById(id + '-preview');
if (!hex || !prev) return;
if (val) {
hex.value = String(val).toUpperCase();
prev.style.background = val;
} else {
hex.value = '';
prev.style.background = 'transparent';
}
});
document.getElementById('cfg-dim-past').checked = !!s.dim_past_events;
document.getElementById('cfg-language').value = getLang();
@@ -2394,32 +2307,6 @@ function bindSettingsModal() {
});
});
// Optional override colours (text / line / background) — empty = use default
[
{ prefix: 'cfg-text-color', defaultColor: '#c8c8d8' },
{ prefix: 'cfg-line-color', defaultColor: '#3a3a52' },
{ prefix: 'cfg-bg-color', defaultColor: '#0e0e14' },
].forEach(({ prefix, defaultColor }) => {
const preview = document.getElementById(prefix + '-preview');
const hex = document.getElementById(prefix + '-hex');
const reset = document.getElementById(prefix + '-reset');
if (!preview || !hex || !reset) return;
preview.addEventListener('click', async () => {
const picked = await openColorPicker(preview, hex.value || defaultColor);
if (picked) { hex.value = picked.toUpperCase(); preview.style.background = picked; }
});
hex.addEventListener('change', () => {
let val = hex.value.trim();
if (!val) { preview.style.background = 'transparent'; return; }
if (!val.startsWith('#')) val = '#' + val;
if (/^#[0-9a-fA-F]{6}$/.test(val)) { hex.value = val.toUpperCase(); preview.style.background = val; }
});
reset.addEventListener('click', () => {
hex.value = '';
preview.style.background = 'transparent';
});
});
// Panel navigation
document.querySelectorAll('.settings-nav-btn').forEach(btn => {
btn.addEventListener('click', () => activateSettingsPanel(btn.dataset.panel));
@@ -2459,11 +2346,6 @@ function bindSettingsModal() {
const btn = document.querySelector(`#${id} .contrast-btn.active`);
return btn ? Number(btn.dataset.val) : null;
};
// Optional override colours: empty input → null (use default)
const colourOrNull = (id) => {
const v = (document.getElementById(id).value || '').trim();
return /^#[0-9a-fA-F]{6}$/.test(v) ? v : null;
};
const settings = {
default_view: document.getElementById('cfg-default-view').value,
week_start_day: document.getElementById('cfg-week-start').value,
@@ -2472,10 +2354,9 @@ function bindSettingsModal() {
today_color: document.getElementById('cfg-today-hex').value,
month_divider_color: document.getElementById('cfg-month-divider-hex').value,
month_label_color: document.getElementById('cfg-month-label-hex').value,
text_color: colourOrNull('cfg-text-color-hex'),
line_color: colourOrNull('cfg-line-color-hex'),
bg_color: colourOrNull('cfg-bg-color-hex'),
dim_past_events: document.getElementById('cfg-dim-past').checked,
text_contrast: getActive('cfg-text-contrast') || 3,
line_contrast: getActive('cfg-line-contrast') || 3,
hour_height: getActive('cfg-hour-height') || 44,
language: document.getElementById('cfg-language').value,
};

View File

@@ -67,10 +67,6 @@ const translations = {
settings_today_color: 'Heutige-Tag-Farbe',
settings_month_divider_color: 'Monatswechsel-Linie',
settings_month_label_color: 'Monatskürzel-Farbe',
settings_text_color: 'Schriftfarbe',
settings_line_color: 'Linienfarbe',
settings_bg_color: 'Hintergrundfarbe',
reset: 'Reset',
settings_text_contrast: 'Schriftkontrast',
settings_text_contrast_desc: 'Helligkeit der Beschriftungen und Texte',
contrast_dark: 'Dunkel', contrast_medium: 'Mittel',
@@ -158,11 +154,7 @@ const translations = {
rec_every: 'Alle', rec_days: 'Tage', rec_weeks: 'Wochen', rec_months: 'Monate',
rec_ends: 'Endet', rec_never: 'Nie', rec_after_count: 'Nach Anzahl',
rec_on_date: 'Am Datum', rec_occurrences: 'Termine',
<<<<<<< HEAD
copy_to_calendar: 'Kopieren nach…', event_copied: 'Termin kopiert', copy: 'Kopieren',
=======
copy_to_calendar: 'Kopieren nach…', event_copied: 'Termin kopiert',
>>>>>>> e744b1829e99db6b80922f75542ced329138e474
edit_before_copy: 'Vor dem Kopieren bearbeiten',
event_updated: 'Termin aktualisiert', event_created: 'Termin erstellt',
confirm_delete_event: '"{title}" wirklich löschen?',
@@ -286,10 +278,6 @@ const translations = {
settings_today_color: 'Today highlight color',
settings_month_divider_color: 'Month divider line',
settings_month_label_color: 'Month label color',
settings_text_color: 'Text color',
settings_line_color: 'Line color',
settings_bg_color: 'Background color',
reset: 'Reset',
settings_text_contrast: 'Text contrast',
settings_text_contrast_desc: 'Brightness of labels and text',
contrast_dark: 'Dark', contrast_medium: 'Medium',
@@ -377,11 +365,7 @@ const translations = {
rec_every: 'Every', rec_days: 'days', rec_weeks: 'weeks', rec_months: 'months',
rec_ends: 'Ends', rec_never: 'Never', rec_after_count: 'After count',
rec_on_date: 'On date', rec_occurrences: 'occurrences',
<<<<<<< HEAD
copy_to_calendar: 'Copy to…', event_copied: 'Event copied', copy: 'Copy',
=======
copy_to_calendar: 'Copy to…', event_copied: 'Event copied',
>>>>>>> e744b1829e99db6b80922f75542ced329138e474
edit_before_copy: 'Edit before copying',
event_updated: 'Event updated', event_created: 'Event created',
confirm_delete_event: 'Really delete "{title}"?',
@@ -458,26 +442,15 @@ export function t(key, vars = {}) {
return val.replace(/\{(\w+)\}/g, (_, k) => vars[k] ?? '');
}
// Look up a translation but return null if the key is undefined in both
// the current language and German. Lets callers fall back to the existing
// HTML default rather than displaying the raw key.
function tOrNull(key) {
const dict = translations[currentLang] ?? translations.de;
const val = dict[key] ?? translations.de[key];
return typeof val === 'string' ? val : null;
}
export function applyLang() {
document.querySelectorAll('[data-i18n]').forEach(el => {
const v = tOrNull(el.dataset.i18n);
if (v != null) el.textContent = v;
const v = t(el.dataset.i18n);
if (typeof v === 'string') el.textContent = v;
});
document.querySelectorAll('[data-i18n-ph]').forEach(el => {
const v = tOrNull(el.dataset.i18nPh);
if (v != null) el.placeholder = v;
el.placeholder = t(el.dataset.i18nPh);
});
document.querySelectorAll('[data-i18n-title]').forEach(el => {
const v = tOrNull(el.dataset.i18nTitle);
if (v != null) el.title = v;
el.title = t(el.dataset.i18nTitle);
});
}

View File

@@ -83,47 +83,14 @@ export function applyTheme(settings) {
root.style.setProperty('--accent', settings.accent_color || '#ea4335');
root.style.setProperty('--today-color', settings.today_color || '#4285f4');
// Text colour: a custom hex (settings.text_color) wins over the legacy
// 14 contrast step. We derive --text-2/--text-3 by darkening the
// chosen colour so the secondary/tertiary text stays in the same hue.
if (settings.text_color) {
root.style.setProperty('--text-1', settings.text_color);
root.style.setProperty('--text-2', shadeHex(settings.text_color, -0.25));
root.style.setProperty('--text-3', shadeHex(settings.text_color, -0.55));
} else {
const tc = TEXT_CONTRAST[settings.text_contrast || 3];
root.style.setProperty('--text-1', tc.t1);
root.style.setProperty('--text-2', tc.t2);
root.style.setProperty('--text-3', tc.t3);
}
const tc = TEXT_CONTRAST[settings.text_contrast || 3];
root.style.setProperty('--text-1', tc.t1);
root.style.setProperty('--text-2', tc.t2);
root.style.setProperty('--text-3', tc.t3);
// Line colour: custom hex overrides the legacy contrast step.
if (settings.line_color) {
root.style.setProperty('--border', settings.line_color);
root.style.setProperty('--border-light', shadeHex(settings.line_color, -0.25));
} else {
const lc = LINE_CONTRAST[settings.line_contrast || 3];
root.style.setProperty('--border', lc.border);
root.style.setProperty('--border-light', lc.light);
}
// Background colour: optional. If set, also tint the topbar/sidebar
// and surface variants so the whole UI stays coherent.
if (settings.bg_color) {
root.style.setProperty('--bg-app', settings.bg_color);
root.style.setProperty('--bg-topbar', shadeHex(settings.bg_color, 0.10));
root.style.setProperty('--bg-sidebar', shadeHex(settings.bg_color, 0.10));
root.style.setProperty('--bg-surface', shadeHex(settings.bg_color, 0.18));
root.style.setProperty('--bg-hover', shadeHex(settings.bg_color, 0.26));
root.style.setProperty('--bg-active', shadeHex(settings.bg_color, 0.40));
} else {
root.style.removeProperty('--bg-app');
root.style.removeProperty('--bg-topbar');
root.style.removeProperty('--bg-sidebar');
root.style.removeProperty('--bg-surface');
root.style.removeProperty('--bg-hover');
root.style.removeProperty('--bg-active');
}
const lc = LINE_CONTRAST[settings.line_contrast || 3];
root.style.setProperty('--border', lc.border);
root.style.setProperty('--border-light', lc.light);
const hh = settings.hour_height || 44;
root.style.setProperty('--hour-h', hh + 'px');
@@ -138,24 +105,3 @@ function hexToRgba(hex, alpha) {
const b = parseInt(hex.slice(5,7), 16);
return `rgba(${r},${g},${b},${alpha})`;
}
// Brighten (positive amount) or darken (negative) a hex colour.
// Used to derive supporting shades (sidebar bg, hover bg, secondary text…)
// from a single user-picked colour so the whole UI stays in the same family.
function shadeHex(hex, amount) {
let r = parseInt(hex.slice(1,3), 16);
let g = parseInt(hex.slice(3,5), 16);
let b = parseInt(hex.slice(5,7), 16);
if (amount >= 0) {
r = Math.round(r + (255 - r) * amount);
g = Math.round(g + (255 - g) * amount);
b = Math.round(b + (255 - b) * amount);
} else {
const a = 1 + amount; // amount is negative: e.g. -0.25 → keep 75%
r = Math.round(r * a);
g = Math.round(g * a);
b = Math.round(b * a);
}
const h = n => Math.max(0, Math.min(255, n)).toString(16).padStart(2, '0');
return '#' + h(r) + h(g) + h(b);
}

View File

@@ -1,2 +1,2 @@
// Increment APP_VERSION with every code change
export const APP_VERSION = 'v18';
export const APP_VERSION = 'v11';

View File

@@ -63,24 +63,8 @@ export function renderWeek(container, currentDate, events, onSlotClick, onEventC
const color = ev.color || ev.calendarColor || '#4285f4';
const pastCls = isPast(ev) ? 'past' : '';
const multiCls = isMultiTimed ? 'multiday-timed' : '';
<<<<<<< HEAD
// continues-left/right: compute on date-only basis for all-day events
let evStart = new Date(ev.start);
let evEnd = new Date(ev.end);
if (ev.allDay) {
evStart.setHours(0, 0, 0, 0);
evEnd.setHours(0, 0, 0, 0);
if (evEnd > evStart) evEnd.setDate(evEnd.getDate() - 1);
}
const firstDay = new Date(days[0]); firstDay.setHours(0, 0, 0, 0);
const lastDayMidnight = new Date(days[n-1]); lastDayMidnight.setHours(24, 0, 0, 0);
const lastDay = new Date(days[n-1]); lastDay.setHours(0, 0, 0, 0);
const cL = evStart < firstDay ? 'continues-left' : '';
const cR = (ev.allDay ? evEnd > lastDay : evEnd > lastDayMidnight) ? 'continues-right' : '';
=======
const cL = new Date(ev.start) < new Date(days[0]) ? 'continues-left' : '';
const cR = new Date(ev.end) > (() => { const d = new Date(days[n-1]); d.setHours(24,0,0,0); return d; })() ? 'continues-right' : '';
>>>>>>> e744b1829e99db6b80922f75542ced329138e474
const label = isMultiTimed && isSameDay(new Date(ev.start), days[colStart])
? `${fmtTime(new Date(ev.start))} ${ev.title}`
: ev.title;
@@ -252,36 +236,11 @@ function renderNowLine(container, days, hourH = 60) {
function layoutWeekAllDay(evs, days) {
const items = [];
evs.forEach(ev => {
<<<<<<< HEAD
// For all-day events, normalize to date-only with inclusive end-day
// (iCal stores exclusive end → subtract 1). For timed events, keep
// the original strict-overlap logic so events ending exactly at
// midnight don't bleed into the next day.
let ns, ne;
if (ev.allDay) {
ns = new Date(ev.start); ns.setHours(0, 0, 0, 0);
ne = new Date(ev.end); ne.setHours(0, 0, 0, 0);
if (ne > ns) ne.setDate(ne.getDate() - 1);
}
let colStart = -1, colEnd = -1;
days.forEach((day, i) => {
const ds = new Date(day); ds.setHours(0, 0, 0, 0);
let matches;
if (ev.allDay) {
matches = ds >= ns && ds <= ne;
} else {
const de = new Date(day); de.setHours(24, 0, 0, 0);
matches = new Date(ev.start) < de && new Date(ev.end) > ds;
}
if (matches) {
=======
let colStart = -1, colEnd = -1;
days.forEach((day, i) => {
const ds = new Date(day); ds.setHours(0, 0, 0, 0);
const de = new Date(day); de.setHours(24, 0, 0, 0);
if (new Date(ev.start) < de && new Date(ev.end) > ds) {
>>>>>>> e744b1829e99db6b80922f75542ced329138e474
if (colStart === -1) colStart = i;
colEnd = i;
}

View File

@@ -1,21 +1,39 @@
// Calendarr Service Worker — minimal-cache strategy
//
// Strategy: network-first for everything. The cache is only used as a
// last-resort fallback when offline (so the app shell still opens). This
// means every online request hits the network and respects the
// server's Cache-Control headers (≤ 2h for static assets, no-cache for
// the entry HTML / version files). New releases take effect on the next
// reload, no manual SW unregister required.
// Calendarr Service Worker
// Cache-first for static assets, network-first for /api/* (graceful offline)
const CACHE_VERSION = 'calendarr-v18';
const OFFLINE_SHELL = ['/', '/index.html'];
const CACHE_VERSION = 'calendarr-v11';
const STATIC_ASSETS = [
'/',
'/index.html',
'/manifest.json',
'/static/css/app.css',
'/static/favicon.svg',
'/static/js/app.js',
'/static/js/api.js',
'/static/js/calendar.js',
'/static/js/color-picker.js',
'/static/js/date-picker.js',
'/static/js/i18n.js',
'/static/js/utils.js',
'/static/js/version.js',
'/static/js/views/agenda.js',
'/static/js/views/month.js',
'/static/js/views/quarter.js',
'/static/js/views/week.js',
'/icons/icon-192.png',
'/icons/icon-512.png',
'/icons/icon.svg',
];
self.addEventListener('install', event => {
event.waitUntil(
caches.open(CACHE_VERSION).then(cache =>
Promise.all(OFFLINE_SHELL.map(url =>
cache.add(url).catch(err => console.warn('[SW] skip', url, err))
))
// Use addAll with a fallback so a single missing file doesn't abort install
Promise.all(
STATIC_ASSETS.map(url =>
cache.add(url).catch(err => console.warn('[SW] skip', url, err))
)
)
).then(() => self.skipWaiting())
);
});
@@ -34,8 +52,7 @@ self.addEventListener('fetch', event => {
const url = new URL(req.url);
// API routes: always go to the network, no offline fallback (we'd just
// be returning stale account/event data otherwise).
// Network-first for API routes — fail silently if offline
if (url.pathname.startsWith('/api/')) {
event.respondWith(
fetch(req).catch(() =>
@@ -48,29 +65,45 @@ self.addEventListener('fetch', event => {
return;
}
// Everything else: network-first. The browser's HTTP cache (driven by
// the server's Cache-Control headers) already throttles re-fetches —
// the SW just makes sure offline still works for the entry HTML.
// Network-first for navigation (HTML) and the version-defining files —
// ensures users always get the freshest entry point so new releases
// take effect on the next reload without a manual SW unregister.
const isHtml = req.mode === 'navigate'
|| url.pathname === '/'
|| url.pathname === '/index.html';
const isVersionFile = url.pathname === '/static/js/version.js';
if (isHtml || isVersionFile) {
event.respondWith(
fetch(req).then(resp => {
if (resp && resp.status === 200) {
const clone = resp.clone();
caches.open(CACHE_VERSION).then(c => c.put(req, clone)).catch(() => {});
}
return resp;
}).catch(() =>
caches.match(req).then(c => c || caches.match('/index.html'))
)
);
return;
}
// Cache-first for everything else (static)
event.respondWith(
fetch(req).then(resp => {
// Keep a fresh copy of navigation requests / index.html for offline
const isNavigation = req.mode === 'navigate'
|| url.pathname === '/'
|| url.pathname === '/index.html';
if (isNavigation && resp && resp.status === 200) {
const clone = resp.clone();
caches.open(CACHE_VERSION).then(c => c.put(req, clone)).catch(() => {});
}
return resp;
}).catch(() => {
// Offline fallback: only the HTML shell is served from cache, so the
// app at least renders and can show its own offline UI.
if (req.mode === 'navigate'
|| url.pathname === '/'
|| url.pathname === '/index.html') {
return caches.match(req).then(c => c || caches.match('/index.html'));
}
return new Response('', { status: 503 });
caches.match(req).then(cached => {
if (cached) return cached;
return fetch(req).then(resp => {
// Only cache successful, basic-origin responses
if (resp && resp.status === 200 && resp.type === 'basic') {
const clone = resp.clone();
caches.open(CACHE_VERSION).then(c => c.put(req, clone)).catch(() => {});
}
return resp;
}).catch(() => {
// Offline fallback for navigation requests
if (req.mode === 'navigate') return caches.match('/index.html');
return new Response('', { status: 503 });
});
})
);
});