Compare commits
19 Commits
0c8fbb86e8
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f791160d1a | ||
|
|
906241a743 | ||
|
|
2e76e63ba2 | ||
|
|
6e8efb2ed5 | ||
|
|
d6159f0639 | ||
|
|
be3d4057b3 | ||
|
|
eb9440c2b2 | ||
|
|
34777c78c9 | ||
|
|
0427e1189d | ||
|
|
c1fdfe72d0 | ||
|
|
6365f99ac0 | ||
|
|
d04e2952cb | ||
|
|
c5fd5b4e55 | ||
|
|
d1c5b66230 | ||
|
|
2c59d873f3 | ||
|
|
c515e9d7e1 | ||
|
|
e6bc7eab9d | ||
|
|
70aaec3401 | ||
|
|
900abcdb7a |
@@ -210,6 +210,20 @@ def _migrate():
|
|||||||
logging.info("Migration: added reminders_enabled to %s", tbl)
|
logging.info("Migration: added reminders_enabled to %s", tbl)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
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()
|
_migrate()
|
||||||
|
|
||||||
|
|||||||
@@ -106,6 +106,10 @@ class UserSettings(Base):
|
|||||||
# Default reminder in minutes-before-start applied to all events client-side
|
# Default reminder in minutes-before-start applied to all events client-side
|
||||||
# (0 = at start time). NULL = no default reminder.
|
# (0 = at start time). NULL = no default reminder.
|
||||||
default_reminder_minutes = Column(Integer, nullable=True)
|
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")
|
user = relationship("User", back_populates="settings")
|
||||||
|
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ def _fetch_ics(url: str) -> str:
|
|||||||
try:
|
try:
|
||||||
resp = http_requests.get(url, timeout=30, allow_redirects=True)
|
resp = http_requests.get(url, timeout=30, allow_redirects=True)
|
||||||
resp.raise_for_status()
|
resp.raise_for_status()
|
||||||
|
resp.encoding = 'utf-8'
|
||||||
return resp.text
|
return resp.text
|
||||||
except http_requests.RequestException as e:
|
except http_requests.RequestException as e:
|
||||||
raise ValueError(f"Fehler beim Abrufen der URL: {e}")
|
raise ValueError(f"Fehler beim Abrufen der URL: {e}")
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ class SettingsUpdate(BaseModel):
|
|||||||
private_event_visibility: Optional[str] = None
|
private_event_visibility: Optional[str] = None
|
||||||
group_visible_calendar_id: Optional[int] = None
|
group_visible_calendar_id: Optional[int] = None
|
||||||
default_reminder_minutes: Optional[int] = None # null = off
|
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:
|
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",
|
"private_event_visibility": s.private_event_visibility or "busy",
|
||||||
"group_visible_calendar_id": s.group_visible_calendar_id,
|
"group_visible_calendar_id": s.group_visible_calendar_id,
|
||||||
"default_reminder_minutes": s.default_reminder_minutes,
|
"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
|
# For these three override colours, an explicit null is meaningful
|
||||||
# ("reset to default") and must be persisted as NULL. All other fields
|
# ("reset to default") and must be persisted as NULL. All other fields
|
||||||
# keep the previous behaviour where a null/missing value is ignored.
|
# 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)
|
update_data = data.model_dump(exclude_unset=True)
|
||||||
for field, value in update_data.items():
|
for field, value in update_data.items():
|
||||||
if field in NULLABLE_OVERRIDES:
|
if field in NULLABLE_OVERRIDES:
|
||||||
|
|||||||
@@ -355,9 +355,16 @@ a { color: var(--primary); text-decoration: none; }
|
|||||||
|
|
||||||
.ev-color-row { display: flex; align-items: center; gap: 8px; }
|
.ev-color-row { display: flex; align-items: center; gap: 8px; }
|
||||||
.ev-reminders-list { display: flex; flex-direction: column; gap: 6px; margin-bottom: 6px; }
|
.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 { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
||||||
.ev-reminder-row select { flex: 1; }
|
.ev-reminder-row > select { flex: 1; min-width: 120px; }
|
||||||
.ev-reminder-remove { flex-shrink: 0; }
|
.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 {
|
.ev-color-hex {
|
||||||
flex: 1; height: 36px; padding: 0 10px;
|
flex: 1; height: 36px; padding: 0 10px;
|
||||||
background: var(--bg-hover); border: 1px solid var(--border);
|
background: var(--bg-hover); border: 1px solid var(--border);
|
||||||
@@ -501,7 +508,9 @@ a { color: var(--primary); text-decoration: none; }
|
|||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
transition: transform var(--transition);
|
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.collapsed { transform: translateX(calc(-1 * var(--sidebar-w))); margin-right: calc(-1 * var(--sidebar-w)); }
|
||||||
.sidebar-inner { padding-bottom: 8px; }
|
.sidebar-inner { padding-bottom: 8px; }
|
||||||
.sidebar-copyright {
|
.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-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 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-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 {
|
.cal-rename-input {
|
||||||
font-size: 13px; flex: 1; color: var(--text-1);
|
font-size: 13px; flex: 1; color: var(--text-1);
|
||||||
background: var(--bg-app); border: 1px solid var(--primary);
|
background: var(--bg-app); border: 1px solid var(--primary);
|
||||||
@@ -591,15 +600,13 @@ a { color: var(--primary); text-decoration: none; }
|
|||||||
outline: none;
|
outline: none;
|
||||||
}
|
}
|
||||||
.cal-account-name { font-size: 11px; color: var(--text-3); padding: 4px 16px 2px; font-weight: 500; }
|
.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
|
/* Fixed min-height so 28px mini-btns never change row height when they appear. */
|
||||||
width and only truncates while the button is visible. */
|
.cal-item { position: relative; min-height: 40px; }
|
||||||
.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. */
|
|
||||||
.cal-item-bell { display: none; flex-shrink: 0; }
|
.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: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 ─────────────────────────────────────────── */
|
||||||
.month-view { display: flex; flex-direction: column; flex: 1; min-height: 0; }
|
.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); }
|
.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 / Day Views ───────────────────────────────────── */
|
||||||
.week-view, .day-view { display: flex; flex-direction: column; flex: 1; min-height: 0; overflow-y: scroll; }
|
.week-view, .day-view { display: flex; flex-direction: column; flex: 1; min-height: 0; overflow-y: scroll; }
|
||||||
.week-head-sticky {
|
.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-time { font-size: 10px; opacity: .85; }
|
||||||
.timed-event .ev-title { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
.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; }
|
.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 specifics */
|
||||||
.day-view .week-day-col { flex: 1; }
|
.day-view .week-day-col { flex: 1; }
|
||||||
@@ -990,7 +1060,9 @@ a { color: var(--primary); text-decoration: none; }
|
|||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: 20px;
|
border-radius: 20px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-height: 90vh; overflow-y: auto;
|
max-height: 90vh;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex; flex-direction: column;
|
||||||
box-shadow: var(--shadow-lg);
|
box-shadow: var(--shadow-lg);
|
||||||
}
|
}
|
||||||
.modal-header {
|
.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-header h3 { font-size: 18px; font-weight: 500; flex: 1; }
|
||||||
.modal-close { font-size: 24px; }
|
.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 { margin: 0 0 14px; font-size: 14px; color: var(--text-1); }
|
||||||
.modal-body p:last-child { margin-bottom: 0; }
|
.modal-body p:last-child { margin-bottom: 0; }
|
||||||
.modal-body a { color: var(--primary); text-decoration: none; }
|
.modal-body a { color: var(--primary); text-decoration: none; }
|
||||||
@@ -1071,6 +1144,7 @@ a { color: var(--primary); text-decoration: none; }
|
|||||||
background: linear-gradient(180deg,
|
background: linear-gradient(180deg,
|
||||||
color-mix(in srgb, var(--ev-color, var(--primary)) 13%, transparent), transparent);
|
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. */
|
/* Slim accent strip in the event's colour. */
|
||||||
.popup-header::before {
|
.popup-header::before {
|
||||||
content: ""; position: absolute; left: 0; top: 0; bottom: 0; width: 4px;
|
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-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 { 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-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-time { color: var(--text-1); font-weight: 500; }
|
||||||
.popup-row-desc { color: var(--text-1); }
|
.popup-row-desc { color: var(--text-1); }
|
||||||
.popup-row-desc span { white-space: pre-wrap; }
|
.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-creator { font-style: italic; }
|
||||||
|
|
||||||
.popup-copy-menu {
|
.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;
|
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 View (spanning bars) ─────────────────────────── */
|
||||||
.month-body {
|
.month-body {
|
||||||
display: flex; flex-direction: column; flex: 1; overflow: hidden;
|
display: flex; flex-direction: column; flex: 1; overflow: hidden;
|
||||||
|
|||||||
@@ -80,7 +80,7 @@
|
|||||||
<button type="submit" class="btn btn-primary btn-full">Anmelden</button>
|
<button type="submit" class="btn btn-primary btn-full">Anmelden</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
<button class="impressum-link" onclick="openImpressum()">© 2026 Scarriffleservices · v18</button>
|
<button class="impressum-link" onclick="openImpressum()">© 2026 Scarriffleservices</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ─── MAIN APP ──────────────────────────────────────────── -->
|
<!-- ─── MAIN APP ──────────────────────────────────────────── -->
|
||||||
@@ -375,6 +375,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="form-group" id="ev-reminders-group" style="display:none">
|
<div class="form-group" id="ev-reminders-group" style="display:none">
|
||||||
<label data-i18n="reminders">Benachrichtigungen</label>
|
<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>
|
<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>
|
<button type="button" class="btn btn-ghost btn-sm" id="ev-reminder-add" data-i18n="reminder_add">Benachrichtigung hinzufügen</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -691,7 +692,6 @@
|
|||||||
<nav class="settings-nav">
|
<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 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="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" 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>
|
<button class="settings-nav-btn hidden" data-panel="users" id="settings-nav-users" data-i18n="settings_nav_users">Benutzerverwaltung</button>
|
||||||
</nav>
|
</nav>
|
||||||
@@ -726,6 +726,16 @@
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</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>
|
<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>
|
<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">
|
<div class="form-group">
|
||||||
@@ -734,10 +744,35 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Darstellung: Sprache, Farben, Stundenhöhe -->
|
<!-- Darstellung: Kalenderansicht, Sprache, Stundenhöhe, Farben -->
|
||||||
<div class="settings-panel" id="settings-panel-general">
|
<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">
|
<div class="form-group">
|
||||||
<select id="cfg-language">
|
<select id="cfg-language">
|
||||||
<option value="de">Deutsch</option>
|
<option value="de">Deutsch</option>
|
||||||
@@ -745,6 +780,19 @@
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<h4 class="panel-title" style="margin-top:24px" data-i18n="settings_hour_height">Stundenhöhe (Wochen- & 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>
|
<h4 class="panel-title" style="margin-top:24px" data-i18n="settings_colors">Farben</h4>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label data-i18n="settings_primary_color">Primärfarbe</label>
|
<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>
|
<button type="button" class="btn btn-ghost btn-sm" id="cfg-bg-color-reset" data-i18n="reset" title="Zurücksetzen">Reset</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h4 class="panel-title" style="margin-top:24px" data-i18n="settings_hour_height">Stundenhöhe (Wochen- & 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>
|
</div>
|
||||||
|
|
||||||
<!-- Ansicht: Standardansicht, Wochenstart, vergangene Termine, ausgeblendete Kalender -->
|
<!-- Kalender: einheitliche Tabelle aller Kalender aus allen Quellen -->
|
||||||
<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) -->
|
|
||||||
<div class="settings-panel" id="settings-panel-accounts">
|
<div class="settings-panel" id="settings-panel-accounts">
|
||||||
<h4 class="panel-title" data-i18n="settings_nav_calendars">Kalender</h4>
|
<h4 class="panel-title" data-i18n="settings_nav_calendars">Kalender</h4>
|
||||||
|
<div class="accounts-add-row">
|
||||||
<div class="accounts-section">
|
<button class="btn btn-secondary btn-sm" id="settings-btn-add-local">+ Lokal</button>
|
||||||
<div class="accounts-section-heading" data-i18n="settings_accounts_caldav">CalDAV-Konten</div>
|
<button class="btn btn-secondary btn-sm" id="settings-btn-add-caldav">+ CalDAV</button>
|
||||||
<div id="accounts-caldav-list"><span class="accounts-section-empty" data-i18n="settings_no_caldav_accounts">Keine CalDAV-Konten</span></div>
|
<button class="btn btn-secondary btn-sm" id="settings-btn-add-ical">+ iCal</button>
|
||||||
</div>
|
<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 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>
|
</div>
|
||||||
|
<div id="cal-settings-table" style="margin-top:16px;overflow-x:auto"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Benutzerverwaltung -->
|
<!-- Benutzerverwaltung -->
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { api } from './api.js';
|
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 { renderMonth } from './views/month.js';
|
||||||
import { renderWeek } from './views/week.js';
|
import { renderWeek } from './views/week.js';
|
||||||
import { renderAgenda } from './views/agenda.js';
|
import { renderAgenda } from './views/agenda.js';
|
||||||
@@ -9,8 +9,8 @@ import { openDatePicker, formatDtDisplay } from './date-picker.js';
|
|||||||
import { t, setLang, getLang } from './i18n.js';
|
import { t, setLang, getLang } from './i18n.js';
|
||||||
import { APP_VERSION } from './version.js';
|
import { APP_VERSION } from './version.js';
|
||||||
|
|
||||||
// Version sofort beim Modul-Load ueberall sichtbar setzen.
|
// Version im Impressum/Sidebar sichtbar, nicht im Tab-Titel.
|
||||||
document.title = `Calendarr ${APP_VERSION}`;
|
document.title = 'Calendarr';
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
const imp = document.getElementById('impressum-version');
|
const imp = document.getElementById('impressum-version');
|
||||||
if (imp) imp.textContent = `Calendarr ${APP_VERSION}`;
|
if (imp) imp.textContent = `Calendarr ${APP_VERSION}`;
|
||||||
@@ -76,6 +76,8 @@ function readUrlState() {
|
|||||||
if (!isNaN(d.getTime())) out.date = d;
|
if (!isNaN(d.getTime())) out.date = d;
|
||||||
}
|
}
|
||||||
out.settings = params.get('settings') === '1';
|
out.settings = params.get('settings') === '1';
|
||||||
|
const stab = params.get('stab');
|
||||||
|
if (stab && ['profile','general','accounts','users'].includes(stab)) out.stab = stab;
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,7 +89,11 @@ function writeUrlState() {
|
|||||||
const d = state.currentDate;
|
const d = state.currentDate;
|
||||||
const dateStr = `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`;
|
const dateStr = `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`;
|
||||||
let newHash = `date=${dateStr}&view=${state.currentView}`;
|
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) {
|
if (window.location.hash.replace(/^#/,'') !== newHash) {
|
||||||
// replaceState statt pushState: prev/next-Klicks sollen nicht jeden
|
// replaceState statt pushState: prev/next-Klicks sollen nicht jeden
|
||||||
// einzelnen Tag in den Browser-History-Stack drücken
|
// einzelnen Tag in den Browser-History-Stack drücken
|
||||||
@@ -543,7 +549,7 @@ function updateTitle() {
|
|||||||
titleEl.innerHTML =
|
titleEl.innerHTML =
|
||||||
`<span class="view-title-main">${main}</span>` +
|
`<span class="view-title-main">${main}</span>` +
|
||||||
(year ? `<span class="view-title-year">${year}</span>` : '');
|
(year ? `<span class="view-title-year">${year}</span>` : '');
|
||||||
document.title = `Calendarr ${APP_VERSION} - ${fullText}`;
|
document.title = `Calendarr – ${fullText}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateViewButtons() {
|
function updateViewButtons() {
|
||||||
@@ -570,11 +576,22 @@ function renderMiniCal() {
|
|||||||
const DOW_LABELS = weekStartDay === 'sunday' ? DOW_SUNDAY : DOW_MONDAY;
|
const DOW_LABELS = weekStartDay === 'sunday' ? DOW_SUNDAY : DOW_MONDAY;
|
||||||
miniDowEls.forEach((el, i) => { el.textContent = DOW_LABELS[i]; });
|
miniDowEls.forEach((el, i) => { el.textContent = DOW_LABELS[i]; });
|
||||||
|
|
||||||
// Build event date set
|
// Build event date set — mark every day an event spans, not just its start
|
||||||
const eventDates = new Set(state.events.map(ev => {
|
// 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);
|
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 days = [];
|
||||||
const cur = new Date(gridStart);
|
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 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 = `<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 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
|
// 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
|
// 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}"`,
|
entries.push({ key: `local:${cal.id}`, source: 'local', dataId: `data-cal-id="${cal.id}"`,
|
||||||
name: cal.name, color: cal.color, enabled: cal.enabled,
|
name: cal.name, color: cal.color, enabled: cal.enabled,
|
||||||
reminders: cal.owned !== false, remindersEnabled: cal.reminders_enabled !== false,
|
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 => {
|
state.icalSubscriptions.forEach(sub => {
|
||||||
entries.push({ key: `ical:${sub.id}`, source: 'ical', dataId: `data-sub-id="${sub.id}"`,
|
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>
|
<span class="cal-drag-handle" title="${t('drag_reorder')}">⠿</span>
|
||||||
<input type="checkbox" ${e.enabled ? 'checked' : ''} data-source="${e.source}" ${e.dataId} />
|
<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>
|
<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>
|
<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.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>` : ''}
|
${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>`
|
</div>`
|
||||||
@@ -959,6 +978,20 @@ function renderCalendarList() {
|
|||||||
await api.put(`/ical/subscriptions/${subId}`, { name: newName });
|
await api.put(`/ical/subscriptions/${subId}`, { name: newName });
|
||||||
const sub = state.icalSubscriptions.find(s => s.id === subId);
|
const sub = state.icalSubscriptions.find(s => s.id === subId);
|
||||||
if (sub) sub.name = newName;
|
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();
|
renderCalendarList();
|
||||||
@@ -1401,7 +1434,7 @@ function showEventPopup(ev, anchor) {
|
|||||||
|
|
||||||
document.getElementById('popup-location').textContent = ev.location || '';
|
document.getElementById('popup-location').textContent = ev.location || '';
|
||||||
document.getElementById('popup-row-location').style.display = ev.location ? '' : 'none';
|
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-row-description').style.display = ev.description ? '' : 'none';
|
||||||
document.getElementById('popup-calendar').textContent = ev.calendar_name || '';
|
document.getElementById('popup-calendar').textContent = ev.calendar_name || '';
|
||||||
document.getElementById('popup-row-calendar').style.display = ev.calendar_name ? '' : 'none';
|
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.left = Math.max(8, left) + 'px';
|
||||||
popup.style.top = Math.max(8, top) + '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
|
// Hide edit/delete for read-only iCal subscription events
|
||||||
const isReadOnly = (ev.source === 'ical');
|
const isReadOnly = (ev.source === 'ical');
|
||||||
document.getElementById('popup-edit').style.display = isReadOnly ? 'none' : '';
|
document.getElementById('popup-edit').style.display = isReadOnly ? 'none' : '';
|
||||||
@@ -1700,8 +1763,8 @@ function openNewEventModal(date) {
|
|||||||
document.getElementById('ev-allday').checked = false;
|
document.getElementById('ev-allday').checked = false;
|
||||||
|
|
||||||
const start = new Date(date);
|
const start = new Date(date);
|
||||||
const end = new Date(date);
|
const durMin = (state.settings && state.settings.default_event_duration_minutes) || 60;
|
||||||
end.setHours(end.getHours() + 1);
|
const end = new Date(start.getTime() + durMin * 60000);
|
||||||
setDtValue('ev-start', toLocalDatetimeInput(start), 'datetime');
|
setDtValue('ev-start', toLocalDatetimeInput(start), 'datetime');
|
||||||
setDtValue('ev-end', toLocalDatetimeInput(end), 'datetime');
|
setDtValue('ev-end', toLocalDatetimeInput(end), 'datetime');
|
||||||
setDtValue('ev-start-date', toDateInput(start), 'date');
|
setDtValue('ev-start-date', toDateInput(start), 'date');
|
||||||
@@ -1835,7 +1898,15 @@ function resetColorPicker(color) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Reminders (local events only) ─────────────────────────
|
// ── 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) {
|
function reminderLabel(min) {
|
||||||
if (min <= 0) return t('reminder_at_start');
|
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 });
|
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) {
|
function setEventReminders(arr) {
|
||||||
state.eventReminders = Array.isArray(arr)
|
state.eventReminders = Array.isArray(arr)
|
||||||
? arr.map(Number).filter(n => !isNaN(n)) : [];
|
? arr.map(Number).filter(n => !isNaN(n)) : [];
|
||||||
@@ -1858,19 +1938,63 @@ function renderReminderRows() {
|
|||||||
state.eventReminders.forEach((min, idx) => {
|
state.eventReminders.forEach((min, idx) => {
|
||||||
const row = document.createElement('div');
|
const row = document.createElement('div');
|
||||||
row.className = 'ev-reminder-row';
|
row.className = 'ev-reminder-row';
|
||||||
|
|
||||||
|
const isPreset = REMINDER_PRESETS.includes(min);
|
||||||
const sel = document.createElement('select');
|
const sel = document.createElement('select');
|
||||||
// Keep a non-catalog value (from an old/imported reminder) selectable.
|
REMINDER_PRESETS.forEach(v => {
|
||||||
const opts = REMINDER_OPTIONS.includes(min) ? REMINDER_OPTIONS : [min, ...REMINDER_OPTIONS];
|
|
||||||
opts.forEach(v => {
|
|
||||||
const o = document.createElement('option');
|
const o = document.createElement('option');
|
||||||
o.value = String(v);
|
o.value = String(v);
|
||||||
o.textContent = reminderLabel(v);
|
o.textContent = reminderLabel(v);
|
||||||
if (v === min) o.selected = true;
|
if (isPreset && v === min) o.selected = true;
|
||||||
sel.appendChild(o);
|
sel.appendChild(o);
|
||||||
});
|
});
|
||||||
sel.addEventListener('change', () => {
|
const customOpt = document.createElement('option');
|
||||||
state.eventReminders[idx] = parseInt(sel.value, 10);
|
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');
|
const rm = document.createElement('button');
|
||||||
rm.type = 'button';
|
rm.type = 'button';
|
||||||
rm.className = 'icon-btn ev-reminder-remove';
|
rm.className = 'icon-btn ev-reminder-remove';
|
||||||
@@ -1880,6 +2004,7 @@ function renderReminderRows() {
|
|||||||
renderReminderRows();
|
renderReminderRows();
|
||||||
});
|
});
|
||||||
row.appendChild(sel);
|
row.appendChild(sel);
|
||||||
|
row.appendChild(customWrap);
|
||||||
row.appendChild(rm);
|
row.appendChild(rm);
|
||||||
list.appendChild(row);
|
list.appendChild(row);
|
||||||
});
|
});
|
||||||
@@ -1887,16 +2012,30 @@ function renderReminderRows() {
|
|||||||
|
|
||||||
function addReminderRow() {
|
function addReminderRow() {
|
||||||
const def = (state.settings && state.settings.default_reminder_minutes != null)
|
const def = (state.settings && state.settings.default_reminder_minutes != null)
|
||||||
? state.settings.default_reminder_minutes : 10;
|
? state.settings.default_reminder_minutes : 30;
|
||||||
state.eventReminders.push(REMINDER_OPTIONS.includes(def) ? def : 10);
|
state.eventReminders.push(def >= 0 ? def : 30);
|
||||||
renderReminderRows();
|
renderReminderRows();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reminders apply to local events only (the server stores them on local events).
|
// 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() {
|
function updateRemindersRow() {
|
||||||
const calVal = document.getElementById('ev-calendar').value || '';
|
const calVal = document.getElementById('ev-calendar').value || '';
|
||||||
const isLocal = calVal.startsWith('local-');
|
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() {
|
function buildRruleFromUI() {
|
||||||
@@ -2773,6 +2912,23 @@ function groupIconHtml(icon, size = 18) {
|
|||||||
if (icon) return escHtml(icon);
|
if (icon) return escHtml(icon);
|
||||||
return groupIconSvg('people', size);
|
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() {
|
function renderGroupIconPicker() {
|
||||||
const modal = document.getElementById('modal-group');
|
const modal = document.getElementById('modal-group');
|
||||||
const sel = modal.__icon || 'people';
|
const sel = modal.__icon || 'people';
|
||||||
@@ -2932,6 +3088,21 @@ function openSettingsModal() {
|
|||||||
document.getElementById('cfg-private-visibility').value = s.private_event_visibility || 'busy';
|
document.getElementById('cfg-private-visibility').value = s.private_event_visibility || 'busy';
|
||||||
renderGroupVisibleList(s.group_visible_calendar_id);
|
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).
|
// Profile chapter: name (from cached user) + email (fresh from /profile).
|
||||||
const pu = JSON.parse(localStorage.getItem('user') || '{}');
|
const pu = JSON.parse(localStorage.getItem('user') || '{}');
|
||||||
document.getElementById('cfg-display-name').value = pu.display_name || pu.username || '';
|
document.getElementById('cfg-display-name').value = pu.display_name || pu.username || '';
|
||||||
@@ -2940,11 +3111,12 @@ function openSettingsModal() {
|
|||||||
document.getElementById('cfg-email').value = p.email || '';
|
document.getElementById('cfg-email').value = p.email || '';
|
||||||
}).catch(() => {});
|
}).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-text-contrast', val: s.text_contrast || 3 },
|
||||||
{ id: 'cfg-line-contrast', val: s.line_contrast || 3 },
|
{ id: 'cfg-line-contrast', val: s.line_contrast || 3 },
|
||||||
{ id: 'cfg-hour-height', val: s.hour_height || 60 },
|
{ id: 'cfg-hour-height', val: s.hour_height || 60 },
|
||||||
|
{ id: 'cfg-event-duration', val: s.default_event_duration_minutes || 60 },
|
||||||
].forEach(({ id, val }) => {
|
].forEach(({ id, val }) => {
|
||||||
const sel = document.getElementById(id);
|
const sel = document.getElementById(id);
|
||||||
if (!sel) return;
|
if (!sel) return;
|
||||||
@@ -2959,13 +3131,14 @@ function openSettingsModal() {
|
|||||||
if (usersNavBtn) usersNavBtn.classList.toggle('hidden', !user.is_admin);
|
if (usersNavBtn) usersNavBtn.classList.toggle('hidden', !user.is_admin);
|
||||||
if (user.is_admin) loadUsers();
|
if (user.is_admin) loadUsers();
|
||||||
|
|
||||||
// Activate first panel
|
// Activate panel from URL or fall back to first visible
|
||||||
const firstBtn = document.querySelector('.settings-nav-btn:not(.hidden)');
|
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);
|
if (firstBtn) activateSettingsPanel(firstBtn.dataset.panel);
|
||||||
|
|
||||||
// Render all accounts and hidden calendars
|
// Render unified calendar table
|
||||||
renderAllAccounts();
|
renderAllAccounts();
|
||||||
renderHiddenCalendars();
|
|
||||||
|
|
||||||
openModal('modal-settings');
|
openModal('modal-settings');
|
||||||
}
|
}
|
||||||
@@ -2973,6 +3146,7 @@ function openSettingsModal() {
|
|||||||
function activateSettingsPanel(panel) {
|
function activateSettingsPanel(panel) {
|
||||||
document.querySelectorAll('.settings-nav-btn').forEach(b => b.classList.toggle('active', b.dataset.panel === 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));
|
document.querySelectorAll('.settings-panel').forEach(p => p.classList.toggle('active', p.id === 'settings-panel-' + panel));
|
||||||
|
writeUrlState();
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderGoogleAccounts() {
|
function renderGoogleAccounts() {
|
||||||
@@ -3138,9 +3312,6 @@ function renderAllAccounts() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Google accounts section — delegate to existing function
|
|
||||||
renderGoogleAccounts();
|
|
||||||
|
|
||||||
// Home Assistant accounts section
|
// Home Assistant accounts section
|
||||||
const haList = document.getElementById('accounts-ha-list');
|
const haList = document.getElementById('accounts-ha-list');
|
||||||
if (haList) {
|
if (haList) {
|
||||||
@@ -3185,10 +3356,13 @@ function renderAllAccounts() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
renderCalendarTable();
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderHiddenCalendars() {
|
function renderHiddenCalendars() {
|
||||||
const list = document.getElementById('hidden-cals-list');
|
const list = document.getElementById('hidden-cals-list');
|
||||||
|
if (!list) return;
|
||||||
const hidden = [];
|
const hidden = [];
|
||||||
for (const acc of state.accounts) {
|
for (const acc of state.accounts) {
|
||||||
for (const cal of acc.calendars) {
|
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() {
|
async function loadUsers() {
|
||||||
try {
|
try {
|
||||||
const users = await api.get('/users/');
|
const users = await api.get('/users/');
|
||||||
@@ -3438,12 +3866,15 @@ function bindSettingsModal() {
|
|||||||
line_color: colourOrNull('cfg-line-color-hex'),
|
line_color: colourOrNull('cfg-line-color-hex'),
|
||||||
bg_color: colourOrNull('cfg-bg-color-hex'),
|
bg_color: colourOrNull('cfg-bg-color-hex'),
|
||||||
dim_past_events: document.getElementById('cfg-dim-past').checked,
|
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,
|
hour_height: getActive('cfg-hour-height') || 44,
|
||||||
language: document.getElementById('cfg-language').value,
|
language: document.getElementById('cfg-language').value,
|
||||||
private_event_visibility: document.getElementById('cfg-private-visibility').value,
|
private_event_visibility: document.getElementById('cfg-private-visibility').value,
|
||||||
};
|
};
|
||||||
const gvVal = document.getElementById('cfg-group-visible-list')?.dataset.selected;
|
const gvVal = document.getElementById('cfg-group-visible-list')?.dataset.selected;
|
||||||
settings.group_visible_calendar_id = gvVal ? parseInt(gvVal) : null;
|
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 {
|
try {
|
||||||
await api.put('/settings/', settings);
|
await api.put('/settings/', settings);
|
||||||
state.settings = { ...state.settings, ...settings };
|
state.settings = { ...state.settings, ...settings };
|
||||||
@@ -3453,10 +3884,27 @@ function bindSettingsModal() {
|
|||||||
applyTheme(state.settings);
|
applyTheme(state.settings);
|
||||||
showToast(t('settings_saved'));
|
showToast(t('settings_saved'));
|
||||||
closeModal('modal-settings');
|
closeModal('modal-settings');
|
||||||
|
renderCalendarList();
|
||||||
renderMiniCal();
|
renderMiniCal();
|
||||||
fetchAndRender();
|
fetchAndRender();
|
||||||
} catch (e) { showToast(e.message, true); }
|
} 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 ─────────────────────────────────────────
|
// ── Profile Modal ─────────────────────────────────────────
|
||||||
|
|||||||
@@ -87,6 +87,7 @@ const translations = {
|
|||||||
settings_week_start: 'Erster Wochentag',
|
settings_week_start: 'Erster Wochentag',
|
||||||
week_start_monday: 'Montag', week_start_sunday: 'Sonntag',
|
week_start_monday: 'Montag', week_start_sunday: 'Sonntag',
|
||||||
settings_dim_past: 'Vergangene Termine ausgrauen',
|
settings_dim_past: 'Vergangene Termine ausgrauen',
|
||||||
|
settings_default_duration: 'Standard-Termindauer',
|
||||||
settings_privacy: 'Privatsphäre',
|
settings_privacy: 'Privatsphäre',
|
||||||
settings_private_visibility: 'Private Termine für Gruppenmitglieder',
|
settings_private_visibility: 'Private Termine für Gruppenmitglieder',
|
||||||
settings_private_visibility_desc: 'Wie private Termine für andere Gruppenmitglieder erscheinen',
|
settings_private_visibility_desc: 'Wie private Termine für andere Gruppenmitglieder erscheinen',
|
||||||
@@ -104,6 +105,13 @@ const translations = {
|
|||||||
reminder_days: '{n} Tage vorher',
|
reminder_days: '{n} Tage vorher',
|
||||||
reminder_week_one: '1 Woche vorher',
|
reminder_week_one: '1 Woche vorher',
|
||||||
reminder_weeks: '{n} Wochen 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_on: 'Benachrichtigungen aktiviert',
|
||||||
calendar_reminders_off: 'Benachrichtigungen deaktiviert',
|
calendar_reminders_off: 'Benachrichtigungen deaktiviert',
|
||||||
share: 'Teilen',
|
share: 'Teilen',
|
||||||
@@ -372,6 +380,7 @@ const translations = {
|
|||||||
settings_week_start: 'First day of week',
|
settings_week_start: 'First day of week',
|
||||||
week_start_monday: 'Monday', week_start_sunday: 'Sunday',
|
week_start_monday: 'Monday', week_start_sunday: 'Sunday',
|
||||||
settings_dim_past: 'Dim past events',
|
settings_dim_past: 'Dim past events',
|
||||||
|
settings_default_duration: 'Default event duration',
|
||||||
settings_privacy: 'Privacy',
|
settings_privacy: 'Privacy',
|
||||||
settings_private_visibility: 'Private events for group members',
|
settings_private_visibility: 'Private events for group members',
|
||||||
settings_private_visibility_desc: 'How your private events appear to other 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_days: '{n} days before',
|
||||||
reminder_week_one: '1 week before',
|
reminder_week_one: '1 week before',
|
||||||
reminder_weeks: '{n} weeks 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_on: 'Reminders enabled',
|
||||||
calendar_reminders_off: 'Reminders disabled',
|
calendar_reminders_off: 'Reminders disabled',
|
||||||
share: 'Share',
|
share: 'Share',
|
||||||
|
|||||||
@@ -142,6 +142,79 @@ function contrastRatio(c1, c2) {
|
|||||||
} catch { return 21; }
|
} 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) {
|
function hexToRgba(hex, alpha) {
|
||||||
const r = parseInt(hex.slice(1,3), 16);
|
const r = parseInt(hex.slice(1,3), 16);
|
||||||
const g = parseInt(hex.slice(3,5), 16);
|
const g = parseInt(hex.slice(3,5), 16);
|
||||||
|
|||||||
@@ -1,2 +1,2 @@
|
|||||||
// Increment APP_VERSION with every code change
|
// Increment APP_VERSION with every code change
|
||||||
export const APP_VERSION = 'v48';
|
export const APP_VERSION = 'v62';
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ export function renderAgenda(container, currentDate, events, onEventClick) {
|
|||||||
</div>`;
|
</div>`;
|
||||||
}).join('');
|
}).join('');
|
||||||
|
|
||||||
return `<div class="agenda-day">
|
return `<div class="agenda-day" data-date="${key}">
|
||||||
<div class="agenda-date ${todayCls}">
|
<div class="agenda-date ${todayCls}">
|
||||||
<div class="agenda-date-num">${date.getDate()}</div>
|
<div class="agenda-date-num">${date.getDate()}</div>
|
||||||
<div class="agenda-date-label">
|
<div class="agenda-date-label">
|
||||||
@@ -65,6 +65,19 @@ export function renderAgenda(container, currentDate, events, onEventClick) {
|
|||||||
|
|
||||||
container.innerHTML = `<div class="agenda-view">${html}</div>`;
|
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 => {
|
container.querySelectorAll('.agenda-event').forEach(el => {
|
||||||
el.addEventListener('click', () => {
|
el.addEventListener('click', () => {
|
||||||
const ev = events.find(ev => ev.id === el.dataset.id && ev.url === el.dataset.url);
|
const ev = events.find(ev => ev.id === el.dataset.id && ev.url === el.dataset.url);
|
||||||
|
|||||||
@@ -71,13 +71,21 @@ export function renderMonth(container, currentDate, events, onDayClick, onEventC
|
|||||||
return new Date(a.ev.start) - new Date(b.ev.start);
|
return new Date(a.ev.start) - new Date(b.ev.start);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Assign lanes (greedy interval packing)
|
// Assign lanes using a 2D occupancy grid (per-column tracking).
|
||||||
const lanes = [];
|
// 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 => {
|
rowItems.forEach(item => {
|
||||||
let laneIdx = lanes.findIndex(l => item.colStart >= l.colEnd);
|
const cols = Array.from({ length: item.span }, (_, i) => item.colStart + i);
|
||||||
if (laneIdx === -1) { laneIdx = lanes.length; lanes.push({ colEnd: 0 }); }
|
let laneIdx = 0;
|
||||||
item.lane = laneIdx;
|
for (;;) {
|
||||||
lanes[laneIdx].colEnd = item.colStart + item.span;
|
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;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
laneIdx++;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Track overflow per column
|
// Track overflow per column
|
||||||
@@ -190,11 +198,20 @@ export function renderMonth(container, currentDate, events, onDayClick, onEventC
|
|||||||
if (ev) onEventClick(ev, spanEl);
|
if (ev) onEventClick(ev, spanEl);
|
||||||
return;
|
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');
|
const moreEl = e.target.closest('.month-more');
|
||||||
if (moreEl) {
|
if (moreEl) {
|
||||||
e.stopPropagation();
|
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;
|
return;
|
||||||
}
|
}
|
||||||
// Column click → select day
|
// Column click → select day
|
||||||
@@ -243,3 +260,91 @@ function escHtml(s) {
|
|||||||
function escAttr(s) {
|
function escAttr(s) {
|
||||||
return String(s).replace(/"/g,'"').replace(/'/g,''');
|
return String(s).replace(/"/g,'"').replace(/'/g,''');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -124,8 +124,12 @@ export function renderWeek(container, currentDate, events, onSlotClick, onEventC
|
|||||||
const color = ev.color || ev.calendarColor || '#4285f4';
|
const color = ev.color || ev.calendarColor || '#4285f4';
|
||||||
const pastCls = isPast(ev) ? 'past' : '';
|
const pastCls = isPast(ev) ? 'past' : '';
|
||||||
const startStr = fmtTime(s);
|
const startStr = fmtTime(s);
|
||||||
const locHtml = ev.location ? `<div class="ev-loc">${escHtml(ev.location)}</div>` : '';
|
// Short events lack the vertical room to stack time over title, so render
|
||||||
return `<div class="timed-event ${pastCls}"
|
// 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"
|
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)}">
|
data-id="${ev.id}" data-url="${escAttr(ev.url)}" title="${escAttr(ev.title)}">
|
||||||
<div class="ev-time">${startStr}</div>
|
<div class="ev-time">${startStr}</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user