Compare commits
73 Commits
254adfa12a
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
58faf3876c | ||
|
|
639d7f3c9c | ||
|
|
a60c27f66f | ||
|
|
e744b1829e | ||
|
|
f09b5e7c48 | ||
|
|
8a34618453 | ||
|
|
64d499647d | ||
|
|
b120d9d430 | ||
|
|
371678aac4 | ||
|
|
6503d18637 | ||
|
|
ebe250ca01 | ||
|
|
f4bcdf458b | ||
|
|
e0a61b7368 | ||
|
|
7cabfb10de | ||
|
|
b9691ea209 | ||
|
|
49b1935a28 | ||
|
|
3d7779ae83 | ||
|
|
6c7c8a4662 | ||
|
|
0aeb421970 | ||
|
|
29fef6ea77 | ||
|
|
dd18a0b594 | ||
|
|
4aaf6672f7 | ||
|
|
ac5996693f | ||
|
|
0e6672b909 | ||
|
|
8d95dd0b97 | ||
|
|
e99f91dcf3 | ||
|
|
64f8b901dd | ||
|
|
c61d7fd698 | ||
|
|
b803d4bf4c | ||
|
|
d942b82e1d | ||
|
|
a700bc5350 | ||
|
|
9ae247c7c5 | ||
|
|
4b6839f1ff | ||
|
|
aee9689d46 | ||
|
|
3351263c85 | ||
|
|
20e98e660a | ||
|
|
134b238dea | ||
|
|
d4ea097831 | ||
|
|
e3984eb5cf | ||
|
|
58c7cbc38c | ||
|
|
3d4fdb3f8f | ||
|
|
c03af1b7ea | ||
|
|
c5c6a5f71b | ||
|
|
69f5789e2d | ||
|
|
978ad55af4 | ||
|
|
d6e67a97c8 | ||
|
|
5c7a74e221 | ||
|
|
f28aa706e7 | ||
|
|
5a7d8ad362 | ||
|
|
d1d1135e32 | ||
|
|
4c8face22a | ||
|
|
7070e23cc6 | ||
|
|
240b7af1c8 | ||
| 804d6ac9eb | |||
| 377a24eac6 | |||
| 6a25607103 | |||
| f50f5fa1e1 | |||
| 8fc3472b1c | |||
| fce162693c | |||
| e317b799d0 | |||
| 77936b3b8d | |||
| cae39e6086 | |||
| b40e8c6731 | |||
| ea7442db32 | |||
| bda4a75a11 | |||
| ba73bde353 | |||
|
|
0b4060beae | ||
|
|
d8ec22d573 | ||
|
|
faada7359e | ||
|
|
e9bc56e857 | ||
|
|
b268e88d84 | ||
|
|
7f92e0423c | ||
|
|
cd4879d573 |
@@ -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
|
||||
@@ -118,34 +113,6 @@ _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"])
|
||||
|
||||
@@ -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;
|
||||
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,
|
||||
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;
|
||||
}
|
||||
.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,75 +924,26 @@ a { color: var(--primary); text-decoration: none; }
|
||||
}
|
||||
.ctx-item:hover { background: var(--bg-hover); }
|
||||
|
||||
/* ── 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 ────────────────────────────────────────── */
|
||||
.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; }
|
||||
@@ -1157,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 {
|
||||
@@ -1285,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;
|
||||
@@ -1697,12 +1597,15 @@ a { color: var(--primary); text-decoration: none; }
|
||||
.topbar-left { gap: 0; }
|
||||
.topbar-right { gap: 0; }
|
||||
|
||||
/* 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;
|
||||
min-height: 32px !important;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
}
|
||||
.event-popup .popup-header { gap: 2px; padding: 10px 12px; }
|
||||
.event-popup { width: min(92vw, 340px); max-width: 92vw; }
|
||||
|
||||
/* Monatsansicht: Startzeit ausblenden — nur Titel anzeigen ──── */
|
||||
.month-event-time { display: none; }
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<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 v17</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()">© 2026 Scarriffleservices · v17</button>
|
||||
<button class="impressum-link" onclick="openImpressum()">© 2026 Scarriffleservices · v11</button>
|
||||
</div>
|
||||
|
||||
<!-- ─── MAIN APP ──────────────────────────────────────────── -->
|
||||
@@ -185,7 +185,7 @@
|
||||
<span data-i18n="my_calendars">Meine Kalender</span>
|
||||
<div class="add-cal-dropdown-wrap">
|
||||
<button class="icon-btn mini-btn" id="btn-add-cal" title="Kalender hinzufügen">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"/></svg>
|
||||
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M19 13h-6v11h-2v-6H5v-2h6V5h2v11h6v2z"/></svg>
|
||||
</button>
|
||||
<div class="add-cal-dropdown hidden" id="add-cal-dropdown">
|
||||
<button data-action="local">Lokaler Kalender</button>
|
||||
@@ -199,7 +199,7 @@
|
||||
<div id="cal-list-items"></div>
|
||||
</div>
|
||||
</div>
|
||||
<button class="sidebar-copyright" onclick="openImpressum()">© 2026 Scarriffleservices · v17</button>
|
||||
<button class="sidebar-copyright" onclick="openImpressum()">© 2026 Scarriffleservices · 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 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="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 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="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 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="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-close" id="popup-close">×</button>
|
||||
</div>
|
||||
<div class="popup-body">
|
||||
<div class="popup-time" id="popup-time"></div>
|
||||
@@ -888,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 v17</span>
|
||||
<span style="font-size:12px;color:var(--text-3)">Calendarr v11</span>
|
||||
<button class="btn btn-ghost" onclick="closeImpressum()">Schliessen</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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,22 +78,6 @@ export async function initCalendar() {
|
||||
bindProfileModal();
|
||||
bindSwipeNavigation();
|
||||
handleHAOAuthReturn();
|
||||
|
||||
// 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();
|
||||
});
|
||||
}
|
||||
|
||||
function handleHAOAuthReturn() {
|
||||
@@ -144,7 +91,7 @@ function handleHAOAuthReturn() {
|
||||
};
|
||||
if (params.has('ha_connected')) {
|
||||
showToast('Home Assistant verbunden');
|
||||
window.history.replaceState({}, '', window.location.pathname + window.location.hash);
|
||||
window.history.replaceState({}, '', window.location.pathname);
|
||||
fetchAndRender(true);
|
||||
api.get('/homeassistant/accounts').then(accs => {
|
||||
state.haAccounts = accs || [];
|
||||
@@ -154,7 +101,7 @@ function handleHAOAuthReturn() {
|
||||
} else if (params.has('ha_error')) {
|
||||
const code = params.get('ha_error');
|
||||
showToast(errMap[code] || `HA-Anmeldung fehlgeschlagen: ${code}`, true);
|
||||
window.history.replaceState({}, '', window.location.pathname + window.location.hash);
|
||||
window.history.replaceState({}, '', window.location.pathname);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -249,7 +196,6 @@ async function fetchAndRender(force = false, silent = false) {
|
||||
renderView();
|
||||
updateTitle();
|
||||
renderMiniCal();
|
||||
writeUrlState();
|
||||
prefetchIfNeeded(start, end); // extend cache in background if approaching an edge
|
||||
return;
|
||||
}
|
||||
@@ -278,7 +224,6 @@ async function fetchAndRender(force = false, silent = false) {
|
||||
renderView();
|
||||
updateTitle();
|
||||
renderMiniCal();
|
||||
writeUrlState();
|
||||
}
|
||||
|
||||
function getViewRange() {
|
||||
|
||||
@@ -154,7 +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',
|
||||
copy_to_calendar: 'Kopieren nach…', event_copied: 'Termin kopiert', copy: 'Kopieren',
|
||||
copy_to_calendar: 'Kopieren nach…', event_copied: 'Termin kopiert',
|
||||
edit_before_copy: 'Vor dem Kopieren bearbeiten',
|
||||
event_updated: 'Termin aktualisiert', event_created: 'Termin erstellt',
|
||||
confirm_delete_event: '"{title}" wirklich löschen?',
|
||||
@@ -365,7 +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',
|
||||
copy_to_calendar: 'Copy to…', event_copied: 'Event copied', copy: 'Copy',
|
||||
copy_to_calendar: 'Copy to…', event_copied: 'Event copied',
|
||||
edit_before_copy: 'Edit before copying',
|
||||
event_updated: 'Event updated', event_created: 'Event created',
|
||||
confirm_delete_event: 'Really delete "{title}"?',
|
||||
@@ -442,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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
// Increment APP_VERSION with every code change
|
||||
export const APP_VERSION = 'v17';
|
||||
export const APP_VERSION = 'v11';
|
||||
|
||||
@@ -63,19 +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' : '';
|
||||
// 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' : '';
|
||||
const label = isMultiTimed && isSameDay(new Date(ev.start), days[colStart])
|
||||
? `${fmtTime(new Date(ev.start))} ${ev.title}`
|
||||
: ev.title;
|
||||
@@ -247,28 +236,11 @@ function renderNowLine(container, days, hourH = 60) {
|
||||
function layoutWeekAllDay(evs, days) {
|
||||
const items = [];
|
||||
evs.forEach(ev => {
|
||||
// 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) {
|
||||
if (new Date(ev.start) < de && new Date(ev.end) > ds) {
|
||||
if (colStart === -1) colStart = i;
|
||||
colEnd = i;
|
||||
}
|
||||
|
||||
@@ -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-v17';
|
||||
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 =>
|
||||
// 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.
|
||||
event.respondWith(
|
||||
fetch(req).then(resp => {
|
||||
// Keep a fresh copy of navigation requests / index.html for offline
|
||||
const isNavigation = req.mode === 'navigate'
|
||||
// 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';
|
||||
if (isNavigation && resp && resp.status === 200) {
|
||||
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(
|
||||
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: 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'));
|
||||
}
|
||||
// Offline fallback for navigation requests
|
||||
if (req.mode === 'navigate') return caches.match('/index.html');
|
||||
return new Response('', { status: 503 });
|
||||
});
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user