Compare commits

...

19 Commits

Author SHA1 Message Date
Scarriffle
f791160d1a fix: mini-cal marks full multi-day span; short week events show title inline
- Mini calendar: mark every day an event spans (not just the start day) so
  multi-day events (Urlaub/Ferien) show a dot across the whole range
- Week view: render short events on one line (time next to title) so the title
  stays visible; drop the location line when there's no room

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 09:42:02 +02:00
Scarriffle
906241a743 fix: agenda view scrolls to current date on render
The agenda lists the whole cached range, so clicking Today only reset scroll
to the top (earliest past event). Scroll the agenda to the current date (or the
next day with events) after rendering, so Today/mini-cal clicks land correctly.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 17:13:18 +02:00
Scarriffle
2e76e63ba2 fix: event popup — allow toolbar button clicks and text selection while draggable
- Don't start a header drag when the pointer goes down on a button/link, so
  edit/copy/delete/close work again
- Make the popup body text selectable (user-select: text) for copying

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 12:15:38 +02:00
Scarriffle
6e8efb2ed5 feat(sidebar): dedicated share-icon set for group-visible indicator
Replace group-category icons with 8 sharing-specific icons in the
Darstellung picker: share (3 nodes), link, send, eye, upload, wifi,
person_add, ios_share. Uses new shareIconSvg() + SHARE_ICON_KEYS/PATHS
constants, separate from group icons. Default changes from 'people' to 'share'.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 22:02:54 +02:00
Scarriffle
d6159f0639 feat: calendar rename for all sources, group icon in sidebar, configurable share icon
- Rename: Add google and homeassistant branches to the dblclick-rename
  save() handler; backend already accepted name updates for both sources
- Group icon: Pass groupIconForLocalCal(cal.id) into sidebar entries so
  the group's custom icon (home/heart/work/…) shows instead of always
  defaulting to the people icon
- Share icon: New share_calendar_icon field in UserSettings (+ migration)
  replaces the hardcoded SHARE_ICON SVG; users pick from GROUP_ICON_KEYS
  in Settings → Darstellung via a new icon-picker row

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 21:56:24 +02:00
Scarriffle
be3d4057b3 feat(sidebar): distinct share icon for groupVisible, hide scrollbars
- groupVisible calendars get a share icon instead of the people icon;
  people icon is reserved for actual group calendars
- sidebar scrollbar hidden (scrollbar-width: none + webkit)
- modal-body scrollbar hidden — fixes Impressum scroll indicator

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 21:36:36 +02:00
Scarriffle
eb9440c2b2 feat(sidebar): move group icon before calendar name
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 21:30:31 +02:00
Scarriffle
34777c78c9 fix(sidebar): min-height approach for action buttons, no overlap or jump
Instead of absolute positioning (which caused icons to overlay text),
use min-height:40px on .cal-item so the row is always tall enough for
28px mini-btns. Buttons stay in the flex flow with display:none when
hidden — no space reserved, no text truncation, no height shift on hover.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 21:24:57 +02:00
Scarriffle
0427e1189d fix(sidebar): absolute-position action buttons to prevent name truncation
Buttons (.cal-item-remove, .cal-item-bell) are now position:absolute so
they never participate in flex layout — names get full available width at
all times. Buttons fade in over the right edge of the item on hover with
no layout shift and no row-height change.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 21:18:04 +02:00
Scarriffle
c1fdfe72d0 fix: modal scrollbar clips rounded corners + sidebar hover jump (v56)
- modal-card: overflow:hidden + flex column; modal-body scrolls instead,
  so native scrollbar never overlaps the 20px border-radius corners
- cal-item-remove / cal-item-bell: display:none→flex replaced with
  opacity:0/pointer-events:none→1/auto, eliminating reflow on hover

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 20:58:44 +02:00
Scarriffle
6365f99ac0 feat: persist active settings tab in URL hash (v55)
URL gains &stab=<panel> when settings are open (e.g. #...&settings=1&stab=accounts).
Tab switches update the URL; reload reopens settings on the same tab.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 20:52:14 +02:00
Scarriffle
d04e2952cb fix: replace reminders checkbox with bell icon in calendar table (v54)
Bell-on = Benachrichtigungen aktiv, Bell-off (durchgestrichen) = stumm.
Gleiche Logik wie Auge-Icon für Sichtbarkeit.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 20:48:12 +02:00
Scarriffle
c5fd5b4e55 fix: calendar table visibility column uses eye icon instead of checkbox (v53)
Replace misleading "Ausgeblendet" checkbox with eye-open/eye-off icon button.
Sichtbar column: eye-open = shown, eye-off = hidden (dimmed). Clicking toggles.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 20:45:33 +02:00
Scarriffle
d1c5b66230 feat: web settings restructure + popup drag + calendar management table (v52)
- Remove "Ansicht" tab; move Kalenderansicht to top of Darstellung
- Reorder Darstellung: Kalenderansicht → Sprache → Stundenhöhe → Farben
- Move Standard-Termindauer to Profil tab as contrast-button selector
- Replace per-source account sections in Kalender tab with unified
  calendar management table (Name/Herkunft/Ausgeblendet/Benachrichtigungen/
  Export·Import/Sync/Delete) covering all calendar sources
- Add "Konto hinzufügen" buttons (Lokal/CalDAV/iCal/HA/Google) to Kalender tab
- Popup drag-to-move via pointer capture on .popup-header

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 20:36:09 +02:00
Scarriffle
2c59d873f3 fix: UTF-8 encoding for iCal subscriptions + default_event_duration_minutes setting
Force resp.encoding='utf-8' in _fetch_ics so umlauts (ö/ä/ü) from sources
like Ferienwiki.de are decoded correctly instead of showing as ö/ä/ü.

Add default_event_duration_minutes to UserSettings (nullable int, same pattern
as default_reminder_minutes) with DB migration, GET/PUT in settings_router.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 19:34:43 +02:00
Scarriffle
c515e9d7e1 feat: safe HTML descriptions, custom reminder picker, synced default event duration
- Render event descriptions as sanitized HTML (links/line breaks) instead of
  raw escaped text; no script execution
- Reminder picker: presets + custom number+unit (minutes/hours/days/weeks)
- Grey out + hint the reminder editor when a calendar's notifications are off
  (reminders are kept, just not fired)
- New synced setting default_event_duration_minutes (default 60) for new events

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 10:00:52 +02:00
Scarriffle
e6bc7eab9d Web: replace chevron chars with clip-path pointed bar ends in overflow popup
All-day continuation bars now use CSS clip-path polygon for the tapered
arrow shape; bars stay inset within the popup (no edge bleed). Version v51.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 20:32:29 +02:00
Scarriffle
70aaec3401 Web: enhance overflow popup with styled all-day bars and continuation arrows
All-day events in the "+N weitere" popup now render as colored bars matching
the month-grid style: continues-left/right arrows when the event spans beyond
the selected day. Timed events keep dot+time+title format. Version bump v50.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 20:02:04 +02:00
Scarriffle
900abcdb7a Web: remove version from tab title, fix month event packing, add day overflow popup
- Tab title shows only "Calendarr" (version stays in sidebar/impressum)
- Fix lane assignment in month view: use 2D per-column occupancy grid instead of
  global colEnd tracking; eliminates empty rows caused by non-overlapping events
  being pushed to lower lanes
- "+N weitere" now opens an inline popup listing all events for that day
  (all-day first, then by time); clicking an event opens its detail popup
- Double-click on day cell navigates to day view (was already implemented)
- Remove hardcoded "v18" from login screen impressum button
- Bump version to v49

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 18:06:54 +02:00
13 changed files with 910 additions and 131 deletions

View File

@@ -210,6 +210,20 @@ def _migrate():
logging.info("Migration: added reminders_enabled to %s", tbl)
except Exception:
pass
# Synced default duration (minutes) for newly created events.
try:
conn.execute(text("ALTER TABLE user_settings ADD COLUMN default_event_duration_minutes INTEGER DEFAULT 60"))
conn.commit()
logging.info("Migration: added default_event_duration_minutes to user_settings")
except Exception:
pass
try:
conn.execute(text("ALTER TABLE user_settings ADD COLUMN share_calendar_icon VARCHAR(16)"))
conn.commit()
logging.info("Migration: added share_calendar_icon to user_settings")
except Exception:
pass
_migrate()

View File

@@ -106,6 +106,10 @@ class UserSettings(Base):
# Default reminder in minutes-before-start applied to all events client-side
# (0 = at start time). NULL = no default reminder.
default_reminder_minutes = Column(Integer, nullable=True)
# Default duration (in minutes) applied to a newly created event's end time.
default_event_duration_minutes = Column(Integer, default=60)
# Icon key (from GROUP_ICON_KEYS) shown next to calendars this user shares with groups.
share_calendar_icon = Column(String(16), nullable=True)
user = relationship("User", back_populates="settings")

View File

@@ -50,6 +50,7 @@ def _fetch_ics(url: str) -> str:
try:
resp = http_requests.get(url, timeout=30, allow_redirects=True)
resp.raise_for_status()
resp.encoding = 'utf-8'
return resp.text
except http_requests.RequestException as e:
raise ValueError(f"Fehler beim Abrufen der URL: {e}")

View File

@@ -30,6 +30,8 @@ class SettingsUpdate(BaseModel):
private_event_visibility: Optional[str] = None
group_visible_calendar_id: Optional[int] = None
default_reminder_minutes: Optional[int] = None # null = off
default_event_duration_minutes: Optional[int] = None
share_calendar_icon: Optional[str] = None
def _settings_dict(s: models.UserSettings) -> dict:
@@ -52,6 +54,8 @@ def _settings_dict(s: models.UserSettings) -> dict:
"private_event_visibility": s.private_event_visibility or "busy",
"group_visible_calendar_id": s.group_visible_calendar_id,
"default_reminder_minutes": s.default_reminder_minutes,
"default_event_duration_minutes": s.default_event_duration_minutes or 60,
"share_calendar_icon": s.share_calendar_icon,
}
@@ -94,7 +98,7 @@ def update_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", "group_visible_calendar_id", "default_reminder_minutes"}
NULLABLE_OVERRIDES = {"text_color", "line_color", "bg_color", "group_visible_calendar_id", "default_reminder_minutes", "default_event_duration_minutes", "share_calendar_icon"}
update_data = data.model_dump(exclude_unset=True)
for field, value in update_data.items():
if field in NULLABLE_OVERRIDES:

View File

@@ -355,9 +355,16 @@ a { color: var(--primary); text-decoration: none; }
.ev-color-row { display: flex; align-items: center; gap: 8px; }
.ev-reminders-list { display: flex; flex-direction: column; gap: 6px; margin-bottom: 6px; }
.ev-reminder-row { display: flex; align-items: center; gap: 8px; }
.ev-reminder-row select { flex: 1; }
.ev-reminder-row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.ev-reminder-row > select { flex: 1; min-width: 120px; }
.ev-reminder-remove { flex-shrink: 0; }
.ev-reminder-custom { display: inline-flex; align-items: center; gap: 6px; flex-basis: 100%; }
.ev-reminder-custom input[type="number"] { width: 64px; }
.ev-reminder-before { color: var(--text-3); font-size: 12px; }
.form-hint { font-size: 12px; color: var(--text-3); margin-bottom: 6px; }
.reminders-disabled .ev-reminders-list,
.reminders-disabled #ev-reminder-add { opacity: 0.5; }
#ev-reminders-hint { color: var(--accent); }
.ev-color-hex {
flex: 1; height: 36px; padding: 0 10px;
background: var(--bg-hover); border: 1px solid var(--border);
@@ -501,7 +508,9 @@ a { color: var(--primary); text-decoration: none; }
overflow-y: auto;
overflow-x: hidden;
transition: transform var(--transition);
scrollbar-width: none;
}
.sidebar::-webkit-scrollbar { display: none; }
.sidebar.collapsed { transform: translateX(calc(-1 * var(--sidebar-w))); margin-right: calc(-1 * var(--sidebar-w)); }
.sidebar-inner { padding-bottom: 8px; }
.sidebar-copyright {
@@ -583,7 +592,7 @@ a { color: var(--primary); text-decoration: none; }
.cal-item-dot:hover { outline: 2px solid var(--text-2); outline-offset: 1px; }
.cal-item input[type=checkbox] { accent-color: var(--primary); width: 14px; height: 14px; }
.cal-item-name { font-size: 13px; flex: 1; min-width: 0; color: var(--text-1); cursor: default; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.cal-item-remove { flex-shrink: 0; }
.cal-item-remove { flex-shrink: 0; display: none; }
.cal-rename-input {
font-size: 13px; flex: 1; color: var(--text-1);
background: var(--bg-app); border: 1px solid var(--primary);
@@ -591,15 +600,13 @@ a { color: var(--primary); text-decoration: none; }
outline: none;
}
.cal-account-name { font-size: 11px; color: var(--text-3); padding: 4px 16px 2px; font-weight: 500; }
/* Hide the remove/eye button until hover so the calendar name uses the full
width and only truncates while the button is visible. */
.cal-item-remove { display: none; }
.cal-item:hover .cal-item-remove { display: inline-flex; }
/* Reminder bell: only shown on hover when enabled, but always shown (dimmed)
when disabled so the muted state is visible at a glance. */
/* Fixed min-height so 28px mini-btns never change row height when they appear. */
.cal-item { position: relative; min-height: 40px; }
.cal-item-bell { display: none; flex-shrink: 0; }
.cal-item:hover .cal-item-remove,
.cal-item:hover .cal-item-bell { display: inline-flex; }
.cal-item-bell.off { display: inline-flex; color: var(--text-3); opacity: .75; }
.cal-item-bell.off { display: inline-flex; opacity: .55; color: var(--text-3); }
.cal-item:hover .cal-item-bell.off { opacity: 1; color: inherit; }
/* ── Month View ─────────────────────────────────────────── */
.month-view { display: flex; flex-direction: column; flex: 1; min-height: 0; }
@@ -733,6 +740,65 @@ a { color: var(--primary); text-decoration: none; }
}
.month-more:hover { color: var(--primary); }
/* Overflow popup ("+N weitere") */
.month-overflow-popup {
position: fixed; z-index: 800;
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: 12px;
box-shadow: var(--shadow-lg);
padding: 10px 0;
min-width: 220px; max-width: 300px;
overflow: hidden;
user-select: none;
}
.mop-header {
font-size: 12px; font-weight: 600;
color: var(--text-2);
padding: 0 12px 8px;
border-bottom: 1px solid var(--border);
margin-bottom: 4px;
}
.mop-row {
display: flex; align-items: center; gap: 7px;
padding: 4px 12px; cursor: pointer;
border-radius: 6px; margin: 0 4px;
}
.mop-row:hover { background: var(--bg-hover); }
.mop-dot {
width: 8px; height: 8px; border-radius: 50%;
flex-shrink: 0;
}
.mop-time {
font-size: 11px; color: var(--text-2);
min-width: 42px; flex-shrink: 0;
}
.mop-title {
font-size: 12px; font-weight: 500; color: var(--text-1);
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
/* All-day event bars in overflow popup — pointed ends via clip-path */
.mop-bar {
display: block;
margin: 2px 8px; padding: 3px 8px;
border-radius: 4px;
font-size: 11px; font-weight: 500; color: #fff;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
cursor: pointer;
}
.mop-bar:hover { filter: brightness(1.15); }
.mop-bar.continues-left {
clip-path: polygon(10px 0, 100% 0, 100% 100%, 10px 100%, 0 50%);
border-radius: 0; padding-left: 16px;
}
.mop-bar.continues-right {
clip-path: polygon(0 0, calc(100% - 10px) 0, 100% 50%, calc(100% - 10px) 100%, 0 100%);
border-radius: 0; padding-right: 16px;
}
.mop-bar.continues-left.continues-right {
clip-path: polygon(10px 0, calc(100% - 10px) 0, 100% 50%, calc(100% - 10px) 100%, 10px 100%, 0 50%);
}
/* ── Week / Day Views ───────────────────────────────────── */
.week-view, .day-view { display: flex; flex-direction: column; flex: 1; min-height: 0; overflow-y: scroll; }
.week-head-sticky {
@@ -849,6 +915,10 @@ a { color: var(--primary); text-decoration: none; }
.timed-event .ev-time { font-size: 10px; opacity: .85; }
.timed-event .ev-title { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.timed-event .ev-loc { font-size: 10px; opacity: .75; white-space: nowrap; overflow: hidden; }
/* Short events: time and title on one line so the title stays visible. */
.timed-event.short { display: flex; flex-direction: row; align-items: baseline; gap: 4px; }
.timed-event.short .ev-time { flex-shrink: 0; }
.timed-event.short .ev-title { flex: 1; min-width: 0; }
/* Day view specifics */
.day-view .week-day-col { flex: 1; }
@@ -990,7 +1060,9 @@ a { color: var(--primary); text-decoration: none; }
border: 1px solid var(--border);
border-radius: 20px;
width: 100%;
max-height: 90vh; overflow-y: auto;
max-height: 90vh;
overflow: hidden;
display: flex; flex-direction: column;
box-shadow: var(--shadow-lg);
}
.modal-header {
@@ -1000,7 +1072,8 @@ a { color: var(--primary); text-decoration: none; }
}
.modal-header h3 { font-size: 18px; font-weight: 500; flex: 1; }
.modal-close { font-size: 24px; }
.modal-body { padding: 20px; }
.modal-body { padding: 20px; overflow-y: auto; flex: 1; scrollbar-width: none; }
.modal-body::-webkit-scrollbar { display: none; }
.modal-body p { margin: 0 0 14px; font-size: 14px; color: var(--text-1); }
.modal-body p:last-child { margin-bottom: 0; }
.modal-body a { color: var(--primary); text-decoration: none; }
@@ -1071,6 +1144,7 @@ a { color: var(--primary); text-decoration: none; }
background: linear-gradient(180deg,
color-mix(in srgb, var(--ev-color, var(--primary)) 13%, transparent), transparent);
}
.ep-dragging { user-select: none; }
/* Slim accent strip in the event's colour. */
.popup-header::before {
content: ""; position: absolute; left: 0; top: 0; bottom: 0; width: 4px;
@@ -1128,12 +1202,16 @@ a { color: var(--primary); text-decoration: none; }
}
.popup-icon-btn:active { transform: scale(.88); }
.popup-body { padding: 12px 16px 14px; display: flex; flex-direction: column; gap: 9px; }
.popup-body { padding: 12px 16px 14px; display: flex; flex-direction: column; gap: 9px; -webkit-user-select: text; user-select: text; }
.popup-row { display: flex; align-items: flex-start; gap: 10px; font-size: 13px; line-height: 1.45; color: var(--text-2); }
.popup-row-icon { width: 16px; height: 16px; flex-shrink: 0; margin-top: 1px; fill: var(--text-3); }
#popup-time { color: var(--text-1); font-weight: 500; }
.popup-row-desc { color: var(--text-1); }
.popup-row-desc span { white-space: pre-wrap; }
#popup-description { overflow-wrap: anywhere; }
#popup-description a { color: var(--primary); text-decoration: underline; }
#popup-description p { margin: 0 0 6px; }
#popup-description ul, #popup-description ol { margin: 4px 0; padding-left: 20px; }
#popup-creator { font-style: italic; }
.popup-copy-menu {
@@ -1360,6 +1438,34 @@ a { color: var(--primary); text-decoration: none; }
width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; display: inline-block;
}
/* ── Calendar Management Table ──────────────────────────── */
.accounts-add-row { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 4px; }
.cal-manage-table { width: 100%; border-collapse: collapse; font-size: 13px; }
.cal-manage-table th {
text-align: left; font-size: 11px; font-weight: 600; text-transform: uppercase;
letter-spacing: .4px; color: var(--text-3); padding: 0 8px 8px 0;
border-bottom: 1px solid var(--border-light);
}
.cal-manage-table td { padding: 6px 8px 6px 0; border-bottom: 1px solid var(--border-light); vertical-align: middle; }
.cal-manage-table tbody tr:last-child td { border-bottom: none; }
.ct-acc-row td { padding-top: 14px; background: none; }
.ct-acc-row td:first-child { font-size: 13px; }
.ct-cal-row .ct-indent { padding-left: 16px; }
.ct-src { font-size: 11px; color: var(--text-3); white-space: nowrap; }
.ct-badge {
display: inline-block; font-size: 10px; font-weight: 600; padding: 1px 5px;
border-radius: 3px; background: var(--surface-2); color: var(--text-3);
margin-left: 6px; vertical-align: middle;
}
.ct-badge-g { background: #e8f0fe; color: #1a73e8; }
.ct-badge-ha { background: #e8f5e9; color: #2e7d32; }
.ct-dot { width: 9px; height: 9px; border-radius: 50%; display: inline-block; margin-right: 5px; vertical-align: middle; flex-shrink: 0; }
.ct-empty { color: var(--text-3); font-size: 13px; padding: 16px 0; text-align: center; }
.ct-toggle { cursor: pointer; }
.ct-eye, .ct-bell { opacity: .45; transition: opacity .15s; }
.ct-eye[data-ct-visible="1"], .ct-bell[data-ct-on="1"] { opacity: 1; }
.ct-eye:hover, .ct-bell:hover { opacity: 1; }
/* ── Month View (spanning bars) ─────────────────────────── */
.month-body {
display: flex; flex-direction: column; flex: 1; overflow: hidden;

View File

@@ -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</button>
</div>
<!-- ─── MAIN APP ──────────────────────────────────────────── -->
@@ -375,6 +375,7 @@
</div>
<div class="form-group" id="ev-reminders-group" style="display:none">
<label data-i18n="reminders">Benachrichtigungen</label>
<div id="ev-reminders-hint" class="form-hint" style="display:none" data-i18n="reminders_calendar_off">Für diesen Kalender sind Benachrichtigungen deaktiviert Erinnerungen werden nicht ausgeführt.</div>
<div id="ev-reminders-list" class="ev-reminders-list"></div>
<button type="button" class="btn btn-ghost btn-sm" id="ev-reminder-add" data-i18n="reminder_add">Benachrichtigung hinzufügen</button>
</div>
@@ -691,7 +692,6 @@
<nav class="settings-nav">
<button class="settings-nav-btn active" data-panel="profile" data-i18n="settings_nav_profile">Profil</button>
<button class="settings-nav-btn" data-panel="general" data-i18n="settings_nav_appearance">Darstellung</button>
<button class="settings-nav-btn" data-panel="view" data-i18n="settings_nav_view">Ansicht</button>
<button class="settings-nav-btn" data-panel="accounts" data-i18n="settings_nav_calendars">Kalender</button>
<button class="settings-nav-btn hidden" data-panel="users" id="settings-nav-users" data-i18n="settings_nav_users">Benutzerverwaltung</button>
</nav>
@@ -726,6 +726,16 @@
</select>
</div>
<h4 class="panel-title" style="margin-top:24px" data-i18n="settings_default_duration">Standard-Termindauer</h4>
<div class="contrast-selector" id="cfg-event-duration">
<button class="contrast-btn" data-val="15">15 min</button>
<button class="contrast-btn" data-val="30">30 min</button>
<button class="contrast-btn" data-val="45">45 min</button>
<button class="contrast-btn" data-val="60">1 h</button>
<button class="contrast-btn" data-val="90">1,5 h</button>
<button class="contrast-btn" data-val="120">2 h</button>
</div>
<h4 class="panel-title" style="margin-top:24px" data-i18n="settings_calendars">Geteilter Kalender</h4>
<p class="panel-desc" data-i18n="settings_group_visible_desc">Wähle, welcher deiner Kalender für deine Gruppenmitglieder sichtbar ist</p>
<div class="form-group">
@@ -734,10 +744,35 @@
</div>
</div>
<!-- Darstellung: Sprache, Farben, Stundenhöhe -->
<!-- Darstellung: Kalenderansicht, Sprache, Stundenhöhe, Farben -->
<div class="settings-panel" id="settings-panel-general">
<h4 class="panel-title" data-i18n="settings_language">Sprache</h4>
<h4 class="panel-title" data-i18n="settings_calendar_view">Kalenderansicht</h4>
<div class="form-group">
<label data-i18n="settings_default_view">Standardansicht</label>
<select id="cfg-default-view">
<option value="month" data-i18n="view_month">Monat</option>
<option value="week" data-i18n="view_week">Woche</option>
<option value="day" data-i18n="view_day">Tag</option>
<option value="quarter" data-i18n="view_quarter">Quartal</option>
<option value="agenda" data-i18n="view_agenda">Termine</option>
</select>
</div>
<div class="form-group">
<label data-i18n="settings_week_start">Erster Wochentag</label>
<select id="cfg-week-start">
<option value="monday" data-i18n="week_start_monday">Montag</option>
<option value="sunday" data-i18n="week_start_sunday">Sonntag</option>
</select>
</div>
<div class="form-group">
<label class="toggle-label">
<input type="checkbox" id="cfg-dim-past" />
<span data-i18n="settings_dim_past">Vergangene Termine ausgrauen</span>
</label>
</div>
<h4 class="panel-title" style="margin-top:24px" data-i18n="settings_language">Sprache</h4>
<div class="form-group">
<select id="cfg-language">
<option value="de">Deutsch</option>
@@ -745,6 +780,19 @@
</select>
</div>
<h4 class="panel-title" style="margin-top:24px" data-i18n="settings_hour_height">Stundenhöhe (Wochen- &amp; Tagesansicht)</h4>
<p class="panel-desc" data-i18n="settings_hour_height_desc">Wie viel Platz eine Stunde in der Zeitrasteransicht einnimmt</p>
<div class="contrast-selector" id="cfg-hour-height" data-setting="hour_height">
<button class="contrast-btn" data-val="28"><span class="hour-preview">━━</span><span class="contrast-lbl" data-i18n="hour_compact">Kompakt</span></button>
<button class="contrast-btn" data-val="44"><span class="hour-preview">━━━</span><span class="contrast-lbl" data-i18n="hour_normal">Normal</span></button>
<button class="contrast-btn" data-val="60"><span class="hour-preview">━━━━</span><span class="contrast-lbl" data-i18n="hour_comfort">Komfort</span></button>
<button class="contrast-btn" data-val="80"><span class="hour-preview">━━━━━</span><span class="contrast-lbl" data-i18n="hour_large">Gross</span></button>
</div>
<h4 class="panel-title" style="margin-top:24px">Teilen-Symbol</h4>
<p class="panel-desc">Symbol neben deinem geteilten Kalender in der Seitenleiste</p>
<div class="group-icon-picker" id="cfg-share-icon"></div>
<h4 class="panel-title" style="margin-top:24px" data-i18n="settings_colors">Farben</h4>
<div class="form-group">
<label data-i18n="settings_primary_color">Primärfarbe</label>
@@ -806,76 +854,19 @@
<button type="button" class="btn btn-ghost btn-sm" id="cfg-bg-color-reset" data-i18n="reset" title="Zurücksetzen">Reset</button>
</div>
</div>
<h4 class="panel-title" style="margin-top:24px" data-i18n="settings_hour_height">Stundenhöhe (Wochen- &amp; Tagesansicht)</h4>
<p class="panel-desc" data-i18n="settings_hour_height_desc">Wie viel Platz eine Stunde in der Zeitrasteransicht einnimmt</p>
<div class="contrast-selector" id="cfg-hour-height" data-setting="hour_height">
<button class="contrast-btn" data-val="28"><span class="hour-preview">━━</span><span class="contrast-lbl" data-i18n="hour_compact">Kompakt</span></button>
<button class="contrast-btn" data-val="44"><span class="hour-preview">━━━</span><span class="contrast-lbl" data-i18n="hour_normal">Normal</span></button>
<button class="contrast-btn" data-val="60"><span class="hour-preview">━━━━</span><span class="contrast-lbl" data-i18n="hour_comfort">Komfort</span></button>
<button class="contrast-btn" data-val="80"><span class="hour-preview">━━━━━</span><span class="contrast-lbl" data-i18n="hour_large">Gross</span></button>
</div>
</div>
<!-- Ansicht: Standardansicht, Wochenstart, vergangene Termine, ausgeblendete Kalender -->
<div class="settings-panel" id="settings-panel-view">
<h4 class="panel-title" data-i18n="settings_calendar_view">Kalenderansicht</h4>
<div class="form-group">
<label data-i18n="settings_default_view">Standardansicht</label>
<select id="cfg-default-view">
<option value="month" data-i18n="view_month">Monat</option>
<option value="week" data-i18n="view_week">Woche</option>
<option value="day" data-i18n="view_day">Tag</option>
<option value="quarter" data-i18n="view_quarter">Quartal</option>
<option value="agenda" data-i18n="view_agenda">Termine</option>
</select>
</div>
<div class="form-group">
<label data-i18n="settings_week_start">Erster Wochentag</label>
<select id="cfg-week-start">
<option value="monday" data-i18n="week_start_monday">Montag</option>
<option value="sunday" data-i18n="week_start_sunday">Sonntag</option>
</select>
</div>
<div class="form-group">
<label class="toggle-label">
<input type="checkbox" id="cfg-dim-past" />
<span data-i18n="settings_dim_past">Vergangene Termine ausgrauen</span>
</label>
</div>
<h4 class="panel-title" style="margin-top:24px" data-i18n="settings_hidden_cals">Ausgeblendete Kalender</h4>
<div id="hidden-cals-list"><span style="font-size:13px;color:var(--text-3)" data-i18n="settings_no_hidden_cals">Keine ausgeblendeten Kalender</span></div>
</div>
<!-- Konten (CalDAV, Lokal, iCal, Google) -->
<!-- Kalender: einheitliche Tabelle aller Kalender aus allen Quellen -->
<div class="settings-panel" id="settings-panel-accounts">
<h4 class="panel-title" data-i18n="settings_nav_calendars">Kalender</h4>
<div class="accounts-section">
<div class="accounts-section-heading" data-i18n="settings_accounts_caldav">CalDAV-Konten</div>
<div id="accounts-caldav-list"><span class="accounts-section-empty" data-i18n="settings_no_caldav_accounts">Keine CalDAV-Konten</span></div>
</div>
<div class="accounts-section">
<div class="accounts-section-heading" data-i18n="settings_accounts_local">Lokale Kalender</div>
<div id="accounts-local-list"><span class="accounts-section-empty" data-i18n="settings_no_local_cals">Keine lokalen Kalender</span></div>
</div>
<div class="accounts-section">
<div class="accounts-section-heading" data-i18n="settings_accounts_ical">iCal-Abonnements</div>
<div id="accounts-ical-list"><span class="accounts-section-empty" data-i18n="settings_no_ical_subs">Keine Abonnements</span></div>
</div>
<div class="accounts-section">
<div class="accounts-section-heading" data-i18n="settings_accounts_google">Google-Konten</div>
<div id="google-accounts-list"><span class="accounts-section-empty" data-i18n="settings_no_google_accounts">Keine Google-Konten</span></div>
</div>
<div class="accounts-section">
<div class="accounts-section-heading">Home Assistant</div>
<div id="accounts-ha-list"><span class="accounts-section-empty">Keine HA-Konten</span></div>
<div class="accounts-add-row">
<button class="btn btn-secondary btn-sm" id="settings-btn-add-local">+ Lokal</button>
<button class="btn btn-secondary btn-sm" id="settings-btn-add-caldav">+ CalDAV</button>
<button class="btn btn-secondary btn-sm" id="settings-btn-add-ical">+ iCal</button>
<button class="btn btn-secondary btn-sm" id="settings-btn-add-ha">+ Home Assistant</button>
<button class="btn btn-secondary btn-sm" id="settings-btn-add-google">+ Google</button>
</div>
<div id="cal-settings-table" style="margin-top:16px;overflow-x:auto"></div>
</div>
<!-- Benutzerverwaltung -->

View File

@@ -1,5 +1,5 @@
import { api } from './api.js';
import { applyTheme, isToday, isSameDay, toLocalDatetimeInput, toDateInput, dateKey, dayOfWeek, weekStart, DEFAULT_TEXT_COLOR, DEFAULT_LINE_COLOR, DEFAULT_BG_COLOR } from './utils.js';
import { applyTheme, isToday, isSameDay, toLocalDatetimeInput, toDateInput, dateKey, dayOfWeek, weekStart, renderDescriptionHtml, DEFAULT_TEXT_COLOR, DEFAULT_LINE_COLOR, DEFAULT_BG_COLOR } from './utils.js';
import { renderMonth } from './views/month.js';
import { renderWeek } from './views/week.js';
import { renderAgenda } from './views/agenda.js';
@@ -9,8 +9,8 @@ import { openDatePicker, formatDtDisplay } from './date-picker.js';
import { t, setLang, getLang } from './i18n.js';
import { APP_VERSION } from './version.js';
// Version sofort beim Modul-Load ueberall sichtbar setzen.
document.title = `Calendarr ${APP_VERSION}`;
// Version im Impressum/Sidebar sichtbar, nicht im Tab-Titel.
document.title = 'Calendarr';
document.addEventListener('DOMContentLoaded', () => {
const imp = document.getElementById('impressum-version');
if (imp) imp.textContent = `Calendarr ${APP_VERSION}`;
@@ -76,6 +76,8 @@ function readUrlState() {
if (!isNaN(d.getTime())) out.date = d;
}
out.settings = params.get('settings') === '1';
const stab = params.get('stab');
if (stab && ['profile','general','accounts','users'].includes(stab)) out.stab = stab;
return out;
}
@@ -87,7 +89,11 @@ function writeUrlState() {
const d = state.currentDate;
const dateStr = `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`;
let newHash = `date=${dateStr}&view=${state.currentView}`;
if (uiSettingsOpen) newHash += '&settings=1';
if (uiSettingsOpen) {
newHash += '&settings=1';
const activeTab = document.querySelector('.settings-nav-btn.active');
if (activeTab) newHash += `&stab=${activeTab.dataset.panel}`;
}
if (window.location.hash.replace(/^#/,'') !== newHash) {
// replaceState statt pushState: prev/next-Klicks sollen nicht jeden
// einzelnen Tag in den Browser-History-Stack drücken
@@ -543,7 +549,7 @@ function updateTitle() {
titleEl.innerHTML =
`<span class="view-title-main">${main}</span>` +
(year ? `<span class="view-title-year">${year}</span>` : '');
document.title = `Calendarr ${APP_VERSION} - ${fullText}`;
document.title = `Calendarr ${fullText}`;
}
function updateViewButtons() {
@@ -570,11 +576,22 @@ function renderMiniCal() {
const DOW_LABELS = weekStartDay === 'sunday' ? DOW_SUNDAY : DOW_MONDAY;
miniDowEls.forEach((el, i) => { el.textContent = DOW_LABELS[i]; });
// Build event date set
const eventDates = new Set(state.events.map(ev => {
// Build event date set — mark every day an event spans, not just its start
// day, so multi-day events (Urlaub/Ferien) show a dot across the whole range.
const eventDates = new Set();
state.events.forEach(ev => {
const s = new Date(ev.start);
return `${s.getFullYear()}-${s.getMonth()}-${s.getDate()}`;
}));
let end = new Date(ev.end || ev.start);
// All-day events store an exclusive end → step back to the last real day.
if (ev.allDay) { end.setHours(0, 0, 0, 0); if (end > s) end.setDate(end.getDate() - 1); }
const cur = new Date(s.getFullYear(), s.getMonth(), s.getDate());
const last = new Date(end.getFullYear(), end.getMonth(), end.getDate());
let guard = 0;
while (cur <= last && guard++ < 400) {
eventDates.add(`${cur.getFullYear()}-${cur.getMonth()}-${cur.getDate()}`);
cur.setDate(cur.getDate() + 1);
}
});
const days = [];
const cur = new Date(gridStart);
@@ -686,6 +703,7 @@ function renderCalendarList() {
const TRASH = `<svg viewBox="0 0 24 24" fill="currentColor" width="14" height="14"><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>`;
const BELL = `<svg viewBox="0 0 24 24" fill="currentColor" width="14" height="14"><path d="M12 22c1.1 0 2-.9 2-2h-4c0 1.1.9 2 2 2zm6-6v-5c0-3.07-1.63-5.64-4.5-6.32V4c0-.83-.67-1.5-1.5-1.5s-1.5.67-1.5 1.5v.68C7.64 5.36 6 7.92 6 11v5l-2 2v1h16v-1l-2-2z"/></svg>`;
const BELL_OFF = `<svg viewBox="0 0 24 24" fill="currentColor" width="14" height="14"><path d="M20 18.69L7.84 6.14 5.27 3.49 4 4.76l2.8 2.8v.01c-.52.99-.8 2.16-.8 3.42v5l-2 2v1h13.73l2 2L21 19.72l-1-1.03zM12 22c1.11 0 2-.89 2-2h-4c0 1.11.89 2 2 2zm6-7.32V11c0-3.08-1.64-5.64-4.5-6.32V4c0-.83-.67-1.5-1.5-1.5s-1.5.67-1.5 1.5v.68c-.15.03-.29.08-.42.12-.1.03-.2.07-.3.11h-.01c-.01 0-.01 0-.02.01-.23.09-.46.2-.68.31L18 14.68z"/></svg>`;
const SHARE_ICON = `<svg viewBox="0 0 24 24" fill="currentColor" width="13" height="13"><path d="M18 16.08c-.76 0-1.44.3-1.96.77L8.91 12.7c.05-.23.09-.46.09-.7s-.04-.47-.09-.7l7.05-4.11c.54.5 1.25.81 2.04.81 1.66 0 3-1.34 3-3s-1.34-3-3-3-3 1.34-3 3c0 .24.04.47.09.7L8.04 9.81C7.5 9.31 6.79 9 6 9c-1.66 0-3 1.34-3 3s1.34 3 3 3c.79 0 1.5-.31 2.04-.81l7.12 4.16c-.05.21-.08.43-.08.65 0 1.61 1.31 2.92 2.92 2.92s2.92-1.31 2.92-2.92-1.31-2.92-2.92-2.92z"/></svg>`;
// Build a single flat list of all calendars. The source/account is shown
// inline (small, grey) next to the name and section headers are gone, so the
@@ -719,7 +737,8 @@ function renderCalendarList() {
entries.push({ key: `local:${cal.id}`, source: 'local', dataId: `data-cal-id="${cal.id}"`,
name: cal.name, color: cal.color, enabled: cal.enabled,
reminders: cal.owned !== false, remindersEnabled: cal.reminders_enabled !== false,
sourceLabel: `${t('groups_title')} · ${cal.shared_by || ''}`, isGroupCal: true, remove: null });
sourceLabel: `${t('groups_title')} · ${cal.shared_by || ''}`,
isGroupCal: true, groupIcon: groupIconForLocalCal(cal.id), remove: null });
});
state.icalSubscriptions.forEach(sub => {
entries.push({ key: `ical:${sub.id}`, source: 'ical', dataId: `data-sub-id="${sub.id}"`,
@@ -765,9 +784,9 @@ function renderCalendarList() {
<span class="cal-drag-handle" title="${t('drag_reorder')}">⠿</span>
<input type="checkbox" ${e.enabled ? 'checked' : ''} data-source="${e.source}" ${e.dataId} />
<div class="cal-item-dot" style="background:${e.color}" data-source="${e.source}" ${e.dataId} title="${t('change_color')}"></div>
${e.isGroupCal ? `<span class="cal-shared-flag" title="${escHtml(e.sourceLabel)}">${groupIconSvg(e.groupIcon || 'people', 13)}</span>` : ''}
${e.groupVisible ? `<span class="cal-shared-flag cal-shared-flag-own" title="${t('group_visible_flag')}">${shareIconSvg(state.settings?.share_calendar_icon || 'share', 13)}</span>` : ''}
<span class="cal-item-name" data-source="${e.source}">${escHtml(e.name)}</span>
${e.isGroupCal ? `<span class="cal-shared-flag" title="${escHtml(e.sourceLabel)}">${groupIconSvg('people', 13)}</span>` : ''}
${e.groupVisible ? `<span class="cal-shared-flag" title="${t('group_visible_flag')}">${groupIconSvg('people', 13)}</span>` : ''}
${e.reminders ? `<button class="icon-btn mini-btn cal-item-bell ${e.remindersEnabled ? '' : 'off'}" data-source="${e.source}" ${e.dataId} title="${e.remindersEnabled ? t('calendar_reminders_on') : t('calendar_reminders_off')}">${e.remindersEnabled ? BELL : BELL_OFF}</button>` : ''}
${e.remove ? `<button class="icon-btn mini-btn cal-item-remove" data-source="${e.source}" ${e.dataId} title="${e.remove.title}">${e.remove.icon}</button>` : ''}
</div>`
@@ -959,6 +978,20 @@ function renderCalendarList() {
await api.put(`/ical/subscriptions/${subId}`, { name: newName });
const sub = state.icalSubscriptions.find(s => s.id === subId);
if (sub) sub.name = newName;
} else if (source === 'google') {
const calId = parseInt(item.dataset.calId);
await api.put(`/google/calendars/${calId}`, { name: newName });
for (const acc of state.googleAccounts) {
const cal = (acc.calendars || []).find(c => c.id === calId);
if (cal) cal.name = newName;
}
} else if (source === 'homeassistant') {
const calId = parseInt(item.dataset.calId);
await api.put(`/homeassistant/calendars/${calId}`, { name: newName });
for (const acc of state.haAccounts) {
const cal = (acc.calendars || []).find(c => c.id === calId);
if (cal) cal.name = newName;
}
}
}
renderCalendarList();
@@ -1401,7 +1434,7 @@ function showEventPopup(ev, anchor) {
document.getElementById('popup-location').textContent = ev.location || '';
document.getElementById('popup-row-location').style.display = ev.location ? '' : 'none';
document.getElementById('popup-description').textContent = ev.description || '';
document.getElementById('popup-description').innerHTML = renderDescriptionHtml(ev.description || '');
document.getElementById('popup-row-description').style.display = ev.description ? '' : 'none';
document.getElementById('popup-calendar').textContent = ev.calendar_name || '';
document.getElementById('popup-row-calendar').style.display = ev.calendar_name ? '' : 'none';
@@ -1425,6 +1458,36 @@ function showEventPopup(ev, anchor) {
popup.style.left = Math.max(8, left) + 'px';
popup.style.top = Math.max(8, top) + 'px';
// Drag-to-move: grab the header and drag the popup anywhere on screen
const dragHandle = popup.querySelector('.popup-header');
let dragOffX = 0, dragOffY = 0;
dragHandle.style.cursor = 'grab';
const onPointerMove = e => {
if (!popup.classList.contains('ep-dragging')) return;
const vw = window.innerWidth, vh = window.innerHeight;
const x = Math.max(0, Math.min(vw - popup.offsetWidth, e.clientX - dragOffX));
const y = Math.max(0, Math.min(vh - popup.offsetHeight, e.clientY - dragOffY));
popup.style.left = x + 'px';
popup.style.top = y + 'px';
};
const onPointerUp = () => {
popup.classList.remove('ep-dragging');
dragHandle.style.cursor = 'grab';
};
dragHandle.onpointerdown = e => {
if (e.button !== 0) return;
// Let the toolbar buttons (edit/copy/delete/close) receive their click —
// only the bare header area starts a drag.
if (e.target.closest('button, a')) return;
dragOffX = e.clientX - popup.getBoundingClientRect().left;
dragOffY = e.clientY - popup.getBoundingClientRect().top;
dragHandle.setPointerCapture(e.pointerId);
popup.classList.add('ep-dragging');
dragHandle.style.cursor = 'grabbing';
};
dragHandle.onpointermove = onPointerMove;
dragHandle.onpointerup = onPointerUp;
// Hide edit/delete for read-only iCal subscription events
const isReadOnly = (ev.source === 'ical');
document.getElementById('popup-edit').style.display = isReadOnly ? 'none' : '';
@@ -1700,8 +1763,8 @@ function openNewEventModal(date) {
document.getElementById('ev-allday').checked = false;
const start = new Date(date);
const end = new Date(date);
end.setHours(end.getHours() + 1);
const durMin = (state.settings && state.settings.default_event_duration_minutes) || 60;
const end = new Date(start.getTime() + durMin * 60000);
setDtValue('ev-start', toLocalDatetimeInput(start), 'datetime');
setDtValue('ev-end', toLocalDatetimeInput(end), 'datetime');
setDtValue('ev-start-date', toDateInput(start), 'date');
@@ -1835,7 +1898,15 @@ function resetColorPicker(color) {
}
// ── Reminders (local events only) ─────────────────────────
const REMINDER_OPTIONS = [0, 5, 15, 30, 60, 1440, 10080];
// A few quick presets; anything else is entered via the "custom" mode
// (number + unit). Reminders are stored as minutes-before-start integers.
const REMINDER_PRESETS = [0, 30, 1440]; // at start, 30 min, 1 day
const REMINDER_UNITS = [
{ unit: 'minutes', mult: 1 },
{ unit: 'hours', mult: 60 },
{ unit: 'days', mult: 1440 },
{ unit: 'weeks', mult: 10080 },
];
function reminderLabel(min) {
if (min <= 0) return t('reminder_at_start');
@@ -1845,6 +1916,15 @@ function reminderLabel(min) {
const w = min / 10080; return w === 1 ? t('reminder_week_one') : t('reminder_weeks', { n: w });
}
// Split a minutes value into the largest exact {value, unit} for the custom picker.
function splitReminder(min) {
for (let i = REMINDER_UNITS.length - 1; i >= 0; i--) {
const u = REMINDER_UNITS[i];
if (min > 0 && min % u.mult === 0) return { value: min / u.mult, unit: u.unit };
}
return { value: Math.max(1, min), unit: 'minutes' };
}
function setEventReminders(arr) {
state.eventReminders = Array.isArray(arr)
? arr.map(Number).filter(n => !isNaN(n)) : [];
@@ -1858,19 +1938,63 @@ function renderReminderRows() {
state.eventReminders.forEach((min, idx) => {
const row = document.createElement('div');
row.className = 'ev-reminder-row';
const isPreset = REMINDER_PRESETS.includes(min);
const sel = document.createElement('select');
// Keep a non-catalog value (from an old/imported reminder) selectable.
const opts = REMINDER_OPTIONS.includes(min) ? REMINDER_OPTIONS : [min, ...REMINDER_OPTIONS];
opts.forEach(v => {
REMINDER_PRESETS.forEach(v => {
const o = document.createElement('option');
o.value = String(v);
o.textContent = reminderLabel(v);
if (v === min) o.selected = true;
if (isPreset && v === min) o.selected = true;
sel.appendChild(o);
});
sel.addEventListener('change', () => {
state.eventReminders[idx] = parseInt(sel.value, 10);
const customOpt = document.createElement('option');
customOpt.value = 'custom';
customOpt.textContent = t('reminder_custom');
if (!isPreset) customOpt.selected = true;
sel.appendChild(customOpt);
// Custom (number + unit) inputs, shown only in custom mode.
const customWrap = document.createElement('span');
customWrap.className = 'ev-reminder-custom';
customWrap.style.display = isPreset ? 'none' : '';
const numInput = document.createElement('input');
numInput.type = 'number';
numInput.min = '1';
const unitSel = document.createElement('select');
REMINDER_UNITS.forEach(u => {
const o = document.createElement('option');
o.value = u.unit;
o.textContent = t('reminder_unit_' + u.unit);
unitSel.appendChild(o);
});
const beforeLbl = document.createElement('span');
beforeLbl.className = 'ev-reminder-before';
beforeLbl.textContent = t('reminder_before');
const sp = splitReminder(isPreset ? 30 : min);
numInput.value = String(sp.value);
unitSel.value = sp.unit;
customWrap.appendChild(numInput);
customWrap.appendChild(unitSel);
customWrap.appendChild(beforeLbl);
function commitCustom() {
const n = Math.max(1, parseInt(numInput.value, 10) || 1);
const mult = REMINDER_UNITS.find(u => u.unit === unitSel.value).mult;
state.eventReminders[idx] = n * mult;
}
sel.addEventListener('change', () => {
if (sel.value === 'custom') {
customWrap.style.display = '';
commitCustom();
} else {
customWrap.style.display = 'none';
state.eventReminders[idx] = parseInt(sel.value, 10);
}
});
numInput.addEventListener('input', commitCustom);
unitSel.addEventListener('change', commitCustom);
const rm = document.createElement('button');
rm.type = 'button';
rm.className = 'icon-btn ev-reminder-remove';
@@ -1880,6 +2004,7 @@ function renderReminderRows() {
renderReminderRows();
});
row.appendChild(sel);
row.appendChild(customWrap);
row.appendChild(rm);
list.appendChild(row);
});
@@ -1887,16 +2012,30 @@ function renderReminderRows() {
function addReminderRow() {
const def = (state.settings && state.settings.default_reminder_minutes != null)
? state.settings.default_reminder_minutes : 10;
state.eventReminders.push(REMINDER_OPTIONS.includes(def) ? def : 10);
? state.settings.default_reminder_minutes : 30;
state.eventReminders.push(def >= 0 ? def : 30);
renderReminderRows();
}
// Reminders apply to local events only (the server stores them on local events).
// When the selected calendar has its reminders disabled, we keep any existing
// reminders intact (never delete them) but grey out the controls and explain
// that they won't fire — matching the apps' behaviour.
function updateRemindersRow() {
const calVal = document.getElementById('ev-calendar').value || '';
const isLocal = calVal.startsWith('local-');
document.getElementById('ev-reminders-group').style.display = isLocal ? '' : 'none';
const group = document.getElementById('ev-reminders-group');
group.style.display = isLocal ? '' : 'none';
if (!isLocal) return;
const calId = parseInt(calVal.slice('local-'.length), 10);
const cal = (state.localCalendars || []).find(c => c.id === calId);
const disabled = !!(cal && cal.reminders_enabled === false);
const hint = document.getElementById('ev-reminders-hint');
if (hint) hint.style.display = disabled ? '' : 'none';
group.classList.toggle('reminders-disabled', disabled);
group.querySelectorAll('select, input, button').forEach(el => { el.disabled = disabled; });
}
function buildRruleFromUI() {
@@ -2773,6 +2912,23 @@ function groupIconHtml(icon, size = 18) {
if (icon) return escHtml(icon);
return groupIconSvg('people', size);
}
// Icons specifically for the "this calendar is shared with a group" indicator.
const SHARE_ICON_KEYS = ['share', 'link', 'send', 'eye', 'upload', 'wifi', 'person_add', 'ios_share'];
const SHARE_ICON_PATHS = {
share: 'M18 16.08c-.76 0-1.44.3-1.96.77L8.91 12.7c.05-.23.09-.46.09-.7s-.04-.47-.09-.7l7.05-4.11c.54.5 1.25.81 2.04.81 1.66 0 3-1.34 3-3s-1.34-3-3-3-3 1.34-3 3c0 .24.04.47.09.7L8.04 9.81C7.5 9.31 6.79 9 6 9c-1.66 0-3 1.34-3 3s1.34 3 3 3c.79 0 1.5-.31 2.04-.81l7.12 4.16c-.05.21-.08.43-.08.65 0 1.61 1.31 2.92 2.92 2.92s2.92-1.31 2.92-2.92-1.31-2.92-2.92-2.92z',
link: 'M3.9 12c0-1.71 1.39-3.1 3.1-3.1h4V7H7c-2.76 0-5 2.24-5 5s2.24 5 5 5h4v-1.9H7c-1.71 0-3.1-1.39-3.1-3.1zM8 13h8v-2H8v2zm9-6h-4v1.9h4c1.71 0 3.1 1.39 3.1 3.1s-1.39 3.1-3.1 3.1h-4V17h4c2.76 0 5-2.24 5-5s-2.24-5-5-5z',
send: 'M2.01 21L23 12 2.01 3 2 10l15 2-15 2z',
eye: 'M12 4.5C7 4.5 2.73 7.61 1 12c1.73 4.39 6 7.5 11 7.5s9.27-3.11 11-7.5c-1.73-4.39-6-7.5-11-7.5zM12 17c-2.76 0-5-2.24-5-5s2.24-5 5-5 5 2.24 5 5-2.24 5-5 5zm0-8c-1.66 0-3 1.34-3 3s1.34 3 3 3 3-1.34 3-3-1.34-3-3-3z',
upload: 'M9 16h6v-6h4l-7-7-7 7h4v6zm-4 2h14v2H5v-2z',
wifi: 'M1 9l2 2c4.97-4.97 13.03-4.97 18 0l2-2C16.93 2.93 7.08 2.93 1 9zm8 8l3 3 3-3c-1.65-1.66-4.34-1.66-6 0zm-4-4l2 2c2.76-2.76 7.24-2.76 10 0l2-2C15.14 9.14 8.87 9.14 5 13z',
person_add: 'M15 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm-9-2V7H4v3H1v2h3v3h2v-3h3v-2H6zm9 4c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z',
ios_share: 'M16 5l-1.42 1.42-1.59-1.59V16h-1.98V4.83L9.42 6.42 8 5l4-4 4 4zm4 5v11c0 1.1-.9 2-2 2H6c-1.11 0-2-.9-2-2V10c0-1.11.89-2 2-2h3v2H6v11h12V10h-3V8h3c1.1 0 2 .89 2 2z',
};
function shareIconSvg(key, size = 18) {
const p = SHARE_ICON_PATHS[key] || SHARE_ICON_PATHS.share;
return `<svg viewBox="0 0 24 24" width="${size}" height="${size}" fill="currentColor" aria-hidden="true"><path d="${p}"/></svg>`;
}
function renderGroupIconPicker() {
const modal = document.getElementById('modal-group');
const sel = modal.__icon || 'people';
@@ -2932,6 +3088,21 @@ function openSettingsModal() {
document.getElementById('cfg-private-visibility').value = s.private_event_visibility || 'busy';
renderGroupVisibleList(s.group_visible_calendar_id);
// Share-Icon-Picker in Darstellung
const shareIconPicker = document.getElementById('cfg-share-icon');
if (shareIconPicker) {
const cur = s.share_calendar_icon || 'share';
shareIconPicker.innerHTML = SHARE_ICON_KEYS.map(k =>
`<button type="button" class="group-icon-opt ${cur === k ? 'on' : ''}" data-share-icon="${k}" title="${k}">${shareIconSvg(k, 20)}</button>`
).join('');
shareIconPicker.querySelectorAll('.group-icon-opt').forEach(btn => {
btn.addEventListener('click', () => {
shareIconPicker.querySelectorAll('.group-icon-opt').forEach(b => b.classList.remove('on'));
btn.classList.add('on');
});
});
}
// Profile chapter: name (from cached user) + email (fresh from /profile).
const pu = JSON.parse(localStorage.getItem('user') || '{}');
document.getElementById('cfg-display-name').value = pu.display_name || pu.username || '';
@@ -2940,11 +3111,12 @@ function openSettingsModal() {
document.getElementById('cfg-email').value = p.email || '';
}).catch(() => {});
// Set active contrast/hour-height buttons
// Set active contrast/hour-height/duration buttons
[
{ id: 'cfg-text-contrast', val: s.text_contrast || 3 },
{ id: 'cfg-line-contrast', val: s.line_contrast || 3 },
{ id: 'cfg-hour-height', val: s.hour_height || 60 },
{ id: 'cfg-event-duration', val: s.default_event_duration_minutes || 60 },
].forEach(({ id, val }) => {
const sel = document.getElementById(id);
if (!sel) return;
@@ -2959,13 +3131,14 @@ function openSettingsModal() {
if (usersNavBtn) usersNavBtn.classList.toggle('hidden', !user.is_admin);
if (user.is_admin) loadUsers();
// Activate first panel
const firstBtn = document.querySelector('.settings-nav-btn:not(.hidden)');
// Activate panel from URL or fall back to first visible
const urlTab = readUrlState().stab;
const tabBtn = urlTab && document.querySelector(`.settings-nav-btn[data-panel="${urlTab}"]:not(.hidden)`);
const firstBtn = tabBtn || document.querySelector('.settings-nav-btn:not(.hidden)');
if (firstBtn) activateSettingsPanel(firstBtn.dataset.panel);
// Render all accounts and hidden calendars
// Render unified calendar table
renderAllAccounts();
renderHiddenCalendars();
openModal('modal-settings');
}
@@ -2973,6 +3146,7 @@ function openSettingsModal() {
function activateSettingsPanel(panel) {
document.querySelectorAll('.settings-nav-btn').forEach(b => b.classList.toggle('active', b.dataset.panel === panel));
document.querySelectorAll('.settings-panel').forEach(p => p.classList.toggle('active', p.id === 'settings-panel-' + panel));
writeUrlState();
}
function renderGoogleAccounts() {
@@ -3138,9 +3312,6 @@ function renderAllAccounts() {
}
}
// Google accounts section — delegate to existing function
renderGoogleAccounts();
// Home Assistant accounts section
const haList = document.getElementById('accounts-ha-list');
if (haList) {
@@ -3185,10 +3356,13 @@ function renderAllAccounts() {
});
}
}
renderCalendarTable();
}
function renderHiddenCalendars() {
const list = document.getElementById('hidden-cals-list');
if (!list) return;
const hidden = [];
for (const acc of state.accounts) {
for (const cal of acc.calendars) {
@@ -3248,6 +3422,260 @@ function renderHiddenCalendars() {
});
}
function renderCalendarTable() {
const container = document.getElementById('cal-settings-table');
if (!container) return;
const TRASH = `<svg viewBox="0 0 24 24" fill="currentColor" width="14" height="14"><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>`;
const EYE_ON = `<svg viewBox="0 0 24 24" fill="currentColor" width="16" height="16"><path d="M12 4.5C7 4.5 2.73 7.61 1 12c1.73 4.39 6 7.5 11 7.5s9.27-3.11 11-7.5c-1.73-4.39-6-7.5-11-7.5zM12 17c-2.76 0-5-2.24-5-5s2.24-5 5-5 5 2.24 5 5-2.24 5-5 5zm0-8c-1.66 0-3 1.34-3 3s1.34 3 3 3 3-1.34 3-3-1.34-3-3-3z"/></svg>`;
const EYE_OFF = `<svg viewBox="0 0 24 24" fill="currentColor" width="16" height="16"><path d="M12 7c2.76 0 5 2.24 5 5 0 .65-.13 1.26-.36 1.83l2.92 2.92c1.51-1.26 2.7-2.89 3.43-4.75-1.73-4.39-6-7.5-11-7.5-1.4 0-2.74.25-3.98.7l2.16 2.16C10.74 7.13 11.35 7 12 7zM2 4.27l2.28 2.28.46.46C3.08 8.3 1.78 10.02 1 12c1.73 4.39 6 7.5 11 7.5 1.55 0 3.03-.3 4.38-.84l.42.42L19.73 22 21 20.73 3.27 3 2 4.27zM7.53 9.8l1.55 1.55c-.05.21-.08.43-.08.65 0 1.66 1.34 3 3 3 .22 0 .44-.03.65-.08l1.55 1.55c-.67.33-1.41.53-2.2.53-2.76 0-5-2.24-5-5 0-.79.2-1.53.53-2.2zm4.31-.78l3.15 3.15.02-.16c0-1.66-1.34-3-3-3l-.17.01z"/></svg>`;
const BELL_ON = `<svg viewBox="0 0 24 24" fill="currentColor" width="16" height="16"><path d="M12 22c1.1 0 2-.9 2-2h-4c0 1.1.9 2 2 2zm6-6v-5c0-3.07-1.63-5.64-4.5-6.32V4c0-.83-.67-1.5-1.5-1.5s-1.5.67-1.5 1.5v.68C7.64 5.36 6 7.92 6 11v5l-2 2v1h16v-1l-2-2z"/></svg>`;
const BELL_OFF = `<svg viewBox="0 0 24 24" fill="currentColor" width="16" height="16"><path d="M20 18.69L7.84 6.14 5.27 3.49 4 4.76l2.8 2.8v.01c-.52.99-.8 2.16-.8 3.42v5l-2 2v1h13.73l2 2L21 19.72l-1-1.03zM12 22c1.11 0 2-.89 2-2h-4c0 1.11.89 2 2 2zm6-7.32V11c0-3.08-1.64-5.64-4.5-6.32V4c0-.83-.67-1.5-1.5-1.5s-1.5.67-1.5 1.5v.68c-.15.03-.29.08-.42.12-.1.03-.2.07-.3.11h-.01c-.01 0-.01 0-.02.01-.23.09-.46.2-.68.31L18 14.68z"/></svg>`;
let rowCount = 0;
const hid = (src, id, isVisible) =>
`<button class="icon-btn mini-btn ct-eye" data-ct-hid="${src}" data-ct-id="${id}" data-ct-visible="${isVisible ? '1' : '0'}" title="${isVisible ? 'Ausblenden' : 'Anzeigen'}">${isVisible ? EYE_ON : EYE_OFF}</button>`;
const rem = (src, id, on) =>
`<button class="icon-btn mini-btn ct-bell" data-ct-rem="${src}" data-ct-id="${id}" data-ct-on="${on ? '1' : '0'}" title="${on ? 'Benachrichtigungen aus' : 'Benachrichtigungen an'}">${on ? BELL_ON : BELL_OFF}</button>`;
const dot = (color, fb) =>
`<span class="ct-dot" style="background:${color || fb}"></span>`;
let rows = '';
// Local calendars
for (const cal of state.localCalendars) {
if (cal.group) continue;
const owned = cal.owned !== false;
const canWrite = owned || cal.permission === 'read_write';
rows += `<tr>
<td>${dot(cal.color, '#34a853')}${escHtml(cal.name)}</td>
<td class="ct-src">Lokal</td>
<td>—</td>
<td>${owned ? rem('local', cal.id, cal.reminders_enabled !== false) : '—'}</td>
<td>
<button class="btn btn-ghost btn-sm" data-ct-export="local" data-ct-id="${cal.id}" data-ct-name="${escHtml(cal.name)}">Export</button>
${canWrite ? `<button class="btn btn-ghost btn-sm" data-ct-import="local" data-ct-id="${cal.id}">Import</button>` : ''}
</td>
<td>—</td>
<td>${owned ? `<button class="icon-btn mini-btn" data-ct-del="local" data-ct-id="${cal.id}">${TRASH}</button>` : ''}</td>
</tr>`;
rowCount++;
}
// iCal subscriptions
for (const sub of state.icalSubscriptions) {
rows += `<tr>
<td>${dot(sub.color, '#aa66cc')}${escHtml(sub.name)}</td>
<td class="ct-src">iCal</td>
<td>${hid('ical', sub.id, !sub.sidebar_hidden)}</td>
<td>${rem('ical', sub.id, sub.reminders_enabled !== false)}</td>
<td>—</td>
<td>—</td>
<td><button class="icon-btn mini-btn" data-ct-del="ical" data-ct-id="${sub.id}">${TRASH}</button></td>
</tr>`;
rowCount++;
}
// CalDAV accounts + their calendars
for (const acc of state.accounts) {
rows += `<tr class="ct-acc-row">
<td colspan="2"><strong>${escHtml(acc.name)}</strong><span class="ct-badge">CalDAV</span></td>
<td>—</td><td>—</td><td>—</td>
<td><button class="btn btn-secondary btn-sm" data-ct-sync="caldav" data-ct-id="${acc.id}">${t('sync')}</button></td>
<td><button class="btn btn-ghost btn-sm" data-ct-disc="caldav" data-ct-id="${acc.id}">${t('disconnect')}</button></td>
</tr>`;
rowCount++;
for (const cal of (acc.calendars || [])) {
rows += `<tr class="ct-cal-row">
<td class="ct-indent">${dot(cal.color, '#4285f4')}${escHtml(cal.name)}</td>
<td class="ct-src">${escHtml(acc.name)}</td>
<td>${hid('caldav', cal.id, !cal.sidebar_hidden)}</td>
<td>${rem('caldav', cal.id, cal.reminders_enabled !== false)}</td>
<td>—</td><td>—</td><td>—</td>
</tr>`;
rowCount++;
}
}
// Google accounts + their calendars
for (const acc of state.googleAccounts) {
rows += `<tr class="ct-acc-row">
<td colspan="2"><strong>${escHtml(acc.email)}</strong><span class="ct-badge ct-badge-g">Google</span></td>
<td>—</td><td>—</td><td>—</td>
<td><button class="btn btn-secondary btn-sm" data-ct-sync="google" data-ct-id="${acc.id}">${t('sync')}</button></td>
<td><button class="btn btn-ghost btn-sm" data-ct-disc="google" data-ct-id="${acc.id}">${t('disconnect')}</button></td>
</tr>`;
rowCount++;
for (const cal of (acc.calendars || [])) {
rows += `<tr class="ct-cal-row">
<td class="ct-indent">${dot(cal.color, '#4285f4')}${escHtml(cal.name)}</td>
<td class="ct-src">${escHtml(acc.email)}</td>
<td>${hid('google', cal.id, !cal.sidebar_hidden)}</td>
<td>${rem('google', cal.id, cal.reminders_enabled !== false)}</td>
<td>—</td><td>—</td><td>—</td>
</tr>`;
rowCount++;
}
}
// Home Assistant accounts + their calendars
for (const acc of state.haAccounts) {
rows += `<tr class="ct-acc-row">
<td colspan="2"><strong>${escHtml(acc.name)}</strong><span class="ct-badge ct-badge-ha">Home Assistant</span></td>
<td>—</td><td>—</td><td>—</td>
<td><button class="btn btn-secondary btn-sm" data-ct-sync="ha" data-ct-id="${acc.id}">${t('sync')}</button></td>
<td><button class="btn btn-ghost btn-sm" data-ct-disc="ha" data-ct-id="${acc.id}">${t('disconnect')}</button></td>
</tr>`;
rowCount++;
for (const cal of (acc.calendars || [])) {
rows += `<tr class="ct-cal-row">
<td class="ct-indent">${dot(cal.color, '#03a9f4')}${escHtml(cal.name)}</td>
<td class="ct-src">${escHtml(acc.name)}</td>
<td>${hid('ha', cal.id, !cal.sidebar_hidden)}</td>
<td>${rem('ha', cal.id, cal.reminders_enabled !== false)}</td>
<td>—</td><td>—</td><td>—</td>
</tr>`;
rowCount++;
}
}
if (!rowCount) {
rows = `<tr><td colspan="7" class="ct-empty">Keine Kalender vorhanden</td></tr>`;
}
container.innerHTML = `<table class="cal-manage-table">
<thead><tr>
<th>Name</th><th>Herkunft</th><th>Sichtbar</th><th>Benachrichtigungen</th><th>Export / Import</th><th>Sync</th><th></th>
</tr></thead>
<tbody>${rows}</tbody>
</table>`;
// Visibility eye toggle
container.querySelectorAll('.ct-eye[data-ct-hid]').forEach(btn => {
btn.addEventListener('click', async () => {
const src = btn.dataset.ctHid, id = parseInt(btn.dataset.ctId);
const hidden = btn.dataset.ctVisible === '1'; // currently visible → we're hiding it
try {
if (src === 'ical') {
await api.put(`/ical/subscriptions/${id}`, { sidebar_hidden: hidden });
const s = state.icalSubscriptions.find(s => s.id === id);
if (s) s.sidebar_hidden = hidden;
} else if (src === 'caldav') {
await api.put(`/caldav/calendars/${id}`, { enabled: !hidden, sidebar_hidden: hidden });
for (const acc of state.accounts) { const c = acc.calendars.find(c => c.id === id); if (c) { c.sidebar_hidden = hidden; c.enabled = !hidden; } }
} else if (src === 'google') {
await api.put(`/google/calendars/${id}`, { enabled: !hidden, sidebar_hidden: hidden });
for (const acc of state.googleAccounts) { const c = acc.calendars.find(c => c.id === id); if (c) { c.sidebar_hidden = hidden; c.enabled = !hidden; } }
} else if (src === 'ha') {
await api.put(`/homeassistant/calendars/${id}`, { enabled: !hidden, sidebar_hidden: hidden });
for (const acc of state.haAccounts) { const c = acc.calendars.find(c => c.id === id); if (c) { c.sidebar_hidden = hidden; c.enabled = !hidden; } }
}
renderCalendarTable();
renderCalendarList();
} catch (e) { showToast(e.message, true); }
});
});
// Reminders bell toggle
container.querySelectorAll('.ct-bell[data-ct-rem]').forEach(btn => {
btn.addEventListener('click', async () => {
const src = btn.dataset.ctRem, id = parseInt(btn.dataset.ctId);
const enabled = btn.dataset.ctOn !== '1'; // currently on → turn off, currently off → turn on
const path = src === 'ical' ? `/ical/subscriptions/${id}` : `/${src === 'ha' ? 'homeassistant' : src}/calendars/${id}`;
try {
await api.put(path, { reminders_enabled: enabled });
if (src === 'local') { const c = state.localCalendars.find(c => c.id === id); if (c) c.reminders_enabled = enabled; }
else if (src === 'ical') { const s = state.icalSubscriptions.find(s => s.id === id); if (s) s.reminders_enabled = enabled; }
else if (src === 'caldav') { for (const acc of state.accounts) { const c = acc.calendars.find(c => c.id === id); if (c) c.reminders_enabled = enabled; } }
else if (src === 'google') { for (const acc of state.googleAccounts) { const c = acc.calendars.find(c => c.id === id); if (c) c.reminders_enabled = enabled; } }
else if (src === 'ha') { for (const acc of state.haAccounts) { const c = acc.calendars.find(c => c.id === id); if (c) c.reminders_enabled = enabled; } }
renderCalendarTable();
renderCalendarList();
} catch (e) { showToast(e.message, true); }
});
});
// Sync buttons
container.querySelectorAll('[data-ct-sync]').forEach(btn => {
btn.addEventListener('click', async () => {
const src = btn.dataset.ctSync, id = parseInt(btn.dataset.ctId);
const label = btn.textContent;
btn.disabled = true; btn.textContent = '…';
try {
if (src === 'caldav') {
await api.post(`/caldav/accounts/${id}/sync`);
} else if (src === 'google') {
const updated = await api.post(`/google/accounts/${id}/sync`);
const idx = state.googleAccounts.findIndex(a => a.id === updated.id);
if (idx !== -1) state.googleAccounts[idx] = updated;
} else if (src === 'ha') {
const updated = await api.post(`/homeassistant/accounts/${id}/sync`);
const idx = state.haAccounts.findIndex(a => a.id === updated.id);
if (idx !== -1) state.haAccounts[idx] = updated;
}
renderCalendarTable(); renderCalendarList(); fetchAndRender(true);
showToast(t('google_synced'));
} catch (e) { showToast(e.message, true); }
finally { btn.disabled = false; btn.textContent = label; }
});
});
// Disconnect buttons
container.querySelectorAll('[data-ct-disc]').forEach(btn => {
btn.addEventListener('click', async () => {
const src = btn.dataset.ctDisc, id = parseInt(btn.dataset.ctId);
const msg = src === 'caldav' ? t('confirm_caldav_disconnect')
: src === 'google' ? t('confirm_google_disconnect')
: 'Konto wirklich trennen?';
if (!confirm(msg)) return;
try {
if (src === 'caldav') {
await api.delete(`/caldav/accounts/${id}`);
state.accounts = state.accounts.filter(a => a.id !== id);
} else if (src === 'google') {
await api.delete(`/google/accounts/${id}`);
state.googleAccounts = state.googleAccounts.filter(a => a.id !== id);
} else if (src === 'ha') {
await api.delete(`/homeassistant/accounts/${id}`);
state.haAccounts = state.haAccounts.filter(a => a.id !== id);
}
renderCalendarTable(); renderCalendarList(); fetchAndRender(true);
showToast(t('caldav_disconnected'));
} catch (e) { showToast(e.message, true); }
});
});
// Export / Import
container.querySelectorAll('[data-ct-export]').forEach(btn => {
btn.addEventListener('click', async () => {
try { await api.download(`/local/calendars/${btn.dataset.ctId}/export`, `${btn.dataset.ctName || 'calendar'}.ics`); }
catch (e) { showToast(e.message, true); }
});
});
container.querySelectorAll('[data-ct-import]').forEach(btn => {
btn.addEventListener('click', () => triggerIcsImport(parseInt(btn.dataset.ctId)));
});
// Delete
container.querySelectorAll('[data-ct-del]').forEach(btn => {
btn.addEventListener('click', async () => {
const src = btn.dataset.ctDel, id = parseInt(btn.dataset.ctId);
const msg = src === 'local' ? t('confirm_delete_local_cal') : t('confirm_remove_ical');
if (!confirm(msg)) return;
try {
if (src === 'local') {
await api.delete(`/local/calendars/${id}`);
state.localCalendars = state.localCalendars.filter(c => c.id !== id);
} else {
await api.delete(`/ical/subscriptions/${id}`);
state.icalSubscriptions = state.icalSubscriptions.filter(s => s.id !== id);
}
renderCalendarTable(); renderCalendarList(); fetchAndRender(true);
} catch (e) { showToast(e.message, true); }
});
});
}
async function loadUsers() {
try {
const users = await api.get('/users/');
@@ -3438,12 +3866,15 @@ function bindSettingsModal() {
line_color: colourOrNull('cfg-line-color-hex'),
bg_color: colourOrNull('cfg-bg-color-hex'),
dim_past_events: document.getElementById('cfg-dim-past').checked,
default_event_duration_minutes: getActive('cfg-event-duration') || 60,
hour_height: getActive('cfg-hour-height') || 44,
language: document.getElementById('cfg-language').value,
private_event_visibility: document.getElementById('cfg-private-visibility').value,
};
const gvVal = document.getElementById('cfg-group-visible-list')?.dataset.selected;
settings.group_visible_calendar_id = gvVal ? parseInt(gvVal) : null;
const shareIconPicker = document.getElementById('cfg-share-icon');
settings.share_calendar_icon = shareIconPicker?.querySelector('.group-icon-opt.on')?.dataset.shareIcon || null;
try {
await api.put('/settings/', settings);
state.settings = { ...state.settings, ...settings };
@@ -3453,10 +3884,27 @@ function bindSettingsModal() {
applyTheme(state.settings);
showToast(t('settings_saved'));
closeModal('modal-settings');
renderCalendarList();
renderMiniCal();
fetchAndRender();
} catch (e) { showToast(e.message, true); }
};
// Add-account buttons in calendar panel
const addBtnMap = {
'settings-btn-add-local': openLocalCalModal,
'settings-btn-add-caldav': openAccountModal,
'settings-btn-add-ical': openICalSubModal,
'settings-btn-add-ha': openHAAccountModal,
'settings-btn-add-google': async () => {
const url = await api.get('/google/auth/url');
if (url && url.auth_url) window.open(url.auth_url, '_blank');
},
};
Object.entries(addBtnMap).forEach(([id, fn]) => {
const btn = document.getElementById(id);
if (btn) btn.addEventListener('click', () => { closeModal('modal-settings'); fn(); });
});
}
// ── Profile Modal ─────────────────────────────────────────

View File

@@ -87,6 +87,7 @@ const translations = {
settings_week_start: 'Erster Wochentag',
week_start_monday: 'Montag', week_start_sunday: 'Sonntag',
settings_dim_past: 'Vergangene Termine ausgrauen',
settings_default_duration: 'Standard-Termindauer',
settings_privacy: 'Privatsphäre',
settings_private_visibility: 'Private Termine für Gruppenmitglieder',
settings_private_visibility_desc: 'Wie private Termine für andere Gruppenmitglieder erscheinen',
@@ -104,6 +105,13 @@ const translations = {
reminder_days: '{n} Tage vorher',
reminder_week_one: '1 Woche vorher',
reminder_weeks: '{n} Wochen vorher',
reminder_custom: 'Benutzerdefiniert…',
reminder_unit_minutes: 'Minuten',
reminder_unit_hours: 'Stunden',
reminder_unit_days: 'Tage',
reminder_unit_weeks: 'Wochen',
reminder_before: 'vorher',
reminders_calendar_off: 'Für diesen Kalender sind Benachrichtigungen deaktiviert Erinnerungen werden nicht ausgeführt.',
calendar_reminders_on: 'Benachrichtigungen aktiviert',
calendar_reminders_off: 'Benachrichtigungen deaktiviert',
share: 'Teilen',
@@ -372,6 +380,7 @@ const translations = {
settings_week_start: 'First day of week',
week_start_monday: 'Monday', week_start_sunday: 'Sunday',
settings_dim_past: 'Dim past events',
settings_default_duration: 'Default event duration',
settings_privacy: 'Privacy',
settings_private_visibility: 'Private events for group members',
settings_private_visibility_desc: 'How your private events appear to other group members',
@@ -389,6 +398,13 @@ const translations = {
reminder_days: '{n} days before',
reminder_week_one: '1 week before',
reminder_weeks: '{n} weeks before',
reminder_custom: 'Custom…',
reminder_unit_minutes: 'minutes',
reminder_unit_hours: 'hours',
reminder_unit_days: 'days',
reminder_unit_weeks: 'weeks',
reminder_before: 'before',
reminders_calendar_off: 'Reminders are disabled for this calendar they will not fire.',
calendar_reminders_on: 'Reminders enabled',
calendar_reminders_off: 'Reminders disabled',
share: 'Share',

View File

@@ -142,6 +142,79 @@ function contrastRatio(c1, c2) {
} catch { return 21; }
}
// ── Safe HTML rendering for event descriptions ───────────────────────────
// External calendars (CalDAV, iCal) often store rich-text descriptions as raw
// HTML. We render a small allowlist of formatting tags so links/line breaks
// look right, but strip everything dangerous (scripts, event handlers, inline
// styles) — we never execute code from a description.
const ALLOWED_TAGS = new Set(['A', 'BR', 'P', 'DIV', 'B', 'STRONG', 'I', 'EM', 'U', 'UL', 'OL', 'LI', 'SPAN']);
const URL_RE = /(https?:\/\/[^\s<]+[^\s<.,:;!?)\]}'"])/g;
function makeSafeLink(href, text) {
const a = document.createElement('a');
a.href = href;
a.textContent = text;
a.target = '_blank';
a.rel = 'noopener noreferrer';
return a;
}
// Replace bare URLs inside a text node with clickable <a> elements.
function linkifyTextNode(node, out) {
const text = node.nodeValue;
let last = 0;
let m;
URL_RE.lastIndex = 0;
while ((m = URL_RE.exec(text)) !== null) {
if (m.index > last) out.appendChild(document.createTextNode(text.slice(last, m.index)));
out.appendChild(makeSafeLink(m[0], m[0]));
last = m.index + m[0].length;
}
if (last < text.length) out.appendChild(document.createTextNode(text.slice(last)));
}
// Recursively copy `src` into `dest`, keeping only allowlisted tags/attributes.
function sanitizeInto(src, dest) {
src.childNodes.forEach(node => {
if (node.nodeType === Node.TEXT_NODE) {
linkifyTextNode(node, dest);
return;
}
if (node.nodeType !== Node.ELEMENT_NODE) return;
const tag = node.tagName;
if (!ALLOWED_TAGS.has(tag)) {
// Drop the tag but keep its (sanitized) contents.
sanitizeInto(node, dest);
return;
}
let el;
if (tag === 'A') {
const href = node.getAttribute('href') || '';
if (/^(https?:|mailto:)/i.test(href)) {
el = makeSafeLink(href, '');
} else {
// Unsafe/relative href: render as plain text container.
el = document.createElement('span');
}
} else {
el = document.createElement(tag.toLowerCase());
}
sanitizeInto(node, el);
dest.appendChild(el);
});
}
// Returns a sanitized HTML string for an event description, safe for innerHTML.
export function renderDescriptionHtml(raw) {
if (!raw) return '';
const hasHtml = /<[a-z][\s\S]*>/i.test(raw);
const doc = new DOMParser().parseFromString(
hasHtml ? raw : raw.replace(/\n/g, '<br>'), 'text/html');
const out = document.createElement('div');
sanitizeInto(doc.body, out);
return out.innerHTML;
}
function hexToRgba(hex, alpha) {
const r = parseInt(hex.slice(1,3), 16);
const g = parseInt(hex.slice(3,5), 16);

View File

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

View File

@@ -51,7 +51,7 @@ export function renderAgenda(container, currentDate, events, onEventClick) {
</div>`;
}).join('');
return `<div class="agenda-day">
return `<div class="agenda-day" data-date="${key}">
<div class="agenda-date ${todayCls}">
<div class="agenda-date-num">${date.getDate()}</div>
<div class="agenda-date-label">
@@ -65,6 +65,19 @@ export function renderAgenda(container, currentDate, events, onEventClick) {
container.innerHTML = `<div class="agenda-view">${html}</div>`;
// The agenda lists the whole cached range (past + future). Scroll so the
// current date (e.g. "today" after the Today button) sits at the top — or
// the next day with events if the current date itself has none.
const scrollEl = container.querySelector('.agenda-view');
if (scrollEl) {
const curKey = `${currentDate.getFullYear()}-${String(currentDate.getMonth()+1).padStart(2,'0')}-${String(currentDate.getDate()).padStart(2,'0')}`;
const days = [...scrollEl.querySelectorAll('.agenda-day')];
const target = days.find(d => d.dataset.date >= curKey) || days[days.length - 1];
if (target) {
scrollEl.scrollTop += target.getBoundingClientRect().top - scrollEl.getBoundingClientRect().top;
}
}
container.querySelectorAll('.agenda-event').forEach(el => {
el.addEventListener('click', () => {
const ev = events.find(ev => ev.id === el.dataset.id && ev.url === el.dataset.url);

View File

@@ -71,13 +71,21 @@ export function renderMonth(container, currentDate, events, onDayClick, onEventC
return new Date(a.ev.start) - new Date(b.ev.start);
});
// Assign lanes (greedy interval packing)
const lanes = [];
// Assign lanes using a 2D occupancy grid (per-column tracking).
// This prevents gaps: an event in cols 3-6 no longer blocks col 0-2 in the same lane.
const occupiedGrid = []; // occupiedGrid[lane] = Set<col>
rowItems.forEach(item => {
let laneIdx = lanes.findIndex(l => item.colStart >= l.colEnd);
if (laneIdx === -1) { laneIdx = lanes.length; lanes.push({ colEnd: 0 }); }
const cols = Array.from({ length: item.span }, (_, i) => item.colStart + i);
let laneIdx = 0;
for (;;) {
if (!occupiedGrid[laneIdx]) occupiedGrid[laneIdx] = new Set();
if (cols.every(c => !occupiedGrid[laneIdx].has(c))) {
cols.forEach(c => occupiedGrid[laneIdx].add(c));
item.lane = laneIdx;
lanes[laneIdx].colEnd = item.colStart + item.span;
break;
}
laneIdx++;
}
});
// Track overflow per column
@@ -190,11 +198,20 @@ export function renderMonth(container, currentDate, events, onDayClick, onEventC
if (ev) onEventClick(ev, spanEl);
return;
}
// "+N more" → navigate to day view
// "+N more" → show overflow popup with all events for that day
const moreEl = e.target.closest('.month-more');
if (moreEl) {
e.stopPropagation();
onDayClick(new Date(moreEl.dataset.date + 'T00:00:00'), 'navigate');
const dayDate = new Date(moreEl.dataset.date + 'T00:00:00');
const dayEvents = normed
.filter(({ ns, ne }) => ns <= dayDate && ne >= dayDate)
.map(({ ev }) => ev)
.sort((a, b) => {
if (a.allDay && !b.allDay) return -1;
if (!a.allDay && b.allDay) return 1;
return new Date(a.start) - new Date(b.start);
});
showOverflowPopup(moreEl, dayDate, dayEvents, onEventClick);
return;
}
// Column click → select day
@@ -243,3 +260,91 @@ function escHtml(s) {
function escAttr(s) {
return String(s).replace(/"/g,'&quot;').replace(/'/g,'&#39;');
}
function showOverflowPopup(anchor, date, events, onEventClick) {
document.querySelectorAll('.month-overflow-popup').forEach(p => p.remove());
const popup = document.createElement('div');
popup.className = 'month-overflow-popup';
// Header: "Mo, 3. Jul"
const months = t('months');
const dow = t('dow_monday');
const dowIdx = (date.getDay() + 6) % 7; // 0=Mon
const header = document.createElement('div');
header.className = 'mop-header';
header.textContent = `${dow[dowIdx]}, ${date.getDate()}. ${months[date.getMonth()]}`;
popup.appendChild(header);
events.forEach(ev => {
const evStart = new Date(ev.start); evStart.setHours(0, 0, 0, 0);
const evEnd = new Date(ev.end); evEnd.setHours(0, 0, 0, 0);
// allDay end from API is exclusive → actual last day = evEnd - 1d
const lastDay = ev.allDay ? new Date(evEnd.getTime() - 86400000) : evEnd;
const continuesLeft = evStart < date;
const continuesRight = lastDay > date;
const color = ev.color || ev.calendarColor || '#4285f4';
if (ev.allDay) {
// All-day events → colored bar with continuation arrows
const bar = document.createElement('div');
bar.className = 'mop-bar'
+ (continuesLeft ? ' continues-left' : '')
+ (continuesRight ? ' continues-right' : '');
bar.style.background = color;
bar.textContent = ev.title;
bar.addEventListener('click', e => {
e.stopPropagation();
popup.remove();
onEventClick(ev, bar);
});
popup.appendChild(bar);
} else {
// Timed events → dot + time + title
const row = document.createElement('div');
row.className = 'mop-row';
const dot = document.createElement('span');
dot.className = 'mop-dot';
dot.style.background = color;
const time = document.createElement('span');
time.className = 'mop-time';
time.textContent = fmtTime(new Date(ev.start));
const title = document.createElement('span');
title.className = 'mop-title';
title.textContent = ev.title;
row.append(dot, time, title);
row.addEventListener('click', e => {
e.stopPropagation();
popup.remove();
onEventClick(ev, row);
});
popup.appendChild(row);
}
});
document.body.appendChild(popup);
// Position near anchor, stay in viewport
const r = anchor.getBoundingClientRect();
const pw = popup.offsetWidth || 260;
const ph = popup.offsetHeight || 200;
let left = r.left;
let top = r.bottom + 4;
if (left + pw > window.innerWidth - 8) left = window.innerWidth - pw - 8;
if (top + ph > window.innerHeight - 8) top = r.top - ph - 4;
if (left < 8) left = 8;
if (top < 8) top = 8;
popup.style.left = left + 'px';
popup.style.top = top + 'px';
setTimeout(() => {
document.addEventListener('click', function close() {
popup.remove();
document.removeEventListener('click', close);
});
}, 0);
}

View File

@@ -124,8 +124,12 @@ export function renderWeek(container, currentDate, events, onSlotClick, onEventC
const color = ev.color || ev.calendarColor || '#4285f4';
const pastCls = isPast(ev) ? 'past' : '';
const startStr = fmtTime(s);
const locHtml = ev.location ? `<div class="ev-loc">${escHtml(ev.location)}</div>` : '';
return `<div class="timed-event ${pastCls}"
// Short events lack the vertical room to stack time over title, so render
// them on one line (time next to title) and drop the location.
const isShort = height < 34;
const shortCls = isShort ? 'short' : '';
const locHtml = (!isShort && ev.location) ? `<div class="ev-loc">${escHtml(ev.location)}</div>` : '';
return `<div class="timed-event ${pastCls} ${shortCls}"
style="top:${top}px;height:${height}px;left:${left}%;width:${width}%;background:${color};color:#fff"
data-id="${ev.id}" data-url="${escAttr(ev.url)}" title="${escAttr(ev.title)}">
<div class="ev-time">${startStr}</div>